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 ListSessions,
20 ResumeSession(String),
21 DeleteSession(String),
22 InjectPrompt(String),
24 Compact,
26 Purge(Option<String>),
28 McpCommand(String),
31 Expand(usize),
33 Verbose(Option<bool>),
35 ListAgents,
37 ShowDiff,
39 MemoryCommand(Option<String>),
41 Undo,
43 ListSkills(Option<String>),
45 #[allow(dead_code)]
46 Handled,
47 NotACommand,
48}
49
50pub async fn handle_command(
52 input: &str,
53 _config: &KodaConfig,
54 _provider: &Arc<RwLock<Box<dyn LlmProvider>>>,
55) -> ReplAction {
56 let parts: Vec<&str> = input.splitn(2, ' ').collect();
57 let cmd = parts[0];
58 let arg = parts.get(1).map(|s| s.trim());
59
60 match cmd {
61 "/exit" => ReplAction::Quit,
62
63 "/model" => match arg {
64 Some(model) => ReplAction::SwitchModel(model.to_string()),
65 None => ReplAction::PickModel,
66 },
67
68 "/provider" => match arg {
69 Some(name) => {
70 let ptype = ProviderType::from_url_or_name("", Some(name));
71 let base_url = ptype.default_base_url().to_string();
72 ReplAction::SetupProvider(ptype, base_url)
73 }
74 None => ReplAction::PickProvider,
75 },
76
77 "/help" => ReplAction::ShowHelp,
78
79 "/diff" => match arg {
80 Some("review") => {
81 let full_diff = get_git_diff();
82 ReplAction::InjectPrompt(format!(
83 "Review these uncommitted changes. Point out bugs, improvements, and concerns:\n\n```diff\n{full_diff}\n```"
84 ))
85 }
86 Some("commit") => {
87 let full_diff = get_git_diff();
88 ReplAction::InjectPrompt(format!(
89 "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```"
90 ))
91 }
92 _ => ReplAction::ShowDiff,
93 },
94
95 "/compact" => ReplAction::Compact,
96 "/purge" => ReplAction::Purge(arg.map(|s| s.to_string())),
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}