Skip to main content

koda_cli/
repl.rs

1//! REPL command dispatch — parses slash commands and returns actions.
2//!
3//! This module is shared between the TUI and headless entry points.
4//! It parses the command string and returns a [`ReplAction`] enum that
5//! the caller translates into UI-specific behavior.
6//!
7//! See [`crate`] module docs for the full command table.
8
9use koda_core::config::{KodaConfig, ProviderType};
10use koda_core::providers::LlmProvider;
11use std::sync::Arc;
12use tokio::sync::RwLock;
13
14/// Action to take after processing a REPL command.
15pub enum ReplAction {
16    Quit,
17    SwitchModel(String),
18    PickModel,
19    SetupProvider(ProviderType, String), // (provider_type, base_url)
20    PickProvider,
21    ShowHelp,
22    ListSessions,
23    ResumeSession(String),
24    DeleteSession(String),
25    /// Inject text as if the user typed it (used by /diff review, /diff commit)
26    InjectPrompt(String),
27    /// Compact the conversation by summarizing history
28    Compact,
29    /// Purge compacted messages (optional age filter like "90d")
30    Purge(Option<String>),
31    /// Expand Nth most recent tool output (1 = last)
32    Expand(usize),
33    /// Toggle verbose tool output (None = toggle, Some = set)
34    Verbose(Option<bool>),
35    /// List available sub-agents
36    ListAgents,
37    /// Show git diff summary
38    ShowDiff,
39    /// Memory management command
40    MemoryCommand(Option<String>),
41    /// Undo last turn's file mutations
42    Undo,
43    /// List available skills (optional search query)
44    ListSkills(Option<String>),
45    /// Manage API keys
46    ManageKeys,
47    #[allow(dead_code)]
48    Handled,
49    NotACommand,
50}
51
52/// Parse and handle a slash command. Returns the action for the main loop.
53pub async fn handle_command(
54    input: &str,
55    _config: &KodaConfig,
56    _provider: &Arc<RwLock<Box<dyn LlmProvider>>>,
57) -> ReplAction {
58    let parts: Vec<&str> = input.splitn(2, ' ').collect();
59    let cmd = parts[0];
60    let arg = parts.get(1).map(|s| s.trim());
61
62    match cmd {
63        "/exit" => ReplAction::Quit,
64
65        "/model" => match arg {
66            Some(model) => ReplAction::SwitchModel(model.to_string()),
67            None => ReplAction::PickModel,
68        },
69
70        "/provider" => match arg {
71            Some(name) => {
72                let ptype = ProviderType::from_url_or_name("", Some(name));
73                let base_url = ptype.default_base_url().to_string();
74                ReplAction::SetupProvider(ptype, base_url)
75            }
76            None => ReplAction::PickProvider,
77        },
78
79        "/help" => ReplAction::ShowHelp,
80
81        "/diff" => match arg {
82            Some("review") => {
83                let full_diff = get_git_diff();
84                ReplAction::InjectPrompt(format!(
85                    "Review these uncommitted changes. Point out bugs, improvements, and concerns:\n\n```diff\n{full_diff}\n```"
86                ))
87            }
88            Some("commit") => {
89                let full_diff = get_git_diff();
90                ReplAction::InjectPrompt(format!(
91                    "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```"
92                ))
93            }
94            _ => ReplAction::ShowDiff,
95        },
96
97        "/compact" => ReplAction::Compact,
98        "/purge" => ReplAction::Purge(arg.map(|s| s.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        "/key" | "/keys" => ReplAction::ManageKeys,
136
137        _ => ReplAction::NotACommand,
138    }
139}
140
141/// Available providers for the interactive picker.
142///
143/// Tuple: (internal_key, display_name). Descriptions like "Local, no API key"
144/// are derived from `ProviderType::requires_api_key()` at render time.
145pub const PROVIDERS: &[(&str, &str)] = &[
146    ("lmstudio", "LM Studio"),
147    ("ollama", "Ollama"),
148    ("openai", "OpenAI"),
149    ("anthropic", "Anthropic"),
150    ("deepseek", "DeepSeek"),
151    ("gemini", "Google Gemini"),
152    ("groq", "Groq"),
153    ("grok", "Grok (xAI)"),
154    ("mistral", "Mistral"),
155    ("minimax", "MiniMax"),
156    ("openrouter", "OpenRouter"),
157    ("together", "Together"),
158    ("fireworks", "Fireworks"),
159    ("vllm", "vLLM"),
160];
161
162/// Get the full git diff (unstaged + staged), capped for context window safety.
163fn get_git_diff() -> String {
164    const MAX_DIFF_CHARS: usize = 30_000;
165
166    let unstaged = std::process::Command::new("git")
167        .args(["diff"])
168        .output()
169        .ok()
170        .filter(|o| o.status.success())
171        .map(|o| String::from_utf8_lossy(&o.stdout).to_string())
172        .unwrap_or_default();
173
174    let staged = std::process::Command::new("git")
175        .args(["diff", "--cached"])
176        .output()
177        .ok()
178        .filter(|o| o.status.success())
179        .map(|o| String::from_utf8_lossy(&o.stdout).to_string())
180        .unwrap_or_default();
181
182    let mut diff = String::new();
183    if !unstaged.is_empty() {
184        diff.push_str(&unstaged);
185    }
186    if !staged.is_empty() {
187        if !diff.is_empty() {
188            diff.push_str("\n# --- Staged changes ---\n\n");
189        }
190        diff.push_str(&staged);
191    }
192
193    if diff.len() > MAX_DIFF_CHARS {
194        let mut end = MAX_DIFF_CHARS;
195        while end > 0 && !diff.is_char_boundary(end) {
196            end -= 1;
197        }
198        format!(
199            "{}\n\n[TRUNCATED: diff was {} chars, showing first {}]",
200            &diff[..end],
201            diff.len(),
202            MAX_DIFF_CHARS
203        )
204    } else {
205        diff
206    }
207}