Skip to main content

xcodeai/repl/
mod.rs

1// src/repl/mod.rs
2// REPL loop and related state for xcodeai interactive mode.
3// Extracted from main.rs for clarity and modularity.
4//
5// This module contains:
6// - REPL loop (repl_command)
7// - REPL state enums and structs
8// - Provider presets for /connect
9// - Session picker and connect menu
10// - crossterm-based readline with live slash-command suggestions (input.rs)
11//
12// For Rust learners: This file demonstrates how to organize a REPL loop and related state in a separate module. It also shows how to use enums and structs to manage interactive CLI state.
13
14use 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;
20// rustyline is no longer used — all input goes through src/repl/input.rs.
21use std::path::PathBuf;
22
23pub mod commands;
24pub mod input;
25use commands::{handle_command, ReplState};
26
27// SlashHelper and rustyline editor removed — superseded by crossterm readline.
28#[derive(Clone, Copy, PartialEq)]
29pub enum ReplMode {
30    Act,
31    Plan,
32}
33
34/// Built-in provider presets shown in /connect menu
35pub 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
89/// Show a /command picker. Returns the chosen command string (e.g. "/session"), or None if the user pressed Esc / Ctrl-C.
90pub fn show_command_menu() -> Option<String> {
91    commands::show_command_menu()
92}
93
94pub enum SessionPickResult {
95    /// User chose to start a brand-new session
96    NewSession,
97    /// User selected an existing session to resume
98    Resume(Session),
99    /// User cancelled (Esc)
100    Cancelled,
101}
102
103/// Show an interactive session picker.
104pub fn session_picker(store: &SessionStore) -> SessionPickResult {
105    // Implementation copied from main.rs
106    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
140/// Interactive /connect menu — lets user pick a provider from a numbered list.
141/// Prompts for URL and API key using plain stdin (no rustyline needed).
142pub 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    // Helper: read a line from stdin with a visible prompt.
163    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/// Main REPL loop for interactive mode.
200#[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    // When true, enables compact mode for this REPL session.
210    compact: bool,
211    // When true, disable markdown rendering of agent output in the terminal.
212    no_markdown: bool,
213) -> Result<()> {
214    // ─── REPL loop implementation ───────────────────────────────────────────────
215    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        // --confirm flag: prompt before destructive operations.
228        std::sync::Arc::new(crate::io::terminal::TerminalIO { no_markdown })
229    } else {
230        // Default: fully autonomous — auto-approve all destructive calls.
231        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    // Auth status string with colour
246    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    // ── Crossterm-based line editor with live slash-command suggestions ────
273    // `InputHistory` owns the in-process history list.  We load it from a
274    // text file at start and save it back on clean exit.
275    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    // When the command-menu returns a choice, we store it here instead of
286    // calling rl.readline() again.  At the top of every loop iteration we
287    // drain this slot first; only if it is empty do we ask readline for
288    // actual user input.  This eliminates the ~100-line duplicate dispatch
289    // block that previously lived inside the menu handler.
290    let mut pending_line: Option<String> = None;
291
292    // Plan mode conversation history (discuss, no tools)
293    let mut conversation_messages: Vec<llm::Message> = Vec::new();
294
295    // Act mode conversation history — seeded with CoderAgent system prompt.
296    // Persists across turns so the agent has full context of previous work.
297    let coder_system_prompt = {
298        use crate::agent::agents_md::load_agents_md;
299        use crate::agent::coder::CoderAgent;
300        // Load AGENTS.md unless --no-agents-md was passed.
301        // The content is prepended to the system prompt for the entire REPL session.
302        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            // We can't easily .await here inside a sync block, so we print directly.
309            // show_status uses eprintln internally; this is the REPL init path.
310            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    // ── Undo stack ────────────────────────────────────────────────────────────
317    // The undo history is persisted in the SQLite DB (undo_history table).
318    // Before each Act-mode run we push a unique stash label; after each run we
319    // record the label in the DB if git actually created a stash entry.
320    // /undo pops entries from the DB and calls `git stash pop stash@{N}` to
321    // restore the matching stash.
322    //
323    // If the project is not inside a git repo (or git is not installed) the
324    // commands fail silently and undo is simply unavailable.
325    //
326    // Session-level token tracker — accumulates usage across all task runs this REPL session.
327    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        // ── Status bar ─────────────────────────────────────────────────────────
335        // Print a compact info line above the prompt showing cumulative token
336        // usage, MCP servers, and LSP state.  Skipped on first prompt (no tokens).
337        //
338        // We gather the three pieces of data the bar needs:
339        //   1. session_tracker — already in scope above, accumulates token/cost.
340        //   2. MCP server names — from ctx.mcp_clients (a Vec of (name, Arc<Mutex<McpClient>>)).
341        //   3. LSP: check if lsp_client is Some, and what server_command is configured.
342        //
343        // NOTE: ctx.tool_ctx.lsp_client is Arc<Mutex<Option<LspClient>>>.
344        // We use try_lock() (non-blocking) to avoid deadlocking the async runtime.
345        // If the lock is held (very unlikely at prompt time), we conservatively
346        // assume no LSP is active rather than blocking.
347        let mcp_names: Vec<String> = ctx
348            .mcp_clients
349            .iter()
350            .map(|(name, _)| name.clone())  // extract just the display name
351            .collect();
352
353        // Check if the LSP client is currently running.
354        // try_lock() returns Ok(guard) immediately or Err if already locked.
355        let lsp_active = ctx
356            .tool_ctx
357            .lsp_client
358            .try_lock()  // non-blocking attempt
359            .map(|guard| guard.is_some())  // Some(LspClient) means it is running
360            .unwrap_or(false);  // if locked, assume not active (safe fallback)
361
362        // LSP server name from config (e.g. "rust-analyzer", or empty if not set).
363        let lsp_server_name = ctx
364            .config
365            .lsp
366            .server_command
367            .as_deref()  // Option<String> → Option<&str>
368            .unwrap_or("");  // use empty string when not configured
369
370        // Print the bar.  If there's nothing to show yet, this is a no-op.
371        print_status_bar(&session_tracker, &mcp_names, lsp_active, lsp_server_name);
372
373        // Drain a pending command from the menu, or read a new line from the user.
374        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        // ── REPL commands ──────────────────────────────────────────────────
397        // If the line starts with '/' or is a plain exit word, route it to
398        // handle_command().  The function returns:
399        //   Ok(None)          → command handled, continue the loop
400        //   Ok(Some(mode))    → mode changed (not used currently, mode is mutated in place)
401        //   Err(e)            → propagate fatal error
402        // Special case: '/' with a menu selection needs to buffer the chosen
403        // command into `pending_line` so the loop processes it next iteration.
404        if line.starts_with('/')
405            || matches!(
406                line.as_str(),
407                "exit" | "quit" | "q" | "bye" | "bye!" | "exit!" | "quit!"
408            )
409        {
410            // handle_command mutates mode/sess/ctx/messages in place.
411            // For the '/' bare-slash case, commands.rs returns the chosen label
412            // via rl history — but we need pending_line.  We special-case that
413            // here: if show_command_menu() returns something, buffer it.
414            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                // Unknown /xxx command — show the interactive picker.
435                if let Some(chosen) = show_command_menu() {
436                    history.push(&chosen);
437                    pending_line = Some(chosen);
438                }
439                continue;
440            }
441            // Clone the session ID before constructing ReplState so that
442            // Rust doesn't see simultaneous mutable (&mut sess) and
443            // immutable (&sess.id) borrows of the same binding.
444            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        // ── Lazy auth guard ────────────────────────────────────────
463        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        // ── Save message & run agent ───────────────────────────────
475        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                // Append the user message to Act-mode history before calling the agent.
485                act_messages.push(llm::Message::user(&line));
486
487                // ── Pre-run git stash (enables /undo) ────────────────
488                // Use a unique UUID-based label so we can identify this
489                // specific stash later, even if the user created their own
490                // stashes in the meantime.
491                let stash_ref = format!("xcodeai-undo-{}", uuid::Uuid::new_v4());
492                // Capture the first 80 chars of the user's message for the
493                // undo history description shown in `/undo list`.
494                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                    // Clone stash_ref into the thread so we can move it.
499                    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                // Stash is now running concurrently.  Start the agent immediately.
510                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                // Agent finished.  Collect the stash result (with a 30-second grace period).
520                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                // Persist the undo entry in the DB so the user can restore
528                // this state later with /undo (or /undo N).
529                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                        // Fill in model name now (CoderAgent left it empty) so
552                        // cost estimation in summary_line() can look up the price.
553                        agent_result.tracker.model = ctx.config.model.clone();
554
555                        // Merge this task's turns into the session-level tracker so
556                        // /tokens shows cumulative stats across all runs this session.
557                        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                        // Keep the session tracker's model current (user may have used /model).
565                        session_tracker.model = ctx.config.model.clone();
566
567                        // Persist the accumulated token counts for this task run to SQLite.
568                        // Non-fatal — don't crash on DB write failure, just silently ignore.
569                        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                        // Build a stats line showing iterations, tool calls, and auto-continues.
576                        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                        // Append token summary when the provider returned usage data.
585                        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                        // ── Git diff summary ──────────────────────
605                        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                // Add user message to plan conversation history
636                conversation_messages.push(llm::Message::user(&line));
637
638                // Disable streaming stdout so we can post-process the reply
639                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                        // Save to conversation history and session store.
657                        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}