Skip to main content

koda_cli/
repl.rs

1//! REPL command handling and display helpers.
2//!
3//! Handles slash commands (/model, /provider, /help, /quit)
4//! and the interactive provider/model pickers.
5
6use koda_core::config::{KodaConfig, ProviderType};
7use koda_core::providers::LlmProvider;
8use std::sync::Arc;
9use tokio::sync::RwLock;
10
11/// Action to take after processing a REPL command.
12pub enum ReplAction {
13    Quit,
14    SwitchModel(String),
15    PickModel,
16    SetupProvider(ProviderType, String), // (provider_type, base_url)
17    PickProvider,
18    ShowHelp,
19    ShowCost,
20    ListSessions,
21    ResumeSession(String),
22    DeleteSession(String),
23    /// Inject text as if the user typed it (used by /diff review, /diff commit)
24    InjectPrompt(String),
25    /// Compact the conversation by summarizing history
26    Compact,
27    /// Switch approval mode (with optional name, or interactive picker)
28    /// MCP server management command
29    McpCommand(String),
30    /// Expand Nth most recent tool output (1 = last)
31    Expand(usize),
32    /// Toggle verbose tool output (None = toggle, Some = set)
33    Verbose(Option<bool>),
34    /// List available sub-agents
35    ListAgents,
36    /// Show git diff summary
37    ShowDiff,
38    /// Memory management command
39    MemoryCommand(Option<String>),
40    /// Undo last turn's file mutations
41    Undo,
42    /// List available skills (optional search query)
43    ListSkills(Option<String>),
44    #[allow(dead_code)]
45    Handled,
46    NotACommand,
47}
48
49/// Parse and handle a slash command. Returns the action for the main loop.
50pub async fn handle_command(
51    input: &str,
52    _config: &KodaConfig,
53    _provider: &Arc<RwLock<Box<dyn LlmProvider>>>,
54) -> ReplAction {
55    let parts: Vec<&str> = input.splitn(2, ' ').collect();
56    let cmd = parts[0];
57    let arg = parts.get(1).map(|s| s.trim());
58
59    match cmd {
60        "/exit" => ReplAction::Quit,
61
62        "/model" => match arg {
63            Some(model) => ReplAction::SwitchModel(model.to_string()),
64            None => ReplAction::PickModel,
65        },
66
67        "/provider" => match arg {
68            Some(name) => {
69                let ptype = ProviderType::from_url_or_name("", Some(name));
70                let base_url = ptype.default_base_url().to_string();
71                ReplAction::SetupProvider(ptype, base_url)
72            }
73            None => ReplAction::PickProvider,
74        },
75
76        "/help" => ReplAction::ShowHelp,
77
78        "/cost" => ReplAction::ShowCost,
79
80        "/diff" => match arg {
81            Some("review") => {
82                let full_diff = get_git_diff();
83                ReplAction::InjectPrompt(format!(
84                    "Review these uncommitted changes. Point out bugs, improvements, and concerns:\n\n```diff\n{full_diff}\n```"
85                ))
86            }
87            Some("commit") => {
88                let full_diff = get_git_diff();
89                ReplAction::InjectPrompt(format!(
90                    "Write a conventional commit message for these changes. Use the format: type: description\n\nInclude a body with bullet points for each logical change.\n\n```diff\n{full_diff}\n```"
91                ))
92            }
93            _ => ReplAction::ShowDiff,
94        },
95
96        "/compact" => ReplAction::Compact,
97
98        "/mcp" => ReplAction::McpCommand(arg.unwrap_or("").to_string()),
99
100        "/expand" => {
101            let n: usize = arg.and_then(|s| s.parse().ok()).unwrap_or(1);
102            ReplAction::Expand(n)
103        }
104
105        "/verbose" => match arg {
106            Some("on") => ReplAction::Verbose(Some(true)),
107            Some("off") => ReplAction::Verbose(Some(false)),
108            _ => ReplAction::Verbose(None), // toggle
109        },
110
111        "/agent" => ReplAction::ListAgents,
112
113        "/sessions" => match arg {
114            Some(sub) if sub.starts_with("delete ") => {
115                let id = sub.strip_prefix("delete ").unwrap().trim().to_string();
116                ReplAction::DeleteSession(id)
117            }
118            Some(sub) if sub.starts_with("resume ") => {
119                let id = sub.strip_prefix("resume ").unwrap().trim().to_string();
120                ReplAction::ResumeSession(id)
121            }
122            // Bare ID shorthand: /sessions <id>
123            Some(id) if !id.is_empty() && id.chars().all(|c| c.is_ascii_hexdigit() || c == '-') => {
124                ReplAction::ResumeSession(id.to_string())
125            }
126            _ => ReplAction::ListSessions,
127        },
128
129        "/memory" => ReplAction::MemoryCommand(arg.map(|s| s.to_string())),
130
131        "/undo" => ReplAction::Undo,
132
133        "/skills" => ReplAction::ListSkills(arg.map(|s| s.to_string())),
134
135        _ => ReplAction::NotACommand,
136    }
137}
138
139/// Available providers for the interactive picker.
140pub const PROVIDERS: &[(&str, &str, &str)] = &[
141    ("lmstudio", "LM Studio", "Local models, no API key needed"),
142    ("ollama", "Ollama", "Local models, no API key needed"),
143    ("openai", "OpenAI", "GPT-4o, o1, o3"),
144    ("anthropic", "Anthropic", "Claude Sonnet, Opus"),
145    ("deepseek", "DeepSeek", "DeepSeek-V3, R1"),
146    ("gemini", "Google Gemini", "Gemini 2.0 Flash, Pro"),
147    ("groq", "Groq", "Fast inference"),
148    ("grok", "Grok (xAI)", "Grok-3, Grok-2"),
149    ("mistral", "Mistral", "Mistral Large, Codestral"),
150    ("minimax", "MiniMax", "MiniMax-01"),
151    ("openrouter", "OpenRouter", "Meta-provider, 100+ models"),
152    ("together", "Together", "Open-source model hosting"),
153    ("fireworks", "Fireworks", "Fast inference"),
154    ("vllm", "vLLM", "Local high-performance serving"),
155];
156
157/// Get the full git diff (unstaged + staged), capped for context window safety.
158fn get_git_diff() -> String {
159    const MAX_DIFF_CHARS: usize = 30_000;
160
161    let unstaged = std::process::Command::new("git")
162        .args(["diff"])
163        .output()
164        .ok()
165        .filter(|o| o.status.success())
166        .map(|o| String::from_utf8_lossy(&o.stdout).to_string())
167        .unwrap_or_default();
168
169    let staged = std::process::Command::new("git")
170        .args(["diff", "--cached"])
171        .output()
172        .ok()
173        .filter(|o| o.status.success())
174        .map(|o| String::from_utf8_lossy(&o.stdout).to_string())
175        .unwrap_or_default();
176
177    let mut diff = String::new();
178    if !unstaged.is_empty() {
179        diff.push_str(&unstaged);
180    }
181    if !staged.is_empty() {
182        if !diff.is_empty() {
183            diff.push_str("\n# --- Staged changes ---\n\n");
184        }
185        diff.push_str(&staged);
186    }
187
188    if diff.len() > MAX_DIFF_CHARS {
189        let mut end = MAX_DIFF_CHARS;
190        while end > 0 && !diff.is_char_boundary(end) {
191            end -= 1;
192        }
193        format!(
194            "{}\n\n[TRUNCATED: diff was {} chars, showing first {}]",
195            &diff[..end],
196            diff.len(),
197            MAX_DIFF_CHARS
198        )
199    } else {
200        diff
201    }
202}