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    ListSessions,
20    ResumeSession(String),
21    DeleteSession(String),
22    /// Inject text as if the user typed it (used by /diff review, /diff commit)
23    InjectPrompt(String),
24    /// Compact the conversation by summarizing history
25    Compact,
26    /// Purge compacted messages (optional age filter like "90d")
27    Purge(Option<String>),
28    /// Expand Nth most recent tool output (1 = last)
29    Expand(usize),
30    /// Toggle verbose tool output (None = toggle, Some = set)
31    Verbose(Option<bool>),
32    /// List available sub-agents
33    ListAgents,
34    /// Show git diff summary
35    ShowDiff,
36    /// Memory management command
37    MemoryCommand(Option<String>),
38    /// Undo last turn's file mutations
39    Undo,
40    /// List available skills (optional search query)
41    ListSkills(Option<String>),
42    /// Manage API keys
43    ManageKeys,
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        "/diff" => match arg {
79            Some("review") => {
80                let full_diff = get_git_diff();
81                ReplAction::InjectPrompt(format!(
82                    "Review these uncommitted changes. Point out bugs, improvements, and concerns:\n\n```diff\n{full_diff}\n```"
83                ))
84            }
85            Some("commit") => {
86                let full_diff = get_git_diff();
87                ReplAction::InjectPrompt(format!(
88                    "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```"
89                ))
90            }
91            _ => ReplAction::ShowDiff,
92        },
93
94        "/compact" => ReplAction::Compact,
95        "/purge" => ReplAction::Purge(arg.map(|s| s.to_string())),
96
97        "/expand" => {
98            let n: usize = arg.and_then(|s| s.parse().ok()).unwrap_or(1);
99            ReplAction::Expand(n)
100        }
101
102        "/verbose" => match arg {
103            Some("on") => ReplAction::Verbose(Some(true)),
104            Some("off") => ReplAction::Verbose(Some(false)),
105            _ => ReplAction::Verbose(None), // toggle
106        },
107
108        "/agent" => ReplAction::ListAgents,
109
110        "/sessions" => match arg {
111            Some(sub) if sub.starts_with("delete ") => {
112                let id = sub.strip_prefix("delete ").unwrap().trim().to_string();
113                ReplAction::DeleteSession(id)
114            }
115            Some(sub) if sub.starts_with("resume ") => {
116                let id = sub.strip_prefix("resume ").unwrap().trim().to_string();
117                ReplAction::ResumeSession(id)
118            }
119            // Bare ID shorthand: /sessions <id>
120            Some(id) if !id.is_empty() && id.chars().all(|c| c.is_ascii_hexdigit() || c == '-') => {
121                ReplAction::ResumeSession(id.to_string())
122            }
123            _ => ReplAction::ListSessions,
124        },
125
126        "/memory" => ReplAction::MemoryCommand(arg.map(|s| s.to_string())),
127
128        "/undo" => ReplAction::Undo,
129
130        "/skills" => ReplAction::ListSkills(arg.map(|s| s.to_string())),
131
132        "/key" | "/keys" => ReplAction::ManageKeys,
133
134        _ => ReplAction::NotACommand,
135    }
136}
137
138/// Available providers for the interactive picker.
139///
140/// Tuple: (internal_key, display_name). Descriptions like "Local, no API key"
141/// are derived from `ProviderType::requires_api_key()` at render time.
142pub const PROVIDERS: &[(&str, &str)] = &[
143    ("lmstudio", "LM Studio"),
144    ("ollama", "Ollama"),
145    ("openai", "OpenAI"),
146    ("anthropic", "Anthropic"),
147    ("deepseek", "DeepSeek"),
148    ("gemini", "Google Gemini"),
149    ("groq", "Groq"),
150    ("grok", "Grok (xAI)"),
151    ("mistral", "Mistral"),
152    ("minimax", "MiniMax"),
153    ("openrouter", "OpenRouter"),
154    ("together", "Together"),
155    ("fireworks", "Fireworks"),
156    ("vllm", "vLLM"),
157];
158
159/// Get the full git diff (unstaged + staged), capped for context window safety.
160fn get_git_diff() -> String {
161    const MAX_DIFF_CHARS: usize = 30_000;
162
163    let unstaged = std::process::Command::new("git")
164        .args(["diff"])
165        .output()
166        .ok()
167        .filter(|o| o.status.success())
168        .map(|o| String::from_utf8_lossy(&o.stdout).to_string())
169        .unwrap_or_default();
170
171    let staged = std::process::Command::new("git")
172        .args(["diff", "--cached"])
173        .output()
174        .ok()
175        .filter(|o| o.status.success())
176        .map(|o| String::from_utf8_lossy(&o.stdout).to_string())
177        .unwrap_or_default();
178
179    let mut diff = String::new();
180    if !unstaged.is_empty() {
181        diff.push_str(&unstaged);
182    }
183    if !staged.is_empty() {
184        if !diff.is_empty() {
185            diff.push_str("\n# --- Staged changes ---\n\n");
186        }
187        diff.push_str(&staged);
188    }
189
190    if diff.len() > MAX_DIFF_CHARS {
191        let mut end = MAX_DIFF_CHARS;
192        while end > 0 && !diff.is_char_boundary(end) {
193            end -= 1;
194        }
195        format!(
196            "{}\n\n[TRUNCATED: diff was {} chars, showing first {}]",
197            &diff[..end],
198            diff.len(),
199            MAX_DIFF_CHARS
200        )
201    } else {
202        diff
203    }
204}