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