use crate::app::{ App, Entry, FILTER_LABELS, Kind, Lane, LaneId, MAIN_LANE, Search, Session, SharedApp, ToolResult, filter_index, fmt_tokens, }; use crate::keymap::{Act, Menu, Prefix, ROOT}; use crate::term::{EmbeddedTerm, PaneView}; use ratatui::Frame; use ratatui::crossterm::cursor::SetCursorStyle; use ratatui::crossterm::event::{ self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, Event, KeyCode, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind, }; use ratatui::crossterm::execute; use ratatui::layout::{Constraint, Layout, Position, Rect}; use ratatui::style::{Color, Modifier, Style, Stylize}; use ratatui::text::{Line, Span, Text}; use ratatui::widgets::{Block, Clear, List, ListItem, ListState, Paragraph, Wrap}; use serde_json::Value; use std::collections::{HashMap, HashSet}; use std::time::{Duration, Instant}; /// Embedded `claude` pane state (UI-thread only; the shared App carries just /// the session id + grow flag so the proxy tap can talk to it). /// /// One app instance hosts at most one embedded claude (`term`). The pane is /// drawn only while the feed selection is on its session: tabbing away hides /// it without killing the child, so tabbing back is instant. ctrl-↓ is the /// commit point that may kill + respawn (`attach_selected`). struct EmbedUi { term: Option, visible: bool, /// The feed takes the screen and the pane is hidden (`prefix Z`). This /// *covers* the pane, which is exactly why Esc leaves it — unlike /// `fullscreen`, which is nothing but the pane and therefore passes Esc /// through to the child. zoom_feed: bool, /// Pane takes (nearly) the whole screen (`prefix z`). fullscreen: bool, /// Which-key menu level currently on screen; `None` = closed. UI-thread /// only, so it never touches `App` or the reload snapshot. menu: Option<&'static Menu>, /// The key that opens that menu. Everything unprefixed goes to the child. prefix: Prefix, /// The focused child was on the **alternate screen** last frame /// (`EmbeddedTerm::alt_screen`): an editor it launched — nvim, a /// `git commit`, a pager — owns the terminal, so the pane is fullscreen and /// the ctrl-l wipe is off (that keystroke belongs to the editor now). alt_screen: bool, /// Fullscreen was entered *for* that editor, so leaving the alternate /// screen puts the pane back the way it was. A manual ctrl-f clears it: /// the last explicit choice wins over the restore. alt_fullscreen: bool, port: u16, /// Sessions that were embedded earlier in this process: their instances /// are known dead (we killed them), so resuming needs no liveness guard. past_embeds: HashSet, /// Armed by ctrl-↓ on a live external session: pressing again on the /// same session within a few seconds forces the resume (the session may /// have ended long ago — liveness of external instances is unknowable). force_resume: Option<(String, Instant)>, /// Cursor shape the embedded pane wants this frame (set by `draw` from the /// child's DECSCUSR state); `None` when no pane cursor is shown. The event /// loop diffs it and emits `SetCursorStyle` only on change, so the outer /// terminal mirrors the child (bar in insert mode, block in vim normal). cursor_shape: Option, /// Currently-applied compact-pane inner height, smoothed with hysteresis /// (`compact_height`): grows immediately, shrinks only after the smaller /// value has held for `SHRINK_DELAY`. Remeasuring Claude Code's live screen /// every frame otherwise oscillates during subagent turns / `@`/`/` menu /// filtering, and each change resizes the PTY → Ink repaints → flicker. compact_inner: u16, /// A pending shrink target and when it was first seen; adopted once it has /// been stable for `SHRINK_DELAY`. Reset whenever the pane grows or the /// measured height matches what's applied. shrink_pending: Option<(u16, Instant)>, } /// Rows one wheel notch scrolls. The feed and the fullscreen pane share the /// step, so the wheel feels the same either side of ctrl-f. const WHEEL_ROWS: isize = 3; /// How long a smaller compact-pane height must persist before the pane /// actually shrinks. Long enough to ride out the repaint churn of a subagent /// turn or a filtering `@`/`/` menu, short enough to feel responsive. const SHRINK_DELAY: Duration = Duration::from_millis(400); /// Follow the child in and out of the alternate screen: entering fullscreens /// the pane, leaving puts it back — but only if *we* were the one that /// fullscreened it (`auto`). Returns true on either edge, so the caller can /// reset the scroll view (the two screens index stable rows differently, and /// the alternate one holds no scrollback at all). /// /// Edge-triggered, never re-asserted per frame, which is what leaves ctrl-f in /// charge: a manual toggle while the editor is open sticks instead of being /// overridden on the next draw. Pure over its `&mut` state so it can be /// unit-tested without an `EmbedUi`. fn sync_alt_screen(alt: bool, last: &mut bool, fullscreen: &mut bool, auto: &mut bool) -> bool { if alt == *last { return false; } *last = alt; if alt { *auto = !*fullscreen; *fullscreen = true; } else if std::mem::take(auto) { *fullscreen = false; } true } /// Mouse text selection over the whole screen (mimics Claude Code: drag to /// select with a reversed-video highlight, the underlying screen text is /// copied to the system clipboard on release via OSC 52). struct Selection { start: (u16, u16), // (x, y) anchor cell end: (u16, u16), // (x, y) current cell (inclusive) dragging: bool, /// Set on mouse release: the next draw extracts + copies the text. copy_pending: bool, } impl EmbedUi { /// The pane is on screen with a live child. There is no focus flag any /// more: an on-screen pane always has the keyboard, which is the rule the /// whole keymap rests on (see `keymap`). fn alive(&self) -> bool { self.visible && !self.zoom_feed && self.term.as_ref().is_some_and(|t| !t.exited()) } /// Does the child get this keystroke? Only an overlay or the menu can take /// it away, and both are transient and Esc-closable. fn takes_keys(&self, overlay: bool) -> bool { self.alive() && !overlay && self.menu.is_none() } /// Scroll target while the pane is fullscreen: the pane's own scrollback, /// exactly as a plain terminal running `claude` would scroll (Claude Code /// grabs no mouse, so nothing is forwarded to the child — see /// `EmbeddedTerm::scroll`). Returns false when the scroll belongs to the /// feed instead: any non-fullscreen pane, and a dead child. fn scroll_pane(&self, rows: isize) -> bool { if !self.fullscreen { return false; } match self.term.as_ref().filter(|t| !t.exited()) { Some(t) => { t.scroll(rows); true } None => false, } } /// Smoothed compact-pane inner height. `measured` is this frame's raw /// reading (`None` when the input box couldn't be located — a transient /// mid-repaint, so keep what we have). Grows immediately so the prompt /// never scrolls out of view, but shrinks only after the smaller height /// has held for `SHRINK_DELAY`. Without this the height oscillates every /// frame while a subagent streams or an `@`/`/` menu filters, and each /// change resizes the PTY, making Claude Code repaint and flicker. fn compact_height(&mut self, measured: Option) -> u16 { smooth_compact(&mut self.compact_inner, &mut self.shrink_pending, measured) } } /// Hysteresis for the compact pane height (see `EmbedUi::compact_height`). /// Grows `applied` immediately; commits a smaller `measured` only once that /// target has held for `SHRINK_DELAY`; keeps `applied` unchanged on `None`. /// Pure over its `&mut` state so it can be unit-tested without an `EmbedUi`. fn smooth_compact( applied: &mut u16, pending: &mut Option<(u16, Instant)>, measured: Option, ) -> u16 { if let Some(m) = measured { if m >= *applied { *applied = m; *pending = None; } else { // Want to shrink to `m`; only commit once it has been stable. match *pending { Some((target, since)) if target == m => { if since.elapsed() >= SHRINK_DELAY { *applied = m; *pending = None; } } // First sight of this smaller target (or the target moved). _ => *pending = Some((m, Instant::now())), } } } *applied } /// Per-entry feed render cache. Entries are append-only and an entry's /// content only changes by streaming appends / tool-result attachment, so the /// rendered lines + wrapped height are cached per entry and rebuilt only when /// the fingerprint changes. Two effects: the per-frame work done *while /// holding the app mutex* is proportional to what changed, not to session /// length (the proxy tap shares that mutex — a slow render must not starve /// it), and the feed scrolls correctly past u16::MAX wrapped lines because /// only the visible window of lines is handed to ratatui. /// One `FeedCache` per lane ever drawn (the main feed and the popup's agent /// feed have different widths and scrolls, so they must not share cached /// wrapped heights). Dropped wholesale when the displayed session changes. #[derive(Default)] struct FeedCaches { session: String, by_lane: HashMap, } impl FeedCaches { fn get(&mut self, session: &str, lane: LaneId) -> &mut FeedCache { if self.session != session { self.session = session.to_string(); self.by_lane.clear(); } self.by_lane.entry(lane).or_default() } } #[derive(Default)] struct FeedCache { session_key: String, width: u16, /// Tree path (leaf turn) the cached view renders; None = whole file/live. leaf: Option, /// Live in-memory session vs on-disk transcript (same uuid, different /// entry lists — must not share cache slots). live: bool, /// Which lane this cache renders (the popup is narrower than the feed). lane: LaneId, entries: Vec, } struct CachedEntry { fingerprint: (usize, bool, usize, bool, bool, u64), lines: Vec>, /// Rows after wrapping to `FeedCache::width` (incl. trailing separator). height: usize, } impl CachedEntry { /// Placeholder for an entry belonging to another lane: cache slots stay /// aligned with the session's entry indices (an entry never changes lane, /// so a placeholder is never re-examined). fn blank() -> Self { Self { fingerprint: (usize::MAX, false, usize::MAX, false, false, 0), lines: Vec::new(), height: 0, } } } /// Cheap change-detector for a cached entry: content only ever grows (or is /// swapped for the pretty-printed form along with `done`), and a result /// attaches once — or, for an `Agent` call, is later *replaced* by the report /// its `` carried (`app::Session::attach_task_report`). /// Length + flags capture every mutation the app performs; a replacement that /// happened to be byte-for-byte the same length as the `Async agent launched…` /// acknowledgement is the one thing this would miss, which costs a stale /// render of one tool result and no correctness. The trailing /// bool folds in feed focus, but *only* for `Kind::User` entries (their block /// color tracks focus): toggling focus then re-renders just the user blocks, /// not the whole transcript. fn fingerprint(e: &Entry, focused: bool, query: u64) -> (usize, bool, usize, bool, bool, u64) { let (rlen, rerr) = e .result .as_ref() .map_or((usize::MAX, false), |r| (r.content.len(), r.is_error)); let focus_bit = matches!(e.kind, Kind::User) && focused; (e.content.len(), e.done, rlen, rerr, focus_bit, query) } /// Hash of the active search query, folded into the render fingerprint. /// /// Highlighting is applied *after* an entry is rendered, so a cached entry from /// before the query would keep serving unhighlighted lines. Editing the query /// therefore has to invalidate the cache, and this is the cheapest thing that /// does it without storing the string per entry. FNV-1a: no dependency, and /// collisions cost a missed re-render on one keystroke, not correctness. fn query_hash(q: Option<&str>) -> u64 { let Some(q) = q else { return 0 }; let mut h: u64 = 0xcbf2_9ce4_8422_2325; for b in q.as_bytes() { h ^= u64::from(*b); h = h.wrapping_mul(0x1000_0000_01b3); } // Never 0 for a real query — that is "no search". h | 1 } /// Rows of context left above a search hit when scrolling to it, so the line /// lands *in* the viewport rather than flush against its top edge. const MATCH_CONTEXT: usize = 2; /// Wrapped rows above the first painted search match in `lines`, or `None` /// when the entry holds no match at all (it can be the *result* text that /// matched, which is rendered clipped — then there is nothing to aim at and /// the entry's top is the honest answer). /// /// Reads the highlight the render pass already applied rather than re-running /// the query: one definition of "this line matched", used for both the paint /// and the scroll. fn match_row(lines: &[Line<'static>], width: u16) -> Option { let k = lines .iter() .position(|l| l.spans.iter().any(|sp| sp.style.bg == Some(MATCH_BG)))?; Some(wrapped_height(&lines[..k], width)) } /// Background of a search match. Yellow reads as "found" in every terminal /// theme, and `color_on` picks the foreground so it stays legible. const MATCH_BG: Color = Color::Indexed(226); /// Paint every case-insensitive occurrence of `q` in already-rendered lines. /// /// Post-processing the spans rather than teaching each renderer about search is /// deliberate: `entry_lines` fans out into markdown, a dozen tool renderers and /// the ANSI parser, and a match has to light up the same way in all of them. /// Splitting a span keeps whatever style it already had and only overrides the /// colours, so bold/italic/dim survive the highlight. fn highlight_lines(lines: &mut Vec>, q: &str) { if q.is_empty() { return; } let hl = Style::new().bg(MATCH_BG).fg(color_on(MATCH_BG)); for line in lines.iter_mut() { let mut out: Vec> = Vec::with_capacity(line.spans.len()); for sp in line.spans.drain(..) { let hay = sp.content.to_lowercase(); if !hay.contains(q) { out.push(sp); continue; } // Match on the lowercased copy but slice the original, so the text // keeps its own casing. Both are byte-aligned only while // lowercasing is 1:1 in length; when it is not (ß, İ) the offsets // can disagree, so fall back to leaving the span alone. if hay.len() != sp.content.len() { out.push(sp); continue; } let text = sp.content.into_owned(); let mut at = 0usize; while let Some(rel) = hay[at..].find(q) { let start = at + rel; let end = start + q.len(); if !text.is_char_boundary(start) || !text.is_char_boundary(end) { break; } if start > at { out.push(Span::styled(text[at..start].to_string(), sp.style)); } out.push(Span::styled(text[start..end].to_string(), sp.style.patch(hl))); at = end; } if at < text.len() { out.push(Span::styled(text[at..].to_string(), sp.style)); } } line.spans = out; } } /// Detach a `Line` from the text it borrows so it can outlive the app lock. fn own_line(l: Line<'_>) -> Line<'static> { Line { spans: l .spans .into_iter() .map(|s| Span::styled(s.content.into_owned(), s.style)) .collect(), style: l.style, alignment: l.alignment, } } /// Background used to highlight a visual-mode turn range in the session list. const USER_BG: Color = Color::Indexed(17); // deep blue /// Focus accent — Claude's orange (indexed so 256-color terminals match it). /// This is the single source of truth for "the feed/pane has keyboard focus": /// borders, the scroll thumb, the user-message markers and the user-message /// blocks all use it when focused. const ACCENT: Color = Color::Indexed(208); // orange /// Background of a focused user-prompt block (the active accent). const USER_BG_ACTIVE: Color = Color::Indexed(208); // orange /// Background of an unfocused user-prompt block (dimmed grey). const USER_BG_DIM: Color = Color::Indexed(238); // dark grey /// Style for a user-prompt block: a filled orange box when the feed is focused, /// a dim grey box when it isn't (focus is folded into the per-entry fingerprint /// for `Kind::User` only, so toggling focus re-renders just these blocks). The /// foreground is chosen by `color_on` so the text stays legible on either bg. fn user_block_style(focused: bool) -> Style { let bg = if focused { USER_BG_ACTIVE } else { USER_BG_DIM }; let s = Style::new().bg(bg).fg(color_on(bg)); if focused { s.bold() } else { s } } /// Pick a legible foreground (black or white) for text drawn on `bg`, from the /// background's perceived luminance. A filled block sets its own bg, so this /// keeps the text readable regardless of the user's light/dark terminal theme. fn color_on(bg: Color) -> Color { let (r, g, b) = rgb_of(bg); let luma = 0.299 * f32::from(r) + 0.587 * f32::from(g) + 0.114 * f32::from(b); if luma > 140.0 { Color::Black } else { Color::White } } /// Approximate 8-bit RGB for a ratatui `Color`, enough to judge brightness: /// RGB passes through; the xterm-256 indexed palette is decoded (16 base + the /// 6×6×6 cube + the 24-step grey ramp); anything else falls back to mid grey. fn rgb_of(c: Color) -> (u8, u8, u8) { match c { Color::Rgb(r, g, b) => (r, g, b), Color::Black => (0, 0, 0), Color::White => (255, 255, 255), Color::Indexed(i) => indexed_rgb(i), _ => (128, 128, 128), } } fn indexed_rgb(i: u8) -> (u8, u8, u8) { const BASE: [(u8, u8, u8); 16] = [ (0, 0, 0), (128, 0, 0), (0, 128, 0), (128, 128, 0), (0, 0, 128), (128, 0, 128), (0, 128, 128), (192, 192, 192), (128, 128, 128), (255, 0, 0), (0, 255, 0), (255, 255, 0), (0, 0, 255), (255, 0, 255), (0, 255, 255), (255, 255, 255), ]; match i { 0..=15 => BASE[i as usize], 16..=231 => { const STEP: [u8; 6] = [0, 95, 135, 175, 215, 255]; let n = i - 16; (STEP[(n / 36) as usize], STEP[((n / 6) % 6) as usize], STEP[(n % 6) as usize]) } _ => { let v = 8 + 10 * (i - 232); (v, v, v) } } } /// Render one entry to owned lines, including the blank separator row that /// follows every entry in the feed. `focused` only affects user-prompt blocks. fn entry_lines( e: &Entry, width: u16, focused: bool, query: Option<&str>, ) -> Vec> { let mut lines: Vec> = Vec::new(); match &e.kind { // Meta is one dim row — except a citation source list, which is a // head line plus one row per source. Splitting is the whole difference: // a `\n` inside a single `Line` is not a row break to ratatui. Kind::Meta if e.content.contains('\n') => { for l in e.content.lines() { lines.push(Line::from(sanitize(l)).dark_gray()); } } Kind::Meta => lines.push(Line::from(e.content.clone()).dark_gray()), // Request-context metadata the model received (the system prompt is too // long to show verbatim, so only its size; tools as a name list). Dim, // wrapping is handled by the feed Paragraph. Kind::System => lines.push(Line::from(format!("⚙ {}", e.content)).dark_gray()), Kind::ToolDefs => lines.push(Line::from(format!("🔧 {}", e.content)).dark_gray()), // A ``, already reduced to its final text by // `app::task_note_line` — this arm is a pure styling pass, so the // wording stays in one place. **The leading glyph is the status // channel** (`app::TaskNotification::glyph` writes it): change the // glyph set there and this match must follow. Continuation rows (a // monitor's `` payload, or a report that had no `Agent` entry // to attach to) are dim under the head line. Kind::TaskNote => { let mut rows = e.content.lines(); if let Some(head) = rows.next() { let fg = match head.chars().next() { Some('✔') => Color::Green, Some('✖') => Color::Red, Some('◼') => Color::Yellow, Some('▸') => Color::Cyan, _ => Color::DarkGray, }; lines.push(Line::from(sanitize(head)).fg(fg)); } for l in rows { lines.push(Line::from(sanitize(l)).dark_gray()); } } Kind::User => { let style = user_block_style(focused); let w = (width as usize).max(1); // Wrap to the inner feed width ourselves and pad every row to // *exactly* w, so the block is a clean filled rectangle that ends // flush with the borders — no reliance on the Paragraph's own // wrapping (which left a stray, half-empty continuation row), and // no trailing blank: the content is right-trimmed first. let mut first = true; for raw in e.content.trim_end().lines() { // Slash-command stdout rides along inside the prompt verbatim // (`/model` prints `\x1b[1mSonnet 5\x1b[22m`). This block is a // *filled* rectangle whose foreground `color_on` picks from the // background's luminance so it stays legible under any terminal // theme — an arbitrary ANSI foreground would destroy exactly // that contrast. So colour is dropped here and only the // attributes (bold/dim/italic/underline) survive; everywhere // else in the feed (`ansi::spans`) keeps the colour too. let (plain, mods) = crate::ansi::plain_with_mods(raw); let prefix = if first { "❯ " } else { " " }; let prefixed = format!("{prefix}{plain}"); // Attributes indexed by character of `prefixed`; the marker // columns carry none. let src: Vec = prefixed.chars().collect(); let mut src_mods = vec![Modifier::empty(); prefix.chars().count()]; src_mods.extend(mods); let mut cur = 0usize; for seg in wrap_words(&prefixed, w) { let mut spans: Vec> = Vec::new(); let mut run = String::new(); let mut run_mod = Modifier::empty(); let mut used = 0usize; for ch in seg.chars() { // `wrap_words` only ever drops whitespace and never // reorders, so walking the source forward to the next // matching char re-aligns the attributes after a wrap. while cur < src.len() && src[cur] != ch { cur += 1; } let m = src_mods.get(cur).copied().unwrap_or_default(); cur = cur.saturating_add(1).min(src.len()); if !run.is_empty() && m != run_mod { spans.push(Span::styled( std::mem::take(&mut run), style.add_modifier(run_mod), )); } if run.is_empty() { run_mod = m; } run.push(ch); used += 1; } if !run.is_empty() { spans.push(Span::styled(run, style.add_modifier(run_mod))); } // Pad to exactly `w` so the block ends flush with the // borders (the filler carries no ANSI attributes). let pad = w.saturating_sub(used); if pad > 0 { spans.push(Span::styled(" ".repeat(pad), style)); } lines.push(Line::from(spans)); } first = false; } if lines.is_empty() { lines.push(Line::from(Span::styled(" ".repeat(w), style))); } } Kind::Reminder => { // Injected context Claude Code received — shown dim, like thinking. // Reminders carry client-side machinery verbatim, including // `` from slash commands, which is genuine // terminal output: its SGR sequences become styling on top of the // dim italic base rather than literal `[1m` text. let base = Style::new().dark_gray().italic(); lines.push(Line::from("⌁ system reminder").dark_gray().italic()); for l in e.content.lines() { let mut spans = vec![Span::styled(" ", base)]; spans.extend(crate::ansi::spans(l, base)); lines.push(Line::from(spans)); } } Kind::Thinking => { let head = if e.done { "✻ thought" } else { "✻ thinking…" }; lines.push(Line::from(head).magenta().italic()); for l in e.content.lines() { lines.push(Line::from(sanitize(l)).dark_gray().italic()); } } Kind::Text => { // Sanitize first (preserving newlines): a literal tab in a code // block would otherwise survive as a `\t` cell symbol and shift the // terminal cursor to the next tab stop, scattering the line. let clean = sanitize_md(&e.content); lines.extend(crate::markdown::render(&clean, width).into_iter().map(own_line)); } Kind::Tool { name } => { // Once the input JSON is complete, every tool gets a // human-readable rendering; partial streams fall back to // the raw JSON-fragment view. let parsed = e .done .then(|| serde_json::from_str::(&e.content).ok()) .flatten(); match parsed { Some(v) => render_tool(name, &v, e.result.as_ref(), &mut lines, width), None => { let head = if e.done { format!("⚙ {name}") } else { format!("⚙ {name} …") }; lines.push(Line::from(head).yellow().bold()); for l in e.content.lines() { lines.push(Line::from(format!(" {}", sanitize(l))).cyan()); } } } } Kind::Error => { for l in e.content.lines() { lines.push(Line::from(sanitize(l)).red().bold()); } } } if let Some(q) = query { highlight_lines(&mut lines, q); } lines.push(Line::default()); lines } /// Rows the lines occupy after wrapping to `width` (must match the wrap /// configuration of the feed Paragraph). fn wrapped_height(lines: &[Line<'static>], width: u16) -> usize { Paragraph::new(Text::from(lines.to_vec())) .wrap(Wrap { trim: false }) .line_count(width) } /// `pane`/`pane_ui` are non-empty only after a hot reload: the `claude` child /// is still running on the pty we inherited through the exec, so the pane is /// re-adopted rather than respawned (`reload.rs`). pub fn run( app: SharedApp, port: u16, pane: Option, pane_ui: crate::reload::PaneState, reloaded: bool, ) -> anyhow::Result<()> { // Keep the session list populated with this directory's on-disk history. crate::sessions::spawn_scanner(app.clone()); // Refresh the `n` model picker from the live `claude` alias set. crate::term::spawn_model_discovery(app.clone()); let mut terminal = ratatui::init(); // After a reload the screen still holds the *old* image's frame, and // ratatui diffs the first draw against an empty buffer — so any cell the // new frame leaves blank would keep the old glyph. Wipe it once. if reloaded { let _ = terminal.clear(); } // Mouse capture: wheel events scroll the feed (regardless of pane focus). // Trade-off: text selection in the outer terminal now needs shift held. // Bracketed paste: a multiline paste arrives as one `Event::Paste` we hand // to the child as a bracketed paste, instead of N keystrokes whose first // newline would submit the prompt. let _ = execute!(std::io::stdout(), EnableMouseCapture, EnableBracketedPaste); // A reload hands the pane back as a bare fd + pid; failing to adopt it // costs the pane, never the app, so the error is only reported. let adopted = pane.and_then(|h| match crate::term::EmbeddedTerm::adopt(h) { Ok(t) => Some(t), Err(e) => { app.lock().unwrap().status = format!("pane lost in reload: {e:#}"); None } }); let has_pane = adopted.is_some(); // Read the alternate-screen flag *before* the first draw: seeding it from // the live child means an editor that was open across the reload raises no // edge, so the snapshotted `alt_fullscreen` survives and quitting the // editor still restores the compact pane. let on_alt = adopted.as_ref().is_some_and(EmbeddedTerm::alt_screen); let mut eui = EmbedUi { visible: pane_ui.visible && has_pane, zoom_feed: false, menu: None, prefix: Prefix::from_env(), fullscreen: pane_ui.fullscreen && has_pane, alt_screen: on_alt, alt_fullscreen: pane_ui.alt_fullscreen && has_pane, past_embeds: pane_ui.past_embeds.into_iter().collect(), compact_inner: if pane_ui.compact_inner == 0 { crate::term::DEFAULT_COMPACT_INNER } else { pane_ui.compact_inner }, term: adopted, port, force_resume: None, cursor_shape: None, shrink_pending: None, }; let res = event_loop(&mut terminal, app, &mut eui); let _ = execute!( std::io::stdout(), SetCursorStyle::DefaultUserShape, DisableBracketedPaste, DisableMouseCapture ); ratatui::restore(); res } /// Begin a hot reload (ctrl-r): ask the proxy to drain. `try_reload` does the /// exec once it reports back. /// /// No build happens here or anywhere else — the binary on disk is whatever you /// last built, and pressing this says "run that one now". fn start_reload(app: &SharedApp) { use crate::reload::Status; let mut a = app.lock().unwrap(); if matches!(a.reload, Status::Draining(_)) { return; } a.reload = Status::Draining(Instant::now()); // Take the sender rather than drop it: the proxy reads a *dropped* channel // as "never shut down", so dropping would signal nothing. if let Some(tx) = a.drain_tx.take() { let _ = tx.send(()); } } /// Finish a hot reload: once the proxy has drained, `execve` the binary on disk /// into this process. Called every frame; a no-op unless a reload is running. /// /// The drain is the important part. An exec destroys the tokio tasks relaying /// in-flight responses, so the proxy is asked to stop accepting and finish what /// it has first (`proxy::run`'s graceful shutdown). The *socket* stays open /// throughout — `App::listener_fd` is a dup of it — so requests Claude Code /// makes during the swap wait in the kernel backlog and are served by the new /// image. Nothing is refused and nothing is truncated. /// /// Returns normally only when the reload did **not** happen. fn try_reload(app: &SharedApp, eui: &mut EmbedUi, terminal: &mut ratatui::DefaultTerminal) { use crate::reload::Status; let exe = { let a = app.lock().unwrap(); let Status::Draining(since) = &a.reload else { return }; // The cap keeps one stuck upstream response from pinning the reload // forever; past it that response is cut, exactly as it would be with // no drain at all. if !a.drained.load(std::sync::atomic::Ordering::SeqCst) && since.elapsed() < crate::reload::DRAIN_MAX { return; } a.exe.clone() }; // The pane crosses over only while its child is alive. Note this borrows // rather than takes: an exec runs no destructors, so `EmbeddedTerm::drop` // never fires and never SIGHUPs the child — and if the exec *fails* we // still own the pane, unchanged. Do not turn this into a `take()`. let pane = eui.term.as_ref().filter(|t| !t.exited()).and_then(|t| t.handoff()); let pane_ui = crate::reload::PaneState { visible: eui.visible, focused: eui.visible, fullscreen: eui.fullscreen, alt_fullscreen: eui.alt_fullscreen, past_embeds: eui.past_embeds.iter().cloned().collect(), compact_inner: eui.compact_inner, }; // crossterm caches the pre-raw termios in a process global that the exec // wipes. Without this the new image records *raw* as the original state and // hands the user's shell back in raw mode on quit. let _ = ratatui::crossterm::terminal::disable_raw_mode(); let mut a = app.lock().unwrap(); let e = crate::reload::exec_into(&exe, &a, pane, &pane_ui); // Only reachable when the exec failed — most likely the key landed while // the linker had the file half-written. Put the terminal back, keep // serving, and say so; pressing it again is safe. let _ = ratatui::crossterm::terminal::enable_raw_mode(); let _ = terminal.clear(); a.reload = Status::Failed(format!("{e:#}")); } /// Tear down the current embedded child (if any): dropping it kills the /// process (`EmbeddedTerm::drop`), the learned session id is retired into /// `past_embeds` (so a later `--resume` of it skips the liveness guard — we /// know it's dead), and every per-pane flag is cleared. The single teardown /// path every spawn/replace routes through, so pane identity and the /// grow/clear flags can never drift between the four call sites. fn kill_current_embed(eui: &mut EmbedUi, app: &SharedApp) { if eui.term.take().is_none() { return; } let mut a = app.lock().unwrap(); if let Some(old) = a.embed_session.take() { eui.past_embeds.insert(old); } a.embed_token = None; a.embed_grow = false; a.embed_grow_rows = None; a.embed_clear_at = None; } /// Register a freshly spawned pane as the embedded child: record its token /// (so the tap recognises the pane's traffic and learns its real session id), /// reset per-pane flags, and store the child. `session` is `Some(uuid)` only /// for a resume — where we know the id up front and pre-load its transcript; /// a fresh spawn passes `None` and lets the first tagged request bind the id. fn bind_new_pane(eui: &mut EmbedUi, app: &SharedApp, t: EmbeddedTerm, session: Option<&str>) { let mut a = app.lock().unwrap(); a.embed_token = Some(t.pane_token.clone()); a.embed_session = session.map(str::to_string); a.embed_grow = false; a.embed_grow_rows = None; a.embed_clear_at = None; if let Some(key) = session { a.select_key(key); } a.follow = true; // A pane you just started is what you want the feed on: re-arm the tail. a.follow_pane = true; a.feed_lane = MAIN_LANE; a.clear_turn_focus(); drop(a); eui.term = Some(t); } /// Register a freshly spawned pane *and* switch the feed to it immediately: /// create a provisional, empty session row keyed by the spawned `--session-id` /// and select it, so a fresh `a` / first spawn clears the feed to the new blank /// session at once instead of waiting for the first prompt's traffic to bind /// it. If Claude Code later reports a different id, the tap renames this row /// onto it (the same provisional-rename path a resume uses), so we never end up /// with two rows for the one session. fn bind_fresh_pane(eui: &mut EmbedUi, app: &SharedApp, t: EmbeddedTerm, model: &str) { let sid = t.session_id.clone(); { let mut a = app.lock().unwrap(); if !a.sessions.iter().any(|s| s.key == sid) { a.sessions.push(Session::new(sid.clone(), model.to_string())); } // Remember the exact `--model` argument: the transcript only records // the base model id, so this is the one place a `[1m]` (1M-context) // pick can survive into a later resume. a.set_spawn_model(&sid, model); } bind_new_pane(eui, app, t, Some(&sid)); } /// Show (spawning if needed) the claude pane and give it keyboard focus. /// A live child is reused (instant reveal); a dead one is replaced. fn show_embed_pane(eui: &mut EmbedUi, app: &SharedApp) { if eui.term.as_ref().is_some_and(|t| t.exited()) { kill_current_embed(eui, app); } if eui.term.is_none() { // Real dimensions are applied on the first draw via resize(): the // model is Claude Code's own default, at the 1M window (`spawn_arg`). let model = app.lock().unwrap().spawn_arg(""); match EmbeddedTerm::spawn(eui.port, 20, 80, &model) { Ok(t) => bind_fresh_pane(eui, app, t, &model), Err(e) => { app.lock().unwrap().status = format!("claude spawn failed: {e}"); return; } } } else { // Reusing a live child: re-select its session if we've learned it. let mut a = app.lock().unwrap(); if let Some(key) = a.embed_session.clone() { a.select_key(&key); a.follow = true; a.follow_pane = true; } } eui.visible = true; eui.zoom_feed = false; } /// `a` model picker: always spawn a *fresh* `claude --session-id ` /// (optionally `--model `), killing any current pane first. Unlike /// `show_embed_pane` this never reuses an existing child — the point is to /// start a brand-new session without resume + /clear. /// /// `pick` is the picker row's argument; `App::spawn_arg` resolves the empty /// `default` row and guarantees the 1M window either way. fn show_embed_new(eui: &mut EmbedUi, app: &SharedApp, pick: &str) { kill_current_embed(eui, app); let model = app.lock().unwrap().spawn_arg(pick); match EmbeddedTerm::spawn(eui.port, 20, 80, &model) { Ok(t) => bind_fresh_pane(eui, app, t, &model), Err(e) => { app.lock().unwrap().status = format!("claude spawn failed: {e}"); return; } } eui.visible = true; eui.zoom_feed = false; } /// Spawn (or replace) the embedded pane resuming a past session by UUID. /// Any existing pane (live or dead) is killed and replaced — this is the /// only expensive path, and it only runs from an explicit ctrl-↓ / `c`. /// /// The resume carries the session's own model forward (`App::resume_model`, /// read from its transcript) instead of falling back to the CLI default, and /// always at the 1M context window. fn show_embed_resume(eui: &mut EmbedUi, app: &SharedApp, session_id: &str) { kill_current_embed(eui, app); let model = app.lock().unwrap().resume_model(session_id); match EmbeddedTerm::spawn_resume(eui.port, 20, 80, session_id, &model) { Ok(t) => { { // Pre-fill the feed from the on-disk transcript so it isn't // blank until the next turn streams. Always re-read (drop any // cached path view) — the resume continues the trunk, which a // dead-branch view wouldn't show. The bind below provisionally // points `embed_session` at this uuid; if Claude Code reports a // different id, the tap rebinds (renaming this row onto it). let mut a = app.lock().unwrap(); a.history.remove(session_id); if !a.sessions.iter().any(|s| s.key == session_id) && let Some(s) = crate::sessions::load_history(session_id) { a.sessions.push(s); } a.set_spawn_model(session_id, &model); a.status = if model.is_empty() { format!("resumed {}", &session_id[..8.min(session_id.len())]) } else { format!( "resumed {} on {model}", &session_id[..8.min(session_id.len())] ) }; } bind_new_pane(eui, app, t, Some(session_id)); } Err(e) => { app.lock().unwrap().status = format!("claude spawn failed: {e}"); return; } } eui.visible = true; eui.zoom_feed = false; } /// ctrl-↓ / `c`: attach the embedded pane to the *selected* session. The /// cheap cases (reveal/focus the live pane, spawn the first instance) are /// instant; only attaching to a different session kills + respawns claude. /// Returns false when nothing was attached because the liveness guard armed /// instead — the caller must then leave the overlay open, or the warning it /// just wrote is invisible and the confirming second press has nowhere to go. fn attach_selected(eui: &mut EmbedUi, app: &SharedApp) -> bool { enum Plan { Fresh, Reveal, Resume(String), Guard(String), } let plan = { let mut a = app.lock().unwrap(); a.filter_popup = None; match a.selected_key() { // Nothing anywhere yet → fresh `claude --session-id `. None => Plan::Fresh, Some(key) if a.embed_session.as_deref() == Some(key.as_str()) => { if eui.term.as_ref().is_some_and(|t| !t.exited()) { Plan::Reveal } else if a .sessions .iter() .find(|s| s.key == key) .is_some_and(|s| !s.entries.is_empty()) { // Pane died on its own session → respawn resuming it. Plan::Resume(key) } else { // Died before any turn: nothing to resume, start fresh. Plan::Fresh } } Some(key) => { let live = a.sessions.iter().any(|s| s.key == key); if live && !eui.past_embeds.contains(&key) { // External instance on our port; it may still be running // (an idle claude sends no traffic, so we can't know). Plan::Guard(key) } else { Plan::Resume(key) } } } }; match plan { Plan::Fresh => show_embed_pane(eui, app), Plan::Reveal => { eui.visible = true; eui.zoom_feed = false; } Plan::Resume(key) => { eui.force_resume = None; show_embed_resume(eui, app, &key); } Plan::Guard(key) => { let armed = eui .force_resume .as_ref() .is_some_and(|(k, t)| *k == key && t.elapsed() < Duration::from_secs(3)); if armed { eui.force_resume = None; show_embed_resume(eui, app, &key); } else { eui.force_resume = Some((key, Instant::now())); app.lock().unwrap().status = "session may be live in another claude instance — enter again to resume anyway" .into(); return false; } } } true } fn event_loop( terminal: &mut ratatui::DefaultTerminal, app: SharedApp, eui: &mut EmbedUi, ) -> anyhow::Result<()> { let mut sel: Option = None; let mut caches = FeedCaches::default(); // Last cursor shape pushed to the outer terminal; re-emit only on change so // a blinking cursor isn't reset to its non-blinking phase every frame. let mut applied_cursor: Option = None; loop { terminal.draw(|f| draw(f, &app, eui, &mut sel, &mut caches))?; // A drained ctrl-r takes over here — this call does not return when // it succeeds. try_reload(&app, eui, terminal); if eui.cursor_shape != applied_cursor { applied_cursor = eui.cursor_shape; let style = eui .cursor_shape .map_or(SetCursorStyle::DefaultUserShape, crate::term::cursor_style); let _ = execute!(std::io::stdout(), style); } // Scheduled transcript wipe (set by the tap when an embedded-session // turn finishes): keeps the *compact* pane prompt-only, because the // feed above it carries the context. Fullscreen is the opposite case — // the pane is all there is on screen and its transcript is what the // wheel scrolls back through — so the wipe is cancelled there rather // than deferred: firing it later would delete that history the moment // ctrl-f dropped out of fullscreen… let clear_due = { let mut a = app.lock().unwrap(); if a.embed_clear_at.is_some_and(|t| std::time::Instant::now() >= t) { a.embed_clear_at = None; // …and dropped outright while an editor holds the child's // screen (`alt_screen`): ctrl-l is meant for Claude Code's own // input, and sending it into nvim is not ours to do. !eui.fullscreen && !eui.alt_screen } else { false } }; // …and cancelled the same way when Claude Code has an error on screen: // the pane is the only place its own error rows are ever shown (a // tool_result reaches the feed with the *next* request, which a failed // turn never sends, and an `API Error` row is not in the stream at // all), and Ink redraws only the live frame — so a wipe here would // lose the error 400ms after it appeared. Dropping the schedule rather // than deferring it keeps the report until the next turn ends cleanly. if clear_due && let Some(et) = &eui.term && !et.exited() && !et.shows_error() { et.clear_screen(); } if !event::poll(Duration::from_millis(33))? { continue; } let ev = event::read()?; // Bracketed paste: forward the whole blob to the child as one paste // when the pane has focus (no submit on embedded newlines). Nowhere // else accepts text input, so ignore it otherwise. if let Event::Paste(text) = &ev { if eui.alive() && !app.lock().unwrap().overlay_open() && let Some(et) = &eui.term { et.paste(text); } continue; } // Wheel scroll drives the feed, regardless of which pane has keyboard // focus — except in fullscreen, where the feed is not on screen at all // and the wheel scrolls the pane's own scrollback instead, the way it // would in a plain terminal (`EmbedUi::scroll_pane`). // The agent popup is modal, so while it is up the wheel is *its*: // it moves the picker highlight or scrolls the agent's feed. // Left drag = text selection; the copy happens on release in draw(). if let Event::Mouse(m) = &ev { // `CT_DEBUG_KEYS=1` covers the mouse too: it separates "the // terminal sends no wheel event" from "the pane has no scrollback // to move into", which look identical on screen. if std::env::var_os("CT_DEBUG_KEYS").is_some() { let back = eui.term.as_ref().map_or(0, |t| t.scrolled_rows()); app.lock().unwrap().status = format!( "mouse: {:?} mods={:?} fullscreen={} back={back}", m.kind, m.modifiers, eui.fullscreen ); } match m.kind { MouseEventKind::ScrollUp => { if !eui.scroll_pane(-WHEEL_ROWS) { wheel(&app, -1); } } MouseEventKind::ScrollDown => { if !eui.scroll_pane(WHEEL_ROWS) { wheel(&app, 1); } } MouseEventKind::Down(MouseButton::Left) => { sel = Some(Selection { start: (m.column, m.row), end: (m.column, m.row), dragging: true, copy_pending: false, }); } MouseEventKind::Drag(MouseButton::Left) => { if let Some(s) = sel.as_mut() && s.dragging { s.end = (m.column, m.row); } } MouseEventKind::Up(MouseButton::Left) => { if let Some(s) = sel.as_mut() && s.dragging { s.dragging = false; if s.start == s.end { sel = None; // plain click, nothing to copy } else { s.copy_pending = true; } } } _ => {} } continue; } if let Event::Key(k) = ev { if k.kind == KeyEventKind::Release { continue; } // `CT_DEBUG_KEYS=1`: surface every key event in the status bar, // for diagnosing what the outer terminal actually delivers. if std::env::var_os("CT_DEBUG_KEYS").is_some() { app.lock().unwrap().status = format!("key: {:?} mods={:?} kind={:?}", k.code, k.modifiers, k.kind); } // --------------------------------------------------------- // Key routing. One rule: **unprefixed keys are the child's**. // Everything cloak owns sits behind the prefix (see `keymap`). // --------------------------------------------------------- if k.kind != KeyEventKind::Press { continue; } // 1. The which-key menu owns every key while it is up. if let Some(level) = eui.menu { // Prefix twice = send a literal prefix through, tmux-style. if eui.prefix.matches(&k) { eui.menu = None; if let Some(et) = &eui.term { et.follow_live(); et.key(k); } continue; } let act = match k.code { KeyCode::Char(c) => level.find(c).map(|b| b.act), _ => None, }; match act { Some(act) => { // Repeatable leaves keep the menu up so `]]]` walks. if !act.sticky() { eui.menu = None; } if run_act(act, eui, &app) == Flow::Quit { return Ok(()); } } // Esc and any unbound key close it. A menu you cannot // leave by mashing a key is a trap. None => eui.menu = None, } continue; } // 2. The prefix opens it. if eui.prefix.matches(&k) { eui.menu = Some(&ROOT); continue; } // ctrl-q is the one global outside the prefix, and it is there for // exactly one reason: if a terminal does not deliver the prefix at // all, the app would otherwise have no way out. Not a focus // workaround — there is no focus model left. if k.modifiers.contains(KeyModifiers::CONTROL) && k.code == KeyCode::Char('q') { return Ok(()); } // 3. shift+PgUp/PgDn is the terminal's own scroll key, so it is // ours the same way the wheel is — and it is handled *before* // the overlays, not after: the sessions overlay only takes the // bottom third of the screen, so the feed it is steering is // still on screen and still worth scrolling. if k.modifiers.contains(KeyModifiers::SHIFT) && matches!(k.code, KeyCode::PageUp | KeyCode::PageDown) { let dir = if k.code == KeyCode::PageUp { -1 } else { 1 }; let page = eui.term.as_ref().map_or(20, |t| t.page_rows()); if !eui.scroll_pane(dir * page) { app.lock().unwrap().scroll_col(None, dir * 20); } continue; } // 4. Overlays are modal: while one is up it takes every key. { let mut a = app.lock().unwrap(); if a.overlay_open() { if k.code == KeyCode::Esc { a.close_overlay(); continue; } if a.search.is_some() { search_key(&mut a, k.code); continue; } if a.filter_popup.is_some() { filter_key(&mut a, k.code); continue; } if a.streams_popup.is_some() { streams_key(&mut a, k.code); continue; } if let Some(msel) = a.model_popup { let choices = a.models.choices(); let n = choices.len().max(1); match k.code { KeyCode::Up | KeyCode::Char('k') => { a.model_popup = Some((msel + n - 1) % n) } KeyCode::Down | KeyCode::Char('j') => { a.model_popup = Some((msel + 1) % n) } KeyCode::Enter => { a.model_popup = None; let model = choices.get(msel).map(|c| c.1.clone()); drop(a); if let Some(model) = model { show_embed_new(eui, &app, &model); } } _ => {} } continue; } // The sessions overlay: the turn tree and branching live // in here now, which is the only home they have left. if sessions_key(&mut a, k.code) { continue; } drop(a); // Enter: point the *pane* at the highlighted session. Only // close on success — an armed liveness guard needs its // warning on screen and a second press to land. if attach_selected(eui, &app) { let mut a = app.lock().unwrap(); a.show_sessions = false; a.follow_pane = true; a.show_lane(MAIN_LANE); } continue; } } // 4b. `prefix Z` is the one non-overlay state that *covers* the // pane, so it answers to Esc for the same reason an overlay // does: the test is "is the pane reachable?", not "is this a // mode?". Fullscreen (`prefix z`) is the opposite case — // nothing is covering the pane there, so Esc goes through to // the child and interrupts, which is what it is for. if k.code == KeyCode::Esc && eui.zoom_feed { eui.zoom_feed = false; continue; } // 5. The child. Any key snaps its scrollback back to live first // (xterm's scroll-on-key), so typing can never leave you // reading history while the child answers off screen. if eui.alive() { if let Some(et) = &eui.term { et.follow_live(); et.key(k); } continue; } // 6. No pane on screen (none spawned yet, or `prefix Z`): the feed // takes the keys, so the app is still usable with no child. let mut a = app.lock().unwrap(); match k.code { KeyCode::Up | KeyCode::Char('k') => a.scroll_col(None, -1), KeyCode::Down | KeyCode::Char('j') => a.scroll_col(None, 1), KeyCode::PageUp => a.scroll_col(None, -20), KeyCode::PageDown => a.scroll_col(None, 20), KeyCode::Home | KeyCode::Char('g') => a.scroll_col_end(None, false), KeyCode::End | KeyCode::Char('G') => a.scroll_col_end(None, true), _ => {} } } } } /// What a menu action wants the event loop to do next. #[derive(PartialEq, Eq)] enum Flow { Done, Quit, } /// Run one binding from the table. The single dispatch point: the popup, the /// footer hint and this function all read `keymap::ROOT`, so a binding cannot /// exist in one and not the others. fn run_act(act: Act, eui: &mut EmbedUi, app: &SharedApp) -> Flow { match act { Act::Quit => return Flow::Quit, Act::Reload => start_reload(app), Act::ZoomPane => { eui.fullscreen = !eui.fullscreen; // An explicit choice outranks the alternate-screen restore. eui.alt_fullscreen = false; } Act::ZoomFeed => { eui.zoom_feed = !eui.zoom_feed; if eui.zoom_feed { eui.fullscreen = false; } } Act::NewSession => app.lock().unwrap().model_popup = Some(0), // The footer already spells the overlay's keys out; a status line // repeating them just says the same thing twice on one row. Act::Sessions => app.lock().unwrap().show_sessions = true, Act::Streams => app.lock().unwrap().open_streams(), Act::Filter => app.lock().unwrap().filter_popup = Some(0), Act::Search => { let mut a = app.lock().unwrap(); a.search = Some(Search::default()); } Act::NextPrompt | Act::PrevPrompt => { let mut a = app.lock().unwrap(); let lane = a.feed_lane; let at = a.lane_col_of(lane).0; a.set_lane_col(lane, at, false); a.prompt_jump = Some(act == Act::NextPrompt); } // "Live" means all four things at once, because being pinned is // never just one of them: a picked session, a picked lane, a scroll // position parked by a search or a prompt jump, and an expanded turn // tree all read as "the feed stopped moving". Re-arming the tail is // the part that was missing — without it the feed sits where you left // it and never catches up, even once the turn ends. Act::FollowLive => { let mut a = app.lock().unwrap(); a.follow_pane = true; a.expanded = None; a.show_lane(MAIN_LANE); if let Some(k) = a.embed_session.clone() { a.select_key(&k); } a.scroll_col_end(Some(MAIN_LANE), true); a.status = "following the live main chain".into(); } Act::Continue => { let mut a = app.lock().unwrap(); match a.disk_sessions.first().map(|d| d.uuid.clone()) { None => a.status = "no past sessions found for this directory".into(), Some(uuid) => { a.select_key(&uuid); a.clear_turn_focus(); drop(a); attach_selected(eui, app); } } } } Flow::Done } /// Keys of the search bar — a browser find bar, which is the interaction /// everyone already knows: typing re-runs the query from the top so the feed /// tracks what you type, and Enter/Down/Up walk the matches in place. /// /// Enter is deliberately *next match*, not "commit": the bar is where you walk /// hits, and Esc is how you leave with the position you landed on. fn search_key(a: &mut App, code: KeyCode) { match code { KeyCode::Char(c) => { if let Some(s) = a.search.as_mut() { s.query.push(c); // Restart from the top of the feed on every edit, so the first // hit of the new query is the one you see. s.at = None; } a.search_run(true); } KeyCode::Backspace => { if let Some(s) = a.search.as_mut() { s.query.pop(); s.at = None; } a.search_run(true); } KeyCode::Down | KeyCode::Enter | KeyCode::Tab => a.search_run(true), KeyCode::Up | KeyCode::BackTab => a.search_run(false), _ => {} } } fn filter_key(a: &mut App, code: KeyCode) { let Some(sel) = a.filter_popup else { return }; let n = FILTER_LABELS.len(); match code { KeyCode::Char(' ') | KeyCode::Enter => a.filters[sel] = !a.filters[sel], KeyCode::Up | KeyCode::Char('k') => a.filter_popup = Some((sel + n - 1) % n), KeyCode::Down | KeyCode::Char('j') => a.filter_popup = Some((sel + 1) % n), KeyCode::Char('a') => a.filters = [true; FILTER_LABELS.len()], KeyCode::Char('n') => a.filters = [false; FILTER_LABELS.len()], _ => {} } } /// Keys of the stream picker. j/k already shows the lane it moves to, so there /// is nothing left for Enter to commit — it and Esc both just close, keeping /// whatever you walked to. Same shape as the sessions overlay, minus the one /// thing that overlay's Enter does that this one has no equivalent for /// (attaching the pane). fn streams_key(a: &mut App, code: KeyCode) { match code { KeyCode::Up | KeyCode::Char('k') => a.streams_move(-1), KeyCode::Down | KeyCode::Char('j') => a.streams_move(1), KeyCode::Enter | KeyCode::Right | KeyCode::Char('l') => a.streams_popup = None, _ => {} } } /// Keys of the sessions overlay. Returns false for Enter — the one key that /// needs the `EmbedUi` the caller holds — and true for everything else. /// /// **Viewing needs no key at all.** The overlay is a bottom strip, so j/k /// already re-points the feed live as you walk the list; that is the /// view-without-resuming that `/resume` cannot do, and it happens by hovering /// rather than by committing. So Enter is free to be the thing you almost /// always want — attach the pane — and Esc keeps whatever you were reading /// (see `App::close_overlay`). fn sessions_key(a: &mut App, code: KeyCode) -> bool { let nsess = a.merged_len(); match code { // Enter is the commit and the only one: it attaches the pane. Handled // by the caller, which holds the `EmbedUi`. KeyCode::Enter => return false, KeyCode::Up | KeyCode::Char('k') => a.nav(false), KeyCode::Down | KeyCode::Char('j') => a.nav(true), KeyCode::Char(' ') => a.toggle_expand(), KeyCode::Right | KeyCode::Char('l') => a.tree_right(), KeyCode::Left | KeyCode::Char('h') => a.tree_left(), KeyCode::Char('v') => a.toggle_visual(), KeyCode::Char('b') => match a.branch_selected() { Ok(u) => { a.status = format!( "branched → {} (enter to start it)", u.chars().take(8).collect::() ); } Err(e) => a.status = e, }, KeyCode::Tab if nsess > 0 => { a.selected = (a.selected + 1) % nsess; a.clear_turn_focus(); } KeyCode::BackTab if nsess > 0 => { a.selected = (a.selected + nsess - 1) % nsess; a.clear_turn_focus(); } _ => {} } true } fn draw( f: &mut Frame, app: &SharedApp, eui: &mut EmbedUi, selection: &mut Option, caches: &mut FeedCaches, ) { let mut a = app.lock().unwrap(); // Embedded pane height: nothing when hidden. The pane is meant to be // prompt-only (the feed above shows the context; term.rs crops Claude // Code's hint/token/effort chrome), so a Compact pane auto-sizes to the // input box: it grows as the prompt gains lines or an `@`/`/` menu opens, // and shrinks back when idle (term::compact_rows measures it). An // Interactive prompt (AskUserQuestion / ExitPlanMode) is grown by the tap: // sized from the question's option count when it could estimate it, 75% of // the screen as fallback. const EMBED_MIN: u16 = 7; // MIN_COMPACT_INNER + borders // The pane is drawn whenever it exists and is not hidden — it is no longer // gated on the feed selection. That coupling only existed to keep keyboard // focus and the visible session in step; with the pane always holding the // keyboard there is nothing to keep in step, and decoupling them is the // point: the feed can show a past session, or a subagent's lane, while the // pane keeps running the live one. let show_embed = eui.visible && !eui.zoom_feed && eui.term.is_some(); if !show_embed { eui.fullscreen = false; } // The feed tails the pane, `tail -f` style. Only while nothing is open and // no turn tree is expanded: those are deliberate navigation, and yanking // the selection out from under them is what this rule exists to avoid. let overlay = a.overlay_open(); if a.follow_pane && !overlay && a.expanded.is_none() && let Some(k) = a.embed_session.clone() && a.selected_key().as_deref() != Some(k.as_str()) { a.select_key(&k); } // Mirror focus into shared state so the off-thread tap can avoid stealing // the selection from a pane the user is actively driving. let pane_keys = eui.takes_keys(overlay); a.pane_focused = pane_keys; // An editor Claude Code launched (nvim, `git commit`, a pager) takes the // child's screen over via the alternate buffer, which Claude Code never // does itself. So there is no input box left to frame — give the pane the // whole screen for as long as the editor lasts, and put it back after. // Gated on the pane actually taking keys, so an overlay opened mid-edit // hands the screen back to the feed and closing it returns to the editor. let on_alt = pane_keys && eui.term.as_ref().is_some_and(EmbeddedTerm::alt_screen); if sync_alt_screen(on_alt, &mut eui.alt_screen, &mut eui.fullscreen, &mut eui.alt_fullscreen) && let Some(et) = &eui.term { et.follow_live(); } let pane_view = if eui.fullscreen { PaneView::Full } else if a.embed_grow { PaneView::Interactive } else { PaneView::Compact }; let embed_h = if show_embed { let total = f.area().height; let cap = total.saturating_sub(6).max(1); match pane_view { // Whole screen minus the footer and the 1-row Min(1) the feed area // keeps (layout below still reserves it). PaneView::Full => total.saturating_sub(2), // Both cropped views size themselves from what the child actually // drew, smoothed by the same hysteresis so the PTY isn't resized // (→ Ink repaint → flicker) on every per-frame wobble. PaneView::Interactive => { // Until the prompt appears on screen (the tap grows the pane a // beat early) fall back to the tap's estimate from the tool // JSON, then to three quarters of the screen. let measured = eui.term.as_ref().and_then(EmbeddedTerm::interactive_rows).or(a .embed_grow_rows .map(|r| r.saturating_add(3))); // + borders + hint row let inner = eui.compact_height(measured); inner.saturating_add(2).clamp(EMBED_MIN.min(cap), cap) } PaneView::Compact => { let measured = eui.term.as_ref().and_then(EmbeddedTerm::compact_rows); let inner = eui.compact_height(measured); inner.saturating_add(2).clamp(EMBED_MIN.min(cap), cap) } } } else { 0 }; // Keyboard focus lives in exactly one place: the claude pane (when it has // focus) or the feed/sessions area otherwise. One rule for every panel: // focused → bold accent border, unfocused → dimmed. So the feed (and the // sessions panel, if shown) and the pane all read the same way, and the // dimming tells you at a glance which side ctrl-↑/ctrl-↓ left focus on. let border_style = |focused: bool| { if focused { Style::new().fg(ACCENT).bold() } else { Style::new().dark_gray() } }; let feed_focused = !pane_keys; let [main, embed_area, footer] = Layout::vertical([ Constraint::Min(1), Constraint::Length(embed_h), Constraint::Length(1), ]) .areas(f.area()); // One region, full width. The sessions panel is gone: it is an overlay // now (`prefix s`), so the feed never gives up half the screen to a list // you look at for a few seconds at a time. let right = main; let live_n = a.sessions.len(); let stubs = a.visible_stubs(); let sel = a.selected.min((live_n + stubs.len()).saturating_sub(1)); a.selected = sel; let sel_key = a.selected_key(); // Agent scroll state belongs to the displayed session: a switch drops it // (and closes the popup), so a lane id can never inherit another session's // offset or, worse, point into another session's lane vec. if a.cols_session != sel_key.clone().unwrap_or_default() { a.cols_session = sel_key.clone().unwrap_or_default(); a.lane_cols.clear(); a.streams_popup = None; a.feed_lane = MAIN_LANE; } // What the feed shows: a highlighted turn views the on-disk transcript // along the path *through that turn* (works for live sessions too — pure // viewing); a stub selection views its whole file; otherwise the live // in-memory session. Path views are cached per uuid and rebuilt only // when the requested leaf changes (a few ms even for MB-sized files). let turn_view: Option = a .expanded .as_ref() .filter(|e| sel_key.as_deref() == Some(e.uuid.as_str())) .and_then(|e| e.sel.map(|p| e.tree.display[p])); let hist_view: Option<(String, Option)> = match (&sel_key, turn_view) { (Some(u), Some(t)) => { let tree = &a.expanded.as_ref().unwrap().tree; Some((u.clone(), Some(tree.trunk_leaf(t)))) } (Some(u), None) if sel >= live_n => Some((u.clone(), None)), _ => None, }; if let Some((u, leaf)) = &hist_view && a.history.get(u).is_none_or(|h| h.leaf != *leaf) { let aref = &mut *a; let built = match (*leaf, aref.expanded.as_ref()) { (Some(l), Some(e)) => crate::sessions::load_view(u, Some((&e.tree, l))), _ => crate::sessions::load_view(u, None), }; let h = built.unwrap_or_else(|| { let mut s = Session::new(u.clone(), "(disk)".into()); s.entries.push(Entry::meta("(empty or unreadable transcript)".into())); crate::sessions::HistoryView { session: s, leaf: *leaf, turn_entries: Vec::new() } }); aref.history.insert(u.clone(), h); } // Feed let filters = a.filters; // Scroll the feed to the highlighted turn's first entry, once per // highlight move (the offset needs the freshly cached entry heights). let scroll_target: Option = match (a.turn_dirty, &hist_view, turn_view) { (true, Some((u, _)), Some(t)) => a .history .get(u) .and_then(|h| h.turn_entries.iter().find(|(ti, _)| *ti == t)) .map(|&(_, e)| e), _ => None, }; a.turn_dirty = false; // `n`/`N`: jump to the next/previous user prompt. Taken once, here, before // the feed borrow so we can clear it (offset computed below from the cache). let prompt_jump = a.prompt_jump.take(); // Keep the feed (and the picker) pointed at a lane that exists: a session // switch or a rebuilt on-disk view can invalidate either. Done before the // feed borrow, which freezes `a`. a.validate_lanes(); // Two things ask for a scroll by entry index, and they want different // landings: a turn jump pins the prompt's top, a search hit wants the // matching row. Only `scroll_entry` is set by search, so the source is the // flag. Either way the offset needs heights that only the render cache has. let search_jump = a.scroll_entry.take(); let scroll_to_match = search_jump.is_some(); let scroll_target = search_jump.or(scroll_target); let (feed_session, feed_leaf): (Option<&Session>, Option) = match &hist_view { Some((u, _)) => { let h = a.history.get(u); (h.map(|h| &h.session), h.and_then(|h| h.leaf)) } None => (a.sessions.get(sel), None), }; let feed_live = hist_view.is_none(); // Scroll state written back after the feed borrow ends. One feed, one // lane: `lane_col_of` resolves `MAIN_LANE` to `scroll`/`follow` and every // other lane to its own `lane_cols` entry, so each stream keeps its place. let lane = a.feed_lane; let mut col = a.lane_col_of(lane); // Matches light up only while the search overlay is up: that overlay *is* // the search, and walking hits happens inside it (Up/Down/Enter). Leaving // keeps the position you landed on and drops the paint, so there is no // stale highlight to clear and no `:nohlsearch` to remember. let query: Option = a .search .as_ref() .map(|s| s.query.to_lowercase()) .filter(|q| !q.is_empty()); if let Some(s) = feed_session { // The one feed, full width, showing exactly one lane. Which lane is a // filter (`draw_feed` matches `e.lane == args.lane`), never an // interleave — that part of the old design is unchanged; what is gone // is the second feed that used to render agents in a popup. let lane = if (lane as usize) < s.lanes.len() { lane } else { MAIN_LANE }; let title = if lane == MAIN_LANE { main_title(s) } else { let list = App::stream_list_of(s); let pos = list.iter().position(|&x| x == lane).map(|i| (i + 1, list.len())); lane_title(&s.lanes[lane as usize], pos) }; let out = draw_feed( f, caches.get(&s.key, lane), &FeedArgs { s, lane, area: right, focused: feed_focused && !overlay, filters, live: feed_live, leaf: feed_leaf, scroll: col.0, follow: col.1, scroll_target, prompt_jump, search: query.as_deref(), scroll_to_match, title, // A subagent's stream has no user prompts, so no minimap and // no prompt jumps to mark. minimap: lane == MAIN_LANE, }, ); col = (out.scroll, out.follow); } else { f.render_widget( Paragraph::new(format!( "\n\n waiting for traffic…\n\n make sure claude runs with:\n ANTHROPIC_BASE_URL=http://127.0.0.1:{}", eui.port )) .dark_gray() .block(Block::bordered().border_style(border_style(feed_focused))), right, ); } a.set_lane_col(lane, col.0, col.1); // Embedded claude pane let embed_focused = pane_keys; // Cursor shape the pane wants this frame (None unless it draws a cursor). let mut want_cursor: Option = None; if show_embed { let et = eui.term.as_mut().unwrap(); let exited = et.exited(); let id: String = et.session_id.chars().take(8).collect(); // A scrolled-back fullscreen pane says how far up it sits, so a screen // that stopped moving reads as "you scrolled", not "the child stalled". let back = et.scrolled_rows(); let title = if exited { format!(" claude · {id} · exited ") } else if back > 0 { format!(" claude · {id} · ↑{back} rows · any key = live ") } else { format!(" claude · {id} ") }; // Bold accent border = keyboard focus; dimmed = unfocused (same rule // as the feed/sessions panels above). let block = Block::bordered().title(title).border_style(border_style(embed_focused)); let inner = block.inner(embed_area); f.render_widget(block, embed_area); if exited { f.render_widget( Paragraph::new("\n claude exited — F2 to close this pane") .dark_gray(), inner, ); } else { // Fullscreen shows the child's screen verbatim (PTY sized exactly, // no chrome crop); the compact/interactive pane crops Claude Code // chrome, so the PTY gets pad rows to draw what we hide. // // Only fullscreen ties the PTY to the visible pane. Every cropped // view derives its height by measuring what's already on the // child's screen, so tying the PTY to that height is a feedback // loop — Ink only ever draws as many rows as the PTY reports, so a // view that suddenly needs more rows than PTY+pad can never draw // them, can never be measured, and stays stuck small. // // Compact hits this with an `@`/`/` menu or a big paste. // Interactive hits it harder: Claude Code *lays out* its // AskUserQuestion / ExitPlanMode prompt against the reported rows // and switches to a truncated rendering when they are few, so a // pane-sized PTY made the child itself hide parts of the prompt — // no amount of framing on our side could bring them back. A // screen-tall PTY lets Ink draw the prompt in full; the render // window still shows only the framed region. // Only fullscreen has a scrollback view, so leaving it (ctrl-f, // ctrl-↑, F2, a session switch — every exit route) drops back to // the live screen here, rather than at each of those call sites. if pane_view != PaneView::Full { et.follow_live(); } let pty_rows = if pane_view == PaneView::Full { inner.height } else { f.area().height }; et.resize(pty_rows, inner.width, pane_view != PaneView::Full); if let Some(pos) = et.render(inner, f.buffer_mut(), pane_view) { f.set_cursor_position(pos); want_cursor = Some(et.cursor_shape()); } } } eui.cursor_shape = want_cursor; // Footer. The hint is derived from the binding table, not written out // beside it: the whole point of one table is that the hint cannot drift // from what the keys actually do. let visual_on = a.expanded.as_ref().is_some_and(|e| e.visual.is_some()); let px = &eui.prefix.label; let keys = if a.search.is_some() { "enter/↓ next · ↑ prev · esc close".into() } else if a.filter_popup.is_some() { "space toggle · a all · n none · j/k move · esc close".into() } else if a.model_popup.is_some() { "enter new session · j/k move · esc cancel".into() } else if a.streams_popup.is_some() { "j/k preview · enter/esc close".into() } else if visual_on { "j/k extend · b branch selection · esc cancel".into() } else if a.show_sessions { "enter open in claude · space tree · v visual · b branch · esc keep viewing".into() } else if pane_view == PaneView::Full { // Fullscreen owns the wheel: the feed is off screen, and scrolling // walks the child's own scrollback instead. format!("{px} z exit zoom · wheel/shift-PgUp scroll claude · {px} menu") } else { format!("{px} menu · wheel/shift-PgUp scroll feed · keys go to claude") }; // Two things that are worth knowing at a glance and are not keys: how many // side streams the session has (the only route to them is `prefix a`), and // whether the feed is showing something other than the live main chain — // because nothing else on screen says "you are not looking at the pane". let n_lanes = a.agent_list().len(); let mut lead = String::new(); if !a.follow_pane || a.feed_lane != MAIN_LANE { lead.push_str(&format!("⇤ pinned · {px} . to follow · ")); } if n_lanes > 0 && !a.overlay_open() { lead.push_str(&format!("{px} a streams ({n_lanes}) · ")); } let keys = format!("{lead}{keys}"); // A rebuild in flight outranks the proxy line: it is the only thing in // the app the user is actively waiting on. let status = match a.reload.note(a.in_flight()) { Some(n) => format!("{n} · {}", a.status), None => a.status.clone(), }; f.render_widget( Paragraph::new(Line::from(format!(" {status} | {keys}")).dark_gray()), footer, ); // Overlays. They normally anchor to the bottom of the *feed*, which puts // them just above the pane — but in fullscreen the feed area is the one // row the layout reserves, and a strip drawn into it is invisible. That is // what made `prefix z` look like a trap: the menu telling you `z` gets you // back out was being rendered one row tall behind the pane. So fullscreen // anchors overlays to the screen instead (everything above the footer). let overlay_area = if pane_view == PaneView::Full { Rect { height: f.area().height.saturating_sub(1), ..f.area() } } else { main }; if a.show_sessions { draw_sessions(f, list_rect(overlay_area), &a, live_n, sel); } if let Some(sr) = a.search.as_ref() { draw_search(f, overlay_area, sr); } if let Some(sel) = a.streams_popup && let Some(sess) = a.displayed_session() { let lanes = App::stream_list_of(sess); draw_streams(f, streams_rect(overlay_area, lanes.len()), sess, &lanes, sel); } if let Some(fsel) = a.filter_popup { let w = 26u16.min(overlay_area.width); let h = (FILTER_LABELS.len() as u16 + 2).min(overlay_area.height); f.render_widget(Clear, centred(overlay_area, w, h)); let items: Vec = FILTER_LABELS .iter() .enumerate() .map(|(i, name)| { let mark = if a.filters[i] { "[x]" } else { "[ ]" }; ListItem::new(format!(" {mark} {name}")) }) .collect(); let mut ls = ListState::default(); ls.select(Some(fsel)); f.render_stateful_widget( List::new(items) .block(Block::bordered().title(" filter ").border_style(Style::new().fg(ACCENT))) .highlight_style(ratatui::style::Style::new().reversed()), centred(overlay_area, w, h), &mut ls, ); } if let Some(msel) = a.model_popup { let choices = a.models.choices(); let w = 30u16.min(overlay_area.width); let h = (choices.len() as u16 + 2).min(overlay_area.height); let area = centred(overlay_area, w, h); f.render_widget(Clear, area); let items: Vec = choices .iter() .map(|(label, _)| ListItem::new(format!(" {label}"))) .collect(); let mut ls = ListState::default(); ls.select(Some(msel)); f.render_stateful_widget( List::new(items) .block( Block::bordered() .title(" new session ") .border_style(Style::new().fg(ACCENT)), ) .highlight_style(ratatui::style::Style::new().reversed()), area, &mut ls, ); } // The which-key popup is drawn last and lowest: it is a hint, not a // window, so it sits over the pane rather than over what you are reading. if let Some(level) = eui.menu { draw_menu(f, overlay_area, level, &eui.prefix.label); } drop(a); // release the app lock before the mouse-selection pass // Mouse selection: while dragging, paint a reversed-video highlight over // the (fully rendered) buffer; on release, read the selected cells out of // the buffer instead and copy them to the clipboard. if let Some(s) = selection.take() { // Linear (terminal-style) selection: order endpoints by (row, col). let (mut from, mut to) = (s.start, s.end); if (from.1, from.0) > (to.1, to.0) { std::mem::swap(&mut from, &mut to); } let buf = f.buffer_mut(); // Clip the selection to one panel's *inner* content box so a multi-row // sweep grabs only that panel's text — never an adjacent panel or the // box-drawing borders between them (a linear selection otherwise spans // the full screen width on its middle rows, which is what produced the // border/padding artefacts). The panel is chosen by where the drag // began, hit-tested against each panel's *outer* rect so starting on a // border or in the margin still resolves to the panel; the feed is the // default, since that is what prose gets copied from. let contains = |r: Rect, p: (u16, u16)| { p.0 >= r.left() && p.0 < r.right() && p.1 >= r.top() && p.1 < r.bottom() }; let outer = if embed_h > 0 && contains(embed_area, s.start) { embed_area } else { right }; // Inner content box = the bordered rect minus its 1-cell border. let region = Rect { x: outer.x.saturating_add(1), y: outer.y.saturating_add(1), width: outer.width.saturating_sub(2), height: outer.height.saturating_sub(2), }; let (clip_l, clip_r, clip_t, clip_b) = ( region.left(), region.right().saturating_sub(1), region.top(), region.bottom().saturating_sub(1), ); let y_start = from.1.max(clip_t); let y_end = to.1.min(clip_b); let mut lines: Vec = Vec::new(); for y in y_start..=y_end { let x_from = (if y == from.1 { from.0 } else { clip_l }).max(clip_l); let x_to = (if y == to.1 { to.0 } else { clip_r }).min(clip_r); if x_from > x_to { if s.copy_pending { lines.push(String::new()); } continue; } let mut line = String::new(); for x in x_from..=x_to { if let Some(c) = buf.cell_mut(Position::new(x, y)) { if s.copy_pending { line.push_str(c.symbol()); } else { c.set_style(Style::new().add_modifier(Modifier::REVERSED)); } } } if s.copy_pending { // Drop trailing padding spaces (the buffer is space-filled to // the panel width) so only real text — and its line breaks — // ends up on the clipboard. lines.push(line.trim_end().to_string()); } } if s.copy_pending { // Trim blank rows off both ends: the empty padding lines above and // below the text would otherwise paste as stray newlines (each one // a submit in a prompt). Interior blanks (paragraph breaks) stay. while lines.first().is_some_and(|l| l.is_empty()) { lines.remove(0); } while lines.last().is_some_and(|l| l.is_empty()) { lines.pop(); } let copied = lines.join("\n"); osc52_copy(&copied); app.lock().unwrap().status = format!("copied {} chars to clipboard", copied.chars().count()); // selection stays None: the highlight disappears with the copy. } else { *selection = Some(s); } } } /// The sessions overlay (`prefix s`): live sessions first, then this /// directory's past sessions as dimmed stubs, with the selected session's turn /// tree expanded under it (abandoned rewind branches indented `⑂` under their /// fork point). /// /// This was a permanent half-width panel. It is an overlay because that is /// what it always behaved like — you look at it for a few seconds, pick /// something, and go back to reading — and because `enter` here *views* a /// session without touching a process, which is the one thing Claude Code's /// own `/resume` picker cannot do. fn draw_sessions(f: &mut Frame, area: Rect, a: &App, live_n: usize, sel: usize) { f.render_widget(Clear, area); let stubs = a.visible_stubs(); // Inner content width (panel minus its border): titles wrap to this, // turn labels truncate to it. let inner_w = area.width.saturating_sub(2).max(1) as usize; let mut items: Vec = Vec::new(); let mut flat_sel = 0usize; let vis_range = a.expanded.as_ref().and_then(|e| { let (av, p) = (e.visual?, e.sel?); Some((av.min(p), av.max(p))) }); let white = Style::new().fg(Color::White); // The session whose `claude` child we spawned and is still alive: the // one running instance this app owns (cleared the moment the pane // exits). It gets a bright accent marker + accent title so it reads as // "running here" at a glance; external live sessions only show a green // dot while they're actively streaming (their instance may have ended — // liveness is unknowable), and disk stubs stay dimmed. let embed_key = a.embed_session.clone(); for m in 0..live_n + stubs.len() { // Every session is a multi-line item: the full title (white, // wrapped to the panel width — continuation rows aligned under // it) followed by a dimmed meta row (status dot + id + model for // live sessions, just the id for disk stubs). let (uuid, lead, title, meta, title_style) = if m < live_n { let s = &a.sessions[m]; let is_embed = embed_key.as_deref() == Some(s.key.as_str()); let lead = if is_embed { "▶ ".fg(ACCENT).bold() } else if s.active > 0 { "● ".green() } else { "○ ".dark_gray() }; let id: String = s.key.chars().take(8).collect(); let meta = if is_embed { format!("{id} · {} · running", short_model(&s.main().model)) } else { format!("{id} · {}", short_model(&s.main().model)) }; let title_style = if is_embed { Style::new().fg(ACCENT).bold() } else { white }; // Claude Code's own name for the session, exactly as its // `/resume` picker reads it; the feed's first prompt only // covers the gap before the transcript names it. let title: String = a .cc_title(&s.key) .map(str::to_string) .unwrap_or_else(|| live_title(s)); (s.key.clone(), lead, title, meta, title_style) } else { let d = &a.disk_sessions[stubs[m - live_n]]; let id: String = d.uuid.chars().take(8).collect(); // ⑂N = subagent transcripts recorded next to this session. let meta = if d.agents > 0 { format!("{id} · ⑂{}", d.agents) } else { id }; ( d.uuid.clone(), "· ".dark_gray(), d.label(), meta, white, ) }; let mut rows: Vec = Vec::new(); for (i, w) in wrap_words(&sanitize(&title), inner_w.saturating_sub(2)) .into_iter() .enumerate() { if i == 0 { rows.push(Line::from(vec![lead.clone(), Span::styled(w, title_style)])); } else { rows.push(Line::from(Span::styled(format!(" {w}"), title_style))); } } rows.push(Line::from(format!(" {meta}")).dark_gray()); let on_sel_row = m == sel; let turn_hl = a .expanded .as_ref() .filter(|e| e.uuid == uuid) .and_then(|e| e.sel); if on_sel_row && turn_hl.is_none() { flat_sel = items.len(); } items.push(ListItem::new(rows)); if let Some(e) = a.expanded.as_ref().filter(|e| e.uuid == uuid) { for (p, &t) in e.tree.display.iter().enumerate() { let turn = &e.tree.turns[t]; let bullet = if turn.depth > 0 { "⑂" } else { "❯" }; // Indent turns past the title gutter, then by tree depth; // labels truncate (never wrap) so one row = one turn. let prefix = format!(" {}{bullet} ", " ".repeat(turn.depth.min(6))); let avail = inner_w.saturating_sub(prefix.chars().count()); let txt = format!("{prefix}{}", truncate_str(&turn.label, avail)); let line = if vis_range.is_some_and(|(lo, hi)| p >= lo && p <= hi) { Line::from(txt).style(Style::new().bg(USER_BG).fg(color_on(USER_BG))) } else { Line::from(txt).dark_gray() }; if on_sel_row && turn_hl == Some(p) { flat_sel = items.len(); } items.push(ListItem::new(line)); } } } let mut ls = ListState::default(); if !items.is_empty() { ls.select(Some(flat_sel)); } f.render_stateful_widget( List::new(items) .block( Block::bordered() .title(" sessions ") .border_style(Style::new().fg(ACCENT).bold()), ) .highlight_style(ratatui::style::Style::new().reversed()), area, &mut ls, ); } /// Share of the feed a list overlay takes. A third leaves the feed readable /// above it, which is the whole point of anchoring these low: moving the /// highlight re-points the feed, and you have to be able to *see* that. const LIST_PCT: u16 = 33; /// Floor for that strip — border plus two multi-line items. Below this the /// list shows one row and is useless. const LIST_MIN: u16 = 8; /// The search bar's rect: the same full-width bottom strip as the sessions /// overlay, three rows tall. It has to be *visible* — a query that lives only /// in the footer reads as nothing happening at all. fn search_rect(area: Rect) -> Rect { bottom_rect(area, 3) } /// A full-width strip `h` rows tall, flush with the bottom of `area`. fn bottom_rect(area: Rect, h: u16) -> Rect { let h = h.min(area.height); Rect { x: area.x, y: area.bottom().saturating_sub(h), width: area.width, height: h, } } /// A list overlay's rect: full width, bottom-anchored, a third of the feed /// tall. Both pickers use it, and neither is a centred box on purpose — a /// centred box would cover the feed it is steering. fn list_rect(area: Rect) -> Rect { let h = (area.height * LIST_PCT / 100).max(LIST_MIN.min(area.height)); bottom_rect(area, h) } /// Rows one stream row occupies: a head line and a dimmed meta line. const STREAM_ROWS: usize = 2; /// The stream picker shrinks to what it holds, capped at `list_rect`. /// /// The session list cannot do this — its items wrap to an unknown number of /// rows and there are usually dozens — but a stream list is exactly two rows /// per lane and a turn often fans out to two or three, so a fixed third of the /// screen is mostly blank space taken from the feed above it. Growing past the /// cap is what scrolls (`ListState` keeps the highlight in view). fn streams_rect(area: Rect, lanes: usize) -> Rect { let want = (lanes * STREAM_ROWS + 2) as u16; bottom_rect(area, want.min(list_rect(area).height)) } /// A `w`×`h` rect centred in `area`, clamped to it. fn centred(area: Rect, w: u16, h: u16) -> Rect { let w = w.min(area.width); let h = h.min(area.height); Rect { x: area.x + (area.width - w) / 2, y: area.y + (area.height - h) / 2, width: w, height: h, } } /// The search bar: the query on the left, `3/12` on the right. A block caret /// after the text rather than a real terminal cursor, so this never argues /// with the pane's own DECSCUSR mirroring. fn draw_search(f: &mut Frame, area: Rect, sr: &Search) { let rect = search_rect(area); f.render_widget(Clear, rect); let inner_w = rect.width.saturating_sub(2) as usize; let count = if sr.query.is_empty() { String::new() } else if sr.hits == 0 { "no matches".into() } else { format!("{}/{}", sr.pos, sr.hits) }; let count_style = if sr.hits == 0 && !sr.query.is_empty() { Style::new().red() } else { Style::new().dark_gray() }; let left = format!(" /{}", sr.query); let pad = inner_w .saturating_sub(left.chars().count() + 1 + count.chars().count()) .max(1); let line = Line::from(vec![ Span::styled(left, Style::new().fg(Color::White)), // Caret: a reversed space, so it reads as an input field. Span::styled(" ", Style::new().add_modifier(Modifier::REVERSED)), Span::raw(" ".repeat(pad)), Span::styled(count, count_style), ]); f.render_widget( Paragraph::new(line).block( Block::bordered() .title(" search ") .border_style(Style::new().fg(ACCENT).bold()), ), rect, ); } /// Rows the which-key popup needs for `n` bindings at `cols` columns. fn menu_rows(n: usize, cols: usize) -> u16 { n.div_ceil(cols.max(1)) as u16 } /// The which-key popup: the binding table, laid out in columns, anchored to /// the bottom of `area` so it covers the pane rather than what you are reading. /// /// It is a *hint*, not a mode: the keys work the moment the prefix is pressed, /// whether or not you wait for this to appear. Any unbound key closes it, so /// it can never trap you. fn draw_menu(f: &mut Frame, area: Rect, level: &'static Menu, prefix: &str) { // Widest label decides the column width; 3 columns unless the terminal is // too narrow for them. let cell = level .binds .iter() .map(|b| b.label.chars().count() + 6) .max() .unwrap_or(12); let cols = ((area.width.saturating_sub(2) as usize) / cell.max(1)).clamp(1, 4); let rows = menu_rows(level.binds.len(), cols); let rect = bottom_rect(area, rows + 2); f.render_widget(Clear, rect); let mut lines: Vec = Vec::new(); for r in 0..rows as usize { let mut spans: Vec = vec![Span::raw(" ")]; for c in 0..cols { // Column-major, so a menu reads down then across. let Some(b) = level.binds.get(c * rows as usize + r) else { continue; }; let mut cellw = cell; let label = if b.act.opens() { format!("{} ▸", b.label) } else { b.label.to_string() }; spans.push(Span::styled( b.key.to_string(), Style::new().fg(ACCENT).bold(), )); spans.push(Span::raw(" ")); spans.push(Span::styled(label.clone(), Style::new().fg(Color::White))); cellw = cellw.saturating_sub(label.chars().count() + 2); spans.push(Span::raw(" ".repeat(cellw))); } lines.push(Line::from(spans)); } let title = if level.title.is_empty() { format!(" {prefix} ") } else { format!(" {prefix} {} ", level.title) }; f.render_widget( Paragraph::new(Text::from(lines)) .block(Block::bordered().title(title).border_style(Style::new().fg(ACCENT))), rect, ); } /// Title of the main feed: session id, the main chain's model and its token /// counters. fn main_title(s: &Session) -> String { let m = s.main(); let tokens = format!( "{} · in {} · out {} ", m.model, fmt_tokens(m.input_tokens), fmt_tokens(m.output_tokens) ); let id: String = s.key.chars().take(8).collect(); format!(" {id} · {tokens}") } /// Title of an agent's popup feed: activity mark, agent type · description, /// model, output tokens — plus `2/3` when there are siblings for `[`/`]` to /// walk to. fn lane_title(l: &Lane, pos: Option<(usize, usize)>) -> String { let mut t = format!( " {} {} · {} · {}{}", lane_mark(l), l.title(), short_model(&l.model), lane_tokens(l), lane_dur(l), ); if let Some((i, n)) = pos.filter(|&(_, n)| n > 1) { t.push_str(&format!(" · {i}/{n}")); } t.push(' '); t } /// Token total for a lane. A ``'s `` block is Claude /// Code's own accounting for the *whole* agent run, so it wins over the /// `output_tokens` we summed off the wire — which covers only the turns that /// passed through us, and is zero for a lane spliced in from disk (the /// transcript records no usage). Without one, the wire count is still shown, /// labelled `out` to say so. fn lane_tokens(l: &Lane) -> String { match l.subagent_tokens { Some(t) => format!("{} tok", fmt_tokens(t)), None => format!("out {}", fmt_tokens(l.output_tokens)), } } /// ` · 18m35s` when a notification reported the run's wall time, else nothing. fn lane_dur(l: &Lane) -> String { l.duration_ms.map(|d| format!(" · {}", fmt_ms(d))).unwrap_or_default() } /// Activity mark, the same three-way answer the picker sorts on /// (`Lane::running`): running now, known finished, or neither (idle without a /// finish signal, or a lane read from disk). fn lane_mark(l: &Lane) -> &'static str { // A nested server-tool call is not an agent: the tool glyph the feed // already uses for a tool call says so at a glance. It is a single request, // and no `` will ever confirm it, so the three-way // running/finished/idle answer has nothing to add — the picker still reads // its liveness from the accent styling and the running-first order. if l.is_server_tool() { return "⚙"; } if l.running() { "⟳" } else if l.finished() { // A `` confirmed the run is over. "✓" } else { "·" } } /// The popup's agent picker (shown when the session has more than one agent): /// two lines per agent — mark + `type · description` over a dimmed /// model/tools/tokens row — in `App::agent_list_of` order, so the running ones /// come first and read bright while finished ones stay reachable below. fn draw_streams(f: &mut Frame, area: Rect, s: &Session, lanes: &[LaneId], sel: usize) { f.render_widget(Clear, area); let inner_w = area.width.saturating_sub(3) as usize; let items: Vec = lanes .iter() .map(|&i| { let l = &s.lanes[i as usize]; let style = if l.running() { Style::new().fg(ACCENT).bold() } else { Style::new().fg(Color::White) }; // No "currently shown" marker: the highlight *is* that, because // moving it shows the lane (`App::streams_move`). let head = if i == MAIN_LANE { " · main chain".to_string() } else { format!(" {} {}", lane_mark(l), l.title()) }; // Same substitution as `lane_tokens`: a notification's `` // counted the whole run, our own tally only what we saw. let meta = format!( " {} · {} tools · {}{}", short_model(&l.model), l.tool_uses.unwrap_or(l.tool_calls as u64), lane_tokens(l), lane_dur(l), ); ListItem::new(vec![ Line::from(truncate_str(&head, inner_w)).style(style), Line::from(truncate_str(&meta, inner_w)).dark_gray(), ]) }) .collect(); // `main` is never counted as running: it is not a side stream, and the // header answers "how many agents are still going". let running = lanes .iter() .filter(|&&i| i != MAIN_LANE && s.lanes[i as usize].running()) .count(); let mut ls = ListState::default(); ls.select(Some(sel.min(lanes.len().saturating_sub(1)))); f.render_stateful_widget( List::new(items) .block( Block::bordered() .title(format!( " streams · {running} of {} running ", lanes.len().saturating_sub(1) )) .border_style(Style::new().fg(ACCENT).bold()), ) .highlight_style(Style::new().reversed()), area, &mut ls, ); } /// Everything one feed needs. There is exactly one feed now — which lane it /// shows is `lane`, chosen in the stream picker — but the bundle stays because /// the argument list is long and mostly stable. struct FeedArgs<'a> { s: &'a Session, lane: LaneId, area: Rect, focused: bool, filters: [bool; FILTER_LABELS.len()], live: bool, leaf: Option, scroll: usize, follow: bool, /// Pin a turn's first entry to the top (main feed only). scroll_target: Option, /// Active search query, already lowercased: every occurrence is painted in /// the rendered lines (`highlight_lines`) and it is part of the cache /// fingerprint, so editing the query re-renders. search: Option<&'a str>, /// `scroll_target` came from a search hit, so scroll to the matching /// *line* inside that entry rather than to the entry's top. A turn jump /// wants the top (it is pinning a prompt, whose match is its first line); /// a search hit can sit hundreds of rows into a long tool result, where /// pinning the top shows no match at all and reads as "nothing found". scroll_to_match: bool, /// `n`/`N` prompt jump (main feed only). prompt_jump: Option, title: String, /// Draw the user-prompt minimap on the right border (main feed only). minimap: bool, } struct FeedOut { scroll: usize, follow: bool, } /// Render one lane of a session as a scrollable feed, returning its clamped /// scroll state. Only the entries of `args.lane` are considered — that filter /// is what keeps subagents out of the main feed entirely, and what lets the /// popup show one agent's stream as its own conversation, following its own /// tail. fn draw_feed(f: &mut Frame, cache: &mut FeedCache, args: &FeedArgs) -> FeedOut { let mut new_scroll = args.scroll; let mut new_follow = args.follow; let s = args.s; if args.area.height < 3 || args.area.width < 4 { return FeedOut { scroll: new_scroll, follow: new_follow, }; } let feed_width = args.area.width.saturating_sub(2); let qhash = query_hash(args.search); // (In)validate the render cache: a width change, session switch, or // a different transcript view of the same session (live vs on-disk, // another tree path) invalidates everything, otherwise only entries // whose fingerprint changed (the in-flight one, or a tool entry // whose result attached) are re-rendered. if cache.session_key != s.key || cache.width != feed_width || cache.leaf != args.leaf || cache.live != args.live || cache.lane != args.lane { cache.session_key = s.key.clone(); cache.width = feed_width; cache.leaf = args.leaf; cache.live = args.live; cache.lane = args.lane; cache.entries.clear(); } for (i, e) in s.entries.iter().enumerate() { if e.lane != args.lane { if cache.entries.len() <= i { cache.entries.push(CachedEntry::blank()); } continue; } let fp = fingerprint(e, args.focused, qhash); if cache.entries.get(i).is_none_or(|c| c.fingerprint != fp) { let lines = entry_lines(e, feed_width, args.focused, args.search); let height = wrapped_height(&lines, feed_width); let ce = CachedEntry { fingerprint: fp, lines, height, }; if i < cache.entries.len() { cache.entries[i] = ce; } else { cache.entries.push(ce); } } } // Visible (filter-passing) entries of this lane and their total height. let visible: Vec = s .entries .iter() .enumerate() .filter(|(_, e)| e.lane == args.lane && args.filters[filter_index(&e.kind)]) .map(|(i, _)| i) .collect(); let total: usize = visible.iter().map(|&i| cache.entries[i].height).sum(); let height = args.area.height.saturating_sub(2) as usize; let max_scroll = total.saturating_sub(height); new_scroll = if args.follow { max_scroll } else { args.scroll.min(max_scroll) }; if let Some(target) = args.scroll_target { // Rows above the target entry. let mut off = visible .iter() .take_while(|&&i| i < target) .map(|&i| cache.entries[i].height) .sum::(); // A search hit refines that to the matching row *inside* the entry: // the highlight pass already marked which lines matched, so the line // index falls out of the rendered cache rather than needing its own // bookkeeping. `MATCH_CONTEXT` rows are left above it so you land with // the tool header / preceding prose in view instead of flush at the top. if args.scroll_to_match && args.search.is_some() && let Some(ce) = cache.entries.get(target) && let Some(row) = match_row(&ce.lines, feed_width) { off = (off + row).saturating_sub(MATCH_CONTEXT); } new_scroll = off.min(max_scroll); new_follow = false; } if let Some(down) = args.prompt_jump { // Wrapped-row offset of each visible user prompt's first row. let mut offsets: Vec = Vec::new(); let mut acc = 0usize; for &i in &visible { if matches!(s.entries[i].kind, Kind::User) { offsets.push(acc); } acc += cache.entries[i].height; } let pick = if down { offsets.iter().copied().find(|&o| o > args.scroll) } else { offsets.iter().rev().copied().find(|&o| o < args.scroll) }; if let Some(o) = pick { new_scroll = o.min(max_scroll); new_follow = false; } } // Reaching the bottom re-engages follow automatically. if new_scroll >= max_scroll { new_follow = true; } // Window: hand ratatui only the entries intersecting the viewport, // with the residual offset into the first one. Scroll state stays // usize end-to-end, so feeds longer than u16::MAX rows keep working. let mut lines: Vec> = Vec::new(); let mut acc = 0usize; // wrapped rows before the current entry let mut skipped = 0usize; // wrapped rows before the first included entry let mut included = false; for &i in &visible { let h = cache.entries[i].height; if acc + h <= new_scroll { acc += h; continue; // entirely above the viewport } if acc >= new_scroll + height { break; // below the viewport } if !included { skipped = acc; included = true; } lines.extend(cache.entries[i].lines.iter().cloned()); acc += h; } let residual = new_scroll.saturating_sub(skipped); let border = if args.focused { Style::new().fg(ACCENT).bold() } else { Style::new().dark_gray() }; let p = Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false }); f.render_widget( p.block( Block::bordered() .title(args.title.clone()) .border_style(border), ) // residual < first visible entry's height; an entry would // need >65k wrapped rows of its own to hit the clamp. .scroll((residual.min(u16::MAX as usize) as u16, 0)), args.area, ); // Right-border overlay: first `*` markers showing where the user's own // messages sit in the whole conversation (a minimap of prompts), then // the scroll thumb painted on top of them where they coincide. if height > 0 && args.area.width >= 2 { let col = args.area.x + args.area.width - 1; // Map a wrapped-row offset within the transcript to a border row. // When everything fits, offsets are 1:1 with screen rows; once // scrollable, compress the whole transcript onto the track. let track_row = |offset: usize| -> u16 { let r = if total <= height { offset } else { offset * height / total }; r.min(height - 1) as u16 }; // Markers track focus like the prompt blocks: orange when focused, // dim grey when not. let marker_style = if args.focused { Style::new().fg(ACCENT).bold() } else { Style::new().fg(Color::DarkGray) }; if args.minimap { let mut acc = 0usize; let buf = f.buffer_mut(); for &i in &visible { if matches!(s.entries[i].kind, Kind::User) { let y = args.area.y + 1 + track_row(acc); buf[(col, y)].set_symbol("*").set_style(marker_style); } acc += cache.entries[i].height; } } // Scroll thumb: a solid block marking the visible window's position // within the whole transcript, drawn last so it sits *on top* of a // marker at the same row. Shown only when scrollable. if total > height { let thumb = ((height * height) / total).max(1).min(height); let max_scroll = total - height; let thumb_top = if max_scroll == 0 { 0 } else { (new_scroll * (height - thumb)) / max_scroll }; let style = if args.focused { Style::new().fg(ACCENT) } else { Style::new().fg(Color::Gray) }; let buf = f.buffer_mut(); for k in 0..thumb { let y = args.area.y + 1 + (thumb_top + k) as u16; buf[(col, y)].set_symbol("█").set_style(style); } } } FeedOut { scroll: new_scroll, follow: new_follow, } } /// One wheel notch, `dir` = -1 up / +1 down. The agent popup is modal, so /// while it is open the notch is its: the picker takes it as a highlight move, /// an agent feed as a scroll. No rect hit-testing — a modal owns the wheel. /// Otherwise the main feed scrolls, wherever the pointer sits. fn wheel(app: &SharedApp, dir: isize) { let mut a = app.lock().unwrap(); // An overlay is modal, so it owns the wheel too — no pointer hit-testing // is involved anywhere. A list moves its highlight; everything else // scrolls the feed's current lane. follow re-engages automatically when // draw() clamps the scroll to the bottom. // Both list overlays are bottom strips with the feed still readable above // them, so the wheel keeps scrolling that feed; j/k moves the list. Nothing // covers the feed any more, so there is no case left where the wheel // belongs to a list. a.scroll_col(None, dir * WHEEL_ROWS); } /// Copy text to the system clipboard via the OSC 52 escape sequence (works /// over SSH; the outer terminal must support it, as it must for Claude Code). fn osc52_copy(text: &str) { use std::io::Write; let mut out = std::io::stdout(); let _ = write!(out, "\x1b]52;c;{}\x07", base64(text.as_bytes())); let _ = out.flush(); } fn base64(data: &[u8]) -> String { const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; let mut s = String::with_capacity(data.len().div_ceil(3) * 4); for chunk in data.chunks(3) { let n = (u32::from(chunk[0]) << 16) | (u32::from(*chunk.get(1).unwrap_or(&0)) << 8) | u32::from(*chunk.get(2).unwrap_or(&0)); s.push(A[(n >> 18) as usize & 63] as char); s.push(A[(n >> 12) as usize & 63] as char); s.push(if chunk.len() > 1 { A[(n >> 6) as usize & 63] as char } else { '=' }); s.push(if chunk.len() > 2 { A[n as usize & 63] as char } else { '=' }); } s } fn short_model(m: &str) -> String { m.strip_prefix("claude-").unwrap_or(m).to_string() } /// Fallback title for a live session, used only while `App::cc_title` has /// nothing: Claude Code names every session in its transcript, but it writes /// that file a moment after the first request reaches us. Until then the feed /// answers — the first line of the first user prompt (what the session is /// *about*), then the model / a placeholder before any prompt streamed in. fn live_title(s: &Session) -> String { if let Some(e) = s.entries.iter().find(|e| matches!(e.kind, Kind::User)) { let first = e.content.lines().map(str::trim).find(|l| !l.is_empty()); if let Some(t) = first.filter(|t| !t.is_empty()) { return t.to_string(); } } if s.entries.is_empty() { "(new session)".into() } else { short_model(&s.main().model) } } /// Greedy word-wrap to `width` columns (char-counted). Words longer than the /// width are hard-split. Always returns at least one (possibly empty) row. fn wrap_words(text: &str, width: usize) -> Vec { let width = width.max(1); let mut out: Vec = Vec::new(); let mut cur = String::new(); let mut cur_len = 0usize; let push_word = |out: &mut Vec, cur: &mut String, cur_len: &mut usize, word: &str| { let wlen = word.chars().count(); if *cur_len == 0 { // start of a row } else if *cur_len + 1 + wlen <= width { cur.push(' '); *cur_len += 1; } else { out.push(std::mem::take(cur)); *cur_len = 0; } if wlen <= width { cur.push_str(word); *cur_len += wlen; } else { // hard-split an over-long word for c in word.chars() { if *cur_len == width { out.push(std::mem::take(cur)); *cur_len = 0; } cur.push(c); *cur_len += 1; } } }; for word in text.split_whitespace() { push_word(&mut out, &mut cur, &mut cur_len, word); } out.push(cur); if out.is_empty() { out.push(String::new()); } out } /// Single-line truncation with an ellipsis when the text doesn't fit. fn truncate_str(s: &str, width: usize) -> String { let s = sanitize(s); if s.chars().count() <= width { return s; } if width == 0 { return String::new(); } let mut out: String = s.chars().take(width - 1).collect(); out.push('…'); out } /// Per-tool human-readable rendering, plus the tool_result (if it has come /// back in a subsequent request) attached underneath. fn render_tool<'a>( name: &str, input: &Value, result: Option<&ToolResult>, out: &mut Vec>, width: u16, ) { let sf = |k: &str| input.get(k).and_then(Value::as_str); let lower = name.to_ascii_lowercase(); match lower.as_str() { // Diff/content view; success confirmations are noise, only surface failures. "write" | "edit" if render_file_tool(name, input, out, width) => { if result.is_some_and(|r| r.is_error) { push_result(out, result, None); } } "read" => { let mut head = vec![ "⚙ Read ".yellow().bold(), sf("file_path").unwrap_or("?").to_string().bold(), ]; // offset/limit (or pages) = an explicit range: show the whole // result. Plain reads get clipped to 5 lines. let ranged = input.get("offset").is_some() || input.get("limit").is_some() || input.get("pages").is_some(); if ranged { let mut parts = Vec::new(); if let Some(o) = input.get("offset").and_then(Value::as_u64) { parts.push(format!("offset {o}")); } if let Some(l) = input.get("limit").and_then(Value::as_u64) { parts.push(format!("limit {l}")); } if let Some(p) = sf("pages") { parts.push(format!("pages {p}")); } head.push(format!(" ({})", parts.join(", ")).dark_gray()); } out.push(Line::from(head)); push_result(out, result, if ranged { None } else { Some(5) }); } "bash" => { let cmd = sf("command").unwrap_or("?"); let mut cmd_lines = cmd.lines(); out.push(Line::from(vec![ "⚙ Bash ".yellow().bold(), sanitize(cmd_lines.next().unwrap_or("")).cyan(), ])); for l in cmd_lines { out.push(Line::from(format!(" {}", sanitize(l))).cyan()); } push_result(out, result, None); } // Subagent spawn. The child's own stream is never in this feed (it is // the `A` popup's), so the main chain shows only what was delegated // (type, description, opening line of the prompt) and the report that // came back — which is all the parent model ever saw of it. "agent" | "task" => { let kind = sf("subagent_type").unwrap_or("agent"); let mut head = vec![ format!("⚙ Agent({kind}) ").yellow().bold(), sf("description").unwrap_or_default().to_string().bold(), ]; if input.get("run_in_background").and_then(Value::as_bool) == Some(true) { head.push(" (background)".dark_gray()); } out.push(Line::from(head)); if let Some(prompt) = sf("prompt") { out.push(Line::from(format!(" → {}", one_line(prompt))).dark_gray()); } push_result(out, result, Some(20)); } "glob" | "grep" => { out.push(Line::from(vec![ format!("⚙ {name} ").yellow().bold(), format!("\"{}\"", sf("pattern").unwrap_or("?")).cyan(), " in ".dark_gray(), sf("path").unwrap_or(".").to_string().into(), ])); push_result(out, result, None); } "todowrite" => { out.push(Line::from("⚙ Todos").yellow().bold()); for t in input .get("todos") .and_then(Value::as_array) .map(Vec::as_slice) .unwrap_or_default() { let content = t.get("content").and_then(Value::as_str).unwrap_or("?"); let row = |mark: &str| format!(" {mark} {}", sanitize(content)); out.push(match t.get("status").and_then(Value::as_str) { Some("completed") => Line::from(row("☑")).green(), Some("in_progress") => Line::from(row("◐")).yellow(), _ => Line::from(row("☐")).dark_gray(), }); } // The result just echoes the list back; only surface failures. if result.is_some_and(|r| r.is_error) { push_result(out, result, None); } } // Background task queue. The subject is the whole point of the call; // the description is the fine print, and the ack ("Task #1 created // successfully: …") is one confirmation line. "taskcreate" => { out.push(Line::from(vec![ "⚙ Task + ".yellow().bold(), sf("subject").unwrap_or("?").to_string().bold(), ])); if let Some(d) = sf("description").filter(|d| !d.trim().is_empty()) { push_wrapped(out, d, " ", width, Style::new().dark_gray()); } push_result(out, result, ok_clip(result, 1)); } "taskupdate" => { let id = sf("taskId").or_else(|| sf("task_id")).unwrap_or("?"); let status = sf("status").unwrap_or("?"); // Same palette as `todowrite`, so a status reads identically // whichever of the two task tools the session is driving. let (mark, mark_style) = match status { "completed" => ("☑", Style::new().green()), "in_progress" => ("◐", Style::new().yellow()), _ => ("☐", Style::new().dark_gray()), }; out.push(Line::from(vec![ format!("⚙ Task #{id} → ").yellow().bold(), Span::styled(format!("{mark} {}", sanitize(status)), mark_style), ])); // The result only echoes the new state back; failures still matter. if result.is_some_and(|r| r.is_error) { push_result(out, result, None); } } // A background watch: the description says what it is for, the command // is what actually runs — shown like `bash`, cyan and unabridged. "monitor" => { out.push(Line::from(vec![ "⚙ Monitor ".yellow().bold(), sf("description").unwrap_or_default().to_string().bold(), ])); if let Some(cmd) = sf("command") { for l in cmd.lines() { out.push(Line::from(format!(" {}", sanitize(l))).cyan()); } } // The websocket form carries a url instead of a command. if let Some(url) = input.get("ws").and_then(|w| w.get("url")).and_then(Value::as_str) { out.push(Line::from(format!(" ws {}", sanitize(url))).cyan()); } let mut flags: Vec = Vec::new(); if let Some(ms) = input.get("timeout_ms").and_then(Value::as_u64) { flags.push(format!("timeout {}", fmt_ms(ms))); } if input.get("persistent").and_then(Value::as_bool) == Some(true) { flags.push("persistent".into()); } if !flags.is_empty() { out.push(Line::from(format!(" {}", flags.join(" · "))).dark_gray()); } // "Monitor started (task …). You will be notified…" — one line is // the whole signal. push_result(out, result, ok_clip(result, 1)); } // Background-task control: the inputs are an id plus a flag or two, so // one line covers them. The result is the interesting half here // (TaskOutput returns the task's actual output), so it isn't clipped — // except TaskStop's one-line ack. l @ ("taskoutput" | "taskget" | "tasklist" | "taskstop") => { let label = match l { "taskoutput" => "TaskOutput", "taskget" => "TaskGet", "tasklist" => "TaskList", _ => "TaskStop", }; let id = sf("task_id") .or_else(|| sf("taskId")) .or_else(|| sf("shell_id")) .unwrap_or_default(); let mut head = vec![ format!("⚙ {label} ").yellow().bold(), sanitize(id).bold(), ]; let mut opts: Vec = Vec::new(); if input.get("block").and_then(Value::as_bool) == Some(true) { opts.push("block".into()); } if let Some(t) = input.get("timeout").and_then(Value::as_u64) { opts.push(format!("timeout {t}s")); } if !opts.is_empty() { head.push(format!(" ({})", opts.join(", ")).dark_gray()); } out.push(Line::from(head)); let clip = if l == "taskstop" { ok_clip(result, 1) } else { None }; push_result(out, result, clip); } // The interactive prompt the tap grows the pane for. Its JSON nests // questions → options → descriptions; dumping that raw is screens of // braces, so it is laid out as a list instead. "askuserquestion" => { out.push(Line::from("⚙ AskUserQuestion").yellow().bold()); for q in arr(input, "questions") { let mut head = Vec::new(); if let Some(h) = q .get("header") .and_then(Value::as_str) .filter(|h| !h.is_empty()) { head.push(format!(" [{}]", sanitize(h)).bold()); } if q.get("multiSelect").and_then(Value::as_bool) == Some(true) { head.push(" multi-select".dark_gray()); } if !head.is_empty() { out.push(Line::from(head)); } let text = q.get("question").and_then(Value::as_str).unwrap_or("?"); push_wrapped(out, text, " ", width, Style::new()); for o in arr(q, "options") { let label = o.get("label").and_then(Value::as_str).unwrap_or("?"); out.push(Line::from(format!(" • {}", sanitize(label))).cyan()); if let Some(d) = o .get("description") .and_then(Value::as_str) .filter(|d| !d.trim().is_empty()) { push_wrapped(out, d, " ", width, Style::new().dark_gray()); } } } // The answer the user picked. push_result(out, result, Some(5)); } // The plan is markdown the model wrote — headings, numbered steps — so // render it as such rather than as one long escaped string. "exitplanmode" => { out.push(Line::from("⚙ ExitPlanMode").yellow().bold()); push_markdown(out, sf("plan").unwrap_or_default(), width); push_result(out, result, Some(3)); } "skill" => { let mut head = vec![ "⚙ Skill ".yellow().bold(), sf("skill").unwrap_or("?").to_string().bold(), ]; if let Some(a) = sf("args").filter(|a| !a.trim().is_empty()) { head.push(format!(" {}", one_line(a)).cyan()); } out.push(Line::from(head)); // "Launching skill: " — the instructions themselves land in // the next turn's context, not in this result. push_result(out, result, ok_clip(result, 1)); } // `websearch` is Claude Code's client-side tool; `web_search` is // Anthropic's *hosted* one, which the nested server-tool request // streams (see `app::ReqKind::ServerTool`). Same input key, and // `app::server_tool_result` emits the same `Links: […]` result shape, // so one arm renders both. "websearch" | "web_search" => { out.push(Line::from(vec![ "⚙ WebSearch ".yellow().bold(), format!("\"{}\"", sanitize(sf("query").unwrap_or("?"))).cyan(), ])); for key in ["allowed_domains", "blocked_domains"] { let doms: Vec<&str> = arr(input, key).iter().filter_map(Value::as_str).collect(); if !doms.is_empty() { out.push(Line::from(format!(" {key}: {}", doms.join(", "))).dark_gray()); } } let handled = match result { Some(r) if !r.is_error => push_search_result(out, &r.content, width), _ => false, }; if !handled { push_result(out, result, None); } } "webfetch" => { out.push(Line::from(vec![ "⚙ WebFetch ".yellow().bold(), sanitize(sf("url").unwrap_or("?")).underlined(), ])); if let Some(p) = sf("prompt").filter(|p| !p.trim().is_empty()) { push_wrapped(out, p, " ", width, Style::new().dark_gray()); } // The result is the model's summary of the page: markdown. match result { Some(r) if !r.is_error => push_markdown(out, &r.content, width), _ => push_result(out, result, None), } } // Deferred tool discovery: one line. Its result arrives as // `tool_reference` content blocks that `app.rs` doesn't flatten yet, so // whatever string reaches us is rendered as-is. "toolsearch" => { let mut head = vec![ "⚙ ToolSearch ".yellow().bold(), format!("\"{}\"", sanitize(sf("query").unwrap_or("?"))).cyan(), ]; if let Some(n) = input.get("max_results").and_then(Value::as_u64) { head.push(format!(" max {n}").dark_gray()); } out.push(Line::from(head)); push_result(out, result, Some(10)); } // Generic fallback: tool name header, inputs as `key: value` rows. An // MCP tool's wire name is `mcp____`; split that so the // server and the tool read as two names instead of one underscore run. _ => { let head = mcp_header(name).unwrap_or_else(|| format!("⚙ {name}")); out.push(Line::from(head).yellow().bold()); match input.as_object() { Some(obj) => { for (k, v) in obj { let val = match v { Value::String(s) => s.clone(), other => other.to_string(), }; out.push(Line::from(vec![ format!(" {k}: ").dark_gray(), one_line(&val).cyan(), ])); } } None => { for l in input.to_string().lines() { out.push(Line::from(format!(" {}", sanitize(l))).cyan()); } } } push_result(out, result, None); } } } /// A JSON array field as a slice — empty when the key is absent or isn't an /// array, so a shape change upstream renders short instead of panicking. fn arr<'v>(v: &'v Value, key: &str) -> &'v [Value] { v.get(key) .and_then(Value::as_array) .map_or(&[], Vec::as_slice) } /// Result clip that always shows a failure in full: a chatty success ack /// ("Task #1 created successfully: …", "Monitor started (task …)") is noise, /// an error never is. fn ok_clip(result: Option<&ToolResult>, lines: usize) -> Option { (!result.is_some_and(|r| r.is_error)).then_some(lines) } /// Push `text` as wrapped rows under `indent`, one source line at a time so /// paragraph breaks survive the wrap. For the free-text fields of the /// prompt-shaped tools (a question, an option's description, a task's /// description) — long enough to need wrapping, not markdown. fn push_wrapped<'a>(out: &mut Vec>, text: &str, indent: &str, width: u16, style: Style) { let w = (width as usize) .saturating_sub(indent.chars().count()) .max(1); for src in text.lines() { for row in wrap_words(&sanitize(src), w) { out.push(Line::from(Span::styled(format!("{indent}{row}"), style))); } } } /// Render `text` as markdown, indented so it reads as a tool's content rather /// than assistant prose. `ExitPlanMode`'s plan and `WebFetch`'s page summary /// are both markdown the model wrote; as a raw string they lose every heading /// and list they were written with. fn push_markdown<'a>(out: &mut Vec>, text: &str, width: u16) { const INDENT: usize = 4; let clean = sanitize_md(text); let w = width.saturating_sub(INDENT as u16).max(10); for l in crate::markdown::render(&clean, w) { let mut l = own_line(l); l.spans.insert(0, Span::raw(" ".repeat(INDENT))); out.push(l); } } /// `mcp____` → `⚙ MCP /`; `None` for every other /// name, so the caller falls back to the plain `⚙ ` header. fn mcp_header(name: &str) -> Option { const PREFIX: &str = "mcp__"; if !name .get(..PREFIX.len()) .is_some_and(|p| p.eq_ignore_ascii_case(PREFIX)) { return None; } let (server, tool) = name[PREFIX.len()..].split_once("__")?; (!server.is_empty() && !tool.is_empty()).then(|| format!("⚙ MCP {server}/{tool}")) } /// WebSearch hands back one plain string: a `Web search results for query: …` /// header, a `Links: [{"title":…,"url":…}]` JSON array, and then the model's /// prose summary. Split that into one `⎿ title url` row per hit plus the prose /// underneath. Returns false when there is no parseable, non-empty `Links:` /// array so the caller can fall back to the raw result rendering — a shape /// change upstream degrades to what we rendered before, never to a panic. fn push_search_result<'a>(out: &mut Vec>, content: &str, width: u16) -> bool { /// Titles are prose and can run long; the url is the load-bearing half. const TITLE_W: usize = 56; let found = content.lines().enumerate().find_map(|(i, l)| { l.trim_start() .strip_prefix("Links:") .map(|rest| (i, rest.trim())) }); let Some((idx, raw)) = found else { return false }; let Ok(Value::Array(items)) = serde_json::from_str::(raw) else { return false; }; if items.is_empty() { return false; } let mut first = true; for it in &items { let Some(url) = it.get("url").and_then(Value::as_str) else { continue; }; let title = it.get("title").and_then(Value::as_str).unwrap_or_default(); let prefix = if first { " ⎿ " } else { " " }; first = false; out.push(Line::from(vec![ prefix.dark_gray(), truncate_str(title, TITLE_W).into(), " ".into(), sanitize(url).underlined(), ])); } let prose = content.lines().skip(idx + 1).collect::>().join("\n"); push_wrapped(out, prose.trim(), " ", width, Style::new().dark_gray()); true } /// Milliseconds as something readable in a dimmed flag row (`1200000` → /// `20m00s`): the tool JSON carries a raw millisecond count, which at monitor /// timeouts is six digits of nothing. fn fmt_ms(ms: u64) -> String { let s = ms / 1000; if s >= 60 { format!("{}m{:02}s", s / 60, s % 60) } else if s > 0 { format!("{s}s") } else { format!("{ms}ms") } } /// Renders a tool_result under its tool entry: `⎿`-marked, dimmed (red when /// `is_error`). `limit` clips to the first N lines with a "N more lines" tail. fn push_result<'a>(out: &mut Vec>, result: Option<&ToolResult>, limit: Option) { let Some(r) = result else { return }; if r.content.is_empty() { if r.is_error { out.push(Line::from(" ⎿ (error)").red()); } return; } // A tool_result is real terminal output — colourised `cargo`/rustfmt // diffs, slash-command stdout — so its SGR sequences become styling here // instead of literal `[31m` text. The dim (or red) base still governs // everything the output doesn't colour itself, and `39`/`49` fall back to // it, so an uncoloured result looks exactly as it did before. let base = if r.is_error { Style::new().red() } else { Style::new().dark_gray() }; let total = r.content.lines().count(); let shown = limit.map_or(total, |n| n.min(total)); for (i, l) in r.content.lines().take(shown).enumerate() { let prefix = if i == 0 { " ⎿ " } else { " " }; let mut spans = vec![Span::styled(prefix, base)]; spans.extend(crate::ansi::spans(l, base)); out.push(Line::from(spans)); } if shown < total { out.push( Line::from(format!(" {} more lines", total - shown)) .dark_gray() .italic(), ); } } /// First line only, clipped, with an ellipsis when anything was dropped. fn one_line(s: &str) -> String { const MAX: usize = 120; let first = s.lines().next().unwrap_or(""); let truncated = first.chars().count() > MAX; let multiline = s.contains('\n'); let clipped: String = first.chars().take(MAX).collect(); if truncated || multiline { format!("{}…", sanitize(&clipped)) } else { sanitize(&clipped) } } /// Dark diff backgrounds that stay readable under white/default text. const DIFF_DEL: Color = Color::Indexed(52); // dark red const DIFF_ADD: Color = Color::Indexed(22); // dark green /// Human-readable rendering for file-mutating tools: `file_path` as header, /// body as numbered lines (Edit shows old/new as a red/green diff). /// Returns false when the tool/input isn't one we special-case. fn render_file_tool<'a>(name: &str, input: &Value, out: &mut Vec>, width: u16) -> bool { let Some(path) = input.get("file_path").and_then(Value::as_str) else { return false; }; let str_field = |k: &str| input.get(k).and_then(Value::as_str); match name.to_ascii_lowercase().as_str() { "write" => { let Some(content) = str_field("content") else { return false }; out.push(Line::from(vec![ "⚙ Write ".yellow().bold(), path.to_string().bold(), ])); let gutter = content.lines().count().max(1).to_string().len(); push_numbered(out, content, None, width, 1, gutter); true } "edit" => { let (Some(old), Some(new)) = (str_field("old_string"), str_field("new_string")) else { return false; }; let mut head = vec!["⚙ Edit ".yellow().bold(), path.to_string().bold()]; if input.get("replace_all").and_then(Value::as_bool) == Some(true) { head.push(" (replace_all)".dark_gray()); } let start = edit_line_number(path, old, new); if let Some(n) = start { head.push(format!(" (line {n})").dark_gray()); } out.push(Line::from(head)); let start = start.unwrap_or(1); // Shared gutter width so the old/new blocks line up with each // other even when one side has more lines than the other. let last_line = old.lines().count().max(new.lines().count()).max(1); let gutter = (start + last_line - 1).to_string().len(); push_numbered(out, old, Some(DIFF_DEL), width, start, gutter); push_numbered(out, new, Some(DIFF_ADD), width, start, gutter); true } _ => false, } } /// Best-effort lookup of the 1-based line number where an edit lands in the /// file on disk. The tool's own `old_string`/`new_string` are unaware of line /// numbers (they're a plain substring replacement), and the `push_numbered` /// gutter used to always start both diff sides at 1, which didn't match the /// real file at all. Tries `new_string` first: Claude Code executes tools /// client-side, not through the proxy, so by render time the edit has /// virtually always already landed on disk. Falls back to `old_string` /// (pre-edit state) for the rare case the edit hasn't run yet. `None` /// (numbering falls back to 1) when the file can't be read or neither string /// is found, e.g. a later edit already changed the surrounding text. fn edit_line_number(path: &str, old: &str, new: &str) -> Option { let content = std::fs::read_to_string(path).ok()?; let offset = content.find(new).or_else(|| content.find(old))?; Some(content[..offset].matches('\n').count() + 1) } /// Pushes `text` line by line with a line-number gutter, numbered from /// `start` (the line it actually occupies in the file, when known — see /// [`edit_line_number`] — otherwise 1) and right-aligned to a fixed `gutter` /// width so an old/new diff pair lines up even when one side has more lines /// than the other. With a `bg`, the whole row (gutter included) is /// white-on-bg and padded to `width` so the background forms a solid block; /// without one, the gutter is dark gray. fn push_numbered<'a>( out: &mut Vec>, text: &str, bg: Option, width: u16, start: usize, gutter: usize, ) { for (i, l) in text.lines().enumerate() { let l = sanitize(l); let n = start + i; match bg { Some(bg) => { let mut row = format!("{n:>gutter$} │ {l}"); let pad = (width as usize).saturating_sub(row.chars().count()); row.extend(std::iter::repeat_n(' ', pad)); out.push(Line::from(Span::styled( row, Style::new().bg(bg).fg(color_on(bg)), ))); } None => out.push(Line::from(vec![ format!("{n:>gutter$} │ ").dark_gray(), Span::raw(l), ])), } } } /// Plain text for a single visual row. A literal tab survives ratatui as a /// `\t` cell symbol that the terminal then renders by jumping to the next tab /// stop, desyncing the per-cell cursor and scattering everything painted after /// it (other control chars render zero-width and smear too), so tabs expand and /// the rest are dropped. ANSI escape sequences are *parsed* away by /// [`crate::ansi`] rather than shedding their ESC byte as one more control char /// — that used to leave `[1m` behind as literal text. Use `ansi::spans` instead /// wherever the styling itself is worth keeping. fn sanitize(l: &str) -> String { crate::ansi::strip(l) } /// Like [`sanitize`] but keeps `\n`, for multi-line content rendered as a block /// (markdown, where newlines carry structure). Same tab/control/escape /// handling: the content is split into rows downstream, so a stray tab must /// already be gone. fn sanitize_md(s: &str) -> String { crate::ansi::strip_multiline(s) } #[cfg(test)] mod tests { use super::{ SHRINK_DELAY, base64, color_on, entry_lines, fmt_ms, lane_dur, lane_mark, lane_title, lane_tokens, mcp_header, menu_rows, sanitize_md, smooth_compact, sync_alt_screen, truncate_str, user_block_style, wrap_words, }; use crate::app::{App, Entry, Kind, Lane, MAIN_LANE, ToolResult}; use crate::keymap::ROOT; use ratatui::layout::Rect; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use std::time::Instant; /// A finished tool entry with its input JSON and (optionally) the /// tool_result that came back — the shape `entry_lines` renders from. fn tool(name: &str, input: &str, result: Option<&str>) -> Entry { Entry { kind: Kind::Tool { name: name.into() }, content: input.into(), done: true, result: result.map(|c| ToolResult { content: c.into(), is_error: false, }), lane: MAIN_LANE, } } /// One rendered row as plain text (spans concatenated). fn flat(l: &Line<'static>) -> String { l.spans.iter().map(|s| s.content.as_ref()).collect() } /// All rendered rows as plain text, newline-separated. fn text(lines: &[Line<'static>]) -> String { lines.iter().map(flat).collect::>().join("\n") } /// Effective foreground of the row containing `needle`: the line's style /// patched by the span's own, which is how ratatui composes them when it /// paints (some arms style the whole `Line`, others each `Span`). fn fg_of(lines: &[Line<'static>], needle: &str) -> Option { let l = lines .iter() .find(|l| flat(l).contains(needle)) .unwrap_or_else(|| panic!("no rendered row contains {needle:?}")); let s = l .spans .iter() .find(|s| s.content.contains(needle)) .unwrap_or_else(|| panic!("{needle:?} straddles spans")); l.style.patch(s.style).fg } /// The subagent popup covers 80% of the *feed* area, centred — never the /// sessions panel, and never so small that a tiny terminal loses the view. /// A stream list of two lanes should not take a third of the screen just /// to show four rows of content. #[test] fn stream_picker_shrinks_to_its_contents_but_never_past_the_cap() { let main = Rect { x: 0, y: 0, width: 120, height: 30 }; let cap = super::list_rect(main).height; // Two lanes = two 2-row items + borders. let r = super::streams_rect(main, 2); assert_eq!(r.height, 6); assert_eq!(r.width, main.width, "still full width"); assert_eq!(r.bottom(), main.bottom(), "still bottom-anchored"); assert!(r.height < cap, "and smaller than the session list"); // One lane (a session with no agents still lists `main`). assert_eq!(super::streams_rect(main, 1).height, 4); // Many lanes stop at the cap and scroll inside it. assert_eq!(super::streams_rect(main, 50).height, cap); // A tiny terminal is bounded by the screen, not by the cap arithmetic. let tiny = Rect { x: 0, y: 0, width: 80, height: 5 }; assert!(super::streams_rect(tiny, 50).height <= 5); } /// Both list overlays are the same bottom strip: full width, a third of /// the feed, feed readable above. A centred box would cover the thing they /// steer. #[test] fn both_list_overlays_are_the_same_bottom_strip() { let main = Rect { x: 0, y: 0, width: 120, height: 30 }; let r = super::list_rect(main); assert_eq!(r.width, main.width, "full width"); assert_eq!(r.bottom(), main.bottom(), "flush with the bottom"); assert_eq!(r.height, 9, "a third of the feed"); assert!(r.top() > main.top() + main.height / 2, "feed stays visible above"); // A short terminal gets the floor rather than a one-row list. let tiny = Rect { x: 0, y: 0, width: 80, height: 12 }; assert_eq!(super::list_rect(tiny).height, super::LIST_MIN); // …and never more than there is. let squashed = Rect { x: 0, y: 0, width: 80, height: 5 }; assert_eq!(super::list_rect(squashed).height, 5); // The search bar is the short one. assert!(super::search_rect(main).height < r.height); } #[test] fn search_scroll_targets_the_matching_row_not_the_entry_top() { let width = 40u16; let mut lines: Vec> = (0..12) .map(|i| Line::from(format!("filler row {i}"))) .collect(); lines.push(Line::from(vec![Span::styled( "cargo", Style::new().bg(super::MATCH_BG), )])); lines.push(Line::from("after")); assert_eq!( super::match_row(&lines, width), Some(12), "twelve unwrapped rows sit above the hit" ); // A line that wraps counts as the rows it really occupies. let wrapped = vec![ Line::from("x".repeat(width as usize * 3)), Line::from(vec![Span::styled("cargo", Style::new().bg(super::MATCH_BG))]), ]; assert_eq!(super::match_row(&wrapped, width), Some(3)); // No painted match (the *result* matched, and it renders clipped): // fall back to the entry top rather than guessing a row. assert_eq!(super::match_row(&[Line::from("nothing here")], width), None); } /// Matches must light up wherever they land, whatever renderer produced /// the line, and the surrounding styling must survive. #[test] fn search_highlights_every_occurrence_and_keeps_the_style() { let mut lines = vec![Line::from(vec![ Span::styled("the Retry helper RETRYs", Style::new().add_modifier(Modifier::BOLD)), ])]; super::highlight_lines(&mut lines, "retry"); let spans = &lines[0].spans; assert_eq!( spans.iter().map(|s| s.content.as_ref()).collect::>(), vec!["the ", "Retry", " helper ", "RETRY", "s"], "case-insensitive match, original casing kept" ); let hit: Vec<&Span> = spans.iter().filter(|s| s.style.bg == Some(super::MATCH_BG)).collect(); assert_eq!(hit.len(), 2, "both occurrences painted"); assert!( hit.iter().all(|s| s.style.add_modifier.contains(Modifier::BOLD)), "the span's own styling survives the highlight" ); // An empty query is a no-op, not a span explosion. let mut plain = vec![Line::from("untouched")]; super::highlight_lines(&mut plain, ""); assert_eq!(plain[0].spans.len(), 1); } /// The query is part of the render fingerprint, or a cached entry keeps /// serving lines from before the search. #[test] fn editing_the_query_invalidates_the_render_cache() { let e = Entry::done(Kind::Text, "hello".into()); let a = super::fingerprint(&e, false, super::query_hash(Some("he"))); let b = super::fingerprint(&e, false, super::query_hash(Some("hel"))); let none = super::fingerprint(&e, false, super::query_hash(None)); assert_ne!(a, b, "a keystroke re-renders"); assert_ne!(a, none, "so does clearing the search"); assert_eq!(super::query_hash(None), 0); assert_ne!(super::query_hash(Some("")), 0, "an empty query is still a search"); } /// The bar is a short bottom strip, like the sessions list but three rows. #[test] fn search_bar_is_a_visible_strip_not_just_a_footer_note() { let main = Rect { x: 0, y: 0, width: 100, height: 30 }; let r = super::search_rect(main); assert_eq!((r.width, r.height), (100, 3)); assert_eq!(r.bottom(), main.bottom()); assert!(r.height < super::list_rect(main).height, "shorter than the session list"); } #[test] fn esc_unwinds_overlays_then_belongs_to_the_child() { let mut a = App::new(); a.filters[0] = false; a.feed_lane = 3; assert!(!a.close_overlay(), "nothing open → the key is the child's"); a.show_sessions = true; a.expanded = Some(crate::app::Expanded { uuid: "u".into(), tree: crate::sessions::TurnTree::default(), sel: Some(0), visual: Some(0), }); assert!(a.close_overlay()); assert!(a.expanded.as_ref().unwrap().visual.is_none(), "visual range first"); assert!(a.close_overlay()); assert!(a.expanded.is_none(), "then the turn tree"); assert!(a.close_overlay()); assert!(!a.show_sessions, "then the overlay itself"); assert!(!a.close_overlay()); // Leaving the sessions overlay keeps what you were reading rather than // snapping back to the pane — walking the list is how you view a // session without resuming it, so throwing that away on the way out // would waste the whole trip. a.show_sessions = true; a.embed_session = Some("pane".into()); a.close_overlay(); assert!(!a.follow_pane, "pinned on the session you were looking at"); // Filters and the shown lane are settings, not modes — Esc never // resets them, which is the whole reason `prefix Esc` does not exist. assert!(!a.filters[0]); assert_eq!(a.feed_lane, 3); } /// Every root binding dispatches, and an unbound key closes the menu /// rather than trapping you in it. #[test] fn the_menu_is_the_binding_table_and_is_never_a_trap() { assert!(ROOT.find('s').is_some()); assert!(ROOT.find('§').is_none()); // Only the repeatable prompt jumps hold the menu open. let sticky: Vec = ROOT .binds .iter() .filter(|b| b.act.sticky()) .map(|b| b.key) .collect(); assert_eq!(sticky, vec![']', '[']); } /// The popup is laid out column-major, so it must reserve a row per item /// per column — a miscount would clip the last binding off the table. #[test] fn menu_reserves_a_row_for_every_binding() { assert_eq!(menu_rows(ROOT.binds.len(), 3), ROOT.binds.len().div_ceil(3) as u16); assert_eq!(menu_rows(ROOT.binds.len(), 1), ROOT.binds.len() as u16); assert_eq!(menu_rows(0, 3), 0); assert_eq!(menu_rows(7, 3), 3, "a partial last column still gets its rows"); // Column-major indexing must reach every entry. let (cols, rows) = (3usize, menu_rows(ROOT.binds.len(), 3) as usize); let mut seen = 0; for r in 0..rows { for c in 0..cols { if ROOT.binds.get(c * rows + r).is_some() { seen += 1; } } } assert_eq!(seen, ROOT.binds.len()); } #[test] fn compact_height_grows_fast_shrinks_slow() { let mut applied = 7u16; let mut pending = None; // Grow: adopted immediately, no pending shrink. assert_eq!(smooth_compact(&mut applied, &mut pending, Some(12)), 12); assert!(pending.is_none()); // Transient miss: height unchanged. assert_eq!(smooth_compact(&mut applied, &mut pending, None), 12); // A smaller reading is not applied yet — it only arms a pending shrink. assert_eq!(smooth_compact(&mut applied, &mut pending, Some(6)), 12); assert!(pending.is_some()); // A bounce back up while shrink is pending cancels it. assert_eq!(smooth_compact(&mut applied, &mut pending, Some(12)), 12); assert!(pending.is_none()); // Re-arm the shrink, then backdate its timestamp past the delay so the // next matching reading commits it (no real sleep needed). assert_eq!(smooth_compact(&mut applied, &mut pending, Some(6)), 12); pending = Some((6, Instant::now() - SHRINK_DELAY - std::time::Duration::from_millis(1))); assert_eq!(smooth_compact(&mut applied, &mut pending, Some(6)), 6); assert!(pending.is_none()); } #[test] fn sanitize_md_expands_tabs_keeps_newlines() { assert_eq!(sanitize_md("a\tb\nc"), "a b\nc"); // other control chars (here a CR) are dropped, newlines survive assert_eq!(sanitize_md("x\r\ny"), "x\ny"); } /// A tab-indented code block in an assistant message must not leave literal /// tabs (or any other control char) in the rendered cells — a `\t` symbol /// reaches the terminal verbatim and shifts the cursor, scattering the row. #[test] fn feed_text_strips_control_chars() { let e = Entry::done(Kind::Text, "```\n\tif self.queued:\n\t\treturn\n```".into()); let lines = entry_lines(&e, 60, false, None); for line in &lines { for span in &line.spans { assert!( !span.content.chars().any(|c| c.is_control()), "rendered span still contains a control char: {:?}", span.content ); } } } #[test] fn color_on_contrasts_with_background() { // Bright orange / white → black text; dark grey / blue → white text. assert_eq!(color_on(Color::Indexed(208)), Color::Black); assert_eq!(color_on(Color::White), Color::Black); assert_eq!(color_on(Color::Indexed(238)), Color::White); assert_eq!(color_on(Color::Indexed(17)), Color::White); assert_eq!(color_on(Color::Rgb(0, 0, 0)), Color::White); } #[test] fn wrap_words_greedy_and_hard_splits() { assert_eq!(wrap_words("", 10), vec![""]); assert_eq!(wrap_words("a b c", 3), vec!["a b", "c"]); // an over-long word is hard-split at the width boundary assert_eq!(wrap_words("abcdef", 2), vec!["ab", "cd", "ef"]); } #[test] fn truncate_str_adds_ellipsis() { assert_eq!(truncate_str("hello", 10), "hello"); assert_eq!(truncate_str("hello", 3), "he…"); assert_eq!(truncate_str("hello", 0), ""); } #[test] fn base64_matches_rfc4648_vectors() { assert_eq!(base64(b""), ""); assert_eq!(base64(b"f"), "Zg=="); assert_eq!(base64(b"fo"), "Zm8="); assert_eq!(base64(b"foo"), "Zm9v"); assert_eq!(base64(b"foobar"), "Zm9vYmFy"); } /// A tool_result is real terminal output. Its SGR sequences must become /// span styling, not survive as literal `[31m` text, and the dim base must /// still govern whatever the output didn't colour itself. #[test] fn tool_result_ansi_becomes_style_not_literal_text() { let e = tool( "Bash", r#"{"command":"cargo fmt --check"}"#, Some("\u{1b}[31m- app.lock()\u{1b}[0m\nplain tail"), ); let lines = entry_lines(&e, 80, false, None); let t = text(&lines); assert!(t.contains("- app.lock()"), "{t}"); assert!(!t.contains("[31m"), "escape leaked as literal text: {t}"); assert_eq!(fg_of(&lines, "app.lock()"), Some(Color::Red)); assert_eq!(fg_of(&lines, "plain tail"), Some(Color::DarkGray)); } /// `` reaches the feed through the reminder path; /// its styling applies on top of the dim italic base rather than replacing /// it (`/model` prints bold, the surrounding reminder stays dim). #[test] fn reminder_renders_slash_command_stdout_styling() { let e = Entry::done( Kind::Reminder, "Set model to \u{1b}[1mOpus 5\u{1b}[22m" .into(), ); let lines = entry_lines(&e, 80, false, None); let t = text(&lines); assert!(t.contains("Set model to Opus 5"), "{t}"); assert!(!t.contains("[1m"), "{t}"); let bold = lines .iter() .flat_map(|l| &l.spans) .find(|s| s.content.contains("Opus 5")) .expect("styled run"); assert!(bold.style.add_modifier.contains(Modifier::BOLD)); assert!( bold.style.add_modifier.contains(Modifier::ITALIC), "reminder base survives underneath" ); } /// The user-prompt block is a filled rectangle whose foreground `color_on` /// picks for contrast, so ANSI colour is dropped inside it while the /// attributes survive — and every row still pads flush to the full width. #[test] fn user_prompt_drops_ansi_colour_but_keeps_bold() { let raw = "Set model to \u{1b}[1;31mSonnet 5\u{1b}[22m and saved as your default"; let e = Entry::done(Kind::User, raw.into()); let lines = entry_lines(&e, 40, true, None); let t = text(&lines); assert!(t.contains("Sonnet 5"), "{t}"); assert!(!t.contains("[1m") && !t.contains("[22m"), "{t}"); let bold = lines .iter() .flat_map(|l| &l.spans) .find(|s| s.content.contains("Sonnet")) .expect("styled run"); assert!(bold.style.add_modifier.contains(Modifier::BOLD)); let block = user_block_style(true); for l in &lines { if l.spans.is_empty() { continue; // the trailing separator row } for s in &l.spans { assert_eq!(s.style.bg, block.bg, "block fill broken by {:?}", s.content); assert_eq!( s.style.fg, block.fg, "ANSI colour reached the filled block: {:?}", s.content ); } let w: usize = l.spans.iter().map(|s| s.content.chars().count()).sum(); assert_eq!(w, 40, "row not padded flush: {:?}", flat(l)); } } /// TaskCreate/TaskUpdate get the subject and the todo status palette, and /// no raw JSON reaches the feed. #[test] fn task_tools_render_subject_and_status_without_json() { let e = tool( "TaskCreate", r#"{"subject":"Add ring-buffer retroactive capture to InteractionRecorder","description":"Keep last N seconds in memory","activeForm":"Adding ring-buffer capture"}"#, Some("Task #1 created successfully: Add ring-buffer retroactive capture"), ); let t = text(&entry_lines(&e, 90, false, None)); assert!(t.contains("⚙ Task + Add ring-buffer retroactive capture"), "{t}"); assert!(t.contains("Keep last N seconds in memory"), "{t}"); assert!(!t.contains("activeForm") && !t.contains('{'), "raw JSON: {t}"); let e = tool( "TaskUpdate", r#"{"taskId":"1","status":"in_progress"}"#, Some("Task #1 updated"), ); let lines = entry_lines(&e, 80, false, None); let t = text(&lines); assert!(t.contains("⚙ Task #1 → ◐ in_progress"), "{t}"); assert!(!t.contains("taskId"), "raw JSON: {t}"); assert!(!t.contains("updated"), "the success echo is noise: {t}"); assert_eq!(fg_of(&lines, "in_progress"), Some(Color::Yellow)); let e = tool("TaskUpdate", r#"{"taskId":"7","status":"completed"}"#, None); let lines = entry_lines(&e, 80, false, None); assert!(text(&lines).contains("⚙ Task #7 → ☑ completed")); assert_eq!(fg_of(&lines, "completed"), Some(Color::Green)); } /// Monitor reads like `bash`: description in the header, the command cyan /// across as many lines as it has, the flags dimmed and in real time units. #[test] fn monitor_shows_description_command_and_flags() { let e = tool( "Monitor", r#"{"command":"until grep -q done log; do sleep 1; done\necho ok","description":"world skin bench phase transitions","timeout_ms":1200000,"persistent":false}"#, Some("Monitor started (task b8s2gso3a, timeout 1200000ms).\nYou will be notified."), ); let lines = entry_lines(&e, 100, false, None); let t = text(&lines); assert!( t.contains("⚙ Monitor world skin bench phase transitions"), "{t}" ); assert!(t.contains("until grep -q done log; do sleep 1; done"), "{t}"); assert!(t.contains("echo ok"), "command keeps every line: {t}"); assert!(t.contains("timeout 20m00s"), "{t}"); assert!(!t.contains("persistent"), "a false flag isn't shown: {t}"); assert!(t.contains("Monitor started"), "{t}"); assert!(!t.contains("You will be notified"), "ack collapses: {t}"); assert_eq!(fg_of(&lines, "until grep"), Some(Color::Cyan)); } /// AskUserQuestion's nested JSON becomes a question with a labelled option /// list; none of the braces reach the feed. #[test] fn ask_user_question_lists_options_without_json() { let e = tool( "AskUserQuestion", r#"{"questions":[{"question":"How should the pane be framed?","header":"Framing","multiSelect":false,"options":[{"label":"Measure the box","description":"Frame from the top border down"},{"label":"Fixed offsets","description":"Crop a constant row count"}]}]}"#, Some("Measure the box"), ); let t = text(&entry_lines(&e, 70, false, None)); assert!(t.contains("⚙ AskUserQuestion"), "{t}"); assert!(t.contains("[Framing]"), "{t}"); assert!(t.contains("How should the pane be framed?"), "{t}"); assert!(t.contains("• Measure the box"), "{t}"); assert!(t.contains("• Fixed offsets"), "{t}"); assert!(t.contains("Frame from the top border down"), "{t}"); assert!(t.contains("⎿ Measure the box"), "the answer: {t}"); assert!( !t.contains("multiSelect") && !t.contains("\"label\""), "raw JSON: {t}" ); } /// The plan is markdown the model wrote, so it renders through /// `crate::markdown` (which strips the heading markers) — not as a string. #[test] fn exit_plan_mode_renders_the_plan_as_markdown() { let e = tool( "ExitPlanMode", // Not a raw string: `"##` would close one mid-heading. "{\"plan\":\"## Plan\\n\\n1. Size the PTY to the screen\\n2. Measure what Ink drew\"}", None, ); let t = text(&entry_lines(&e, 70, false, None)); assert!(t.contains("⚙ ExitPlanMode"), "{t}"); assert!(t.contains("Plan") && !t.contains("## Plan"), "{t}"); assert!(t.contains("Size the PTY to the screen"), "{t}"); assert!(!t.contains("\\n"), "escaped JSON leaked: {t}"); } /// WebSearch's result is one plain string with a `Links:` JSON array baked /// into it: the hits become rows, the prose stays, the JSON goes. An /// unparseable array falls back to the raw rendering instead of panicking. #[test] fn web_search_splits_links_from_prose() { let res = concat!( "Web search results for query: \"ratatui line style\"\n\n", "Links: [{\"title\":\"Line in ratatui::text\",\"url\":\"https://docs.rs/ratatui/latest/text/Line.html\"},", "{\"title\":\"Styling text\",\"url\":\"https://ratatui.rs/concepts/text\"}]\n\n", "Line style is patched by each span's own style." ); let e = tool("WebSearch", r#"{"query":"ratatui line style"}"#, Some(res)); let t = text(&entry_lines(&e, 110, false, None)); assert!(t.contains("⚙ WebSearch \"ratatui line style\""), "{t}"); assert!(t.contains("⎿ Line in ratatui::text"), "{t}"); assert!(t.contains("https://ratatui.rs/concepts/text"), "{t}"); assert!(t.contains("Line style is patched"), "prose survives: {t}"); assert!(!t.contains("Links: ["), "the array itself is gone: {t}"); assert!(!t.contains("{\"title\""), "raw JSON: {t}"); let e = tool("WebSearch", r#"{"query":"q"}"#, Some("Links: not json\nprose")); let t = text(&entry_lines(&e, 100, false, None)); assert!(t.contains("Links: not json"), "falls back raw: {t}"); } /// Anthropic's *hosted* `web_search` (the nested server-tool request) /// renders through the same arm as Claude Code's client-side `WebSearch`: /// `app::server_tool_result` emits the same `Links: […]` shape, so the hits /// reach the screen through one renderer rather than two. #[test] fn hosted_web_search_hits_render_like_the_client_tool() { // The wire block, formatted by the very function the tap calls — so // this pins app.rs and ui.rs agreeing, not a hand-copied string. let block: serde_json::Value = serde_json::from_str( r#"{"type":"web_search_tool_result","tool_use_id":"srvtoolu_01","content":[ {"type":"web_search_result","title":"Ratatui docs","url":"https://ratatui.rs","page_age":"2 days"}, {"type":"web_search_result","title":"Scrollbar example","url":"https://ratatui.rs/examples","page_age":null}]}"#, ) .unwrap(); let r = crate::app::server_tool_result(Some(&block)); assert!(!r.is_error); let e = tool("web_search", r#"{"query":"ratatui scrollbar"}"#, Some(&r.content)); let t = text(&entry_lines(&e, 110, false, None)); assert!(t.contains("⚙ WebSearch \"ratatui scrollbar\""), "{t}"); assert!(t.contains("⎿ Ratatui docs"), "{t}"); assert!(t.contains("https://ratatui.rs/examples"), "{t}"); assert!(!t.contains("Links: ["), "the array itself is gone: {t}"); } /// The citation source list is one multi-line `Kind::Meta` entry: a `\n` /// inside a single `Line` is not a row break to ratatui, so it has to be /// split. A one-line meta is untouched. #[test] fn a_multiline_meta_renders_one_dim_row_per_line() { let e = Entry { kind: Kind::Meta, content: "▸ 2 sources\n · Ratatui docs — https://ratatui.rs\n · Examples — https://ratatui.rs/examples".into(), done: true, result: None, lane: MAIN_LANE, }; let lines = entry_lines(&e, 80, false, None); let t = text(&lines); assert!(t.starts_with("▸ 2 sources\n"), "{t}"); assert!(t.contains("· Ratatui docs — https://ratatui.rs\n"), "{t}"); assert_eq!(fg_of(&lines, "Examples"), Some(Color::DarkGray)); // Blank separator row aside, one rendered row per source line. assert_eq!(lines.iter().filter(|l| !flat(l).is_empty()).count(), 3); } /// A nested server-tool call shares the popup with the subagents but is /// not one, so it must not wear the agent's activity mark. #[test] fn a_server_tool_lane_reads_differently_from_an_agent() { let mut agent = Lane::new("a1".into(), "claude-haiku-4-5".into(), Some(MAIN_LANE), 1); agent.agent_type = "Explore".into(); agent.active = 1; // `srvtool-` is `app::SERVER_TOOL_PREFIX`; no hex agent id can look // like it, which is what `Lane::is_server_tool` keys off. let mut srv = Lane::new("srvtool-0".into(), "claude-haiku-4-5".into(), Some(MAIN_LANE), 1); srv.agent_type = "web_search".into(); srv.label = "\"ratatui scrollbar\"".into(); srv.active = 1; assert_eq!(lane_mark(&agent), "⟳"); assert_eq!(lane_mark(&srv), "⚙"); assert_ne!(lane_mark(&agent), lane_mark(&srv)); // Finishing changes an agent's mark; the server tool keeps its own. agent.active = 0; agent.finished_at = Some(std::time::Instant::now()); srv.active = 0; srv.finished_at = Some(std::time::Instant::now()); assert_eq!(lane_mark(&agent), "✓"); assert_eq!(lane_mark(&srv), "⚙"); assert!( lane_title(&srv, None).starts_with(" ⚙ web_search · \"ratatui scrollbar\""), "{}", lane_title(&srv, None) ); } #[test] fn web_fetch_shows_url_prompt_and_markdown_summary() { let e = tool( "WebFetch", r#"{"url":"https://docs.rs/ratatui","prompt":"How does Line combine with Span styles?"}"#, Some("## Answer\n\nThe line style is applied first."), ); let t = text(&entry_lines(&e, 80, false, None)); assert!(t.contains("⚙ WebFetch https://docs.rs/ratatui"), "{t}"); assert!(t.contains("How does Line combine with Span styles?"), "{t}"); assert!(t.contains("The line style is applied first."), "{t}"); assert!(!t.contains("## Answer"), "summary is markdown: {t}"); } #[test] fn skill_tool_search_and_task_control_render_one_line() { let e = tool( "Skill", r#"{"skill":"optimize-materialization"}"#, Some("Launching skill: optimize-materialization"), ); let t = text(&entry_lines(&e, 80, false, None)); assert!(t.contains("⚙ Skill optimize-materialization"), "{t}"); assert!(t.contains("⎿ Launching skill"), "{t}"); let e = tool("ToolSearch", r#"{"query":"select:Monitor","max_results":1}"#, None); let t = text(&entry_lines(&e, 80, false, None)); assert!(t.contains("⚙ ToolSearch \"select:Monitor\""), "{t}"); assert!(t.contains("max 1"), "{t}"); let e = tool( "TaskOutput", r#"{"task_id":"b8s2gso3a","block":true}"#, Some("phase 3 done\nphase 4 done"), ); let t = text(&entry_lines(&e, 80, false, None)); assert!(t.contains("⚙ TaskOutput b8s2gso3a (block)"), "{t}"); assert!(t.contains("phase 4 done"), "the output isn't clipped: {t}"); assert!(!t.contains("task_id"), "raw JSON: {t}"); let e = tool("TaskStop", r#"{"task_id":"b8s2gso3a"}"#, None); assert!(text(&entry_lines(&e, 80, false, None)).contains("⚙ TaskStop b8s2gso3a")); } #[test] fn mcp_tool_header_names_server_and_tool() { let e = tool( "mcp__pistdio__probe_run", r#"{"probe":"world_skin","frames":120}"#, None, ); let t = text(&entry_lines(&e, 80, false, None)); assert!(t.contains("⚙ MCP pistdio/probe_run"), "{t}"); assert!(t.contains("probe: world_skin"), "{t}"); assert!(t.contains("frames: 120"), "{t}"); // Everything else keeps the plain `⚙ ` header. assert_eq!(mcp_header("Bash"), None); assert_eq!(mcp_header("mcp__only"), None); assert_eq!(mcp_header("mcp____tool"), None); assert_eq!( mcp_header("mcp__a__b__c").as_deref(), Some("⚙ MCP a/b__c") ); } /// The glyph `app::task_note_line` writes is the *only* channel the colour /// comes from — this pins that contract from the render side. #[test] fn task_note_colour_comes_from_the_leading_glyph() { let note = |content: &str| Entry { kind: Kind::TaskNote, content: content.into(), done: true, result: None, lane: MAIN_LANE, }; for (line, want) in [ ("✔ Explore finished · 129k tok", Color::Green), ("✖ Explore finished · Error: 403", Color::Red), ("◼ Explore finished · killed", Color::Yellow), ("▸ Monitor event: bench", Color::Cyan), ("· task zz9", Color::DarkGray), ] { let lines = entry_lines(¬e(line), 80, false, None); assert_eq!(fg_of(&lines, line), Some(want), "{line}"); } // A monitor payload / an unattachable report rides along dimmed. let lines = entry_lines(¬e("▸ Monitor event: bench\n BENCH phase=traverse"), 80, false, None); assert_eq!(fg_of(&lines, "BENCH phase"), Some(Color::DarkGray)); } #[test] fn lane_titles_prefer_the_notifications_usage_totals() { let mut l = Lane::new("a1".into(), "claude-sonnet-4-5".into(), Some(MAIN_LANE), 1); l.agent_type = "Explore".into(); l.output_tokens = 2_100; l.tool_calls = 2; // Without a `` block: what we counted off the wire, labelled so. assert!(lane_title(&l, None).contains("· out 2.1k "), "{}", lane_title(&l, None)); assert_eq!(lane_dur(&l), ""); // With one: Claude Code's totals for the whole run, wall time included. l.subagent_tokens = Some(128_633); l.tool_uses = Some(63); l.duration_ms = Some(1_115_197); assert_eq!( lane_title(&l, None), " · Explore · sonnet-4-5 · 128.6k tok · 18m35s " ); assert_eq!(lane_tokens(&l), "128.6k tok"); } #[test] fn fmt_ms_reads_as_time_not_a_digit_count() { assert_eq!(fmt_ms(1_200_000), "20m00s"); assert_eq!(fmt_ms(90_000), "1m30s"); assert_eq!(fmt_ms(5_000), "5s"); assert_eq!(fmt_ms(400), "400ms"); assert_eq!(fmt_ms(0), "0ms"); } /// An editor on the child's alternate screen fullscreens the pane, and /// quitting it gives the compact pane back. #[test] fn alt_screen_fullscreens_the_pane_and_restores_it() { let (mut last, mut full, mut auto) = (false, false, false); assert!(sync_alt_screen(true, &mut last, &mut full, &mut auto)); assert!(full && auto); // Steady state raises no edge, so nothing is re-asserted per frame. assert!(!sync_alt_screen(true, &mut last, &mut full, &mut auto)); assert!(sync_alt_screen(false, &mut last, &mut full, &mut auto)); assert!(!full && !auto); } /// A pane that was already fullscreen stays fullscreen after the editor /// exits — the restore only undoes a fullscreen we entered ourselves. #[test] fn alt_screen_keeps_a_fullscreen_the_user_chose() { let (mut last, mut full, mut auto) = (false, true, false); sync_alt_screen(true, &mut last, &mut full, &mut auto); assert!(full && !auto); sync_alt_screen(false, &mut last, &mut full, &mut auto); assert!(full); } /// ctrl-f while the editor is open clears the restore flag (the key /// handler does that), so quitting the editor leaves the choice alone. #[test] fn manual_toggle_outranks_the_alt_screen_restore() { let (mut last, mut full, mut auto) = (false, false, false); sync_alt_screen(true, &mut last, &mut full, &mut auto); // ctrl-f, ctrl-f: back to fullscreen, explicitly. for _ in 0..2 { full = !full; auto = false; } sync_alt_screen(false, &mut last, &mut full, &mut auto); assert!(full); } }