Skip to main content

atomcode_tuix/event_loop/
mod.rs

1// crates/atomcode-tuix/src/event_loop/mod.rs
2//
3// Main event-loop crate root. `run_loop` is the entry point from
4// `atomcode-tuix::run`; everything else in this module tree supports it.
5//
6// Layout:
7//   mod.rs       — App struct + LoopCtx + run_loop + input plumbing
8//                  (handle_input / handle_idle_key / handle_streaming_key /
9//                  handle_approval_key / redraw helpers), plus Buffer +
10//                  BufferResult + agent-event handler + spinner draw.
11//   commands.rs  — slash-command dispatcher + /login (OAuth child handoff)
12//
13// Over time more subfiles should split out (agent_events, redraw helpers,
14// Buffer); modal overlays already live in `crate::modals`.
15
16pub(crate) mod bg_runtime;
17pub(crate) mod commands;
18pub(crate) mod file_index;
19pub(crate) mod monitor;
20pub(crate) mod oauth_poll;
21pub(crate) mod usage_monitor;
22use commands::execute_slash_command;
23pub use commands::{perform_session_rename, validate_session_name, MAX_SESSION_NAME_LEN};
24
25use std::collections::VecDeque;
26use std::path::PathBuf;
27use std::time::Duration;
28
29use anyhow::Result;
30use atomcode_core::agent::{
31    AgentClient, AgentCommand, AgentEvent, AgentPhase, AgentRuntimeFactory,
32};
33use atomcode_core::config::Config;
34use atomcode_core::session::{SessionId, SessionManager};
35use crossterm::event::{KeyCode, KeyEvent, KeyEventKind};
36use tokio::sync::mpsc;
37
38use base64::Engine;
39use atomcode_core::conversation::message::ImagePart;
40
41use crate::commands::{parse_slash_line, CommandRegistry};
42use crate::input::history::History;
43use crate::input::key_action::{classify, Action};
44use crate::input::InputEvent;
45use crate::render::{Renderer, UiLine};
46use crate::state::{UiPhase, UiState};
47use crate::think::ThinkStripper;
48
49/// Encode raw RGBA pixel data as a PNG image in memory.
50fn encode_rgba_to_png(width: u32, height: u32, rgba: &[u8]) -> Option<Vec<u8>> {
51    let mut buf = Vec::new();
52    let mut encoder = png::Encoder::new(&mut buf, width, height);
53    encoder.set_color(png::ColorType::Rgba);
54    encoder.set_depth(png::BitDepth::Eight);
55    let mut writer = encoder.write_header().ok()?;
56    writer.write_image_data(rgba).ok()?;
57    drop(writer);
58    Some(buf)
59}
60
61/// Try to grab an image from the system clipboard via `arboard`.
62/// Returns `Some((ImagePart, fingerprint))` if the clipboard holds an
63/// image, `None` otherwise. The fingerprint is hashed off the raw RGBA
64/// Try to get an image from the clipboard. First attempts to read image
65/// bytes directly (screenshots, Preview Copy). If that fails, falls back
66/// to reading a file:// URL from the clipboard text (Finder Cmd+C case)
67/// and loading the image from that path.
68/// Returns the image data and a fingerprint hash.
69/// bytes (not the PNG-encoded base64) — same hash function the status
70/// poll uses, so paste-side and poll-side fingerprints line up for the
71/// "is this the same image we already attached?" check.
72fn try_paste_clipboard_image() -> Option<(ImagePart, u64)> {
73    // Three-tier fallback chain for Ctrl+V → image attach. Each tier
74    // covers a real-world clipboard shape Cmd+V already handled via
75    // bracketed paste; Ctrl+V is intercepted at the key layer before
76    // the terminal's paste pipeline runs, so we have to reproduce
77    // those shapes from the clipboard ourselves.
78    let mut clipboard = arboard::Clipboard::new().ok()?;
79
80    // Tier 1: raw bytes (NSPasteboardTypePNG / TIFF / NSImage).
81    //   Sources: Cmd+Shift+Ctrl+4 screenshot, Preview "Copy", browser
82    //   "Copy image", any app's Edit-menu Copy on a bitmap. arboard's
83    //   get_image decodes these into RGBA.
84    if let Ok(img) = clipboard.get_image() {
85        let hash = rgba_fingerprint(img.width, img.height, img.bytes.as_ref());
86        let png_data = encode_rgba_to_png(img.width as u32, img.height as u32, img.bytes.as_ref())?;
87        let b64 = base64::engine::general_purpose::STANDARD.encode(&png_data);
88        return Some((
89            ImagePart {
90                media_type: "image/png".into(),
91                data: b64,
92            },
93            hash,
94        ));
95    }
96
97    // Tier 2: file URL / path arriving via the text type (`public.utf8-
98    // plain-text` on macOS, `text/uri-list` on X11/Wayland through
99    // arboard's text bridge). Some apps mirror their file-URL into
100    // text as a courtesy ("Copy as path" tools, browser drag-source,
101    // certain file managers). Trim, strip `file://`, percent-decode,
102    // hand off to the existing path attachment helper.
103    if let Ok(text) = clipboard.get_text() {
104        let trimmed = text.trim();
105        let stripped = trimmed.strip_prefix("file://").unwrap_or(trimmed);
106        if let Ok(decoded) = urlencoding::decode(stripped) {
107            if let Some(result) = try_attach_image_from_path(&decoded) {
108                return Some(result);
109            }
110        }
111    }
112
113    // Tier 3 (macOS only): read NSPasteboard's `public.file-url` type
114    // directly. This is the case Finder `Cmd+C` on an image file
115    // produces — there are NO image bytes and the text type is
116    // typically NOT auto-populated. iTerm2's Cmd+V handles this by
117    // querying the file-URL type and writing the temp path to the
118    // PTY; we read the same type via AppKit so Ctrl+V matches.
119    #[cfg(target_os = "macos")]
120    if let Some(path) = read_macos_clipboard_file_url() {
121        if let Some(result) = try_attach_image_from_path(&path) {
122            return Some(result);
123        }
124    }
125
126    None
127}
128
129/// Pull plain text off the system clipboard for the Ctrl+V → text-paste
130/// fallback. Returns `None` when arboard fails to open the clipboard
131/// or the clipboard holds no text — the caller is expected to swallow
132/// the keystroke in that case rather than insert a literal `v`.
133///
134/// Why a dedicated helper instead of inlining `arboard::Clipboard::new`:
135/// the Ctrl+V branch already shells out to `try_paste_clipboard_image`,
136/// which itself opens a fresh `Clipboard` handle; symmetry keeps the
137/// two call sites readable, and the helper drops the handle promptly
138/// (some Windows clipboards lock briefly after a read).
139fn try_paste_clipboard_text() -> Option<String> {
140    let mut clipboard = arboard::Clipboard::new().ok()?;
141    clipboard.get_text().ok().filter(|s| !s.is_empty())
142}
143
144/// Read NSPasteboard's `public.file-url` type and return the decoded
145/// filesystem path. Returns `None` when the type isn't on the
146/// pasteboard, the value isn't a `file://` URL, or percent-decoding
147/// fails — caller should fall through, not abort.
148///
149/// Why AppKit instead of arboard: arboard 3.x doesn't expose any
150/// pasteboard type beyond `image` and `text`. Finder `Cmd+C` writes
151/// to `public.file-url` exclusively, so we have to query that type
152/// directly. The `objc2-app-kit` / `objc2-foundation` deps are
153/// already in the tree transitively (arboard pulls them on macOS),
154/// so this is cheap to add — just wires up a binding we own.
155#[cfg(target_os = "macos")]
156fn read_macos_clipboard_file_url() -> Option<String> {
157    use objc2_app_kit::{NSPasteboard, NSPasteboardTypeFileURL};
158    let pb = NSPasteboard::generalPasteboard();
159    let raw = unsafe { pb.stringForType(NSPasteboardTypeFileURL) }?.to_string();
160    let stripped = raw.strip_prefix("file://").unwrap_or(&raw);
161    let decoded = urlencoding::decode(stripped).ok()?;
162    Some(decoded.into_owned())
163}
164
165/// Map an `ImagePart::media_type` to a cache filename extension.
166/// Unknown MIMEs degrade to `bin` — they still round-trip via the
167/// stored `media_type` field on `HistoryImageRef`, so the extension is
168/// purely informational for humans poking at `~/.atomcode/image-cache/`.
169fn ext_for_mt(mt: &str) -> &'static str {
170    match mt {
171        "image/png" => "png",
172        "image/jpeg" => "jpg",
173        "image/webp" => "webp",
174        "image/gif" => "gif",
175        _ => "bin",
176    }
177}
178
179/// Best-effort cache write. Decodes `img.data` (base64) and persists
180/// the raw bytes to `<cache_dir>/<hex_hash>.<ext>`. Skips if the file
181/// already exists (cache is content-addressable). Failures are
182/// trace-logged and swallowed — the in-memory pending_images path is
183/// the source of truth for the current submit.
184fn cache_write_image(cache_dir: &std::path::Path, img: &atomcode_core::conversation::message::ImagePart, hash: u64) {
185    let path = cache_dir.join(format!("{:016x}.{}", hash, ext_for_mt(&img.media_type)));
186    if path.exists() {
187        return;
188    }
189    if let Err(e) = std::fs::create_dir_all(cache_dir) {
190        crate::tuix_trace!("IMG", "cache mkdir failed: {}", e);
191        return;
192    }
193    let raw = match base64::engine::general_purpose::STANDARD.decode(&img.data) {
194        Ok(b) => b,
195        Err(e) => {
196            crate::tuix_trace!("IMG", "cache base64 decode failed: {}", e);
197            return;
198        }
199    };
200    if let Err(e) = std::fs::write(&path, &raw) {
201        crate::tuix_trace!("IMG", "cache write failed: {}", e);
202    }
203}
204
205/// Compute the set of `[Image #N]` markers in `buf_text` whose `N`
206/// actually corresponds to image bytes that will be sent on submit.
207///
208/// Two sources count as "real attachment":
209///   1. Freshly-attached this session — the marker `N` lives in
210///      `state.pending_image_markers`, with bytes in
211///      `state.pending_images` at the same index.
212///   2. Cache-recalled via arrow-up — the marker `N` lives in
213///      `state.pending_recalled_attachments[*].n` (still using the
214///      saved-history numbering; will be renumbered on submit by
215///      `hydrate_recalled_attachments`).
216///
217/// Markers in `buf_text` that match neither (e.g. user typed
218/// `[Image #99]` literally as text) are excluded. Result preserves
219/// the order markers appear in `buf_text` and de-duplicates so the
220/// same marker referenced twice surfaces a single preview row.
221///
222/// Used by `redraw_idle_plain` / `draw_spinner_now` / similar to
223/// populate `UiLine::InputPrompt { attachments }`, which the
224/// renderer then turns into `└ [Image #N]` preview rows under the
225/// input box. Mirror of the post-submit echo (`UiLine::ImageAttachment`)
226/// — same visual treatment so users see the attachment status pre-
227/// AND post-submit identically.
228pub(crate) fn compute_input_attachments(
229    state: &crate::state::UiState,
230    buf_text: &str,
231) -> Vec<usize> {
232    let mut available: std::collections::HashSet<usize> =
233        state.pending_image_markers.iter().copied().collect();
234    for refed in &state.pending_recalled_attachments {
235        available.insert(refed.n);
236    }
237    if available.is_empty() {
238        return Vec::new();
239    }
240    // Walk `buf_text` once collecting `[Image #N]` markers in order.
241    // De-dupe while preserving first-occurrence order.
242    let mut seen: std::collections::HashSet<usize> = std::collections::HashSet::new();
243    let mut out = Vec::new();
244    let bytes = buf_text.as_bytes();
245    let needle = b"[Image #";
246    let mut i = 0;
247    while i + needle.len() < bytes.len() {
248        if &bytes[i..i + needle.len()] == needle {
249            let mut j = i + needle.len();
250            let mut n: usize = 0;
251            let mut had_digit = false;
252            while j < bytes.len() && bytes[j].is_ascii_digit() {
253                n = n.saturating_mul(10).saturating_add((bytes[j] - b'0') as usize);
254                j += 1;
255                had_digit = true;
256            }
257            if had_digit && j < bytes.len() && bytes[j] == b']' {
258                if available.contains(&n) && seen.insert(n) {
259                    out.push(n);
260                }
261                i = j + 1;
262                continue;
263            }
264        }
265        i += 1;
266    }
267    out
268}
269
270/// Drain `state.pending_recalled_attachments`. For each entry: read the
271/// cache file, allocate a fresh marker via `session_image_count`, rewrite
272/// `[Image #old]` → `[Image #new]` in `line`, and push into the live
273/// pending_* vecs. On cache miss, strip the marker and accumulate a
274/// notice string for the caller to render.
275///
276/// Returns the list of notice strings (empty when every attachment hit).
277pub(crate) fn hydrate_recalled_attachments(
278    state: &mut UiState,
279    line: &mut String,
280    cache_dir: &std::path::Path,
281) -> Vec<String> {
282    use base64::Engine;
283    let mut notices = Vec::new();
284    if state.pending_recalled_attachments.is_empty() {
285        return notices;
286    }
287    for refed in std::mem::take(&mut state.pending_recalled_attachments) {
288        let cache_path = cache_dir.join(format!("{}.{}", refed.hash, ext_for_mt(&refed.mt)));
289        match std::fs::read(&cache_path) {
290            Ok(raw) => {
291                state.session_image_count += 1;
292                let new_marker = state.session_image_count;
293                *line = line.replace(
294                    &format!("[Image #{}]", refed.n),
295                    &format!("[Image #{}]", new_marker),
296                );
297                let hash_u64 = u64::from_str_radix(&refed.hash, 16).unwrap_or(0);
298                state.pending_images.push(atomcode_core::conversation::message::ImagePart {
299                    media_type: refed.mt.clone(),
300                    data: base64::engine::general_purpose::STANDARD.encode(&raw),
301                });
302                state.pending_image_hashes.push(hash_u64);
303                state.pending_image_markers.push(new_marker);
304            }
305            Err(_) => {
306                *line = line.replace(&format!("[Image #{}]", refed.n), "");
307                notices.push(format!(
308                    "[Image #{}] 缓存已丢失,已从消息中移除",
309                    refed.n
310                ));
311            }
312        }
313    }
314    notices
315}
316
317/// Upper bound on a single attached image (20 MB raw bytes). OpenAI's
318/// chat/completions cap is 20 MB per image; Anthropic's is 5 MB. We pick
319/// the looser of the two as the tool-side gate so the attempt at least
320/// reaches the API — the server's 413 with a clearer reason is a better
321/// signal than a silent local rejection.
322const MAX_PATH_IMAGE_BYTES: u64 = 20 * 1024 * 1024;
323
324/// Try to interpret a paste payload as a filesystem path to an image
325/// file and load it as an [`ImagePart`]. Returns `Some` only when the
326/// payload looks unambiguously like an image-attachment intent, never
327/// for plain prose that happens to mention a file name.
328///
329/// The two real-world flows this covers:
330///
331/// 1. **iTerm2 Cmd+V on image clipboard** — iTerm2 saves the clipboard
332///    image to a temp file under `/var/folders/.../T/com.googlecode.iterm2/`
333///    and pastes the **file path** as plaintext through the PTY. The
334///    image bytes never travel through `InputEvent::Paste`'s text payload
335///    or through the system clipboard's "text" slot, so the existing
336///    `try_paste_clipboard_image()` empty-text fallback wouldn't fire.
337///    Recognising the path is the only way to attach the image. This is
338///    the workflow Claude Code / Aider / cursor-cli all support.
339/// 2. **Finder drag-and-drop into the terminal** — terminal types the
340///    file's absolute path as plaintext, optionally quoted (paths with
341///    spaces wrap in `'...'`) or shell-escaped (`\ ` for spaces).
342///
343/// Acceptance criteria — all must hold:
344/// * Single-line content (no `\n`).
345/// * After trimming + stripping balanced outer quotes + unescaping
346///   `\<space>`, the remainder is an absolute path.
347/// * Extension is one of png/jpg/jpeg/gif/webp (case-insensitive).
348/// * The path resolves to an existing regular file.
349/// * File size ≤ `MAX_PATH_IMAGE_BYTES`.
350///
351/// Returns `None` for anything that fails any of these — including
352/// legitimate text pastes, relative paths (a literal `notes.png` typed
353/// at the prompt is ambiguous: text or attachment?), missing files, and
354/// oversized files.
355///
356/// The fingerprint is hashed off the raw file bytes via the same
357/// [`rgba_fingerprint`] helper. Identical paste of the same path
358/// produces the same hash so the dedup check in `pending_image_hashes`
359/// works; collisions with a clipboard-paste of the same image (which
360/// hashes RGBA, not file bytes) are out of scope — the hash is a
361/// per-source dedup signal, not a global content identity.
362fn try_attach_image_from_path(text: &str) -> Option<(ImagePart, u64)> {
363    let trimmed = text.trim();
364    if trimmed.is_empty() || trimmed.contains('\n') {
365        return None;
366    }
367    // Strip a single layer of matched outer quotes. Finder drag of paths
368    // containing spaces wraps in `'...'`; some shells produce `"..."`.
369    let unquoted: &str = if trimmed.len() >= 2
370        && ((trimmed.starts_with('\'') && trimmed.ends_with('\''))
371            || (trimmed.starts_with('"') && trimmed.ends_with('"')))
372    {
373        &trimmed[1..trimmed.len() - 1]
374    } else {
375        trimmed
376    };
377    // Unescape shell-escaped spaces (iTerm2 / drag-and-drop emit
378    // `/path/with\ space.png`). Backslash before any other char is left
379    // alone — no other shell-escape forms occur in real-world drag
380    // pastes.
381    let unescaped = unquoted.replace("\\ ", " ");
382    let candidate = unescaped.trim();
383    let path = std::path::Path::new(candidate);
384    if !path.is_absolute() {
385        return None;
386    }
387    let media_type = match path
388        .extension()
389        .and_then(|e| e.to_str())
390        .map(|s| s.to_ascii_lowercase())
391        .as_deref()
392    {
393        Some("png") => "image/png",
394        Some("jpg") | Some("jpeg") => "image/jpeg",
395        Some("gif") => "image/gif",
396        Some("webp") => "image/webp",
397        _ => return None,
398    };
399    let meta = std::fs::metadata(path).ok()?;
400    if !meta.is_file() || meta.len() > MAX_PATH_IMAGE_BYTES {
401        return None;
402    }
403    let bytes = std::fs::read(path).ok()?;
404    let hash = rgba_fingerprint(0, 0, &bytes);
405    let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
406    Some((
407        ImagePart {
408            media_type: media_type.into(),
409            data: b64,
410        },
411        hash,
412    ))
413}
414
415#[cfg(test)]
416mod image_path_tests {
417    use super::*;
418    use std::io::Write as _;
419    use tempfile::tempdir;
420
421    /// Materialise a small file at `<dir>/<name>` whose contents are
422    /// `bytes`. Returned absolute path is what the user-facing paste
423    /// detector sees on iTerm2 Cmd+V or Finder drag.
424    fn write_tmp_file(dir: &tempfile::TempDir, name: &str, bytes: &[u8]) -> std::path::PathBuf {
425        let p = dir.path().join(name);
426        let mut f = std::fs::File::create(&p).expect("create tmp file");
427        f.write_all(bytes).expect("write tmp file");
428        p
429    }
430
431    /// PNG path → ImagePart with `image/png` media type. The single
432    /// happy-path covering iTerm2's Cmd+V-of-image temp-file shape.
433    #[test]
434    fn png_path_attaches_as_image_png() {
435        let dir = tempdir().unwrap();
436        let p = write_tmp_file(&dir, "snap.png", b"\x89PNG\r\n\x1a\nstub-bytes");
437        let res = try_attach_image_from_path(p.to_str().unwrap());
438        let (img, _) = res.expect("PNG path must be recognised");
439        assert_eq!(img.media_type, "image/png");
440        assert!(!img.data.is_empty(), "base64 data must be populated");
441    }
442
443    /// JPG and JPEG both map to `image/jpeg` (case-insensitive ext).
444    #[test]
445    fn jpg_and_jpeg_map_to_image_jpeg() {
446        let dir = tempdir().unwrap();
447        for name in ["a.jpg", "b.JPEG", "c.Jpg"] {
448            let p = write_tmp_file(&dir, name, b"\xff\xd8\xff\xe0\x00\x10JFIF stub");
449            let (img, _) = try_attach_image_from_path(p.to_str().unwrap())
450                .unwrap_or_else(|| panic!("expected attachment for {name}"));
451            assert_eq!(
452                img.media_type, "image/jpeg",
453                "{name} must map to image/jpeg"
454            );
455        }
456    }
457
458    /// Quoted absolute path (Finder drag of paths-with-spaces) is
459    /// recognised after a single layer of outer quotes is stripped.
460    /// Both ASCII single and double quotes are accepted.
461    #[test]
462    fn quoted_absolute_path_is_recognised() {
463        let dir = tempdir().unwrap();
464        let p = write_tmp_file(&dir, "shot with space.png", b"stub");
465        let path_str = p.to_str().unwrap();
466        let single_quoted = format!("'{}'", path_str);
467        let double_quoted = format!("\"{}\"", path_str);
468        assert!(try_attach_image_from_path(&single_quoted).is_some());
469        assert!(try_attach_image_from_path(&double_quoted).is_some());
470    }
471
472    /// Shell-escaped spaces (`\ `) are unescaped before fs lookup —
473    /// matches the form some terminals emit on drag-and-drop.
474    #[test]
475    fn shell_escaped_space_is_unescaped() {
476        let dir = tempdir().unwrap();
477        let p = write_tmp_file(&dir, "shot with space.png", b"stub");
478        let abs = p.to_str().unwrap();
479        // Replace each space in the absolute path with `\<space>` to
480        // simulate the drag-paste form.
481        let escaped = abs.replace(' ', "\\ ");
482        assert!(
483            try_attach_image_from_path(&escaped).is_some(),
484            "shell-escaped path must be unescaped before fs lookup"
485        );
486    }
487
488    /// Trailing whitespace (iTerm2 often appends a space after the
489    /// path) must not defeat detection.
490    #[test]
491    fn trailing_whitespace_is_trimmed() {
492        let dir = tempdir().unwrap();
493        let p = write_tmp_file(&dir, "snap.png", b"stub");
494        let with_trailing_ws = format!("{}   \t  ", p.to_str().unwrap());
495        assert!(try_attach_image_from_path(&with_trailing_ws).is_some());
496    }
497
498    /// Same path pasted twice → same fingerprint, so the dedup check in
499    /// `pending_image_hashes` works.
500    #[test]
501    fn same_path_yields_same_fingerprint() {
502        let dir = tempdir().unwrap();
503        let p = write_tmp_file(&dir, "snap.png", b"stub-bytes");
504        let path_str = p.to_str().unwrap();
505        let (_, h1) = try_attach_image_from_path(path_str).unwrap();
506        let (_, h2) = try_attach_image_from_path(path_str).unwrap();
507        assert_eq!(h1, h2, "deterministic hash for the same file");
508    }
509
510    /// Plain prose containing words must NOT be treated as a path.
511    #[test]
512    fn prose_paste_is_not_an_image() {
513        assert!(try_attach_image_from_path("hello world").is_none());
514        assert!(try_attach_image_from_path("see /tmp/notes for context").is_none());
515        assert!(try_attach_image_from_path("").is_none());
516        assert!(try_attach_image_from_path("   ").is_none());
517    }
518
519    /// Multi-line paste (real text content) is rejected — the path
520    /// detector is a single-line gate.
521    #[test]
522    fn multi_line_paste_is_not_an_image() {
523        let two_lines = "/tmp/snap.png\nsecond line";
524        assert!(try_attach_image_from_path(two_lines).is_none());
525    }
526
527    /// Relative paths are ambiguous (could be intentional text). Must
528    /// not be auto-attached — only absolute paths flip the switch.
529    #[test]
530    fn relative_path_is_not_attached() {
531        assert!(try_attach_image_from_path("snap.png").is_none());
532        assert!(try_attach_image_from_path("./snap.png").is_none());
533        assert!(try_attach_image_from_path("../snap.png").is_none());
534    }
535
536    /// Non-image extensions are rejected even when the file exists.
537    /// Defends against the user pasting an absolute path to a `.txt` /
538    /// `.json` / etc. — that's clearly text-attachment intent, not
539    /// image-attachment intent.
540    #[test]
541    fn non_image_extension_is_rejected() {
542        let dir = tempdir().unwrap();
543        let p = write_tmp_file(&dir, "notes.txt", b"hello");
544        assert!(try_attach_image_from_path(p.to_str().unwrap()).is_none());
545        let p2 = write_tmp_file(&dir, "data.json", b"{}");
546        assert!(try_attach_image_from_path(p2.to_str().unwrap()).is_none());
547    }
548
549    /// Absolute path with image extension but no file on disk — the
550    /// paste was just a literal-looking path string that happens to
551    /// match the shape. Reject so we don't silently swallow the text.
552    #[test]
553    fn missing_file_is_rejected() {
554        // Nonexistent path under a real tempdir prefix — guaranteed
555        // unique and unwriteable in normal test layout.
556        assert!(
557            try_attach_image_from_path("/this/path/definitely/does/not/exist/snap.png").is_none()
558        );
559    }
560
561    /// Files larger than `MAX_PATH_IMAGE_BYTES` are rejected. The cap
562    /// is the looser of OpenAI / Anthropic's per-image limits — beyond
563    /// it, server-side rejection is certain and round-tripping the
564    /// payload wastes bandwidth.
565    #[test]
566    fn oversized_file_is_rejected() {
567        let dir = tempdir().unwrap();
568        let huge = vec![0u8; (MAX_PATH_IMAGE_BYTES + 1) as usize];
569        let p = write_tmp_file(&dir, "huge.png", &huge);
570        assert!(
571            try_attach_image_from_path(p.to_str().unwrap()).is_none(),
572            "files over MAX_PATH_IMAGE_BYTES must be rejected before read"
573        );
574    }
575}
576
577#[cfg(test)]
578mod compute_input_attachments_tests {
579    use super::compute_input_attachments;
580    use crate::input::history::HistoryImageRef;
581    use crate::state::UiState;
582
583    fn recalled(n: usize) -> HistoryImageRef {
584        HistoryImageRef {
585            hash: "0".repeat(16),
586            mt: "image/png".into(),
587            n,
588        }
589    }
590
591    #[test]
592    fn fresh_paste_marker_emits_preview() {
593        let mut s = UiState::default();
594        s.pending_image_markers.push(3);
595        let attachments = compute_input_attachments(&s, "look [Image #3] here");
596        assert_eq!(attachments, vec![3]);
597    }
598
599    #[test]
600    fn cache_recalled_marker_emits_preview() {
601        let mut s = UiState::default();
602        s.pending_recalled_attachments.push(recalled(7));
603        let attachments = compute_input_attachments(&s, "[Image #7] from history");
604        assert_eq!(attachments, vec![7]);
605    }
606
607    #[test]
608    fn typed_marker_with_no_pending_emits_no_preview() {
609        let s = UiState::default();
610        let attachments = compute_input_attachments(&s, "I typed [Image #99] literally");
611        assert!(attachments.is_empty(), "literal text must not surface a preview row");
612    }
613
614    #[test]
615    fn marker_deleted_from_buffer_disappears_from_preview() {
616        let mut s = UiState::default();
617        s.pending_image_markers.push(1);
618        let with_marker = compute_input_attachments(&s, "see [Image #1]");
619        assert_eq!(with_marker, vec![1]);
620        let without_marker = compute_input_attachments(&s, "no marker now");
621        assert!(without_marker.is_empty(), "removing marker text must drop preview row");
622    }
623
624    #[test]
625    fn duplicate_markers_dedup_to_first_occurrence() {
626        let mut s = UiState::default();
627        s.pending_image_markers.push(2);
628        let attachments = compute_input_attachments(&s, "[Image #2] then [Image #2] again");
629        assert_eq!(attachments, vec![2], "same marker referenced twice must surface a single preview row");
630    }
631
632    #[test]
633    fn preserves_first_occurrence_order_across_sources() {
634        let mut s = UiState::default();
635        s.pending_image_markers.push(5);
636        s.pending_recalled_attachments.push(recalled(3));
637        let attachments = compute_input_attachments(&s, "first [Image #5] then [Image #3]");
638        assert_eq!(attachments, vec![5, 3], "preview rows follow buffer text order, not source order");
639    }
640}
641
642#[derive(Debug, Clone)]
643pub struct McpReloadProgress {
644    pub total: usize,
645    pub done: usize,
646    pub connected: usize,
647    pub failed: usize,
648    pub started_at: std::time::Instant,
649}
650
651/// Bag of handles passed into the loop.
652pub struct LoopCtx {
653    pub config: Config,
654    pub model_name: String,
655    pub agent: AgentClient,
656    pub runtime_factory: AgentRuntimeFactory,
657    pub bg_manager: bg_runtime::BgRuntimeManager,
658    pub foreground_runtime_id: bg_runtime::RuntimeId,
659    pub runtime_event_tx: mpsc::UnboundedSender<bg_runtime::RuntimeEvent>,
660    pub runtime_event_rx: mpsc::UnboundedReceiver<bg_runtime::RuntimeEvent>,
661    pub working_dir: PathBuf,
662    pub previous_dir: Option<PathBuf>,
663    /// Recently visited project directories, most recent first (max 5).
664    /// Persisted to `~/.atomcode/recent_dirs.txt`. Drives the `/cd`
665    /// picker when invoked with no argument and is updated whenever
666    /// the working directory changes (via slash command or agent tool).
667    pub recent_dirs: Vec<PathBuf>,
668    pub history: History,
669    pub input_rx: mpsc::UnboundedReceiver<InputEvent>,
670    pub commands: CommandRegistry,
671    pub session_manager: SessionManager,
672    /// Session actively being accumulated. Updated on TurnComplete /
673    /// TurnCancelled (both carry the latest `messages` slice), saved to
674    /// disk via `session_manager` on the same events so `/resume` after
675    /// a quit sees the conversation. Replaced wholesale when the user
676    /// resumes another session via `/resume` + SessionPicker.
677    pub current_session: atomcode_core::session::Session,
678    /// Shared "new version available" hint. Populated by the detached
679    /// version-check task spawned from `run()`; read by `build_status`
680    /// on each redraw. `None` = no hint (either check still pending,
681    /// network failed silently, or already up to date).
682    pub update_hint: std::sync::Arc<std::sync::Mutex<Option<String>>>,
683    /// Shared CodingPlan drift-monitor warning slot. Written by the
684    /// detached check task (see `monitor::spawn_check`); read by
685    /// `build_status` on each redraw. Takes precedence over `update_hint`
686    /// so a drift warning isn't buried by an upgrade banner. Cleared
687    /// when `/codingplan` persists a fresh config (re-sync resets the
688    /// hint state).
689    pub monitor_warning: std::sync::Arc<std::sync::Mutex<Option<monitor::CodingPlanWarning>>>,
690    /// Last time a monitor check was fired this session. Pre-turn
691    /// triggers respect `monitor::CHECK_COOLDOWN` (15 min) against this
692    /// timestamp; startup + `/model` switch bypass the cooldown.
693    /// `None` = no check has run yet this session.
694    pub monitor_last_check_at: Option<std::time::Instant>,
695    /// CodingPlan token-usage snapshot. Populated by
696    /// `usage_monitor::spawn_check` at startup and after each
697    /// TurnComplete (30s cooldown). Read on every redraw to construct
698    /// the right-aligned usage hint when usage_percent ≥ 80% and the
699    /// current model is on a CodingPlan provider.
700    pub usage_slot: std::sync::Arc<
701        std::sync::Mutex<Option<atomcode_core::coding_plan::types::UsageInfo>>,
702    >,
703    /// Last time `usage_monitor::spawn_check` was invoked. Used to
704    /// enforce `usage_monitor::USAGE_COOLDOWN` on TurnComplete-triggered
705    /// refreshes. `None` = no check has run yet this session.
706    pub usage_last_check_at: Option<std::time::Instant>,
707    /// Last-observed timestamp from the shared CodingPlan sync marker
708    /// (`~/.atomcode/codingplan_sync.json`). On every user input we
709    /// re-read it; a change means ANOTHER atomcode process (e.g. a
710    /// second terminal) just ran `/codingplan` and the server is now
711    /// in sync with the on-disk config. We then hot-reload config
712    /// from disk + clear the stale drift warning. Without this,
713    /// Terminal A's "CodingPlan 模型列表更新" hint would stick forever
714    /// after Terminal B ran the fix.
715    pub monitor_last_sync_seen: Option<std::time::SystemTime>,
716    /// Wake signal from background tasks (version check + CodingPlan
717    /// drift monitor). One `()` sent when any task needs the event loop
718    /// to repaint so a freshly-computed hint/warning appears without
719    /// waiting for the user's next keystroke. Bounded at 1 — overlapping
720    /// wakes coalesce since the redraw is idempotent.
721    pub wake_rx: mpsc::Receiver<()>,
722    /// Sender side of `wake_rx`. Cloned into every spawned check task
723    /// so `/model` switches, pre-turn triggers, and the like can wake
724    /// the event loop after updating `monitor_warning`.
725    pub wake_tx: mpsc::Sender<()>,
726    /// Receiver for `OauthEvent`s emitted by the QR-fast-path onboarding
727    /// poll thread (see `event_loop::oauth_poll`). One event arrives
728    /// per spawned poll task (Authorized or Failed). The `tokio::select!`
729    /// arm that reads this channel closes the wizard modal + flips
730    /// `pending_run_codingplan` on Authorized, or surfaces the failure
731    /// reason in scrollback on Failed.
732    pub oauth_event_rx: mpsc::UnboundedReceiver<oauth_poll::OauthEvent>,
733    /// Sender cloned into each spawned poll task.
734    pub oauth_event_tx: mpsc::UnboundedSender<oauth_poll::OauthEvent>,
735    /// Control handle for the crossterm reader thread — `Some` in raw-mode
736    /// TTY sessions, `None` in pipe mode. Used by child-process handoffs
737    /// (OAuth login, future `/shell`) to pause+resume event consumption
738    /// so our reader doesn't race the child for stdin bytes.
739    pub reader: Option<crate::input::reader::ReaderHandle>,
740    /// Sender used by `/upgrade` to report streaming progress/failure
741    /// events from the detached upgrade task. Cloned into the task at
742    /// spawn time; kept here so the receiver in the loop outlives any
743    /// number of upgrades (no reconstructing on each invocation).
744    pub upgrade_tx: mpsc::UnboundedSender<atomcode_core::self_update::UpgradeEvent>,
745    /// Consumed in the main `select!` so upgrade progress is rendered
746    /// alongside agent events.
747    pub upgrade_rx: mpsc::UnboundedReceiver<atomcode_core::self_update::UpgradeEvent>,
748    /// Long-lived channel for /plugin marketplace add|update and /plugin
749    /// install. Each invocation spawns a blocking task that does the git
750    /// clone/pull and pushes a `PluginJobEvent` here when done. Mirrors the
751    /// `upgrade_tx`/`rx` layout so the event loop only has to add a single
752    /// `select!` arm. Unbounded — events are tiny terminal results.
753    pub plugin_job_tx: mpsc::UnboundedSender<atomcode_core::plugin::PluginJobEvent>,
754    pub plugin_job_rx: mpsc::UnboundedReceiver<atomcode_core::plugin::PluginJobEvent>,
755    /// Signal channel from the `/issue` wizard modal back to the event
756    /// loop. The wizard's Enter handler can't touch `App` directly
757    /// (modals only see `LoopCtx`), so it stores the collected title +
758    /// body here, returns `Close`, and the event loop's post-close
759    /// branch POSTs the issue to AtomGit and echoes the URL of the
760    /// newly-created issue back into the conversation.
761    pub pending_new_issue: Option<NewIssueDraft>,
762    /// Set by `OnboardingWizard` (step 3, Setup) when the user picks
763    /// option 0 (Set up CodingPlan). The event loop drains this on
764    /// modal close and runs the full CodingPlan setup flow (login if
765    /// needed → claim → fetch models → register providers). Needs
766    /// raw-mode suspend/resume, something modals can't drive
767    /// themselves. Same pattern as `pending_new_issue`.
768    pub pending_run_codingplan: bool,
769    /// Set by `OnboardingWizard` (step 3, Setup) when the user picks
770    /// option 1 (Configure manually). The event loop drains this on
771    /// modal close and swaps in `ProviderWizard::MainMenu` — a
772    /// Modal-to-Modal transition that needs mutable `active_modal`
773    /// access only the event loop has.
774    pub pending_open_provider_wizard: bool,
775    /// MCP server registry for `/mcp` status display. `None` when no MCP
776    /// servers are configured or all failed to connect.
777    pub mcp_registry: Option<std::sync::Arc<atomcode_core::mcp::McpRegistry>>,
778    /// Channel for receiving MCP connection status events (Connected/Failed).
779    /// Events are rendered into scrollback as they arrive during startup.
780    pub mcp_connect_rx:
781        Option<tokio::sync::mpsc::UnboundedReceiver<atomcode_core::mcp::McpConnectEvent>>,
782    /// When `/mcp reload` is invoked, we track progress until every configured
783    /// server reports Connected/Failed, then emit a one-line summary.
784    pub mcp_reload: Option<McpReloadProgress>,
785    /// Channel for receiving LSP connection status events (Started / Failed
786    /// / Warning). Same plumbing as `mcp_connect_rx` — wired in TUI mode
787    /// so the manager's start failures land in scrollback as `✗ LSP server
788    /// 'rust-analyzer' for .rs failed: ...` instead of leaking to stderr
789    /// and printing inside the input box.
790    pub lsp_connect_rx: Option<tokio::sync::mpsc::UnboundedReceiver<atomcode_core::lsp::LspConnectEvent>>,
791    /// Telemetry handle — used to emit `UseCommand` at each slash dispatch.
792    pub telemetry: std::sync::Arc<atomcode_telemetry::Telemetry>,
793    /// Original working dir before `/worktree create`, for `/worktree done`.
794    pub worktree_original_dir: Option<PathBuf>,
795    /// User-defined custom commands loaded from `~/.atomcode/commands/` and
796    /// `<project>/.atomcode/commands/`. Queried by the slash-command
797    /// dispatcher as a fallback when the entered name doesn't match a
798    /// built-in command.
799    pub custom_commands: atomcode_core::commands::CustomCommandRegistry,
800    /// Loaded skills (`.claude/skills/*/SKILL.md`, etc.). Same `Arc`
801    /// the agent loop holds, so `reload(...)` there is visible here
802    /// without extra plumbing. Used by the slash-command palette to
803    /// surface user-invocable skills, and by the dispatcher to expand
804    /// `/skill_name [args]` into a SendMessage.
805    pub skill_registry: std::sync::Arc<std::sync::RwLock<atomcode_core::skill::SkillRegistry>>,
806    /// Snapshot of the terminal's rendering capabilities. Probed once at
807    /// startup in `lib.rs`; threaded into `App::new` so `UiState` knows
808    /// whether to use Unicode or ASCII fallbacks for the spinner glyph
809    /// and ellipsis. Same value as `RetainedRenderer` was constructed
810    /// with — single source of truth.
811    pub caps: crate::terminal::TerminalCaps,
812    /// Session loaded by the CLI auto-continue path (`atomcode -c` /
813    /// `--continue`). Replayed into scrollback AND restored into the
814    /// agent's model context via `AgentCommand::SetMessages` on first
815    /// `run_loop` entry, then dropped — matching `/resume` behaviour.
816    pub replay_on_start: Option<atomcode_core::session::Session>,
817    /// Lazy file/dir index for `@`-mention popup. Built on first `@`
818    /// keystroke via `FileIndex::filter`; session-life cache.
819    pub file_index: file_index::FileIndex,
820    /// Active session id once `/resume` has loaded one. Required by the
821    /// `/rename` slash command to know which session file to update.
822    pub current_session_id: Option<SessionId>,
823    /// Cached "clipboard currently holds an image" flag, with a short TTL
824    /// so the right-aligned `Image in clipboard · ctrl+v to paste` hint
825    /// stays current without thrashing the system clipboard on every
826    /// redraw. Refreshed lazily inside `build_status`.
827    pub clipboard_check: std::sync::Arc<std::sync::Mutex<ClipboardCheckState>>,
828    /// `true` when the TUI was launched with `PlainRenderer` (CI / pipe
829    /// / non-TTY). The onboarding wizard checks this — plain mode can't
830    /// run interactive multi-step flows, so first-run falls through to
831    /// the existing "no provider configured" status hint.
832    pub is_plain_renderer: bool,
833}
834
835/// Memoised result of the most recent clipboard probe. The hash is a
836/// content fingerprint of the clipboard image's raw RGBA bytes (or
837/// `None` when the clipboard holds no image). Letting `build_status`
838/// compare this against `UiState::pending_image_hashes` is what powers
839/// the "hide hint after I already pasted THIS image, but show it again
840/// if the user copies a different one" UX.
841#[derive(Debug, Default)]
842pub struct ClipboardCheckState {
843    pub image_hash: Option<u64>,
844    pub last_checked: Option<std::time::Instant>,
845}
846
847/// Cheap content fingerprint for clipboard images. Hashes width, height,
848/// total byte length, plus the first and last 1KB of RGBA bytes — enough
849/// to distinguish typical screenshots while keeping the per-poll cost
850/// O(2KB) regardless of image dimensions (a 4K screenshot's 32MB raw
851/// buffer would be too slow to hash in full at 1.5s polling cadence).
852fn rgba_fingerprint(width: usize, height: usize, bytes: &[u8]) -> u64 {
853    use std::hash::{Hash, Hasher};
854    let mut hasher = std::collections::hash_map::DefaultHasher::new();
855    width.hash(&mut hasher);
856    height.hash(&mut hasher);
857    bytes.len().hash(&mut hasher);
858    let head_end = bytes.len().min(1024);
859    bytes[..head_end].hash(&mut hasher);
860    let tail_start = bytes.len().saturating_sub(1024);
861    bytes[tail_start..].hash(&mut hasher);
862    hasher.finish()
863}
864
865/// What the `/issue` wizard hands back to the event loop after the user
866/// finishes step 2. The event loop turns this into a `POST /repos/.../issues`
867/// API call and echoes the resulting issue URL into scrollback.
868#[derive(Debug, Clone)]
869pub struct NewIssueDraft {
870    pub owner: String,
871    pub repo: String,
872    pub title: String,
873    pub body: String,
874}
875
876/// Line-edit buffer for input composition. Byte-indexed cursor.
877///
878/// Large pasted blocks are folded into `[Pasted #N +M lines]` placeholders
879/// stored in `text`; the original contents live in `pastes` and are
880/// spliced back in when the line is submitted. This keeps the visible
881/// input short (matching CC's paste UX) without truncating what the
882/// agent actually sees.
883pub struct Buffer {
884    pub text: String,
885    pub cursor: usize,
886    history_idx: Option<usize>,
887    stash: String,
888    /// Placeholder index → original pasted text. Index 0 = paste #1.
889    pastes: Vec<String>,
890}
891
892/// Minimum line count or char count for a paste to fold into a
893/// placeholder. Smaller pastes are inserted inline — no point hiding
894/// 3 lines behind a `[Pasted ...]` token.
895const PASTE_FOLD_LINES: usize = 5;
896const PASTE_FOLD_CHARS: usize = 400;
897
898/// Fold `\r\n` and lone `\r` line endings to `\n`. Bracketed-paste
899/// payloads from macOS Terminal / iTerm2 / Windows clipboard frequently
900/// carry CR separators; leaving them in place makes `str::lines()` miss
901/// line breaks and can confuse downstream JSON/prompt serialisation.
902fn normalize_newlines(s: &str) -> String {
903    s.replace("\r\n", "\n").replace('\r', "\n")
904}
905
906impl Buffer {
907    fn new() -> Self {
908        Self {
909            text: String::new(),
910            cursor: 0,
911            history_idx: None,
912            stash: String::new(),
913            pastes: Vec::new(),
914        }
915    }
916
917    /// True while the user is scrolling input history (Up/Down on an
918    /// empty / non-empty buffer). The slash-command menu suppresses
919    /// itself in this state so that recalling a previous `/session foo`
920    /// from history doesn't immediately re-pop the menu and trap Up
921    /// inside it. Cleared automatically by `Insert` / `Cancel` (typing
922    /// or Esc) and by `HistoryNext` returning past the newest entry
923    /// to the user's stashed draft.
924    pub fn is_in_history(&self) -> bool {
925        self.history_idx.is_some()
926    }
927
928    /// The index into history of the entry currently being displayed,
929    /// or `None` if the buffer is showing the user's own draft. Used
930    /// by `event_loop` to look up `HistoryEntry::images` after every
931    /// `apply()` so `pending_recalled_attachments` mirrors what the
932    /// buffer is showing.
933    pub fn history_idx(&self) -> Option<usize> {
934        self.history_idx
935    }
936
937    /// Insert a pasted block. Folds into a `[Pasted …]` placeholder if
938    /// the block exceeds the fold threshold, keeping the visible input
939    /// terse. Returns the placeholder that was inserted (or the raw
940    /// text for small pastes) so callers can advance the cursor.
941    ///
942    /// Single-line long pastes (e.g. a 600-char URL) use a `{N} chars`
943    /// summary — `+1 lines` would be misleading. Multi-line pastes use
944    /// `+{M} lines` which is what people expect for code blocks / diffs.
945    ///
946    /// **Line-ending normalisation:** most terminals in bracketed paste
947    /// mode emit `\r` (or `\r\n`) between lines rather than `\n`. Without
948    /// normalising, a 20-line paste looks like one gigantic line to
949    /// `str::lines()` (returning count 1), and downstream agents may
950    /// mis-handle payloads that mix CR-only separators. We fold `\r\n`
951    /// and lone `\r` to `\n` at ingress so both the placeholder summary
952    /// and the expanded agent payload are in canonical form.
953    pub fn insert_paste(&mut self, text: String) -> String {
954        let text = normalize_newlines(&text);
955        let line_count = text.lines().count().max(1);
956        let char_count = text.chars().count();
957        if line_count >= PASTE_FOLD_LINES || char_count >= PASTE_FOLD_CHARS {
958            let id = self.pastes.len() + 1;
959            let placeholder = if line_count <= 1 {
960                format!("[Pasted #{} {} chars]", id, char_count)
961            } else {
962                format!("[Pasted #{} +{} lines]", id, line_count)
963            };
964            self.pastes.push(text);
965            self.text.insert_str(self.cursor, &placeholder);
966            self.cursor += placeholder.len();
967            placeholder
968        } else {
969            let n = text.len();
970            self.text.insert_str(self.cursor, &text);
971            self.cursor += n;
972            text
973        }
974    }
975
976    /// Expand every `[Pasted #N +M lines]` token in `line` back to the
977    /// original paste contents. Called at submit time — the agent gets
978    /// the full pasted payload, while history/display keeps the compact
979    /// form.
980    fn expand_pastes(&self, line: &str) -> String {
981        if self.pastes.is_empty() {
982            return line.to_string();
983        }
984        let mut out = String::with_capacity(line.len());
985        let mut rest = line;
986        while let Some(start) = rest.find("[Pasted #") {
987            out.push_str(&rest[..start]);
988            let tail = &rest[start..];
989            if let Some(end) = tail.find(']') {
990                // Parse id from "[Pasted #N +M lines]"
991                let header = &tail[..=end];
992                let id_part = header
993                    .strip_prefix("[Pasted #")
994                    .and_then(|s| s.split_whitespace().next());
995                if let Some(id_str) = id_part {
996                    if let Ok(id) = id_str.parse::<usize>() {
997                        if id >= 1 && id <= self.pastes.len() {
998                            out.push_str(&self.pastes[id - 1]);
999                            rest = &tail[end + 1..];
1000                            continue;
1001                        }
1002                    }
1003                }
1004                // Malformed or out-of-range token — leave as-is.
1005                out.push_str(header);
1006                rest = &tail[end + 1..];
1007            } else {
1008                out.push_str(tail);
1009                rest = "";
1010                break;
1011            }
1012        }
1013        out.push_str(rest);
1014        out
1015    }
1016
1017    fn clear_pastes(&mut self) {
1018        self.pastes.clear();
1019    }
1020
1021    pub(crate) fn apply(
1022        &mut self,
1023        action: Action,
1024        history: &[crate::input::history::HistoryEntry],
1025        commands: &CommandRegistry,
1026    ) -> BufferResult {
1027        match action {
1028            Action::Insert(c) => {
1029                self.text.insert(self.cursor, c);
1030                self.cursor += c.len_utf8();
1031                self.history_idx = None;
1032                BufferResult::Redraw
1033            }
1034            Action::Submit => {
1035                // Line continuation: a `\` immediately before the cursor
1036                // is consumed and replaced with `\n`. Lets users insert
1037                // newlines on terminals that swallow Shift/Ctrl/Alt+Enter
1038                // (notably WSL + Windows Terminal). Mirrors Claude Code's
1039                // behavior and matches the shell line-continuation
1040                // convention Linux users already know.
1041                if self.cursor > 0 && self.text.as_bytes()[self.cursor - 1] == b'\\' {
1042                    let bs = self.cursor - 1;
1043                    self.text.replace_range(bs..self.cursor, "\n");
1044                    self.cursor = bs + 1;
1045                    self.history_idx = None;
1046                    return BufferResult::Redraw;
1047                }
1048                let line = self.text.trim().to_string();
1049                if line.is_empty() {
1050                    return BufferResult::Redraw;
1051                }
1052                BufferResult::Commit(line)
1053            }
1054            Action::InsertNewline => {
1055                self.text.insert(self.cursor, '\n');
1056                self.cursor += 1;
1057                BufferResult::Redraw
1058            }
1059            Action::Cancel => {
1060                if self.text.is_empty() {
1061                    BufferResult::Exit
1062                } else {
1063                    self.text.clear();
1064                    self.cursor = 0;
1065                    self.history_idx = None;
1066                    self.pastes.clear();
1067                    BufferResult::Redraw
1068                }
1069            }
1070            Action::ClearLine => {
1071                self.text.clear();
1072                self.cursor = 0;
1073                self.pastes.clear();
1074                BufferResult::Redraw
1075            }
1076            Action::DeleteWordBackward => {
1077                let before = &self.text[..self.cursor];
1078                let trimmed = before.trim_end_matches(' ');
1079                let word_start = trimmed.rfind(' ').map(|i| i + 1).unwrap_or(0);
1080                self.text.drain(word_start..self.cursor);
1081                self.cursor = word_start;
1082                BufferResult::Redraw
1083            }
1084            Action::DeleteToEnd => {
1085                let end = self.text[self.cursor..]
1086                    .find('\n')
1087                    .map(|i| self.cursor + i)
1088                    .unwrap_or(self.text.len());
1089                self.text.drain(self.cursor..end);
1090                BufferResult::Redraw
1091            }
1092            Action::Backspace => {
1093                if self.cursor > 0 {
1094                    let p = prev_boundary(&self.text, self.cursor);
1095                    self.text.drain(p..self.cursor);
1096                    self.cursor = p;
1097                }
1098                BufferResult::Redraw
1099            }
1100            Action::DeleteForward => {
1101                if self.cursor < self.text.len() {
1102                    let n = next_boundary(&self.text, self.cursor);
1103                    self.text.drain(self.cursor..n);
1104                }
1105                BufferResult::Redraw
1106            }
1107            Action::CursorLeft => {
1108                if self.cursor > 0 {
1109                    self.cursor = prev_boundary(&self.text, self.cursor);
1110                }
1111                BufferResult::Redraw
1112            }
1113            Action::CursorRight => {
1114                if self.cursor < self.text.len() {
1115                    self.cursor = next_boundary(&self.text, self.cursor);
1116                }
1117                BufferResult::Redraw
1118            }
1119            Action::LineStart => {
1120                let start = self.text[..self.cursor]
1121                    .rfind('\n')
1122                    .map(|i| i + 1)
1123                    .unwrap_or(0);
1124                self.cursor = start;
1125                BufferResult::Redraw
1126            }
1127            Action::LineEnd => {
1128                let end = self.text[self.cursor..]
1129                    .find('\n')
1130                    .map(|i| self.cursor + i)
1131                    .unwrap_or(self.text.len());
1132                self.cursor = end;
1133                BufferResult::Redraw
1134            }
1135            Action::HistoryPrev => {
1136                if history.is_empty() {
1137                    return BufferResult::Redraw;
1138                }
1139                // The current buffer (including any newlines) is stashed
1140                // before we replace it with a history entry, so users
1141                // who pressed Up mid-multi-line-compose can recover it
1142                // via HistoryNext (Down). No need to block the action.
1143                let new_idx = match self.history_idx {
1144                    None => {
1145                        self.stash = self.text.clone();
1146                        Some(history.len() - 1)
1147                    }
1148                    Some(i) if i > 0 => Some(i - 1),
1149                    Some(i) => Some(i),
1150                };
1151                self.history_idx = new_idx;
1152                if let Some(i) = new_idx {
1153                    self.text = history[i].text.clone();
1154                    // Park cursor at column 0 — recalled history is for
1155                    // re-running, not editing in place. A `/session foo`
1156                    // pulled from history would otherwise leave the
1157                    // cursor at end and re-trigger the slash menu via
1158                    // `is_in_history()`-gated logic; keeping it at 0
1159                    // mirrors Claude Code's behaviour and feels
1160                    // consistent with "this is recalled text, scroll
1161                    // again to keep going".
1162                    self.cursor = 0;
1163                }
1164                BufferResult::Redraw
1165            }
1166            Action::HistoryNext => {
1167                if let Some(i) = self.history_idx {
1168                    if i + 1 < history.len() {
1169                        // Still inside history — same cursor-at-0 rule
1170                        // as HistoryPrev.
1171                        self.history_idx = Some(i + 1);
1172                        self.text = history[i + 1].text.clone();
1173                        self.cursor = 0;
1174                    } else {
1175                        // Past the newest entry — restore the user's
1176                        // stashed draft. Cursor goes to end so they
1177                        // can keep typing where they left off before
1178                        // they started scrolling.
1179                        self.history_idx = None;
1180                        self.text = self.stash.clone();
1181                        self.cursor = self.text.len();
1182                    }
1183                }
1184                BufferResult::Redraw
1185            }
1186            Action::Complete => {
1187                if self.text.starts_with('/') {
1188                    let prefix = &self.text[1..];
1189                    let matches = commands.matching_prefix(prefix);
1190                    if matches.len() == 1 {
1191                        self.text = format!("/{} ", matches[0].name);
1192                        self.cursor = self.text.len();
1193                    }
1194                    // Could also show a list for multiple matches; omit for v1.
1195                }
1196                BufferResult::Redraw
1197            }
1198            Action::NoOp => BufferResult::NoOp,
1199            Action::ToggleToolOutput => BufferResult::NoOp,
1200        }
1201    }
1202
1203    /// Try to move the cursor up one logical line, preserving the
1204    /// column (measured in display cells so CJK lines up). Returns
1205    /// `false` only when the cursor is already at byte 0 — caller
1206    /// can then fall through to history navigation. Designed for the
1207    /// `Up` keystroke in multi-line composition: pressing Up walks
1208    /// the cursor through the buffer's lines first, then snaps to
1209    /// the start of the first line, and only the next Up after that
1210    /// surfaces history. Costs one extra keystroke before history
1211    /// kicks in but rescues anyone who paged Up to fix a typo on
1212    /// line 1 from losing their draft.
1213    pub fn cursor_line_up(&mut self) -> bool {
1214        let cur_line_start = self.text[..self.cursor]
1215            .rfind('\n')
1216            .map(|i| i + 1)
1217            .unwrap_or(0);
1218        if cur_line_start == 0 {
1219            // Already on the first line. Snap to byte 0 first; only
1220            // the next Up after that falls through to HistoryPrev.
1221            if self.cursor > 0 {
1222                self.cursor = 0;
1223                return true;
1224            }
1225            return false;
1226        }
1227        let target_col = crate::width::display_width(&self.text[cur_line_start..self.cursor]);
1228        let prev_line_end = cur_line_start - 1;
1229        let prev_line_start = self.text[..prev_line_end]
1230            .rfind('\n')
1231            .map(|i| i + 1)
1232            .unwrap_or(0);
1233        self.cursor =
1234            prev_line_start + byte_offset_at_col(&self.text[prev_line_start..prev_line_end], target_col);
1235        true
1236    }
1237
1238    /// Mirror of [`cursor_line_up`] for `Down`. Returns `false` only
1239    /// when the cursor is already at `text.len()` — caller then
1240    /// falls through to HistoryNext. On the last logical line, Down
1241    /// first snaps to end-of-buffer; the keystroke after that hands
1242    /// off to history.
1243    pub fn cursor_line_down(&mut self) -> bool {
1244        let Some(rel_end) = self.text[self.cursor..].find('\n') else {
1245            if self.cursor < self.text.len() {
1246                self.cursor = self.text.len();
1247                return true;
1248            }
1249            return false;
1250        };
1251        let cur_line_start = self.text[..self.cursor]
1252            .rfind('\n')
1253            .map(|i| i + 1)
1254            .unwrap_or(0);
1255        let target_col = crate::width::display_width(&self.text[cur_line_start..self.cursor]);
1256        let next_line_start = self.cursor + rel_end + 1;
1257        let next_line_end = self.text[next_line_start..]
1258            .find('\n')
1259            .map(|i| next_line_start + i)
1260            .unwrap_or(self.text.len());
1261        self.cursor =
1262            next_line_start + byte_offset_at_col(&self.text[next_line_start..next_line_end], target_col);
1263        true
1264    }
1265}
1266
1267/// Find the byte offset within `line` at the first character whose
1268/// cumulative display width meets or exceeds `target_col`. If the line
1269/// is shorter than `target_col` cells, returns `line.len()` — the
1270/// caller clamps the cursor to the end of that shorter line.
1271fn byte_offset_at_col(line: &str, target_col: usize) -> usize {
1272    let mut acc = 0usize;
1273    for (i, ch) in line.char_indices() {
1274        if acc >= target_col {
1275            return i;
1276        }
1277        acc += unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
1278    }
1279    line.len()
1280}
1281
1282#[cfg(test)]
1283mod buffer_tests {
1284    use super::*;
1285
1286    #[test]
1287    fn small_paste_inserts_inline() {
1288        let mut b = Buffer::new();
1289        b.insert_paste("hi\n".to_string());
1290        assert_eq!(b.text, "hi\n");
1291        assert!(b.pastes.is_empty(), "small paste should not fold");
1292    }
1293
1294    #[test]
1295    fn large_paste_folds_into_placeholder() {
1296        let mut b = Buffer::new();
1297        let big = "line\n".repeat(10);
1298        b.insert_paste(big.clone());
1299        assert!(b.text.contains("[Pasted #1 +10 lines]"));
1300        assert_eq!(b.pastes, vec![big]);
1301    }
1302
1303    #[test]
1304    fn expand_pastes_restores_original() {
1305        let mut b = Buffer::new();
1306        let big = "line\n".repeat(10);
1307        b.insert_paste(big.clone());
1308        let committed = b.text.clone();
1309        let expanded = b.expand_pastes(&committed);
1310        assert_eq!(expanded, big);
1311    }
1312
1313    #[test]
1314    fn expand_pastes_is_noop_without_placeholders() {
1315        let b = Buffer::new();
1316        assert_eq!(b.expand_pastes("plain text"), "plain text");
1317    }
1318
1319    #[test]
1320    fn paste_with_cr_separators_folds_correctly() {
1321        // Bracketed-paste often uses \r between lines (esp. macOS
1322        // Terminal.app). Without normalising, str::lines() sees one
1323        // gigantic line and the placeholder misreports "+1 lines".
1324        let mut b = Buffer::new();
1325        let cr_paste: String = (1..=20).map(|i| format!("line{}\r", i)).collect();
1326        b.insert_paste(cr_paste.clone());
1327        assert!(
1328            b.text.contains("+20 lines"),
1329            "expected 20-line placeholder, got: {}",
1330            b.text
1331        );
1332        // Original stored in pastes[0] is normalised (no \r).
1333        assert!(!b.pastes[0].contains('\r'));
1334        // Expanded body round-trips with \n separators.
1335        let expanded = b.expand_pastes(&b.text);
1336        assert_eq!(expanded.lines().count(), 20);
1337    }
1338
1339    #[test]
1340    fn expand_handles_multiple_pastes_interleaved() {
1341        let mut b = Buffer::new();
1342        b.insert_paste("A\n".repeat(6));
1343        b.text.insert_str(b.cursor, " then ");
1344        b.cursor += 6;
1345        b.insert_paste("B\n".repeat(6));
1346        let line = b.text.clone();
1347        let out = b.expand_pastes(&line);
1348        assert!(out.contains("A\n"));
1349        assert!(out.contains(" then "));
1350        assert!(out.contains("B\n"));
1351        assert!(!out.contains("[Pasted"));
1352    }
1353
1354    /// Regression: `clear_pastes` then `expand_pastes` is the broken
1355    /// ordering that shipped before — the agent received the bare
1356    /// `[Pasted #N +M lines]` placeholder instead of the pasted body
1357    /// and (correctly) responded "I don't see any pasted content".
1358    /// Callers MUST expand FIRST, clear SECOND. This test pins that
1359    /// contract: if someone reintroduces an early clear, the
1360    /// substitution silently turns into a no-op and the
1361    /// `contains("important data")` assertion below catches it.
1362    #[test]
1363    fn clear_before_expand_loses_paste_body() {
1364        let mut b = Buffer::new();
1365        let body = "important data\n".repeat(200);
1366        b.insert_paste(body.clone());
1367        let line = b.text.clone();
1368        // Mis-ordered: clear first.
1369        b.clear_pastes();
1370        let expanded = b.expand_pastes(&line);
1371        assert!(
1372            expanded.contains("[Pasted #1"),
1373            "early-clear must leave the placeholder unsubstituted: {}",
1374            expanded
1375        );
1376        assert!(
1377            !expanded.contains("important data"),
1378            "early-clear must NOT magically still have the body: {}",
1379            expanded
1380        );
1381
1382        // Sanity check the correct order: expand first, then clear.
1383        let mut b = Buffer::new();
1384        b.insert_paste(body.clone());
1385        let line = b.text.clone();
1386        let expanded = b.expand_pastes(&line);
1387        b.clear_pastes();
1388        assert!(
1389            expanded.contains("important data"),
1390            "expand-before-clear must surface the body: {}",
1391            &expanded[..expanded.len().min(120)]
1392        );
1393        assert!(b.pastes.is_empty(), "clear after expand must still empty the registry");
1394    }
1395
1396    #[test]
1397    fn submit_with_trailing_backslash_inserts_newline() {
1398        // WSL + Windows Terminal swallows Shift/Ctrl/Alt+Enter, so we
1399        // give users a `\<Enter>` continuation escape. The `\` itself
1400        // must not survive into the buffer.
1401        let reg = CommandRegistry::builtin();
1402        let history: Vec<crate::input::history::HistoryEntry> = Vec::new();
1403        let mut b = Buffer::new();
1404        b.text = "hello\\".to_string();
1405        b.cursor = b.text.len();
1406        let r = b.apply(Action::Submit, &history, &reg);
1407        assert!(matches!(r, BufferResult::Redraw));
1408        assert_eq!(b.text, "hello\n");
1409        assert_eq!(b.cursor, b.text.len());
1410    }
1411
1412    #[test]
1413    fn submit_with_backslash_mid_buffer_inserts_newline_at_cursor() {
1414        let reg = CommandRegistry::builtin();
1415        let history: Vec<crate::input::history::HistoryEntry> = Vec::new();
1416        let mut b = Buffer::new();
1417        b.text = "abc\\def".to_string();
1418        b.cursor = 4; // right after the backslash
1419        let r = b.apply(Action::Submit, &history, &reg);
1420        assert!(matches!(r, BufferResult::Redraw));
1421        assert_eq!(b.text, "abc\ndef");
1422        assert_eq!(b.cursor, 4);
1423    }
1424
1425    #[test]
1426    fn submit_without_trailing_backslash_commits_normally() {
1427        let reg = CommandRegistry::builtin();
1428        let history: Vec<crate::input::history::HistoryEntry> = Vec::new();
1429        let mut b = Buffer::new();
1430        b.text = "ship it".to_string();
1431        b.cursor = b.text.len();
1432        let r = b.apply(Action::Submit, &history, &reg);
1433        match r {
1434            BufferResult::Commit(s) => assert_eq!(s, "ship it"),
1435            _ => panic!("expected Commit"),
1436        }
1437    }
1438
1439    #[test]
1440    fn submit_with_backslash_not_before_cursor_commits_normally() {
1441        // Backslash exists in the buffer but cursor isn't right after
1442        // it — Enter should still submit, not insert a newline.
1443        let reg = CommandRegistry::builtin();
1444        let history: Vec<crate::input::history::HistoryEntry> = Vec::new();
1445        let mut b = Buffer::new();
1446        b.text = "abc\\def".to_string();
1447        b.cursor = b.text.len(); // at end, byte before is 'f'
1448        let r = b.apply(Action::Submit, &history, &reg);
1449        match r {
1450            BufferResult::Commit(s) => assert_eq!(s, "abc\\def"),
1451            _ => panic!("expected Commit"),
1452        }
1453    }
1454}
1455
1456#[cfg(test)]
1457mod menu_tests {
1458    use super::*;
1459    use atomcode_core::commands::CustomCommandRegistry;
1460
1461    #[test]
1462    fn non_slash_input_returns_no_menu() {
1463        let reg = CommandRegistry::builtin();
1464        let custom = CustomCommandRegistry::empty();
1465        assert!(build_menu_items("hello world", 0, &reg, &custom, None, None).is_none());
1466    }
1467
1468    #[test]
1469    fn slash_prefix_returns_all_commands() {
1470        let reg = CommandRegistry::builtin();
1471        let custom = CustomCommandRegistry::empty();
1472        let items = build_menu_items("/", 0, &reg, &custom, None, None).expect("menu should show for '/'");
1473        assert!(!items.is_empty(), "builtin registry should have commands");
1474    }
1475
1476    #[test]
1477    fn slash_with_filter_narrows_list() {
1478        let reg = CommandRegistry::builtin();
1479        let custom = CustomCommandRegistry::empty();
1480        let all = build_menu_items("/", 0, &reg, &custom, None, None).unwrap();
1481        let filtered = build_menu_items("/he", 0, &reg, &custom, None, None).unwrap_or_default();
1482        assert!(
1483            filtered.len() < all.len(),
1484            "prefix '/he' should filter builtin commands"
1485        );
1486        // Every filtered entry must start with "he".
1487        for (name, _) in &filtered {
1488            assert!(
1489                name.starts_with("he"),
1490                "prefix filter leaked non-matching '{}'",
1491                name
1492            );
1493        }
1494    }
1495
1496    #[test]
1497    fn whitespace_after_slash_closes_menu() {
1498        // Once the user types args, menu goes away so arrow keys don't
1499        // start navigating a stale palette.
1500        let reg = CommandRegistry::builtin();
1501        let custom = CustomCommandRegistry::empty();
1502        assert!(build_menu_items("/cd ", 0, &reg, &custom, None, None).is_none());
1503        assert!(build_menu_items("/cd /tmp", 0, &reg, &custom, None, None).is_none());
1504    }
1505
1506    #[test]
1507    fn slash_with_no_matches_returns_none() {
1508        let reg = CommandRegistry::builtin();
1509        let custom = CustomCommandRegistry::empty();
1510        assert!(build_menu_items("/zzznomatch", 0, &reg, &custom, None, None).is_none());
1511    }
1512
1513    fn skill_fixture(name: &str, desc: &str, user_invocable: bool) -> atomcode_core::skill::Skill {
1514        atomcode_core::skill::Skill {
1515            name: name.to_string(),
1516            description: desc.to_string(),
1517            template: "do thing".to_string(),
1518            disable_model_invocation: false,
1519            user_invocable,
1520            argument_hint: None,
1521            allowed_tools: vec![],
1522            skill_dir: std::path::PathBuf::new(),
1523            source_path: std::path::PathBuf::new(),
1524        }
1525    }
1526
1527    #[test]
1528    fn top_level_hides_individual_skills() {
1529        // Regression for the two-level palette: typing /bra or any
1530        // bare-name prefix must NOT surface skills. They live behind
1531        // the `/skills` gateway so the top palette stays uncluttered.
1532        let reg = CommandRegistry::builtin();
1533        let custom = CustomCommandRegistry::empty();
1534        let mut skills = atomcode_core::skill::SkillRegistry::new();
1535        skills.register(skill_fixture("skills:brainstorming", "Brainstorm", true));
1536        skills.register(skill_fixture("skills:web-access", "Web", true));
1537        let lock = std::sync::RwLock::new(skills);
1538
1539        // /bra — no skill should appear; /bra falls through to "no
1540        // matches" since no built-in starts with bra either.
1541        assert!(
1542            build_menu_items("/bra", 0, &reg, &custom, Some(&lock), None).is_none(),
1543            "individual skills must not leak into the top-level menu"
1544        );
1545
1546        // /skills — only the built-in gateway entry, never the
1547        // individual skills.
1548        let items = build_menu_items("/skills", 0, &reg, &custom, Some(&lock), None)
1549            .expect("/skills must include the built-in gateway");
1550        assert!(items.iter().any(|(n, _)| n == "skills"));
1551        for (n, _) in &items {
1552            assert!(
1553                !n.contains(':'),
1554                "namespaced skill leaked into top-level: {}",
1555                n
1556            );
1557        }
1558    }
1559
1560    #[test]
1561    fn skills_sub_mode_lists_skills_under_bare_names() {
1562        // Once the user has typed `/skills ` (trailing space, normally
1563        // injected by the needs_args path on Enter), the palette
1564        // switches to second-level: bare skill names, ready to commit
1565        // as `/skills <name>`.
1566        let reg = CommandRegistry::builtin();
1567        let custom = CustomCommandRegistry::empty();
1568        let mut skills = atomcode_core::skill::SkillRegistry::new();
1569        skills.register(skill_fixture("skills:brainstorming", "Brainstorm", true));
1570        skills.register(skill_fixture("skills:web-access", "Web", true));
1571        let lock = std::sync::RwLock::new(skills);
1572
1573        let items = build_menu_items("/skills ", 0, &reg, &custom, Some(&lock), None)
1574            .expect("/skills (with space) must list skills");
1575        assert!(items.iter().any(|(n, _)| n == "skills:brainstorming"));
1576        assert!(items.iter().any(|(n, _)| n == "skills:web-access"));
1577        for (n, _) in &items {
1578            assert!(n.contains(':'), "sub-mode names must be qualified: {}", n);
1579        }
1580    }
1581
1582    #[test]
1583    fn skills_sub_mode_filters_by_bare_prefix() {
1584        // /skills bra narrows to brainstorming. /skills web narrows
1585        // to web-access. /skills zz returns no menu at all.
1586        let reg = CommandRegistry::builtin();
1587        let custom = CustomCommandRegistry::empty();
1588        let mut skills = atomcode_core::skill::SkillRegistry::new();
1589        skills.register(skill_fixture("skills:brainstorming", "Brainstorm", true));
1590        skills.register(skill_fixture("skills:web-access", "Web", true));
1591        let lock = std::sync::RwLock::new(skills);
1592
1593        let bra = build_menu_items("/skills bra", 0, &reg, &custom, Some(&lock), None)
1594            .expect("filter must produce a result");
1595        assert_eq!(bra.len(), 1);
1596        assert_eq!(bra[0].0, "skills:brainstorming");
1597
1598        let web = build_menu_items("/skills web", 0, &reg, &custom, Some(&lock), None)
1599            .expect("filter must produce a result");
1600        assert_eq!(web.len(), 1);
1601        assert_eq!(web[0].0, "skills:web-access");
1602
1603        assert!(build_menu_items("/skills zz", 0, &reg, &custom, Some(&lock), None).is_none());
1604    }
1605
1606    #[test]
1607    fn skills_sub_mode_hides_after_skill_name() {
1608        // /skills brainstorming why X — user is typing skill args now,
1609        // menu should disappear so arrow keys don't navigate stale entries.
1610        let reg = CommandRegistry::builtin();
1611        let custom = CustomCommandRegistry::empty();
1612        let mut skills = atomcode_core::skill::SkillRegistry::new();
1613        skills.register(skill_fixture("skills:brainstorming", "Brainstorm", true));
1614        let lock = std::sync::RwLock::new(skills);
1615
1616        assert!(build_menu_items("/skills brainstorming why", 0, &reg, &custom, Some(&lock), None).is_none());
1617    }
1618
1619    #[test]
1620    fn skills_sub_mode_excludes_hidden_skills() {
1621        // user_invocable=false skills must not surface in the sub-menu
1622        // either — they're LLM-only via the use_skill tool.
1623        let reg = CommandRegistry::builtin();
1624        let custom = CustomCommandRegistry::empty();
1625        let mut skills = atomcode_core::skill::SkillRegistry::new();
1626        skills.register(skill_fixture("skills:visible", "shown", true));
1627        skills.register(skill_fixture("skills:hidden", "hidden", false));
1628        let lock = std::sync::RwLock::new(skills);
1629
1630        let items = build_menu_items("/skills ", 0, &reg, &custom, Some(&lock), None)
1631            .expect("at least one visible skill should produce a menu");
1632        assert!(items.iter().any(|(n, _)| n == "skills:visible"));
1633        assert!(
1634            !items.iter().any(|(n, _)| n == "skills:hidden"),
1635            "user_invocable=false skill leaked into sub-menu"
1636        );
1637    }
1638
1639    #[test]
1640    fn no_skill_registry_is_no_op() {
1641        // Ensures the legacy call path (None) keeps working.
1642        let reg = CommandRegistry::builtin();
1643        let custom = CustomCommandRegistry::empty();
1644        let with_none = build_menu_items("/", 0, &reg, &custom, None, None).unwrap();
1645        let empty_skills = std::sync::RwLock::new(atomcode_core::skill::SkillRegistry::new());
1646        let with_empty = build_menu_items("/", 0, &reg, &custom, Some(&empty_skills), None).unwrap();
1647        assert_eq!(
1648            with_none.len(),
1649            with_empty.len(),
1650            "empty registry must produce same menu as None"
1651        );
1652    }
1653
1654    // Regression: HistoryPrev used to leave the cursor at end-of-text,
1655    // so a recalled `/session foo` from history would `is_in_history()`
1656    // true AND have the slash prefix — without the call-site gate, the
1657    // menu would auto-pop, trapping Up/Down inside it. The fix is twofold
1658    // (caller skips menu while in history; cursor parks at 0 to signal
1659    // "this is recalled, scroll again"). These two unit tests pin both.
1660    #[test]
1661    fn history_prev_parks_cursor_at_zero_and_marks_history_mode() {
1662        let mut buf = Buffer::new();
1663        let reg = CommandRegistry::builtin();
1664        let history = vec![crate::input::history::HistoryEntry { text: "/session foo".into(), images: vec![] }];
1665
1666        let _ = buf.apply(Action::HistoryPrev, &history, &reg);
1667
1668        assert_eq!(buf.text, "/session foo");
1669        assert_eq!(buf.cursor, 0, "cursor must park at 0 to suppress menu");
1670        assert!(buf.is_in_history(), "buffer must report history mode");
1671    }
1672
1673    #[test]
1674    fn cursor_line_up_walks_lines_then_signals_history_at_top() {
1675        // "1\n2\n3" with cursor after the trailing "3". Up should
1676        // walk: end-of-3 → end-of-2 → end-of-1 → start-of-1 → false.
1677        // The start-of-1 snap is the rescue step: even on a single-
1678        // line draft, Up first parks at column 0 before history nav
1679        // kicks in, so a fat-fingered Up can't silently swallow what
1680        // the user just typed.
1681        let mut buf = Buffer::new();
1682        buf.text = "1\n2\n3".into();
1683        buf.cursor = buf.text.len();
1684
1685        assert!(buf.cursor_line_up(), "first Up moves up from line 3");
1686        assert_eq!(&buf.text[..buf.cursor], "1\n2");
1687        assert!(buf.cursor_line_up(), "second Up moves up from line 2");
1688        assert_eq!(&buf.text[..buf.cursor], "1");
1689        assert!(
1690            buf.cursor_line_up(),
1691            "on the first line Up snaps to byte 0 before yielding"
1692        );
1693        assert_eq!(buf.cursor, 0);
1694        assert!(
1695            !buf.cursor_line_up(),
1696            "already at byte 0 → caller falls through to HistoryPrev"
1697        );
1698    }
1699
1700    #[test]
1701    fn cursor_line_down_walks_lines_then_signals_history_at_bottom() {
1702        let mut buf = Buffer::new();
1703        buf.text = "1\n2\n3".into();
1704        buf.cursor = 0;
1705
1706        assert!(buf.cursor_line_down(), "Down from line 1 → line 2");
1707        assert_eq!(&buf.text[..buf.cursor], "1\n");
1708        assert!(buf.cursor_line_down(), "Down from line 2 → line 3");
1709        assert_eq!(&buf.text[..buf.cursor], "1\n2\n");
1710        assert!(
1711            buf.cursor_line_down(),
1712            "on the last line Down snaps to end-of-buffer before yielding"
1713        );
1714        assert_eq!(buf.cursor, buf.text.len());
1715        assert!(
1716            !buf.cursor_line_down(),
1717            "already at end → caller falls through to HistoryNext"
1718        );
1719    }
1720
1721    #[test]
1722    fn cursor_line_up_snaps_to_start_on_single_line() {
1723        // Single-line draft: Up should pull the cursor to column 0
1724        // first; only the next Up after that surfaces history.
1725        let mut buf = Buffer::new();
1726        buf.text = "hello world".into();
1727        buf.cursor = 7; // inside the word "world"
1728
1729        assert!(buf.cursor_line_up(), "Up snaps to byte 0 on single line");
1730        assert_eq!(buf.cursor, 0);
1731        assert!(!buf.cursor_line_up(), "second Up yields to history");
1732    }
1733
1734    #[test]
1735    fn cursor_line_down_snaps_to_end_on_single_line() {
1736        let mut buf = Buffer::new();
1737        buf.text = "hello world".into();
1738        buf.cursor = 4;
1739
1740        assert!(buf.cursor_line_down(), "Down snaps to end on single line");
1741        assert_eq!(buf.cursor, buf.text.len());
1742        assert!(!buf.cursor_line_down(), "second Down yields to history");
1743    }
1744
1745    #[test]
1746    fn cursor_line_up_clamps_to_shorter_line() {
1747        // Column-preservation: cursor at col 5 on line 2 ("hello"),
1748        // line 1 is only "ab" — Up clamps to end of "ab".
1749        let mut buf = Buffer::new();
1750        buf.text = "ab\nhello".into();
1751        buf.cursor = buf.text.len(); // after final 'o'
1752
1753        assert!(buf.cursor_line_up());
1754        assert_eq!(buf.cursor, 2, "cursor clamps to end of shorter prev line");
1755    }
1756
1757    #[test]
1758    fn cursor_line_up_handles_cjk_width() {
1759        // 你好 = 2 chars but 4 display cells. Target column on line
1760        // 2 lands inside line 1's CJK run — should pick a char
1761        // boundary (no panic) and preserve visual column.
1762        let mut buf = Buffer::new();
1763        buf.text = "你好world\nabcd".into();
1764        // Move cursor to end of line 2 (col 4 → display width 4 →
1765        // lands at "你好" exactly on line 1).
1766        buf.cursor = buf.text.len();
1767        assert!(buf.cursor_line_up());
1768        // "你好" is the first 6 bytes (3 bytes per CJK char in UTF-8).
1769        assert_eq!(buf.cursor, 6);
1770    }
1771
1772    #[test]
1773    fn history_next_back_to_stash_restores_cursor_to_end() {
1774        let mut buf = Buffer::new();
1775        let reg = CommandRegistry::builtin();
1776        let history = vec![crate::input::history::HistoryEntry { text: "/session foo".into(), images: vec![] }];
1777
1778        // Type a partial draft, then scroll into history and back out.
1779        let _ = buf.apply(Action::Insert('h'), &history, &reg);
1780        let _ = buf.apply(Action::Insert('i'), &history, &reg);
1781        let _ = buf.apply(Action::HistoryPrev, &history, &reg);
1782        assert!(buf.is_in_history());
1783        let _ = buf.apply(Action::HistoryNext, &history, &reg);
1784
1785        // Past newest entry → restored stash with cursor at the end so
1786        // the user can keep typing where they left off.
1787        assert_eq!(buf.text, "hi");
1788        assert_eq!(buf.cursor, 2);
1789        assert!(!buf.is_in_history());
1790    }
1791
1792    #[test]
1793    fn typing_clears_history_mode() {
1794        // Sanity check — Insert resets history_idx, so the menu can
1795        // re-appear naturally once the user starts editing the recall.
1796        let mut buf = Buffer::new();
1797        let reg = CommandRegistry::builtin();
1798        let history = vec![crate::input::history::HistoryEntry { text: "/session foo".into(), images: vec![] }];
1799
1800        let _ = buf.apply(Action::HistoryPrev, &history, &reg);
1801        assert!(buf.is_in_history());
1802        let _ = buf.apply(Action::Insert('x'), &history, &reg);
1803        assert!(!buf.is_in_history());
1804    }
1805
1806    #[test]
1807    fn sync_recalled_attachments_mirrors_buffer_history_idx() {
1808        use crate::input::history::{HistoryEntry, HistoryImageRef};
1809        let history: Vec<HistoryEntry> = vec![
1810            HistoryEntry { text: "no img".into(), images: vec![] },
1811            HistoryEntry {
1812                text: "with img".into(),
1813                images: vec![HistoryImageRef {
1814                    hash: "deadbeef12345678".into(),
1815                    mt: "image/png".into(),
1816                    n: 1,
1817                }],
1818            },
1819        ];
1820        let reg = CommandRegistry::builtin();
1821        let mut buf = Buffer::new();
1822        let mut state = UiState::new();
1823        // ↑ once → newest entry (idx=1, has image).
1824        let _ = buf.apply(Action::HistoryPrev, &history, &reg);
1825        super::sync_recalled_attachments(&mut state, &buf, &history);
1826        assert_eq!(state.pending_recalled_attachments.len(), 1);
1827        // ↑ again → idx=0 (no images) → wholesale replace empties the vec.
1828        let _ = buf.apply(Action::HistoryPrev, &history, &reg);
1829        super::sync_recalled_attachments(&mut state, &buf, &history);
1830        assert!(state.pending_recalled_attachments.is_empty());
1831        // Type a char on an empty-images entry → history_idx clears
1832        // but the retain pass keeps the (already empty) vec empty.
1833        let _ = buf.apply(Action::Insert('a'), &history, &reg);
1834        super::sync_recalled_attachments(&mut state, &buf, &history);
1835        assert!(state.pending_recalled_attachments.is_empty());
1836    }
1837
1838    /// Regression: arrow-up recalls `[Image #1]这是什么?`, user appends
1839    /// ` 现在不清楚为啥...`, submits — the trailing edit must NOT drop
1840    /// the recalled image. Pre-fix, `Insert` cleared `history_idx` and
1841    /// the wholesale `clear()` wiped `pending_recalled_attachments`,
1842    /// so the marker text reached the model as literal `[Image #1]`
1843    /// without bytes. Post-fix, the retain pass keeps refs whose
1844    /// marker is still in `buf.text`.
1845    #[test]
1846    fn sync_recalled_attachments_retains_on_edit_when_marker_present() {
1847        use crate::input::history::{HistoryEntry, HistoryImageRef};
1848        let history: Vec<HistoryEntry> = vec![HistoryEntry {
1849            text: "[Image #1]hello".into(),
1850            images: vec![HistoryImageRef {
1851                hash: "deadbeef12345678".into(),
1852                mt: "image/png".into(),
1853                n: 1,
1854            }],
1855        }];
1856        let reg = CommandRegistry::builtin();
1857        let mut buf = Buffer::new();
1858        let mut state = UiState::new();
1859        let _ = buf.apply(Action::HistoryPrev, &history, &reg);
1860        super::sync_recalled_attachments(&mut state, &buf, &history);
1861        assert_eq!(state.pending_recalled_attachments.len(), 1);
1862        // Append a char — history_idx clears, but `[Image #1]` is still
1863        // in buf, so the recalled ref must survive.
1864        let _ = buf.apply(Action::Insert('!'), &history, &reg);
1865        super::sync_recalled_attachments(&mut state, &buf, &history);
1866        assert_eq!(
1867            state.pending_recalled_attachments.len(),
1868            1,
1869            "edit that leaves marker intact must preserve recalled ref"
1870        );
1871    }
1872
1873    /// Companion to the retain-on-edit test: when the user backspaces
1874    /// over the `[Image #N]` marker itself, the recalled ref tied to
1875    /// that marker should drop — otherwise `hydrate_recalled_attachments`
1876    /// would inject orphan bytes the user explicitly removed.
1877    #[test]
1878    fn sync_recalled_attachments_drops_when_marker_removed() {
1879        use crate::input::history::{HistoryEntry, HistoryImageRef};
1880        let history: Vec<HistoryEntry> = vec![HistoryEntry {
1881            text: "[Image #1]hi".into(),
1882            images: vec![HistoryImageRef {
1883                hash: "deadbeef12345678".into(),
1884                mt: "image/png".into(),
1885                n: 1,
1886            }],
1887        }];
1888        let reg = CommandRegistry::builtin();
1889        let mut buf = Buffer::new();
1890        let mut state = UiState::new();
1891        let _ = buf.apply(Action::HistoryPrev, &history, &reg);
1892        super::sync_recalled_attachments(&mut state, &buf, &history);
1893        assert_eq!(state.pending_recalled_attachments.len(), 1);
1894        // Replace the buffer text so the marker is gone — simulates the
1895        // user backspacing over `[Image #1]`. We use a direct edit
1896        // through Action::Insert + delete is overkill; mutating the
1897        // buf's text via a fresh Buffer simulates the same end state.
1898        // Drop history_idx by inserting a char then verify retain
1899        // strips the ref since the marker is no longer present.
1900        // We force buf.text to a no-marker string by replaying from
1901        // empty + Insert sequence:
1902        let mut buf2 = Buffer::new();
1903        let _ = buf2.apply(Action::Insert('h'), &history, &reg);
1904        let _ = buf2.apply(Action::Insert('i'), &history, &reg);
1905        // pending_recalled_attachments still has the entry from earlier
1906        // (state isn't reset between buffer swaps in the real loop —
1907        // sync runs on each apply).
1908        super::sync_recalled_attachments(&mut state, &buf2, &history);
1909        assert!(
1910            state.pending_recalled_attachments.is_empty(),
1911            "removed marker must drop the matching recalled ref"
1912        );
1913    }
1914
1915    #[test]
1916    fn cache_write_image_writes_and_is_idempotent() {
1917        use base64::Engine;
1918        let dir = tempfile::tempdir().unwrap();
1919        let img = atomcode_core::conversation::message::ImagePart {
1920            media_type: "image/png".into(),
1921            data: base64::engine::general_purpose::STANDARD.encode(b"hello"),
1922        };
1923        super::cache_write_image(dir.path(), &img, 0xdead_beef_1234_5678);
1924        let p = dir.path().join("deadbeef12345678.png");
1925        assert!(p.exists());
1926        assert_eq!(std::fs::read(&p).unwrap(), b"hello");
1927        // Calling again must not error and not change the file mtime.
1928        let mtime1 = std::fs::metadata(&p).unwrap().modified().unwrap();
1929        super::cache_write_image(dir.path(), &img, 0xdead_beef_1234_5678);
1930        let mtime2 = std::fs::metadata(&p).unwrap().modified().unwrap();
1931        assert_eq!(mtime1, mtime2, "second call must short-circuit on exists");
1932    }
1933
1934    #[test]
1935    fn hydrate_renumbers_and_rewrites_line() {
1936        use crate::input::history::HistoryImageRef;
1937        let dir = tempfile::tempdir().unwrap();
1938        let cache_dir = dir.path().to_path_buf();
1939        // Write a fake cache file at hash=deadbeef12345678.
1940        std::fs::write(cache_dir.join("deadbeef12345678.png"), b"\x89PNG").unwrap();
1941
1942        let mut state = UiState::new();
1943        state.session_image_count = 5; // current session already used #1..#5
1944        state.pending_recalled_attachments.push(HistoryImageRef {
1945            hash: "deadbeef12345678".into(),
1946            mt: "image/png".into(),
1947            n: 2, // recalled marker number from the saved entry
1948        });
1949        let mut line = "look at [Image #2] please".to_string();
1950        let notice = super::hydrate_recalled_attachments(&mut state, &mut line, &cache_dir);
1951
1952        assert_eq!(notice.len(), 0, "no cache miss expected");
1953        assert_eq!(line, "look at [Image #6] please", "marker renumbered to #6");
1954        assert_eq!(state.pending_images.len(), 1);
1955        assert_eq!(state.pending_image_markers, vec![6]);
1956        assert_eq!(state.session_image_count, 6);
1957        assert!(state.pending_recalled_attachments.is_empty());
1958    }
1959
1960    #[test]
1961    fn hydrate_strips_marker_on_cache_miss() {
1962        use crate::input::history::HistoryImageRef;
1963        let dir = tempfile::tempdir().unwrap();
1964        let cache_dir = dir.path().to_path_buf();
1965        // No cache file written → cache miss.
1966        let mut state = UiState::new();
1967        state.pending_recalled_attachments.push(HistoryImageRef {
1968            hash: "0000000000000000".into(),
1969            mt: "image/png".into(),
1970            n: 3,
1971        });
1972        let mut line = "before [Image #3] after".to_string();
1973        let notice = super::hydrate_recalled_attachments(&mut state, &mut line, &cache_dir);
1974
1975        assert_eq!(line, "before  after", "marker stripped on cache miss");
1976        assert_eq!(state.pending_images.len(), 0);
1977        assert!(state.pending_recalled_attachments.is_empty());
1978        assert_eq!(notice.len(), 1, "expected one cache-miss notice");
1979        assert!(notice[0].contains("[Image #3]"));
1980        assert!(notice[0].contains("缓存"));
1981    }
1982
1983    #[test]
1984    fn paste_submit_recall_submit_rehydrates_image() {
1985        use crate::input::history::{History, HistoryEntry, HistoryImageRef};
1986        use base64::Engine;
1987
1988        let tmp = tempfile::tempdir().unwrap();
1989        let cache_dir = tmp.path().join("image-cache");
1990        std::fs::create_dir(&cache_dir).unwrap();
1991        let hist_path = tmp.path().join("hist");
1992        let mut history = History::load_with_cache(&hist_path, cache_dir.clone());
1993
1994        // ── Turn 1: paste image, submit ────────────────────────────────
1995        let raw_bytes = b"\x89PNG\r\n\x1a\nfake".to_vec();
1996        let img = atomcode_core::conversation::message::ImagePart {
1997            media_type: "image/png".into(),
1998            data: base64::engine::general_purpose::STANDARD.encode(&raw_bytes),
1999        };
2000        let hash: u64 = 0xdead_beef_1234_5678;
2001        super::cache_write_image(&cache_dir, &img, hash);
2002        history.push(HistoryEntry {
2003            text: "describe [Image #1]".into(),
2004            images: vec![HistoryImageRef {
2005                hash: format!("{:016x}", hash),
2006                mt: img.media_type.clone(),
2007                n: 1,
2008            }],
2009        });
2010        history.save().unwrap();
2011        // GC must NOT delete our file (it's referenced).
2012        assert!(cache_dir.join(format!("{:016x}.png", hash)).exists());
2013
2014        // ── Reload (new "session") ─────────────────────────────────────
2015        let history2 = History::load_with_cache(&hist_path, cache_dir.clone());
2016        assert_eq!(history2.entries().len(), 1);
2017        assert_eq!(history2.entries()[0].images.len(), 1);
2018
2019        // ── Turn 2: simulate up-arrow + submit ─────────────────────────
2020        let mut state = UiState::new();
2021        // Up-arrow handler would do this:
2022        state.pending_recalled_attachments = history2.entries()[0].images.clone();
2023        let mut line = history2.entries()[0].text.clone();
2024        let notices = super::hydrate_recalled_attachments(&mut state, &mut line, &cache_dir);
2025        assert!(notices.is_empty(), "cache hit, no notice expected");
2026        assert_eq!(state.pending_images.len(), 1, "image rehydrated");
2027        let rehydrated = base64::engine::general_purpose::STANDARD
2028            .decode(&state.pending_images[0].data)
2029            .unwrap();
2030        assert_eq!(rehydrated, raw_bytes, "bytes round-trip exact");
2031        // Marker renumbered (recalled was #1, new session also starts at #1
2032        // but session_image_count was 0 → bumped to 1, so new marker = 1).
2033        assert_eq!(line, "describe [Image #1]");
2034        assert_eq!(state.pending_image_markers, vec![1]);
2035    }
2036
2037    #[test]
2038    fn hydrate_runs_for_streaming_queued_submit_too() {
2039        // Regression: handle_streaming_key's Commit branch must also
2040        // hydrate `pending_recalled_attachments` so a user who pressed
2041        // ↑ during streaming and queued the recalled message travels
2042        // with their image. Pre-fix, the queue carried empty images.
2043        use crate::input::history::HistoryImageRef;
2044        let dir = tempfile::tempdir().unwrap();
2045        let cache_dir = dir.path().to_path_buf();
2046        std::fs::write(cache_dir.join("deadbeef12345678.png"), b"\x89PNG").unwrap();
2047
2048        let mut state = UiState::new();
2049        state.pending_recalled_attachments.push(HistoryImageRef {
2050            hash: "deadbeef12345678".into(),
2051            mt: "image/png".into(),
2052            n: 4,
2053        });
2054        let mut line = "describe [Image #4]".to_string();
2055        let _ = super::hydrate_recalled_attachments(&mut state, &mut line, &cache_dir);
2056        // After hydrate, the line + pending state should match what the
2057        // queued-submit pending-drain loop expects to see.
2058        assert_eq!(state.pending_images.len(), 1);
2059        assert_eq!(line, "describe [Image #1]"); // first paste this session
2060        assert_eq!(state.pending_image_markers, vec![1]);
2061        assert!(line.contains("[Image #1]"), "marker survives in line for the survival filter");
2062    }
2063}
2064
2065#[cfg(test)]
2066mod tool_format_tests {
2067    use super::*;
2068
2069    #[test]
2070    fn fmt_elapsed_under_one_minute_uses_seconds_only() {
2071        assert_eq!(fmt_elapsed(0), "0s");
2072        assert_eq!(fmt_elapsed(999), "0s");
2073        assert_eq!(fmt_elapsed(1_000), "1s");
2074        assert_eq!(fmt_elapsed(45_500), "45s");
2075    }
2076
2077    #[test]
2078    fn fmt_elapsed_above_one_minute_splits_minutes_and_seconds() {
2079        assert_eq!(fmt_elapsed(60_000), "1m0s");
2080        assert_eq!(fmt_elapsed(141_000), "2m21s");
2081        assert_eq!(fmt_elapsed(342_000), "5m42s");
2082    }
2083
2084    #[test]
2085    fn display_tool_name_snake_to_pascal() {
2086        assert_eq!(display_tool_name("read_file"), "ReadFile");
2087        assert_eq!(display_tool_name("search_replace"), "SearchReplace");
2088        assert_eq!(display_tool_name("bash"), "Bash");
2089    }
2090
2091    #[test]
2092    fn display_tool_name_handles_edge_cases() {
2093        assert_eq!(display_tool_name(""), "");
2094        assert_eq!(display_tool_name("x"), "X");
2095        assert_eq!(display_tool_name("x_"), "X");
2096        assert_eq!(display_tool_name("_x"), "X");
2097    }
2098
2099    /// Short form strips the redundant noun suffix so batch UI shows
2100    /// `Read(mod.rs)` instead of `ReadFile(mod.rs)` — matches CC's
2101    /// function-call-style tool labels. Strip list is generic
2102    /// (`_file`, `_files`, `_directory`); other suffixes pass through
2103    /// untouched so `search_replace` stays `SearchReplace` (no
2104    /// disambiguation lost).
2105    #[test]
2106    fn display_tool_name_short_strips_redundant_noun() {
2107        assert_eq!(display_tool_name_short("read_file"), "Read");
2108        assert_eq!(display_tool_name_short("write_file"), "Write");
2109        assert_eq!(display_tool_name_short("edit_file"), "Edit");
2110        assert_eq!(display_tool_name_short("create_file"), "Create");
2111        assert_eq!(display_tool_name_short("list_directory"), "List");
2112        assert_eq!(display_tool_name_short("parallel_edit_files"), "ParallelEdit");
2113        // Suffixes not in strip list pass through.
2114        assert_eq!(display_tool_name_short("bash"), "Bash");
2115        assert_eq!(display_tool_name_short("grep"), "Grep");
2116        assert_eq!(display_tool_name_short("search_replace"), "SearchReplace");
2117        assert_eq!(display_tool_name_short("web_fetch"), "WebFetch");
2118        assert_eq!(display_tool_name_short("blast_radius"), "BlastRadius");
2119    }
2120
2121    #[test]
2122    fn format_tool_detail_read_file_basename() {
2123        let args = r#"{"file_path":"/abs/path/to/foo.rs"}"#;
2124        assert_eq!(format_tool_detail("read_file", args), "foo.rs");
2125    }
2126
2127    #[test]
2128    fn format_tool_detail_read_symbol_combines_symbol_and_file() {
2129        let args = r#"{"symbol":"parse","file_path":"src/lexer.rs"}"#;
2130        assert_eq!(format_tool_detail("read_symbol", args), "parse in lexer.rs");
2131    }
2132
2133    #[test]
2134    fn format_tool_detail_bash_truncates_at_500() {
2135        let args = format!(r#"{{"command":"{}"}}"#, "a".repeat(600));
2136        let out = format_tool_detail("bash", &args);
2137        // `truncate_with_ellipsis` preserves `max_cols-1` display columns
2138        // (499) then appends '…' (display width 1, 3 UTF-8 bytes).
2139        // Display width = 500, byte length = 502.
2140        assert_eq!(out.len(), 502, "byte length: 499 'a' + 3-byte '…'");
2141        assert!(out.ends_with('…'), "should end with Unicode ellipsis");
2142        assert_eq!(&out[..499], "a".repeat(499));
2143    }
2144
2145    #[test]
2146    fn format_tool_detail_bash_preserves_short_command() {
2147        let args = format!(r#"{{"command":"{}"}}"#, "a".repeat(500));
2148        let out = format_tool_detail("bash", &args);
2149        // Full command preserved — `push_body_prefixed` handles wrapping
2150        // for the committed body, and `build_inflight_tool_row` clips the
2151        // live spinner row to terminal width.
2152        assert_eq!(out, "a".repeat(500));
2153    }
2154
2155    #[test]
2156    fn format_tool_detail_unknown_tool_falls_back_to_common_keys() {
2157        // Unknown tool but args carry `file_path` — fallback uses it.
2158        let args = r#"{"file_path":"/tmp/a.txt","extra":"x"}"#;
2159        let out = format_tool_detail("my_custom_tool", args);
2160        assert!(!out.is_empty(), "fallback should find file_path");
2161    }
2162
2163    #[test]
2164    fn format_tool_detail_invalid_json_returns_empty() {
2165        let out = format_tool_detail("read_file", "not json");
2166        assert_eq!(out, "");
2167    }
2168
2169    #[test]
2170    fn format_tool_detail_search_replace_shows_arrow() {
2171        let args = r#"{"search":"bg-blue-600","replace":"bg-violet-600","glob":"*.vue"}"#;
2172        let out = format_tool_detail("search_replace", args);
2173        assert!(
2174            out.contains("bg-blue-600"),
2175            "should contain search term: got {:?}",
2176            out
2177        );
2178        assert!(
2179            out.contains("bg-violet-600"),
2180            "should contain replace term: got {:?}",
2181            out
2182        );
2183        assert!(
2184            out.contains("→"),
2185            "should contain arrow separator: got {:?}",
2186            out
2187        );
2188        assert!(
2189            out.contains("glob: *.vue"),
2190            "should contain glob info: got {:?}",
2191            out
2192        );
2193    }
2194
2195    #[test]
2196    fn format_tool_detail_search_replace_without_glob() {
2197        let args = r#"{"search":"oldFunc","replace":"newFunc"}"#;
2198        let out = format_tool_detail("search_replace", args);
2199        assert_eq!(out, "oldFunc → newFunc");
2200    }
2201
2202    #[test]
2203    fn format_tool_detail_search_replace_with_path() {
2204        let args = r#"{"search":"foo","replace":"bar","path":"/some/dir"}"#;
2205        let out = format_tool_detail("search_replace", args);
2206        assert!(
2207            out.contains("path: dir"),
2208            "should contain path basename: got {:?}",
2209            out
2210        );
2211    }
2212
2213    #[test]
2214    fn format_tool_detail_search_replace_dot_path_omitted() {
2215        let args = r#"{"search":"foo","replace":"bar","path":"."}"#;
2216        let out = format_tool_detail("search_replace", args);
2217        assert!(
2218            !out.contains("path:"),
2219            "default '.' path should be omitted: got {:?}",
2220            out
2221        );
2222    }
2223
2224    // ── disambiguate_batch_details tests (issue #437) ──
2225
2226    #[test]
2227    fn disambiguate_no_duplicates_returns_as_is() {
2228        let names = vec!["read_file", "read_file"];
2229        let args = vec![
2230            r#"{"file_path":"/a/foo.rs"}"#,
2231            r#"{"file_path":"/b/bar.rs"}"#,
2232        ];
2233        let details = vec!["foo.rs".to_string(), "bar.rs".to_string()];
2234        let result = disambiguate_batch_details(&names, &args, &details);
2235        assert_eq!(result, vec!["foo.rs", "bar.rs"]);
2236    }
2237
2238    #[test]
2239    fn disambiguate_same_basename_adds_parent_dir() {
2240        // Issue #437: three SKILL.md files in different directories
2241        let names = vec!["read_file", "read_file", "read_file"];
2242        let args = vec![
2243            r#"{"file_path":"/home/.atomcode/skills/atomcode-automation-recommender/SKILL.md"}"#,
2244            r#"{"file_path":"/home/.atomcode/skills/tosshub-skill/SKILL.md"}"#,
2245            r#"{"file_path":"/home/.atomcode/skills/zouwu-skill/SKILL.md"}"#,
2246        ];
2247        let details = vec![
2248            "SKILL.md".to_string(),
2249            "SKILL.md".to_string(),
2250            "SKILL.md".to_string(),
2251        ];
2252        let result = disambiguate_batch_details(&names, &args, &details);
2253        // Each should include its parent directory to disambiguate
2254        assert_eq!(
2255            result,
2256            vec![
2257                "atomcode-automation-recommender/SKILL.md",
2258                "tosshub-skill/SKILL.md",
2259                "zouwu-skill/SKILL.md",
2260            ]
2261        );
2262    }
2263
2264    #[test]
2265    fn disambiguate_partial_duplicates_only_touches_dups() {
2266        // Two same-name files + one unique file
2267        let names = vec!["read_file", "read_file", "read_file"];
2268        let args = vec![
2269            r#"{"file_path":"/a/mod.rs"}"#,
2270            r#"{"file_path":"/b/mod.rs"}"#,
2271            r#"{"file_path":"/c/unique.rs"}"#,
2272        ];
2273        let details = vec![
2274            "mod.rs".to_string(),
2275            "mod.rs".to_string(),
2276            "unique.rs".to_string(),
2277        ];
2278        let result = disambiguate_batch_details(&names, &args, &details);
2279        // unique.rs is left unchanged
2280        assert_eq!(result[2], "unique.rs");
2281        // mod.rs entries get parent dir
2282        assert_eq!(result[0], "a/mod.rs");
2283        assert_eq!(result[1], "b/mod.rs");
2284    }
2285
2286    #[test]
2287    fn disambiguate_no_path_uses_hash_suffix() {
2288        // Non-file tools that produce duplicate details
2289        let names = vec!["bash", "bash"];
2290        let args = vec![r#"{"command":"echo hi"}"#, r#"{"command":"echo hi"}"#];
2291        let details = vec!["echo hi".to_string(), "echo hi".to_string()];
2292        let result = disambiguate_batch_details(&names, &args, &details);
2293        // First stays the same, second gets #2 suffix
2294        assert_eq!(result[0], "echo hi");
2295        assert_eq!(result[1], "echo hi #2");
2296    }
2297
2298    #[test]
2299    fn tail_path_basic() {
2300        assert_eq!(tail_path("a/b/c/SKILL.md", 0), "SKILL.md");
2301        assert_eq!(tail_path("a/b/c/SKILL.md", 1), "c/SKILL.md");
2302        assert_eq!(tail_path("a/b/c/SKILL.md", 2), "b/c/SKILL.md");
2303        assert_eq!(tail_path("a/b/c/SKILL.md", 3), "a/b/c/SKILL.md");
2304        // depth exceeding path depth returns full path
2305        assert_eq!(tail_path("a/b/c/SKILL.md", 10), "a/b/c/SKILL.md");
2306    }
2307
2308    #[test]
2309    fn tail_path_no_parent() {
2310        // File with no directory component
2311        assert_eq!(tail_path("foo.rs", 0), "foo.rs");
2312        assert_eq!(tail_path("foo.rs", 1), "foo.rs");
2313    }
2314
2315    #[test]
2316    fn disambiguate_long_path_is_truncated() {
2317        // Very deep duplicate paths should be truncated to 100 display cols
2318        let long_seg = "a".repeat(30); // 30 chars each
2319        let path1 = format!("/x/{}/{}/mod.rs", long_seg, long_seg);
2320        let path2 = format!("/y/{}/{}/mod.rs", long_seg, long_seg);
2321        let names = vec!["read_file", "read_file"];
2322        let args: Vec<String> = vec![
2323            format!(r#"{{"file_path":"{}"}}"#, path1),
2324            format!(r#"{{"file_path":"{}"}}"#, path2),
2325        ];
2326        let args_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
2327        let details = vec!["mod.rs".to_string(), "mod.rs".to_string()];
2328        let result = disambiguate_batch_details(&names, &args_refs, &details);
2329        // Both entries should be truncated — no entry exceeds 100 display width
2330        for entry in &result {
2331            assert!(
2332                crate::width::display_width(entry) <= 100,
2333                "entry too wide ({} cols): {}",
2334                crate::width::display_width(entry),
2335                entry,
2336            );
2337        }
2338        // And they should still be different from each other
2339        assert_ne!(result[0], result[1]);
2340    }
2341
2342    #[test]
2343    fn summarise_single_line_returned_as_is() {
2344        assert_eq!(summarise("ok", true), "ok");
2345    }
2346
2347    #[test]
2348    fn summarise_multi_line_adds_line_count() {
2349        let out = summarise("first line\nsecond line\nthird line", true);
2350        assert!(out.starts_with("first line"));
2351        assert!(out.contains("(3 lines)"));
2352    }
2353
2354    #[test]
2355    fn summarise_empty_string_has_fallback() {
2356        let out = summarise("", true);
2357        // Empty input: `lines()` yields nothing, so first falls back
2358        // to "(no output)" and n==0 means no " (N lines)" suffix.
2359        assert!(out.contains("(no output)"), "got: {}", out);
2360    }
2361
2362    /// Reproduces the bug: a long error message ending in a deep WSL
2363    /// path used to silently truncate to 80 cols, leaving `f_stor`
2364    /// instead of `f_store` with no `…` to indicate the cut. Failures
2365    /// now get a 200-col budget so the path stays intact, and any
2366    /// truncation that does happen is visibly marked with `…`.
2367    #[test]
2368    fn summarise_failure_keeps_long_path_intact() {
2369        let err = "Error: old_string not found in \
2370            /mnt/d/docs/work/cangjie/projects/fountain/f_store.";
2371        let out = summarise(err, false);
2372        assert!(
2373            out.contains("/mnt/d/docs/work/cangjie/projects/fountain/f_store"),
2374            "the full path must survive the summary. got: {}",
2375            out
2376        );
2377        assert!(
2378            !out.contains("f_stor "),
2379            "must not produce mid-token truncation like `f_stor ` (note the \
2380            trailing space — that's where (N lines) would attach). got: {}",
2381            out
2382        );
2383    }
2384
2385    /// Sanity check: success summaries still respect the tighter
2386    /// 80-col cap (we don't want to flood the body with full status
2387    /// output on every successful tool call). When that cap *does*
2388    /// truncate, the ellipsis must appear — that was the second leg
2389    /// of the fix beyond just enlarging the budget.
2390    #[test]
2391    fn summarise_success_truncates_with_ellipsis_at_80() {
2392        let long: String = "x".repeat(200);
2393        let out = summarise(&long, true);
2394        // 80 col cap means at most 80 chars of x, plus the ellipsis.
2395        assert!(
2396            out.ends_with('…'),
2397            "ellipsis is the visible-truncation marker. got: {}",
2398            out
2399        );
2400        assert!(out.chars().count() <= 80);
2401    }
2402
2403    /// Failure summaries keep the line-count suffix when the original
2404    /// was multi-line — the budget bump shouldn't change that behaviour.
2405    #[test]
2406    fn summarise_failure_multi_line_still_appends_count() {
2407        let err = "Error: foo\nbar\nbaz";
2408        let out = summarise(err, false);
2409        assert!(out.starts_with("Error: foo"));
2410        assert!(out.contains("(3 lines)"));
2411    }
2412}
2413
2414pub(crate) enum BufferResult {
2415    NoOp,
2416    Redraw,
2417    Commit(String),
2418    Exit,
2419}
2420
2421fn prev_boundary(s: &str, mut p: usize) -> usize {
2422    p -= 1;
2423    while !s.is_char_boundary(p) {
2424        p -= 1;
2425    }
2426    p
2427}
2428
2429fn next_boundary(s: &str, mut p: usize) -> usize {
2430    p += 1;
2431    while p < s.len() && !s.is_char_boundary(p) {
2432        p += 1;
2433    }
2434    p
2435}
2436
2437/// All the per-session UI state that flows through key/event handlers.
2438///
2439/// Before this aggregation, handlers took 7–9 `&mut` parameters each
2440/// and the call sites filled a paragraph. Now the handlers take
2441/// `(&mut App, &mut LoopCtx, &mut dyn Renderer, …event)` — the LoopCtx
2442/// stays separate because the tokio `select!` in `run_loop` needs to
2443/// borrow `ctx.input_rx`, `ctx.runtime_event_rx`, `ctx.wake_rx`
2444/// independently, and bundling them into App would fight the borrow
2445/// checker on every arm.
2446pub struct App {
2447    pub state: UiState,
2448    pub buf: Buffer,
2449    pub menu: MenuState,
2450    /// Exactly one overlay at a time — /model, /provider, /resume all
2451    /// push into the same slot. The Modal trait owns draw + key handling
2452    /// so adding a fourth overlay is `Some(Box::new(X))`, not a new
2453    /// field + new dispatch branch.
2454    pub active_modal: Option<Box<dyn crate::modals::Modal>>,
2455    /// Messages the user submitted while a turn was already running.
2456    /// Drained one-at-a-time from the head whenever the current turn
2457    /// finishes. Matches CC's "type-ahead" UX — queue the next prompt
2458    /// while the model is still thinking and it fires automatically.
2459    pub message_queue: VecDeque<crate::state::QueuedMessage>,
2460    /// Streaming-state `<think>…</think>` stripper. Kept on App (not
2461    /// a local in the streaming arm) because it carries state across
2462    /// agent events — a tag straddling two chunks would break if the
2463    /// stripper were re-constructed each event.
2464    pub think: ThinkStripper,
2465    /// call_id → (tool_name, detail, call_rendered). Populated on
2466    /// ToolCallStarted, read by `ApprovalNeeded` (which renders the
2467    /// `▸ Tool(detail)` line eagerly so the user sees *what* they're
2468    /// being asked to approve), and consumed on ToolCallResult. The
2469    /// `call_rendered` flag prevents rendering the tool-call line
2470    /// twice when ApprovalNeeded fired first.
2471    pub pending_tools: std::collections::HashMap<String, (String, String, bool)>,
2472    /// Timestamp of the first Ctrl+C press on an empty idle buffer.
2473    /// Requires a second press within `CTRL_C_EXIT_WINDOW` to actually
2474    /// exit — protects against accidental single-tap exits.
2475    pub exit_pending: Option<std::time::Instant>,
2476    /// Set by `/fixissue <url>` while the agent is resolving that issue.
2477    /// On `TurnComplete` the text buffered in `fixissue_buffer` is posted
2478    /// back as an issue comment + the `fixed` label is applied. Cleared
2479    /// on TurnComplete / TurnCancelled / Error so a subsequent normal
2480    /// message doesn't accidentally trigger a post-back.
2481    pub fixissue_pending: Option<atomcode_core::atomgit::IssueRef>,
2482    /// Accumulates every visible `AssistantText` delta produced during a
2483    /// fixissue turn, verbatim. Sent as the AtomGit comment body on
2484    /// successful completion.
2485    pub fixissue_buffer: String,
2486    /// Accumulates reasoning/thinking content for display in verbose mode.
2487    /// Flushed on newline or when buffer exceeds threshold.
2488    pub reasoning_buffer: String,
2489    /// Guards the one-shot `/setup` hint so it fires at most once per
2490    /// session. Flipped to `true` after the first render; subsequent
2491    /// redraws skip the check entirely.
2492    pub setup_hint_shown: bool,
2493    /// Timestamp of the last Ctrl+C that was consumed by `copy_selection()`
2494    /// via the Windows OS-level signal handler. On Windows, the OS Ctrl+C
2495    /// signal fires *before* the keyboard event arrives in the input
2496    /// buffer (biased `tokio::select!` prioritises the signal arm), so
2497    /// after the signal handler copies the selection, the keyboard event
2498    /// still shows up in `handle_input` and would trigger Cancel/exit.
2499    /// This timestamp lets the keyboard path detect and suppress that
2500    /// stale echo within a short debounce window.
2501    #[cfg(windows)]
2502    pub last_ctrl_c_copy: Option<std::time::Instant>,
2503}
2504
2505/// How long the "press Ctrl+C again to exit" confirmation stays armed.
2506const CTRL_C_EXIT_WINDOW: Duration = Duration::from_secs(2);
2507
2508impl App {
2509    fn new(caps: &crate::terminal::TerminalCaps) -> Self {
2510        Self {
2511            state: UiState::with_unicode(caps.unicode_symbols),
2512            buf: Buffer::new(),
2513            menu: MenuState::new(),
2514            active_modal: None,
2515            message_queue: VecDeque::new(),
2516            think: ThinkStripper::new(),
2517            pending_tools: std::collections::HashMap::new(),
2518            exit_pending: None,
2519            fixissue_pending: None,
2520            fixissue_buffer: String::new(),
2521            reasoning_buffer: String::new(),
2522            setup_hint_shown: false,
2523            #[cfg(windows)]
2524            last_ctrl_c_copy: None,
2525        }
2526    }
2527}
2528
2529/// Why the event loop exited. Callers (currently just `atomcode-tuix::run`)
2530/// use this to decide whether to re-exec into the new binary after an
2531/// in-place upgrade or just terminate normally.
2532#[derive(Debug, Clone, PartialEq, Eq)]
2533pub enum ExitReason {
2534    /// Normal termination (user quit, Ctrl+C, etc.).
2535    Normal,
2536    /// `/upgrade` or `/upgrade rollback` succeeded; the live binary has been
2537    /// replaced and the caller should `re_exec_self` to start the new version.
2538    ///
2539    /// Carries the *original* exe path (e.g. `atomcode.exe`) captured
2540    /// **before** `replace_binary` renamed the running binary. On Windows,
2541    /// `std::env::current_exe()` returns the renamed path after the swap,
2542    /// so callers MUST use this path for `re_exec_self` instead of
2543    /// `current_exe()`.
2544    UpgradeRestart { exe: std::path::PathBuf },
2545}
2546
2547pub async fn run_loop(mut ctx: LoopCtx, renderer: &mut dyn Renderer) -> Result<ExitReason> {
2548    let mut app = App::new(&ctx.caps);
2549
2550    crate::tuix_trace!(
2551        "SES",
2552        "run_loop start model={} cwd={}",
2553        ctx.model_name,
2554        ctx.working_dir.display()
2555    );
2556
2557    // Draw welcome + initial prompt
2558    let dir_display = crate::platform::collapse_home(&ctx.working_dir.to_string_lossy());
2559    renderer.render(UiLine::Welcome {
2560        model: ctx.model_name.clone(),
2561        working_dir: dir_display.clone(),
2562    });
2563    // If this process was spawned by `apply_pending_upgrade` → `re_exec_self`,
2564    // an env var carries the version we just upgraded from. Surface one line
2565    // on the welcome screen so the user knows the upgrade succeeded, then
2566    // clear the var so any subprocesses we spawn don't inherit a stale hint.
2567    if let Ok(prev) = std::env::var("ATOMCODE_UPGRADED_FROM") {
2568        std::env::remove_var("ATOMCODE_UPGRADED_FROM");
2569        let current = format!("v{}", env!("CARGO_PKG_VERSION"));
2570        renderer.render(UiLine::CommandOutput(
2571            crate::i18n::t(crate::i18n::Msg::UpgradeSuccess { from: &prev, to: &current }).into_owned(),
2572        ));
2573    }
2574    // Same env-var handoff from `atomcode codingplan` (see CLI `run()`):
2575    // the subcommand stashes its rendered SetupReport here instead of
2576    // printing to stdout, so the user sees the ✓/✗ lines in the chat
2577    // scrollback rather than scrolled off above the welcome banner.
2578    if let Ok(report) = std::env::var("ATOMCODE_CODINGPLAN_REPORT") {
2579        std::env::remove_var("ATOMCODE_CODINGPLAN_REPORT");
2580        if !report.is_empty() {
2581            renderer.render(UiLine::CommandOutput(report));
2582        }
2583    }
2584
2585    // Terminal keyboard hint: shown when crossterm couldn't negotiate
2586    // the Kitty keyboard protocol (CSI u). The previous copy claimed
2587    // "Shift+Enter won't work" — but Kitty is only ONE of several ways
2588    // a terminal can disambiguate modifier+Enter. Windows Terminal,
2589    // VSCode (xterm.js), mintty/Git Bash, and modern PowerShell hosts
2590    // all forward Shift/Alt/Ctrl+Enter via VT modifyOtherKeys without
2591    // ever negotiating CSI u, so the user sees Shift+Enter work in
2592    // their daily session yet boots into a banner asserting it can't.
2593    // Re-frame as informational guidance rather than a definitive
2594    // "won't work" claim, and surface `\<Enter>` as the universal
2595    // fallback so legacy-conhost users (where modifier+Enter IS
2596    // genuinely swallowed at the OS layer) have a guaranteed path.
2597    //
2598    // Also suppressed when the legacy-conhost hint is firing — that
2599    // hint already covers the only environment where the chord
2600    // truly fails, so dual-firing produced wall-of-text noise (see
2601    // user feedback 2026-05-09 "全部展示的是…可以更精细化下").
2602    let kbd_hint_set = std::env::var("ATOMCODE_KBD_NOT_ENHANCED").is_ok();
2603    let jediterm_set = std::env::var("ATOMCODE_JEDITERM_FALLBACK").is_ok();
2604    if kbd_hint_set {
2605        std::env::remove_var("ATOMCODE_KBD_NOT_ENHANCED");
2606    }
2607    // Suppress the standalone keyboard hint when the JediTerm banner
2608    // is firing — that banner already carries its own newline
2609    // guidance, so dual-firing produced wall-of-text noise. Otherwise
2610    // emit a single universal hint pointing at `\<Enter>`.
2611    //
2612    // Why the universal-fallback message instead of per-terminal
2613    // chord recommendations: the previous helper detected MSYSTEM /
2614    // WT_SESSION / ConEmuPID / TERM_PROGRAM and named the most
2615    // reliable chord per terminal, but the detection misfires
2616    // whenever the env vars don't survive (e.g. PowerShell sessions
2617    // launched in Windows Terminal that lose WT_SESSION through a
2618    // helper process — observed in user feedback 2026-05-09). The
2619    // `\<Enter>` line continuation is implemented at the buffer
2620    // layer (event_loop/mod.rs Action::Submit handler), so it
2621    // works on EVERY terminal regardless of keyboard protocol or
2622    // env var fidelity. Modifier+Enter chords stay supported in
2623    // `key_action.rs::classify`; users who know they have them just
2624    // use them. The startup hint targets the user who doesn't know,
2625    // and for them a guaranteed-works recommendation beats a
2626    // sometimes-wrong terminal-specific one.
2627    if kbd_hint_set && !jediterm_set {
2628        renderer.render(UiLine::CommandOutput(
2629            crate::i18n::t(crate::i18n::Msg::HintMultiLineInput).into_owned(),
2630        ));
2631    }
2632
2633    // JediTerm auto-fallback hint: lib.rs detected
2634    // `TERMINAL_EMULATOR=JetBrains-JediTerm` (Android Studio, IntelliJ,
2635    // PyCharm, etc.) and routed to AltScreenRenderer because the
2636    // retained renderer's DECSTBM-pinned footer misaligns there.
2637    // Tell the user about the trade-off — alt-screen owns the
2638    // viewport so the host terminal's native scrollback isn't
2639    // available; the app provides its own (PageUp / Shift+Up /
2640    // mouse wheel). Only set by lib.rs when the user did NOT
2641    // explicitly opt in via ATOMCODE_PLAIN / ATOMCODE_ALT —
2642    // informed choices don't get lectured.
2643    if std::env::var("ATOMCODE_JEDITERM_FALLBACK").is_ok() {
2644        std::env::remove_var("ATOMCODE_JEDITERM_FALLBACK");
2645        // Includes newline-insertion guidance because the standalone
2646        // keyboard hint is suppressed when this banner fires (see
2647        // kbd_hint_set block above). Lead with `\<Enter>` — the
2648        // buffer-layer fallback that works regardless of which
2649        // chord the IDE's JediTerm fork happens to forward. Modifier
2650        // chords still supported by `key_action.rs::classify`; users
2651        // who know they have them just use them.
2652        renderer.render(UiLine::CommandOutput(
2653            "  ⓘ JetBrains IDE terminal detected — running in alt-screen mode.\n    \
2654            Newlines: end the line with `\\` then press Enter (Shift / Alt /\n    \
2655            Ctrl + Enter may also work depending on your IDE version).\n    \
2656            Use mouse wheel, PageUp/PageDown, or Shift+Up/Down to scroll history.\n    \
2657            Native terminal scrollback is unavailable while atomcode runs;\n    \
2658            on exit your host terminal restores its pre-atomcode state.\n    \
2659            Set ATOMCODE_PLAIN=1 for a bare CI-style baseline, or\n    \
2660            ATOMCODE_RETAIN=1 to bypass this fallback (may misalign).\n\n"
2661                .into(),
2662        ));
2663    }
2664
2665    // The legacy-Windows-conhost fallback banner used to fire here
2666    // (gated on ATOMCODE_LEGACY_CONHOST_FALLBACK set by lib.rs). It
2667    // walked the user through wheel-scroll, PageUp/Down, third-party
2668    // terminal alternatives, and the ATOMCODE_PLAIN / ATOMCODE_RETAIN
2669    // bypass switches. Removed in v4.22 once alt-screen on conhost
2670    // shipped working wheel + PageUp/Down + SGR mouse: the wall of
2671    // text became dead weight (every conhost user immediately
2672    // wanted it gone). Newline guidance still reaches them via the
2673    // universal `\<Enter>` block above (kbd_hint_set arm), which is
2674    // one line and terminal-agnostic.
2675
2676    // Auto-continue: if the CLI loaded the most recent session for this
2677    // working dir (via `atomcode -c` / `--continue`), replay its messages
2678    // into scrollback AND restore the agent's model context so follow-up
2679    // questions can reference prior conversation. This mirrors the `/resume`
2680    // slash command's behaviour: visual replay + AgentCommand::SetMessages.
2681    if let Some(session) = ctx.replay_on_start.take() {
2682        if !session.messages.is_empty() {
2683            crate::modals::session_picker::replay_session(renderer, &session, false);
2684            // Sync messages into the agent loop so the LLM has full context.
2685            ctx.agent
2686                .cmd_tx
2687                .send(AgentCommand::SetMessages(session.messages.clone()))
2688                .ok();
2689            // Continue accumulating into the same session file — future
2690            // TurnComplete saves overwrite it instead of creating a new one.
2691            if let Ok(uuid) = uuid::Uuid::parse_str(session.id.as_str()) {
2692                ctx.telemetry.set_session_id(uuid);
2693            }
2694            ctx.current_session = session;
2695            app.state.on_turn_complete();
2696        }
2697    }
2698
2699    // First-run onboarding: no providers configured AND no OAuth login
2700    // on disk means the user has never set this up — open the
2701    // OnboardingWizard. Users with a config or prior OAuth auth are
2702    // never shown this and boot straight to idle. Plain renderer
2703    // (CI / pipe / non-TTY) is also gated out — the bordered box
2704    // would just garble its output channel with no human to see it.
2705    if should_auto_show_onboarding(&ctx) {
2706        // Modal trait imported so `wizard.draw(...)` resolves; the
2707        // OnboardingWizard's Modal impl owns the per-step box drawing.
2708        use crate::modals::Modal;
2709        renderer.clear_screen();
2710        // First-launch fast path: single-page QR + URL. Background
2711        // poll thread (PR 1b) watches `/auth/check` and auto-closes
2712        // the modal the moment AtomGit reports authorisation, then
2713        // the `OauthEvent::Authorized` branch in the main `select!`
2714        // flips `pending_run_codingplan` so `/codingplan` claims
2715        // immediately — zero keystrokes after the user finishes the
2716        // browser flow. The legacy 3-step Intro / Language / Setup
2717        // wizard stays intact for `/welcome` — `new_qr_fast_path` is
2718        // ONLY used here. /welcome's command arm still uses `new()`
2719        // / `new_with_confirm()` so users who explicitly re-run the
2720        // wizard see the familiar language + setup path.
2721        let mut wizard = crate::modals::OnboardingWizard::new_qr_fast_path();
2722        // Pull the LoginSession out of the wizard before boxing — the
2723        // background poll thread owns it from here. wizard.draw still
2724        // has access to `qr_login_url` so the QR keeps rendering.
2725        if let Some(session) = wizard.take_pending_session() {
2726            oauth_poll::spawn_oauth_poll(
2727                session,
2728                Some(std::sync::Arc::clone(&ctx.telemetry)),
2729                ctx.oauth_event_tx.clone(),
2730                ctx.wake_tx.clone(),
2731            );
2732        }
2733        wizard.draw(&app.buf, &app.state, &ctx, renderer);
2734        app.active_modal = Some(Box::new(wizard));
2735    } else {
2736        // One-shot /setup hint — only on first boot into this project,
2737        // gated by preferences + setup-state presence.
2738        if !app.setup_hint_shown && should_auto_show_setup(&ctx) {
2739            renderer.render(UiLine::CommandOutput(
2740                crate::i18n::t(crate::i18n::Msg::CmdSetupTip).into_owned(),
2741            ));
2742            app.setup_hint_shown = true;
2743        }
2744        renderer.render(UiLine::InputPrompt {
2745            buf: String::new(),
2746            cursor_byte: 0,
2747            menu: None,
2748            status: build_status(&app.state, &ctx),
2749            attachments: Vec::new(),
2750        });
2751        renderer.flush();
2752    }
2753
2754    // Startup CodingPlan drift check. Without this, a user who ran
2755    // `/codingplan` days ago and now sees a new model in the plan lineup
2756    // wouldn't learn until they typed a message — the mid-turn trigger
2757    // at the submit-path only fires on user action. Gating:
2758    //
2759    //   * Only when the active provider is an AtomGit* (CodingPlan)
2760    //     provider — non-CodingPlan users do zero network work on boot.
2761    //   * Still respects the 15-min cooldown against `monitor_last_check_at`
2762    //     so rapid restarts (e.g. crash-loop during development) don't
2763    //     spam the API gateway.
2764    //
2765    // The check itself is fully async (`spawn_check` returns immediately
2766    // and runs on a tokio task); the event loop entering its main tick
2767    // loop below isn't blocked, and the warning — when it arrives a
2768    // second or two later — wakes the loop via `wake_tx` so the status
2769    // row repaints without the user needing to press a key.
2770    if monitor::is_codingplan_provider(&ctx.config.default_provider) {
2771        let cooled = ctx
2772            .monitor_last_check_at
2773            .map(|t| t.elapsed() >= monitor::CHECK_COOLDOWN)
2774            .unwrap_or(true);
2775        if cooled {
2776            ctx.monitor_last_check_at = Some(std::time::Instant::now());
2777            monitor::spawn_check(
2778                ctx.config.clone(),
2779                ctx.model_name.clone(),
2780                ctx.monitor_warning.clone(),
2781                ctx.wake_tx.clone(),
2782            );
2783        }
2784        // Startup usage check (separate cooldown — 30s vs drift's 15min).
2785        // Always fires once at startup so the user sees current quota
2786        // immediately if they're already over 80%.
2787        ctx.usage_last_check_at = Some(std::time::Instant::now());
2788        usage_monitor::spawn_check(ctx.usage_slot.clone(), ctx.wake_tx.clone());
2789    }
2790
2791    // Spinner tick channel — a background task fires a tick every 100ms
2792    // into a bounded (cap 1) mpsc. The main loop recv's this in the
2793    // `tokio::select!` alongside the agent-event channel, so spinner
2794    // ticks compete fairly with agent events (both are channel reads
2795    // rather than a time-interval future that the runtime can skip
2796    // over when other branches are always ready).
2797    //
2798    // Cap 1 + try_send means if the main loop is mid-event and a tick
2799    // can't land in the channel, we silently drop it — no burst of
2800    // queued frames when control eventually returns. The post-event
2801    // pump (below) complements this by advancing the spinner as soon
2802    // as a slow handler finishes, even if the next scheduled tick is
2803    // still 50ms away.
2804    let (spin_tx, mut spin_rx) = tokio::sync::mpsc::channel::<()>(1);
2805    let spin_task = {
2806        let spin_tx = spin_tx.clone();
2807        tokio::spawn(async move {
2808            use tokio::sync::mpsc::error::TrySendError;
2809            let mut interval = tokio::time::interval(Duration::from_millis(100));
2810            interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
2811            interval.tick().await; // discard the immediate tick
2812            loop {
2813                interval.tick().await;
2814                match spin_tx.try_send(()) {
2815                    Ok(_) | Err(TrySendError::Full(_)) => {}
2816                    Err(TrySendError::Closed(_)) => break,
2817                }
2818            }
2819        })
2820    };
2821    drop(spin_tx); // only the task needs the sender
2822
2823    // Deferred-render tick: 50fps. The renderer throttles InputPrompt /
2824    // StreamingBox redraws to 20ms windows so Mac Terminal.app doesn't
2825    // choke on back-to-back full footer payloads, but the trailing
2826    // edge of a burst needs someone to paint it — that someone is this
2827    // tick. No-op when nothing is pending.
2828    // 5ms matches the InputThrottle window (see render::throttle) —
2829    // tick == window means the max visible lag from "burst ended" to
2830    // "parked paint landed" is ~10ms, imperceptible. Previously 20ms
2831    // which compounded with the 20ms throttle window to ~40ms lag,
2832    // visible for IME commit bursts.
2833    let mut deferred_render_tick = tokio::time::interval(Duration::from_millis(5));
2834    deferred_render_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
2835    deferred_render_tick.tick().await; // consume the immediate fire
2836
2837    // Last-draw timestamp — consulted by the post-event pump so we
2838    // don't redraw more often than every 100ms even when handlers
2839    // fire back-to-back.
2840    let mut last_spinner_draw = std::time::Instant::now();
2841
2842    // Last emitted integer percent for the /upgrade download line.
2843    // Gate on change so we don't spam the renderer with a progress
2844    // line for every chunk (a 10 MB binary at 64 KiB chunks would be
2845    // 160 redraws). `-1` means "no download active yet".
2846    let mut upgrade_last_pct: i32 = -1;
2847    // True once Done fired successfully — the loop exits after the
2848    // current pending message finishes so the user sees the success
2849    // line before the TUI shuts down.
2850    let mut upgrade_done: Option<std::path::PathBuf> = None;
2851
2852    // DEVIATION from plan:
2853    // 1. plan uses `SignalKind::terminal_stop()` which does not exist in tokio 1.x.
2854    //    Using `SignalKind::from_raw(libc::SIGTSTP)` instead.
2855    // 2. tokio::select! does not support #[cfg(...)] on individual arms, so signal
2856    //    handling is split into a cfg-gated loop variant below.
2857    #[cfg(unix)]
2858    let mut sigtstp =
2859        tokio::signal::unix::signal(tokio::signal::unix::SignalKind::from_raw(libc::SIGTSTP))?;
2860    #[cfg(unix)]
2861    let mut sigcont =
2862        tokio::signal::unix::signal(tokio::signal::unix::SignalKind::from_raw(libc::SIGCONT))?;
2863
2864    // Windows-only OS-level Ctrl+C fallback. The keyboard path
2865    // (crossterm KeyEvent → handle_input → 2-press confirm) is the
2866    // primary route, but on legacy conhost the Ctrl+C keystroke is
2867    // sometimes swallowed before reaching the input buffer when raw
2868    // mode + ENABLE_VIRTUAL_TERMINAL_INPUT are both active — users
2869    // report "completely no reaction" with no hint shown.
2870    // `tokio::signal::windows::ctrl_c` hooks SetConsoleCtrlHandler so
2871    // the OS signal still lands here regardless of whether the
2872    // keystroke ever made it into the console input queue. Single-press
2873    // exit on this path: when the keypress chain is broken, this is the
2874    // user's only escape — a 2-press confirm would just trap them.
2875    #[cfg(windows)]
2876    let mut win_ctrl_c = tokio::signal::windows::ctrl_c()?;
2877
2878    loop {
2879        #[cfg(unix)]
2880        tokio::select! {
2881            // Biased ordering: spinner first so whenever a tick is
2882            // pending in spin_rx we draw it before racing with agent
2883            // events. Without `biased` tokio picks a ready branch
2884            // randomly, so under heavy agent traffic the spinner gets
2885            // chosen ~50% of the time its tick is ready, dropping the
2886            // effective frame rate to ~5 fps and looking like "frozen
2887            // then jumps".
2888            biased;
2889
2890            // ── Deferred-render trailing edge ──
2891            // Drains any InputPrompt / StreamingBox payload the
2892            // renderer parked during its 20ms throttle window. No-op
2893            // when nothing is pending.
2894            _ = deferred_render_tick.tick() => {
2895                renderer.flush_deferred();
2896            }
2897
2898            // ── Spinner tick (from background task) ──
2899            Some(()) = spin_rx.recv(), if matches!(app.state.phase, UiPhase::Streaming) => {
2900                draw_spinner_now(&mut app.state, &app.buf, &ctx, renderer, app.message_queue.len(), app.menu.selected);
2901                last_spinner_draw = std::time::Instant::now();
2902            }
2903
2904            // ── Terminal input ──
2905            maybe = ctx.input_rx.recv() => {
2906                let Some(ev) = maybe else { break };
2907                handle_input(&mut app, &mut ctx, renderer, ev)?;
2908            }
2909
2910            // ── Version-check wake ──
2911            // Fires once when the detached startup check resolves with a
2912            // positive result. Idle-only: in Streaming the spinner tick
2913            // redraws frequently enough that the hint picks up naturally.
2914            Some(()) = ctx.wake_rx.recv(), if matches!(app.state.phase, UiPhase::Idle) => {
2915                redraw_idle_plain(&app.buf, &app.state, &ctx, renderer);
2916            }
2917
2918            // ── OAuth poll thread results ──
2919            // Emitted by `event_loop::oauth_poll::spawn_oauth_poll`
2920            // once per QR-fast-path session. Authorized → close the
2921            // wizard + flip `pending_run_codingplan` so the existing
2922            // /codingplan driver picks up the just-written auth.toml
2923            // and claims the plan. Failed → close the wizard too and
2924            // surface the reason in scrollback with a retry hint;
2925            // leaving the modal open would require a Modal trait
2926            // extension (as_any_mut + downcast) we don't yet have.
2927            Some(ev) = ctx.oauth_event_rx.recv() => {
2928                use oauth_poll::OauthEvent;
2929                let was_modal_open = app.active_modal.is_some();
2930                if was_modal_open {
2931                    app.active_modal = None;
2932                    renderer.clear_screen();
2933                }
2934                match ev {
2935                    OauthEvent::Authorized => {
2936                        // Banner FIRST, /codingplan output below — per
2937                        // user direction: AtomCode chrome should anchor
2938                        // the top of scrollback, the codingplan claim
2939                        // output is verbose detail underneath. Model
2940                        // bullet is blank at this point because the
2941                        // claim hasn't picked a default provider yet —
2942                        // refreshed below once the claim writes
2943                        // ctx.model_name.
2944                        crate::modals::onboarding_wizard::paint_welcome(&ctx, renderer);
2945                        // `pending_run_codingplan` is only drained by the
2946                        // keystroke-handler path (handle_input → modal
2947                        // close → drain flag). The OAuth poll path doesn't
2948                        // route through there, so just call the codingplan
2949                        // driver directly — same effect, runs in this
2950                        // select! arm's scope where renderer + ctx are
2951                        // already mutable.
2952                        if let Err(e) = crate::event_loop::commands::run_codingplan_flow(renderer, &mut ctx) {
2953                            renderer.render(crate::render::UiLine::Error(
2954                                format!("CodingPlan 自动领取失败: {e:#}。可运行 /codingplan 手动重试。"),
2955                            ));
2956                            renderer.flush();
2957                        }
2958                        // Splice the resolved model name into the
2959                        // banner painted above. `run_codingplan_flow`
2960                        // updates `ctx.model_name` from the picked
2961                        // default provider (see commands.rs:2906) — at
2962                        // this point the banner's cached model="" is
2963                        // stale, so refresh in place.
2964                        let dir_display = crate::platform::collapse_home(
2965                            &ctx.working_dir.to_string_lossy(),
2966                        );
2967                        renderer.refresh_welcome_banner(&ctx.model_name, &dir_display);
2968                        // QR-fast-path onboarding bypasses the regular
2969                        // first-boot idle render (see ~line 2506), so
2970                        // the one-shot /setup tip never fires for users
2971                        // who land through the scan flow. Surface it
2972                        // here under the same gates: in-session
2973                        // once-only + `should_auto_show_setup` (no
2974                        // setup-state.json or missing recommender
2975                        // skill).
2976                        if !app.setup_hint_shown && should_auto_show_setup(&ctx) {
2977                            renderer.render(crate::render::UiLine::CommandOutput(
2978                                crate::i18n::t(crate::i18n::Msg::CmdSetupTip).into_owned(),
2979                            ));
2980                            renderer.flush();
2981                            app.setup_hint_shown = true;
2982                        }
2983                    }
2984                    OauthEvent::Failed(reason) => {
2985                        renderer.render(crate::render::UiLine::Error(
2986                            format!(
2987                                "登录失败: {reason}。运行 /codingplan 可重试。",
2988                            ),
2989                        ));
2990                        renderer.flush();
2991                    }
2992                }
2993            }
2994
2995            // ── MCP connection events ──
2996            // Render connection success/failure into scrollback as they arrive.
2997            // Also register tools dynamically when servers connect.
2998            Some(ev) = async {
2999                if let Some(rx) = ctx.mcp_connect_rx.as_mut() {
3000                    rx.recv().await
3001                } else {
3002                    None
3003                }
3004            }, if ctx.mcp_connect_rx.is_some() => {
3005                use atomcode_core::mcp::{McpConnectEvent, register_mcp_tools_async};
3006                match &ev {
3007                    McpConnectEvent::Connected { name } => {
3008                        renderer.render(UiLine::CommandOutput(
3009                            crate::i18n::t(crate::i18n::Msg::McpServerConnected { name }).into_owned(),
3010                        ));
3011                        // Register tools from this newly connected server.
3012                        // Important: do this in a background task so a slow `tools/list`
3013                        // can't block the TUI event loop and freeze input.
3014                        if let Some(registry) = &ctx.mcp_registry {
3015                            let registry = registry.clone();
3016                            let tools = ctx.agent.tool_registry.clone();
3017                            let name = name.clone();
3018                            let tx = registry.event_sender();
3019                            tokio::spawn(async move {
3020                                let list_timeout = registry.list_tools_timeout(&name).await;
3021                                let server_tools = match tokio::time::timeout(
3022                                    list_timeout,
3023                                    registry.list_tools_for_server(&name),
3024                                )
3025                                .await
3026                                {
3027                                    Ok(v) => v,
3028                                    Err(_) => {
3029                                        if let Some(tx) = tx {
3030                                            let _ = tx.send(McpConnectEvent::Warning {
3031                                                name,
3032                                                message: format!(
3033                                                    "tools/list timed out after {}s during auto-registration",
3034                                                    list_timeout.as_secs()
3035                                                ),
3036                                            });
3037                                        }
3038                                        return;
3039                                    }
3040                                };
3041                                if !server_tools.is_empty() {
3042                                    register_mcp_tools_async(&tools, registry, server_tools).await;
3043                                }
3044                            });
3045                        }
3046                    }
3047                    McpConnectEvent::Failed { name, error } => {
3048                        renderer.render(UiLine::Error(
3049                            crate::i18n::t(crate::i18n::Msg::McpServerFailed { name, error }).into_owned(),
3050                        ));
3051                    }
3052                    McpConnectEvent::Warning { name, message } => {
3053                        // Default: keep MCP startup/runtime noise out of scrollback.
3054                        //
3055                        // Exception: `/mcp tools <server>` uses Warning events to return the tool list
3056                        // (and related timeouts) from a background task. Those should be user-visible.
3057                        if message.starts_with("tools:\n")
3058                            || message.contains("tools/list timed out")
3059                            || message.contains("tools/list failed")
3060                        {
3061                            renderer.render(UiLine::CommandOutput(format!(
3062                                "  [mcp:{}] {}\n",
3063                                name,
3064                                message.trim_end()
3065                            )));
3066                        } else {
3067                            // Route to the opt-in tuix trace log instead (safe for raw-mode TUI).
3068                            crate::tuix_trace!("MCP", "server='{}' warning: {}", name, message);
3069                        }
3070                    }
3071                }
3072
3073                // `/mcp reload` progress: once every configured server has reported a
3074                // terminal state (Connected/Failed), emit a summary line.
3075                if let Some(p) = ctx.mcp_reload.as_mut() {
3076                    match &ev {
3077                        McpConnectEvent::Connected { .. } => {
3078                            p.done = p.done.saturating_add(1);
3079                            p.connected = p.connected.saturating_add(1)
3080                        }
3081                        McpConnectEvent::Failed { .. } => {
3082                            p.done = p.done.saturating_add(1);
3083                            p.failed = p.failed.saturating_add(1)
3084                        }
3085                        McpConnectEvent::Warning { .. } => {}
3086                    }
3087                    if p.done >= p.total {
3088                        let elapsed_ms = p.started_at.elapsed().as_millis();
3089                        renderer.render(UiLine::CommandOutput(format!(
3090                            "  MCP reload complete: {} connected, {} failed ({}ms)\n",
3091                            p.connected, p.failed, elapsed_ms
3092                        )));
3093                        ctx.mcp_reload = None;
3094                    }
3095                }
3096                renderer.flush();
3097            }
3098
3099            // ── LSP server start / failure ──
3100            // Mirrors the MCP arm above. Without this, `LspManager`'s
3101            // raw `eprintln!` on a failed server start would land in the
3102            // input box (TUI owns the screen, stderr-fd writes hit
3103            // wherever the cursor sits — between the cyan rules).
3104            // Started → ✓ in scrollback. Failed → ✗ as an Error line.
3105            // Warning is non-actionable noise (e.g. shutdown teardown
3106            // errors) and routed to the trace log instead.
3107            Some(ev) = async {
3108                if let Some(rx) = ctx.lsp_connect_rx.as_mut() {
3109                    rx.recv().await
3110                } else {
3111                    None
3112                }
3113            }, if ctx.lsp_connect_rx.is_some() => {
3114                use atomcode_core::lsp::LspConnectEvent;
3115                match &ev {
3116                    LspConnectEvent::Started { command, ext } => {
3117                        renderer.render(UiLine::CommandOutput(
3118                            crate::i18n::t(crate::i18n::Msg::LspServerStarted { name: command, ext }).into_owned(),
3119                        ));
3120                    }
3121                    LspConnectEvent::Failed { command, ext, error } => {
3122                        renderer.render(UiLine::Error(
3123                            crate::i18n::t(crate::i18n::Msg::LspServerFailed { name: command, ext, error }).into_owned(),
3124                        ));
3125                    }
3126                    LspConnectEvent::Warning { ext, message } => {
3127                        crate::tuix_trace!("LSP", "ext='{}' warning: {}", ext, message);
3128                    }
3129                }
3130                renderer.flush();
3131            }
3132
3133            // ── /upgrade progress ──
3134            Some(ev) = ctx.upgrade_rx.recv() => {
3135                handle_upgrade_event(ev, &mut upgrade_last_pct, &mut upgrade_done, &mut ctx, renderer);
3136                if upgrade_done.is_some() { break; }
3137                if matches!(app.state.phase, UiPhase::Idle) {
3138                    redraw_idle_plain(&app.buf, &app.state, &ctx, renderer);
3139                }
3140            }
3141
3142            // ── /plugin async job result ──
3143            Some(ev) = ctx.plugin_job_rx.recv() => {
3144                handle_plugin_job_event(ev, &mut ctx, &app.state, renderer);
3145                if matches!(app.state.phase, UiPhase::Idle) {
3146                    redraw_idle_plain(&app.buf, &app.state, &ctx, renderer);
3147                }
3148            }
3149
3150            // ── Agent events ──
3151            // Consumed regardless of phase. Gating on Streaming missed
3152            // the TurnComplete that arrives *after* an Error event: the
3153            // Error handler flips phase to Idle, so the very next event
3154            // on the channel is stuck until the user submits again —
3155            // which is what "得发两次你好才结束" looked like in the UI.
3156            // Phase-specific behaviour (spinner redraw, type-ahead queue
3157            // drain) lives inside the match arms on `app.state.phase`.
3158            maybe = ctx.runtime_event_rx.recv() => {
3159                let Some(runtime_event) = maybe else { break };
3160                if runtime_event.runtime_id == ctx.foreground_runtime_id {
3161                    let pre_phase = app.state.phase;
3162                    handle_agent_event(runtime_event.event, &mut app.state, &mut app.think, renderer, &mut app.pending_tools, &mut ctx, &mut app.fixissue_pending, &mut app.fixissue_buffer, &mut app.reasoning_buffer, &app.buf);
3163                    if pre_phase != app.state.phase {
3164                        crate::tuix_trace!("PH", "{:?} -> {:?}", pre_phase, app.state.phase);
3165                    }
3166                    if matches!(app.state.phase, UiPhase::Streaming)
3167                        && last_spinner_draw.elapsed() >= Duration::from_millis(100)
3168                    {
3169                        draw_spinner_now(&mut app.state, &app.buf, &ctx, renderer, app.message_queue.len(), app.menu.selected);
3170                        last_spinner_draw = std::time::Instant::now();
3171                    }
3172                    if matches!(app.state.phase, UiPhase::Idle) {
3173                        // Turn just ended — drain the type-ahead queue.
3174                        // Pop the oldest queued message, echo as a User
3175                        // line, dispatch to the agent, and transition
3176                        // back to Streaming. Remaining queue entries
3177                        // fire in order on subsequent completions.
3178                        if let Some(queued) = app.message_queue.pop_front() {
3179                            crate::tuix_trace!("QUE", "pop_front remaining={}", app.message_queue.len());
3180                            renderer.render(UiLine::User(queued.text.clone()));
3181                            renderer.flush();
3182                            ctx.agent.cmd_tx.send(AgentCommand::SendMessage {
3183                                text: queued.text,
3184                                images: queued.images,
3185                                image_markers: queued.image_markers,
3186                            }).ok();
3187                            app.state.on_submit();
3188                            draw_spinner_now(&mut app.state, &app.buf, &ctx, renderer, app.message_queue.len(), app.menu.selected);
3189                        } else {
3190                            crate::tuix_trace!("PH", "turn_end -> Idle, queue empty, redraw_idle");
3191                            redraw_idle_plain(&app.buf, &app.state, &ctx, renderer);
3192                        }
3193                    }
3194                } else {
3195                    ctx.bg_manager.apply_background_event(
3196                        runtime_event.runtime_id,
3197                        runtime_event.event,
3198                        &ctx.session_manager,
3199                    );
3200                }
3201            }
3202
3203            // ── Suspend ──
3204            _ = sigtstp.recv() => {
3205                renderer.render(UiLine::ClearTransient);
3206                renderer.shutdown();
3207                app.state.on_suspend();
3208                // Disable raw mode before SIGSTOP so shell gets a sane terminal.
3209                let _ = crossterm::terminal::disable_raw_mode();
3210                unsafe { libc::raise(libc::SIGSTOP); }
3211            }
3212
3213            // ── Resume ──
3214            _ = sigcont.recv() => {
3215                let _ = crossterm::terminal::enable_raw_mode();
3216                app.state.on_resume();
3217                match app.state.phase {
3218                    UiPhase::Streaming => {
3219                        draw_spinner_now(&mut app.state, &app.buf, &ctx, renderer, app.message_queue.len(), app.menu.selected);
3220                        last_spinner_draw = std::time::Instant::now();
3221                    }
3222                    _ => {
3223                        redraw_idle_plain(&app.buf, &app.state, &ctx, renderer);
3224                    }
3225                }
3226            }
3227        }
3228
3229        // Was `cfg(not(unix))` to bracket the whole non-Unix select.
3230        // Narrowed to `cfg(windows)` because the only arm that needs
3231        // this branch (`win_ctrl_c.recv()`) is itself Windows-only,
3232        // and tokio's `select!` macro doesn't accept arm-level
3233        // `#[cfg(...)]` attributes — it tries to expand them inside
3234        // its own ruleset and fails with "no rules expected `#`".
3235        // We only support Unix + Windows, so cfg(not(unix)) ≡
3236        // cfg(windows) for our build matrix anyway.
3237        #[cfg(windows)]
3238        tokio::select! {
3239            biased;
3240
3241            // ── Windows OS-level Ctrl+C ──
3242            // Fallback for conhost configurations where the keystroke
3243            // never lands in the input buffer. Healthy terminals fire
3244            // the keypress arm in `handle_input` first; this only wins
3245            // when that path is silent. Single-press exit: skips the
3246            // 2-press confirm because if we got here, the user has no
3247            // working keyboard route to confirm with anyway.
3248            //
3249            // However, on Windows the OS Ctrl+C signal fires *before*
3250            // the keyboard event arrives in the input buffer (and the
3251            // `biased` select gives this arm priority), so when the
3252            // user has a mouse selection active they expect Ctrl+C to
3253            // *copy* — not exit. Try copy_selection first; only fall
3254            // through to Shutdown when there's nothing selected.
3255            Some(()) = win_ctrl_c.recv() => {
3256                if renderer.copy_selection() {
3257                    crate::tuix_trace!("KEY", "windows ctrl_c signal -> copy_selection (had selection)");
3258                    // Stamp so the keyboard-event echo (which arrives
3259                    // shortly after via input_rx) knows to suppress
3260                    // itself instead of triggering Cancel/exit.
3261                    app.last_ctrl_c_copy = Some(std::time::Instant::now());
3262                } else if matches!(app.state.phase, UiPhase::Streaming) {
3263                    // In Streaming phase, Ctrl+C should cancel the
3264                    // running turn (matching keyboard-path behaviour)
3265                    // rather than shut down the whole application.
3266                    crate::tuix_trace!("KEY", "windows ctrl_c signal -> Cancel (streaming)");
3267                    ctx.agent.cmd_tx.send(AgentCommand::Cancel).ok();
3268                    restore_cancelled_message_to_buf(&mut app, renderer, &ctx);
3269                } else {
3270                    crate::tuix_trace!("KEY", "windows ctrl_c signal -> Shutdown");
3271                    ctx.agent.cmd_tx.send(AgentCommand::Shutdown).ok();
3272                }
3273            }
3274
3275            // ── Deferred-render trailing edge ──
3276            // Drains any InputPrompt / StreamingBox payload the
3277            // renderer parked during its 20ms throttle window. No-op
3278            // when nothing is pending.
3279            _ = deferred_render_tick.tick() => {
3280                renderer.flush_deferred();
3281            }
3282
3283            // ── Spinner tick (from background task) ──
3284            Some(()) = spin_rx.recv(), if matches!(app.state.phase, UiPhase::Streaming) => {
3285                draw_spinner_now(&mut app.state, &app.buf, &ctx, renderer, app.message_queue.len(), app.menu.selected);
3286                last_spinner_draw = std::time::Instant::now();
3287            }
3288
3289            // ── Terminal input ──
3290            maybe = ctx.input_rx.recv() => {
3291                let Some(ev) = maybe else { break };
3292                handle_input(&mut app, &mut ctx, renderer, ev)?;
3293            }
3294
3295            // ── Version-check wake ──
3296            Some(()) = ctx.wake_rx.recv(), if matches!(app.state.phase, UiPhase::Idle) => {
3297                redraw_idle_plain(&app.buf, &app.state, &ctx, renderer);
3298            }
3299
3300            // ── OAuth poll thread results ──
3301            // Emitted by `event_loop::oauth_poll::spawn_oauth_poll`
3302            // once per QR-fast-path session. Authorized → close the
3303            // wizard + flip `pending_run_codingplan` so the existing
3304            // /codingplan driver picks up the just-written auth.toml
3305            // and claims the plan. Failed → close the wizard too and
3306            // surface the reason in scrollback with a retry hint;
3307            // leaving the modal open would require a Modal trait
3308            // extension (as_any_mut + downcast) we don't yet have.
3309            Some(ev) = ctx.oauth_event_rx.recv() => {
3310                use oauth_poll::OauthEvent;
3311                let was_modal_open = app.active_modal.is_some();
3312                if was_modal_open {
3313                    app.active_modal = None;
3314                    renderer.clear_screen();
3315                }
3316                match ev {
3317                    OauthEvent::Authorized => {
3318                        // Banner FIRST, /codingplan output below — per
3319                        // user direction: AtomCode chrome should anchor
3320                        // the top of scrollback, the codingplan claim
3321                        // output is verbose detail underneath. Model
3322                        // bullet is blank at this point because the
3323                        // claim hasn't picked a default provider yet —
3324                        // refreshed below once the claim writes
3325                        // ctx.model_name.
3326                        crate::modals::onboarding_wizard::paint_welcome(&ctx, renderer);
3327                        // `pending_run_codingplan` is only drained by the
3328                        // keystroke-handler path (handle_input → modal
3329                        // close → drain flag). The OAuth poll path doesn't
3330                        // route through there, so just call the codingplan
3331                        // driver directly — same effect, runs in this
3332                        // select! arm's scope where renderer + ctx are
3333                        // already mutable.
3334                        if let Err(e) = crate::event_loop::commands::run_codingplan_flow(renderer, &mut ctx) {
3335                            renderer.render(crate::render::UiLine::Error(
3336                                format!("CodingPlan 自动领取失败: {e:#}。可运行 /codingplan 手动重试。"),
3337                            ));
3338                            renderer.flush();
3339                        }
3340                        // Splice the resolved model name into the
3341                        // banner painted above. `run_codingplan_flow`
3342                        // updates `ctx.model_name` from the picked
3343                        // default provider (see commands.rs:2906) — at
3344                        // this point the banner's cached model="" is
3345                        // stale, so refresh in place.
3346                        let dir_display = crate::platform::collapse_home(
3347                            &ctx.working_dir.to_string_lossy(),
3348                        );
3349                        renderer.refresh_welcome_banner(&ctx.model_name, &dir_display);
3350                        // QR-fast-path onboarding bypasses the regular
3351                        // first-boot idle render (see ~line 2506), so
3352                        // the one-shot /setup tip never fires for users
3353                        // who land through the scan flow. Surface it
3354                        // here under the same gates: in-session
3355                        // once-only + `should_auto_show_setup` (no
3356                        // setup-state.json or missing recommender
3357                        // skill).
3358                        if !app.setup_hint_shown && should_auto_show_setup(&ctx) {
3359                            renderer.render(crate::render::UiLine::CommandOutput(
3360                                crate::i18n::t(crate::i18n::Msg::CmdSetupTip).into_owned(),
3361                            ));
3362                            renderer.flush();
3363                            app.setup_hint_shown = true;
3364                        }
3365                    }
3366                    OauthEvent::Failed(reason) => {
3367                        renderer.render(crate::render::UiLine::Error(
3368                            format!(
3369                                "登录失败: {reason}。运行 /codingplan 可重试。",
3370                            ),
3371                        ));
3372                        renderer.flush();
3373                    }
3374                }
3375            }
3376
3377            // ── MCP connection events ──
3378            // Render connection success/failure into scrollback as they arrive.
3379            // Also register tools dynamically when servers connect.
3380            Some(ev) = async {
3381                if let Some(rx) = ctx.mcp_connect_rx.as_mut() {
3382                    rx.recv().await
3383                } else {
3384                    None
3385                }
3386            }, if ctx.mcp_connect_rx.is_some() => {
3387                use atomcode_core::mcp::{McpConnectEvent, register_mcp_tools_async};
3388                match &ev {
3389                    McpConnectEvent::Connected { name } => {
3390                        renderer.render(UiLine::CommandOutput(
3391                            crate::i18n::t(crate::i18n::Msg::McpServerConnected { name }).into_owned(),
3392                        ));
3393                        // Register tools from this newly connected server (backgrounded).
3394                        if let Some(registry) = &ctx.mcp_registry {
3395                            let registry = registry.clone();
3396                            let tools = ctx.agent.tool_registry.clone();
3397                            let name = name.clone();
3398                            let tx = registry.event_sender();
3399                            tokio::spawn(async move {
3400                                let list_timeout = registry.list_tools_timeout(&name).await;
3401                                let server_tools = match tokio::time::timeout(
3402                                    list_timeout,
3403                                    registry.list_tools_for_server(&name),
3404                                )
3405                                .await
3406                                {
3407                                    Ok(v) => v,
3408                                    Err(_) => {
3409                                        if let Some(tx) = tx {
3410                                            let _ = tx.send(McpConnectEvent::Warning {
3411                                                name,
3412                                                message: format!(
3413                                                    "tools/list timed out after {}s during auto-registration",
3414                                                    list_timeout.as_secs()
3415                                                ),
3416                                            });
3417                                        }
3418                                        return;
3419                                    }
3420                                };
3421                                if !server_tools.is_empty() {
3422                                    register_mcp_tools_async(&tools, registry, server_tools).await;
3423                                }
3424                            });
3425                        }
3426                    }
3427                    McpConnectEvent::Failed { name, error } => {
3428                        renderer.render(UiLine::Error(
3429                            crate::i18n::t(crate::i18n::Msg::McpServerFailed { name, error }).into_owned(),
3430                        ));
3431                    }
3432                    McpConnectEvent::Warning { name, message } => {
3433                        // Default: keep MCP startup/runtime noise out of scrollback.
3434                        //
3435                        // Exception: `/mcp tools <server>` uses Warning events to return the tool list
3436                        // (and related timeouts) from a background task. Those should be user-visible.
3437                        if message.starts_with("tools:\n")
3438                            || message.contains("tools/list timed out")
3439                            || message.contains("tools/list failed")
3440                        {
3441                            renderer.render(UiLine::CommandOutput(format!(
3442                                "  [mcp:{}] {}\n",
3443                                name,
3444                                message.trim_end()
3445                            )));
3446                        } else {
3447                            // Route to the opt-in tuix trace log instead (safe for raw-mode TUI).
3448                            crate::tuix_trace!("MCP", "server='{}' warning: {}", name, message);
3449                        }
3450                    }
3451                }
3452
3453                // `/mcp reload` progress: once every configured server has reported a
3454                // terminal state (Connected/Failed), emit a summary line.
3455                if let Some(p) = ctx.mcp_reload.as_mut() {
3456                    match &ev {
3457                        McpConnectEvent::Connected { .. } => {
3458                            p.done = p.done.saturating_add(1);
3459                            p.connected = p.connected.saturating_add(1)
3460                        }
3461                        McpConnectEvent::Failed { .. } => {
3462                            p.done = p.done.saturating_add(1);
3463                            p.failed = p.failed.saturating_add(1)
3464                        }
3465                        McpConnectEvent::Warning { .. } => {}
3466                    }
3467                    if p.done >= p.total {
3468                        let elapsed_ms = p.started_at.elapsed().as_millis();
3469                        renderer.render(UiLine::CommandOutput(format!(
3470                            "  MCP reload complete: {} connected, {} failed ({}ms)\n",
3471                            p.connected, p.failed, elapsed_ms
3472                        )));
3473                        ctx.mcp_reload = None;
3474                    }
3475                }
3476                renderer.flush();
3477            }
3478
3479            // ── LSP server start / failure ──
3480            // Mirrors the MCP arm above. Without this, `LspManager`'s
3481            // raw `eprintln!` on a failed server start would land in the
3482            // input box (TUI owns the screen, stderr-fd writes hit
3483            // wherever the cursor sits — between the cyan rules).
3484            // Started → ✓ in scrollback. Failed → ✗ as an Error line.
3485            // Warning is non-actionable noise (e.g. shutdown teardown
3486            // errors) and routed to the trace log instead.
3487            Some(ev) = async {
3488                if let Some(rx) = ctx.lsp_connect_rx.as_mut() {
3489                    rx.recv().await
3490                } else {
3491                    None
3492                }
3493            }, if ctx.lsp_connect_rx.is_some() => {
3494                use atomcode_core::lsp::LspConnectEvent;
3495                match &ev {
3496                    LspConnectEvent::Started { command, ext } => {
3497                        renderer.render(UiLine::CommandOutput(
3498                            crate::i18n::t(crate::i18n::Msg::LspServerStarted { name: command, ext }).into_owned(),
3499                        ));
3500                    }
3501                    LspConnectEvent::Failed { command, ext, error } => {
3502                        renderer.render(UiLine::Error(
3503                            crate::i18n::t(crate::i18n::Msg::LspServerFailed { name: command, ext, error }).into_owned(),
3504                        ));
3505                    }
3506                    LspConnectEvent::Warning { ext, message } => {
3507                        crate::tuix_trace!("LSP", "ext='{}' warning: {}", ext, message);
3508                    }
3509                }
3510                renderer.flush();
3511            }
3512
3513            // ── /upgrade progress ──
3514            Some(ev) = ctx.upgrade_rx.recv() => {
3515                handle_upgrade_event(ev, &mut upgrade_last_pct, &mut upgrade_done, &mut ctx, renderer);
3516                if upgrade_done.is_some() { break; }
3517                if matches!(app.state.phase, UiPhase::Idle) {
3518                    redraw_idle_plain(&app.buf, &app.state, &ctx, renderer);
3519                }
3520            }
3521
3522            // ── /plugin async job result ──
3523            Some(ev) = ctx.plugin_job_rx.recv() => {
3524                handle_plugin_job_event(ev, &mut ctx, &app.state, renderer);
3525                if matches!(app.state.phase, UiPhase::Idle) {
3526                    redraw_idle_plain(&app.buf, &app.state, &ctx, renderer);
3527                }
3528            }
3529
3530            // ── Agent events ──
3531            // Consumed regardless of phase. Gating on Streaming missed
3532            // the TurnComplete that arrives *after* an Error event: the
3533            // Error handler flips phase to Idle, so the very next event
3534            // on the channel is stuck until the user submits again —
3535            // which is what "得发两次你好才结束" looked like in the UI.
3536            // Phase-specific behaviour (spinner redraw, type-ahead queue
3537            // drain) lives inside the match arms on `app.state.phase`.
3538            maybe = ctx.runtime_event_rx.recv() => {
3539                let Some(runtime_event) = maybe else { break };
3540                if runtime_event.runtime_id == ctx.foreground_runtime_id {
3541                    let pre_phase = app.state.phase;
3542                    handle_agent_event(runtime_event.event, &mut app.state, &mut app.think, renderer, &mut app.pending_tools, &mut ctx, &mut app.fixissue_pending, &mut app.fixissue_buffer, &mut app.reasoning_buffer, &app.buf);
3543                    if pre_phase != app.state.phase {
3544                        crate::tuix_trace!("PH", "{:?} -> {:?}", pre_phase, app.state.phase);
3545                    }
3546                    if matches!(app.state.phase, UiPhase::Streaming)
3547                        && last_spinner_draw.elapsed() >= Duration::from_millis(100)
3548                    {
3549                        draw_spinner_now(&mut app.state, &app.buf, &ctx, renderer, app.message_queue.len(), app.menu.selected);
3550                        last_spinner_draw = std::time::Instant::now();
3551                    }
3552                    if matches!(app.state.phase, UiPhase::Idle) {
3553                        if let Some(queued) = app.message_queue.pop_front() {
3554                            crate::tuix_trace!("QUE", "pop_front remaining={}", app.message_queue.len());
3555                            renderer.render(UiLine::User(queued.text.clone()));
3556                            renderer.flush();
3557                            ctx.agent.cmd_tx.send(AgentCommand::SendMessage {
3558                                text: queued.text,
3559                                images: queued.images,
3560                                image_markers: queued.image_markers,
3561                            }).ok();
3562                            app.state.on_submit();
3563                            draw_spinner_now(&mut app.state, &app.buf, &ctx, renderer, app.message_queue.len(), app.menu.selected);
3564                        } else {
3565                            crate::tuix_trace!("PH", "turn_end -> Idle, queue empty, redraw_idle");
3566                            redraw_idle_plain(&app.buf, &app.state, &ctx, renderer);
3567                        }
3568                    }
3569                } else {
3570                    ctx.bg_manager.apply_background_event(
3571                        runtime_event.runtime_id,
3572                        runtime_event.event,
3573                        &ctx.session_manager,
3574                    );
3575                }
3576            }
3577        }
3578
3579        if matches!(app.state.phase, UiPhase::Idle) && ctx.agent.cmd_tx.is_closed() {
3580            break;
3581        }
3582    }
3583
3584    // Stop the background spinner task. Dropping `spin_rx` at scope
3585    // exit would let it self-terminate on the next try_send, but abort
3586    // is immediate and has no downside — the task holds no resources
3587    // beyond the interval timer.
3588    spin_task.abort();
3589    let _ = ctx.history.save();
3590
3591    // Determine the exit reason. If the upgrade_done flag was set,
3592    // the loop exited because /upgrade (or /upgrade rollback) succeeded
3593    // and the live binary has been replaced — the caller should re-exec.
3594    if let Some(exe) = upgrade_done {
3595        Ok(ExitReason::UpgradeRestart { exe })
3596    } else {
3597        Ok(ExitReason::Normal)
3598    }
3599}
3600
3601/// If another atomcode process just ran `/codingplan` (i.e. the shared
3602/// sync marker file advanced since we last looked), pull the fresh
3603/// config from disk, clear our stale drift warning, and hand the new
3604/// config to the agent. Cheap on every keystroke: a single file-read
3605/// + serde parse. Idempotent — when no other process has synced, the
3606/// early return skips all work.
3607fn refresh_after_cross_process_codingplan_sync(ctx: &mut LoopCtx) {
3608    let current = atomcode_core::coding_plan::read_last_sync();
3609    let advanced = match (current, ctx.monitor_last_sync_seen) {
3610        (Some(new), Some(old)) => new > old,
3611        (Some(_), None) => true, // marker just appeared
3612        _ => false,
3613    };
3614    if !advanced {
3615        return;
3616    }
3617    ctx.monitor_last_sync_seen = current;
3618
3619    // Hot-reload the config file. Fail silently: if the other process
3620    // wrote a malformed config (shouldn't happen — it would have
3621    // rejected its own reload), leave our in-memory snapshot alone.
3622    let path = atomcode_core::config::Config::default_path();
3623    if let Ok(fresh) = atomcode_core::config::Config::load(&path) {
3624        ctx.config = fresh;
3625        ctx.runtime_factory.set_config(ctx.config.clone());
3626        if let Some(p) = ctx.config.providers.get(&ctx.config.default_provider) {
3627            ctx.model_name = p.model.clone();
3628        }
3629        let _ = ctx
3630            .agent
3631            .cmd_tx
3632            .send(AgentCommand::ReloadConfig(ctx.config.clone()));
3633    }
3634
3635    // Sync marker = another process just reconciled config with
3636    // server, so any drift warning we're still showing is stale by
3637    // definition. Reset the cooldown too so the next drift check
3638    // (if needed) fires immediately instead of waiting 15 min from
3639    // whenever we last checked.
3640    if let Ok(mut g) = ctx.monitor_warning.lock() {
3641        *g = None;
3642    }
3643    ctx.monitor_last_check_at = None;
3644    // Same logic for the usage slot — a cross-process /codingplan
3645    // re-sync may also have rotated the quota window. Clear + reset
3646    // so the next opportunity fetches fresh.
3647    if let Ok(mut g) = ctx.usage_slot.lock() {
3648        *g = None;
3649    }
3650    ctx.usage_last_check_at = None;
3651}
3652
3653/// Common attach-orchestration shared by every "I just got an image
3654/// from somewhere" entry point: bracketed-paste with empty payload
3655/// (clipboard image), bracketed-paste with file-path payload (iTerm2
3656/// Cmd+V on image / Finder drag-and-drop), and the explicit Ctrl+V
3657/// keystroke that pulls the clipboard image without going through any
3658/// paste event at all (the iTerm2 default-Cmd+V case where iTerm2
3659/// sends nothing through the PTY for image-only clipboards).
3660///
3661/// `img_hash` is the result of whichever provider the caller used —
3662/// `None` means no image was found and the caller should fall through
3663/// to its own non-image handling. When `Some`, this function takes
3664/// over: capability-checks the active model, emits a `[Image #N]`
3665/// marker into the input buffer, pushes the image bytes to
3666/// `pending_images` (drained at submit), writes the bytes to the
3667/// shared image cache so /resume can rehydrate, and triggers a
3668/// redraw appropriate to the current phase.
3669///
3670/// Returns:
3671///   - `Ok(true)`  — image was attached OR rejected with an error
3672///                    message; caller must `return Ok(())`.
3673///   - `Ok(false)` — no image to attach (`img_hash == None`); caller
3674///                    continues with its non-image flow.
3675fn attach_image_to_input(
3676    app: &mut App,
3677    ctx: &mut LoopCtx,
3678    renderer: &mut dyn Renderer,
3679    img_hash: Option<(ImagePart, u64)>,
3680) -> Result<bool> {
3681    let Some((img, hash)) = img_hash else {
3682        return Ok(false);
3683    };
3684    if !ctx.config.can_handle_attached_images() {
3685        renderer.render(UiLine::Error(
3686            crate::i18n::t(crate::i18n::Msg::ModelNoImageSupport {
3687                model: &ctx.model_name,
3688            })
3689            .into_owned(),
3690        ));
3691        renderer.flush();
3692        if matches!(app.state.phase, UiPhase::Idle) {
3693            redraw_idle_plain(&app.buf, &app.state, ctx, renderer);
3694        }
3695        return Ok(true);
3696    }
3697    // N comes from `session_image_count` (monotonic across turns), NOT
3698    // `pending_images.len()+1` — otherwise turn 1's first paste and
3699    // turn 2's first paste would both render as `[Image #1]` in
3700    // scrollback, ambiguous when scrolling back.
3701    app.state.session_image_count += 1;
3702    let n = app.state.session_image_count;
3703    app.state.pending_images.push(img.clone());
3704    app.state.pending_image_hashes.push(hash);
3705    app.state.pending_image_markers.push(n);
3706    cache_write_image(&crate::platform::image_cache_dir(), &img, hash);
3707    let marker = format!("[Image #{}]", n);
3708    app.buf.text.insert_str(app.buf.cursor, &marker);
3709    app.buf.cursor += marker.len();
3710    if matches!(app.state.phase, UiPhase::Streaming) {
3711        draw_spinner_now(
3712            &mut app.state,
3713            &app.buf,
3714            ctx,
3715            renderer,
3716            app.message_queue.len(),
3717            app.menu.selected,
3718        );
3719    } else {
3720        redraw_idle_plain(&app.buf, &app.state, ctx, renderer);
3721    }
3722    Ok(true)
3723}
3724
3725/// `/paste` slash-command handler. Exists for Windows users whose
3726/// Ctrl+V is intercepted by Windows Terminal / conhost before the
3727/// keystroke reaches atomcode — the terminal-layer `paste` action
3728/// only forwards `CF_UNICODETEXT`, so an image-only clipboard never
3729/// triggers the in-app `KeyCode::Char('v') + CONTROL` branch.
3730/// `/paste` invokes the same `try_paste_clipboard_image` →
3731/// `attach_image_to_input` pipeline directly, bypassing the
3732/// terminal's keybinds. Works on every platform — Windows / macOS /
3733/// Linux / git-bash — so it doubles as a discoverable backup
3734/// regardless of how Ctrl+V is configured locally. Falls back to a
3735/// scrollback error line when the clipboard has no image so the
3736/// user isn't left wondering whether the command did anything.
3737fn handle_paste_command(
3738    app: &mut App,
3739    ctx: &mut LoopCtx,
3740    renderer: &mut dyn Renderer,
3741) -> Result<()> {
3742    let img_hash = try_paste_clipboard_image();
3743    if img_hash.is_none() {
3744        renderer.render(UiLine::Error(
3745            crate::i18n::t(crate::i18n::Msg::CmdPasteNoImage).into_owned(),
3746        ));
3747        renderer.flush();
3748        if matches!(app.state.phase, UiPhase::Idle) {
3749            redraw_idle_plain(&app.buf, &app.state, ctx, renderer);
3750        }
3751        return Ok(());
3752    }
3753    attach_image_to_input(app, ctx, renderer, img_hash)?;
3754    Ok(())
3755}
3756
3757fn handle_input(
3758    app: &mut App,
3759    ctx: &mut LoopCtx,
3760    renderer: &mut dyn Renderer,
3761    ev: InputEvent,
3762) -> Result<()> {
3763    use crate::modals::ModalAction;
3764
3765    // Pick up any cross-process `/codingplan` that ran since the last
3766    // input — hot-reloads config + clears stale drift hint before we
3767    // act on the current keystroke.
3768    refresh_after_cross_process_codingplan_sync(ctx);
3769
3770    crate::tuix_trace!(
3771        "IN",
3772        "phase={:?} modal={} qlen={} ev={}",
3773        app.state.phase,
3774        app.active_modal.is_some(),
3775        app.message_queue.len(),
3776        match &ev {
3777            InputEvent::Paste(t) => format!("paste({})", t.len()),
3778            InputEvent::Eof => "eof".into(),
3779            InputEvent::Key(k) => format!("key({:?},{:?})", k.kind, k.code),
3780            InputEvent::Resize(w, h) => format!("resize({}x{})", w, h),
3781            InputEvent::MouseScroll(d) => format!("mouse_scroll({})", d),
3782            InputEvent::MouseDown { col, row } => format!("mouse_down({},{})", col, row),
3783            InputEvent::MouseDrag { col, row } => format!("mouse_drag({},{})", col, row),
3784            InputEvent::MouseUp => "mouse_up".into(),
3785        }
3786    );
3787
3788    match ev {
3789        InputEvent::MouseScroll(delta) => {
3790            // Mouse wheel — only the alt-screen renderer takes action;
3791            // retained / plain default to no-op (host terminal handles
3792            // their scrollback natively, mouse capture isn't enabled
3793            // for them anyway).
3794            renderer.scroll_body(delta);
3795        }
3796        InputEvent::MouseDown { col, row } => {
3797            // Anchor a new selection. Only AltScreenRenderer responds
3798            // (it owns mouse capture); other backends no-op since the
3799            // host terminal still does native drag-to-select for them.
3800            renderer.begin_selection(col, row);
3801        }
3802        InputEvent::MouseDrag { col, row } => {
3803            renderer.update_selection(col, row);
3804        }
3805        InputEvent::MouseUp => {
3806            renderer.end_selection();
3807        }
3808        InputEvent::Resize(mut cols, mut rows) => {
3809            // Coalesce burst-fired SIGWINCH events. gnome-terminal /
3810            // alacritty / iTerm2 send a Resize per pixel during a
3811            // window drag — a 200ms drag fires 30+ events. Without
3812            // coalescing each one runs `on_resize` (per-row CUP+EL
3813            // wipe + body re-emit + footer repaint), which the user
3814            // sees as flicker / 刷屏 (Linux Mint bug report).
3815            //
3816            // Drain whatever is already queued in `input_rx`:
3817            //   - adjacent Resize events collapse to the latest size
3818            //     (intermediate sizes are discarded — only the final
3819            //     geometry matters)
3820            //   - non-Resize events are buffered and dispatched AFTER
3821            //     `on_resize` settles, so they read `screen.width()` /
3822            //     `screen.height()` at the new geometry rather than
3823            //     an in-flight intermediate.
3824            //
3825            // Forward to the renderer so DECSTBM-based backends can
3826            // re-issue their scroll region and repaint the footer at
3827            // the new geometry. Fire-and-forget; the render worker
3828            // serialises this against in-flight content writes.
3829            let mut deferred: Vec<InputEvent> = Vec::new();
3830            while let Ok(next) = ctx.input_rx.try_recv() {
3831                match next {
3832                    InputEvent::Resize(w, h) => {
3833                        cols = w;
3834                        rows = h;
3835                    }
3836                    other => deferred.push(other),
3837                }
3838            }
3839            renderer.on_resize(cols, rows);
3840            for ev in deferred {
3841                handle_input(app, ctx, renderer, ev)?;
3842            }
3843        }
3844        InputEvent::Paste(text) => {
3845            // Route paste to the active modal when one is installed — the
3846            // provider/model/session wizards all have text-input steps
3847            // where pasting URLs / API keys / tokens is the natural UX.
3848            // Modals that don't want paste can override `handle_paste`
3849            // to drop it; the default inserts into `buf` + redraws.
3850            if matches!(app.state.phase, UiPhase::Idle) {
3851                if let Some(modal) = app.active_modal.as_mut() {
3852                    let action =
3853                        modal.handle_paste(&text, &mut app.buf, &mut app.state, ctx, renderer)?;
3854                    if matches!(action, crate::modals::ModalAction::Close) {
3855                        app.active_modal = None;
3856                        redraw_idle_plain(&app.buf, &app.state, ctx, renderer);
3857                    }
3858                    return Ok(());
3859                }
3860            }
3861            // No modal: paste goes into the type-ahead buffer just like
3862            // keyboard input (Idle or Streaming, both consume it).
3863            if matches!(app.state.phase, UiPhase::Idle | UiPhase::Streaming) {
3864                // Image-paste detection — two parallel providers, mutually
3865                // exclusive on `text` shape:
3866                //   * `text` empty → terminal sent bracketed paste with
3867                //     no payload because the system clipboard holds image
3868                //     bytes, not text. Pull via `arboard`. Terminals with
3869                //     bracketed paste enabled go through here on Cmd+V.
3870                //   * `text` non-empty + parses as an image filesystem
3871                //     path → iTerm2 Cmd+V on image clipboard (saves to a
3872                //     temp file under
3873                //     `/var/folders/.../T/com.googlecode.iterm2/` and
3874                //     pastes the path instead of bytes), Finder
3875                //     drag-and-drop, kitty/wezterm drag-and-drop. Without
3876                //     this branch the user just sees the literal path
3877                //     string land in their input buffer — Cmd+V on iTerm2
3878                //     felt broken vs. Claude Code / Aider, which all do
3879                //     this same path-recognition.
3880                let image_paste: Option<(ImagePart, u64)> = if text.trim().is_empty() {
3881                    try_paste_clipboard_image()
3882                } else {
3883                    try_attach_image_from_path(&text)
3884                };
3885                if attach_image_to_input(app, ctx, renderer, image_paste)? {
3886                    return Ok(());
3887                }
3888                app.buf.insert_paste(text);
3889                if matches!(app.state.phase, UiPhase::Streaming) {
3890                    draw_spinner_now(
3891                        &mut app.state,
3892                        &app.buf,
3893                        ctx,
3894                        renderer,
3895                        app.message_queue.len(),
3896                        app.menu.selected,
3897                    );
3898                } else {
3899                    redraw_idle_plain(&app.buf, &app.state, ctx, renderer);
3900                }
3901            }
3902        }
3903        InputEvent::Eof => {}
3904        // Act on Press AND Repeat. Release is dropped (it would double-fire
3905        // every handler on Windows, where crossterm emits all three kinds
3906        // per keystroke).
3907        //
3908        // Repeat is what the Kitty protocol's `REPORT_EVENT_TYPES` bit
3909        // (enabled in lib.rs) turns OS key autorepeat into — without
3910        // accepting it, holding Left/Right/Backspace only moves one step
3911        // because every autorepeat tick gets dropped here. Accepting it
3912        // also doesn't cause runaway Submit on a held Enter: Submit
3913        // transitions to Streaming phase, and Streaming's Enter handler
3914        // doesn't submit again.
3915        //
3916        // Terminals that don't support `REPORT_EVENT_TYPES` (iTerm2 3.5+,
3917        // Apple Terminal) leak autorepeat as repeated Press events
3918        // instead; the reader-level `MODIFIER_ENTER_DEDUP` handles the
3919        // one case where that's harmful (modifier+Enter → spurious
3920        // newlines).
3921        InputEvent::Key(KeyEvent {
3922            kind: KeyEventKind::Press | KeyEventKind::Repeat,
3923            code,
3924            modifiers,
3925            ..
3926        }) => {
3927            // Modal trumps phase handlers when it's installed — /model,
3928            // /provider, /resume all install a modal and the event loop
3929            // funnels every keystroke through it until it reports Close.
3930            //
3931            // Exception: Ctrl+C is a global exit shortcut and must NOT
3932            // be trappable by any modal. The OnboardingWizard's Intro
3933            // screen explicitly promises "Ctrl+C exits anytime" — and
3934            // more broadly, the universal keyboard escape hatch should
3935            // never depend on whichever modal happens to be open
3936            // forwarding it. Dismiss the modal and send Shutdown so
3937            // the run-loop tears down cleanly.
3938            if matches!(app.state.phase, UiPhase::Idle)
3939                && code == KeyCode::Char('c')
3940                && modifiers.contains(crossterm::event::KeyModifiers::CONTROL)
3941                && app.active_modal.is_some()
3942            {
3943                app.active_modal = None;
3944                ctx.agent.cmd_tx.send(AgentCommand::Shutdown).ok();
3945                return Ok(());
3946            }
3947            if matches!(app.state.phase, UiPhase::Idle) {
3948                if let Some(modal) = app.active_modal.as_mut() {
3949                    let action = modal.handle_key(
3950                        code,
3951                        modifiers,
3952                        &mut app.buf,
3953                        &mut app.state,
3954                        ctx,
3955                        renderer,
3956                    )?;
3957                    if matches!(action, ModalAction::Close) {
3958                        app.active_modal = None;
3959                        // IssueWizard signals a staged title+body via
3960                        // `ctx.pending_new_issue`. Drain + POST to the
3961                        // AtomGit API here and echo the created-issue
3962                        // URL into scrollback. Blocking call — the
3963                        // wizard is modal so UI freezing briefly is
3964                        // expected / acceptable.
3965                        if let Some(draft) = ctx.pending_new_issue.take() {
3966                            match atomcode_core::atomgit::Client::from_stored_auth().and_then(|c| {
3967                                c.create_issue(&draft.owner, &draft.repo, &draft.title, &draft.body)
3968                            }) {
3969                                Ok(created) => {
3970                                    let shown_url = created.html_url.clone().unwrap_or_else(|| {
3971                                        format!(
3972                                            "https://atomgit.com/{}/{}/issues/{}",
3973                                            draft.owner, draft.repo, created.number
3974                                        )
3975                                    });
3976                                    renderer.render(UiLine::CommandOutput(
3977                                        crate::i18n::t(crate::i18n::Msg::IssueCreated {
3978                                            number: created.number,
3979                                            title: &created.title,
3980                                            url: &shown_url,
3981                                        }).into_owned(),
3982                                    ));
3983                                }
3984                                Err(e) => {
3985                                    renderer.render(UiLine::CommandOutput(
3986                                        crate::i18n::t(crate::i18n::Msg::IssueCreateFailed {
3987                                            error: &format!("{:#}", e),
3988                                        }).into_owned(),
3989                                    ));
3990                                }
3991                            }
3992                            renderer.flush();
3993                        }
3994                        // OnboardingWizard signals its follow-up via two bool
3995                        // flags. Drain one, execute it here — the
3996                        // CodingPlan flow (which internally handles
3997                        // OAuth login when needed) needs suspend/resume
3998                        // of raw mode (only event-loop scope can drive
3999                        // that safely), and opening ProviderWizard is a
4000                        // Modal-to-Modal swap that needs mutable
4001                        // `active_modal` access the modals themselves
4002                        // don't have.
4003                        if std::mem::take(&mut ctx.pending_run_codingplan) {
4004                            crate::event_loop::commands::run_codingplan_flow(renderer, ctx)?;
4005                        }
4006                        if std::mem::take(&mut ctx.pending_open_provider_wizard) {
4007                            let pw = crate::modals::ProviderWizard::MainMenu { selected: 0 };
4008                            app.active_modal = Some(Box::new(pw));
4009                            if let Some(m) = app.active_modal.as_mut() {
4010                                m.draw(&app.buf, &app.state, ctx, renderer);
4011                            }
4012                            // ProviderWizard owns the next frame now; skip
4013                            // the idle redraw below so we don't clobber it.
4014                            return Ok(());
4015                        }
4016                        redraw_idle_plain(&app.buf, &app.state, ctx, renderer);
4017                    }
4018                    return Ok(());
4019                }
4020            }
4021            // PageUp / PageDown / Home / End: scroll the body
4022            // viewport. Universal across phases — same as a terminal's
4023            // own scrollback navigation. Only AltScreenRenderer
4024            // implements these (it owns the alt-screen buffer and
4025            // host-terminal scrollback is unavailable while we're
4026            // in alt-screen); other renderers default to no-op so
4027            // these keys do nothing in retained / plain modes (as
4028            // before — those rely on the host terminal's native
4029            // scrollback). We intercept BEFORE phase dispatch so
4030            // scrolling works in Idle / Streaming alike.
4031
4032            // ── Ctrl+C: copy selection ──────────────────────────────
4033            // On Windows, OSC 52 is not supported by Windows Terminal /
4034            // conhost, so the user cannot copy text by mouse-selecting
4035            // alone (end_selection's OSC 52 write is silently ignored).
4036            // Ctrl+C is the user's natural instinct to copy selected
4037            // text. We intercept it here: if a selection exists in the
4038            // alt-screen renderer, copy its text to the system clipboard
4039            // via arboard and clear the selection. If no selection exists,
4040            // fall through to the normal Cancel behaviour.
4041            //
4042            // This also helps on macOS / Linux when the user prefers
4043            // Ctrl+C / Cmd+C over the mouse-release OSC 52 auto-copy,
4044            // or when the terminal ignores OSC 52 (macOS Terminal.app
4045            // without the opt-in setting).
4046            if code == crossterm::event::KeyCode::Char('c')
4047                && modifiers.contains(crossterm::event::KeyModifiers::CONTROL)
4048                && !modifiers.contains(crossterm::event::KeyModifiers::SHIFT)
4049                && !modifiers.contains(crossterm::event::KeyModifiers::ALT)
4050            {
4051                // Windows: the OS Ctrl+C signal handler may have already
4052                // consumed this Ctrl+C as a copy (see the `win_ctrl_c`
4053                // select arm above). The keyboard event arrives shortly
4054                // after via input_rx. If the signal handler stamped
4055                // `last_ctrl_c_copy` within the last 500 ms, suppress
4056                // the keyboard echo so it doesn't trigger Cancel/exit.
4057                #[cfg(windows)]
4058                if let Some(ts) = app.last_ctrl_c_copy.take() {
4059                    if ts.elapsed() < Duration::from_millis(500) {
4060                        crate::tuix_trace!("KEY", "ctrl+c keyboard echo suppressed (OS signal already handled copy)");
4061                        return Ok(());
4062                    }
4063                }
4064                if renderer.copy_selection() {
4065                    return Ok(());
4066                }
4067                // No selection — fall through to Cancel below.
4068            }
4069
4070            // Ctrl+V: pull the system clipboard image and attach as
4071            // `[Image #N]` — independent of whether the host terminal
4072            // forwarded a Paste event for the keystroke. The status
4073            // line hint "Image in clipboard · ctrl+v to paste"
4074            // already promises this chord, but iTerm2's default Cmd+V
4075            // on an image-only clipboard sends NOTHING through the
4076            // PTY (no plaintext to paste, so iTerm2's Paste action
4077            // becomes a no-op), which made Cmd+V feel broken vs.
4078            // Claude Code on the same setup. Catching the literal
4079            // Ctrl+V (\x16, KeyCode::Char('v') + CONTROL) here closes
4080            // the gap on every terminal in one place — no per-host
4081            // OSC negotiation needed.
4082            //
4083            // For users who want Cmd+V muscle memory: remap iTerm2's
4084            // Cmd+V to "Send: 0x16" in Preferences → Profiles → Keys
4085            // → Key Mappings, then Cmd+V → Ctrl+V → this handler.
4086            //
4087            // Gated to Idle / Streaming. Approval and Suspended don't
4088            // accept input; modals (handled above) get first refusal.
4089            // Shift / Alt with Ctrl+V are excluded so reserved chords
4090            // (e.g. terminal-emulator-defined Ctrl+Shift+V "Paste as
4091            // Plain Text") still pass through to whatever else might
4092            // bind them in the future.
4093            if matches!(
4094                app.state.phase,
4095                UiPhase::Idle | UiPhase::Streaming
4096            ) && code == crossterm::event::KeyCode::Char('v')
4097                && modifiers.contains(crossterm::event::KeyModifiers::CONTROL)
4098                && !modifiers.contains(crossterm::event::KeyModifiers::SHIFT)
4099                && !modifiers.contains(crossterm::event::KeyModifiers::ALT)
4100            {
4101                let img_hash = try_paste_clipboard_image();
4102                if attach_image_to_input(app, ctx, renderer, img_hash)? {
4103                    return Ok(());
4104                }
4105                // No image — fall back to clipboard text. Reaching this
4106                // branch means the host terminal forwarded Ctrl+V as a
4107                // real `\x16` key event rather than intercepting it as
4108                // bracketed paste or character injection (classic
4109                // conhost / older Windows Terminal configs / WT after
4110                // the user removed the `paste` keybind per our Windows
4111                // docs all hit this path). Without this fallback the
4112                // keystroke is silently swallowed and the user's text
4113                // paste disappears — a regression from before the
4114                // Ctrl+V → image handler existed.
4115                //
4116                // Routing through `InputEvent::Paste` instead of
4117                // `app.buf.insert_paste` directly so we get the modal-
4118                // first dispatch, the image-from-path check, and the
4119                // Streaming-vs-Idle redraw branching for free.
4120                if let Some(text) = try_paste_clipboard_text() {
4121                    return handle_input(app, ctx, renderer, InputEvent::Paste(text));
4122                }
4123                // Empty clipboard — Ctrl+V has no other binding
4124                // (key_action::classify maps it to NoOp), so swallow
4125                // silently rather than insert a literal `v`.
4126                return Ok(());
4127            }
4128
4129            if let Some(handled) = handle_scroll_key(code, modifiers, renderer, &app.buf) {
4130                if handled {
4131                    return Ok(());
4132                }
4133            }
4134            match app.state.phase {
4135                UiPhase::Idle => handle_idle_key(app, ctx, renderer, code, modifiers)?,
4136                UiPhase::Streaming => handle_streaming_key(app, ctx, renderer, code, modifiers)?,
4137                UiPhase::Approval => handle_approval_key(app, ctx, renderer, code, modifiers)?,
4138                UiPhase::Suspended => {}
4139            }
4140        }
4141        // Release key events: drop on the floor. Press / Repeat are handled
4142        // above; Release is noise on Windows.
4143        InputEvent::Key(_) => {}
4144    }
4145    Ok(())
4146}
4147
4148/// Try handling a scroll-related key (PageUp/PageDown/Home/End).
4149/// Returns:
4150///   - `Some(true)`  → key consumed; caller should skip phase dispatch
4151///   - `Some(false)` → key was a scroll key but not consumed (e.g.
4152///     Home/End with text in input buffer, where they should move
4153///     cursor instead)
4154///   - `None`        → not a scroll key at all
4155///
4156/// AltScreenRenderer is the only renderer that does anything with
4157/// these calls; the trait defaults are no-op so retained / plain
4158/// silently fall through and let the existing phase dispatch handle
4159/// the key (e.g. End-of-line cursor movement during input).
4160fn handle_scroll_key(
4161    code: crossterm::event::KeyCode,
4162    modifiers: crossterm::event::KeyModifiers,
4163    renderer: &mut dyn crate::render::Renderer,
4164    buf: &Buffer,
4165) -> Option<bool> {
4166    use crossterm::event::{KeyCode, KeyModifiers};
4167    // Don't intercept Home/End when the user is editing a non-empty
4168    // buffer — those should move the cursor, not jump scrollback.
4169    // PageUp/PageDown and Shift+Up/Shift+Down always scroll regardless
4170    // (they're explicit scroll commands, not in-line editing keys).
4171    let buf_empty = buf.text.is_empty();
4172    let has_shift = modifiers.contains(KeyModifiers::SHIFT);
4173    match code {
4174        // Page-step. macOS keyboards: Fn+Up / Fn+Down generate
4175        // PageUp / PageDown. iTerm2 / Windows have dedicated keys.
4176        KeyCode::PageUp => {
4177            renderer.scroll_body(-10);
4178            Some(true)
4179        }
4180        KeyCode::PageDown => {
4181            renderer.scroll_body(10);
4182            Some(true)
4183        }
4184        // Line-step. Shift+Up / Shift+Down is the cross-keyboard
4185        // alternative for users without a dedicated PageUp/Down key.
4186        // Bare Up/Down stays bound to input-history navigation
4187        // (Action::HistoryPrev/Next via key_action::map) for backward
4188        // compat with retained mode.
4189        KeyCode::Up if has_shift => {
4190            renderer.scroll_body(-1);
4191            Some(true)
4192        }
4193        KeyCode::Down if has_shift => {
4194            renderer.scroll_body(1);
4195            Some(true)
4196        }
4197        KeyCode::Home if buf_empty && modifiers.is_empty() => {
4198            renderer.scroll_body_to_top();
4199            Some(true)
4200        }
4201        KeyCode::End if buf_empty && modifiers.is_empty() => {
4202            renderer.scroll_body_to_bottom();
4203            Some(true)
4204        }
4205        _ => None,
4206    }
4207}
4208
4209/// Slash-command palette state. Active whenever buf starts with '/'.
4210pub struct MenuState {
4211    pub selected: usize,
4212}
4213
4214impl MenuState {
4215    pub fn new() -> Self {
4216        Self { selected: 0 }
4217    }
4218}
4219
4220// `ModelPicker` moved to `crate::modals::model_picker`; re-exported at
4221// `crate::modals::ModelPicker` for existing call sites (execute_slash_command).
4222pub use crate::modals::ModelPicker;
4223
4224// `SessionPicker` moved to `crate::modals::session_picker`; re-exported
4225// at `crate::modals::SessionPicker` for existing call sites.
4226pub use crate::modals::SessionPicker;
4227
4228// `ProviderWizard` + `WizardStep` + `DraftProvider` moved to
4229// `crate::modals::provider_wizard`; re-exported at `crate::modals` for
4230// existing call sites (execute_slash_command).
4231pub use crate::modals::ProviderWizard;
4232
4233/// Filter the command registry by the buf's prefix after '/'. Returns the
4234/// (name, desc) pairs matching, or None if menu shouldn't show (buf doesn't
4235/// start with '/' or has whitespace, meaning the user has moved on to args).
4236/// Custom commands are appended after built-in matches; duplicates (custom
4237/// command with the same name as a built-in) are suppressed.
4238fn build_menu_items(
4239    buf: &str,
4240    cursor: usize,
4241    commands: &CommandRegistry,
4242    custom: &atomcode_core::commands::CustomCommandRegistry,
4243    skill_registry: Option<&std::sync::RwLock<atomcode_core::skill::SkillRegistry>>,
4244    file_index: Option<&file_index::FileIndex>,
4245) -> Option<Vec<(String, String)>> {
4246    // `@`-mention branch — checked first so it takes priority over any
4247    // `/` interpretation.
4248    if let (Some(idx), Some(token)) =
4249        (file_index, file_index::detect_at_mention(buf, cursor))
4250    {
4251        let (scope_dir, filter) = file_index::split_token(&token);
4252        let entries = idx.filter(&scope_dir, &filter);
4253        if entries.is_empty() {
4254            return None;
4255        }
4256        // Show the FULL relative path (including the scope prefix) so the
4257        // user always sees where they are. e.g. when scope is `crates/`,
4258        // list `crates/atomcode-cli/` not just `atomcode-cli/`.
4259        return Some(
4260            entries
4261                .into_iter()
4262                .map(|e| (e.rel_path, String::new()))
4263                .collect(),
4264        );
4265    }
4266
4267    if !buf.starts_with('/') {
4268        return None;
4269    }
4270
4271    // Two-level palette for skills.
4272    //
4273    // Level 1 (top): the built-in `/skills` entry acts as a gateway —
4274    // it does NOT expand into individual skills here, so it cannot
4275    // crowd or collide with built-in / custom commands.
4276    //
4277    // Level 2 (sub-mode): once the user has typed `/skills ` (with a
4278    // trailing space, usually injected by the needs_args path on
4279    // Enter), this branch fires and lists user-invocable skills under
4280    // their bare names. Submission rewrites the committed line back
4281    // to `/skills <name>` so the `skills` arm in execute_slash_command
4282    // looks up `skills:<name>` in the registry and dispatches.
4283    if let Some(after) = buf.strip_prefix("/skills ") {
4284        // Beyond the skill name (user typing skill args) — close menu.
4285        if after.contains(char::is_whitespace) {
4286            return None;
4287        }
4288        let prefix_lower = after.to_ascii_lowercase();
4289        let mut items: Vec<(String, String)> = Vec::new();
4290        if let Some(reg) = skill_registry {
4291            if let Ok(reg) = reg.read() {
4292                let skills: Vec<_> = reg.user_invocable().collect();
4293                for skill in &skills {
4294                    // Match against either the bare suffix (`adapter-check…`)
4295                    // or the full qualified name (`ascend-model-agent-plugin:
4296                    // adapter-check…`). Bare match keeps the shorthand UX;
4297                    // full match lets users narrow by plugin (`/ascend-`).
4298                    let bare = skill
4299                        .name
4300                        .split_once(':')
4301                        .map(|(_, s)| s)
4302                        .unwrap_or(skill.name.as_str());
4303                    let full_lower = skill.name.to_ascii_lowercase();
4304                    let bare_lower = bare.to_ascii_lowercase();
4305                    if bare_lower.starts_with(&prefix_lower)
4306                        || full_lower.starts_with(&prefix_lower)
4307                    {
4308                        let bare_is_unique = skills.iter().all(|other| {
4309                            other.name == skill.name
4310                                || other
4311                                    .name
4312                                    .split_once(':')
4313                                    .map(|(_, s)| s)
4314                                    .unwrap_or(other.name.as_str())
4315                                    != bare
4316                        });
4317                        let display = if bare_is_unique {
4318                            bare.to_string()
4319                        } else {
4320                            skill.name.clone()
4321                        };
4322                        items.push((display, skill.description.clone()));
4323                    }
4324                }
4325            }
4326        }
4327        // Stable order so navigation feels predictable across runs.
4328        items.sort_by(|a, b| a.0.cmp(&b.0));
4329        return if items.is_empty() { None } else { Some(items) };
4330    }
4331
4332    let rest = &buf[1..];
4333    // Once a space appears (user is typing args), stop showing menu.
4334    if rest.contains(char::is_whitespace) {
4335        return None;
4336    }
4337    let prefix_lower = rest.to_ascii_lowercase();
4338    // Top-level: built-ins (which now include the `/skills` gateway)
4339    // followed by custom commands. Individual skills are intentionally
4340    // hidden from this level — users access them via `/skills <name>`.
4341    let mut matches: Vec<(String, String)> = commands
4342        .matching_prefix(rest)
4343        .into_iter()
4344        .map(|c| {
4345            let desc = crate::commands::cmd_desc_i18n(c.name)
4346                .map(|cow| cow.into_owned())
4347                .unwrap_or_else(|| c.desc.to_string());
4348            (c.name.to_string(), desc)
4349        })
4350        .collect();
4351    for (name, desc) in custom.command_names_and_descriptions() {
4352        if name.starts_with(&prefix_lower) && !matches.iter().any(|(n, _)| *n == name) {
4353            matches.push((name, desc));
4354        }
4355    }
4356    let _ = skill_registry; // referenced only inside the sub-mode branch above
4357    if matches.is_empty() {
4358        None
4359    } else {
4360        Some(matches)
4361    }
4362}
4363
4364fn handle_idle_key(
4365    app: &mut App,
4366    ctx: &mut LoopCtx,
4367    renderer: &mut dyn Renderer,
4368    code: KeyCode,
4369    modifiers: crossterm::event::KeyModifiers,
4370) -> Result<()> {
4371    // If the menu is active (buf starts with '/'), intercept navigation keys.
4372    // Suppress while scrolling history — otherwise a recalled `/se…` from
4373    // history immediately re-pops the menu and traps Up inside it.
4374    let menu_items = if app.buf.is_in_history() {
4375        None
4376    } else {
4377        build_menu_items(&app.buf.text, app.buf.cursor, &ctx.commands, &ctx.custom_commands, Some(&ctx.skill_registry), Some(&ctx.file_index))
4378    };
4379    if let Some(items) = &menu_items {
4380        // Clamp selection in range.
4381        if app.menu.selected >= items.len() {
4382            app.menu.selected = items.len() - 1;
4383        }
4384        match (code, modifiers) {
4385            (KeyCode::Up, _) => {
4386                // Wrap to the last item (mirror Down's modular wrap below).
4387                // The menu is fully modal — to reach input history with a
4388                // partial slash buffer like `/se`, press Esc or Backspace
4389                // to clear the buffer first.  Previously Up at index 0
4390                // cleared the buffer and fell through to history nav,
4391                // which felt like the menu had silently swallowed your
4392                // text and dumped you somewhere unexpected.
4393                app.menu.selected = if app.menu.selected == 0 {
4394                    items.len() - 1
4395                } else {
4396                    app.menu.selected - 1
4397                };
4398                redraw_with_menu(
4399                    &app.buf,
4400                    items,
4401                    app.menu.selected,
4402                    &app.state,
4403                    ctx,
4404                    renderer,
4405                );
4406                return Ok(());
4407            }
4408            (KeyCode::Down, _) => {
4409                app.menu.selected = (app.menu.selected + 1) % items.len();
4410                redraw_with_menu(
4411                    &app.buf,
4412                    items,
4413                    app.menu.selected,
4414                    &app.state,
4415                    ctx,
4416                    renderer,
4417                );
4418                return Ok(());
4419            }
4420            (KeyCode::Enter | KeyCode::Tab, m)
4421                if !m.contains(crossterm::event::KeyModifiers::SHIFT) =>
4422            {
4423                // Tab and Enter both pick the highlighted entry, but
4424                // they diverge on no-arg top-level commands:
4425                //   * Enter   → execute immediately (legacy behavior).
4426                //   * Tab     → complete only — rewrite the buffer to
4427                //               `/name ` and park the cursor, mirroring
4428                //               shell tab-completion. The user reviews
4429                //               the line and presses Enter to fire.
4430                // For @-mentions, `needs_args` commands, and the
4431                // `/skills` palette, both keys behave identically
4432                // because those branches were already complete-only.
4433                // Shift+Enter (hard newline) is excluded by the
4434                // modifier guard; crossterm reports Shift+Tab as
4435                // `KeyCode::BackTab` so it doesn't match this arm.
4436                //
4437                // `@`-mention selection: insert `@<full_path> ` at the
4438                // token range, with trailing space as terminator.
4439                // Backspace on the trailing space lets the user re-open
4440                // the menu for drill-down.
4441                if !items.is_empty() {
4442                    if let Some((at_pos, end)) =
4443                        file_index::detect_at_mention_range(&app.buf.text, app.buf.cursor)
4444                    {
4445                        // `items[selected].0` is the full relative path
4446                        // (e.g. `crates/atomcode-cli/`); prepend `@` and a
4447                        // trailing space terminator.
4448                        let selected_path = items[app.menu.selected].0.clone();
4449                        let replacement = format!("@{} ", selected_path);
4450                        app.buf.text.replace_range(at_pos..end, &replacement);
4451                        app.buf.cursor = at_pos + replacement.len();
4452                        app.menu.selected = 0;
4453                        redraw_idle_plain(&app.buf, &app.state, ctx, renderer);
4454                        return Ok(());
4455                    }
4456                }
4457
4458                // Accept the highlighted command. Two shapes:
4459                //   * arg-less commands (e.g. /help, /quit, /login) → execute
4460                //     immediately on Enter, as before.
4461                //   * commands that require an arg (e.g. /background <task>) →
4462                //     auto-complete the name + trailing space and park the
4463                //     cursor so the user types the arg next. A SECOND Enter
4464                //     (once the arg is filled in) commits normally through
4465                //     the regular BufferResult::Commit → execute_slash_command
4466                //     path at the bottom of this function.
4467                let name = items[app.menu.selected].0.clone();
4468                let needs_args = ctx
4469                    .commands
4470                    .find(&name)
4471                    .map(|c| c.needs_args)
4472                    .unwrap_or(false);
4473                app.menu.selected = 0;
4474
4475                if needs_args {
4476                    // Rewrite buffer to `/name ` and park cursor at the end.
4477                    // Menu rebuilds on next keystroke — with the trailing
4478                    // space parse_slash_line returns `Some(("name", ""))`
4479                    // so build_menu_items correctly hides the menu.
4480                    app.buf.text = format!("/{} ", name);
4481                    app.buf.cursor = app.buf.text.len();
4482
4483                    // The `/skills` gateway is special: build_menu_items
4484                    // recognises the `/skills ` prefix and returns the
4485                    // second-level palette of skills. Render that
4486                    // immediately so the user doesn't see the menu blink
4487                    // out and reappear.
4488                    if name == "skills" {
4489                        if let Some(items) = build_menu_items(
4490                            &app.buf.text,
4491                            app.buf.cursor,
4492                            &ctx.commands,
4493                            &ctx.custom_commands,
4494                            Some(&ctx.skill_registry),
4495                            Some(&ctx.file_index),
4496                        ) {
4497                            app.menu.selected = 0;
4498                            redraw_with_menu(&app.buf, &items, 0, &app.state, ctx, renderer);
4499                            return Ok(());
4500                        }
4501                        // Empty sub-mode: build_menu_items returned None
4502                        // for the `/skills ` form, which at this point
4503                        // can only mean the registry has zero
4504                        // user-invocable skills (the filter is empty —
4505                        // we just appended a space — so there's no
4506                        // "no matches" case here, only "no skills").
4507                        // Without feedback the user sees `/skills `
4508                        // with no menu and concludes the feature is
4509                        // broken (reported by a Windows user with a
4510                        // clean install). Emit a one-time scrollback
4511                        // hint pointing at the install paths so they
4512                        // know what to do next; keep the buffer at
4513                        // `/skills ` so backspace still recovers.
4514                        renderer.render(UiLine::CommandOutput(
4515                            "  \u{24d8} No user-invocable skills installed yet.\n    \
4516                            \u{2022} Drop SKILL.md into ~/.atomcode/skills/<name>/ \n      \
4517                              (Windows: %USERPROFILE%\\.atomcode\\skills\\<name>\\)\n    \
4518                            \u{2022} Or install a plugin that ships skills via /plugin install <git-url>\n\n"
4519                                .into(),
4520                        ));
4521                    }
4522
4523                    redraw_idle_plain(&app.buf, &app.state, ctx, renderer);
4524                    return Ok(());
4525                }
4526
4527                // Sub-mode submit: items in the skills palette carry
4528                // bare names (e.g. "brainstorming"). Mirror the
4529                // `needs_args` branch above — Enter from the palette
4530                // auto-completes to `/skills <name> ` and parks the
4531                // cursor at the end so the user can append args
4532                // (passed to `/use_skill` as `argument`). A second
4533                // Enter (with or without args) commits through the
4534                // regular BufferResult::Commit path. Without this,
4535                // skills always fired without args, and there was no
4536                // way to pass `argument` into the skill from the
4537                // picker.
4538                let in_skills_sub_mode = app.buf.text.starts_with("/skills ");
4539                if in_skills_sub_mode {
4540                    app.buf.text = format!("/skills {} ", name);
4541                    app.buf.cursor = app.buf.text.len();
4542                    redraw_idle_plain(&app.buf, &app.state, ctx, renderer);
4543                    return Ok(());
4544                }
4545
4546                // Top-level no-arg command (e.g. /quit, /help).
4547                // Tab → complete-only: insert `/name ` and park the
4548                // cursor so the user can review/edit before pressing
4549                // Enter to fire. The trailing space causes
4550                // build_menu_items to hide the menu on the next redraw
4551                // (parse_slash_line treats `/name ` as a fully-named
4552                // command with empty arg).
4553                if code == KeyCode::Tab {
4554                    app.buf.text = format!("/{} ", name);
4555                    app.buf.cursor = app.buf.text.len();
4556                    redraw_idle_plain(&app.buf, &app.state, ctx, renderer);
4557                    return Ok(());
4558                }
4559
4560                // Enter: execute immediately, as before.
4561                let committed = format!("/{}", name);
4562                renderer.render(UiLine::ClearTransient);
4563                renderer.render(UiLine::User(committed.clone()));
4564                app.buf.text.clear();
4565                app.buf.cursor = 0;
4566                if let Some((cmd, arg)) = parse_slash_line(&committed) {
4567                    if cmd.eq_ignore_ascii_case("paste") {
4568                        // `/paste` needs `&mut app.buf` to insert the
4569                        // `[Image #N]` marker at the cursor, which the
4570                        // `execute_slash_command` signature doesn't
4571                        // expose; short-circuit to the local handler.
4572                        handle_paste_command(app, ctx, renderer)?;
4573                    } else {
4574                        execute_slash_command(
4575                            cmd,
4576                            arg,
4577                            &mut app.state,
4578                            ctx,
4579                            renderer,
4580                            &mut app.active_modal,
4581                            &mut app.fixissue_pending,
4582                            &mut app.fixissue_buffer,
4583                        )?;
4584                    }
4585                    if matches!(app.state.phase, UiPhase::Idle) {
4586                        redraw_after_slash(&app.buf, &app.state, ctx, &app.active_modal, renderer);
4587                    }
4588                }
4589                return Ok(());
4590            }
4591            (KeyCode::Esc, _) => {
4592                // Close menu by clearing buffer.
4593                app.buf.text.clear();
4594                app.buf.cursor = 0;
4595                app.menu.selected = 0;
4596                redraw_idle_plain(&app.buf, &app.state, ctx, renderer);
4597                return Ok(());
4598            }
4599            _ => {} // fall through to buffer edits
4600        }
4601    }
4602
4603    // Tab toggles Plan/Build mode when no completion menu is visible —
4604    // there is nothing to complete, so the key is repurposed for mode
4605    // switching instead.
4606    if code == KeyCode::Tab && menu_items.is_none() {
4607        app.state.agent_mode = app.state.agent_mode.toggle();
4608        let is_plan = matches!(app.state.agent_mode, crate::state::AgentMode::Plan);
4609        ctx.agent
4610            .cmd_tx
4611            .send(AgentCommand::SetPlanMode(is_plan))
4612            .ok();
4613        renderer.render(UiLine::CommandOutput(format!(
4614            "  Switched to {} mode.\n",
4615            app.state.agent_mode.label()
4616        )));
4617        renderer.flush();
4618        redraw_idle_plain(&app.buf, &app.state, ctx, renderer);
4619        return Ok(());
4620    }
4621
4622    // Ctrl+V: try clipboard image first, fall back to text paste.
4623    if code == KeyCode::Char('v') && modifiers.contains(crossterm::event::KeyModifiers::CONTROL) {
4624        if let Some((img, hash)) = try_paste_clipboard_image() {
4625            // Refuse to attach an image when there is no path for it to
4626            // reach a vision-capable model — neither the active provider
4627            // accepts images, nor a vision_preprocessor is configured to
4628            // OCR them first. Without this gate, sending burns a turn on
4629            // a 400 from the upstream's param validator (e.g.
4630            // ModelArts.81001 "message[N].content[0] has invalid
4631            // field(s): text, type" for GLM-5.1). Helper in
4632            // `Config::can_handle_attached_images`.
4633            if !ctx.config.can_handle_attached_images() {
4634                renderer.render(UiLine::Error(
4635                    crate::i18n::t(crate::i18n::Msg::ModelNoImageSupport {
4636                        model: &ctx.model_name,
4637                    })
4638                    .into_owned(),
4639                ));
4640                renderer.flush();
4641                redraw_idle_plain(&app.buf, &app.state, ctx, renderer);
4642                return Ok(());
4643            }
4644            // Insert the `[Image #N]` marker into the input buffer at
4645            // cursor — same pattern as `insert_paste` for long text.
4646            // The marker echoes through to scrollback on submit; image
4647            // bytes are stashed in `pending_images` and drained then.
4648            // N comes from `session_image_count` (monotonic across
4649            // turns), NOT `pending_images.len()+1` — otherwise turn 1's
4650            // first paste and turn 2's first paste would both render as
4651            // `[Image #1]` in scrollback, ambiguous when scrolling back.
4652            app.state.session_image_count += 1;
4653            let n = app.state.session_image_count;
4654            app.state.pending_images.push(img.clone());
4655            app.state.pending_image_hashes.push(hash);
4656            app.state.pending_image_markers.push(n);
4657            cache_write_image(&crate::platform::image_cache_dir(), &img, hash);
4658            let marker = format!("[Image #{}]", n);
4659            app.buf.text.insert_str(app.buf.cursor, &marker);
4660            app.buf.cursor += marker.len();
4661            redraw_idle_plain(&app.buf, &app.state, ctx, renderer);
4662            return Ok(());
4663        }
4664        // No image in clipboard — fall through to normal key handling
4665        // (the `v` char will be inserted as a regular character via classify).
4666    }
4667
4668    // Multi-line cursor nav (idle path). Mirror of the streaming-mode
4669    // handler: in a buffer with embedded newlines, plain Up/Down walks
4670    // through the lines first; only when the cursor is already on the
4671    // first/last line does it surface as HistoryPrev/Next. Gated to
4672    // "no modifiers" so Shift+Up (body scroll) and other compound keys
4673    // still classify normally.
4674    if modifiers.is_empty() {
4675        match code {
4676            KeyCode::Up if app.buf.cursor_line_up() => {
4677                redraw_idle_plain(&app.buf, &app.state, ctx, renderer);
4678                return Ok(());
4679            }
4680            KeyCode::Down if app.buf.cursor_line_down() => {
4681                redraw_idle_plain(&app.buf, &app.state, ctx, renderer);
4682                return Ok(());
4683            }
4684            _ => {}
4685        }
4686    }
4687
4688    let action = classify(code, modifiers);
4689    let result = app.buf.apply(action, ctx.history.entries(), &ctx.commands);
4690    sync_recalled_attachments(&mut app.state, &app.buf, ctx.history.entries());
4691    crate::tuix_trace!(
4692        "KEY",
4693        "idle result={} buf_len={} cursor={}",
4694        match &result {
4695            BufferResult::NoOp => "NoOp",
4696            BufferResult::Redraw => "Redraw",
4697            BufferResult::Commit(_) => "Commit",
4698            BufferResult::Exit => "Exit",
4699        },
4700        app.buf.text.len(),
4701        app.buf.cursor
4702    );
4703    // Any key that's not the Ctrl+C-on-empty-buffer exit path resets the
4704    // "press again to exit" arming — otherwise the prompt would stick around
4705    // across arbitrary edits, defeating the point of a short time window.
4706    if !matches!(result, BufferResult::Exit) {
4707        app.exit_pending = None;
4708    }
4709    match result {
4710        BufferResult::NoOp => {}
4711        BufferResult::Redraw => {
4712            // Rebuild menu after buf change. Same is_in_history gate
4713            // as above so a HistoryPrev that just landed on `/se…`
4714            // doesn't immediately re-show the slash menu.
4715            let items = if app.buf.is_in_history() {
4716                None
4717            } else {
4718                build_menu_items(&app.buf.text, app.buf.cursor, &ctx.commands, &ctx.custom_commands, Some(&ctx.skill_registry), Some(&ctx.file_index))
4719            };
4720            if let Some(items) = items {
4721                if app.menu.selected >= items.len() {
4722                    app.menu.selected = 0;
4723                }
4724                redraw_with_menu(
4725                    &app.buf,
4726                    &items,
4727                    app.menu.selected,
4728                    &app.state,
4729                    ctx,
4730                    renderer,
4731                );
4732            } else {
4733                app.menu.selected = 0;
4734                redraw_idle_plain(&app.buf, &app.state, ctx, renderer);
4735            }
4736        }
4737        BufferResult::Commit(line) => {
4738            renderer.render(UiLine::ClearTransient);
4739            app.buf.text.clear();
4740            app.buf.cursor = 0;
4741            // NB: `app.buf.clear_pastes()` is deferred until AFTER the
4742            // submit path calls `expand_pastes(&line)` — wiping the
4743            // paste Vec here used to leave `expand_pastes` with
4744            // nothing to substitute, so the agent received the raw
4745            // `[Pasted #N +M lines]` placeholder instead of the
4746            // pasted body and answered "I don't see any pasted
4747            // content". Mirrors the queue branch below which already
4748            // clears AFTER expansion.
4749            app.menu.selected = 0;
4750            // Only treat `/name …` as a slash command when `name` is
4751            // actually registered. Unrecognised `/foo …` (e.g. the user
4752            // typed `/test 文件下有哪些文件` meaning to *ask about*
4753            // `/test`, or just `/test` as a question) falls through to
4754            // the regular message path — better than the old
4755            // "Unknown command: /foo" dead-end.
4756            let as_slash = parse_slash_line(&line).filter(|(cmd, _)| {
4757                ctx.commands.find(cmd).is_some()
4758                    || ctx.custom_commands.get(&cmd.to_ascii_lowercase()).is_some()
4759                    || ctx
4760                        .skill_registry
4761                        .read()
4762                        .ok()
4763                        .and_then(|r| r.get(cmd).map(|s| s.user_invocable))
4764                        .unwrap_or(false)
4765            });
4766            if let Some((cmd, arg)) = as_slash {
4767                // Slash commands carry no image markers — echo the
4768                // user line as-typed, before dispatch.
4769                renderer.render(UiLine::User(line.clone()));
4770                if cmd.eq_ignore_ascii_case("paste") {
4771                    // See `handle_paste_command` — short-circuited
4772                    // here because the dispatcher signature can't
4773                    // hand it `&mut app.buf`.
4774                    handle_paste_command(app, ctx, renderer)?;
4775                } else {
4776                    execute_slash_command(
4777                        cmd,
4778                        arg,
4779                        &mut app.state,
4780                        ctx,
4781                        renderer,
4782                        &mut app.active_modal,
4783                        &mut app.fixissue_pending,
4784                        &mut app.fixissue_buffer,
4785                    )?;
4786                }
4787                if matches!(app.state.phase, UiPhase::Idle) {
4788                    redraw_after_slash(&app.buf, &app.state, ctx, &app.active_modal, renderer);
4789                } else if matches!(app.state.phase, UiPhase::Approval) {
4790                    // After /bg <N> resume into an approval-waiting session,
4791                    // redraw the footer with an empty input box. Don't use
4792                    // draw_spinner_now because spinner_label was cleared by
4793                    // on_turn_complete() — it would show "◓ …" which is
4794                    // misleading. The next agent event (ApprovalNeeded /
4795                    // TurnComplete) will update the footer naturally.
4796                    redraw_idle_plain(&app.buf, &app.state, ctx, renderer);
4797                }
4798                // Slash commands don't consume pastes (they take a
4799                // single short arg, not a pasted body), but the submit
4800                // semantically consumes the buffer — drop them so the
4801                // next message starts with a clean paste registry.
4802                app.buf.clear_pastes();
4803            } else {
4804                // Hydrate recalled attachments BEFORE echoing the user
4805                // line, so `[Image #N]` markers in the visible body
4806                // match the renumbered markers that the
4807                // `└ [Image #N]` post-submit echo (and the actual
4808                // submit payload) use. Without this, an arrow-up
4809                // recall + edit would render `[Image #1]` in the body
4810                // while the echo + payload carry `[Image #2]` — the
4811                // user reasonably reads that as a bug ("two different
4812                // numbers for the same image").
4813                let cache_dir = crate::platform::image_cache_dir();
4814                let mut line = line; // shadow as mutable so hydrate can rewrite it
4815                for n in hydrate_recalled_attachments(&mut app.state, &mut line, &cache_dir) {
4816                    renderer.render(UiLine::Warning(n));
4817                }
4818                renderer.render(UiLine::User(line.clone()));
4819                let expanded = app.buf.expand_pastes(&line);
4820                // Pastes have now been substituted into `expanded`;
4821                // safe to drop the registry. Doing it any earlier
4822                // (e.g. up at the buf.text.clear() prep) was the
4823                // exact bug that made the agent see only the
4824                // `[Pasted #N]` placeholder.
4825                app.buf.clear_pastes();
4826                // Cache the full expanded form before dispatch. If the
4827                // user hits Ctrl+C / Esc mid-stream, `handle_streaming_key`
4828                // takes this Option and restores it to `app.buf.text`
4829                // so the cancelled message can be edited and resent.
4830                app.state.last_submitted_message = Some(expanded.clone());
4831                // Only attach images whose `[Image #N]` marker survived
4832                // editing — if the user deleted the marker from the input
4833                // buffer, the corresponding image must not be sent. Echo
4834                // the kept images as `└ [Image #N]` sub-lines so scrollback
4835                // shows what was actually sent.
4836                let pending = std::mem::take(&mut app.state.pending_images);
4837                let pending_markers = std::mem::take(&mut app.state.pending_image_markers);
4838                let pending_hashes = std::mem::take(&mut app.state.pending_image_hashes);
4839                let mut images: Vec<ImagePart> = Vec::with_capacity(pending.len());
4840                let mut kept_markers: Vec<usize> = Vec::with_capacity(pending.len());
4841                let mut kept_refs: Vec<crate::input::history::HistoryImageRef> =
4842                    Vec::with_capacity(pending.len());
4843                // Use the marker `n` recorded at paste time, NOT the index.
4844                // Once `session_image_count` became monotonic, paste-time
4845                // markers diverge from positional indices — using the index
4846                // would silently drop every image after the first turn that
4847                // had a paste.
4848                for ((img, n), hash) in pending
4849                    .into_iter()
4850                    .zip(pending_markers.into_iter())
4851                    .zip(pending_hashes.into_iter())
4852                {
4853                    if line.contains(&format!("[Image #{}]", n)) {
4854                        renderer.render(UiLine::ImageAttachment(n));
4855                        kept_refs.push(crate::input::history::HistoryImageRef {
4856                            hash: format!("{:016x}", hash),
4857                            mt: img.media_type.clone(),
4858                            n,
4859                        });
4860                        images.push(img);
4861                        kept_markers.push(n);
4862                    }
4863                }
4864                ctx.history.push(crate::input::history::HistoryEntry {
4865                    text: line.clone(),
4866                    images: kept_refs,
4867                });
4868                ctx.agent
4869                    .cmd_tx
4870                    .send(AgentCommand::SendMessage {
4871                        text: expanded,
4872                        images,
4873                        image_markers: kept_markers,
4874                    })
4875                    .ok();
4876                app.state.on_submit();
4877                // CodingPlan drift check — fire before every turn sent
4878                // to a CodingPlan-managed provider, gated by a 15-min
4879                // cooldown so rapid-fire messages don't spam the API.
4880                // Non-CodingPlan users skip entirely (zero network).
4881                if monitor::is_codingplan_provider(&ctx.config.default_provider) {
4882                    let cooled = ctx
4883                        .monitor_last_check_at
4884                        .map(|t| t.elapsed() >= monitor::CHECK_COOLDOWN)
4885                        .unwrap_or(true);
4886                    if cooled {
4887                        ctx.monitor_last_check_at = Some(std::time::Instant::now());
4888                        monitor::spawn_check(
4889                            ctx.config.clone(),
4890                            ctx.model_name.clone(),
4891                            ctx.monitor_warning.clone(),
4892                            ctx.wake_tx.clone(),
4893                        );
4894                    }
4895                }
4896            }
4897        }
4898        BufferResult::Exit => {
4899            // Two-press confirmation: first Ctrl+C on an empty buffer arms
4900            // the exit; a second Ctrl+C within the window actually exits.
4901            // Any other keystroke (handled above) resets the arming.
4902            let now = std::time::Instant::now();
4903            let armed = app
4904                .exit_pending
4905                .is_some_and(|t| now.duration_since(t) <= CTRL_C_EXIT_WINDOW);
4906            if armed {
4907                ctx.agent.cmd_tx.send(AgentCommand::Shutdown).ok();
4908            } else {
4909                app.exit_pending = Some(now);
4910                renderer.render(UiLine::CommandOutput(
4911                    crate::i18n::t(crate::i18n::Msg::CtrlCAgainToExit).into_owned(),
4912                ));
4913                renderer.flush();
4914                redraw_idle_plain(&app.buf, &app.state, ctx, renderer);
4915            }
4916        }
4917    }
4918    Ok(())
4919}
4920
4921fn redraw_with_menu(
4922    buf: &Buffer,
4923    items: &[(String, String)],
4924    selected: usize,
4925    state: &UiState,
4926    ctx: &LoopCtx,
4927    renderer: &mut dyn Renderer,
4928) {
4929    let kind = if file_index::detect_at_mention_range(&buf.text, buf.cursor).is_some() {
4930        crate::render::MenuKind::AtMention
4931    } else {
4932        crate::render::MenuKind::SlashCommand
4933    };
4934    let payload = crate::render::MenuPayload {
4935        items: items.to_vec(),
4936        selected,
4937        kind,
4938    };
4939    let attachments = compute_input_attachments(state, &buf.text);
4940    renderer.render(UiLine::InputPrompt {
4941        buf: buf.text.clone(),
4942        cursor_byte: buf.cursor,
4943        menu: Some(payload),
4944        status: build_status(state, ctx),
4945        attachments,
4946    });
4947    renderer.flush();
4948}
4949
4950/// Synchronize `state.pending_recalled_attachments` with whatever
4951/// history entry the buffer is currently showing. Called after every
4952/// `buf.apply()` so:
4953///   - HistoryPrev/Next sets the recalled attachments to the new entry
4954///   - Insert/Delete (which clear `history_idx` to None) only drop the
4955///     refs whose `[Image #N]` marker is no longer in `buf.text`. A
4956///     user who arrow-up'd a `[Image #1]这是什么?` entry and then
4957///     appended `还有一个问题` should keep the image attached on
4958///     submit — the marker is still there, so `hydrate_recalled_attachments`
4959///     can still match it. Wiping wholesale (the prior behaviour) sent
4960///     the literal `[Image #1]` as text and silently dropped the bytes.
4961pub(crate) fn sync_recalled_attachments(
4962    state: &mut UiState,
4963    buf: &Buffer,
4964    history: &[crate::input::history::HistoryEntry],
4965) {
4966    match buf.history_idx() {
4967        Some(i) if i < history.len() => {
4968            state.pending_recalled_attachments = history[i].images.clone();
4969        }
4970        _ => {
4971            state
4972                .pending_recalled_attachments
4973                .retain(|r| buf.text.contains(&format!("[Image #{}]", r.n)));
4974        }
4975    }
4976}
4977
4978/// Idle prompt without any menu/picker — used by the common
4979/// "Redraw" path and the post-event-loop fallback after an agent
4980/// event returns the UI to Idle.
4981fn redraw_idle_plain(buf: &Buffer, state: &UiState, ctx: &LoopCtx, renderer: &mut dyn Renderer) {
4982    let attachments = compute_input_attachments(state, &buf.text);
4983    renderer.render(UiLine::InputPrompt {
4984        buf: buf.text.clone(),
4985        cursor_byte: buf.cursor,
4986        menu: None,
4987        status: build_status(state, ctx),
4988        attachments,
4989    });
4990    renderer.flush();
4991}
4992
4993/// True iff startup should auto-open the OnboardingWizard:
4994/// no providers configured AND no OAuth login on disk AND we're
4995/// running in an interactive renderer. Plain mode (CI / pipe /
4996/// non-TTY) falls through to the "no provider configured" status
4997/// hint instead — the bordered-panel wizard can't sensibly run
4998/// without a human watching keystrokes.
4999pub(crate) fn should_auto_show_onboarding(ctx: &LoopCtx) -> bool {
5000    if ctx.is_plain_renderer {
5001        return false;
5002    }
5003    ctx.config.providers.is_empty() && atomcode_core::auth::get_stored_auth().is_none()
5004}
5005
5006/// True iff startup should show the one-shot `/setup` hint in scrollback.
5007/// Returns `true` when either:
5008///   - `setup-state.json` doesn't exist (never ran `/setup`), OR
5009///   - the recommender skill directory is missing (user deleted it after setup).
5010fn should_auto_show_setup(ctx: &LoopCtx) -> bool {
5011    let state = atomcode_core::setup::state::load_setup_state(&ctx.working_dir);
5012    if state.is_none() {
5013        return true; // never ran setup → show hint
5014    }
5015
5016    // setup-state.json exists but the skill may have been deleted manually.
5017    // Path must match SkillRegistry::reload's scan path: the unified
5018    // Config::config_dir() (== ATOMCODE_HOME when set, else ~/.atomcode).
5019    let skill_dir = atomcode_core::config::Config::config_dir()
5020        .join("skills")
5021        .join("atomcode-automation-recommender");
5022    !skill_dir.exists()
5023}
5024
5025/// Extract current + latest version from the `ALREADY_LATEST` error
5026/// body. The shape is fixed by `self_update.rs`:
5027///   `already on {current} (latest is {latest}). Pass --force to reinstall.`
5028/// Returns None if the format ever drifts — caller falls back to "?"
5029/// placeholders so the localized sentence still renders cleanly.
5030fn parse_already_latest_versions(s: &str) -> Option<(&str, &str)> {
5031    let after_on = s.strip_prefix("already on ")?;
5032    let (current, rest) = after_on.split_once(" (latest is ")?;
5033    let latest = rest.strip_suffix(". Pass --force to reinstall.")?;
5034    let latest = latest.strip_suffix(')')?;
5035    Some((current, latest))
5036}
5037
5038#[cfg(test)]
5039mod parse_already_latest_versions_tests {
5040    use super::parse_already_latest_versions;
5041    #[test]
5042    fn extracts_both_versions() {
5043        let s = "already on v4.22.2 (latest is v4.22.2). Pass --force to reinstall.";
5044        assert_eq!(parse_already_latest_versions(s), Some(("v4.22.2", "v4.22.2")));
5045    }
5046    #[test]
5047    fn rejects_unrelated_strings() {
5048        assert!(parse_already_latest_versions("something else entirely").is_none());
5049    }
5050}
5051
5052/// Redraw after running a slash command. If the command installed a
5053/// modal, delegate the draw to it so the modal's menu appears; otherwise
5054/// fall through to the plain idle prompt.
5055///
5056/// Replaces the old per-picker `redraw_idle` that hard-coded payload
5057/// construction for model/session. New modals just implement `draw`.
5058fn redraw_after_slash(
5059    buf: &Buffer,
5060    state: &UiState,
5061    ctx: &LoopCtx,
5062    active_modal: &Option<Box<dyn crate::modals::Modal>>,
5063    renderer: &mut dyn Renderer,
5064) {
5065    if let Some(modal) = active_modal.as_ref() {
5066        modal.draw(buf, state, ctx, renderer);
5067    } else {
5068        redraw_idle_plain(buf, state, ctx, renderer);
5069    }
5070}
5071
5072/// Persist config changes and notify the daemon to pick them up.
5073/// Refresh the plugin-derived registries on `LoopCtx` after a
5074/// `/plugin` install / uninstall / marketplace mutation. Re-walks the
5075/// skill / custom-command sources from disk so newly-installed plugin
5076/// assets become visible to the slash-command palette and the agent
5077/// loop within the same session.
5078///
5079/// Hook executor is NOT rebuilt here: in this codebase the executor
5080/// lives entirely on the agent side (see `agent::mod` lines around
5081/// 718–722) and is reconstructed per `cd`. New hook plugins therefore
5082/// pick up at the next `/cd` (or process restart). Per spec §8 this
5083/// deferred behavior is acceptable.
5084/// Returns `(skills_loaded, skip_warnings)`. Caller decides how (and
5085/// whether) to surface the warnings — the TUI gates them behind verbose
5086/// mode (Ctrl+O) and always shows a `N loaded / M skipped` summary on
5087/// /plugin install. Non-summary callers can ignore both values.
5088pub(crate) fn reload_plugins(ctx: &mut LoopCtx) -> (usize, Vec<String>) {
5089    let mut loaded = 0usize;
5090    let mut warnings = Vec::new();
5091    if let Ok(mut guard) = ctx.skill_registry.write() {
5092        warnings = guard.reload(&ctx.working_dir);
5093        loaded = guard.all().count();
5094    }
5095    ctx.custom_commands = atomcode_core::commands::CustomCommandRegistry::load(&ctx.working_dir);
5096    // Hook executor lives on the agent loop. Send a one-shot rebuild signal
5097    // so plugin-contributed hooks (especially UserPromptSubmit) fire on the
5098    // next user message rather than waiting for /cd or restart.
5099    let _ = ctx
5100        .agent
5101        .cmd_tx
5102        .send(atomcode_core::agent::AgentCommand::ReloadHooks);
5103    (loaded, warnings)
5104}
5105
5106pub(crate) fn save_and_reload(ctx: &mut LoopCtx, renderer: &mut dyn Renderer) {
5107    let path = Config::default_path();
5108    match ctx.config.save(&path) {
5109        Ok(()) => {
5110            ctx.runtime_factory.set_config(ctx.config.clone());
5111            let _ = ctx
5112                .agent
5113                .cmd_tx
5114                .send(AgentCommand::ReloadConfig(ctx.config.clone()));
5115        }
5116        Err(e) => {
5117            renderer.render(UiLine::Error(crate::i18n::t(crate::i18n::Msg::ConfigSaveFailed { error: &format!("{}", e) }).into_owned()));
5118            renderer.flush();
5119        }
5120    }
5121}
5122
5123/// On Ctrl+C / Esc during streaming, pull the running message back
5124/// into the input buffer so the user can edit and resend without
5125/// re-typing. Also drops any type-ahead queue entries: a user
5126/// pulling the escape cord doesn't want queued messages to
5127/// auto-fire after the current one dies. The actual `TurnCancelled`
5128/// event (plus the flip back to Idle + footer redraw) arrives later
5129/// via the agent round-trip — but the spinner tick at 80ms+ redraws
5130/// the StreamingBox with `buf.text`, so the restored message shows
5131/// up within a frame.
5132fn restore_cancelled_message_to_buf(app: &mut App, renderer: &mut dyn Renderer, ctx: &LoopCtx) {
5133    app.message_queue.clear();
5134    if let Some(msg) = app.state.last_submitted_message.take() {
5135        app.buf.text = msg;
5136        app.buf.cursor = app.buf.text.len();
5137        app.menu.selected = 0;
5138        // Force an immediate StreamingBox repaint so the restored
5139        // text shows in the input box on this frame, not the next
5140        // spinner tick.
5141        draw_spinner_now(
5142            &mut app.state,
5143            &app.buf,
5144            ctx,
5145            renderer,
5146            app.message_queue.len(),
5147            app.menu.selected,
5148        );
5149    }
5150}
5151
5152fn handle_streaming_key(
5153    app: &mut App,
5154    ctx: &mut LoopCtx,
5155    renderer: &mut dyn Renderer,
5156    code: KeyCode,
5157    modifiers: crossterm::event::KeyModifiers,
5158) -> Result<()> {
5159    // Ctrl+O toggles verbose mode (real-time tool output + reasoning visibility)
5160    if code == KeyCode::Char('o') && modifiers.contains(crossterm::event::KeyModifiers::CONTROL) {
5161        app.state.toggle_tool_output();
5162        // Show feedback to the user about the current state
5163        let status = if app.state.show_tool_output {
5164            "  ○ Verbose mode enabled (tool output + reasoning visible) (Ctrl+O to hide)\n"
5165        } else {
5166            "  ○ Verbose mode disabled (Ctrl+O to show tool output + reasoning)\n"
5167        };
5168        renderer.render(UiLine::CommandOutput(status.to_string()));
5169        renderer.flush();
5170        draw_spinner_now(
5171            &mut app.state,
5172            &app.buf,
5173            ctx,
5174            renderer,
5175            app.message_queue.len(),
5176            app.menu.selected,
5177        );
5178        return Ok(());
5179    }
5180
5181    // Ctrl+C always cancels the running turn — highest priority so
5182    // users have a reliable escape hatch even mid-edit. Also drops
5183    // the type-ahead queue: a user yanking the escape cord doesn't
5184    // want queued messages to auto-fire after the current one dies.
5185    if code == KeyCode::Char('c') && modifiers.contains(crossterm::event::KeyModifiers::CONTROL) {
5186        ctx.agent.cmd_tx.send(AgentCommand::Cancel).ok();
5187        restore_cancelled_message_to_buf(app, renderer, ctx);
5188        return Ok(());
5189    }
5190
5191    // Esc also cancels a running turn (CC-style). Placed before the
5192    // menu-nav block so Streaming + menu-open Esc still cancels the
5193    // stream — mid-stream the higher-value action is "stop the agent",
5194    // not "clear an unsubmitted slash token" (users can Ctrl+U for that).
5195    if code == KeyCode::Esc {
5196        ctx.agent.cmd_tx.send(AgentCommand::Cancel).ok();
5197        restore_cancelled_message_to_buf(app, renderer, ctx);
5198        return Ok(());
5199    }
5200
5201    // When the menu is active (buf starts with `/`), intercept nav keys
5202    // so the user can browse candidate commands mid-stream. Execution
5203    // is still blocked below — Enter falls through to the commit arm,
5204    // which emits the "disabled while a turn is running" hint.
5205    let menu_items = build_menu_items(&app.buf.text, app.buf.cursor, &ctx.commands, &ctx.custom_commands, Some(&ctx.skill_registry), Some(&ctx.file_index));
5206    if let Some(items) = &menu_items {
5207        if app.menu.selected >= items.len() {
5208            app.menu.selected = items.len() - 1;
5209        }
5210        match code {
5211            KeyCode::Up => {
5212                app.menu.selected = app.menu.selected.saturating_sub(1);
5213                draw_spinner_now(
5214                    &mut app.state,
5215                    &app.buf,
5216                    ctx,
5217                    renderer,
5218                    app.message_queue.len(),
5219                    app.menu.selected,
5220                );
5221                return Ok(());
5222            }
5223            KeyCode::Down => {
5224                if app.menu.selected + 1 < items.len() {
5225                    app.menu.selected += 1;
5226                }
5227                draw_spinner_now(
5228                    &mut app.state,
5229                    &app.buf,
5230                    ctx,
5231                    renderer,
5232                    app.message_queue.len(),
5233                    app.menu.selected,
5234                );
5235                return Ok(());
5236            }
5237            KeyCode::Esc => {
5238                app.buf.text.clear();
5239                app.buf.cursor = 0;
5240                app.menu.selected = 0;
5241                draw_spinner_now(
5242                    &mut app.state,
5243                    &app.buf,
5244                    ctx,
5245                    renderer,
5246                    app.message_queue.len(),
5247                    app.menu.selected,
5248                );
5249                return Ok(());
5250            }
5251            _ => {} // fall through to buffer edits
5252        }
5253    }
5254
5255    // Multi-line cursor nav: in a buffer with embedded newlines, plain
5256    // Up/Down should walk through the lines first; only when the
5257    // cursor is already on the first/last line does it surface as
5258    // HistoryPrev/Next. Matches the convention from fish / Cursor /
5259    // Claude Code — losing a multi-line draft to "I was just trying
5260    // to fix line 2" is far worse than the historical single-line
5261    // shortcut.  Gated to "no modifiers" so Shift+Up (selection in
5262    // some terminals) and other compound keys still classify normally.
5263    if modifiers.is_empty() {
5264        match code {
5265            KeyCode::Up if app.buf.cursor_line_up() => {
5266                draw_spinner_now(
5267                    &mut app.state,
5268                    &app.buf,
5269                    ctx,
5270                    renderer,
5271                    app.message_queue.len(),
5272                    app.menu.selected,
5273                );
5274                return Ok(());
5275            }
5276            KeyCode::Down if app.buf.cursor_line_down() => {
5277                draw_spinner_now(
5278                    &mut app.state,
5279                    &app.buf,
5280                    ctx,
5281                    renderer,
5282                    app.message_queue.len(),
5283                    app.menu.selected,
5284                );
5285                return Ok(());
5286            }
5287            _ => {}
5288        }
5289    }
5290
5291    let action = classify(code, modifiers);
5292    let apply_result = app.buf.apply(action, ctx.history.entries(), &ctx.commands);
5293    sync_recalled_attachments(&mut app.state, &app.buf, ctx.history.entries());
5294    match apply_result {
5295        BufferResult::NoOp => {}
5296        BufferResult::Redraw => {
5297            // Menu shape may have changed — reset selection if it
5298            // now points past the (possibly shorter) list.
5299            if let Some(items) = build_menu_items(&app.buf.text, app.buf.cursor, &ctx.commands, &ctx.custom_commands, Some(&ctx.skill_registry), Some(&ctx.file_index)) {
5300                if app.menu.selected >= items.len() {
5301                    app.menu.selected = 0;
5302                }
5303            } else {
5304                app.menu.selected = 0;
5305            }
5306            draw_spinner_now(
5307                &mut app.state,
5308                &app.buf,
5309                ctx,
5310                renderer,
5311                app.message_queue.len(),
5312                app.menu.selected,
5313            );
5314        }
5315        BufferResult::Commit(line) => {
5316            // Slash commands are not queued — they need ctx access
5317            // that only makes sense between turns. Show a hint and
5318            // leave the buf alone. Gate strictly on *registered*
5319            // commands; unrecognised `/foo …` falls through to the
5320            // type-ahead queue as a regular message.
5321            let bg_background_current = parse_slash_line(&line)
5322                .map(|(cmd, arg)| cmd.eq_ignore_ascii_case("bg") && arg.trim().is_empty())
5323                .unwrap_or(false);
5324            if bg_background_current {
5325                commands::execute_slash_command(
5326                    "bg",
5327                    "",
5328                    &mut app.state,
5329                    ctx,
5330                    renderer,
5331                    &mut app.active_modal,
5332                    &mut app.fixissue_pending,
5333                    &mut app.fixissue_buffer,
5334                )?;
5335                app.message_queue.clear();
5336                app.pending_tools.clear();
5337                app.think.reset();
5338                app.reasoning_buffer.clear();
5339                app.buf.text.clear();
5340                app.buf.cursor = 0;
5341                app.menu.selected = 0;
5342                return Ok(());
5343            }
5344            let is_known_slash = parse_slash_line(&line)
5345                .map(|(cmd, _)| ctx.commands.find(cmd).is_some())
5346                .unwrap_or(false);
5347            if is_known_slash {
5348                renderer.render(UiLine::CommandOutput(
5349                    "  (slash commands are disabled while a turn is running)\n".into(),
5350                ));
5351                renderer.flush();
5352                app.buf.text.clear();
5353                app.buf.cursor = 0;
5354                app.menu.selected = 0;
5355                draw_spinner_now(
5356                    &mut app.state,
5357                    &app.buf,
5358                    ctx,
5359                    renderer,
5360                    app.message_queue.len(),
5361                    app.menu.selected,
5362                );
5363                return Ok(());
5364            }
5365            // Hydrate recalled attachments BEFORE building the queue
5366            // payload — same prelude as the idle submit path, so a user
5367            // who pressed ↑ during streaming sees their recalled images
5368            // travel with the queued message instead of being silently
5369            // dropped on dispatch.
5370            let mut line = line;
5371            let cache_dir_for_hydrate = crate::platform::image_cache_dir();
5372            for n in hydrate_recalled_attachments(&mut app.state, &mut line, &cache_dir_for_hydrate) {
5373                renderer.render(UiLine::Warning(n));
5374            }
5375            let expanded = app.buf.expand_pastes(&line);
5376            // Mirror the main submit path's image filtering: only
5377            // attachments whose `[Image #N]` marker survived editing
5378            // travel with this submission, both into the queue
5379            // payload and into the persisted history entry.
5380            let pending = std::mem::take(&mut app.state.pending_images);
5381            let pending_markers = std::mem::take(&mut app.state.pending_image_markers);
5382            let pending_hashes = std::mem::take(&mut app.state.pending_image_hashes);
5383            let mut q_images: Vec<ImagePart> = Vec::with_capacity(pending.len());
5384            let mut q_markers: Vec<usize> = Vec::with_capacity(pending.len());
5385            let mut q_refs: Vec<crate::input::history::HistoryImageRef> =
5386                Vec::with_capacity(pending.len());
5387            for ((img, n), hash) in pending
5388                .into_iter()
5389                .zip(pending_markers.into_iter())
5390                .zip(pending_hashes.into_iter())
5391            {
5392                if line.contains(&format!("[Image #{}]", n)) {
5393                    renderer.render(UiLine::ImageAttachment(n));
5394                    q_refs.push(crate::input::history::HistoryImageRef {
5395                        hash: format!("{:016x}", hash),
5396                        mt: img.media_type.clone(),
5397                        n,
5398                    });
5399                    q_images.push(img);
5400                    q_markers.push(n);
5401                }
5402            }
5403            ctx.history.push(crate::input::history::HistoryEntry {
5404                text: line.clone(),
5405                images: q_refs,
5406            });
5407            app.message_queue.push_back(crate::state::QueuedMessage {
5408                text: expanded,
5409                images: q_images,
5410                image_markers: q_markers,
5411            });
5412            crate::tuix_trace!("QUE", "push_back len={}", app.message_queue.len());
5413            app.buf.text.clear();
5414            app.buf.cursor = 0;
5415            app.buf.clear_pastes();
5416            // Echo as a queued entry so the user sees it landed.
5417            renderer.render(UiLine::CommandOutput(format!("  ↳ queued: {}\n", line)));
5418            renderer.flush();
5419            draw_spinner_now(
5420                &mut app.state,
5421                &app.buf,
5422                ctx,
5423                renderer,
5424                app.message_queue.len(),
5425                app.menu.selected,
5426            );
5427        }
5428        BufferResult::Exit => {
5429            // Ctrl+C on empty buf during streaming — treat as cancel
5430            // (consistent with the explicit Ctrl+C branch above).
5431            ctx.agent.cmd_tx.send(AgentCommand::Cancel).ok();
5432            restore_cancelled_message_to_buf(app, renderer, ctx);
5433        }
5434    }
5435    Ok(())
5436}
5437
5438fn handle_approval_key(
5439    app: &mut App,
5440    ctx: &mut LoopCtx,
5441    renderer: &mut dyn Renderer,
5442    code: KeyCode,
5443    modifiers: crossterm::event::KeyModifiers,
5444) -> Result<()> {
5445    // Ctrl+C: first press denies the tool and arms exit confirmation;
5446    // second press within the window actually exits.
5447    if code == KeyCode::Char('c') && modifiers.contains(crossterm::event::KeyModifiers::CONTROL) {
5448        let now = std::time::Instant::now();
5449        let armed = app
5450            .exit_pending
5451            .is_some_and(|t| now.duration_since(t) <= CTRL_C_EXIT_WINDOW);
5452        if armed {
5453            ctx.agent.cmd_tx.send(AgentCommand::Shutdown).ok();
5454        } else {
5455            // First Ctrl+C: deny the tool and arm the exit confirmation
5456            app.exit_pending = Some(now);
5457            renderer.pop_approval_prompt();
5458            ctx.agent.cmd_tx.send(AgentCommand::DenyTool).ok();
5459            app.state.on_approval_resolved();
5460            renderer.render(UiLine::CommandOutput(
5461                crate::i18n::t(crate::i18n::Msg::CtrlCAgainToExit).into_owned(),
5462            ));
5463            renderer.flush();
5464        }
5465        return Ok(());
5466    }
5467
5468    // Any other key resets the exit confirmation
5469    app.exit_pending = None;
5470
5471    let cmd = match code {
5472        KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => AgentCommand::ApproveTool,
5473        KeyCode::Char('a') | KeyCode::Char('A') => AgentCommand::ApproveToolAlways,
5474        KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => AgentCommand::DenyTool,
5475        _ => return Ok(()),
5476    };
5477    // Retract the "Waiting for approval" body row now that the user
5478    // responded — without this, the prompt stays in scrollback next to
5479    // the tool result, creating visual noise.
5480    renderer.pop_approval_prompt();
5481    ctx.agent.cmd_tx.send(cmd).ok();
5482    app.state.on_approval_resolved();
5483    Ok(())
5484}
5485
5486/// Render one streamed upgrade event. Mutates the percent tracker so
5487/// Downloading lines only redraw on whole-percent changes (see caller's
5488/// `upgrade_last_pct` reasoning). Sets `done = true` when the upgrade
5489/// succeeds, so the main loop can break after rendering the success
5490/// line — the user must restart to load the new binary.
5491/// Render the result of an async /plugin operation. Mirrors the messages
5492/// emitted by the previous synchronous path in `handle_plugin` so users see
5493/// the same wording — only the timing changes.
5494pub(super) fn handle_plugin_job_event(
5495    ev: atomcode_core::plugin::PluginJobEvent,
5496    ctx: &mut LoopCtx,
5497    state: &crate::state::UiState,
5498    renderer: &mut dyn Renderer,
5499) {
5500    use atomcode_core::plugin::PluginJobEvent;
5501    match ev {
5502        PluginJobEvent::MarketplaceAdded(info) => {
5503            // Marketplace add by itself doesn't load any skills (those come
5504            // from installed plugins) — show only the marketplace summary.
5505            let _ = reload_plugins(ctx);
5506            renderer.render(UiLine::CommandOutput(format!(
5507                "  marketplace `{}` added at {} ({} plugins)\n",
5508                info.name,
5509                &info.git_commit[..7.min(info.git_commit.len())],
5510                info.plugins.len()
5511            )));
5512        }
5513        PluginJobEvent::MarketplaceUpdated(info) => {
5514            let _ = reload_plugins(ctx);
5515            renderer.render(UiLine::CommandOutput(format!(
5516                "  marketplace `{}` updated to {}\n",
5517                info.name,
5518                &info.git_commit[..7.min(info.git_commit.len())]
5519            )));
5520        }
5521        PluginJobEvent::PluginInstalled(info) => {
5522            let (loaded, warnings) = reload_plugins(ctx);
5523            // Verbose mode (Ctrl+O) dumps the per-skill rejection reasons,
5524            // so users can debug a misnamed SKILL.md without restarting.
5525            // Default mode prints only the count summary — no cursor races.
5526            if state.show_tool_output {
5527                for w in &warnings {
5528                    renderer.render(UiLine::CommandOutput(format!("  {}\n", w)));
5529                }
5530            }
5531            let hint = if warnings.is_empty() || state.show_tool_output {
5532                String::new()
5533            } else {
5534                "  (Ctrl+O for details)".to_string()
5535            };
5536            renderer.render(UiLine::CommandOutput(format!(
5537                "  installed `{}@{}` — {} skills loaded, {} skipped{}\n",
5538                info.plugin,
5539                info.marketplace,
5540                loaded,
5541                warnings.len(),
5542                hint,
5543            )));
5544        }
5545        PluginJobEvent::Failed { op, msg } => {
5546            renderer.render(UiLine::Error(format!("{}: {}", op, msg)));
5547        }
5548    }
5549    renderer.flush();
5550}
5551
5552pub(super) fn handle_upgrade_event(
5553    ev: atomcode_core::self_update::UpgradeEvent,
5554    last_pct: &mut i32,
5555    done: &mut Option<std::path::PathBuf>,
5556    ctx: &mut LoopCtx,
5557    renderer: &mut dyn Renderer,
5558) {
5559    use atomcode_core::self_update::UpgradeEvent;
5560    match ev {
5561        UpgradeEvent::ManifestFetched { version } => {
5562            *last_pct = -1;
5563            renderer.render(UiLine::CommandOutput(
5564                crate::i18n::t(crate::i18n::Msg::UpgradeManifestFetched { version: &version }).into_owned(),
5565            ));
5566        }
5567        UpgradeEvent::Downloading { bytes, total } => {
5568            let pct = if total == 0 {
5569                0
5570            } else {
5571                ((bytes * 100) / total) as i32
5572            };
5573            if pct != *last_pct {
5574                *last_pct = pct;
5575                // Emit at 25/50/75/100 to keep output tidy. Finer-grained
5576                // progress would flood the append-only renderer with lines
5577                // since there's no in-place update here.
5578                if pct == 25 || pct == 50 || pct == 75 || pct == 100 {
5579                    renderer.render(UiLine::CommandOutput(
5580                        crate::i18n::t(crate::i18n::Msg::UpgradeDownloading { pct, bytes, total }).into_owned(),
5581                    ));
5582                }
5583            }
5584        }
5585        UpgradeEvent::Verifying => {
5586            renderer.render(UiLine::CommandOutput(
5587                crate::i18n::t(crate::i18n::Msg::UpgradeVerifying).into_owned(),
5588            ));
5589        }
5590        UpgradeEvent::Replacing => {
5591            renderer.render(UiLine::CommandOutput(
5592                crate::i18n::t(crate::i18n::Msg::UpgradeReplacing).into_owned(),
5593            ));
5594        }
5595        UpgradeEvent::Done { version, backup, exe } => {
5596            renderer.render(UiLine::CommandOutput(
5597                crate::i18n::t(crate::i18n::Msg::UpgradeDone {
5598                    version: &version,
5599                    backup: &backup.display().to_string(),
5600                }).into_owned(),
5601            ));
5602            // Push the hint in the status bar to match the new reality —
5603            // the little "↑ vX" arrow goes away for this session.
5604            if let Ok(mut g) = ctx.update_hint.lock() {
5605                *g = None;
5606            }
5607            // Store the *original* exe path so `re_exec_self` uses it
5608            // instead of `current_exe()` (which on Windows returns the
5609            // renamed `.atomcode.rolling` after `replace_binary`).
5610            *done = Some(exe);
5611            // Tell the agent to shut down so the loop exits cleanly.
5612            ctx.agent.cmd_tx.send(AgentCommand::Shutdown).ok();
5613        }
5614        UpgradeEvent::Failed(msg) => {
5615            if msg.contains(atomcode_core::self_update::ALREADY_LATEST) {
5616                // Friendly path — not an error, just "nothing to do".
5617                // self_update.rs's anyhow!() error is fixed-format
5618                // English: "already on {current} (latest is {latest}).
5619                // Pass --force to reinstall." Pull the two version
5620                // strings out so each locale formats the full sentence
5621                // itself instead of pasting English into a translated
5622                // wrapper.
5623                let friendly = msg.replace(
5624                    &format!("{}: ", atomcode_core::self_update::ALREADY_LATEST),
5625                    "",
5626                );
5627                let (current, latest) = parse_already_latest_versions(&friendly)
5628                    .unwrap_or(("?", "?"));
5629                renderer.render(UiLine::CommandOutput(
5630                    crate::i18n::t(crate::i18n::Msg::UpgradeAlreadyLatest { current, latest }).into_owned(),
5631                ));
5632            } else {
5633                renderer.render(UiLine::Error(
5634                    crate::i18n::t(crate::i18n::Msg::UpgradeFailed { error: &msg }).into_owned(),
5635                ));
5636            }
5637        }
5638        UpgradeEvent::RolledBack { exe, backup } => {
5639            renderer.render(UiLine::CommandOutput(
5640                crate::i18n::t(crate::i18n::Msg::UpgradeRolledBack {
5641                    exe: &exe.display().to_string(),
5642                    backup: &backup.display().to_string(),
5643                }).into_owned(),
5644            ));
5645            *done = Some(exe);
5646            ctx.agent.cmd_tx.send(AgentCommand::Shutdown).ok();
5647        }
5648    }
5649    renderer.flush();
5650}
5651
5652fn handle_agent_event(
5653    ev: AgentEvent,
5654    state: &mut UiState,
5655    think: &mut ThinkStripper,
5656    renderer: &mut dyn Renderer,
5657    pending_tools: &mut std::collections::HashMap<String, (String, String, bool)>,
5658    ctx: &mut LoopCtx,
5659    fixissue_pending: &mut Option<atomcode_core::atomgit::IssueRef>,
5660    fixissue_buffer: &mut String,
5661    reasoning_buffer: &mut String,
5662    buf: &Buffer,
5663) {
5664    match ev {
5665        AgentEvent::TextDelta(text) => {
5666            let visible = think.feed(&text);
5667            if !visible.is_empty() {
5668                if fixissue_pending.is_some() {
5669                    fixissue_buffer.push_str(&visible);
5670                }
5671                renderer.render(UiLine::AssistantText(visible));
5672                renderer.flush();
5673            }
5674        }
5675        AgentEvent::ReasoningDelta(text) => {
5676            // Display reasoning/thinking content in verbose mode (Ctrl+O)
5677            // Only show when the user has enabled it
5678            if state.show_reasoning {
5679                reasoning_buffer.push_str(&text);
5680                // Flush on newline or when buffer gets large
5681                if reasoning_buffer.contains('\n') || reasoning_buffer.len() > 80 {
5682                    let output = std::mem::take(reasoning_buffer);
5683                    // Render as gray/dimmed text with automatic line wrapping
5684                    renderer.render(UiLine::ReasoningText(output));
5685                    renderer.flush();
5686                }
5687            }
5688        }
5689        AgentEvent::ToolCallStreaming { name, .. } => {
5690            state.on_tool_call_streaming(&display_tool_name(&name));
5691        }
5692        AgentEvent::ToolCallStarted {
5693            id,
5694            name,
5695            arguments,
5696        } => {
5697            let detail = format_tool_detail(&name, &arguments);
5698            let display = display_tool_name(&name);
5699
5700            // If this call is part of an active batch, the
5701            // ToolBatchStarted handler already rendered the group header
5702            // + child rows — skip the standalone ▸ ToolCallInFlight
5703            // line. Still record into `pending_tools` so the matching
5704            // ToolCallResult knows the display name + detail and skips
5705            // its own ▸ render too.
5706            // Preserve any existing entry (from ToolBatchStarted) which
5707            // carries the disambiguated detail — don't overwrite with
5708            // the raw basename (issue #439).
5709            if state.call_id_to_batch.contains_key(&id) {
5710                pending_tools
5711                    .entry(id)
5712                    .or_insert((display.clone(), detail, true));
5713                state.on_tool_call_started(&display);
5714                return;
5715            }
5716
5717            // Emit the ▸ line immediately so users can see what command
5718            // is running, especially for long-running bash commands.
5719            renderer.render(UiLine::AssistantLineBreak);
5720            renderer.render(UiLine::ToolCallInFlight {
5721                id: id.clone(),
5722                name: display.clone(),
5723                detail: detail.clone(),
5724            });
5725            renderer.flush();
5726
5727            // Mark as rendered so ToolCallResult doesn't emit it again.
5728            pending_tools.insert(id, (display.clone(), detail, true));
5729            state.on_tool_call_started(&display);
5730        }
5731        AgentEvent::ToolOutputChunk { call_id: _, chunk } => {
5732            // Display real-time tool output (e.g., bash stdout/stderr)
5733            // Only show when the user has enabled it via Ctrl+O
5734            if state.show_tool_output {
5735                // Append to the scrollback as command output
5736                renderer.render(UiLine::CommandOutput(chunk));
5737                renderer.flush();
5738            }
5739        }
5740        AgentEvent::ToolCallResult {
5741            call_id,
5742            name,
5743            output,
5744            success,
5745            ..
5746        } => {
5747            // If this call belongs to an active batch, the group header
5748            // already accounts for it; emit a single-line `  ↳ ✓ / ✗`
5749            // child completion and skip the full ▸ + ⎿ body render.
5750            // The model still gets the full output via the ToolResult
5751            // message in the conversation. Task 1.3 will upgrade this
5752            // to in-place checkmarks on the existing child rows instead
5753            // of appending new lines.
5754            if let Some(batch_id) = state.call_id_to_batch.get(&call_id).cloned() {
5755                // CC-style result-data update: `⎿ Read(mod.rs) → 200 lines`.
5756                // The result snippet is generic line count of the
5757                // output (works across read/grep/glob/bash without
5758                // per-tool extraction). Failure shows `→ ✗` so the
5759                // user can spot the broken child without reading
5760                // bytes-of-output.
5761                //
5762                // Renderer's ToolGroupChildUpdate finds the row by
5763                // call_id and CUPs to its terminal position. Falls
5764                // back to no-op if the group has been frozen —
5765                // model still gets the full ToolResult through the
5766                // conversation.
5767                // └ (U+2514 Box Drawing), → (U+2192 Arrows), ✗ (U+2717
5768                // Dingbats), ● (U+25CF Geometric Shapes): all in WGL4 so
5769                // every Windows monospace font (Consolas, NSimSun,
5770                // Cascadia, Microsoft YaHei) ships them. Hardcoded —
5771                // no `unicode_symbols` ASCII fallback — matching
5772                // `alt_screen::push_tool_call`'s ● treatment for visual
5773                // parity between batched and single tool-call paths.
5774                let child_glyph = "\u{2514}";
5775                let arrow = "\u{2192}";
5776                let suffix = if success {
5777                    let n = output.lines().count().max(1);
5778                    let unit = if n == 1 { "line" } else { "lines" };
5779                    format!(" {} {} {}", arrow, n, unit)
5780                } else {
5781                    format!(" {} \u{2717}", arrow)
5782                };
5783                // Reuse the original Tool(arg) prefix the
5784                // ToolBatchStarted handler painted. pending_tools
5785                // holds (display, detail) — strip the previous "name
5786                // detail" join and rebuild as Short(detail) for
5787                // visual consistency with the initial child row.
5788                let prefix = pending_tools
5789                    .remove(&call_id)
5790                    .map(|(_, det, _)| format!(
5791                        "{}({})",
5792                        display_tool_name_short(&name),
5793                        det
5794                    ))
5795                    .unwrap_or_else(|| display_tool_name_short(&name));
5796                renderer.render(UiLine::ToolGroupChildUpdate {
5797                    batch_id,
5798                    call_id: call_id.clone(),
5799                    new_text: format!("  {} {}{}", child_glyph, prefix, suffix),
5800                });
5801                renderer.flush();
5802                return;
5803            }
5804
5805            // Close any in-flight assistant line before emitting the pair.
5806            renderer.render(UiLine::AssistantLineBreak);
5807            // Freeze the animated in-flight tool-call row to its final
5808            // static `▸` icon before the `⎿ result` body row lands beneath
5809            // it. Pass the call_id so we only freeze if the inflight_tool matches.
5810            // This prevents freezing a different tool's spinner when multiple
5811            // tools are in flight (e.g., WriteFile result arrives while Bash spinner is active).
5812            renderer.render(UiLine::ToolCallCommit {
5813                call_id: Some(call_id.clone()),
5814            });
5815
5816            // Prefer the display-name we stored at ToolCallStarted time;
5817            // fall back to converting the raw name if we missed the Start
5818            // (e.g. protocol surfaced a Result without a matching Start).
5819            let (display_name, detail, call_rendered) = pending_tools
5820                .remove(&call_id)
5821                .unwrap_or_else(|| (display_tool_name(&name), String::new(), false));
5822
5823            // Filter empty tool names (model occasionally emits malformed
5824            // tool calls with "" as the name; agent surfaces the error via
5825            // a ToolCallResult but there's no useful ▸ line to render).
5826            let safe_name = if display_name.is_empty() {
5827                "(invalid)".to_string()
5828            } else {
5829                display_name
5830            };
5831
5832            // ParallelEditFiles already streamed a per-task line tree
5833            // and an aggregate summary line via the SubAgentDispatch*
5834            // events — the ToolResult body would just repeat the same
5835            // info as a markdown table, doubling vertical space and
5836            // truncating mid-word at terminal boundaries. Skip both
5837            // the ▸ tool-call line and the ⎿ result line; the model
5838            // still receives full output via the ToolResult message
5839            // in the conversation.
5840            let suppress_body_echo = name == "parallel_edit_files";
5841
5842            // Only emit the tool-call line here if ApprovalNeeded didn't
5843            // already render it — otherwise we'd print it twice.
5844            if !call_rendered && !suppress_body_echo {
5845                renderer.render(UiLine::ToolCall {
5846                    name: safe_name.clone(),
5847                    detail: detail.clone(),
5848                });
5849            }
5850            if !suppress_body_echo {
5851                let summary = summarise(&output, success);
5852                renderer.render(UiLine::ToolResult { success, summary });
5853            }
5854            // Collect diff lines into a single batch — N individual
5855            // DiffLine renders each trigger a full footer redraw and
5856            // tens of KB of ANSI, which blocks the event loop long
5857            // enough to stall the spinner during edit tool results.
5858            //
5859            // Gated on edit-class tools because the `+ ` / `- ` line
5860            // detection is purely textual: markdown bullet lists
5861            // (`- item`) inside non-edit tool outputs trip the same
5862            // pattern. The deepseek-v4-flash screenshot symptom was a
5863            // 162-line `UseSkill(brainstorming)` template — every `- `
5864            // bullet got rendered as a removed-diff line and the whole
5865            // skill body leaked into the scrollback. Restricting to
5866            // tools that actually emit diff payloads (`edit_file`,
5867            // `write_file`, `create_file`, `search_replace`) closes
5868            // that without losing the diff render where it's wanted.
5869            let emits_diff = matches!(
5870                name.as_str(),
5871                "edit_file" | "write_file" | "create_file" | "search_replace"
5872            );
5873            if emits_diff {
5874                let diff_entries: Vec<crate::render::DiffEntry> = output
5875                    .lines()
5876                    .take(120)
5877                    .filter_map(|line| {
5878                        if let Some(rest) = line.strip_prefix("+ ") {
5879                            Some(crate::render::DiffEntry {
5880                                added: true,
5881                                text: rest.to_string(),
5882                            })
5883                        } else if let Some(rest) = line.strip_prefix("- ") {
5884                            Some(crate::render::DiffEntry {
5885                                added: false,
5886                                text: rest.to_string(),
5887                            })
5888                        } else {
5889                            None
5890                        }
5891                    })
5892                    .collect();
5893                if !diff_entries.is_empty() {
5894                    renderer.render(UiLine::DiffBlock(diff_entries));
5895                }
5896            }
5897            // Show hint for bash commands if real-time output is disabled.
5898            // Display AFTER the result so user sees the command first.
5899            // Trailing `\n` is intentional: `push_body_text` splits on
5900            // `\n` and the empty chunk after the `\n` pushes ONE blank
5901            // row, which becomes the breathing-room separator between
5902            // consecutive bash blocks. Without it adjacent bash results
5903            // visually run together (screenshot 47.png) and the eye
5904            // can't tell where one block ends and the next begins.
5905            // The previous over-correction (screenshot 44 → 47) showed
5906            // that "looks like 2 blank lines" is just font line-height
5907            // padding — the actual row count is 1, which is correct.
5908            if name == "bash" && !state.show_tool_output {
5909                renderer.render(UiLine::CommandOutput(
5910                    "  ○ Press Ctrl+O to show real-time output\n".to_string(),
5911                ));
5912            }
5913            renderer.flush();
5914            let _ = name;
5915        }
5916        AgentEvent::ApprovalNeeded {
5917            tool_name, call, messages, ..
5918        } => {
5919            // Persist mid-turn messages to session so /bg can recover
5920            // the conversation even when the turn hasn't finished yet.
5921            if !messages.is_empty() {
5922                apply_session_messages(&mut ctx.current_session, messages);
5923                ctx.bg_manager
5924                    .set_foreground_session(ctx.current_session.clone());
5925            }
5926
5927            // Emit the `▸ Tool(detail)` row BEFORE the approval prompt
5928            // so the user sees what they're approving.
5929            let display = display_tool_name(&tool_name);
5930            // Prefer the disambiguated detail from `pending_tools` (populated
5931            // by ToolBatchStarted for parallel batches) over the raw basename
5932            // from format_tool_detail. Without this, parallel batch approvals
5933            // show "ReadFile(SKILL.md)" for every call, making it impossible
5934            // to tell which file is being approved (issue #439).
5935            let detail = pending_tools
5936                .get(&call.id)
5937                .map(|(_, det, _)| det.clone())
5938                .unwrap_or_else(|| format_tool_detail(&tool_name, &call.arguments));
5939
5940            // Check if ToolCallStarted already rendered this tool call as a
5941            // dynamic ToolCallInFlight spinner. If so, we need to freeze it
5942            // to a static `▸` row before showing the approval prompt.
5943            if let Some(entry) = pending_tools.get_mut(&call.id) {
5944                let (disp, det, rendered) = entry;
5945                if *rendered {
5946                    // ToolCallInFlight is animating — commit it to a static row
5947                    // so the approval prompt appears below a frozen `▸ Bash(...)`.
5948                    // Pass the call_id to ensure we only freeze the matching tool.
5949                    renderer.render(UiLine::ToolCallCommit {
5950                        call_id: Some(call.id.clone()),
5951                    });
5952                } else {
5953                    // Not yet rendered, emit it now
5954                    renderer.render(UiLine::ToolCall {
5955                        name: disp.clone(),
5956                        detail: det.clone(),
5957                    });
5958                    *rendered = true;
5959                }
5960            } else {
5961                // No entry from ToolCallStarted, render and insert
5962                renderer.render(UiLine::ToolCall {
5963                    name: display.clone(),
5964                    detail: detail.clone(),
5965                });
5966                pending_tools.insert(call.id.clone(), (display.clone(), detail.clone(), true));
5967            }
5968
5969            renderer.render(UiLine::ApprovalPrompt {
5970                tool: display.clone(),
5971                detail: detail.clone(),
5972            });
5973            renderer.flush();
5974            atomcode_core::notify::notify(
5975                &ctx.config.notifications,
5976                atomcode_core::notify::NotificationEvent::ApprovalNeeded(
5977                    atomcode_core::notify::ApprovalNotification {
5978                        tool_name: &display_tool_name(&tool_name),
5979                        detail: Some(&format_tool_detail(&tool_name, &call.arguments)),
5980                        working_dir: Some(&ctx.working_dir),
5981                    },
5982                ),
5983            );
5984            // `display` is the already-PascalCased name (e.g. "Bash",
5985            // "ReadFile"); on_approval_needed stashes the current
5986            // "Running X" label so on_approval_resolved can restore it.
5987            state.on_approval_needed(&display);
5988            // Redraw the footer (input box) so the user can type
5989            // Y/A/N in response. Without this, a prior
5990            // on_approval_resolved() transition to Streaming may
5991            // have left the footer stale — especially when
5992            // TurnRunner dispatches the second approval before any
5993            // spinner tick fires (issue #455: "待审批输入 Y 后,
5994            // 输入框没了"). Use redraw_idle_plain instead of
5995            // draw_spinner_now because the spinner label may be
5996            // stale/misleading in the approval phase (mirrors the
5997            // /bg resume path).
5998            redraw_idle_plain(buf, state, ctx, renderer);
5999        }
6000        AgentEvent::PhaseChange(AgentPhase::Thinking) => state.on_thinking(),
6001        AgentEvent::PhaseChange(AgentPhase::CallingTool(name)) => {
6002            state.on_tool_call_streaming(&display_tool_name(&name));
6003        }
6004        AgentEvent::PhaseChange(_) => {}
6005        AgentEvent::TurnComplete {
6006            duration,
6007            total_tokens,
6008            turn_count,
6009            tool_call_count,
6010            stop_reason,
6011            messages,
6012        } => {
6013            atomcode_core::notify::notify(
6014                &ctx.config.notifications,
6015                atomcode_core::notify::NotificationEvent::TurnFinished(
6016                    atomcode_core::notify::TurnNotification {
6017                        duration,
6018                        turn_count,
6019                        tool_call_count,
6020                        total_tokens: Some(total_tokens),
6021                        stop_reason,
6022                        working_dir: Some(&ctx.working_dir),
6023                    },
6024                ),
6025            );
6026            renderer.render(UiLine::AssistantLineBreak);
6027            pending_tools.clear();
6028            let done = state.next_done_label();
6029            let dur = crate::render::fmt_dur(duration);
6030            let label = crate::i18n::t(crate::i18n::Msg::TurnSummary {
6031                done,
6032                turn_count,
6033                tool_call_count,
6034                duration: &dur,
6035                total_tokens,
6036            })
6037            .into_owned();
6038            renderer.render(UiLine::TurnSeparator { label });
6039            renderer.flush();
6040            state.on_turn_complete();
6041
6042            // Reset the think stripper between turns. If the previous turn
6043            // left an unclosed `<think>` in flight (cancelled mid-stream,
6044            // model never emitted `</think>`, provider switch that doesn't
6045            // use `<think>` tags like Kimi thinking-mode via reasoning_content),
6046            // the stripper stays `inside=true` and silently swallows every
6047            // TextDelta of the NEXT turn — user sees blank assistant bubbles
6048            // while datalog proves the model did return text.
6049            think.reset();
6050
6051            // Clear reasoning buffer between turns
6052            reasoning_buffer.clear();
6053
6054            // Persist session after every completed turn so /resume can
6055            // find it after a clean exit — the whole point of sessions.
6056            persist_current_session(ctx, messages, renderer);
6057
6058            // CodingPlan usage refresh — fire after each completed turn
6059            // (with cooldown) so the right-aligned hint reflects the
6060            // tokens the turn just consumed. Gated to CodingPlan users
6061            // only; non-CodingPlan paths skip all network activity.
6062            if monitor::is_codingplan_provider(&ctx.config.default_provider) {
6063                let cooled = ctx
6064                    .usage_last_check_at
6065                    .map(|t| t.elapsed() >= usage_monitor::USAGE_COOLDOWN)
6066                    .unwrap_or(true);
6067                if cooled {
6068                    ctx.usage_last_check_at = Some(std::time::Instant::now());
6069                    usage_monitor::spawn_check(
6070                        ctx.usage_slot.clone(),
6071                        ctx.wake_tx.clone(),
6072                    );
6073                }
6074            }
6075
6076            // fixissue post-run side effects — only on successful TurnComplete
6077            // (TurnCancelled / Error arms below clear `fixissue_pending`
6078            // without posting). Takes the IssueRef out so only this turn's
6079            // completion triggers the post-back.
6080            if let Some(issue_ref) = fixissue_pending.take() {
6081                let body = std::mem::take(fixissue_buffer);
6082                if body.trim().is_empty() {
6083                    renderer.render(UiLine::CommandOutput(format!(
6084                        "  [fixissue] agent produced no text; skipping comment + label on issue #{}\n",
6085                        issue_ref.number
6086                    )));
6087                } else {
6088                    match atomcode_core::atomgit::fixissue::post_completion(&issue_ref, &body) {
6089                        Ok(()) => renderer.render(UiLine::CommandOutput(format!(
6090                            "  [fixissue] ✓ posted summary + applied 'fixed' label to issue #{}\n",
6091                            issue_ref.number
6092                        ))),
6093                        Err(e) => renderer.render(UiLine::CommandOutput(format!(
6094                            "  [fixissue] ✗ post-back failed (local fix still saved): {:#}\n",
6095                            e
6096                        ))),
6097                    }
6098                }
6099                renderer.flush();
6100            }
6101        }
6102        AgentEvent::TurnCancelled { messages } => {
6103            atomcode_core::notify::notify(
6104                &ctx.config.notifications,
6105                atomcode_core::notify::NotificationEvent::TurnFinished(
6106                    atomcode_core::notify::TurnNotification {
6107                        duration: state.turn_elapsed().unwrap_or_default(),
6108                        turn_count: 0,
6109                        tool_call_count: pending_tools.len(),
6110                        total_tokens: None,
6111                        stop_reason: atomcode_core::agent::TurnStopReason::Cancelled,
6112                        working_dir: Some(&ctx.working_dir),
6113                    },
6114                ),
6115            );
6116            // Render any in-flight tool calls that never got a result
6117            // as "(cancelled)" so the user sees what was mid-flight.
6118            for (_id, (name, detail, call_rendered)) in pending_tools.drain() {
6119                let safe_name = if name.is_empty() {
6120                    "(invalid)".into()
6121                } else {
6122                    name
6123                };
6124                if !call_rendered {
6125                    renderer.render(UiLine::ToolCall {
6126                        name: safe_name,
6127                        detail,
6128                    });
6129                }
6130                renderer.render(UiLine::ToolResult {
6131                    success: false,
6132                    summary: "(cancelled)".into(),
6133                });
6134            }
6135            renderer.render(UiLine::TurnCancelled);
6136            renderer.flush();
6137            state.on_turn_cancelled();
6138            // Cancellation = agent didn't finish; don't post a comment
6139            // against an incomplete "fix".
6140            fixissue_pending.take();
6141            fixissue_buffer.clear();
6142            // Same reset rationale as TurnComplete: a cancelled turn is the
6143            // single most common way for `<think>` to go unclosed, so this
6144            // branch is even more important for the stripper's hygiene.
6145            think.reset();
6146            // Save what we did have — a user who Ctrl+C'd mid-stream
6147            // should still be able to /resume the cleaned conversation.
6148            persist_current_session(ctx, messages, renderer);
6149        }
6150        AgentEvent::Error { error, messages } => {
6151            renderer.render(UiLine::Error(error));
6152            renderer.flush();
6153            fixissue_pending.take();
6154            fixissue_buffer.clear();
6155            state.on_error();
6156            // Same reset rationale as TurnComplete / TurnCancelled — an
6157            // aborted turn is another way to leave `<think>` half-open.
6158            think.reset();
6159            // Persist on Error too — without this, a first-turn LLM
6160            // failure (auth, rate limit, gateway 5xx, our own 5-min
6161            // total-request timeout, etc.) silently drops the user's
6162            // typed message from disk so the next `/resume` shows
6163            // nothing for that conversation. Empty `messages` from
6164            // the streaming-error forwarder is treated as a no-op
6165            // by persist_current_session.
6166            persist_current_session(ctx, messages, renderer);
6167        }
6168        AgentEvent::Warning(w) => {
6169            // Non-fatal — flush a yellow advisory line and let the turn
6170            // continue. Don't touch state/think/buffers; the warning is
6171            // purely informational. Used today for the OpenAI provider's
6172            // truncation detector (`prompt_tokens` reported by the proxy
6173            // is implausibly low for the body we sent).
6174            renderer.render(UiLine::Warning(w));
6175            renderer.flush();
6176        }
6177        AgentEvent::VisionPreprocessSuccess { vl_key, char_count } => {
6178            // Format here (not in agent) so we can localize / restyle
6179            // without bumping the AgentEvent contract. Char count helps
6180            // users notice degenerate near-zero VL outputs that would
6181            // mislead the main model into "image failed" responses.
6182            let msg = crate::i18n::t(crate::i18n::Msg::VisionPreprocessSuccess { char_count })
6183                .into_owned();
6184            renderer.render(UiLine::VisionPreprocessSuccess {
6185                msg,
6186                model: vl_key,
6187            });
6188            renderer.flush();
6189        }
6190        AgentEvent::RestorePendingImages { images, markers } => {
6191            // VL preprocessing failed — re-attach the user's images to
6192            // the input state so they can retry without re-pasting from
6193            // clipboard. The `[Image #N]` text marker is gone (lives in
6194            // the conversation history echo at this point), so the user
6195            // typically UP-recalls or retypes the caption + Enter to
6196            // resubmit. The attached image bytes ride along automatically
6197            // with the next submit.
6198            //
6199            // Hash table is rebuilt as best-effort: we hash the base64
6200            // payload (not raw RGBA), which means a fresh clipboard copy
6201            // of the same image won't dedupe against this restored entry.
6202            // Minor UX nit, not a correctness issue.
6203            use std::collections::hash_map::DefaultHasher;
6204            use std::hash::{Hash, Hasher};
6205            // markers length should match images length (agent passed them
6206            // back); zip is best-effort if it doesn't (truncates).
6207            for (img, marker) in images.into_iter().zip(markers.into_iter()) {
6208                let mut hasher = DefaultHasher::new();
6209                img.data.hash(&mut hasher);
6210                let h = hasher.finish();
6211                cache_write_image(&crate::platform::image_cache_dir(), &img, h);
6212                state.pending_image_hashes.push(h);
6213                state.pending_images.push(img);
6214                state.pending_image_markers.push(marker);
6215            }
6216            // Don't redraw — TUI is in Streaming phase here (turn isn't
6217            // over yet); the next idle/streaming redraw picks up the new
6218            // pending state on its own.
6219        }
6220        AgentEvent::TokenUsage(u) => {
6221            state.prompt_tokens += u.prompt_tokens;
6222            state.completion_tokens += u.completion_tokens;
6223            state.cached_tokens += u.cached_tokens;
6224            state.total_tokens += u.completion_tokens;
6225        }
6226        AgentEvent::WorkingDirChanged(new_dir) => {
6227            // Fires when a tool (change_dir / bash cd) or an AgentCommand::ChangeDir
6228            // mutated the shared cwd. Sync the footer's view so the status row
6229            // reflects the new directory on the next redraw (spinner tick if
6230            // streaming, idle redraw after turn complete). Without this the
6231            // footer is stuck on the old path until the user types `/cd` or
6232            // restarts the session.
6233            if ctx.working_dir != new_dir {
6234                ctx.previous_dir = Some(std::mem::replace(&mut ctx.working_dir, new_dir.clone()));
6235                ctx.runtime_factory.set_working_dir(new_dir.clone());
6236                commands::push_recent_dir(&mut ctx.recent_dirs, new_dir);
6237            }
6238        }
6239        AgentEvent::ContextStats {
6240            system_tokens,
6241            sent_tokens,
6242            dropped_tokens: _,
6243            working_set_tokens: _,
6244            total_messages,
6245            tool_defs_tokens,
6246            cold_zone_tokens,
6247            ctx_window,
6248            ctx_name,
6249            system_prompt,
6250        } => {
6251            state.on_context_stats(
6252                system_tokens,
6253                sent_tokens,
6254                tool_defs_tokens,
6255                cold_zone_tokens,
6256                total_messages,
6257                ctx_window,
6258                &ctx_name,
6259                &system_prompt,
6260            );
6261            // If `/context` is waiting for fresh stats, the rich emission
6262            // (ctx_window > 0) is the signal to render. Narrow emissions
6263            // from TurnRunner leave ctx_window at 0 and must not trigger
6264            // a report render (they'd race ahead of the pending refresh
6265            // and print partial data). Clears the flag on fire so a
6266            // single dispatch yields a single render even when multiple
6267            // rich emissions follow (e.g. inside a long multi-round turn).
6268            if ctx_window > 0 {
6269                if let Some(show_prompt) = state.pending_context_render.take() {
6270                    renderer.render(UiLine::CommandOutput(commands::render_context_report(
6271                        state,
6272                        ctx,
6273                        show_prompt,
6274                    )));
6275                    renderer.flush();
6276                }
6277            }
6278        }
6279        AgentEvent::ToolBatchStarted { batch_id, calls } => {
6280            // Header label: "Reading 4 files in parallel" when all calls
6281            // share a tool name (common case for batched read_file /
6282            // grep / glob); otherwise generic "Running 4 tools in
6283            // parallel". No tech-stack hardcoding — tool names come from
6284            // the model's own tool_calls.name.
6285            let count = calls.len();
6286            let unique_names: std::collections::HashSet<&str> =
6287                calls.iter().map(|c| c.name.as_str()).collect();
6288            // Generic header — no per-tool verb table inside the
6289            // framework. Same-name batches surface the model's own
6290            // tool name; mixed batches use "tools". This avoids
6291            // a `match tool_name { "bash" => "Running" ... }` table
6292            // that drifts whenever new tools land or models invent
6293            // names (mcp.foo, custom plugins).
6294            let label = if unique_names.len() == 1 {
6295                let single = unique_names.iter().next().copied().unwrap_or("tool");
6296                format!("Running {} {} calls in parallel", count, single)
6297            } else {
6298                format!("Running {} tools in parallel", count)
6299            };
6300            // Header alone — child rows are NOT pre-rendered. Each
6301            // child surfaces as a `  ↳ ✓ name` line when its
6302            // ToolCallResult arrives. Trade-off:
6303            // - PRO: zero duplication; children "trickle in" as they
6304            //   complete, so user sees real progress on slow batches
6305            //   (4 reads finishing within 1s look near-atomic; a 4-call
6306            //   batch where 3 are fast + 1 is `cargo check` shows the
6307            //   slow tail clearly).
6308            // - PRO: avoids the retained-renderer's "in-place mutation
6309            //   of older body rows" problem (rows already scrolled into
6310            //   native terminal scrollback can't be modified).
6311            // - CON: user doesn't see batch contents until first child
6312            //   completes. Acceptable: footer spinner conveys "working",
6313            //   contents become visible immediately on first result.
6314            // Glyphs: ● (BLACK CIRCLE U+25CF) for batch header,
6315            // └ (BOX DRAWINGS LIGHT UP AND RIGHT U+2514) for each
6316            // child row. Picked over ⏺/⎿ because Cascadia Code
6317            // (Windows VSCode default) renders ⏺ as a flat oval and
6318            // ⎿ as a backslash-shaped fallback -- both are widely
6319            // supported monospace glyphs that survive the same fonts
6320            // where the dental-symbols block tofu's. Aligns with the
6321            // single-tool-call ● glyph (retained::ToolCall arm) so
6322            // batched and single calls share one visual anchor, and
6323            // with `└` for tool-result rows below the call. Both
6324            // glyphs are in WGL4 (Consolas, NSimSun, Cascadia, Microsoft
6325            // YaHei all ship them), so no `unicode_symbols` ASCII
6326            // fallback — matches `alt_screen::push_tool_call`'s
6327            // hardcoded ● for visual parity between batched and single
6328            // tool-call paths.
6329            let head_glyph = "\u{25cf}";
6330            let child_glyph = "\u{2514}";
6331            // Build header + child rows; renderer keeps the group
6332            // "live" while it's the bottom of body_lines, so each
6333            // ToolCallResult below can update the matching child row
6334            // in place (CC-style result data light-up).
6335            //
6336            // Child format: `⎿ Read(mod.rs)`. Tool name is the short
6337            // form (Read not ReadFile); detail is wrapped in parens
6338            // (Tool(arg) reads as a function call, mirroring CC).
6339            let header_text = format!("{} {}", head_glyph, label);
6340            // Build child rows with disambiguation: when multiple calls
6341            // produce the same detail (e.g. 3 × Read(SKILL.md) from
6342            // different directories), show enough parent path to tell
6343            // them apart (issue #437).
6344            let raw_details: Vec<String> = calls
6345                .iter()
6346                .map(|c| format_tool_detail(&c.name, &c.arguments))
6347                .collect();
6348            let disambiguated = disambiguate_batch_details(
6349                &calls.iter().map(|c| c.name.as_str()).collect::<Vec<_>>(),
6350                &calls.iter().map(|c| c.arguments.as_str()).collect::<Vec<_>>(),
6351                &raw_details,
6352            );
6353            let children: Vec<crate::render::ToolGroupChild> = calls
6354                .iter()
6355                .zip(disambiguated.iter())
6356                .map(|(c, detail)| crate::render::ToolGroupChild {
6357                    call_id: c.id.clone(),
6358                    text: format!(
6359                        "  {} {}({})",
6360                        child_glyph,
6361                        display_tool_name_short(&c.name),
6362                        detail
6363                    ),
6364                })
6365                .collect();
6366            renderer.render(UiLine::AssistantLineBreak);
6367            renderer.render(UiLine::ToolGroupRender {
6368                batch_id: batch_id.clone(),
6369                header: header_text,
6370                children,
6371            });
6372            renderer.flush();
6373
6374            let call_ids: Vec<String> = calls.iter().map(|c| c.id.clone()).collect();
6375            for cid in &call_ids {
6376                state
6377                    .call_id_to_batch
6378                    .insert(cid.clone(), batch_id.clone());
6379            }
6380            // Pre-populate `pending_tools` with the disambiguated detail
6381            // so that subsequent ToolCallStarted / ApprovalNeeded events
6382            // use the disambiguated path (e.g. "a/SKILL.md") instead of
6383            // the raw basename ("SKILL.md"). Without this, parallel batch
6384            // approvals show identical "ReadFile(SKILL.md)" prompts and
6385            // the user can't tell which file they're approving (issue #439).
6386            for (c, detail) in calls.iter().zip(disambiguated.iter()) {
6387                pending_tools.insert(
6388                    c.id.clone(),
6389                    (display_tool_name_short(&c.name), detail.clone(), true),
6390                );
6391            }
6392            state.active_tool_batches.insert(
6393                batch_id.clone(),
6394                crate::state::ActiveToolBatch { call_ids },
6395            );
6396        }
6397        AgentEvent::ToolBatchCompleted {
6398            batch_id,
6399            ok: _,
6400            total: _,
6401            elapsed_ms: _,
6402        } => {
6403            // CC-style: NO standalone batch-summary row. Each child
6404            // row already shows its own `→ N lines` / `→ ✗`, so an
6405            // aggregate `batch 4/4 ok · Xs wall` line would just be
6406            // visual noise repeating what's already visible above.
6407            //
6408            // SubAgentDispatchEnd (different code path) STILL emits
6409            // its `▸ ParallelEditFiles · ...` summary because sub-agent
6410            // turns/elapsed per-task is hidden by Task 3's collapse —
6411            // that summary is the only place the user can see how
6412            // long it took.
6413            //
6414            // Just clear batch state so subsequent per-call events
6415            // fall back to the standard single-tool render path.
6416            if let Some(b) = state.active_tool_batches.remove(&batch_id) {
6417                for cid in b.call_ids {
6418                    state.call_id_to_batch.remove(&cid);
6419                }
6420            }
6421        }
6422        AgentEvent::SubAgentDispatchStart { tasks } => {
6423            // Header line: announce the dispatch. The model gets this
6424            // same fact in the ToolResult; the UI line tells the user
6425            // "the wait is intentional, not a hang". Per-task running/
6426            // done lines are suppressed (Task 3 — CC alignment); the
6427            // footer spinner conveys mid-flight progress, the
6428            // DispatchEnd summary lands the final count.
6429            renderer.render(UiLine::CommandOutput(format!(
6430                "Dispatching {} sub-agents in parallel...",
6431                tasks.len()
6432            )));
6433            renderer.flush();
6434            state.on_sub_agent_dispatch_start(tasks);
6435        }
6436        AgentEvent::SubAgentTaskStarted { index: _ } => {
6437            // Per-task running lines suppressed for CC-style collapsed
6438            // view. State tracking still happens via DispatchStart's
6439            // task list. Nothing to render here.
6440        }
6441        AgentEvent::SubAgentTaskDone { index: _, elapsed_ms: _, turns: _, summary: _ } => {
6442            // Per-task done lines suppressed — final count shows in
6443            // DispatchEnd summary. Still tick the counter so the
6444            // aggregate `N/M ok` reflects this completion.
6445            state.on_sub_agent_task_done();
6446        }
6447        AgentEvent::SubAgentTaskFailed { index, elapsed_ms, turns: _, reason } => {
6448            // Failures KEEP their per-task line. Rationale: the user
6449            // needs to know which sub-agent failed for diagnosis;
6450            // collapsing into "1 fail" leaves them blind. Successes
6451            // collapse silently (no actionable info per success).
6452            state.on_sub_agent_task_failed();
6453            if let Some(info) = state.sub_agent_tasks.get(index) {
6454                let cross = "\u{2717}";
6455                let short_reason = reason.lines().next().unwrap_or("").trim();
6456                renderer.render(UiLine::CommandOutput(format!(
6457                    "  {} {}{} — {} · {}",
6458                    cross,
6459                    info.path,
6460                    info.dedup_suffix,
6461                    fmt_elapsed(elapsed_ms),
6462                    if short_reason.is_empty() { "failed" } else { short_reason }
6463                )));
6464                renderer.flush();
6465            }
6466        }
6467        AgentEvent::SubAgentDispatchEnd => {
6468            // Compute the aggregate before clearing state. This is the
6469            // single line that replaces the old multi-row pipe-table
6470            // result block — the model still sees the full breakdown
6471            // in the ToolResult content, but the UI only needs the
6472            // bottom line.
6473            let total = state.sub_agent_total;
6474            let failed = state.sub_agent_failed;
6475            let ok = total.saturating_sub(failed);
6476            let elapsed = state
6477                .sub_agent_started_at
6478                .map(|t| t.elapsed().as_millis() as u64)
6479                .unwrap_or(0);
6480            if total > 0 {
6481                let arrow = "\u{25cf}";
6482                let summary = if failed == 0 {
6483                    format!(
6484                        "{} ParallelEditFiles · {}/{} ok · {} wall",
6485                        arrow,
6486                        ok,
6487                        total,
6488                        fmt_elapsed(elapsed)
6489                    )
6490                } else {
6491                    format!(
6492                        "{} ParallelEditFiles · {} ok · {} fail · {} wall",
6493                        arrow,
6494                        ok,
6495                        failed,
6496                        fmt_elapsed(elapsed)
6497                    )
6498                };
6499                renderer.render(UiLine::ToolGroupSummary { text: summary });
6500                renderer.flush();
6501            }
6502            state.on_sub_agent_dispatch_end();
6503        }
6504        AgentEvent::BackgroundComplete { summary, files_edited, turns, success } => {
6505            let header = if success {
6506                crate::i18n::t(crate::i18n::Msg::BackgroundComplete { turns }).into_owned()
6507            } else {
6508                crate::i18n::t(crate::i18n::Msg::BackgroundFailed { turns }).into_owned()
6509            };
6510            let mut body = String::from(&header);
6511            body.push_str("  ");
6512            body.push_str(&summary);
6513            if !body.ends_with('\n') {
6514                body.push('\n');
6515            }
6516            if !files_edited.is_empty() {
6517                body.push_str(&crate::i18n::t(crate::i18n::Msg::BackgroundFilesEdited));
6518                for f in &files_edited {
6519                    body.push_str(&format!("    - {}\n", f));
6520                }
6521            }
6522            if success {
6523                renderer.render(UiLine::CommandOutput(body));
6524            } else {
6525                renderer.render(UiLine::Error(body));
6526            }
6527            renderer.flush();
6528        }
6529        AgentEvent::MessagesSync { messages } => {
6530            // Response to AgentCommand::SyncMessages. Persist the
6531            // snapshot to the current session so /bg can recover
6532            // the conversation state.
6533            if !messages.is_empty() {
6534                apply_session_messages(&mut ctx.current_session, messages);
6535                ctx.bg_manager
6536                    .set_foreground_session(ctx.current_session.clone());
6537            }
6538        }
6539    }
6540}
6541
6542/// Copy the latest conversation into `ctx.current_session`, auto-name
6543/// the session from the first real user message, and write the session
6544/// file to disk. Called on every TurnComplete and TurnCancelled so
6545/// `/resume` can find the conversation after a quit. No-op when the
6546/// conversation is empty (don't save a blank session).
6547fn persist_current_session(
6548    ctx: &mut LoopCtx,
6549    messages: Vec<atomcode_core::conversation::message::Message>,
6550    renderer: &mut dyn Renderer,
6551) {
6552    if messages.is_empty() {
6553        return;
6554    }
6555    apply_session_messages(&mut ctx.current_session, messages);
6556    ctx.bg_manager
6557        .set_foreground_session(ctx.current_session.clone());
6558    // Surface save failures instead of silently swallowing them.
6559    // Previously this was `let _ = session_manager.save(...)`, which
6560    // hid disk-full / permission / read-only / invalid-path errors —
6561    // users would `/resume` on the next launch, see nothing, and
6562    // assume "the session was lost" with no idea anything went wrong.
6563    if let Err(e) = ctx.session_manager.save(&ctx.current_session) {
6564        renderer.render(UiLine::Error(
6565            crate::i18n::t(crate::i18n::Msg::SessionSaveFailed { error: &e.to_string() })
6566                .into_owned(),
6567        ));
6568        renderer.flush();
6569    }
6570}
6571
6572pub(crate) fn apply_session_messages(
6573    session: &mut atomcode_core::session::Session,
6574    messages: Vec<atomcode_core::conversation::message::Message>,
6575) {
6576    if messages.is_empty() {
6577        return;
6578    }
6579    session.messages = messages;
6580    session.touch();
6581    // Triggers for renaming:
6582    //   * `default` / `session-<ts>` — never renamed yet
6583    //   * leading `[` — previous rename grabbed a synthetic system-meta
6584    //     marker (`[System meta · not a user message]`,
6585    //     `[You are stuck — ...]`, etc.) that the agent injects as a
6586    //     Role::User message for plumbing reasons. Re-derive from the
6587    //     next non-synthetic user turn so the /resume picker stops
6588    //     showing those as session titles.
6589    let should_rename = session.name == "default"
6590        || session.name.starts_with("session-")
6591        || session.name.trim_start().starts_with('[');
6592    if should_rename {
6593        use atomcode_core::conversation::message::Role;
6594        let first_real_user = session
6595            .messages
6596            .iter()
6597            .filter(|m| matches!(m.role, Role::User))
6598            .find_map(|m| m.text().filter(|t| !is_synthetic_user_text(t)));
6599        if let Some(text) = first_real_user {
6600            let name: String = text.lines().next().unwrap_or("").chars().take(40).collect();
6601            if !name.is_empty() {
6602                session.name = name;
6603            }
6604        }
6605        // Else: leave the existing default/session-<ts>/[...]-marker
6606        // name. Better to keep a generic placeholder than to commit to
6607        // a synthetic injection as the title.
6608    }
6609}
6610
6611/// True when `text` looks like a synthetic user-channel injection
6612/// (atomcode plumbs system-meta control signals through `add_user_message`
6613/// and tags them with a leading `[...]` bracket marker on the first line:
6614/// `[System meta · not a user message]`, `[You are stuck — ...]`, etc.).
6615/// Used by session naming to skip these so `/resume` titles stay
6616/// human-meaningful.
6617fn is_synthetic_user_text(text: &str) -> bool {
6618    text.trim_start().starts_with('[')
6619}
6620
6621#[cfg(test)]
6622mod session_naming_tests {
6623    use super::{apply_session_messages, is_synthetic_user_text};
6624
6625    #[test]
6626    fn apply_session_messages_renames_from_first_real_user() {
6627        use atomcode_core::conversation::message::{Message, Role};
6628        let mut session = atomcode_core::session::Session::default_session(
6629            std::path::PathBuf::from("/tmp/project"),
6630        );
6631        let messages = vec![
6632            Message::new(Role::User, "[System meta · not a user message]\nread this"),
6633            Message::new(Role::User, "implement background sessions\nwith tests"),
6634        ];
6635
6636        apply_session_messages(&mut session, messages);
6637
6638        assert_eq!(session.name, "implement background sessions");
6639        assert_eq!(session.messages.len(), 2);
6640    }
6641
6642    #[test]
6643    fn apply_session_messages_preserves_custom_name() {
6644        use atomcode_core::conversation::message::{Message, Role};
6645        let mut session = atomcode_core::session::Session::default_session(
6646            std::path::PathBuf::from("/tmp/project"),
6647        );
6648        session.name = "manual name".to_string();
6649
6650        apply_session_messages(&mut session, vec![Message::new(Role::User, "new task")]);
6651
6652        assert_eq!(session.name, "manual name");
6653    }
6654
6655    #[test]
6656    fn synthetic_system_meta_is_detected() {
6657        assert!(is_synthetic_user_text(
6658            "[System meta · not a user message]\n12 calls..."
6659        ));
6660    }
6661
6662    #[test]
6663    fn synthetic_stuck_warning_is_detected() {
6664        assert!(is_synthetic_user_text(
6665            "[You are stuck — read foo.rs repeatedly without making progress.]"
6666        ));
6667    }
6668
6669    #[test]
6670    fn leading_whitespace_does_not_hide_synthetic_marker() {
6671        assert!(is_synthetic_user_text("   [System meta] body"));
6672    }
6673
6674    #[test]
6675    fn real_user_message_is_not_synthetic() {
6676        assert!(!is_synthetic_user_text("Fix the auth bug in login.rs"));
6677        assert!(!is_synthetic_user_text("Continue."));
6678        assert!(!is_synthetic_user_text("(why does this break?)"));
6679    }
6680}
6681
6682/// Build the persistent status line shown directly below the input box.
6683/// Pulls model name from ctx, cwd from ctx.working_dir (with $HOME
6684/// collapsed to `~`), and running token count from state.
6685/// Probe the system clipboard for an image, memoising the result inside
6686/// the supplied cache for `CLIPBOARD_HINT_TTL_MS`. `build_status` calls
6687/// this on every redraw, so without caching every spinner tick (~12/s
6688/// during streaming) would round-trip to the platform clipboard API.
6689const CLIPBOARD_HINT_TTL_MS: u64 = 1500;
6690
6691fn clipboard_image_hash(cache: &std::sync::Mutex<ClipboardCheckState>) -> Option<u64> {
6692    let mut state = cache.lock().unwrap_or_else(|e| e.into_inner());
6693    let stale = state
6694        .last_checked
6695        .map(|t| t.elapsed() >= std::time::Duration::from_millis(CLIPBOARD_HINT_TTL_MS))
6696        .unwrap_or(true);
6697    if stale {
6698        state.image_hash = arboard::Clipboard::new()
6699            .and_then(|mut c| c.get_image())
6700            .ok()
6701            .map(|img| rgba_fingerprint(img.width, img.height, img.bytes.as_ref()));
6702        state.last_checked = Some(std::time::Instant::now());
6703    }
6704    state.image_hash
6705}
6706
6707pub(crate) fn build_status(state: &UiState, ctx: &LoopCtx) -> crate::render::StatusLine {
6708    let cwd = crate::platform::collapse_home(&ctx.working_dir.to_string_lossy());
6709    // Priority:
6710    //   1. No provider configured + not logged in — show "configure" nudge.
6711    //      This wins over the upgrade hint because without a provider the
6712    //      app literally cannot answer any message; the user needs to know
6713    //      why before they're told to upgrade.
6714    //   2. Upgrade-available hint (existing behavior).
6715    //   3. None.
6716    let no_provider =
6717        ctx.config.providers.is_empty() && atomcode_core::auth::get_stored_auth().is_none();
6718    // Priority: no-provider (Warning red) > CodingPlan drift monitor
6719    // (Warning red) > CodingPlan token-usage hint (Info ≥80%, Warning
6720    // ≥95%) > upgrade banner (Info dim). Usage outranks upgrade because
6721    // ">80% in this rolling window" is more actionable than "new
6722    // version available". Only one hint renders at a time (right-aligned
6723    // on the status row).
6724    let hint: Option<(String, crate::render::HintSeverity)> = if no_provider {
6725        Some((
6726            crate::i18n::t(crate::i18n::Msg::StatusNoProvider).into_owned(),
6727            crate::render::HintSeverity::Warning,
6728        ))
6729    } else if let Some(warning) = ctx.monitor_warning.lock().ok().and_then(|g| g.clone()) {
6730        Some((warning.display_text(), crate::render::HintSeverity::Warning))
6731    } else if let Some(usage) =
6732        usage_monitor::build_usage_hint(&ctx.usage_slot, &ctx.config.default_provider)
6733    {
6734        Some(usage)
6735    } else if let Some(h) = clipboard_image_hash(&ctx.clipboard_check)
6736        .filter(|h| !state.pending_image_hashes.contains(h))
6737    {
6738        // Transient cue — beats the upgrade banner because the action
6739        // window is "now" (the image is in the clipboard right now).
6740        // Suppressed when the clipboard's image fingerprint matches one
6741        // already in `pending_images`: the input box already shows
6742        // `[Image #N]`, prompting another paste of the same image would
6743        // just attach a dup. A NEW image (different fingerprint) appears
6744        // here as a fresh hint so the user can attach it too.
6745        let _ = h;
6746        // Windows Terminal / conhost swallow Ctrl+V (they bind it to
6747        // their own `paste` action that only forwards CF_UNICODETEXT,
6748        // so an image-only clipboard never reaches the in-app
6749        // handler). Surface the `/paste` fallback on Windows; macOS /
6750        // Linux terminals pass Ctrl+V through cleanly, so they keep
6751        // the snappier keybind hint.
6752        let hint_msg = if cfg!(target_os = "windows") {
6753            crate::i18n::Msg::StatusClipboardImageHintSlash
6754        } else {
6755            crate::i18n::Msg::StatusClipboardImageHint
6756        };
6757        Some((
6758            crate::i18n::t(hint_msg).into_owned(),
6759            crate::render::HintSeverity::Info,
6760        ))
6761    } else {
6762        ctx.update_hint
6763            .lock()
6764            .ok()
6765            .and_then(|g| g.clone())
6766            .map(|v| {
6767                (
6768                    crate::i18n::t(crate::i18n::Msg::StatusUpgradeHint { version: &v }).into_owned(),
6769                    crate::render::HintSeverity::Info,
6770                )
6771            })
6772    };
6773    // Pre-configure, `ctx.model_name` is a dummy from the startup fallback
6774    // (empty string or "not-configured") — showing that raw in the status
6775    // line reads as a glitch. Replace with an explicit placeholder so the
6776    // user sees the state, not a rendering artifact.
6777    let model = if no_provider {
6778        crate::i18n::t(crate::i18n::Msg::StatusModelNotConfigured).into_owned()
6779    } else {
6780        ctx.model_name.clone()
6781    };
6782    // Mode indicator: only rendered when the user has explicitly
6783    // switched away from the default Build mode. Plan disables file
6784    // edits + shell, so making it prominent in the status line guards
6785    // against the user being confused why the agent refuses to write
6786    // files. Default Build = None, no visual noise.
6787    let mode_indicator = match state.agent_mode {
6788        crate::state::AgentMode::Plan => Some("PLAN".to_string()),
6789        crate::state::AgentMode::Build => None,
6790    };
6791    // Pull current ctx usage from the last ContextStats emission. Pre-
6792    // first-turn `last_context` is None — render shows nothing then.
6793    // Using `sent_tokens` (what was actually sent to the model on the
6794    // last turn) instead of cumulative `total_tokens` because the user
6795    // cares about "how close to overflow am I", not "how many tokens
6796    // has this session burned in total". See render::StatusLine docs.
6797    let (ctx_used, ctx_window) = match state.last_context.as_ref() {
6798        Some(snap) => (snap.sent_tokens, snap.ctx_window),
6799        None => (0, 0),
6800    };
6801    // Session-name badge: surfaced only when the user has explicitly
6802    // renamed the conversation. Auto-named sessions (default /
6803    // session-* / first-message-derived) intentionally stay badge-less
6804    // so the chrome stays quiet on fresh conversations.
6805    let session_name = if ctx.current_session.user_renamed {
6806        let name = ctx.current_session.name.trim();
6807        if name.is_empty() {
6808            None
6809        } else {
6810            Some(name.to_string())
6811        }
6812    } else {
6813        None
6814    };
6815    crate::render::StatusLine {
6816        model,
6817        cwd,
6818        ctx_used,
6819        ctx_window,
6820        hint,
6821        mode_indicator,
6822        session_name,
6823    }
6824}
6825
6826/// Render one spinner frame. Used from both the interval-driven tick
6827/// path and the opportunistic "post-event" pump path that guards
6828/// against agent-event floods starving the interval tick.
6829///
6830/// When the type-ahead buffer starts with `/`, the slash-command palette
6831/// is attached so the user can see candidate commands mid-stream (the
6832/// renderer then shows the menu in place of the spinner).
6833fn draw_spinner_now(
6834    state: &mut UiState,
6835    buf: &Buffer,
6836    ctx: &LoopCtx,
6837    renderer: &mut dyn Renderer,
6838    queue_len: usize,
6839    menu_selected: usize,
6840) {
6841    let frame = state.tick_spinner();
6842    let label = format_spinner_label(state, queue_len);
6843    let status = build_status(state, ctx);
6844    let menu = build_menu_items(&buf.text, buf.cursor, &ctx.commands, &ctx.custom_commands, Some(&ctx.skill_registry), Some(&ctx.file_index)).map(|items| {
6845        let selected = menu_selected.min(items.len().saturating_sub(1));
6846        let kind = if file_index::detect_at_mention_range(&buf.text, buf.cursor).is_some() {
6847            crate::render::MenuKind::AtMention
6848        } else {
6849            crate::render::MenuKind::SlashCommand
6850        };
6851        crate::render::MenuPayload { items, selected, kind }
6852    });
6853    let attachments = compute_input_attachments(state, &buf.text);
6854    renderer.render(UiLine::StreamingBox {
6855        buf: buf.text.clone(),
6856        cursor_byte: buf.cursor,
6857        frame,
6858        label,
6859        status,
6860        menu,
6861        attachments,
6862    });
6863    renderer.flush();
6864}
6865
6866/// Build the spinner line shown in the footer —
6867/// `"{label}… · {elapsed} · {N} queued"`. State stores only the bare
6868/// word (e.g. `Pondering`, `Running ReadFile`); ellipsis + elapsed +
6869/// queued suffixes are appended here so format is consistent across
6870/// every call site.
6871fn format_spinner_label(state: &UiState, queue_len: usize) -> String {
6872    let base = &state.spinner_label;
6873    let mut out = format!("{}{}", base, state.ellipsis());
6874    // Phase elapsed (NOT total turn elapsed) — `Pondering… 8s`,
6875    // `Running ReadFile… 4s`. CC behaviour: timer resets on every
6876    // phase transition so the user reads "this thing has been
6877    // running for N seconds", not "the whole turn so far is 1301s".
6878    if let Some(d) = state.phase_elapsed() {
6879        out.push_str(&format!(" · {}", crate::render::fmt_dur(d)));
6880    }
6881    if queue_len > 0 {
6882        out.push_str(&format!(" · {} queued", queue_len));
6883    }
6884    out
6885}
6886
6887/// Convert a snake_case tool name to PascalCase for display. The agent
6888/// protocol uses `read_file`, `edit_file`, `web_fetch` etc.; the UI shows
6889/// `ReadFile`, `EditFile`, `WebFetch` — a CC-style convention that reads
6890/// more cleanly at a glance.
6891pub fn display_tool_name(snake: &str) -> String {
6892    let mut out = String::with_capacity(snake.len());
6893    for word in snake.split('_') {
6894        let mut chars = word.chars();
6895        if let Some(c) = chars.next() {
6896            out.extend(c.to_uppercase());
6897            out.push_str(chars.as_str());
6898        }
6899    }
6900    out
6901}
6902
6903/// CC-style short tool name. Strips the redundant `_file` /
6904/// `_directory` / `_files` suffixes (the noun is implicit from the
6905/// arg) before PascalCase conversion. Generic — no per-tool match
6906/// arms; works for any future tool that follows the
6907/// `<verb>_<noun>` convention.
6908///
6909/// Examples:
6910/// - `read_file` → `Read`
6911/// - `write_file` → `Write`
6912/// - `list_directory` → `List`
6913/// - `parallel_edit_files` → `ParallelEdit`
6914/// - `bash` → `Bash` (no suffix to strip)
6915/// - `search_replace` → `SearchReplace` (suffix `_replace` not in
6916///    strip list, kept verbatim → preserves disambiguation)
6917pub fn display_tool_name_short(snake: &str) -> String {
6918    const STRIP_SUFFIXES: &[&str] = &["_files", "_file", "_directory"];
6919    let trimmed = STRIP_SUFFIXES
6920        .iter()
6921        .find_map(|s| snake.strip_suffix(s))
6922        .unwrap_or(snake);
6923    display_tool_name(trimmed)
6924}
6925
6926pub(crate) fn format_tool_detail(name: &str, args_json: &str) -> String {
6927    let Ok(v) = serde_json::from_str::<serde_json::Value>(args_json) else {
6928        return String::new();
6929    };
6930    let get_str = |k: &str| v.get(k).and_then(|x| x.as_str()).map(str::to_string);
6931    let basename = |p: &str| p.rsplit('/').next().unwrap_or(p).to_string();
6932
6933    match name {
6934        "read_file" | "edit_file" | "write_file" | "create_file" | "list_symbols" => {
6935            // Single-call path: basename only (compact). Batch disambiguation
6936            // is handled by `disambiguate_batch_details` which runs after
6937            // all child details are computed and can compare them.
6938            get_str("file_path")
6939                .map(|p| basename(&p))
6940                .unwrap_or_default()
6941        }
6942        "read_symbol" => {
6943            let sym = get_str("symbol").unwrap_or_default();
6944            let file = get_str("file_path")
6945                .map(|p| basename(&p))
6946                .unwrap_or_default();
6947            if sym.is_empty() {
6948                file
6949            } else if file.is_empty() {
6950                sym
6951            } else {
6952                format!("{} in {}", sym, file)
6953            }
6954        }
6955        "glob" => get_str("pattern")
6956            .map(|p| crate::width::truncate_with_ellipsis(&p, 100))
6957            .unwrap_or_default(),
6958        "grep" => get_str("pattern")
6959            .map(|p| crate::width::truncate_with_ellipsis(&p, 100))
6960            .unwrap_or_default(),
6961        "bash" => get_str("command")
6962            .map(|c| crate::width::truncate_with_ellipsis(&c, 500))
6963            .unwrap_or_default(),
6964        "list_directory" | "change_dir" => get_str("path").unwrap_or_else(|| ".".into()),
6965        "web_fetch" => get_str("url")
6966            .map(|u| crate::width::truncate_with_ellipsis(&u, 150))
6967            .unwrap_or_default(),
6968        "web_search" => get_str("query")
6969            .map(|q| crate::width::truncate_with_ellipsis(&q, 100))
6970            .unwrap_or_default(),
6971        "find_references" | "trace_callees" | "trace_callers" | "trace_chain" => {
6972            get_str("symbol").unwrap_or_default()
6973        }
6974        "blast_radius" | "file_dependencies" => {
6975            // Same as above: basename for single-call; batch disambiguation
6976            // handled by `disambiguate_batch_details`.
6977            get_str("file").map(|p| basename(&p)).unwrap_or_default()
6978        }
6979        "search_replace" => {
6980            // SearchReplaceArgs uses search/replace/glob/path (not
6981            // file_path/file/pattern/old). Show "search → replace" so
6982            // the approval prompt tells the user WHAT will be replaced.
6983            let search = get_str("search");
6984            let replace = get_str("replace");
6985            let glob = get_str("glob");
6986            let path = get_str("path");
6987            match (&search, &replace) {
6988                (Some(s), Some(r)) => {
6989                    let arrow = format!(
6990                        "{} → {}",
6991                        crate::width::truncate_with_ellipsis(s, 60),
6992                        crate::width::truncate_with_ellipsis(r, 60)
6993                    );
6994                    let mut parts = vec![arrow];
6995                    if let Some(g) = &glob {
6996                        parts.push(format!("glob: {}", g));
6997                    }
6998                    if let Some(p) = &path {
6999                        if p != "." {
7000                            parts.push(format!("path: {}", basename(p)));
7001                        }
7002                    }
7003                    parts.join(", ")
7004                }
7005                (None, Some(r)) => crate::width::truncate_with_ellipsis(r, 100),
7006                (Some(s), None) => crate::width::truncate_with_ellipsis(s, 100),
7007                _ => String::new(),
7008            }
7009        }
7010        "use_skill" => get_str("name").unwrap_or_default(),
7011        _ => {
7012            // Fallback: try common single-key args that make sense as detail.
7013            for key in [
7014                "file_path",
7015                "path",
7016                "file",
7017                "pattern",
7018                "query",
7019                "url",
7020                "name",
7021                "symbol",
7022                "command",
7023            ] {
7024                if let Some(s) = get_str(key) {
7025                    return crate::width::truncate_with_ellipsis(&s, 100);
7026                }
7027            }
7028            String::new()
7029        }
7030    }
7031}
7032
7033/// Disambiguate parallel-batch child details when multiple calls produce
7034/// the same short display (e.g. 3 × `Read(SKILL.md)` from different dirs).
7035///
7036/// For each child whose `raw_detail` duplicates another, walks up the
7037/// path from basename toward the root until all duplicates are unique.
7038/// Non-duplicate entries are left unchanged.
7039///
7040/// Example:
7041///   paths: [skills/a/SKILL.md, skills/b/SKILL.md, skills/c/SKILL.md]
7042///   raw_details: [SKILL.md, SKILL.md, SKILL.md]
7043///   → [a/SKILL.md, b/SKILL.md, c/SKILL.md]
7044///
7045/// If paths can't be extracted from arguments (non-file tools), falls
7046/// back to appending `#2`, `#3` suffixes.
7047fn disambiguate_batch_details(
7048    names: &[&str],
7049    args_jsons: &[&str],
7050    raw_details: &[String],
7051) -> Vec<String> {
7052    // Fast path: no duplicates → return as-is.
7053    let mut seen = std::collections::HashMap::<&str, usize>::new();
7054    let mut has_dups = false;
7055    for d in raw_details {
7056        let count = seen.entry(d.as_str()).or_insert(0);
7057        *count += 1;
7058        if *count > 1 {
7059            has_dups = true;
7060        }
7061    }
7062    if !has_dups {
7063        return raw_details.to_vec();
7064    }
7065
7066    // Extract full paths from args where possible.
7067    let extract_path = |name: &str, args_json: &str| -> Option<String> {
7068        let Ok(v) = serde_json::from_str::<serde_json::Value>(args_json) else {
7069            return None;
7070        };
7071        let get_str = |k: &str| v.get(k).and_then(|x| x.as_str()).map(str::to_string);
7072        match name {
7073            "read_file" | "edit_file" | "write_file" | "create_file" | "list_symbols"
7074            | "blast_radius" | "file_dependencies" => get_str("file_path").or_else(|| get_str("file")),
7075            "search_replace" => get_str("file_path").or_else(|| get_str("file")),
7076            "read_symbol" => get_str("file_path"),
7077            _ => None,
7078        }
7079    };
7080
7081    let full_paths: Vec<Option<String>> = names
7082        .iter()
7083        .zip(args_jsons.iter())
7084        .map(|(n, a)| extract_path(n, a))
7085        .collect();
7086
7087    // For each group of duplicates, progressively add parent path
7088    // components until unique within that group.
7089    let mut result = raw_details.to_vec();
7090
7091    // Collect groups of indices that share the same raw_detail.
7092    let mut groups: std::collections::HashMap<&str, Vec<usize>> =
7093        std::collections::HashMap::new();
7094    for (i, d) in raw_details.iter().enumerate() {
7095        groups.entry(d.as_str()).or_default().push(i);
7096    }
7097
7098    for (_detail, indices) in groups {
7099        if indices.len() < 2 {
7100            continue; // unique, no disambiguation needed
7101        }
7102
7103        // Check if we have full paths for all in this group.
7104        let all_have_paths = indices.iter().all(|&i| full_paths[i].is_some());
7105
7106        if all_have_paths {
7107            // Strategy: progressively add parent path components until
7108            // all entries are unique. Start with 1 parent component
7109            // (e.g. `a/SKILL.md`), then 2 (`b/a/SKILL.md`), etc.
7110            let paths: Vec<&str> = indices.iter().map(|&i| full_paths[i].as_deref().unwrap()).collect();
7111            let mut depth = 1usize;
7112            let max_depth = paths.iter().map(|p| p.matches('/').count()).max().unwrap_or(0);
7113
7114            loop {
7115                let candidates: Vec<String> = paths
7116                    .iter()
7117                    .map(|p| tail_path(p, depth))
7118                    .collect();
7119
7120                let all_unique = {
7121                    let mut s = std::collections::HashSet::new();
7122                    candidates.iter().all(|c| s.insert(c.as_str()))
7123                };
7124
7125                if all_unique || depth >= max_depth {
7126                    for (i, &idx) in indices.iter().enumerate() {
7127                        result[idx] = crate::width::truncate_with_ellipsis(
7128                            &candidates[i],
7129                            100,
7130                        );
7131                    }
7132                    break;
7133                }
7134                depth += 1;
7135            }
7136        } else {
7137            // Fallback: append #2, #3, … suffixes to disambiguate.
7138            for (seq, &idx) in indices.iter().enumerate() {
7139                if seq > 0 {
7140                    let suffixed = format!("{} #{}", raw_details[idx], seq + 1);
7141                    result[idx] = crate::width::truncate_with_ellipsis(&suffixed, 100);
7142                }
7143            }
7144        }
7145    }
7146
7147    result
7148}
7149
7150/// Return the last `depth + 1` path components of `path`.
7151/// E.g. tail_path("a/b/c/SKILL.md", 1) → "c/SKILL.md"
7152///      tail_path("a/b/c/SKILL.md", 2) → "b/c/SKILL.md"
7153///      tail_path("a/b/c/SKILL.md", 3) → "a/b/c/SKILL.md"
7154fn tail_path(path: &str, depth: usize) -> String {
7155    if depth == 0 {
7156        return path.rsplit('/').next().unwrap_or(path).to_string();
7157    }
7158    // Walk backwards counting separators. To keep `depth + 1` components,
7159    // we need to find the separator that is `depth + 1`-th from the end,
7160    // then return everything after it.
7161    // For depth=1 in "a/b/c/SKILL.md": we need "c/SKILL.md",
7162    // so find the '/' between "b" and "c" (2nd from end), return after it.
7163    let mut seen = 0;
7164    for (i, ch) in path.char_indices().rev() {
7165        if ch == '/' {
7166            seen += 1;
7167            if seen == depth + 1 {
7168                return path[(i + ch.len_utf8())..].to_string();
7169            }
7170        }
7171    }
7172    // Fewer than `depth + 1` separators — return the whole path.
7173    path.to_string()
7174}
7175
7176/// Render an `elapsed_ms` value as `XmYs` (over 60 s) or `Ts` (under).
7177/// Tens of milliseconds aren't useful for the UI; whole seconds match
7178/// what users see on a wall clock and align column-wise across rows.
7179pub(crate) fn fmt_elapsed(ms: u64) -> String {
7180    let total_secs = ms / 1000;
7181    if total_secs >= 60 {
7182        format!("{}m{}s", total_secs / 60, total_secs % 60)
7183    } else {
7184        format!("{}s", total_secs)
7185    }
7186}
7187
7188pub(crate) fn summarise(output: &str, success: bool) -> String {
7189    let first = output.lines().next().unwrap_or("(no output)");
7190    let n = output.lines().count();
7191    // Failures get a larger budget because the first line is usually
7192    // diagnostic ("Error: old_string not found in /mnt/d/.../f_store.")
7193    // and the path is the load-bearing piece of info — silently
7194    // chopping it at 80 cols turned `f_store` into `f_stor` and made
7195    // the agent loop on the wrong file. 200 cols comfortably fits a
7196    // typical WSL-style absolute path; anything beyond that probably
7197    // is too long to read inline anyway.
7198    let budget = if success { 80 } else { 200 };
7199    // `truncate_with_ellipsis` (instead of bare `truncate_to_width`)
7200    // so that whenever the budget IS exceeded, the user / agent sees
7201    // a `…` marker — silent mid-token chops were the actual UX bug.
7202    let trimmed = crate::width::truncate_with_ellipsis(first, budget);
7203    if n > 1 {
7204        format!("{} ({} lines)", trimmed, n)
7205    } else {
7206        trimmed
7207    }
7208}
7209
7210// SessionPicker tests moved alongside the struct in
7211// `crate::modals::session_picker::tests`.