1pub mod export;
6pub mod extensions;
7pub mod packages;
8pub mod session;
9pub mod settings;
10pub mod skills;
11pub mod templates;
12pub mod tui_interactive;
13pub mod tui_components;
14
15use anyhow::{Error, Result};
16use oxi_agent::{Agent, AgentConfig, AgentEvent};
17use oxi_ai::{get_model, get_provider};
18use parking_lot::RwLock;
19use settings::{Settings, ThinkingLevel};
20use skills::SkillManager;
21use std::sync::Arc;
22use tokio::sync::mpsc;
23use uuid::Uuid;
24
25pub struct App {
27 agent: Arc<Agent>,
28 settings: Settings,
29 skills: RwLock<SkillManager>,
30 active_skills: RwLock<Vec<String>>,
31}
32
33#[derive(Debug, Clone)]
35pub struct ChatMessage {
36 pub role: String,
37 pub content: String,
38 pub timestamp: chrono::DateTime<chrono::Utc>,
39}
40
41impl ChatMessage {
42 pub fn user(content: String) -> Self {
43 Self {
44 role: "user".to_string(),
45 content,
46 timestamp: chrono::Utc::now(),
47 }
48 }
49
50 pub fn assistant(content: String) -> Self {
51 Self {
52 role: "assistant".to_string(),
53 content,
54 timestamp: chrono::Utc::now(),
55 }
56 }
57}
58
59pub struct InteractiveSession {
61 pub messages: Vec<ChatMessage>,
62 pub thinking: bool,
63 pub current_response: String,
64 pub session_id: Option<Uuid>,
65 pub entries: Vec<session::SessionEntry>,
66}
67
68impl Default for InteractiveSession {
69 fn default() -> Self {
70 Self {
71 messages: Vec::new(),
72 thinking: false,
73 current_response: String::new(),
74 session_id: None,
75 entries: Vec::new(),
76 }
77 }
78}
79
80impl InteractiveSession {
81 pub fn new() -> Self {
82 Self::default()
83 }
84
85 pub fn add_user_message(&mut self, content: String) {
86 self.messages.push(ChatMessage::user(content.clone()));
87 let entry = session::SessionEntry::new(session::AgentMessage::User { content });
89 self.entries.push(entry);
90 }
91
92 pub fn add_assistant_message(&mut self, content: String) {
93 self.messages.push(ChatMessage::assistant(content.clone()));
94 let entry = session::SessionEntry::new(session::AgentMessage::Assistant { content });
96 self.entries.push(entry);
97 self.current_response.clear();
98 }
99
100 pub fn append_to_response(&mut self, text: &str) {
101 self.current_response.push_str(text);
102 }
103
104 pub fn finish_response(&mut self) {
105 if !self.current_response.is_empty() {
106 let response = std::mem::take(&mut self.current_response);
107 self.add_assistant_message(response);
108 }
109 }
110
111 pub fn entries(&self) -> &[session::SessionEntry] {
113 &self.entries
114 }
115
116 pub fn get_entry(&self, index: usize) -> Option<&session::SessionEntry> {
118 self.entries.get(index)
119 }
120
121 pub fn get_entry_by_id(&self, id: Uuid) -> Option<&session::SessionEntry> {
123 self.entries.iter().find(|e| e.id == id)
124 }
125
126 pub fn truncate_at(&mut self, index: usize) {
128 self.entries.truncate(index + 1);
129 }
130}
131
132fn build_system_prompt(
134 thinking_level: ThinkingLevel,
135 skill_contents: &[String],
136) -> String {
137 let mut prompt = match thinking_level {
138 ThinkingLevel::None => String::from(
139 "You are a helpful AI assistant. Provide direct, concise answers.",
140 ),
141 ThinkingLevel::Minimal => String::from(
142 "You are a helpful AI assistant. Provide clear and helpful answers.",
143 ),
144 ThinkingLevel::Standard => String::from(
145 "You are a helpful AI coding assistant. Think through problems \
146 step by step when helpful, but keep responses focused and actionable.",
147 ),
148 ThinkingLevel::Thorough => String::from(
149 "You are an expert AI coding assistant. Take time to thoroughly \
150 analyze problems, consider edge cases, and provide comprehensive \
151 solutions with explanations. Think deeply before responding.",
152 ),
153 };
154
155 for content in skill_contents {
157 prompt.push_str("\n\n---\n# Active Skill\n\n");
158 prompt.push_str(content);
159 }
160
161 prompt
162}
163
164impl App {
165 pub async fn new(settings: Settings) -> Result<Self> {
167 let model_id = settings.effective_model(None);
168 let provider_name = settings.effective_provider(None);
169
170 let parts: Vec<&str> = model_id.split('/').collect();
172 let (provider_name, model_name) = if parts.len() >= 2 {
173 (parts[0].to_string(), parts[1..].join("/"))
174 } else {
175 (provider_name.clone(), model_id.clone())
176 };
177
178 let _model = get_model(&provider_name, &model_name)
180 .ok_or_else(|| Error::msg(format!("Model '{}' not found", model_id)))?;
181
182 let provider = get_provider(&provider_name)
184 .ok_or_else(|| Error::msg(format!("Provider '{}' not found", provider_name)))?;
185
186 let skills_dir = SkillManager::skills_dir().unwrap_or_else(|_| {
188 dirs::home_dir()
189 .unwrap_or_default()
190 .join(".oxi")
191 .join("skills")
192 });
193 let skills = SkillManager::load_from_dir(&skills_dir).unwrap_or_else(|e| {
194 tracing::debug!("Skills not loaded: {}", e);
195 SkillManager::load_from_dir(std::path::Path::new("/nonexistent")).unwrap()
196 });
197
198 let system_prompt = build_system_prompt(settings.thinking_level, &[]);
200 let compaction_strategy = if settings.auto_compaction {
201 oxi_ai::CompactionStrategy::Threshold(0.8)
202 } else {
203 oxi_ai::CompactionStrategy::Disabled
204 };
205 let config = AgentConfig {
206 name: "oxi".to_string(),
207 description: Some("oxi CLI agent".to_string()),
208 model_id: model_id.clone(),
209 system_prompt: Some(system_prompt),
210 max_iterations: 10,
211 timeout_seconds: settings.tool_timeout_seconds,
212 temperature: settings.effective_temperature(),
213 max_tokens: settings.effective_max_tokens(),
214 compaction_strategy,
215 compaction_instruction: None,
216 context_window: 128_000,
217 };
218
219 let agent = Arc::new(Agent::new(Arc::from(provider), config));
220
221 Ok(Self {
222 agent,
223 settings,
224 skills: RwLock::new(skills),
225 active_skills: RwLock::new(Vec::new()),
226 })
227 }
228
229 pub fn settings(&self) -> &Settings {
231 &self.settings
232 }
233
234 pub fn agent(&self) -> Arc<Agent> {
236 Arc::clone(&self.agent)
237 }
238
239 pub fn agent_tools(&self) -> Arc<oxi_agent::ToolRegistry> {
241 self.agent.tools()
242 }
243
244 pub fn skills(&self) -> parking_lot::RwLockReadGuard<'_, SkillManager> {
246 self.skills.read()
247 }
248
249 pub fn activate_skill(&self, name: &str) -> Result<(), String> {
251 {
252 let skills = self.skills.read();
253 if skills.get(name).is_none() {
254 return Err(format!("Skill '{}' not found", name));
255 }
256 }
257 let name_lower = name.to_lowercase();
258 {
259 let mut active = self.active_skills.write();
260 if !active.contains(&name_lower) {
261 active.push(name_lower);
262 }
263 }
264 self.rebuild_system_prompt();
265 Ok(())
266 }
267
268 pub fn deactivate_skill(&self, name: &str) {
270 let name_lower = name.to_lowercase();
271 {
272 let mut active = self.active_skills.write();
273 active.retain(|n| n != &name_lower);
274 }
275 self.rebuild_system_prompt();
276 }
277
278 pub fn active_skills(&self) -> Vec<String> {
280 self.active_skills.read().clone()
281 }
282
283 fn rebuild_system_prompt(&self) {
285 let active = self.active_skills.read();
286 let skills = self.skills.read();
287 let contents: Vec<String> = active
288 .iter()
289 .filter_map(|name| skills.get(name).map(|s| s.content.clone()))
290 .collect();
291 let prompt = build_system_prompt(self.settings.thinking_level, &contents);
292 self.agent.set_system_prompt(prompt);
293 }
294
295 pub fn agent_state(&self) -> oxi_agent::AgentState {
297 self.agent.state()
298 }
299
300 pub async fn run_prompt(&self, prompt: String) -> Result<String> {
302 let (response, _events) = self.agent.run(prompt).await?;
303 Ok(response.content)
304 }
305
306 pub async fn run_prompt_with_events<F>(&self, prompt: String, on_event: F) -> Result<String>
308 where
309 F: FnMut(AgentEvent) + Send + 'static,
310 {
311 self.agent.run_streaming(prompt, on_event).await?;
312 let state = self.agent_state();
314 for msg in state.messages.iter().rev() {
315 if let oxi_ai::Message::Assistant(a) = msg {
316 return Ok(a.text_content());
317 }
318 }
319 Ok(String::new())
320 }
321
322 pub async fn run_interactive(&self) -> Result<InteractiveLoop<'_>> {
324 let session = InteractiveSession::new();
325 Ok(InteractiveLoop {
326 app: self,
327 session,
328 })
329 }
330
331 pub fn reset(&self) {
333 self.agent.reset();
334 }
335
336 pub fn switch_model(&self, model_id: &str) -> anyhow::Result<()> {
340 self.agent.switch_model(model_id)
341 }
342
343 pub fn model_id(&self) -> String {
345 self.agent.model_id()
346 }
347}
348
349pub struct InteractiveLoop<'a> {
351 app: &'a App,
352 session: InteractiveSession,
353}
354
355impl<'a> InteractiveLoop<'a> {
356 pub async fn send_message(&mut self, prompt: String) -> Result<()> {
358 self.session.add_user_message(prompt.clone());
360 self.session.thinking = true;
361
362 let (tx, mut rx) = mpsc::channel::<AgentEvent>(100);
364
365 let agent = Arc::clone(&self.app.agent);
370
371 let local = tokio::task::LocalSet::new();
373 local.spawn_local(async move {
374 let _ = agent.run_with_channel(prompt, tx).await;
375 });
376
377 while let Some(event) = rx.recv().await {
379 match event {
380 AgentEvent::TextChunk { text } => {
381 self.session.append_to_response(&text);
382 }
383 AgentEvent::Thinking => {
384 }
386 AgentEvent::Complete { .. } => {
387 self.session.finish_response();
388 self.session.thinking = false;
389 }
390 AgentEvent::Error { message } => {
391 self.session.append_to_response(&format!("[Error: {}]", message));
392 self.session.finish_response();
393 self.session.thinking = false;
394 }
395 _ => {}
396 }
397 }
398
399 local.await;
401
402 Ok(())
403 }
404
405 pub fn messages(&self) -> &[ChatMessage] {
407 &self.session.messages
408 }
409
410 pub fn current_response(&self) -> &str {
412 &self.session.current_response
413 }
414
415 pub fn is_thinking(&self) -> bool {
417 self.session.thinking
418 }
419
420 pub fn entries(&self) -> &[session::SessionEntry] {
422 self.session.entries()
423 }
424
425 pub fn get_entry(&self, id: Uuid) -> Option<&session::SessionEntry> {
427 self.session.get_entry_by_id(id)
428 }
429
430 pub fn switch_model(&self, model_id: &str) -> anyhow::Result<()> {
432 self.app.switch_model(model_id)
433 }
434
435 pub fn model_id(&self) -> String {
437 self.app.model_id()
438 }
439}