Skip to main content

oxi/
lib.rs

1//! oxi: CLI coding harness
2//!
3//! This crate provides the main application logic for the oxi CLI.
4
5pub 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
24/// Application state and entry point
25pub struct App {
26    agent: Arc<Agent>,
27    settings: Settings,
28    skills: RwLock<SkillManager>,
29    active_skills: RwLock<Vec<String>>,
30}
31
32/// Chat message for display
33#[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
58/// Interactive session state
59pub 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        // Also add to entries for session persistence
87        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        // Also add to entries for session persistence
94        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    /// Get all entries in the session
111    pub fn entries(&self) -> &[session::SessionEntry] {
112        &self.entries
113    }
114
115    /// Get entry at a specific index
116    pub fn get_entry(&self, index: usize) -> Option<&session::SessionEntry> {
117        self.entries.get(index)
118    }
119
120    /// Get entry by ID
121    pub fn get_entry_by_id(&self, id: Uuid) -> Option<&session::SessionEntry> {
122        self.entries.iter().find(|e| e.id == id)
123    }
124
125    /// Truncate entries at a given index (for branching)
126    pub fn truncate_at(&mut self, index: usize) {
127        self.entries.truncate(index + 1);
128    }
129}
130
131/// Build the system prompt based on thinking level and active skills
132fn 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    // Append active skill content
155    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    /// Create a new App instance
165    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        // Parse model ID to get provider and model
170        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        // Get the model
178        let _model = get_model(&provider_name, &model_name)
179            .ok_or_else(|| Error::msg(format!("Model '{}' not found", model_id)))?;
180
181        // Create a provider for this model
182        let provider = get_provider(&provider_name)
183            .ok_or_else(|| Error::msg(format!("Provider '{}' not found", provider_name)))?;
184
185        // Load skills
186        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        // Build agent config from settings
198        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    /// Get the current settings
229    pub fn settings(&self) -> &Settings {
230        &self.settings
231    }
232
233    /// Get a reference to the underlying agent.
234    pub fn agent(&self) -> Arc<Agent> {
235        Arc::clone(&self.agent)
236    }
237
238    /// Get the tool registry (for registering extension tools)
239    pub fn agent_tools(&self) -> Arc<oxi_agent::ToolRegistry> {
240        self.agent.tools()
241    }
242
243    /// Get a reference to the skill manager
244    pub fn skills(&self) -> parking_lot::RwLockReadGuard<'_, SkillManager> {
245        self.skills.read()
246    }
247
248    /// Activate a skill by name. Returns an error string if not found.
249    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    /// Deactivate a skill by name.
268    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    /// List currently active skill names
278    pub fn active_skills(&self) -> Vec<String> {
279        self.active_skills.read().clone()
280    }
281
282    /// Rebuild the system prompt with current active skills
283    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    /// Get a clone of the current state
295    pub fn agent_state(&self) -> oxi_agent::AgentState {
296        self.agent.state()
297    }
298
299    /// Run a single prompt and return the response
300    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    /// Run a prompt with event callback
306    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        // Get the last assistant message's text content
312        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    /// Run in interactive mode, returning an event stream
322    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    /// Reset the conversation
331    pub fn reset(&self) {
332        self.agent.reset();
333    }
334
335    /// Switch the model used for future LLM calls.
336    ///
337    /// See [`Agent::switch_model`] for details.
338    pub fn switch_model(&self, model_id: &str) -> anyhow::Result<()> {
339        self.agent.switch_model(model_id)
340    }
341
342    /// Get the current model ID
343    pub fn model_id(&self) -> String {
344        self.agent.model_id()
345    }
346}
347
348/// Interactive loop handle
349pub struct InteractiveLoop<'a> {
350    app: &'a App,
351    session: InteractiveSession,
352}
353
354impl<'a> InteractiveLoop<'a> {
355    /// Add a user message and get the assistant response
356    pub async fn send_message(&mut self, prompt: String) -> Result<()> {
357        // Add user message
358        self.session.add_user_message(prompt.clone());
359        self.session.thinking = true;
360
361        // Run agent with channel
362        let (tx, mut rx) = mpsc::channel::<AgentEvent>(100);
363
364        // Run the agent — we execute inline instead of spawning because
365        // the agent's internal RwLockReadGuard is not Send-safe across
366        // await points. We use a select-like approach: run the agent in a
367        // local task that doesn't require Send.
368        let agent = Arc::clone(&self.app.agent);
369
370        // Use LocalSet to spawn a non-Send future
371        let local = tokio::task::LocalSet::new();
372        local.spawn_local(async move {
373            let _ = agent.run_with_channel(prompt, tx).await;
374        });
375
376        // Collect events
377        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                    // Thinking state
384                }
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        // Run local set to completion (drain remaining agent work)
399        local.await;
400
401        Ok(())
402    }
403
404    /// Get current messages
405    pub fn messages(&self) -> &[ChatMessage] {
406        &self.session.messages
407    }
408
409    /// Get the current partial response (while thinking)
410    pub fn current_response(&self) -> &str {
411        &self.session.current_response
412    }
413
414    /// Check if currently thinking
415    pub fn is_thinking(&self) -> bool {
416        self.session.thinking
417    }
418
419    /// Get session entries for tree navigation
420    pub fn entries(&self) -> &[session::SessionEntry] {
421        self.session.entries()
422    }
423
424    /// Get entry by ID
425    pub fn get_entry(&self, id: Uuid) -> Option<&session::SessionEntry> {
426        self.session.get_entry_by_id(id)
427    }
428
429    /// Switch the model used for future LLM calls
430    pub fn switch_model(&self, model_id: &str) -> anyhow::Result<()> {
431        self.app.switch_model(model_id)
432    }
433
434    /// Get the current model ID
435    pub fn model_id(&self) -> String {
436        self.app.model_id()
437    }
438}