1use crate::context::AgentContext;
15use crate::llm::openai::COPILOT_API_BASE;
16use crate::session::{Session, SessionStore};
17use crate::ui::{err, info, ok, print_banner, print_separator, print_status_bar, warn};
18use anyhow::Result;
19use console::style;
20use std::path::PathBuf;
22
23pub mod commands;
24pub mod input;
25use commands::{handle_command, ReplState};
26
27#[derive(Clone, Copy, PartialEq)]
29pub enum ReplMode {
30 Act,
31 Plan,
32}
33
34pub struct ProviderPreset {
36 pub label: &'static str,
37 pub api_base: &'static str,
38 pub needs_key: bool,
39}
40
41pub const PROVIDER_PRESETS: &[ProviderPreset] = &[
42 ProviderPreset {
43 label: "GitHub Copilot (subscription, no key needed)",
44 api_base: "copilot",
45 needs_key: false,
46 },
47 ProviderPreset {
48 label: "OpenAI https://api.openai.com/v1",
49 api_base: "https://api.openai.com/v1",
50 needs_key: true,
51 },
52 ProviderPreset {
53 label: "Anthropic (claude-3-5-sonnet-20241022)",
54 api_base: "anthropic",
55 needs_key: true,
56 },
57 ProviderPreset {
58 label: "Google Gemini (gemini-2.0-flash)",
59 api_base: "gemini",
60 needs_key: true,
61 },
62 ProviderPreset {
63 label: "DeepSeek https://api.deepseek.com/v1",
64 api_base: "https://api.deepseek.com/v1",
65 needs_key: true,
66 },
67 ProviderPreset {
68 label: "Qwen (Alibaba Cloud) https://dashscope.aliyuncs.com/compatible-mode/v1",
69 api_base: "https://dashscope.aliyuncs.com/compatible-mode/v1",
70 needs_key: true,
71 },
72 ProviderPreset {
73 label: "GLM (Zhipu AI) https://open.bigmodel.cn/api/paas/v4",
74 api_base: "https://open.bigmodel.cn/api/paas/v4",
75 needs_key: true,
76 },
77 ProviderPreset {
78 label: "Ollama (local) http://localhost:11434/v1",
79 api_base: "http://localhost:11434/v1",
80 needs_key: false,
81 },
82 ProviderPreset {
83 label: "Custom URL…",
84 api_base: "",
85 needs_key: true,
86 },
87];
88
89pub fn show_command_menu() -> Option<String> {
91 commands::show_command_menu()
92}
93
94pub enum SessionPickResult {
95 NewSession,
97 Resume(Session),
99 Cancelled,
101}
102
103pub fn session_picker(store: &SessionStore) -> SessionPickResult {
105 use dialoguer::{theme::ColorfulTheme, Select};
107 let sessions = match store.list_sessions(30) {
108 Ok(s) => s,
109 Err(e) => {
110 err(&format!("Failed to load sessions: {:#}", e));
111 return SessionPickResult::Cancelled;
112 }
113 };
114 let mut labels: Vec<String> = vec![format!(" {} New session", style("+").green().bold())];
115 for s in &sessions {
116 let date = s.updated_at.format("%Y-%m-%d %H:%M").to_string();
117 let title = s.title.as_deref().unwrap_or("(untitled)");
118 let short_title = if title.chars().count() > 50 {
119 let end = title.char_indices().nth(49).map(|(i, _)| i).unwrap_or(title.len());
120 format!("{}…", &title[..end])
121 } else {
122 title.to_string()
123 };
124 labels.push(format!(" {} {}", style(date).dim(), short_title));
125 }
126 println!();
127 let selection = Select::with_theme(&ColorfulTheme::default())
128 .with_prompt("Session")
129 .items(&labels)
130 .default(0)
131 .interact_opt();
132 println!();
133 match selection {
134 Ok(Some(0)) => SessionPickResult::NewSession,
135 Ok(Some(i)) => SessionPickResult::Resume(sessions[i - 1].clone()),
136 _ => SessionPickResult::Cancelled,
137 }
138}
139
140pub fn connect_menu() -> Option<(String, String)> {
143 use dialoguer::{theme::ColorfulTheme, Select};
144 use std::io::{self, BufRead, Write};
145 println!();
146 info("Select a provider:");
147 println!();
148 let labels: Vec<&str> = PROVIDER_PRESETS.iter().map(|p| p.label).collect();
149 let selection = Select::with_theme(&ColorfulTheme::default())
150 .items(&labels)
151 .default(0)
152 .interact_opt();
153 let idx = match selection {
154 Ok(Some(i)) => i,
155 _ => {
156 info("Cancelled.");
157 return None;
158 }
159 };
160 let preset = &PROVIDER_PRESETS[idx];
161 println!();
162 let read_line = |prompt_str: &str| -> Option<String> {
164 print!("{} ", console::style(prompt_str).cyan());
165 io::stdout().flush().ok()?;
166 let stdin = io::stdin();
167 let mut line = String::new();
168 stdin.lock().read_line(&mut line).ok()?;
169 Some(line.trim().to_string())
170 };
171 let api_base = if preset.api_base.is_empty() {
172 match read_line(" API base URL:") {
173 Some(url) if !url.is_empty() => url,
174 Some(_) => { info("Cancelled."); return None; }
175 None => return None,
176 }
177 } else {
178 preset.api_base.to_string()
179 };
180 if api_base == COPILOT_API_BASE {
181 return Some(("copilot_do_login".to_string(), String::new()));
182 }
183 let api_key = if preset.needs_key {
184 match read_line(" API key:") {
185 Some(k) => {
186 if k.is_empty() {
187 warn("No key entered — provider set, but API calls will fail without a key.");
188 }
189 k
190 }
191 None => return None,
192 }
193 } else {
194 String::new()
195 };
196 Some((api_base, api_key))
197}
198
199#[allow(clippy::too_many_arguments)]
201pub async fn repl_command(
202 project: Option<PathBuf>,
203 no_sandbox: bool,
204 model: Option<String>,
205 provider_url: Option<String>,
206 api_key: Option<String>,
207 confirm: bool,
208 no_agents_md: bool,
209 compact: bool,
211 no_markdown: bool,
213) -> Result<()> {
214 use crate::agent::coder::run_plan_turn;
216 use crate::agent::director::Director;
217 use crate::agent::Agent;
218 use crate::auth;
219 use crate::context::update_session_title;
220 use crate::llm;
221 use crate::session::auto_title;
222 use crate::repl::input::{InputHistory, ReadResult};
223 use std::sync::mpsc;
224 use std::time::Duration;
225
226 let io: std::sync::Arc<dyn crate::io::AgentIO> = if confirm {
227 std::sync::Arc::new(crate::io::terminal::TerminalIO { no_markdown })
229 } else {
230 std::sync::Arc::new(crate::io::AutoApproveIO)
232 };
233 let mut ctx = AgentContext::new(
234 project,
235 no_sandbox,
236 model,
237 provider_url,
238 api_key,
239 compact,
240 io,
241 )
242 .await?;
243 let mut sess = ctx.store.create_session(Some("REPL session"))?;
244
245 let auth_status: String = if ctx.llm.is_copilot() {
247 match auth::CopilotOAuthToken::load() {
248 Ok(Some(_)) => style("GitHub Copilot ✓ authenticated").green().to_string(),
249 _ => style("GitHub Copilot ✗ not authenticated — run /login")
250 .yellow()
251 .to_string(),
252 }
253 } else if ctx.config.provider.api_key.is_empty() {
254 style("no API key — run /connect to configure")
255 .yellow()
256 .to_string()
257 } else {
258 format!(
259 "{} {}",
260 style("provider:").dim(),
261 style(&ctx.config.provider.api_base).cyan()
262 )
263 };
264
265 print_banner(
266 env!("CARGO_PKG_VERSION"),
267 &ctx.config.model,
268 &ctx.project_dir.display().to_string(),
269 &auth_status,
270 );
271
272 let mut history = InputHistory::new();
276 let history_path = dirs::data_local_dir()
277 .unwrap_or_else(|| PathBuf::from("."))
278 .join("xcode")
279 .join("repl_history.txt");
280 history.load_from_file(&history_path);
281
282 let director = Director::new(ctx.config.agent.clone());
283 let mut mode = ReplMode::Act;
284
285 let mut pending_line: Option<String> = None;
291
292 let mut conversation_messages: Vec<llm::Message> = Vec::new();
294
295 let coder_system_prompt = {
298 use crate::agent::agents_md::load_agents_md;
299 use crate::agent::coder::CoderAgent;
300 let agents_md = if no_agents_md {
303 None
304 } else {
305 load_agents_md(&ctx.project_dir)
306 };
307 if agents_md.is_some() {
308 eprintln!(" \u{1F4CB} Loaded project rules from AGENTS.md");
311 }
312 CoderAgent::new_with_agents_md(ctx.config.agent.clone(), agents_md).system_prompt()
313 };
314 let mut act_messages: Vec<llm::Message> = vec![llm::Message::system(&coder_system_prompt)];
315
316 let mut session_tracker = crate::tracking::SessionTracker::new(ctx.config.model.clone());
328 loop {
329 let prompt = match mode {
330 ReplMode::Act => format!("{} ", style("xcodeai›").cyan().bold()),
331 ReplMode::Plan => format!("{} ", style("[plan] xcodeai›").yellow().bold()),
332 };
333
334 let mcp_names: Vec<String> = ctx
348 .mcp_clients
349 .iter()
350 .map(|(name, _)| name.clone()) .collect();
352
353 let lsp_active = ctx
356 .tool_ctx
357 .lsp_client
358 .try_lock() .map(|guard| guard.is_some()) .unwrap_or(false); let lsp_server_name = ctx
364 .config
365 .lsp
366 .server_command
367 .as_deref() .unwrap_or(""); print_status_bar(&session_tracker, &mcp_names, lsp_active, lsp_server_name);
372
373 let line = if let Some(p) = pending_line.take() {
375 p
376 } else {
377 match input::readline_with_suggestions(&prompt, &mut history)
378 .map_err(anyhow::Error::from)?
379 {
380 ReadResult::Line(raw) => {
381 let trimmed = raw.trim().to_string();
382 if trimmed.is_empty() {
383 continue;
384 }
385 history.push(&trimmed);
386 trimmed
387 }
388 ReadResult::Interrupted => {
389 info("Ctrl-C — type /exit or press Ctrl-D to quit.");
390 continue;
391 }
392 ReadResult::Eof => break,
393 }
394 };
395
396 if line.starts_with('/')
405 || matches!(
406 line.as_str(),
407 "exit" | "quit" | "q" | "bye" | "bye!" | "exit!" | "quit!"
408 )
409 {
410 if line == "/"
415 || (line.starts_with('/')
416 && !line.starts_with("/model")
417 && !line.starts_with("/login")
418 && !line.starts_with("/undo")
419 && !matches!(
420 line.as_str(),
421 "/plan"
422 | "/act"
423 | "/tokens"
424 | "/session"
425 | "/connect"
426 | "/clear"
427 | "/help"
428 | "/logout"
429 | "/exit"
430 | "/quit"
431 | "/q"
432 ))
433 {
434 if let Some(chosen) = show_command_menu() {
436 history.push(&chosen);
437 pending_line = Some(chosen);
438 }
439 continue;
440 }
441 let sess_id = sess.id.clone();
445 handle_command(
446 &line,
447 &mut ReplState {
448 mode: &mut mode,
449 sess: &mut sess,
450 ctx: &mut ctx,
451 history: &mut history,
452 conversation_messages: &mut conversation_messages,
453 act_messages: &mut act_messages,
454 coder_system_prompt: &coder_system_prompt,
455 session_id: &sess_id,
456 session_tracker: &session_tracker,
457 },
458 )
459 .await?;
460 continue;
461 }
462 if !ctx.llm.is_copilot() && ctx.config.provider.api_key.is_empty() {
464 err("No API key configured. Run /connect to pick a provider.");
465 continue;
466 }
467 if ctx.llm.is_copilot() {
468 if let Ok(None) | Err(_) = auth::CopilotOAuthToken::load() {
469 err("Not authenticated with GitHub Copilot. Run /login first.");
470 continue;
471 }
472 }
473
474 ctx.store
476 .add_message(&sess.id, &llm::Message::user(&line))?;
477 let title = auto_title(&line);
478 let _ = ctx.store.update_session_timestamp(&sess.id);
479 let _ = update_session_title(&ctx.store, &sess.id, &title);
480
481 println!();
482 match mode {
483 ReplMode::Act => {
484 act_messages.push(llm::Message::user(&line));
486
487 let stash_ref = format!("xcodeai-undo-{}", uuid::Uuid::new_v4());
492 let short_desc: String = line.chars().take(80).collect();
495 let (stash_tx, stash_rx) = mpsc::channel::<std::io::Result<std::process::Output>>();
496 {
497 let project_dir_clone = ctx.project_dir.clone();
498 let stash_ref_clone = stash_ref.clone();
500 std::thread::spawn(move || {
501 let out = std::process::Command::new("git")
502 .args(["stash", "push", "-m", &stash_ref_clone])
503 .current_dir(&project_dir_clone)
504 .output();
505 let _ = stash_tx.send(out);
506 });
507 }
508
509 let result = director
511 .execute(
512 &mut act_messages,
513 ctx.registry.as_ref(),
514 ctx.llm.as_ref(),
515 &ctx.tool_ctx,
516 )
517 .await;
518
519 let stash_was_created = match stash_rx.recv_timeout(Duration::from_secs(30)) {
521 Ok(Ok(out)) if out.status.success() => {
522 let stdout = String::from_utf8_lossy(&out.stdout);
523 !stdout.contains("No local changes to save")
524 }
525 _ => false,
526 };
527 if stash_was_created {
530 let _ = ctx.store.push_undo(&sess.id, &stash_ref, &short_desc);
531 let _ = ctx
532 .store
533 .trim_undo_history(&sess.id, crate::session::store::MAX_UNDO_HISTORY);
534 }
535
536 println!();
537
538 match result {
539 Ok(mut agent_result) => {
540 ctx.store.add_message(
541 &sess.id,
542 &llm::Message::assistant(
543 Some(agent_result.final_message.clone()),
544 None,
545 ),
546 )?;
547 ctx.store.update_session_timestamp(&sess.id)?;
548
549 print_separator("done");
550
551 agent_result.tracker.model = ctx.config.model.clone();
554
555 for turn in &agent_result.tracker.turns {
558 session_tracker.record(Some(&crate::llm::Usage {
559 prompt_tokens: turn.prompt_tokens,
560 completion_tokens: turn.completion_tokens,
561 total_tokens: turn.prompt_tokens + turn.completion_tokens,
562 }));
563 }
564 session_tracker.model = ctx.config.model.clone();
566
567 let _ = ctx.store.update_session_tokens(
570 &sess.id,
571 agent_result.tracker.total_prompt_tokens(),
572 agent_result.tracker.total_completion_tokens(),
573 );
574
575 let mut stats_parts: Vec<String> = vec![
577 format!("{} iterations", agent_result.iterations),
578 format!("{} tool calls", agent_result.tool_calls_total),
579 ];
580 if agent_result.auto_continues > 0 {
581 stats_parts
582 .push(format!("{} auto-continues", agent_result.auto_continues));
583 }
584 let token_summary = agent_result.tracker.summary_line();
586 if !token_summary.is_empty() {
587 stats_parts.push(token_summary);
588 }
589 let stats_str = stats_parts
590 .iter()
591 .map(|s| format!("{}", style(s).dim()))
592 .collect::<Vec<_>>()
593 .join(&format!(" {} ", style("·").dim()));
594
595 println!(
596 " {} {} {} {}",
597 style("✓").green().bold(),
598 style("task complete").green(),
599 style("·").dim(),
600 stats_str,
601 );
602 print_separator("");
603
604 let diff_output = std::process::Command::new("git")
606 .args(["diff", "--stat", "HEAD"])
607 .current_dir(&ctx.project_dir)
608 .output();
609 if let Ok(out) = diff_output {
610 let text = String::from_utf8_lossy(&out.stdout);
611 let trimmed = text.trim();
612 if !trimmed.is_empty() {
613 println!(
614 " {} {}",
615 style("▸").dim(),
616 style("git diff --stat HEAD").dim()
617 );
618 for line in trimmed.lines() {
619 println!(" {}", style(line).dim());
620 }
621 println!();
622 }
623 }
624
625 println!();
626 }
627 Err(e) => {
628 act_messages.pop();
629 err(&format!("{:#}", e));
630 info("Try a different task, or type /exit to quit.");
631 }
632 }
633 }
634 ReplMode::Plan => {
635 conversation_messages.push(llm::Message::user(&line));
637
638 ctx.llm.set_stream_print(false);
640 let plan_result = run_plan_turn(
641 &conversation_messages,
642 ctx.llm.as_ref(),
643 ctx.registry.as_ref(),
644 &ctx.tool_ctx,
645 )
646 .await;
647 ctx.llm.set_stream_print(true);
648
649 match plan_result {
650 Ok(reply) => {
651 if !reply.trim().is_empty() {
652 println!("{}", reply.trim_end());
653 println!();
654 }
655
656 conversation_messages
658 .push(llm::Message::assistant(Some(reply.clone()), None));
659 ctx.store
660 .add_message(&sess.id, &llm::Message::assistant(Some(reply), None))?;
661 ctx.store.update_session_timestamp(&sess.id)?;
662 }
663 Err(e) => {
664 err(&format!("{:#}", e));
665 info("Plan mode error. Try again or type /act to switch back.");
666 conversation_messages.pop();
667 }
668 }
669 }
670 }
671 }
672
673 history.save_to_file(&history_path);
674 println!();
675 ok(&format!("Session saved: {}", style(&sess.id).dim()));
676 info(&format!("xcodeai session show {}", sess.id));
677 println!();
678
679 Ok(())
680}