Skip to main content

atomcode_tuix/event_loop/
commands.rs

1// crates/atomcode-tuix/src/event_loop/commands.rs
2//
3// Slash-command dispatcher. Everything the user can invoke by typing
4// `/name` lives here — built-in info commands, modal openers, the cd
5// helper, and the blocking OAuth flow that suspends the reader + renderer.
6//
7// New commands should be:
8//   1. Registered in `CommandRegistry::builtin` (crates/.../commands.rs)
9//   2. Added as an arm in `execute_slash_command` below
10//   3. Any long handler factored to a private helper in this file
11//
12// Modals open by pushing `Some(Box::new(...))` into `active_modal` — the
13// handler arms for `/model`, `/resume`, `/provider` show the pattern.
14
15use std::path::PathBuf;
16
17use super::{bg_runtime, save_and_reload, LoopCtx};
18use crate::i18n::{t, Msg};
19use crate::modals::{DirPicker, IssueWizard, LanguagePicker, Modal, ModelPicker, ProviderWizard, SessionPicker};
20use crate::render::{Renderer, UiLine};
21use crate::state::{AgentMode, UiState};
22use anyhow::Result;
23use atomcode_core::agent::AgentCommand;
24use atomcode_core::config::provider::ProviderConfig;
25use atomcode_core::config::Config;
26use atomcode_core::conversation::Conversation;
27use atomcode_core::session::{Session, SessionId, SessionManager};
28
29/// Maximum recent project dirs we keep in memory + persist to disk.
30const MAX_RECENT_DIRS: usize = 5;
31
32fn build_oauth_provider() -> ProviderConfig {
33    // Post-P3 cutover: default OAuth fallback now points at the new
34    // signed gateway (`llm-api.atomgit.com`). The legacy
35    // `api-ai.gitcode.com` host previously served plaintext as a
36    // fallback for open-source builds — that escape hatch closes here
37    // because the legacy host is now also signing-enforced (see
38    // `crypto::is_atomgit_gateway`). Open-source-build users hitting
39    // `/login` will see `CpOfficialBuildRequired` on their first chat;
40    // they must either install the official build or configure a
41    // third-party provider via `/provider`.
42    ProviderConfig {
43        provider_type: "openai".to_string(),
44        api_key: None,
45        model: "MiniMax-M2.7".to_string(),
46        base_url: Some("https://llm-api.atomgit.com/v1".to_string()),
47        system_prompt: None,
48        user_agent: None,
49        context_window: 64_000,
50        max_tokens: None,
51        thinking_type: None,
52        thinking_keep: None,
53        reasoning_history: None,
54        thinking_enabled: None,
55        thinking_budget: None,
56        skip_tls_verify: false,
57        ephemeral: false,
58
59}
60}
61
62fn foreground_state_from_ui(state: &UiState) -> bg_runtime::RuntimeState {
63    if matches!(
64        state.phase,
65        crate::state::UiPhase::Streaming | crate::state::UiPhase::Approval
66    ) {
67        bg_runtime::RuntimeState::Running
68    } else {
69        bg_runtime::RuntimeState::Idle
70    }
71}
72
73fn render_welcome(renderer: &mut dyn Renderer, ctx: &LoopCtx) {
74    let dir_display = crate::platform::collapse_home(&ctx.working_dir.to_string_lossy());
75    renderer.render(UiLine::Welcome {
76        model: ctx.model_name.clone(),
77        working_dir: dir_display,
78    });
79}
80
81fn bind_telemetry_to_session(ctx: &LoopCtx, session: &Session) {
82    if let Ok(uuid) = uuid::Uuid::parse_str(session.id.as_str()) {
83        ctx.telemetry.set_session_id(uuid);
84    }
85}
86
87/// Scan session messages for a pending tool approval — an
88/// `AssistantWithToolCalls` message whose tool calls lack corresponding
89/// `ToolResult` entries.  Returns `(display_name, detail)` of the first
90/// unpaired tool call, or `None` if all tool calls have results.
91fn find_pending_approval(session: &Session) -> Option<(String, String)> {
92    use atomcode_core::conversation::message::{MessageContent, Role};
93    use crate::event_loop::format_tool_detail;
94
95    // Collect all call_ids that already have a ToolResult.
96    let mut answered_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
97    for m in &session.messages {
98        if let (Role::Tool, MessageContent::ToolResult(r)) = (&m.role, &m.content) {
99            answered_ids.insert(r.call_id.clone());
100        }
101    }
102
103    // Walk messages in reverse to find the most recent unpaired tool call.
104    for m in session.messages.iter().rev() {
105        if let (
106            Role::Assistant,
107            MessageContent::AssistantWithToolCalls { tool_calls, .. },
108        ) = (&m.role, &m.content)
109        {
110            for tc in tool_calls.iter().rev() {
111                if !answered_ids.contains(&tc.id) {
112                    let display = super::display_tool_name(&tc.name);
113                    let detail = format_tool_detail(&tc.name, &tc.arguments);
114                    return Some((display, detail));
115                }
116            }
117        }
118    }
119    None
120}
121
122fn short_task_name(task: &str) -> String {
123    let first_line = task.lines().next().unwrap_or(task).trim();
124    let mut out: String = first_line.chars().take(80).collect();
125    if out.is_empty() {
126        out = "background task".to_string();
127    }
128    out
129}
130
131fn spawn_runtime(
132    ctx: &mut LoopCtx,
133    session: Session,
134) -> (
135    bg_runtime::RuntimeId,
136    atomcode_core::agent::AgentClient,
137    Session,
138) {
139    let runtime_id = ctx.bg_manager.allocate_runtime_id();
140    let (client, event_rx) = ctx.runtime_factory.spawn_runtime(Conversation::new());
141    bg_runtime::spawn_event_forwarder(runtime_id, event_rx, ctx.runtime_event_tx.clone());
142    (runtime_id, client, session)
143}
144
145/// Synchronise the current foreground session into `BgRuntimeManager`.
146///
147/// Mid-turn session state (including conversations where the agent is
148/// waiting for tool approval) is already persisted to
149/// `ctx.current_session` by `handle_agent_event` when it processes
150/// `AgentEvent::ApprovalNeeded` (which carries a snapshot of
151/// `conversation.messages`).  So by the time `/bg` runs,
152/// `ctx.current_session.messages` should be up-to-date.
153fn sync_bg_foreground(ctx: &mut LoopCtx) {
154    ctx.bg_manager.set_foreground_runtime(
155        ctx.foreground_runtime_id,
156        ctx.agent.clone(),
157        ctx.current_session.clone(),
158    );
159}
160
161// Historical note: there was a `const OAUTH_PROVIDER_NAME = "AtomGit"`
162// and a `build_oauth_provider` helper here. Both are owned by
163// `coding_plan::setup` now — `/login` is identity-only, provider
164// registration is the job of `/codingplan`.
165
166/// Maximum length for a session name.
167pub const MAX_SESSION_NAME_LEN: usize = 100;
168
169/// Validates a session name and returns an error message if invalid.
170/// Returns None if the name is valid.
171pub fn validate_session_name(name: &str) -> Option<String> {
172    let trimmed = name.trim();
173    if trimmed.is_empty() {
174        return Some(t(Msg::SessionNameEmpty).into_owned());
175    }
176    if trimmed.chars().count() > MAX_SESSION_NAME_LEN {
177        return Some(t(Msg::SessionNameTooLong { max: MAX_SESSION_NAME_LEN }).into_owned());
178    }
179    if trimmed.chars().any(char::is_control) {
180        return Some(t(Msg::SessionNameControlChars).into_owned());
181    }
182    None
183}
184
185/// Rename a session after validation, persist it, and return old/new names.
186pub fn perform_session_rename(
187    session_manager: &SessionManager,
188    session_id: &SessionId,
189    new_name: &str,
190) -> Result<(String, String), String> {
191    if let Some(err) = validate_session_name(new_name) {
192        return Err(err);
193    }
194    let new_name = new_name.trim().to_string();
195    let session = session_manager
196        .load(session_id)
197        .map_err(|e| t(Msg::SessionLoadFailed { error: &e.to_string() }).into_owned())?;
198    let old_name = session.name.clone();
199    let renamed_session = atomcode_core::session::Session {
200        name: new_name.clone(),
201        updated_at: std::time::SystemTime::now()
202            .duration_since(std::time::UNIX_EPOCH)
203            .map(|d| d.as_secs())
204            .unwrap_or(session.updated_at),
205        user_renamed: true,
206        ..session
207    };
208    session_manager
209        .save(&renamed_session)
210        .map_err(|e| t(Msg::SessionSaveFailed { error: &e.to_string() }).into_owned())?;
211    Ok((old_name, new_name))
212}
213
214/// Render the "Instruction files:" status block — the same one shown
215/// by `/status`, factored out so `/init` can also display it after
216/// writing `.atomcode.md` (so users see the new file appear under
217/// PROJECT immediately, rather than trusting the success message).
218fn render_instruction_status_block(working_dir: &std::path::Path) -> String {
219    use atomcode_core::config::instructions::LayeredInstructions;
220    let instructions = LayeredInstructions::load(working_dir);
221    let mut out = t(Msg::StatusInstructionFilesHeader).into_owned();
222    for (level, path) in instructions.status_lines() {
223        match path {
224            Some(p) => out.push_str(&t(Msg::StatusInstructionPresent {
225                path: &p.display().to_string(),
226                label: level.label(),
227            })),
228            None => out.push_str(&t(Msg::StatusInstructionMissing { label: level.label() })),
229        }
230    }
231    out
232}
233
234pub(super) fn execute_slash_command(
235    cmd: &str,
236    arg: &str,
237    state: &mut UiState,
238    ctx: &mut LoopCtx,
239    renderer: &mut dyn Renderer,
240    active_modal: &mut Option<Box<dyn Modal>>,
241    fixissue_pending: &mut Option<atomcode_core::atomgit::IssueRef>,
242    fixissue_buffer: &mut String,
243) -> Result<()> {
244    // `fixissue_pending` / `fixissue_buffer` no longer have a slash-command
245    // entry that consumes them (the `/fixissue` arm was removed; the
246    // `atomcode fixissue` CLI subcommand seeds these via cli/main.rs and
247    // event_loop/mod.rs's AgentEvent handler still drains them on
248    // TurnComplete). They stay in the signature so callers don't have to
249    // change, and so a future restoration of the slash command is a
250    // one-arm-add rather than a refactor.
251    let _ = (&fixissue_pending, &fixissue_buffer);
252
253    // Built-in commands are all lowercase ASCII; normalise the user's
254    // input so `/SESSION`, `/Session`, `/sEssIon` all hit the same arm
255    // as `/session`. `arg` is left untouched — paths / URLs are
256    // case-sensitive in general.
257    let cmd_lower = cmd.to_ascii_lowercase();
258    let cmd = cmd_lower.as_str();
259
260    // Emit use_command telemetry before dispatch so the event fires
261    // regardless of whether the command succeeds or errors out.
262    {
263        use atomcode_telemetry::Event;
264        let cmd_name = cmd.trim_start_matches('/').to_string();
265        ctx.telemetry.track(Event::UseCommand { type_: cmd_name, success: Some(true), error_kind: None, error_data: None });
266    }
267
268    match cmd {
269        "quit" | "exit" => {
270            ctx.agent.cmd_tx.send(AgentCommand::Shutdown).ok();
271        }
272        "help" => {
273            if arg.trim() == "commands" {
274                let config_dir = Config::config_dir();
275                let cmds = ctx.custom_commands.list();
276                let mut out = t(Msg::HelpCustomCommandsHeader).into_owned();
277                for cmd in &cmds {
278                    let source_label = if cmd.source.starts_with(&config_dir) {
279                        t(Msg::HelpSourceGlobal)
280                    } else {
281                        t(Msg::HelpSourceProject)
282                    };
283                    out.push_str(&format!(
284                        "    /{}  — {} ({})\n",
285                        cmd.name, cmd.description, source_label
286                    ));
287                }
288                if cmds.is_empty() {
289                    out.push_str(&t(Msg::HelpCustomNone));
290                    out.push_str(&t(Msg::HelpCustomCreateHint));
291                }
292                renderer.render(UiLine::CommandOutput(out));
293            } else {
294                renderer.render(UiLine::CommandOutput(ctx.commands.help_text()));
295            }
296            renderer.flush();
297        }
298        "keys" => {
299            // Dump the full keyboard-shortcut reference into scrollback.
300            // i18n string owns column alignment so translators can adjust
301            // per locale without touching this arm. /help complements
302            // this with the slash-command list.
303            renderer.render(UiLine::CommandOutput(
304                t(Msg::KeybindingsHelp).into_owned(),
305            ));
306            renderer.flush();
307        }
308        "plan" => {
309            state.agent_mode = AgentMode::Plan;
310            ctx.agent.cmd_tx.send(AgentCommand::SetPlanMode(true)).ok();
311            renderer.render(UiLine::CommandOutput(
312                t(Msg::CmdSwitchedPlanMode).into_owned(),
313            ));
314            renderer.flush();
315        }
316        "build" => {
317            state.agent_mode = AgentMode::Build;
318            ctx.agent.cmd_tx.send(AgentCommand::SetPlanMode(false)).ok();
319            renderer.render(UiLine::CommandOutput(
320                t(Msg::CmdSwitchedBuildMode).into_owned(),
321            ));
322            renderer.flush();
323        }
324        "config" => {
325            // Head: current active provider + config path so users know
326            // which provider is talking and where to edit.
327            let config_path = Config::default_path().display().to_string();
328            let mut txt = t(Msg::ConfigProviderLabel {
329                provider: &ctx.config.default_provider,
330                path: &config_path,
331            }).into_owned();
332            // Body: one minimal runnable example + pointer to the full
333            // reference so users know where to get Claude / OpenAI /
334            // Ollama variants without flooding the terminal here.
335            txt.push_str(
336                "  Example:\n\
337                 \n\
338                 ```toml\n\
339                 default_provider = \"deepseek\"\n\
340                 \n\
341                 [providers.deepseek]\n\
342                 type           = \"openai\"\n\
343                 api_key        = \"sk-...\"\n\
344                 model          = \"deepseek-chat\"\n\
345                 base_url       = \"https://api.deepseek.com/v1\"\n\
346                 context_window = 64000\n\
347                 ```\n\
348                 \n\
349                 Full reference: docs/config.example.toml (every field, every provider flavour).\n\
350                 Edit the file, then run /reload — no restart needed.\n",
351            );
352            renderer.render(UiLine::CommandOutput(txt));
353            renderer.flush();
354        }
355        "reload" => {
356            // Re-read ~/.atomcode/config.toml from disk and push it to the
357            // running daemon. Streaming-safe: the agent picks the new config
358            // up on the *next* turn; anything already in-flight finishes on
359            // the old config (ReloadConfig is queued behind the current
360            // AgentCommand stream, not a hot swap).
361            let path = Config::default_path();
362            match Config::load(&path) {
363                Ok(new_cfg) => {
364                    let new_default = new_cfg.default_provider.clone();
365                    let new_model = new_cfg
366                        .providers
367                        .get(&new_default)
368                        .map(|p| p.model.clone())
369                        .unwrap_or_else(|| new_default.clone());
370                    ctx.config = new_cfg.clone();
371                    ctx.runtime_factory.set_config(new_cfg.clone());
372                    ctx.model_name = new_model.clone();
373                    ctx.agent
374                        .cmd_tx
375                        .send(AgentCommand::ReloadConfig(new_cfg))
376                        .ok();
377                    renderer.render(UiLine::CommandOutput(
378                        t(Msg::CmdReloadDone {
379                            provider: &new_default, model: &new_model,
380                        }).into_owned(),
381                    ));
382                }
383                Err(e) => {
384                    let msg = format!("{}", e);
385                    renderer.render(UiLine::Error(
386                        t(Msg::CmdReloadFailed { error: &msg }).into_owned(),
387                    ));
388                }
389            }
390            renderer.flush();
391        }
392        "clear" => {
393            // Physical clear via the renderer (keeps cached footer state
394            // coherent with the terminal). Scrollback is preserved by
395            // most terminals — \x1b[3J would nuke it, which we don't
396            // want; `clear_screen` emits \x1b[2J\x1b[H.
397            renderer.clear_screen();
398            let dir_display = ctx.working_dir.to_string_lossy().to_string();
399            renderer.render(UiLine::Welcome {
400                model: ctx.model_name.clone(),
401                working_dir: dir_display,
402            });
403            renderer.flush();
404        }
405        "session" => {
406            // Start fresh: tell the agent to drop conversation history,
407            // clear the scrollback + type-ahead queue + UI state, and
408            // redraw the welcome screen so the user sees they're in a
409            // brand-new session. Ports `/session` from the legacy TUI.
410            ctx.agent.cmd_tx.send(AgentCommand::ClearConversation).ok();
411            ctx.current_session_id = None;
412            state.total_tokens = 0;
413            state.prompt_tokens = 0;
414            state.completion_tokens = 0;
415            state.cached_tokens = 0;
416            state.last_context = None;
417            state.pending_context_render = None;
418            state.thinking_idx = 0;
419            state.on_turn_complete();
420            // New session = new session file on disk. Old session
421            // (already saved at its last TurnComplete) stays on disk so
422            // it can still be `/resume`d; we just stop writing into it.
423            ctx.current_session =
424                atomcode_core::session::Session::default_session(ctx.working_dir.clone());
425            ctx.bg_manager
426                .set_foreground_session(ctx.current_session.clone());
427            // Bind telemetry session_id to the new session's UUID.
428            if let Ok(uuid) = uuid::Uuid::parse_str(ctx.current_session.id.as_str()) {
429                ctx.telemetry.set_session_id(uuid);
430            }
431            // `reset()` wipes the terminal AND the renderer's cached
432            // footer/stream state, so the next Welcome renders against
433            // a known (row 1, col 1) anchor. This is what makes
434            // /session behave like a fresh launch.
435            renderer.reset();
436            let dir_display = crate::platform::collapse_home(&ctx.working_dir.to_string_lossy());
437            renderer.render(UiLine::Welcome {
438                model: ctx.model_name.clone(),
439                working_dir: dir_display,
440            });
441            renderer.render(UiLine::CommandOutput(
442                t(Msg::CmdNewSession).into_owned(),
443            ));
444            renderer.flush();
445        }
446        "model" => {
447            if ctx.config.providers.is_empty() {
448                renderer.render(UiLine::CommandOutput(
449                    t(Msg::CmdNoProviders).into_owned(),
450                ));
451                renderer.flush();
452            } else {
453                *active_modal = Some(Box::new(ModelPicker::open(&ctx.config)));
454            }
455        }
456        "language" => {
457            if arg.is_empty() {
458                *active_modal = Some(Box::new(LanguagePicker::open()));
459            } else {
460                match arg.parse::<atomcode_core::locale::Locale>() {
461                    Ok(locale) => {
462                        crate::i18n::set_locale(locale);
463                        ctx.config.language = Some(locale);
464                        let config_path = atomcode_core::config::Config::default_path();
465                        if let Err(e) = ctx.config.save(&config_path) {
466                            // TODO: surface via renderer once a non-modal error display is available
467                            eprintln!("[language] failed to save config: {e}");
468                        }
469                        // Display label matches the picker's option list
470                        // so /language en and /language zh both echo a
471                        // human-readable name, not just the locale code.
472                        let label = match locale {
473                            atomcode_core::locale::Locale::En => "English",
474                            atomcode_core::locale::Locale::ZhCn => "简体中文",
475                        };
476                        renderer.render(UiLine::CommandOutput(
477                            t(Msg::LanguageSwitched {
478                                label,
479                                locale: &locale.to_string(),
480                            })
481                            .into_owned(),
482                        ));
483                        renderer.flush();
484                    }
485                    Err(_) => {
486                        let msg = t(Msg::ErrUnsupportedLocale { input: arg });
487                        renderer.render(UiLine::CommandOutput(format!("  {msg}\n")));
488                        renderer.flush();
489                    }
490                }
491            }
492        }
493        "resume" => match ctx.session_manager.list() {
494            Ok(all) => {
495                let sessions: Vec<_> = all.into_iter().filter(|s| s.message_count > 0).collect();
496                if sessions.is_empty() {
497                    renderer.render(UiLine::CommandOutput(
498                        t(Msg::CmdNoSessions).into_owned(),
499                    ));
500                    renderer.flush();
501                } else {
502                    *active_modal = Some(Box::new(SessionPicker::open(sessions)));
503                }
504            }
505            Err(e) => {
506                renderer.render(UiLine::Error(
507                    t(Msg::SessionListFailed { error: &e.to_string() }).into_owned(),
508                ));
509                renderer.flush();
510            }
511        },
512        "rename" => {
513            // Rename targets `ctx.current_session` (the in-flight conversation),
514            // not whichever id `/resume` last loaded — the user expects /rename
515            // to relabel the conversation they're currently typing into. The
516            // session is always initialised at startup, so we never need a
517            // "load a session first" fallback.
518            if let Some(err) = validate_session_name(arg) {
519                renderer.render(UiLine::Error(err));
520                renderer.flush();
521            } else {
522                let old_name = ctx.current_session.name.clone();
523                let new_name = arg.trim().to_string();
524                ctx.current_session.rename(new_name.clone());
525                match ctx.session_manager.save(&ctx.current_session) {
526                    Ok(()) => {
527                        renderer.render(UiLine::CommandOutput(
528                            t(Msg::SessionRenamed { old: &old_name, new: &new_name })
529                                .into_owned(),
530                        ));
531                        renderer.flush();
532                    }
533                    Err(e) => {
534                        // Revert the in-memory rename so a follow-up retry
535                        // still reports the original name.
536                        ctx.current_session.name = old_name;
537                        renderer.render(UiLine::Error(
538                            t(Msg::SessionSaveFailed { error: &e.to_string() })
539                                .into_owned(),
540                        ));
541                        renderer.flush();
542                    }
543                }
544            }
545        }
546        "provider" => {
547            *active_modal = Some(Box::new(ProviderWizard::MainMenu { selected: 0 }));
548            renderer.render(UiLine::CommandOutput(
549                t(Msg::ProviderWizardHeader).into_owned(),
550            ));
551            renderer.flush();
552        }
553        "status" => {
554            let mut txt = t(Msg::StatusBody {
555                model: &ctx.model_name,
556                dir: &ctx.working_dir.display().to_string(),
557                config: &Config::default_path().display().to_string(),
558                tokens: state.total_tokens,
559            }).into_owned();
560            txt.push_str(&render_codingplan_status_for_status_cmd());
561
562            txt.push('\n');
563            txt.push_str(&render_instruction_status_block(&ctx.working_dir));
564
565            renderer.render(UiLine::CommandOutput(txt));
566            renderer.flush();
567        }
568        "diff" => {
569            let out = std::process::Command::new("git")
570                .args(["diff", "--stat"])
571                .current_dir(&ctx.working_dir)
572                .output();
573            match out {
574                Ok(o) => {
575                    let s = String::from_utf8_lossy(&o.stdout).to_string();
576                    renderer.render(UiLine::CommandOutput(if s.is_empty() {
577                        t(Msg::CmdNoChanges).into_owned()
578                    } else {
579                        s
580                    }));
581                }
582                Err(e) => {
583                    renderer.render(UiLine::Error(t(Msg::DiffFailed { error: &format!("{}", e) }).into_owned()));
584                }
585            }
586            renderer.flush();
587        }
588        "undo" => {
589            renderer.render(UiLine::CommandOutput(
590                t(Msg::CmdUndoNotSupported).into_owned(),
591            ));
592            renderer.flush();
593        }
594        "cost" => {
595            let total = state.prompt_tokens + state.completion_tokens;
596            let cache_rate = if state.prompt_tokens > 0 {
597                ((state.cached_tokens as f64 / state.prompt_tokens as f64 * 100.0) + 0.5) as usize
598            } else {
599                0
600            };
601            let cost = atomcode_core::pricing::calculate_cost(
602                &ctx.model_name,
603                state.prompt_tokens,
604                state.completion_tokens,
605                state.cached_tokens,
606            );
607            let cost_str = atomcode_core::pricing::format_cost(cost);
608            renderer.render(UiLine::CommandOutput(
609                t(Msg::CostReport {
610                    prompt: state.prompt_tokens,
611                    completion: state.completion_tokens,
612                    cached: state.cached_tokens,
613                    cache_rate,
614                    total,
615                    cost: &cost_str,
616                }).into_owned(),
617            ));
618            renderer.flush();
619        }
620        "context" => {
621            // `/context` = breakdown only.
622            // `/context prompt` = breakdown + full assembled system prompt
623            // (the exact bytes the most recent turn sent). Useful when
624            // the model is misbehaving and you want to verify what's
625            // actually in the prompt.
626            //
627            // The cached ContextSnapshot only refreshes on LLM round-trips.
628            // Between turns — or after out-of-turn mutations like
629            // `inject_post_compress_state` — the cache lags the actual
630            // conversation. Dispatch a refresh and render when the
631            // resulting rich stats event lands (see `handle_agent_event`
632            // → `AgentEvent::ContextStats`). `pending_context_render =
633            // Some(show_prompt)` marks the pending request; cleared after
634            // the event handler fires the report. If the agent is busy
635            // in a turn, the next rich emission (at the next LLM call)
636            // serves the render — still fresh, just a tick later.
637            let show_prompt = arg.trim().eq_ignore_ascii_case("prompt");
638            state.pending_context_render = Some(show_prompt);
639            ctx.agent
640                .cmd_tx
641                .send(AgentCommand::RefreshContextStats)
642                .ok();
643        }
644        "compact" => {
645            let prompt = (!arg.trim().is_empty()).then(|| arg.trim().to_string());
646            // Agent streams the authoritative result back as TextDelta
647            // ("nothing to compact" / "compacted — dropped N messages").
648            // Don't pre-render a placeholder — the agent's reply could
649            // contradict it when the conversation is too short.
650            ctx.agent.cmd_tx.send(AgentCommand::Compact { prompt }).ok();
651        }
652        "remember" => {
653            let text = arg.trim();
654            if text.is_empty() {
655                renderer.render(UiLine::Error(t(Msg::RememberUsage).into_owned()));
656
657                renderer.flush();
658            } else {
659                let (content, global) = if text.starts_with("--global ") {
660                    (text[9..].trim().to_string(), true)
661                } else {
662                    (text.to_string(), false)
663                };
664                if content.is_empty() {
665                    renderer.render(UiLine::Error(t(Msg::RememberUsage).into_owned()));
666
667                    renderer.flush();
668                } else {
669                    ctx.agent
670                        .cmd_tx
671                        .send(AgentCommand::Remember { content, global })
672                        .ok();
673                }
674            }
675        }
676        "forget" => {
677            let keyword = arg.trim();
678            if keyword.is_empty() {
679                renderer.render(UiLine::Error(t(Msg::ForgetUsage).into_owned()));
680                renderer.flush();
681            } else {
682                ctx.agent
683                    .cmd_tx
684                    .send(AgentCommand::Forget {
685                        keyword: keyword.to_string(),
686                    })
687                    .ok();
688            }
689        }
690        "memory" => {
691            ctx.agent.cmd_tx.send(AgentCommand::ShowMemory).ok();
692        }
693        "login" => {
694            run_login_flow(renderer, ctx)?;
695        }
696        "codingplan" => {
697            run_codingplan_flow(renderer, ctx)?;
698        }
699        "logout" => {
700            // /logout only invalidates the OAuth token on disk.
701            // Provider config is a user asset and stays in config.toml
702            // untouched — if the user's default is an AtomGit* provider,
703            // the next LLM request fails with a "re-run /codingplan"
704            // hint instead of the TUI crashing on next startup because
705            // `default_provider` got cleared.
706            match atomcode_core::auth::logout() {
707                Ok(()) => {
708                    ctx.telemetry.set_account_id(None);
709                    let _ = ctx
710                        .agent
711                        .cmd_tx
712                        .send(AgentCommand::ReloadConfig(ctx.config.clone()));
713                    renderer.render(UiLine::CommandOutput(
714                        t(Msg::CmdLogoutDone).into_owned(),
715                    ));
716                }
717                Err(e) => {
718                    let msg = format!("{}", e);
719                    renderer.render(UiLine::Error(
720                        t(Msg::CmdLogoutFailed { error: &msg }).into_owned(),
721                    ));
722                }
723            }
724            renderer.flush();
725        }
726        "whoami" => {
727            let txt = if let Some(auth) = atomcode_core::auth::get_stored_auth() {
728                let email = auth.user.email.as_deref().unwrap_or("—");
729                let name = auth.user.name.as_deref().unwrap_or(&auth.user.username);
730                format!(
731                    "  {} ({})\n  {}\n  auth: {}\n",
732                    name,
733                    auth.user.username,
734                    email,
735                    atomcode_core::auth::auth_file_path().display(),
736                )
737            } else {
738                t(Msg::CmdWhoamiNotSignedIn).into_owned()
739            };
740            renderer.render(UiLine::CommandOutput(txt));
741            renderer.flush();
742        }
743        "upgrade" => {
744            // Sub-dispatch: `/upgrade`, `/upgrade rollback`, `/upgrade --force`.
745            // Keep parsing deliberately tolerant — users type these things
746            // with assorted capitalization and whitespace; a command that
747            // refuses `/upgrade Rollback` is user-hostile.
748            let arg_norm = arg.trim().to_ascii_lowercase();
749            if arg_norm == "rollback" {
750                // Rollback is sync and fast (three renames). Run inline
751                // so the user sees the result immediately without waiting
752                // for an async task to schedule.
753                match atomcode_core::self_update::run_rollback() {
754                    Ok(sum) => {
755                        // Route through the event channel so rendering
756                        // and "set done → exit" logic stays in one place.
757                        let _ = ctx.upgrade_tx.send(
758                            atomcode_core::self_update::UpgradeEvent::RolledBack {
759                                exe: sum.exe,
760                                backup: sum.backup,
761                            },
762                        );
763                    }
764                    Err(e) => {
765                        let _ =
766                            ctx.upgrade_tx
767                                .send(atomcode_core::self_update::UpgradeEvent::Failed(format!(
768                                    "{:#}",
769                                    e
770                                )));
771                    }
772                }
773            } else {
774                let force = arg_norm == "--force" || arg_norm == "-f";
775                if !force && !arg_norm.is_empty() {
776                    renderer.render(UiLine::Error(
777                        t(Msg::UpgradeUnknownArg { arg }).into_owned(),
778                    ));
779                    renderer.flush();
780                    return Ok(());
781                }
782                renderer.render(UiLine::CommandOutput(
783                    t(Msg::CmdCheckingUpdate).into_owned(),
784                ));
785                renderer.flush();
786                let current = format!("v{}", env!("CARGO_PKG_VERSION"));
787                let tx = ctx.upgrade_tx.clone();
788                tokio::spawn(async move {
789                    // The driver emits Done via `tx` on success; on error
790                    // we translate to a Failed event so the TUI layer
791                    // only has to handle one event stream.
792                    if let Err(e) =
793                        atomcode_core::self_update::run_upgrade(current, force, tx.clone()).await
794                    {
795                        let _ = tx.send(atomcode_core::self_update::UpgradeEvent::Failed(format!(
796                            "{:#}",
797                            e
798                        )));
799                    }
800                });
801            }
802        }
803        "issue" => {
804            // Two-step wizard to file a NEW issue against the **atomcode
805            // upstream repo** (atomgit_atomcode/atomcode), NOT against
806            // the user's current working project. Use case is in-tool
807            // bug reports / feature requests for atomcode itself; using
808            // cwd would be confusing (a user reporting an atomcode bug
809            // while in some unrelated repo would land their issue in
810            // the wrong place, or get blocked by cwd validation).
811            //
812            // Step 1 collects a title (required), step 2 collects a
813            // description (required, Shift+Enter for newlines). On
814            // submit the event loop's post-close branch POSTs
815            // `/repos/atomgit_atomcode/atomcode/issues` and echoes the
816            // new issue URL into scrollback.
817            let _ = arg; // reserved for future options (e.g. --template)
818            let mut wiz = IssueWizard::open(
819                atomcode_core::atomgit::UPSTREAM_OWNER.to_string(),
820                atomcode_core::atomgit::UPSTREAM_REPO.to_string(),
821            );
822            wiz.emit_prompt(renderer);
823            *active_modal = Some(Box::new(wiz));
824        }
825        "cd" => {
826            // Bare `/cd` — open the interactive history picker (matches legacy
827            // TUI behaviour). The picker's Enter-handler invokes `apply_cd`
828            // itself, so there's nothing else to do here.
829            if arg.is_empty() {
830                if ctx.recent_dirs.is_empty() {
831                    let cwd = ctx.working_dir.display().to_string();
832                    renderer.render(UiLine::CommandOutput(
833                        t(Msg::CdWorkingDir { cwd: &cwd }).into_owned(),
834                    ));
835                    renderer.flush();
836                } else {
837                    *active_modal = Some(Box::new(DirPicker::open(
838                        ctx.recent_dirs.clone(),
839                        ctx.working_dir.clone(),
840                    )));
841                }
842                return Ok(());
843            }
844            let new_dir = resolve_cd(arg, &ctx.working_dir, ctx.previous_dir.as_deref());
845            match new_dir {
846                Ok(path) => {
847                    apply_cd(ctx, path.clone());
848                    let p = path.display().to_string();
849                    renderer.render(UiLine::CommandOutput(
850                        t(Msg::DirChanged { path: &p }).into_owned(),
851                    ));
852                }
853                Err(e) => {
854                    renderer.render(UiLine::Error(e));
855                }
856            }
857            renderer.flush();
858        }
859        "bg" => {
860            match bg_runtime::parse_bg_command(arg) {
861                bg_runtime::BgCommand::Help => {
862                    renderer.render(UiLine::CommandOutput(bg_runtime::render_bg_help()));
863                }
864                bg_runtime::BgCommand::List => {
865                    renderer.render(UiLine::CommandOutput(bg_runtime::render_bg_list(
866                        ctx.bg_manager.backgrounds(),
867                    )));
868                }
869                bg_runtime::BgCommand::BackgroundCurrent => {
870                    sync_bg_foreground(ctx);
871                    if !ctx.bg_manager.has_capacity() {
872                        renderer.render(UiLine::Error(
873                            t(Msg::BgSlotLimitReached { max: bg_runtime::MAX_BACKGROUND_SLOTS }).into_owned(),
874                        ));
875                        renderer.flush();
876                        return Ok(());
877                    }
878                    let old_short_id = ctx.current_session.short_id().to_string();
879                    let new_session = Session::default_session(ctx.working_dir.clone());
880                    let new_short_id = new_session.short_id().to_string();
881                    let (runtime_id, client, new_session) = spawn_runtime(ctx, new_session);
882                    let old_state = foreground_state_from_ui(state);
883                    let slot = match ctx.bg_manager.background_current(
884                        client.clone(),
885                        new_session.clone(),
886                        runtime_id,
887                        old_state,
888                    ) {
889                        Ok(slot) => slot,
890                        Err(bg_runtime::BgError::SlotLimit { max }) => {
891                            renderer.render(UiLine::Error(
892                                t(Msg::BgSlotLimitReached { max }).into_owned(),
893                            ));
894                            renderer.flush();
895                            return Ok(());
896                        }
897                        Err(bg_runtime::BgError::InvalidSlot { .. }) => unreachable!(),
898                    };
899
900                    ctx.agent = client;
901                    ctx.foreground_runtime_id = runtime_id;
902                    ctx.current_session = new_session;
903                    bind_telemetry_to_session(ctx, &ctx.current_session);
904                    state.on_turn_complete();
905                    renderer.reset();
906                    render_welcome(renderer, ctx);
907                    renderer.render(UiLine::CommandOutput(
908                        t(Msg::BgBackgroundCurrent {
909                            new_id: &new_short_id,
910                            slot,
911                            old_id: &old_short_id,
912                            state: &old_state.localised(),
913                        }).into_owned(),
914                    ));
915                }
916                bg_runtime::BgCommand::Resume(slot) => {
917                    sync_bg_foreground(ctx);
918                    let outcome = match ctx
919                        .bg_manager
920                        .resume_slot(slot, foreground_state_from_ui(state))
921                    {
922                        Ok(outcome) => outcome,
923                        Err(bg_runtime::BgError::InvalidSlot { slot, len }) => {
924                            renderer.render(UiLine::Error(
925                                t(Msg::BgInvalidSlot { slot, available: len }).into_owned(),
926                            ));
927                            renderer.flush();
928                            return Ok(());
929                        }
930                        Err(bg_runtime::BgError::SlotLimit { max }) => {
931                            renderer.render(UiLine::Error(
932                                t(Msg::BgSlotLimitReached { max }).into_owned(),
933                            ));
934                            renderer.flush();
935                            return Ok(());
936                        }
937                    };
938                    let Some(client) = outcome.resumed_client else {
939                        renderer.render(UiLine::Error(
940                            t(Msg::BgNoRuntimeClient).into_owned(),
941                        ));
942                        renderer.flush();
943                        return Ok(());
944                    };
945
946                    ctx.agent = client;
947                    ctx.foreground_runtime_id = outcome.resumed_runtime_id;
948                    ctx.current_session = outcome.resumed_session;
949                    bind_telemetry_to_session(ctx, &ctx.current_session);
950                    state.on_turn_complete();
951                    crate::modals::session_picker::replay_session(
952                        renderer,
953                        &ctx.current_session,
954                        true,
955                    );
956
957                    // If the resumed session was waiting for tool approval,
958                    // re-render the approval prompt so the user can
959                    // continue interacting.  Detect this by looking for
960                    // an AssistantWithToolCalls message whose tool_calls
961                    // lack corresponding ToolResult entries.
962                    let pending_approval = find_pending_approval(&ctx.current_session);
963                    if let Some((tool_name, detail)) = pending_approval {
964                        renderer.render(UiLine::ApprovalPrompt { tool: tool_name, detail });
965                        state.on_approval_needed("");
966                    }
967
968                    let short_id = ctx.current_session.short_id().to_string();
969                    let mut msg = t(Msg::BgResumed { slot, short_id: &short_id }).into_owned();
970                    if let Some(previous_slot) = outcome.previous_foreground_slot {
971                        msg.push_str(&t(Msg::BgPreviousForegroundMoved { slot: previous_slot }).into_owned());
972                    }
973                    renderer.render(UiLine::CommandOutput(msg));
974                }
975                bg_runtime::BgCommand::Drop(slot) => {
976                    let dropped = match ctx.bg_manager.drop_slot(slot) {
977                        Ok(dropped) => dropped,
978                        Err(bg_runtime::BgError::InvalidSlot { slot, len }) => {
979                            renderer.render(UiLine::Error(
980                                t(Msg::BgInvalidSlot { slot, available: len }).into_owned(),
981                            ));
982                            renderer.flush();
983                            return Ok(());
984                        }
985                        Err(bg_runtime::BgError::SlotLimit { .. }) => unreachable!(),
986                    };
987                    if matches!(dropped.state, bg_runtime::RuntimeState::Running) {
988                        if let Some(client) = dropped.client.as_ref() {
989                            client.cmd_tx.send(AgentCommand::Cancel).ok();
990                        }
991                    }
992                    if !dropped.session.messages.is_empty() {
993                        let _ = ctx.session_manager.save(&dropped.session);
994                    }
995                    let short_id = dropped.session.short_id().to_string();
996                    renderer.render(UiLine::CommandOutput(
997                        t(Msg::BgDropped { slot, short_id: &short_id }).into_owned(),
998                    ));
999                }
1000            }
1001            renderer.flush();
1002        }
1003        "background" => {
1004            // Compatibility wrapper around `/bg`: start a one-shot task in a
1005            // real background runtime, keep the current foreground active.
1006            let task = arg.trim();
1007            if task.is_empty() {
1008                renderer.render(UiLine::CommandOutput(
1009                    t(Msg::BackgroundUsage).into_owned(),
1010                ));
1011                renderer.flush();
1012                return Ok(());
1013            }
1014            if !ctx.bg_manager.has_capacity() {
1015                renderer.render(UiLine::Error(
1016                    t(Msg::BgSlotLimitReached { max: bg_runtime::MAX_BACKGROUND_SLOTS }).into_owned(),
1017                ));
1018                renderer.flush();
1019                return Ok(());
1020            }
1021            let mut session = Session::default_session(ctx.working_dir.clone());
1022            session.name = short_task_name(task);
1023            let short_id = session.short_id().to_string();
1024            let (runtime_id, client, session) = spawn_runtime(ctx, session);
1025            let slot = match ctx.bg_manager.push_background_runtime(
1026                runtime_id,
1027                client.clone(),
1028                session,
1029                bg_runtime::RuntimeState::Running,
1030            ) {
1031                Ok(slot) => slot,
1032                Err(bg_runtime::BgError::SlotLimit { max }) => {
1033                    renderer.render(UiLine::Error(
1034                        t(Msg::BgSlotLimitReached { max }).into_owned(),
1035                    ));
1036                    renderer.flush();
1037                    return Ok(());
1038                }
1039                Err(bg_runtime::BgError::InvalidSlot { .. }) => unreachable!(),
1040            };
1041            client
1042                .cmd_tx
1043                .send(AgentCommand::SendMessage { text: task.to_string(), images: Vec::new(), image_markers: Vec::new() })
1044                .ok();
1045            renderer.render(UiLine::CommandOutput(
1046                t(Msg::BgTaskStarted { slot, short_id: &short_id }).into_owned(),
1047            ));
1048            renderer.flush();
1049        }
1050        "init" => {
1051            // Generate .atomcode.md from project structure. Refuses to
1052            // overwrite by default — `/init --force` opts in. The file is
1053            // picked up by agent::prompt next time the system prompt is
1054            // built; in-flight turns finish on the old prompt.
1055            let target = ctx.working_dir.join(".atomcode.md");
1056            let force = matches!(arg.trim(), "--force" | "force");
1057            if target.exists() && !force {
1058                let path_str = target.display().to_string();
1059                renderer.render(UiLine::CommandOutput(
1060                    t(Msg::InitAlreadyExists { path: &path_str }).into_owned(),
1061                ));
1062                renderer.flush();
1063                return Ok(());
1064            }
1065            let content = atomcode_core::init::generate_project_instructions(&ctx.working_dir);
1066            match std::fs::write(&target, &content) {
1067                Ok(()) => {
1068                    let path_str = target.display().to_string();
1069                    renderer.render(UiLine::CommandOutput(
1070                        t(Msg::InitWrote { path: &path_str, bytes: content.len() }).into_owned(),
1071                    ));
1072                    // Confirm the file is reachable for the prompt-builder by
1073                    // re-running the same load that `/status` uses. If the
1074                    // freshly written file does NOT appear under PROJECT here,
1075                    // the user knows immediately — instead of asking the AI
1076                    // a question and trying to infer load state from its
1077                    // answer.
1078                    renderer.render(UiLine::CommandOutput(
1079                        render_instruction_status_block(&ctx.working_dir),
1080                    ));
1081                }
1082                Err(e) => {
1083                    renderer.render(UiLine::Error(
1084                        t(Msg::InitFailed { error: &format!("{}", e) }).into_owned(),
1085                    ));
1086                }
1087            }
1088            renderer.flush();
1089        }
1090        "mcp" => {
1091            let sub = arg.trim();
1092            if let Some(rest) = sub.strip_prefix("login") {
1093                let server = rest.trim();
1094                if server.is_empty() {
1095                    renderer.render(UiLine::CommandOutput(
1096                        t(Msg::McpOAuthLoginUsage).into_owned(),
1097                    ));
1098                    renderer.flush();
1099                    return Ok(());
1100                }
1101                let configs = match atomcode_core::mcp::load_mcp_config(&ctx.working_dir) {
1102                    Ok(configs) => configs,
1103                    Err(e) => {
1104                        renderer.render(UiLine::Error(
1105                            t(Msg::McpOAuthLoadConfigFailed { error: &format!("{:#}", e) }).into_owned(),
1106                        ));
1107                        renderer.flush();
1108                        return Ok(());
1109                    }
1110                };
1111                let Some(config) = configs.into_iter().find(|config| config.name == server) else {
1112                    renderer.render(UiLine::Error(
1113                        t(Msg::McpOAuthServerNotFound { server }).into_owned(),
1114                    ));
1115                    renderer.flush();
1116                    return Ok(());
1117                };
1118                renderer.render(UiLine::CommandOutput(
1119                    t(Msg::McpOAuthStarting { server }).into_owned(),
1120                ));
1121                renderer.flush();
1122                let is_github_server = matches!(
1123                    &config.config,
1124                    atomcode_core::mcp::McpTransportConfig::Http {
1125                        auth: Some(atomcode_core::mcp::McpHttpAuthConfig::OAuth(auth)),
1126                        ..
1127                    } if auth.provider.as_deref() == Some("github")
1128                );
1129                let result = tokio::task::block_in_place(|| {
1130                    atomcode_core::mcp::login_mcp_oauth(
1131                        &config,
1132                        atomcode_core::mcp::McpOAuthLoginOptions {
1133                            client_id: if is_github_server {
1134                                std::env::var("ATOMCODE_GITHUB_MCP_CLIENT_ID").ok()
1135                            } else {
1136                                None
1137                            },
1138                            client_secret_env: None,
1139                            scopes: Vec::new(),
1140                        },
1141                    )
1142                });
1143                match result {
1144                    Ok(token) => renderer.render(UiLine::CommandOutput(
1145                        t(Msg::McpOAuthSaved { provider: &token.provider, server }).into_owned(),
1146                    )),
1147                    Err(e) => renderer.render(UiLine::Error(
1148                        t(Msg::McpOAuthFailed { error: &format!("{:#}", e) }).into_owned(),
1149                    )),
1150                }
1151                renderer.flush();
1152                return Ok(());
1153            }
1154
1155            if let Some(rest) = sub.strip_prefix("logout") {
1156                let server = rest.trim();
1157                if server.is_empty() {
1158                    renderer.render(UiLine::CommandOutput(
1159                        t(Msg::McpOAuthLogoutUsage).into_owned(),
1160                    ));
1161                    renderer.flush();
1162                    return Ok(());
1163                }
1164                match atomcode_core::mcp::McpTokenStore::default().delete_token(server) {
1165                    Ok(true) => renderer.render(UiLine::CommandOutput(
1166                        t(Msg::McpOAuthTokenRemoved { server }).into_owned(),
1167                    )),
1168                    Ok(false) => renderer.render(UiLine::CommandOutput(
1169                        t(Msg::McpOAuthNoToken { server }).into_owned(),
1170                    )),
1171                    Err(e) => renderer.render(UiLine::Error(
1172                        t(Msg::McpOAuthLogoutFailed { error: &format!("{:#}", e) }).into_owned(),
1173                    )),
1174                }
1175                renderer.flush();
1176                return Ok(());
1177            }
1178
1179            if sub.eq_ignore_ascii_case("reload") {
1180                // Preflight: parse merged MCP config so we can show progress immediately.
1181                // (Connection attempts happen in background and may take up to timeout_ms.)
1182                let configs = match atomcode_core::mcp::load_mcp_config(&ctx.working_dir) {
1183                    Ok(c) => c,
1184                    Err(e) => {
1185                        renderer.render(UiLine::Error(
1186                            t(Msg::McpReloadFailed { error: &format!("{:#}", e) }).into_owned(),
1187                        ));
1188                        renderer.flush();
1189                        return Ok(());
1190                    }
1191                };
1192
1193                let mut header = t(Msg::McpReloading { count: configs.len() }).into_owned();
1194
1195                if !configs.is_empty() {
1196                    header.push_str(&t(Msg::McpConnecting));
1197                    for c in &configs {
1198                        header.push_str(&t(Msg::McpConnectingServer { name: &c.name }));
1199                    }
1200                } else {
1201                    header.push_str(&t(Msg::McpNoServersConfigured));
1202                }
1203                renderer.render(UiLine::CommandOutput(header));
1204                renderer.flush();
1205
1206                // 1) Drop all previously-registered MCP tools so any adapters holding the
1207                // old registry Arc are released and stdio child processes can be killed.
1208                let removed = tokio::task::block_in_place(|| {
1209                    tokio::runtime::Handle::current().block_on(async {
1210                        ctx.agent.tool_registry.unregister_prefix("mcp__").await
1211                    })
1212                });
1213
1214                // 2) Drop old registry + event receiver (stop consuming old events).
1215                ctx.mcp_connect_rx = None;
1216                ctx.mcp_registry = None;
1217                ctx.mcp_reload = None;
1218
1219                // If no servers are configured, we're done after cleanup.
1220                if configs.is_empty() {
1221                    renderer.render(UiLine::CommandOutput(
1222                        t(Msg::McpClearedNoServers { removed }).into_owned(),
1223                    ));
1224                    renderer.flush();
1225                    return Ok(());
1226                }
1227
1228                // 2.5) Arm progress tracker (event loop prints a summary once all results land).
1229                ctx.mcp_reload = Some(super::McpReloadProgress {
1230                    total: configs.len(),
1231                    done: 0,
1232                    connected: 0,
1233                    failed: 0,
1234                    started_at: std::time::Instant::now(),
1235                });
1236
1237                // 3) Recreate registry and event channel. Connections happen in background
1238                // and will stream Connected/Failed events into scrollback (event loop select!).
1239                use atomcode_core::mcp::McpConnectEvent;
1240                let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<McpConnectEvent>();
1241                let registry = atomcode_core::mcp::McpRegistry::from_config_background_with_events(
1242                    &ctx.working_dir,
1243                    Some(tx),
1244                );
1245                ctx.mcp_registry = Some(std::sync::Arc::new(registry));
1246                ctx.mcp_connect_rx = Some(rx);
1247
1248                renderer.render(UiLine::CommandOutput(
1249                    t(Msg::McpClearedReconnecting { removed }).into_owned(),
1250                ));
1251                renderer.flush();
1252                return Ok(());
1253            }
1254
1255            // `/mcp tools <server>`: list remote tool names for a connected server.
1256            // This is intentionally separate from a global `/tools` so we keep the surface minimal.
1257            if let Some(rest) = sub.strip_prefix("tools") {
1258                let server = rest.trim();
1259                if server.is_empty() {
1260                    renderer.render(UiLine::CommandOutput(
1261                        t(Msg::McpToolsUsage).into_owned(),
1262                    ));
1263                    renderer.flush();
1264                    return Ok(());
1265                }
1266                if let Some(registry) = &ctx.mcp_registry {
1267                    let server = server.to_string();
1268                    let server_for_msg = server.clone();
1269                    let registry = registry.clone();
1270                    let tx = registry.event_sender();
1271                    tokio::spawn(async move {
1272                        let list_timeout = registry.list_tools_timeout(&server).await;
1273                        let tools = match tokio::time::timeout(
1274                            list_timeout,
1275                            registry.list_tools_for_server(&server),
1276                        )
1277                        .await
1278                        {
1279                            Ok(v) => v,
1280                            Err(_) => {
1281                                if let Some(tx) = &tx {
1282                                    let _ = tx.send(atomcode_core::mcp::McpConnectEvent::Warning {
1283                                        name: server.clone(),
1284                                        message: format!(
1285                                            "tools/list timed out after {}s (server connected but tools not listed yet)",
1286                                            list_timeout.as_secs()
1287                                        ),
1288                                    });
1289                                }
1290                                return;
1291                            }
1292                        };
1293                        let mut msg = format!("tools:\n");
1294                        if tools.is_empty() {
1295                            msg.push_str("  (none — tools/list may have failed, timed out, or returned empty)\n");
1296                        } else {
1297                            for t in tools {
1298                                msg.push_str(&format!("  - mcp__{}__{}\n", server, t.tool_name));
1299                            }
1300                        }
1301                        if let Some(tx) = tx {
1302                            let _ = tx.send(atomcode_core::mcp::McpConnectEvent::Warning {
1303                                name: server,
1304                                message: msg.trim_end().to_string(),
1305                            });
1306                        }
1307                    });
1308                    renderer.render(UiLine::CommandOutput(
1309                        t(Msg::McpToolsListing { server: &server_for_msg }).into_owned(),
1310                    ));
1311                } else {
1312                    renderer.render(UiLine::CommandOutput(
1313                        t(Msg::McpNoRegistry).into_owned(),
1314                    ));
1315                }
1316                renderer.flush();
1317                return Ok(());
1318            }
1319
1320            // Default: show status.
1321            if let Some(registry) = &ctx.mcp_registry {
1322                let statuses = tokio::task::block_in_place(|| {
1323                    tokio::runtime::Handle::current().block_on(registry.server_statuses())
1324                });
1325                if statuses.is_empty() {
1326                    renderer.render(UiLine::CommandOutput(
1327                        t(Msg::McpNoServersConfigured).into_owned(),
1328                    ));
1329                } else {
1330                    let mut txt = t(Msg::McpServersHeader).into_owned();
1331                    for (name, status) in statuses {
1332                        txt.push_str(&format!("    {}  {}\n", name, status));
1333                    }
1334                    renderer.render(UiLine::CommandOutput(txt));
1335                }
1336            } else {
1337                renderer.render(UiLine::CommandOutput(
1338                    t(Msg::McpNoServersConfigured).into_owned(),
1339                ));
1340            }
1341            renderer.flush();
1342        }
1343        "welcome" => {
1344            // /welcome always opens the OnboardingWizard at the Confirm
1345            // step. The spec differentiates "empty body" (no confirm)
1346            // from "non-empty body" (confirm), but Renderer doesn't
1347            // expose body-emptiness, so we simplify: always show the
1348            // y/N gate. A user who explicitly typed /welcome by
1349            // definition wants the wizard, so a single keystroke is
1350            // acceptable friction; the upside is we never silently
1351            // clobber prior conversation.
1352            let _ = arg;
1353            *active_modal = Some(Box::new(
1354                crate::modals::OnboardingWizard::new_with_confirm()
1355                    .with_initial_language(ctx.config.language),
1356            ));
1357        }
1358        "worktree" => {
1359            handle_worktree(arg, ctx, renderer)?;
1360        }
1361        "think" => {
1362            let sub = arg.trim().to_ascii_lowercase();
1363            let provider_name = ctx.config.default_provider.clone();
1364            let provider = ctx.config.providers.get_mut(&provider_name);
1365            match provider {
1366                None => {
1367                    renderer.render(UiLine::Error(
1368                        t(Msg::CmdNoActiveProvider).into_owned(),
1369                    ));
1370                    renderer.flush();
1371                }
1372                Some(p) => {
1373                    if sub.is_empty() {
1374                        // Show current status
1375                        let enabled = p.thinking_enabled.unwrap_or(false);
1376                        let budget = p.thinking_budget.unwrap_or(10_000);
1377                        let status = if enabled { "enabled" } else { "disabled" };
1378                        renderer.render(UiLine::CommandOutput(
1379                            t(Msg::ThinkStatus { status, budget, provider: &provider_name }).into_owned(),
1380                        ));
1381                        renderer.flush();
1382                    } else if sub == "on" {
1383                        p.thinking_enabled = Some(true);
1384                        let budget = p.thinking_budget.unwrap_or(10_000);
1385                        save_and_reload(ctx, renderer);
1386                        renderer.render(UiLine::CommandOutput(
1387                            t(Msg::ThinkEnabled { budget }).into_owned(),
1388                        ));
1389                        renderer.flush();
1390                    } else if sub == "off" {
1391                        p.thinking_enabled = Some(false);
1392                        save_and_reload(ctx, renderer);
1393                        renderer.render(UiLine::CommandOutput(
1394                            t(Msg::ThinkDisabled).into_owned(),
1395                        ));
1396                        renderer.flush();
1397                    } else if let Some(rest) = sub.strip_prefix("budget") {
1398                        let num_str = rest.trim();
1399                        match num_str.parse::<u32>() {
1400                            Ok(n) if n >= 1024 => {
1401                                p.thinking_budget = Some(n);
1402                                save_and_reload(ctx, renderer);
1403                                renderer.render(UiLine::CommandOutput(
1404                                    t(Msg::ThinkBudgetSet { n }).into_owned(),
1405                                ));
1406                                renderer.flush();
1407                            }
1408                            Ok(n) => {
1409                                renderer.render(UiLine::Error(
1410                                    t(Msg::ThinkBudgetTooSmall { n }).into_owned(),
1411                                ));
1412                                renderer.flush();
1413                            }
1414                            Err(_) => {
1415                                renderer.render(UiLine::Error(
1416                                    t(Msg::ThinkBudgetUsage).into_owned(),
1417                                ));
1418
1419                                renderer.flush();
1420                            }
1421                        }
1422                    } else {
1423                        renderer.render(UiLine::CommandOutput(
1424                            t(Msg::ThinkUsage).into_owned(),
1425                        ));
1426                        renderer.flush();
1427                    }
1428                }
1429            }
1430        }
1431        "plugin" => {
1432            handle_plugin(arg, ctx, renderer);
1433        }
1434        "skills" => {
1435            // Gateway command. With no arg, list user-invocable skills
1436            // so the user knows what's available without opening the
1437            // menu (useful in non-TTY transcripts and copy/paste).
1438            // With an arg, treat the first word as a skill name and
1439            // dispatch its expanded template as a user message — same
1440            // path the menu's sub-mode submission lands on.
1441            let arg_trim = arg.trim();
1442            if arg_trim.is_empty() {
1443                // Show fully qualified names (`<plugin>:<skill>`) so users
1444                // can see which plugin owns each skill — bare-name listing
1445                // becomes ambiguous quickly once two plugins coexist.
1446                // `SkillRegistry::get`'s suffix-fallback still resolves
1447                // `/skills <bare>` for unambiguous bare names, so users
1448                // don't have to type the full prefix unless there's a
1449                // collision.
1450                let lines: Vec<String> = ctx
1451                    .skill_registry
1452                    .read()
1453                    .ok()
1454                    .map(|r| {
1455                        let mut v: Vec<String> = r
1456                            .user_invocable()
1457                            .map(|s| format!("  /skills {:<48}  {}", s.name, s.description))
1458                            .collect();
1459                        v.sort();
1460                        v
1461                    })
1462                    .unwrap_or_default();
1463                if lines.is_empty() {
1464                    renderer.render(UiLine::CommandOutput(
1465                        t(Msg::SkillsNone).into_owned(),
1466                    ));
1467                } else {
1468                    renderer.render(UiLine::CommandOutput(format!(
1469                        "{}{}\n",
1470                        t(Msg::SkillsAvailable),
1471                        lines.join("\n")
1472                    )));
1473                }
1474                renderer.flush();
1475            } else {
1476                let mut parts = arg_trim.splitn(2, char::is_whitespace);
1477                let skill_name = parts.next().unwrap_or("");
1478                let skill_args = parts.next().unwrap_or("").trim_start();
1479                // Pass the bare name straight through — `SkillRegistry::get`
1480                // falls back to a unique `:name` suffix match, which resolves
1481                // both loose skills (`skills:foo`) and plugin-contributed
1482                // skills (`<plugin>:foo`) without us needing to guess the
1483                // prefix here. A user-typed qualified name (`foo:bar`) still
1484                // works because exact match runs first.
1485                if let Some(rendered) = expand_skill(ctx, skill_name, skill_args) {
1486                    ctx.agent
1487                        .cmd_tx
1488                        .send(AgentCommand::SendMessage { text: rendered, images: vec![], image_markers: vec![] })
1489                        .ok();
1490                    state.on_submit();
1491                } else {
1492                    renderer.render(UiLine::Error(
1493                        t(Msg::SkillUnknown { name: skill_name }).into_owned(),
1494                    ));
1495                    renderer.flush();
1496                }
1497            }
1498        }
1499        "setup" => {
1500            // Check if the setup skill is already installed. If so, skip
1501            // the seed-install step and directly invoke the skill — this
1502            // avoids unnecessary file I/O, locking, and reloading every
1503            // time the user runs /setup on a project that's already set up.
1504            let skill_already_installed = {
1505                let reg = ctx.skill_registry.read().ok();
1506                reg.as_ref().map_or(false, |r| r.get("setup").is_some())
1507            };
1508
1509            if skill_already_installed {
1510                // Fast path: skill already present — just invoke it.
1511                if let Some(rendered) = expand_skill(ctx, "setup", arg) {
1512                    renderer.render(UiLine::CommandOutput(
1513                        t(Msg::CmdSetupRunningSkill).into_owned(),
1514                    ));
1515                    renderer.flush();
1516                    ctx.agent
1517                        .cmd_tx
1518                        .send(AgentCommand::SendMessage {
1519                            text: rendered,
1520                            images: vec![],
1521                            image_markers: vec![],
1522                        })
1523                        .ok();
1524                    state.on_submit();
1525                } else {
1526                    renderer.render(UiLine::Error(
1527                        t(Msg::CmdSetupSkillMissing).into_owned(),
1528                    ));
1529                    renderer.flush();
1530                }
1531            } else {
1532                // First run: install seeds, reload, then invoke.
1533                renderer.render(UiLine::CommandOutput(
1534                    t(Msg::CmdSetupRunning).into_owned(),
1535                ));
1536                renderer.flush();
1537
1538                let project_root = ctx.working_dir.clone();
1539                let opts = atomcode_core::setup::RunOptions::new(project_root);
1540
1541                // `setup::run` is synchronous (file I/O only). Run it on the
1542                // current thread via `block_in_place` to avoid blocking the
1543                // tokio runtime — no `block_on` needed since it's not async.
1544                let result = tokio::task::block_in_place(|| {
1545                    atomcode_core::setup::run(opts)
1546                });
1547
1548                match result {
1549                    Ok(report) => {
1550                        for line in report.render_cli().lines() {
1551                            renderer.render(UiLine::CommandOutput(line.to_string()));
1552                        }
1553
1554                        // Reload skills/commands so newly-installed seeds are
1555                        // visible immediately — without this the user would need
1556                        // to restart AtomCode to see them in /skills.
1557                        let (skills_loaded, _) = super::reload_plugins(ctx);
1558                        renderer.render(UiLine::CommandOutput(
1559                            t(Msg::CmdSetupSkillsReloaded { count: skills_loaded }).into_owned(),
1560                        ));
1561                        renderer.flush();
1562
1563                        // After installing seeds and reloading, automatically
1564                        // invoke the "setup" skill (atomcode-automation-recommender)
1565                        // so the user gets a full project analysis + recommendations
1566                        // in one step instead of having to run /skills setup manually.
1567                        if let Some(rendered) = expand_skill(ctx, "setup", arg) {
1568                            renderer.render(UiLine::CommandOutput(
1569                                t(Msg::CmdSetupRunningSkill).into_owned(),
1570                            ));
1571                            renderer.flush();
1572                            ctx.agent
1573                                .cmd_tx
1574                                .send(AgentCommand::SendMessage {
1575                                    text: rendered,
1576                                    images: vec![],
1577                                    image_markers: vec![],
1578                                })
1579                                .ok();
1580                            state.on_submit();
1581                        } else {
1582                            renderer.render(UiLine::Error(
1583                                t(Msg::CmdSetupSkillMissing).into_owned(),
1584                            ));
1585                            renderer.flush();
1586                        }
1587                    }
1588                    Err(e) => {
1589                        renderer.render(UiLine::Error(
1590                            t(Msg::CmdSetupError { error: &e.to_string() }).into_owned(),
1591                        ));
1592                    }
1593                }
1594                renderer.flush();
1595            }
1596        }
1597        other => {
1598            // Before reporting "unknown", check user-defined custom commands,
1599            // then user-invocable skills (loaded from .claude/skills,
1600            // .atomcode/skills, etc.). Both expand to a prompt and dispatch
1601            // as a regular user message.
1602            if let Some(rendered) = ctx.custom_commands.render(other, arg) {
1603                ctx.agent
1604                    .cmd_tx
1605                    .send(AgentCommand::SendMessage { text: rendered, images: vec![], image_markers: vec![] })
1606                    .ok();
1607                state.on_submit();
1608            } else if let Some(rendered) = expand_skill(ctx, other, arg) {
1609                ctx.agent
1610                    .cmd_tx
1611                    .send(AgentCommand::SendMessage { text: rendered, images: vec![], image_markers: vec![] })
1612                    .ok();
1613                state.on_submit();
1614            } else {
1615                // Unknown command — emit failure telemetry
1616                let available_commands: Vec<&str> = vec![
1617                    "help", "quit", "exit", "clear", "compact", "reload", "config",
1618                    "plan", "build", "session", "model", "language", "resume",
1619                    "rename", "provider", "status", "diff", "undo", "cost",
1620                    "context", "remember", "forget", "memory", "login", "logout",
1621                    "whoami", "upgrade", "issue", "cd", "bg", "codingplan",
1622                ];
1623                ctx.telemetry.track(atomcode_telemetry::Event::UseCommand {
1624                    type_: other.to_string(),
1625                    success: Some(false),
1626                    error_kind: Some(atomcode_telemetry::UseCommandErrorKind::NotFound),
1627                    error_data: Some(serde_json::json!({
1628                        "command": other,
1629                        "duration_ms": 0,
1630                        "message": format!("Unknown command: {}", other),
1631                        "reason": "用户输入了不存在的斜杠命令",
1632                        "resolution": "使用 /help 查看所有可用命令",
1633                        "available_commands": available_commands,
1634                    }).to_string()),
1635                });
1636                renderer.render(UiLine::Error(
1637                    t(Msg::CmdUnknownCommand { name: other }).into_owned(),
1638                ));
1639                renderer.flush();
1640            }
1641        }
1642    }
1643    Ok(())
1644}
1645
1646/// Look up a user-invocable skill by name and expand it with the current
1647/// session id. Returns the rendered prompt to send as a user message, or
1648/// `None` if no matching skill exists.
1649fn expand_skill(ctx: &LoopCtx, name: &str, arg: &str) -> Option<String> {
1650    let reg = ctx.skill_registry.read().ok()?;
1651    let skill = reg.get(name)?;
1652    if !skill.user_invocable {
1653        return None;
1654    }
1655    Some(skill.expand(arg, ctx.current_session.id.as_str()))
1656}
1657
1658/// Handle `/plugin` subcommands: marketplace add/remove/update/list,
1659/// install <plugin>@<marketplace>, uninstall <plugin>@<marketplace>, list.
1660/// On success each mutating subcommand calls `super::reload_plugins(ctx)`
1661/// so newly-installed skill/command assets are visible immediately.
1662fn handle_plugin(arg: &str, ctx: &mut super::LoopCtx, renderer: &mut dyn Renderer) {
1663    let rest = arg.trim();
1664    let mut parts = rest.splitn(3, char::is_whitespace);
1665    let sub = parts.next().unwrap_or("");
1666
1667    let ok = |renderer: &mut dyn Renderer, msg: String| {
1668        renderer.render(UiLine::CommandOutput(format!("  {}\n", msg)));
1669        renderer.flush();
1670    };
1671    let err = |renderer: &mut dyn Renderer, msg: String| {
1672        renderer.render(UiLine::Error(msg));
1673        renderer.flush();
1674    };
1675
1676    match sub {
1677        "marketplace" => {
1678            let action = parts.next().unwrap_or("");
1679            let arg = parts.next().unwrap_or("").trim();
1680            match action {
1681                "add" => {
1682                    // Network-bound: git clone happens off the event loop so
1683                    // the input thread keeps drawing. Result event is
1684                    // consumed by handle_plugin_job_event and rendered there.
1685                    let url = arg.to_string();
1686                    let tx = ctx.plugin_job_tx.clone();
1687                    ok(renderer, t(Msg::PluginMarketplaceCloning { url: &url }).into_owned());
1688                    tokio::task::spawn_blocking(move || {
1689                        let ev = match atomcode_core::plugin::marketplace::add_marketplace(&url) {
1690                            Ok(info) => atomcode_core::plugin::PluginJobEvent::MarketplaceAdded(info),
1691                            Err(e) => atomcode_core::plugin::PluginJobEvent::Failed {
1692                                op: "add marketplace".into(),
1693                                msg: format!("{:#}", e),
1694                            },
1695                        };
1696                        let _ = tx.send(ev);
1697                    });
1698                }
1699                "remove" => match atomcode_core::plugin::marketplace::remove_marketplace(arg) {
1700                    Ok(()) => {
1701                        super::reload_plugins(ctx);
1702                        ok(renderer, t(Msg::PluginMarketplaceRemoved { name: arg }).into_owned());
1703                    }
1704                    Err(e) => err(renderer, t(Msg::PluginMarketplaceRemoveFailed { error: &e.to_string() }).into_owned()),
1705                },
1706                "update" => {
1707                    let name = arg.to_string();
1708                    let tx = ctx.plugin_job_tx.clone();
1709                    ok(renderer, t(Msg::PluginMarketplaceUpdating { name: &name }).into_owned());
1710                    tokio::task::spawn_blocking(move || {
1711                        let ev = match atomcode_core::plugin::marketplace::update_marketplace(&name) {
1712                            Ok(info) => atomcode_core::plugin::PluginJobEvent::MarketplaceUpdated(info),
1713                            Err(e) => atomcode_core::plugin::PluginJobEvent::Failed {
1714                                op: "update marketplace".into(),
1715                                msg: format!("{:#}", e),
1716                            },
1717                        };
1718                        let _ = tx.send(ev);
1719                    });
1720                }
1721                "list" => match atomcode_core::plugin::marketplace::list_marketplaces() {
1722                    Ok(items) if items.is_empty() => {
1723                        ok(renderer, t(Msg::PluginNoMarketplaces).into_owned());
1724                    }
1725                    Ok(items) => {
1726                        let mut lines = vec![t(Msg::PluginMarketplacesHeader).into_owned()];
1727                        for m in items {
1728                            lines.push(format!(
1729                                "  {}  {}  {}  ({} plugins)",
1730                                m.name,
1731                                m.source,
1732                                &m.git_commit[..7.min(m.git_commit.len())],
1733                                m.plugins.len()
1734                            ));
1735                        }
1736                        renderer.render(UiLine::CommandOutput(format!(
1737                            "  {}\n",
1738                            lines.join("\n  ")
1739                        )));
1740                        renderer.flush();
1741                    }
1742                    Err(e) => err(renderer, t(Msg::PluginMarketplaceListFailed { error: &e.to_string() }).into_owned()),
1743                },
1744                _ => err(
1745                    renderer,
1746                    t(Msg::PluginMarketplaceUsage).into_owned(),
1747                ),
1748            }
1749        }
1750        "install" => match parse_plugin_at_marketplace(parts.next().unwrap_or("").trim()) {
1751            Some((plugin, mp)) => {
1752                // External-source plugins also clone, so dispatch async like
1753                // the marketplace add path. Inline-source installs are fast
1754                // (state-file edit only) but still go through the same
1755                // codepath for consistency.
1756                let tx = ctx.plugin_job_tx.clone();
1757                ok(renderer, t(Msg::PluginInstalling { plugin: &plugin, marketplace: &mp }).into_owned());
1758                tokio::task::spawn_blocking(move || {
1759                    let ev = match atomcode_core::plugin::installer::install(&plugin, &mp) {
1760                        Ok(info) => atomcode_core::plugin::PluginJobEvent::PluginInstalled(info),
1761                        Err(e) => atomcode_core::plugin::PluginJobEvent::Failed {
1762                            op: "install".into(),
1763                            msg: format!("{:#}", e),
1764                        },
1765                    };
1766                    let _ = tx.send(ev);
1767                });
1768            }
1769            None => err(renderer, t(Msg::PluginInstallUsage).into_owned()),
1770        },
1771        "uninstall" => match parse_plugin_at_marketplace(parts.next().unwrap_or("").trim()) {
1772            Some((plugin, mp)) => match atomcode_core::plugin::installer::uninstall(&plugin, &mp) {
1773                Ok(()) => {
1774                    super::reload_plugins(ctx);
1775                    ok(renderer, t(Msg::PluginUninstalled { plugin: &plugin, marketplace: &mp }).into_owned());
1776                }
1777                Err(e) => err(renderer, t(Msg::PluginUninstallFailed { error: &e.to_string() }).into_owned()),
1778            },
1779            None => err(
1780                renderer,
1781                t(Msg::PluginUninstallUsage).into_owned(),
1782            ),
1783        },
1784        "list" => match atomcode_core::plugin::installer::list_installed() {
1785            Ok(items) if items.is_empty() => {
1786                ok(renderer, t(Msg::PluginNoInstalled).into_owned());
1787            }
1788            Ok(items) => {
1789                let mut lines = vec![t(Msg::PluginInstalledHeader).into_owned()];
1790                for p in items {
1791                    lines.push(format!("  {}@{}  {}", p.plugin, p.marketplace, p.plugin_dir));
1792                }
1793                renderer.render(UiLine::CommandOutput(format!(
1794                    "  {}\n",
1795                    lines.join("\n  ")
1796                )));
1797                renderer.flush();
1798            }
1799            Err(e) => err(renderer, t(Msg::PluginListFailed { error: &e.to_string() }).into_owned()),
1800        },
1801        _ => err(
1802            renderer,
1803            t(Msg::PluginUsage).into_owned(),
1804        ),
1805    }
1806}
1807
1808fn parse_plugin_at_marketplace(s: &str) -> Option<(String, String)> {
1809    let (plugin, mp) = s.split_once('@')?;
1810    if plugin.is_empty() || mp.is_empty() {
1811        return None;
1812    }
1813    Some((plugin.to_string(), mp.to_string()))
1814}
1815
1816/// Handle `/worktree` subcommands: create, list, done, cleanup.
1817fn handle_worktree(arg: &str, ctx: &mut LoopCtx, renderer: &mut dyn Renderer) -> Result<()> {
1818    use atomcode_core::git::worktree::WorktreeManager;
1819
1820    let parts: Vec<&str> = arg.split_whitespace().collect();
1821    let sub = parts.first().map(|s| s.to_ascii_lowercase());
1822
1823    match sub.as_deref() {
1824        Some("create") => {
1825            let branch = match parts.get(1) {
1826                Some(b) => *b,
1827                None => {
1828                    renderer.render(UiLine::CommandOutput(
1829                        t(Msg::WorktreeCreateUsage).into_owned(),
1830                    ));
1831                    renderer.flush();
1832                    return Ok(());
1833                }
1834            };
1835            let base = parts
1836                .get(2)
1837                .map(|s| (*s).to_string())
1838                .or_else(|| detect_current_branch(&ctx.working_dir))
1839                .unwrap_or_else(|| "HEAD".to_string());
1840            let mgr = match WorktreeManager::from_dir(ctx.working_dir.clone()) {
1841                Ok(mgr) => mgr,
1842                Err(e) => {
1843                    renderer.render(UiLine::Error(
1844                        t(Msg::WorktreeCreateFailed { error: &format!("{:#}", e) }).into_owned(),
1845                    ));
1846                    renderer.flush();
1847                    return Ok(());
1848                }
1849            };
1850            match mgr.create(branch, &base) {
1851                Ok(wt) => {
1852                    // Save original dir before switching
1853                    ctx.worktree_original_dir = Some(ctx.working_dir.clone());
1854                    apply_cd(ctx, wt.path.clone());
1855                    let path_str = wt.path.display().to_string();
1856                    renderer.render(UiLine::CommandOutput(
1857                        t(Msg::WorktreeCreated { branch: &wt.branch, base: &wt.base_branch, path: &path_str }).into_owned(),
1858                    ));
1859                }
1860                Err(e) => {
1861                    renderer.render(UiLine::Error(
1862                        t(Msg::WorktreeCreateFailed { error: &format!("{:#}", e) }).into_owned(),
1863                    ));
1864                }
1865            }
1866            renderer.flush();
1867        }
1868        Some("list") => {
1869            let mgr = match WorktreeManager::from_dir(ctx.working_dir.clone()) {
1870                Ok(mgr) => mgr,
1871                Err(e) => {
1872                    renderer.render(UiLine::Error(
1873                        t(Msg::WorktreeListFailed { error: &format!("{:#}", e) }).into_owned(),
1874                    ));
1875                    renderer.flush();
1876                    return Ok(());
1877                }
1878            };
1879            match mgr.list() {
1880                Ok(worktrees) => {
1881                    if worktrees.is_empty() {
1882                        renderer.render(UiLine::CommandOutput(
1883                            t(Msg::WorktreeNoActive).into_owned(),
1884                        ));
1885
1886                    } else {
1887                        let mut txt = t(Msg::WorktreeActiveHeader).into_owned();
1888                        for (branch, path, has_changes) in &worktrees {
1889                            let is_current = path == &ctx.working_dir;
1890                            let marker = if is_current { "\u{25cf}" } else { "\u{25cb}" };
1891                            let change_label = if *has_changes {
1892                                t(Msg::WorktreeHasChanges)
1893                            } else {
1894                                t(Msg::WorktreeClean)
1895                            };
1896                            let current_hint = if is_current {
1897                                t(Msg::WorktreeCurrent)
1898                            } else {
1899                                "".into()
1900                            };
1901
1902                            txt.push_str(&format!(
1903                                "    {} {:<16} {}  {}{}\n",
1904                                marker,
1905                                branch,
1906                                path.display(),
1907                                change_label,
1908                                current_hint,
1909                            ));
1910                        }
1911                        renderer.render(UiLine::CommandOutput(txt));
1912                    }
1913                }
1914                Err(e) => {
1915                    renderer.render(UiLine::Error(
1916                        t(Msg::WorktreeListFailed { error: &format!("{:#}", e) }).into_owned(),
1917                    ));
1918                }
1919            }
1920            renderer.flush();
1921        }
1922        Some("done") => {
1923            if let Some(original) = ctx.worktree_original_dir.take() {
1924                let current_branch = detect_current_branch(&ctx.working_dir);
1925                apply_cd(ctx, original.clone());
1926                let path_str = original.display().to_string();
1927                renderer.render(UiLine::CommandOutput(
1928                    t(Msg::WorktreeDoneBack { path: &path_str }).into_owned(),
1929                ));
1930                if let Some(branch) = current_branch {
1931                    renderer.render(UiLine::CommandOutput(
1932                        t(Msg::WorktreeDoneMergeHint { branch: &branch }).into_owned(),
1933                    ));
1934                }
1935            } else {
1936                renderer.render(UiLine::CommandOutput(
1937                    t(Msg::WorktreeNoSession).into_owned(),
1938                ));
1939            }
1940            renderer.flush();
1941        }
1942        Some("cleanup") => {
1943            let branch = match parts.get(1) {
1944                Some(b) => *b,
1945                None => {
1946                    renderer.render(UiLine::CommandOutput(
1947                        t(Msg::WorktreeCleanupUsage).into_owned(),
1948                    ));
1949                    renderer.flush();
1950                    return Ok(());
1951                }
1952            };
1953            let force = parts
1954                .get(2)
1955                .map(|s| *s == "--force" || *s == "-f")
1956                .unwrap_or(false);
1957            let manager_dir = ctx
1958                .worktree_original_dir
1959                .as_ref()
1960                .cloned()
1961                .unwrap_or_else(|| ctx.working_dir.clone());
1962            let mgr = match WorktreeManager::from_dir(manager_dir) {
1963                Ok(mgr) => mgr,
1964                Err(e) => {
1965                    renderer.render(UiLine::Error(
1966                        t(Msg::WorktreeCleanupFailed { error: &format!("{:#}", e) }).into_owned(),
1967                    ));
1968                    renderer.flush();
1969                    return Ok(());
1970                }
1971            };
1972            let cleanup_path = mgr
1973                .find_worktree_path(branch)
1974                .unwrap_or_else(|_| None)
1975                .unwrap_or_else(|| mgr.worktree_path(branch));
1976            let removing_current = paths_same(&cleanup_path, &ctx.working_dir);
1977            match mgr.remove(branch, force) {
1978                Ok(()) => {
1979                    let switched_to = if removing_current {
1980                        let target = ctx
1981                            .worktree_original_dir
1982                            .take()
1983                            .unwrap_or_else(|| mgr.repo_root().to_path_buf());
1984                        apply_cd(ctx, target.clone());
1985                        Some(target)
1986                    } else {
1987                        None
1988                    };
1989                    renderer.render(UiLine::CommandOutput(
1990                        t(Msg::WorktreeCleaned { branch }).into_owned(),
1991                    ));
1992                    if let Some(target) = switched_to {
1993                        let path_str = target.display().to_string();
1994                        renderer.render(UiLine::CommandOutput(
1995                            t(Msg::WorktreeCleanedSwitched { path: &path_str }).into_owned(),
1996                        ));
1997                    }
1998                }
1999                Err(e) => {
2000                    let err_msg = format!("{:#}", e);
2001                    if !force
2002                        && (err_msg.contains("untracked")
2003                            || err_msg.contains("modified")
2004                            || err_msg.contains("changes"))
2005                    {
2006                        renderer.render(UiLine::CommandOutput(
2007                            t(Msg::WorktreeCleanupUncommitted { branch }).into_owned(),
2008                        ));
2009                    } else {
2010                        renderer.render(UiLine::Error(
2011                            t(Msg::WorktreeCleanupFailed { error: &err_msg }).into_owned(),
2012                        ));
2013                    }
2014                }
2015            }
2016            renderer.flush();
2017        }
2018        _ => {
2019            renderer.render(UiLine::CommandOutput(
2020                t(Msg::WorktreeUsage).into_owned(),
2021            ));
2022            renderer.flush();
2023        }
2024    }
2025    Ok(())
2026}
2027
2028/// Detect the current branch name in a directory.
2029fn detect_current_branch(dir: &std::path::Path) -> Option<String> {
2030    std::process::Command::new("git")
2031        .args(["rev-parse", "--abbrev-ref", "HEAD"])
2032        .current_dir(dir)
2033        .output()
2034        .ok()
2035        .and_then(|o| {
2036            if o.status.success() {
2037                Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
2038            } else {
2039                None
2040            }
2041        })
2042}
2043
2044fn paths_same(a: &std::path::Path, b: &std::path::Path) -> bool {
2045    if a == b {
2046        return true;
2047    }
2048    match (a.canonicalize(), b.canonicalize()) {
2049        (Ok(a), Ok(b)) => a == b,
2050        _ => false,
2051    }
2052}
2053
2054/// Build the `/context` report — horizontal bar + category breakdown,
2055/// optionally followed by the full system prompt when `show_prompt`.
2056///
2057/// Thin wrapper around `format_context_report` that pulls the inputs
2058/// (snapshot + model name + flag) out of state/ctx. Split for
2059/// unit-testability: the inner function takes plain values and can be
2060/// asserted on directly.
2061pub(super) fn render_context_report(state: &UiState, ctx: &LoopCtx, show_prompt: bool) -> String {
2062    format_context_report(state.last_context.as_ref(), &ctx.model_name, show_prompt)
2063}
2064
2065/// Fetch + format the CodingPlan section appended to `/status`. Runs a
2066/// blocking HTTP call (~100–500ms) against `/coding-plan/status` — same
2067/// endpoint as the `/codingplan` flow's step 4. Falls back to a one-line
2068/// hint when the user isn't signed in, has no active plan, or the API
2069/// call fails. Never panics and never returns an error: `/status` is a
2070/// quick-glance command, so any fetch problem degrades into a visible
2071/// note instead of aborting the whole command.
2072fn render_codingplan_status_for_status_cmd() -> String {
2073    use atomcode_core::coding_plan::client::Client;
2074
2075    let client = match Client::from_stored_auth() {
2076        Ok(c) => c,
2077        Err(_) => {
2078            return t(Msg::StatusCpNotSignedIn).into_owned();
2079        }
2080    };
2081    let status = match client.status_v2() {
2082        Ok(s) => s,
2083        Err(e) => {
2084            return t(Msg::StatusCpFetchFailed { error: &format!("{:#}", e) }).into_owned();
2085        }
2086    };
2087    let plan = match &status.codingplan_free {
2088        Some(p) => p,
2089        None => {
2090            return t(Msg::StatusCpNoActive).into_owned();
2091        }
2092    };
2093
2094    let mut out = t(Msg::StatusCpLine {
2095        plan: &plan.plan_name,
2096        expires_at: &plan.expires_at,
2097        remaining_days: plan.remaining_days,
2098        total_days: plan.total_days,
2099    }).into_owned();
2100    if let Some(u) = &status.current_usage {
2101        out.push_str(&t(Msg::StatusCpUsage {
2102            usage: &u.display_desc(),
2103            reset_at: &u.reset_at_display,
2104            seconds: u.seconds_until_reset,
2105        }));
2106    }
2107    if status.window_quota_exhausted {
2108        if let Some(hint) = &status.window_quota_hint {
2109            out.push_str(&t(Msg::StatusCpWindowHint { hint }));
2110        } else {
2111            out.push_str(&t(Msg::StatusCpWindowExhausted));
2112        }
2113    }
2114    out
2115}
2116
2117/// Pure-function core of `/context` — testable without constructing
2118/// `LoopCtx`. Returns the rendered CommandOutput body.
2119fn format_context_report(
2120    snapshot: Option<&crate::state::ContextSnapshot>,
2121    model_name: &str,
2122    show_prompt: bool,
2123) -> String {
2124    let header = t(Msg::CtxUsageHeader);
2125    let Some(snap) = snapshot else {
2126        return format!("  {}\n  \n  {}\n", header, t(Msg::CtxUsageNoTurns));
2127    };
2128    if snap.ctx_window == 0 {
2129        return format!("  {}\n  \n  {}\n", header, t(Msg::CtxUsageWaiting));
2130    }
2131
2132    let window = snap.ctx_window;
2133    // Sum components excluding tool_defs (which in most providers counts
2134    // against input tokens but atomcode tracks separately). Clamp used to
2135    // window so a single oversized tool_defs doesn't drive "free" negative.
2136    let sys = snap.system_tokens;
2137    let tools = snap.tool_defs_tokens;
2138    let cold = snap.cold_zone_tokens;
2139    // Sent = everything sent minus the system message (ctx's own accounting).
2140    // Cold zone is injected as a System message inside `sent`, so we avoid
2141    // double-counting: subtract cold from sent for the "messages" bucket.
2142    let messages = snap.sent_tokens.saturating_sub(cold);
2143    let total_used = sys
2144        .saturating_add(tools)
2145        .saturating_add(cold)
2146        .saturating_add(messages);
2147    let free = window.saturating_sub(total_used);
2148
2149    // Horizontal bar: 40 cells, one segment per category with a distinct glyph.
2150    // Terminals universally render these blocks, no ANSI color required.
2151    const BAR_WIDTH: usize = 40;
2152    let cells = |tokens: usize| -> usize {
2153        if window == 0 {
2154            return 0;
2155        }
2156        (tokens as u128 * BAR_WIDTH as u128 / window as u128) as usize
2157    };
2158    let sys_cells = cells(sys);
2159    let tools_cells = cells(tools);
2160    let cold_cells = cells(cold);
2161    let msg_cells = cells(messages);
2162    // Guard: cell sum shouldn't exceed BAR_WIDTH (rounding can give +1).
2163    let used_cells = sys_cells + tools_cells + cold_cells + msg_cells;
2164    let free_cells = BAR_WIDTH.saturating_sub(used_cells.min(BAR_WIDTH));
2165
2166    let mut bar = String::with_capacity(BAR_WIDTH * 3);
2167    bar.push_str(&"▒".repeat(sys_cells)); // system prompt
2168    bar.push_str(&"▓".repeat(tools_cells)); // tool defs
2169    bar.push_str(&"░".repeat(cold_cells)); // cold zone
2170    bar.push_str(&"█".repeat(msg_cells)); // messages
2171    bar.push_str(&"·".repeat(free_cells)); // free
2172
2173    let pct = |t: usize| -> String {
2174        if window == 0 {
2175            return "  —".to_string();
2176        }
2177        format!("{:>4.1}%", (t as f64 * 100.0) / window as f64)
2178    };
2179    let k = |t: usize| -> String {
2180        if t >= 1000 {
2181            format!("{:.1}K", t as f64 / 1000.0)
2182        } else {
2183            format!("{}", t)
2184        }
2185    };
2186
2187    let used_pct = pct(total_used);
2188
2189    // Localised legend labels. Pad each to the widest display-width
2190    // in the current locale so the `:` column aligns regardless of
2191    // whether the active translation uses ASCII or CJK glyphs (CJK
2192    // chars are 2 cells; char-count padding would mis-align).
2193    let l_sys = t(Msg::CtxLabelSystemPrompt).into_owned();
2194    let l_tools = t(Msg::CtxLabelToolDefs).into_owned();
2195    let l_cold = t(Msg::CtxLabelColdZone).into_owned();
2196    let l_msgs = t(Msg::CtxLabelMessages).into_owned();
2197    let l_free = t(Msg::CtxLabelFree).into_owned();
2198    let max_label = [&l_sys, &l_tools, &l_cold, &l_msgs, &l_free]
2199        .iter()
2200        .map(|s| unicode_width::UnicodeWidthStr::width(s.as_str()))
2201        .max()
2202        .unwrap_or(0);
2203    let pad_label = |label: &str| -> String {
2204        let w = unicode_width::UnicodeWidthStr::width(label);
2205        format!("{}{}", label, " ".repeat(max_label.saturating_sub(w)))
2206    };
2207
2208    let ctx_name = if snap.ctx_name.is_empty() {
2209        "default"
2210    } else {
2211        snap.ctx_name.as_str()
2212    };
2213
2214    let mut out = format!(
2215        "  {header}\n  \
2216         \n  \
2217         {bar}\n  \
2218         {used}/{window} {tokens} ({used_pct})\n  \
2219         \n  \
2220         {provider}: {model}  ·  {ctx_label}: {ctx_name}\n  \
2221         \n  \
2222         ▒ {l_sys} : {sys_s:>7}  ({sys_p})\n  \
2223         ▓ {l_tools} : {tools_s:>7}  ({tools_p})\n  \
2224         ░ {l_cold} : {cold_s:>7}  ({cold_p})\n  \
2225         █ {l_msgs} : {msgs_s:>7}  ({msgs_p})\n  \
2226         · {l_free} : {free_s:>7}  ({free_p})\n  \
2227         \n  \
2228         {msg_count}\n",
2229        header = t(Msg::CtxUsageHeader),
2230        bar = bar,
2231        used = k(total_used),
2232        window = k(window),
2233        tokens = t(Msg::CtxTokensSuffix),
2234        used_pct = used_pct,
2235        provider = t(Msg::CtxProvider),
2236        ctx_label = t(Msg::CtxCtxName),
2237        model = model_name,
2238        ctx_name = ctx_name,
2239        l_sys = pad_label(&l_sys),
2240        l_tools = pad_label(&l_tools),
2241        l_cold = pad_label(&l_cold),
2242        l_msgs = pad_label(&l_msgs),
2243        l_free = pad_label(&l_free),
2244        sys_s = k(sys),
2245        sys_p = pct(sys),
2246        tools_s = k(tools),
2247        tools_p = pct(tools),
2248        cold_s = k(cold),
2249        cold_p = pct(cold),
2250        msgs_s = k(messages),
2251        msgs_p = pct(messages),
2252        free_s = k(free),
2253        free_p = pct(free),
2254        msg_count = t(Msg::CtxMessagesInWindow { n: snap.total_messages }),
2255    );
2256
2257    // `/context prompt` — append the full system-prompt bytes the last
2258    // turn sent. Kept out of the default output because the prompt is
2259    // 5–15 KB and would swamp the breakdown dashboard every invocation.
2260    // Hint line added when empty so the user knows WHY nothing showed
2261    // (snapshot is populated only by the rich emission path, which
2262    // fires once the first complete turn lands).
2263    if show_prompt {
2264        out.push('\n');
2265        out.push_str(&format!("  {}\n", t(Msg::CtxSystemPromptHeader)));
2266        if snap.system_prompt.is_empty() {
2267            out.push_str(&format!("  {}\n", t(Msg::CtxSystemPromptEmpty)));
2268        } else {
2269            // Indent each line with two spaces to match the surrounding
2270            // CommandOutput formatting (every other block uses a 2-space
2271            // left gutter). Avoids the model-prompt bytes looking like
2272            // they're escaping the command-output indentation.
2273            for line in snap.system_prompt.lines() {
2274                out.push_str("  ");
2275                out.push_str(line);
2276                out.push('\n');
2277            }
2278        }
2279    }
2280
2281    out
2282}
2283
2284/// Prepare + dispatch the fixissue pipeline for a given URL. Shared by:
2285/// (a) the `/fixissue <url>` arm, (b) the `/issue <url>` arm, and (c)
2286/// the event loop's post-close hook when `IssueWizard` has stashed a
2287/// URL in `ctx.pending_issue_url`. Handles all three `Prepared` cases
2288/// (Run / Skip / Err) and prints appropriate scrollback feedback. On
2289/// Run it arms the post-completion hook (`fixissue_pending` +
2290/// `fixissue_buffer`), sends `AgentCommand::SendMessage`, and flips
2291/// UiState to Streaming via `state.on_submit()`.
2292/// Currently unused — the `/fixissue` slash command was removed from
2293/// the menu and dispatcher. Kept (with `#[allow(dead_code)]`) so that
2294/// a future restoration of the slash command can re-add a one-line
2295/// dispatcher arm without re-implementing this whole flow. The
2296/// `atomcode fixissue` CLI subcommand uses `atomcode_core::atomgit::fixissue`
2297/// directly and does not depend on this function.
2298#[allow(dead_code)]
2299pub(crate) fn launch_fixissue(
2300    url: &str,
2301    state: &mut UiState,
2302    ctx: &mut LoopCtx,
2303    renderer: &mut dyn Renderer,
2304    fixissue_pending: &mut Option<atomcode_core::atomgit::IssueRef>,
2305    fixissue_buffer: &mut String,
2306) {
2307    match atomcode_core::atomgit::fixissue::prepare(url, &ctx.working_dir) {
2308        Ok(atomcode_core::atomgit::fixissue::Prepared::Run {
2309            prompt,
2310            issue_title,
2311            issue_number,
2312            issue_ref,
2313        }) => {
2314            renderer.render(UiLine::CommandOutput(format!(
2315                "  [fixissue] issue #{}: {}\n  Handing off to agent... (will post summary + 'fixed' label on completion)\n",
2316                issue_number, issue_title,
2317            )));
2318            renderer.flush();
2319            *fixissue_pending = Some(issue_ref);
2320            fixissue_buffer.clear();
2321            ctx.agent
2322                .cmd_tx
2323                .send(AgentCommand::SendMessage { text: prompt, images: vec![], image_markers: vec![] })
2324                .ok();
2325            state.on_submit();
2326        }
2327        Ok(atomcode_core::atomgit::fixissue::Prepared::Skip { reason }) => {
2328            renderer.render(UiLine::CommandOutput(format!("  {}\n", reason)));
2329            renderer.flush();
2330        }
2331        Err(e) => {
2332            renderer.render(UiLine::CommandOutput(format!(
2333                "  fixissue failed: {:#}\n",
2334                e
2335            )));
2336            renderer.flush();
2337        }
2338    }
2339}
2340
2341/// Commit a new working-directory choice: notify the agent, update cwd +
2342/// previous_dir on the shared context, push the new entry into the
2343/// recent-dirs ring, and persist. Shared by the `/cd <path>` arm and the
2344/// DirPicker modal's Enter handler so both paths keep state coherent.
2345pub(crate) fn apply_cd(ctx: &mut LoopCtx, path: PathBuf) {
2346    ctx.agent
2347        .cmd_tx
2348        .send(AgentCommand::ChangeDir(path.to_string_lossy().to_string()))
2349        .ok();
2350    ctx.previous_dir = Some(std::mem::replace(&mut ctx.working_dir, path.clone()));
2351    ctx.runtime_factory.set_working_dir(path.clone());
2352    push_recent_dir(&mut ctx.recent_dirs, path);
2353    save_recent_dirs(&ctx.recent_dirs);
2354}
2355
2356/// Move `new` to the front of `dirs`, dedup, and cap at `MAX_RECENT_DIRS`.
2357/// Does NOT persist — call `save_recent_dirs` after, or use `apply_cd`
2358/// which does both.
2359pub(crate) fn push_recent_dir(dirs: &mut Vec<PathBuf>, new: PathBuf) {
2360    dirs.retain(|d| d != &new);
2361    dirs.insert(0, new);
2362    dirs.truncate(MAX_RECENT_DIRS);
2363}
2364
2365/// Read `~/.atomcode/recent_dirs.txt`. Silently drops missing directories
2366/// so stale entries from a deleted project don't linger in the picker.
2367pub(crate) fn load_recent_dirs() -> Vec<PathBuf> {
2368    let path = atomcode_core::config::Config::config_dir().join("recent_dirs.txt");
2369    std::fs::read_to_string(&path)
2370        .ok()
2371        .map(|s| {
2372            s.lines()
2373                .filter(|l| !l.trim().is_empty())
2374                .map(PathBuf::from)
2375                .filter(|p| p.is_dir())
2376                .take(MAX_RECENT_DIRS)
2377                .collect()
2378        })
2379        .unwrap_or_default()
2380}
2381
2382/// Persist `dirs` to `~/.atomcode/recent_dirs.txt`. Best-effort — a write
2383/// failure (read-only HOME, permission denied) is swallowed so it can
2384/// never break an interactive `/cd`.
2385pub(crate) fn save_recent_dirs(dirs: &[PathBuf]) {
2386    let path = atomcode_core::config::Config::config_dir().join("recent_dirs.txt");
2387    let content = dirs
2388        .iter()
2389        .map(|d| d.to_string_lossy().to_string())
2390        .collect::<Vec<_>>()
2391        .join("\n");
2392    let _ = std::fs::write(&path, content);
2393}
2394
2395fn resolve_cd(
2396    arg: &str,
2397    cwd: &std::path::Path,
2398    prev: Option<&std::path::Path>,
2399) -> std::result::Result<PathBuf, String> {
2400    let home = crate::platform::home_dir();
2401    let target = if arg.is_empty() {
2402        home.ok_or_else(|| "home directory not known".to_string())?
2403    } else if arg == "-" {
2404        prev.map(|p| p.to_path_buf())
2405            .ok_or_else(|| "No previous directory".to_string())?
2406    } else if let Some(rest) = arg.strip_prefix('~') {
2407        let home = home.ok_or_else(|| "home directory not known".to_string())?;
2408        let rest = rest.strip_prefix('/').unwrap_or(rest);
2409        if rest.is_empty() {
2410            home
2411        } else {
2412            home.join(rest)
2413        }
2414    } else {
2415        let p = PathBuf::from(arg);
2416        if p.is_absolute() {
2417            p
2418        } else {
2419            cwd.join(p)
2420        }
2421    };
2422    let canon = target
2423        .canonicalize()
2424        .map_err(|e| format!("{}: {}", target.display(), e))?;
2425    if !canon.is_dir() {
2426        return Err(t(Msg::DirNotADirectory { path: &canon.display().to_string() }).into_owned());
2427    }
2428    Ok(canon)
2429}
2430
2431/// Build the OAuth-prompt body shown in scrollback while waiting for
2432/// the user to complete sign-in. Always includes the URL and ESC
2433/// affordance; renders a QR code above the URL when the terminal can
2434/// display it and the rendered block fits the current width.
2435///
2436/// Style selection (Unicode-capable terminals):
2437/// * `ATOMCODE_QR_DENSE=1` → force `Dense1x2` half-block (≈ 45 cols).
2438///   Override for users on terminals where braille mis-renders.
2439/// * `ATOMCODE_QR_BRAILLE=1` → force braille (≈ 23 cols). Opt-in for
2440///   users who know their terminal renders braille at single cell
2441///   width and don't add line spacing.
2442/// * JediTerm (Android Studio / IntelliJ / GoLand / any JetBrains IDE
2443///   embedded terminal) → no QR. JediTerm renders rows with extra
2444///   line spacing, vertically stretching every text-based QR beyond
2445///   scanner aspect tolerance. URLs are clickable in JediTerm
2446///   anyway, so URL-only is actually a better UX.
2447/// * Otherwise → `Dense1x2`. Block elements (U+2580–U+259F) are
2448///   Unicode-Neutral width and render at single cell on every
2449///   terminal — universally scannable.
2450///
2451/// On terminals without Unicode block-glyph support
2452/// (`TerminalCaps::unicode_symbols == false` — POSIX locale, dumb
2453/// TERM, legacy Windows conhost) we likewise skip the QR: the only
2454/// scannable ASCII form is ≈ 90 columns wide, which doesn't fit any
2455/// realistic terminal window, and those environments are typically
2456/// keyboard-driven anyway.
2457fn compose_login_chrome(url: &str, unicode: bool) -> String {
2458    compose_login_chrome_inner(url, unicode, cfg!(target_env = "ohos"))
2459}
2460
2461/// Testable core of `compose_login_chrome`. `omit_url=true` drops the
2462/// clickable URL block — wired to `cfg!(target_env = "ohos")` by the
2463/// outer fn because the AtomGit OAuth callback's redirect-based flow
2464/// breaks on OpenHarmony PC (system browser hands control back with
2465/// "Invalid state" before the callback can complete; WeChat QR scan
2466/// works because it's a phone-side approval that posts directly to the
2467/// gateway). Surfacing the URL there would just lead users into the
2468/// dead path; QR-only is the better UX. Parameterised so the QR-present
2469/// vs URL-fallback shapes can be unit-tested on every platform.
2470fn compose_login_chrome_inner(url: &str, unicode: bool, omit_url: bool) -> String {
2471    let qr_block = pick_qr_style(unicode).and_then(|style| {
2472        let s = crate::render::qr::render_login_qr(url, style)?;
2473        let cols = crate::render::qr::block_cols(&s);
2474        let term_cols = crossterm::terminal::size().map(|(c, _)| c).unwrap_or(80);
2475        // Reserve 2 cols for the leading indent + 2 cols breathing room.
2476        if (cols as u16).saturating_add(4) <= term_cols {
2477            Some(
2478                s.lines()
2479                    .map(|l| format!("  {}", l))
2480                    .collect::<Vec<_>>()
2481                    .join("\n"),
2482            )
2483        } else {
2484            None
2485        }
2486    });
2487
2488    let mut out = String::new();
2489    if let Some(block) = qr_block {
2490        out.push_str(&t(Msg::LoginQrHeader));
2491        out.push_str(&block);
2492        if !omit_url {
2493            out.push_str(&t(Msg::LoginUrlAfterQr));
2494            out.push_str(url);
2495        }
2496    } else if omit_url {
2497        // No QR + URL doesn't work on this platform → there's nothing
2498        // actionable to offer. Tell the user explicitly rather than
2499        // dropping them into a screen with just "Press ESC to cancel".
2500        out.push_str(&t(Msg::LoginNoQrNoUrl));
2501    } else {
2502        out.push_str(&t(Msg::LoginUrlOnly));
2503        out.push_str(url);
2504    }
2505    out.push_str(&t(Msg::LoginCancelHint));
2506    out
2507}
2508
2509/// Choose a QR rendering style for the current environment, or return
2510/// `None` to skip the QR entirely (URL-only output).
2511///
2512/// Pure function — env vars / TERMINAL_EMULATOR are read once and
2513/// passed through `decide_qr_style` so the decision logic stays unit
2514/// testable.
2515fn pick_qr_style(unicode: bool) -> Option<crate::render::qr::QrStyle> {
2516    let env_flag = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty()).is_some();
2517    let is_jediterm = std::env::var("TERMINAL_EMULATOR")
2518        .map(|v| v == "JetBrains-JediTerm")
2519        .unwrap_or(false);
2520    decide_qr_style(
2521        unicode,
2522        env_flag("ATOMCODE_QR_DENSE"),
2523        env_flag("ATOMCODE_QR_BRAILLE"),
2524        is_jediterm,
2525    )
2526}
2527
2528/// Pure decision table for `pick_qr_style`. Explicit overrides win
2529/// over auto-detection; auto-detection only suppresses the QR when
2530/// no override is set.
2531fn decide_qr_style(
2532    unicode: bool,
2533    force_dense: bool,
2534    force_braille: bool,
2535    is_jediterm: bool,
2536) -> Option<crate::render::qr::QrStyle> {
2537    use crate::render::qr::QrStyle;
2538    if !unicode {
2539        return None;
2540    }
2541    if force_dense {
2542        return Some(QrStyle::Dense1x2);
2543    }
2544    if force_braille {
2545        return Some(QrStyle::Braille);
2546    }
2547    if is_jediterm {
2548        // JediTerm adds line spacing — every text-based QR vertically
2549        // stretches past scanner tolerance. URL-only is the better UX.
2550        return None;
2551    }
2552    Some(QrStyle::Dense1x2)
2553}
2554
2555#[cfg(test)]
2556mod qr_style_tests {
2557    use super::*;
2558    use crate::render::qr::QrStyle;
2559
2560    #[test]
2561    fn no_unicode_means_no_qr() {
2562        assert_eq!(decide_qr_style(false, false, false, false), None);
2563        // overrides do not bring back QR when terminal can't render unicode
2564        assert_eq!(decide_qr_style(false, true, false, false), None);
2565        assert_eq!(decide_qr_style(false, false, true, false), None);
2566    }
2567
2568    #[test]
2569    fn jediterm_default_skips_qr() {
2570        assert_eq!(decide_qr_style(true, false, false, true), None);
2571    }
2572
2573    #[test]
2574    fn jediterm_with_braille_override_renders_braille() {
2575        assert_eq!(
2576            decide_qr_style(true, false, true, true),
2577            Some(QrStyle::Braille)
2578        );
2579    }
2580
2581    #[test]
2582    fn jediterm_with_dense_override_renders_dense() {
2583        assert_eq!(
2584            decide_qr_style(true, true, false, true),
2585            Some(QrStyle::Dense1x2)
2586        );
2587    }
2588
2589    #[test]
2590    fn dense_override_wins_over_braille_override() {
2591        assert_eq!(
2592            decide_qr_style(true, true, true, false),
2593            Some(QrStyle::Dense1x2)
2594        );
2595    }
2596
2597    #[test]
2598    fn braille_override_picks_braille_outside_jediterm() {
2599        assert_eq!(
2600            decide_qr_style(true, false, true, false),
2601            Some(QrStyle::Braille)
2602        );
2603    }
2604
2605    #[test]
2606    fn default_is_dense1x2() {
2607        assert_eq!(
2608            decide_qr_style(true, false, false, false),
2609            Some(QrStyle::Dense1x2)
2610        );
2611    }
2612}
2613
2614#[cfg(test)]
2615mod compose_login_chrome_tests {
2616    use super::*;
2617
2618    const URL: &str = "https://acs.atomgit.com/login?client_id=test";
2619
2620    /// Non-OH default: QR + URL fallback line both present.
2621    #[test]
2622    fn omit_url_false_keeps_url_block_alongside_qr() {
2623        let _g = crate::i18n::test_lock();
2624        crate::i18n::set_locale(crate::i18n::Locale::En);
2625        let s = compose_login_chrome_inner(URL, true, false);
2626        assert!(s.contains("scan the QR code"), "QR header missing:\n{s}");
2627        assert!(
2628            s.contains("OR open the URL below"),
2629            "URL fallback header missing on non-OH build:\n{s}"
2630        );
2631        assert!(s.contains(URL), "URL itself missing on non-OH build:\n{s}");
2632    }
2633
2634    /// OH: QR present, URL line dropped entirely. The clickable AtomGit
2635    /// callback fails on OpenHarmony PC, so surfacing the URL would just
2636    /// lead the user into a dead path.
2637    #[test]
2638    fn omit_url_true_drops_url_block_when_qr_present() {
2639        let _g = crate::i18n::test_lock();
2640        crate::i18n::set_locale(crate::i18n::Locale::En);
2641        let s = compose_login_chrome_inner(URL, true, true);
2642        assert!(s.contains("scan the QR code"), "QR header missing:\n{s}");
2643        assert!(
2644            !s.contains("OR open the URL below"),
2645            "URL fallback header must NOT appear when omit_url:\n{s}"
2646        );
2647        assert!(
2648            !s.contains(URL),
2649            "URL itself must NOT appear when omit_url:\n{s}"
2650        );
2651    }
2652
2653    /// OH + terminal too narrow / non-unicode: no QR available, URL
2654    /// path disabled. Must tell the user explicitly that switching to a
2655    /// Unicode-capable terminal is the way out, otherwise they'd see
2656    /// only "Press ESC to cancel" with no actionable hint.
2657    #[test]
2658    fn omit_url_true_without_qr_explains_dead_end() {
2659        let _g = crate::i18n::test_lock();
2660        crate::i18n::set_locale(crate::i18n::Locale::En);
2661        let s = compose_login_chrome_inner(URL, false, true);
2662        assert!(
2663            !s.contains(URL),
2664            "URL must not appear when omit_url:\n{s}"
2665        );
2666        assert!(
2667            s.contains("Unicode-capable terminal"),
2668            "must guide the user to a unicode terminal:\n{s}"
2669        );
2670    }
2671
2672    /// Non-OH terminal too narrow / non-unicode: URL fallback header
2673    /// present. Regression guard for the existing pre-OH behaviour.
2674    #[test]
2675    fn omit_url_false_without_qr_shows_url_fallback() {
2676        let _g = crate::i18n::test_lock();
2677        crate::i18n::set_locale(crate::i18n::Locale::En);
2678        let s = compose_login_chrome_inner(URL, false, false);
2679        assert!(
2680            s.contains("Open this URL in any browser"),
2681            "URL fallback header missing on non-OH terminal-without-unicode:\n{s}"
2682        );
2683        assert!(s.contains(URL));
2684    }
2685}
2686
2687/// Render the OAuth URL block + ESC affordance into scrollback, then
2688/// drive the auth/check poll loop without leaving raw mode. ESC is read
2689/// from `ctx.input_rx` (the same channel the main event loop uses) so
2690/// no termios manipulation is needed and the input box stays visible
2691/// alongside the URL — same UX as any other slash command.
2692///
2693/// Earlier revisions suspended `renderer` for the OAuth window and let
2694/// `auth::login()` println straight to stdout. That collapsed the input
2695/// box and (worse) wrote URL bytes on top of existing scrollback because
2696/// the cursor was wherever the last paint left it. The renderer-driven
2697/// path here avoids both problems.
2698fn run_oauth_with_renderer(
2699    renderer: &mut dyn Renderer,
2700    ctx: &mut LoopCtx,
2701) -> Result<atomcode_core::auth::AuthInfo> {
2702    use crossterm::event::KeyCode;
2703    use std::time::{Duration, Instant};
2704    use tokio::sync::mpsc::error::TryRecvError;
2705
2706    let session = atomcode_core::auth::start_login()?;
2707
2708    // QR + URL + ESC affordance go through the body via UiLine::CommandOutput
2709    // so they sit in scrollback above the input box exactly like any other
2710    // slash-command output. The QR is the primary CTA (scan with phone); the
2711    // URL is the fallback for users who'd rather click into a desktop browser.
2712    // Both render before the best-effort browser launch so the QR is on
2713    // screen even when the browser opens instantly.
2714    renderer.render(UiLine::CommandOutput(compose_login_chrome(
2715        session.url(),
2716        ctx.caps.unicode_symbols,
2717    )));
2718    renderer.flush();
2719
2720    session.open_browser_best_effort();
2721
2722    // Poll loop. We stay in raw mode and consume keyboard events from
2723    // the existing reader thread via `input_rx`. The main event loop is
2724    // blocked while we run, so non-ESC events queue harmlessly — we
2725    // drain them here so they don't fire as stale input the moment
2726    // we return.
2727    loop {
2728        match session.poll_once()? {
2729            atomcode_core::auth::PollOutcome::Authorized => break,
2730            atomcode_core::auth::PollOutcome::Pending => {}
2731        }
2732
2733        let deadline = Instant::now() + Duration::from_secs(2);
2734        loop {
2735            if Instant::now() >= deadline {
2736                break;
2737            }
2738            match ctx.input_rx.try_recv() {
2739                Ok(crate::input::InputEvent::Key(k)) if k.code == KeyCode::Esc => {
2740                    anyhow::bail!("login cancelled by user");
2741                }
2742                Ok(_) => {
2743                    // Non-ESC events during OAuth are silently dropped:
2744                    // typing in the input box wouldn't render anyway
2745                    // (main thread blocked) and processing them after
2746                    // the loop would replay stale state.
2747                    continue;
2748                }
2749                Err(TryRecvError::Empty) => {
2750                    std::thread::sleep(Duration::from_millis(50));
2751                }
2752                Err(TryRecvError::Disconnected) => {
2753                    anyhow::bail!("input channel closed");
2754                }
2755            }
2756        }
2757    }
2758
2759    session.finish(Some(&ctx.telemetry))
2760}
2761
2762/// Run the OAuth login flow with the URL rendered into scrollback and
2763/// the input box preserved. ESC cancels via `ctx.input_rx`. See
2764/// `run_oauth_with_renderer` for the rationale.
2765pub(crate) fn run_login_flow(renderer: &mut dyn Renderer, ctx: &mut LoopCtx) -> Result<()> {
2766    let result = run_oauth_with_renderer(renderer, ctx)
2767        .and_then(|auth| atomcode_core::auth::save_auth(&auth).map(|()| auth));
2768
2769    match result {
2770        Ok(auth) => {
2771            // /login is identity-only. Provider / model setup lives in
2772            // /codingplan — that flow pulls the authoritative model list
2773            // from the CodingPlan API and writes matching providers.
2774            // Conflating the two paths was the source of a stale
2775            // MiniMax-M2.7 entry being hardcoded here.
2776            let name = auth
2777                .user
2778                .name
2779                .as_deref()
2780                .unwrap_or(&auth.user.username)
2781                .to_string();
2782            let had_provider = !ctx.config.providers.is_empty()
2783                && ctx
2784                    .config
2785                    .providers
2786                    .contains_key(&ctx.config.default_provider);
2787            if !had_provider {
2788                let provider_name = "AtomGit".to_string();
2789                let provider = build_oauth_provider();
2790                ctx.model_name = provider.model.clone();
2791                ctx.config.providers.insert(provider_name.clone(), provider);
2792                ctx.config.default_provider = provider_name;
2793                save_and_reload(ctx, renderer);
2794            } else {
2795                if let Some(provider) = ctx.config.providers.get(&ctx.config.default_provider) {
2796                    ctx.model_name = provider.model.clone();
2797                }
2798                let _ = ctx
2799                    .agent
2800                    .cmd_tx
2801                    .send(AgentCommand::ReloadConfig(ctx.config.clone()));
2802            }
2803            renderer.render(UiLine::CommandOutput(
2804                t(Msg::LoginSignedInWithCpHint {
2805                    name: &name,
2806                    username: &auth.user.username,
2807                }).into_owned(),
2808            ));
2809            renderer.flush();
2810        }
2811        Err(e) => {
2812            renderer.render(UiLine::Error(
2813                t(Msg::CmdLoginFailed { error: &e.to_string() }).into_owned(),
2814            ));
2815            renderer.flush();
2816        }
2817    }
2818    Ok(())
2819}
2820
2821/// Run the full CodingPlan setup flow: login (if needed) → claim →
2822/// fetch models + register providers → fetch status. Shares the
2823/// orchestrator with `atomcode codingplan` (CLI).
2824///
2825/// When the user isn't already logged in we pre-flight the OAuth via
2826/// `run_oauth_with_renderer` so the URL/ESC UI integrates with the TUI
2827/// (input box stays visible). The subsequent `coding_plan::run` call
2828/// then sees `is_logged_in() == true` and skips its own `auth::login`
2829/// path — that path prints to stdout and is reserved for CLI callers.
2830pub(crate) fn run_codingplan_flow(renderer: &mut dyn Renderer, ctx: &mut LoopCtx) -> Result<()> {
2831    // Phase 1: pre-flight login if needed.
2832    if !atomcode_core::auth::is_logged_in() {
2833        if let Err(e) = run_oauth_with_renderer(renderer, ctx)
2834            .and_then(|auth| atomcode_core::auth::save_auth(&auth).map(|_| auth))
2835        {
2836            // Login failed/cancelled. Surface as a top-level error;
2837            // skip the rest of setup since claim/models/status all
2838            // need a token.
2839            renderer.render(UiLine::Error(
2840                t(Msg::CodingPlanSetupFailed { error: &e.to_string() }).into_owned(),
2841            ));
2842            renderer.flush();
2843            return Ok(());
2844        }
2845    }
2846
2847    // Phase 2: claim/models/status. Pure HTTP + config mutation — no
2848    // stdin / stdout interaction, so we don't need to suspend the
2849    // renderer. `step_login` short-circuits via `is_logged_in()`.
2850    //
2851    // If the stored token is locally valid (file present, expires_in
2852    // not yet past) but the server rejects it (revoked, refresh-token
2853    // dead, etc.), the orchestrator surfaces `report.auth_expired =
2854    // true`. Run OAuth *once* on that path — same flow `/login` would
2855    // have used — then re-run setup against the fresh token. Without
2856    // this the user sees "✓ already logged in as X" followed by
2857    // "✗ claim failed — run `atomcode login` again" and has to do
2858    // manually what `/codingplan` could do itself.
2859    let mut report = atomcode_core::coding_plan::run(&mut ctx.config, Some(&ctx.telemetry));
2860    if matches!(&report, Ok(r) if r.auth_expired) {
2861        renderer.render(UiLine::CommandOutput(
2862            t(Msg::CpReauthAfter401).into_owned(),
2863        ));
2864        renderer.flush();
2865        match run_oauth_with_renderer(renderer, ctx)
2866            .and_then(|auth| atomcode_core::auth::save_auth(&auth).map(|_| auth))
2867        {
2868            Ok(_) => {
2869                report = atomcode_core::coding_plan::run(&mut ctx.config, Some(&ctx.telemetry));
2870            }
2871            Err(e) => {
2872                // Re-OAuth itself failed (user pressed ESC, network
2873                // dead, etc.). Render the *original* report so they
2874                // still see what triggered the retry, then surface the
2875                // OAuth error.
2876                if let Ok(r) = &report {
2877                    renderer.render(UiLine::CommandOutput(r.render()));
2878                }
2879                renderer.render(UiLine::Error(
2880                    t(Msg::CodingPlanSetupFailed { error: &e.to_string() }).into_owned(),
2881                ));
2882                renderer.flush();
2883                return Ok(());
2884            }
2885        }
2886    }
2887
2888    match report {
2889        Ok(report) => {
2890            if report.should_persist_config() {
2891                // Config mutation only persists when critical steps passed —
2892                // don't write a half-set-up config if login or models failed.
2893                save_and_reload(ctx, renderer);
2894                // Stamp the drift-monitor sync marker alongside the config
2895                // write. Failures are non-fatal: at worst the 24h staleness
2896                // hint mis-fires once.
2897                let _ = atomcode_core::coding_plan::write_last_sync_now();
2898                // Also bump our own last-seen timestamp so the cross-process
2899                // sync-check on the next keystroke doesn't redundantly
2900                // reload the config we just saved ourselves.
2901                ctx.monitor_last_sync_seen = atomcode_core::coding_plan::read_last_sync();
2902                // Sync ctx.model_name with the freshly-picked default so the
2903                // status line and the next turn use the right model without
2904                // requiring a /reload.
2905                if let Some(p) = ctx.config.providers.get(&ctx.config.default_provider) {
2906                    ctx.model_name = p.model.clone();
2907                }
2908                // Clear any stale drift warning now that we've just
2909                // re-synced. Also reset the cooldown so the next
2910                // pre-turn trigger (if conditions change) can fire
2911                // immediately — no need to wait 15 min after a manual
2912                // refresh.
2913                if let Ok(mut g) = ctx.monitor_warning.lock() {
2914                    *g = None;
2915                }
2916                ctx.monitor_last_check_at = None;
2917                // Same for usage slot — a fresh /codingplan run may have
2918                // rotated the quota window or switched plan tiers.
2919                if let Ok(mut g) = ctx.usage_slot.lock() {
2920                    *g = None;
2921                }
2922                ctx.usage_last_check_at = None;
2923            }
2924            renderer.render(UiLine::CommandOutput(report.render()));
2925            renderer.flush();
2926        }
2927        Err(e) => {
2928            renderer.render(UiLine::Error(
2929                t(Msg::CodingPlanSetupFailed { error: &format!("{:#}", e) }).into_owned(),
2930            ));
2931            renderer.flush();
2932        }
2933    }
2934    Ok(())
2935}
2936
2937#[cfg(test)]
2938mod tests {
2939    use super::*;
2940
2941    /// Create a subdir inside a tempdir and return both. Paths are
2942    /// canonicalized because `resolve_cd` canonicalizes its output, and
2943    /// on macOS `/var/folders/...` → `/private/var/folders/...`.
2944    fn make_dirs() -> (tempfile::TempDir, PathBuf, PathBuf) {
2945        let tmp = tempfile::tempdir().expect("tempdir");
2946        let cwd = tmp.path().canonicalize().expect("canon cwd");
2947        let sub = cwd.join("sub");
2948        std::fs::create_dir(&sub).expect("mkdir sub");
2949        let sub = sub.canonicalize().expect("canon sub");
2950        (tmp, cwd, sub)
2951    }
2952
2953    #[test]
2954    fn relative_path_resolves_against_cwd() {
2955        let (_tmp, cwd, sub) = make_dirs();
2956        let got = resolve_cd("sub", &cwd, None).expect("relative resolves");
2957        assert_eq!(got, sub);
2958    }
2959
2960    #[test]
2961    fn absolute_path_ignores_cwd() {
2962        let (_tmp, _cwd, sub) = make_dirs();
2963        let alt_cwd = PathBuf::from("/"); // unrelated cwd
2964        let got = resolve_cd(sub.to_str().unwrap(), &alt_cwd, None).expect("absolute resolves");
2965        assert_eq!(got, sub);
2966    }
2967
2968    #[test]
2969    fn dash_uses_previous_dir() {
2970        let (_tmp, cwd, sub) = make_dirs();
2971        let got = resolve_cd("-", &sub, Some(&cwd)).expect("dash uses prev");
2972        assert_eq!(got, cwd);
2973    }
2974
2975    #[test]
2976    fn dash_without_previous_errors() {
2977        let (_tmp, cwd, _sub) = make_dirs();
2978        let err = resolve_cd("-", &cwd, None).expect_err("dash w/o prev");
2979        assert!(err.contains("No previous directory"), "got: {}", err);
2980    }
2981
2982    #[test]
2983    fn nonexistent_path_errors() {
2984        let (_tmp, cwd, _sub) = make_dirs();
2985        let err = resolve_cd("nope-does-not-exist", &cwd, None).expect_err("nonexistent errors");
2986        assert!(err.contains("nope-does-not-exist"), "got: {}", err);
2987    }
2988
2989    #[test]
2990    fn file_path_rejected_with_not_a_directory() {
2991        let (_tmp, cwd, _sub) = make_dirs();
2992        let file = cwd.join("a.txt");
2993        std::fs::write(&file, "hi").expect("write");
2994        let err = resolve_cd(file.to_str().unwrap(), &cwd, None).expect_err("file is not a dir");
2995        assert!(err.contains("Not a directory"), "got: {}", err);
2996    }
2997
2998    #[test]
2999    fn tilde_expands_to_home() {
3000        // Only run when HOME is actually resolvable; skip quietly on
3001        // hosts where it isn't (some CI sandboxes).
3002        let Some(home) = crate::platform::home_dir() else {
3003            return;
3004        };
3005        let Ok(canon_home) = home.canonicalize() else {
3006            return;
3007        };
3008        let (_tmp, cwd, _sub) = make_dirs();
3009        let got = resolve_cd("~", &cwd, None).expect("~ resolves");
3010        assert_eq!(got, canon_home);
3011    }
3012
3013    #[test]
3014    fn paths_same_accepts_canonical_equivalents() {
3015        let (_tmp, cwd, sub) = make_dirs();
3016        let via_parent = sub.join("..").join("sub");
3017        assert!(paths_same(&sub, &via_parent));
3018        assert!(!paths_same(&cwd, &sub));
3019    }
3020
3021    #[test]
3022    fn context_report_without_snapshot_prompts_to_run_turn() {
3023        let _g = crate::i18n::test_lock();
3024        crate::i18n::set_locale(crate::i18n::Locale::En);
3025        let out = format_context_report(None, "claude-opus-4-7", false);
3026        assert!(out.contains("run at least one turn"));
3027        // Never leak a window/totals when there's nothing to show
3028        assert!(!out.contains("tokens ("));
3029    }
3030
3031    #[test]
3032    fn context_report_with_zero_window_flags_partial_stats() {
3033        let _g = crate::i18n::test_lock();
3034        crate::i18n::set_locale(crate::i18n::Locale::En);
3035        let snap = crate::state::ContextSnapshot {
3036            system_tokens: 100,
3037            sent_tokens: 200,
3038            tool_defs_tokens: 0,
3039            cold_zone_tokens: 0,
3040            total_messages: 5,
3041            ctx_window: 0,
3042            ctx_name: String::new(),
3043            system_prompt: String::new(),
3044        };
3045        let out = format_context_report(Some(&snap), "test-model", false);
3046        assert!(out.contains("waiting for first complete turn"));
3047    }
3048
3049    #[test]
3050    fn context_report_renders_full_breakdown() {
3051        let _g = crate::i18n::test_lock();
3052        crate::i18n::set_locale(crate::i18n::Locale::En);
3053        let snap = crate::state::ContextSnapshot {
3054            system_tokens: 8_000,
3055            sent_tokens: 30_000, // includes cold
3056            tool_defs_tokens: 14_500,
3057            cold_zone_tokens: 2_000,
3058            total_messages: 42,
3059            ctx_window: 128_000,
3060            ctx_name: "default".into(),
3061            system_prompt: String::new(),
3062        };
3063        let out = format_context_report(Some(&snap), "claude-opus-4-7", false);
3064
3065        // Header
3066        assert!(out.contains("Context Usage"));
3067        // Bar renders (unicode blocks present)
3068        assert!(out.contains("▒") || out.contains("█"));
3069        // Category labels
3070        assert!(out.contains("System prompt"));
3071        assert!(out.contains("Tool defs"));
3072        assert!(out.contains("Cold zone"));
3073        assert!(out.contains("Messages"));
3074        assert!(out.contains("Free"));
3075        // Token values (K formatting)
3076        assert!(out.contains("8.0K")); // system
3077        assert!(out.contains("14.5K")); // tool defs
3078        assert!(out.contains("2.0K")); // cold zone
3079        assert!(out.contains("128.0K")); // window
3080                                         // Messages count
3081        assert!(out.contains("42"));
3082        // ctx name + model
3083        assert!(out.contains("default"));
3084        assert!(out.contains("claude-opus-4-7"));
3085    }
3086
3087    #[test]
3088    fn context_report_messages_excludes_cold_zone() {
3089        let _g = crate::i18n::test_lock();
3090        crate::i18n::set_locale(crate::i18n::Locale::En);
3091        // sent_tokens = messages + cold_zone (cold is injected as a
3092        // System message inside `sent`). Renderer must subtract so
3093        // "Messages" doesn't double-count.
3094        let snap = crate::state::ContextSnapshot {
3095            system_tokens: 1_000,
3096            sent_tokens: 10_000,
3097            tool_defs_tokens: 0,
3098            cold_zone_tokens: 3_000,
3099            total_messages: 10,
3100            ctx_window: 100_000,
3101            ctx_name: "default".into(),
3102            system_prompt: String::new(),
3103        };
3104        let out = format_context_report(Some(&snap), "m", false);
3105        // Messages bucket should be 10K - 3K = 7K, not 10K.
3106        let messages_line = out
3107            .lines()
3108            .find(|l| l.contains("Messages"))
3109            .expect("messages line must exist");
3110        assert!(
3111            messages_line.contains("7.0K"),
3112            "expected Messages=7.0K (sent-cold), got line: {}",
3113            messages_line
3114        );
3115    }
3116
3117    #[test]
3118    fn context_report_free_is_nonneg_under_rounding() {
3119        let _g = crate::i18n::test_lock();
3120        crate::i18n::set_locale(crate::i18n::Locale::En);
3121        // Pathological: sum of components exactly = window. Free must
3122        // render as 0, never blow up the subtraction.
3123        let snap = crate::state::ContextSnapshot {
3124            system_tokens: 20_000,
3125            sent_tokens: 80_000,
3126            tool_defs_tokens: 20_000,
3127            cold_zone_tokens: 0,
3128            total_messages: 50,
3129            ctx_window: 120_000,
3130            ctx_name: "default".into(),
3131            system_prompt: String::new(),
3132        };
3133        let out = format_context_report(Some(&snap), "m", false);
3134        // Free = window - (sys + tools + cold + messages)
3135        //      = 120_000 - (20_000 + 20_000 + 0 + 80_000) = 0
3136        assert!(out.contains("Free"));
3137        // Should not panic and should render — look for "0" tokens on the Free line
3138        let free_line = out
3139            .lines()
3140            .find(|l| l.contains("Free"))
3141            .expect("free line must exist");
3142        assert!(free_line.contains("0"), "free line: {}", free_line);
3143    }
3144
3145    #[test]
3146    fn context_report_without_show_prompt_omits_system_prompt_section() {
3147        let _g = crate::i18n::test_lock();
3148        crate::i18n::set_locale(crate::i18n::Locale::En);
3149        // Default `/context` output must not include the prompt dump
3150        // even when the snapshot HAS a cached prompt. Otherwise the
3151        // breakdown dashboard gets buried under 5-15K chars every call.
3152        let snap = crate::state::ContextSnapshot {
3153            system_tokens: 1_000,
3154            sent_tokens: 5_000,
3155            tool_defs_tokens: 500,
3156            cold_zone_tokens: 0,
3157            total_messages: 8,
3158            ctx_window: 100_000,
3159            ctx_name: "default".into(),
3160            system_prompt: "You are AtomCode.\nSOME SENTINEL BYTES".into(),
3161        };
3162        let out = format_context_report(Some(&snap), "m", false);
3163        assert!(
3164            !out.contains("SYSTEM PROMPT"),
3165            "SYSTEM PROMPT header must not appear in default /context output"
3166        );
3167        assert!(
3168            !out.contains("SOME SENTINEL BYTES"),
3169            "raw prompt body must not leak into default /context output"
3170        );
3171    }
3172
3173    #[test]
3174    fn context_report_with_show_prompt_appends_cached_prompt() {
3175        let _g = crate::i18n::test_lock();
3176        crate::i18n::set_locale(crate::i18n::Locale::En);
3177        let snap = crate::state::ContextSnapshot {
3178            system_tokens: 1_000,
3179            sent_tokens: 5_000,
3180            tool_defs_tokens: 500,
3181            cold_zone_tokens: 0,
3182            total_messages: 8,
3183            ctx_window: 100_000,
3184            ctx_name: "default".into(),
3185            system_prompt: "You are AtomCode.\nRULE_LINE_ABC\nEND".into(),
3186        };
3187        let out = format_context_report(Some(&snap), "m", true);
3188        assert!(out.contains("=== SYSTEM PROMPT ==="));
3189        // Each line indented with leading 2 spaces — verify one line
3190        // survives through the gutter indentation.
3191        assert!(
3192            out.contains("  RULE_LINE_ABC"),
3193            "prompt lines should keep content after 2-space indent"
3194        );
3195        // Breakdown still present (append, not replace)
3196        assert!(out.contains("Context Usage"));
3197        assert!(out.contains("System prompt"));
3198    }
3199
3200    #[test]
3201    fn context_report_show_prompt_with_empty_cached_prompt_shows_hint() {
3202        // Partial snapshot: no turn has landed rich stats yet, so
3203        // system_prompt is "". `/context prompt` should tell the user
3204        // that — not just silently show an empty section.
3205        let snap = crate::state::ContextSnapshot {
3206            system_tokens: 100,
3207            sent_tokens: 200,
3208            tool_defs_tokens: 0,
3209            cold_zone_tokens: 0,
3210            total_messages: 3,
3211            ctx_window: 100_000,
3212            ctx_name: "default".into(),
3213            system_prompt: String::new(),
3214        };
3215        let out = format_context_report(Some(&snap), "m", true);
3216        assert!(out.contains("=== SYSTEM PROMPT ==="));
3217        assert!(
3218            out.contains("(empty"),
3219            "empty cached prompt must show an explanation, got: {}",
3220            out
3221        );
3222    }
3223}