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