1use std::io::{self, Write};
4
5use anyhow::Result;
6use clap::Parser;
7use crossterm::{
8 event::{self, Event, KeyCode, KeyModifiers},
9 terminal::enable_raw_mode,
10};
11
12use crate::utils::terminal::RawModeGuard;
13
14#[derive(Parser)]
22pub struct ChatCommand {
23 #[command(flatten)]
25 pub ai: crate::cli::ai_backend_args::AiBackendArgs,
26}
27
28impl ChatCommand {
29 pub async fn execute(self) -> Result<()> {
31 self.ai.apply();
32
33 let ai_info = crate::utils::preflight::check_ai_credentials(None)?;
34 eprintln!(
35 "Connected to {} (model: {})",
36 ai_info.provider, ai_info.model
37 );
38 eprintln!("Enter to send, Shift+Enter for newline, Ctrl+D to exit.\n");
39
40 let client = crate::claude::create_default_claude_client(None, None).await?;
41
42 chat_loop(&client).await
43 }
44}
45
46pub async fn run_chat(
57 message: &str,
58 model: Option<String>,
59 system_prompt: Option<String>,
60) -> Result<String> {
61 crate::utils::preflight::check_ai_credentials(model.as_deref())?;
62 let client = crate::claude::create_default_claude_client(model, None).await?;
63 let system = system_prompt
64 .as_deref()
65 .unwrap_or("You are a helpful assistant.");
66 client.send_message(system, message).await
67}
68
69async fn chat_loop(client: &crate::claude::client::ClaudeClient) -> Result<()> {
70 let system_prompt = "You are a helpful assistant.";
71
72 loop {
73 let input = match read_user_input() {
74 Ok(Some(text)) => text,
75 Ok(None) => {
76 eprintln!("\nGoodbye!");
77 break;
78 }
79 Err(e) => {
80 eprintln!("\nInput error: {e}");
81 break;
82 }
83 };
84
85 let trimmed = input.trim();
86 if trimmed.is_empty() {
87 continue;
88 }
89
90 let response = client.send_message(system_prompt, trimmed).await?;
91 println!("{response}\n");
92 }
93
94 Ok(())
95}
96
97fn read_user_input() -> Result<Option<String>> {
101 eprint!("> ");
102 io::stderr().flush()?;
103
104 enable_raw_mode()?;
105 let _guard = RawModeGuard;
106
107 let mut buffer = String::new();
108
109 loop {
110 if let Event::Key(key_event) = event::read()? {
111 match key_event.code {
112 KeyCode::Enter => {
113 if key_event.modifiers.contains(KeyModifiers::SHIFT) {
114 buffer.push('\n');
115 eprint!("\r\n... ");
116 io::stderr().flush()?;
117 } else {
118 eprint!("\r\n");
119 io::stderr().flush()?;
120 return Ok(Some(buffer));
121 }
122 }
123 KeyCode::Char('d') if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
124 if buffer.is_empty() {
125 return Ok(None);
126 }
127 eprint!("\r\n");
128 io::stderr().flush()?;
129 return Ok(Some(buffer));
130 }
131 KeyCode::Char('c') if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
132 return Ok(None);
133 }
134 KeyCode::Char(c) => {
135 buffer.push(c);
136 eprint!("{c}");
137 io::stderr().flush()?;
138 }
139 KeyCode::Backspace if buffer.pop().is_some() => {
140 eprint!("\x08 \x08");
141 io::stderr().flush()?;
142 }
143 _ => {}
144 }
145 }
146 }
147}
148
149#[cfg(test)]
150#[allow(clippy::unwrap_used, clippy::expect_used)]
151mod tests {
152 use super::*;
153
154 static ENV_LOCK: &std::sync::Mutex<()> = &crate::test_support::HOME_ENV_MUTEX;
163
164 const KEYS: &[&str] = &[
165 "OMNI_DEV_AI_BACKEND",
166 "OMNI_DEV_MODEL",
167 "OMNI_DEV_BETA_HEADER",
168 "USE_OPENAI",
169 "USE_OLLAMA",
170 "CLAUDE_CODE_USE_BEDROCK",
171 "CLAUDE_API_KEY",
172 "ANTHROPIC_API_KEY",
173 "ANTHROPIC_AUTH_TOKEN",
174 "ANTHROPIC_BEDROCK_BASE_URL",
175 "OPENAI_API_KEY",
176 "OPENAI_AUTH_TOKEN",
177 "OLLAMA_MODEL",
178 "OLLAMA_BASE_URL",
179 "ANTHROPIC_MODEL",
180 "CLAUDE_API_KEY_FILE",
182 "ANTHROPIC_API_KEY_FILE",
183 "ANTHROPIC_AUTH_TOKEN_FILE",
184 "OPENAI_API_KEY_FILE",
185 "OPENAI_AUTH_TOKEN_FILE",
186 ];
187
188 fn snapshot_env() -> Vec<(&'static str, Option<String>)> {
189 let mut v: Vec<(&'static str, Option<String>)> =
190 KEYS.iter().map(|k| (*k, std::env::var(k).ok())).collect();
191 v.push(("HOME", std::env::var("HOME").ok()));
192 v
193 }
194
195 fn restore_env(snap: Vec<(&'static str, Option<String>)>) {
196 for (k, v) in snap {
197 match v {
198 Some(val) => std::env::set_var(k, val),
199 None => std::env::remove_var(k),
200 }
201 }
202 }
203
204 fn isolate_empty_home() -> tempfile::TempDir {
205 let dir = {
206 std::fs::create_dir_all("tmp").ok();
207 tempfile::TempDir::new_in("tmp").unwrap()
208 };
209 std::env::set_var("HOME", dir.path());
210 for k in KEYS {
211 std::env::remove_var(k);
212 }
213 dir
214 }
215
216 #[allow(clippy::await_holding_lock)]
217 #[tokio::test]
218 async fn run_chat_returns_error_when_credentials_missing() {
219 let _guard = ENV_LOCK
220 .lock()
221 .unwrap_or_else(std::sync::PoisonError::into_inner);
222 let snap = snapshot_env();
223 let _home = isolate_empty_home();
224
225 let err = run_chat("hello", None, None).await.unwrap_err();
226 let msg = format!("{err}");
227 assert!(
228 msg.contains("API key not found") || msg.contains("not found"),
229 "expected credential error, got: {msg}"
230 );
231
232 restore_env(snap);
233 }
234
235 #[allow(clippy::await_holding_lock)]
236 #[tokio::test]
237 async fn run_chat_bubbles_up_credential_error_with_custom_system_prompt() {
238 let _guard = ENV_LOCK
239 .lock()
240 .unwrap_or_else(std::sync::PoisonError::into_inner);
241 let snap = snapshot_env();
242 let _home = isolate_empty_home();
243
244 let err = run_chat("hello", None, Some("be terse".to_string()))
246 .await
247 .unwrap_err();
248 assert!(format!("{err}").contains("not found"));
249
250 restore_env(snap);
251 }
252
253 #[allow(clippy::await_holding_lock)]
254 #[tokio::test]
255 async fn run_chat_propagates_model_override_through_preflight() {
256 let _guard = ENV_LOCK
257 .lock()
258 .unwrap_or_else(std::sync::PoisonError::into_inner);
259 let snap = snapshot_env();
260 let _home = isolate_empty_home();
261
262 let err = run_chat("hello", Some("claude-sonnet-4-6".to_string()), None)
264 .await
265 .unwrap_err();
266 assert!(format!("{err}").contains("not found"));
267
268 restore_env(snap);
269 }
270
271 #[allow(clippy::await_holding_lock)]
276 #[tokio::test]
277 async fn run_chat_happy_path_via_mocked_ollama_returns_response_text() {
278 let _guard = ENV_LOCK
279 .lock()
280 .unwrap_or_else(std::sync::PoisonError::into_inner);
281 let snap = snapshot_env();
282 let _home = isolate_empty_home();
283
284 let server = wiremock::MockServer::start().await;
285 wiremock::Mock::given(wiremock::matchers::method("POST"))
286 .and(wiremock::matchers::path("/v1/chat/completions"))
287 .respond_with(
288 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
289 "id": "test",
290 "object": "chat.completion",
291 "choices": [{
292 "index": 0,
293 "message": {"role": "assistant", "content": "canned-response"},
294 "finish_reason": "stop"
295 }]
296 })),
297 )
298 .mount(&server)
299 .await;
300
301 std::env::set_var("USE_OLLAMA", "true");
302 std::env::set_var("OLLAMA_MODEL", "llama2");
303 std::env::set_var("OLLAMA_BASE_URL", server.uri());
304
305 let out = run_chat("hello", None, Some("be terse".to_string()))
306 .await
307 .unwrap();
308 assert_eq!(out, "canned-response");
309
310 restore_env(snap);
311 }
312
313 #[allow(clippy::await_holding_lock)]
316 #[tokio::test]
317 async fn run_chat_default_system_prompt_path_via_mocked_ollama() {
318 let _guard = ENV_LOCK
319 .lock()
320 .unwrap_or_else(std::sync::PoisonError::into_inner);
321 let snap = snapshot_env();
322 let _home = isolate_empty_home();
323
324 let server = wiremock::MockServer::start().await;
325 wiremock::Mock::given(wiremock::matchers::method("POST"))
326 .and(wiremock::matchers::path("/v1/chat/completions"))
327 .respond_with(
328 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
329 "id": "test",
330 "object": "chat.completion",
331 "choices": [{
332 "index": 0,
333 "message": {"role": "assistant", "content": "ok"},
334 "finish_reason": "stop"
335 }]
336 })),
337 )
338 .mount(&server)
339 .await;
340
341 std::env::set_var("USE_OLLAMA", "true");
342 std::env::set_var("OLLAMA_MODEL", "llama2");
343 std::env::set_var("OLLAMA_BASE_URL", server.uri());
344
345 let out = run_chat("hello", None, None).await.unwrap();
346 assert_eq!(out, "ok");
347
348 restore_env(snap);
349 }
350}