1use koda_core::config::{KodaConfig, ProviderType};
7use koda_core::providers::LlmProvider;
8use std::sync::Arc;
9use tokio::sync::RwLock;
10
11pub enum ReplAction {
13 Quit,
14 SwitchModel(String),
15 PickModel,
16 SetupProvider(ProviderType, String), PickProvider,
18 ShowHelp,
19 ShowCost,
20 ListSessions,
21 ResumeSession(String),
22 DeleteSession(String),
23 InjectPrompt(String),
25 Compact,
27 McpCommand(String),
30 Expand(usize),
32 Verbose(Option<bool>),
34 ListAgents,
36 ShowDiff,
38 MemoryCommand(Option<String>),
40 Undo,
42 ListSkills(Option<String>),
44 #[allow(dead_code)]
45 Handled,
46 NotACommand,
47}
48
49pub 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), },
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 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
139pub 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
157fn 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}