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    #[allow(dead_code)]
43    Handled,
44    NotACommand,
45}
46
47/// Parse and handle a slash command. Returns the action for the main loop.
48pub async fn handle_command(
49    input: &str,
50    _config: &KodaConfig,
51    _provider: &Arc<RwLock<Box<dyn LlmProvider>>>,
52) -> ReplAction {
53    let parts: Vec<&str> = input.splitn(2, ' ').collect();
54    let cmd = parts[0];
55    let arg = parts.get(1).map(|s| s.trim());
56
57    match cmd {
58        "/exit" => ReplAction::Quit,
59
60        "/model" => match arg {
61            Some(model) => ReplAction::SwitchModel(model.to_string()),
62            None => ReplAction::PickModel,
63        },
64
65        "/provider" => match arg {
66            Some(name) => {
67                let ptype = ProviderType::from_url_or_name("", Some(name));
68                let base_url = ptype.default_base_url().to_string();
69                ReplAction::SetupProvider(ptype, base_url)
70            }
71            None => ReplAction::PickProvider,
72        },
73
74        "/help" => ReplAction::ShowHelp,
75
76        "/diff" => match arg {
77            Some("review") => {
78                let full_diff = get_git_diff();
79                ReplAction::InjectPrompt(format!(
80                    "Review these uncommitted changes. Point out bugs, improvements, and concerns:\n\n```diff\n{full_diff}\n```"
81                ))
82            }
83            Some("commit") => {
84                let full_diff = get_git_diff();
85                ReplAction::InjectPrompt(format!(
86                    "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```"
87                ))
88            }
89            _ => ReplAction::ShowDiff,
90        },
91
92        "/compact" => ReplAction::Compact,
93        "/purge" => ReplAction::Purge(arg.map(|s| s.to_string())),
94
95        "/expand" => {
96            let n: usize = arg.and_then(|s| s.parse().ok()).unwrap_or(1);
97            ReplAction::Expand(n)
98        }
99
100        "/verbose" => match arg {
101            Some("on") => ReplAction::Verbose(Some(true)),
102            Some("off") => ReplAction::Verbose(Some(false)),
103            _ => ReplAction::Verbose(None), // toggle
104        },
105
106        "/agent" => ReplAction::ListAgents,
107
108        "/sessions" => match arg {
109            Some(sub) if sub.starts_with("delete ") => {
110                let id = sub.strip_prefix("delete ").unwrap().trim().to_string();
111                ReplAction::DeleteSession(id)
112            }
113            Some(sub) if sub.starts_with("resume ") => {
114                let id = sub.strip_prefix("resume ").unwrap().trim().to_string();
115                ReplAction::ResumeSession(id)
116            }
117            // Bare ID shorthand: /sessions <id>
118            Some(id) if !id.is_empty() && id.chars().all(|c| c.is_ascii_hexdigit() || c == '-') => {
119                ReplAction::ResumeSession(id.to_string())
120            }
121            _ => ReplAction::ListSessions,
122        },
123
124        "/memory" => ReplAction::MemoryCommand(arg.map(|s| s.to_string())),
125
126        "/undo" => ReplAction::Undo,
127
128        "/skills" => ReplAction::ListSkills(arg.map(|s| s.to_string())),
129
130        _ => ReplAction::NotACommand,
131    }
132}
133
134/// Available providers for the interactive picker.
135pub const PROVIDERS: &[(&str, &str, &str)] = &[
136    ("lmstudio", "LM Studio", "Local models, no API key needed"),
137    ("ollama", "Ollama", "Local models, no API key needed"),
138    ("openai", "OpenAI", "GPT-4o, o1, o3"),
139    ("anthropic", "Anthropic", "Claude Sonnet, Opus"),
140    ("deepseek", "DeepSeek", "DeepSeek-V3, R1"),
141    ("gemini", "Google Gemini", "Gemini 2.0 Flash, Pro"),
142    ("groq", "Groq", "Fast inference"),
143    ("grok", "Grok (xAI)", "Grok-3, Grok-2"),
144    ("mistral", "Mistral", "Mistral Large, Codestral"),
145    ("minimax", "MiniMax", "MiniMax-01"),
146    ("openrouter", "OpenRouter", "Meta-provider, 100+ models"),
147    ("together", "Together", "Open-source model hosting"),
148    ("fireworks", "Fireworks", "Fast inference"),
149    ("vllm", "vLLM", "Local high-performance serving"),
150];
151
152/// Get the full git diff (unstaged + staged), capped for context window safety.
153fn get_git_diff() -> String {
154    const MAX_DIFF_CHARS: usize = 30_000;
155
156    let unstaged = std::process::Command::new("git")
157        .args(["diff"])
158        .output()
159        .ok()
160        .filter(|o| o.status.success())
161        .map(|o| String::from_utf8_lossy(&o.stdout).to_string())
162        .unwrap_or_default();
163
164    let staged = std::process::Command::new("git")
165        .args(["diff", "--cached"])
166        .output()
167        .ok()
168        .filter(|o| o.status.success())
169        .map(|o| String::from_utf8_lossy(&o.stdout).to_string())
170        .unwrap_or_default();
171
172    let mut diff = String::new();
173    if !unstaged.is_empty() {
174        diff.push_str(&unstaged);
175    }
176    if !staged.is_empty() {
177        if !diff.is_empty() {
178            diff.push_str("\n# --- Staged changes ---\n\n");
179        }
180        diff.push_str(&staged);
181    }
182
183    if diff.len() > MAX_DIFF_CHARS {
184        let mut end = MAX_DIFF_CHARS;
185        while end > 0 && !diff.is_char_boundary(end) {
186            end -= 1;
187        }
188        format!(
189            "{}\n\n[TRUNCATED: diff was {} chars, showing first {}]",
190            &diff[..end],
191            diff.len(),
192            MAX_DIFF_CHARS
193        )
194    } else {
195        diff
196    }
197}