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, ®);
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, ®);
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, ®);
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, ®);
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, ®, &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, ®, &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, ®, &custom, None, None).unwrap();
1481 let filtered = build_menu_items("/he", 0, ®, &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, ®, &custom, None, None).is_none());
1503 assert!(build_menu_items("/cd /tmp", 0, ®, &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, ®, &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, ®, &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, ®, &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, ®, &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, ®, &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, ®, &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, ®, &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, ®, &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, ®, &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, ®, &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, ®, &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, ®);
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, ®);
1780 let _ = buf.apply(Action::Insert('i'), &history, ®);
1781 let _ = buf.apply(Action::HistoryPrev, &history, ®);
1782 assert!(buf.is_in_history());
1783 let _ = buf.apply(Action::HistoryNext, &history, ®);
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, ®);
1801 assert!(buf.is_in_history());
1802 let _ = buf.apply(Action::Insert('x'), &history, ®);
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, ®);
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, ®);
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, ®);
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, ®);
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, ®);
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, ®);
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, ®);
1904 let _ = buf2.apply(Action::Insert('i'), &history, ®);
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: ¤t }).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`.