use crate::app::{ AgentPopup, App, Entry, FILTER_LABELS, Kind, Lane, LaneId, MAIN_LANE, Session, SharedApp, ToolResult, filter_index, fmt_tokens, }; 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, /// Keyboard focus is on the claude pane (vs. the feed above it). /// Directional: ctrl-↓ moves focus into the pane, ctrl-↑ back to the feed. claude_focused: bool, /// Pane takes (nearly) the whole screen. Toggled with ctrl-f while the /// pane has focus; cleared when focus leaves it or it is hidden. 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)>, } /// 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); /// 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 { /// Pane is visible, child alive, and holds keyboard focus → it gets keys. fn focused(&self) -> bool { self.visible && self.claude_focused && self.term.as_ref().is_some_and(|t| !t.exited()) } /// 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), 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), 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`), results attach /// once — length + flags capture every mutation the app performs. 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) -> (usize, bool, usize, bool, bool) { 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) } /// 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) -> Vec> { let mut lines: Vec> = Vec::new(); match &e.kind { 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()), 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() { let prefixed = format!("{}{}", if first { "❯ " } else { " " }, sanitize(raw)); for seg in wrap_words(&prefixed, w) { let mut row = seg; let pad = w.saturating_sub(row.chars().count()); row.extend(std::iter::repeat_n(' ', pad)); lines.push(Line::from(Span::styled(row, style))); } 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. lines.push(Line::from("⌁ system reminder").dark_gray().italic()); for l in e.content.lines() { lines.push(Line::from(format!(" {}", sanitize(l))).dark_gray().italic()); } } 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()); } } } 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) } pub fn run(app: SharedApp, port: u16) -> 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(); // 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); let mut eui = EmbedUi { term: None, visible: false, claude_focused: false, fullscreen: false, port, past_embeds: HashSet::new(), force_resume: None, cursor_shape: None, compact_inner: crate::term::DEFAULT_COMPACT_INNER, shrink_pending: None, }; let res = event_loop(&mut terminal, app, &mut eui); let _ = execute!( std::io::stdout(), SetCursorStyle::DefaultUserShape, DisableBracketedPaste, DisableMouseCapture ); ratatui::restore(); res } fn toggle_embed(eui: &mut EmbedUi, app: &SharedApp) { if eui.visible { eui.visible = false; eui.claude_focused = false; eui.fullscreen = false; // A dead child is dropped on hide so the next toggle respawns. if eui.term.as_ref().is_some_and(|t| t.exited()) { kill_current_embed(eui, app); } return; } show_embed_pane(eui, app); } /// 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.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(). match EmbeddedTerm::spawn(eui.port, 20, 80, "") { Ok(t) => bind_fresh_pane(eui, app, t, ""), 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; } } eui.visible = true; eui.claude_focused = true; } /// `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. fn show_embed_new(eui: &mut EmbedUi, app: &SharedApp, model: &str) { kill_current_embed(eui, app); 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.claude_focused = true; } /// 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. 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.claude_focused = true; } /// 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. fn attach_selected(eui: &mut EmbedUi, app: &SharedApp) { 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.claude_focused = true; } 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 — ctrl-↓ again to resume anyway" .into(); } } } } 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))?; 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 pane prompt-only. 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; true } else { false } }; if clear_due && let Some(et) = &eui.term && !et.exited() { 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.focused() && let Some(et) = &eui.term { et.paste(text); } continue; } // Wheel scroll always drives the feed, regardless of which pane has // keyboard focus (the embedded pane gets no mouse forwarding anyway). // 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 { match m.kind { MouseEventKind::ScrollUp => wheel(&app, -1), MouseEventKind::ScrollDown => 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); } // Pane controls, available in every state (alt-keys are out: // they compose characters on some keyboard layouts): // F2 show/hide the claude pane // ctrl-↓ focus the claude pane (showing it if hidden) // ctrl-↑ focus the feed // ctrl-f toggle pane fullscreen (only while the pane is focused) let ctrl = k.modifiers.contains(KeyModifiers::CONTROL); // ctrl-q quits from anywhere — in particular while the claude pane // is focused, where plain `q` is forwarded to the child. if ctrl && k.code == KeyCode::Char('q') { return Ok(()); } if k.code == KeyCode::F(2) { if k.kind == KeyEventKind::Press { toggle_embed(eui, &app); } continue; } if ctrl && k.code == KeyCode::Down { if k.kind == KeyEventKind::Press { attach_selected(eui, &app); } continue; } if ctrl && k.code == KeyCode::Up { eui.claude_focused = false; // The feed would be invisible behind a fullscreen pane. eui.fullscreen = false; continue; } if ctrl && k.code == KeyCode::Char('f') && eui.focused() { if k.kind == KeyEventKind::Press { eui.fullscreen = !eui.fullscreen; } continue; } // While the claude pane has focus, everything else belongs to it. if eui.focused() { if let Some(et) = &eui.term { et.key(k); } continue; } if k.kind != KeyEventKind::Press { continue; } let mut a = app.lock().unwrap(); let nsess = a.merged_len(); if k.code == KeyCode::Char('c') && ctrl { return Ok(()); } // Filter popup captures input while open. if let Some(sel) = a.filter_popup { let n = FILTER_LABELS.len(); match k.code { KeyCode::Char(' ') => 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('f') | KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => { a.filter_popup = None } _ => {} } continue; } // Model picker (opened with `a`): j/k move, Enter spawns a fresh // session with the chosen model, esc/a/q cancels. if let Some(msel) = a.model_popup { let n = a.model_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::Esc | KeyCode::Char('a') | KeyCode::Char('q') => a.model_popup = None, KeyCode::Enter => { a.model_popup = None; let model = a.model_choices.get(msel).map(|c| c.1.clone()); drop(a); if let Some(model) = model { show_embed_new(eui, &app, &model); } } _ => {} } continue; } // Agent popup (`A`): modal, so it takes every key while open — // the picker navigates, an agent feed scrolls. Esc steps back one // layer (feed → picker → closed), `A`/`q` closes outright. if a.agent_popup.is_some() { match k.code { KeyCode::Up | KeyCode::Char('k') => match a.agent_popup { Some(AgentPopup::List(_)) => a.agent_popup_move(-1), _ => { let t = a.agent_popup_lane(); a.scroll_col(t, -1); } }, KeyCode::Down | KeyCode::Char('j') => match a.agent_popup { Some(AgentPopup::List(_)) => a.agent_popup_move(1), _ => { let t = a.agent_popup_lane(); a.scroll_col(t, 1); } }, KeyCode::Enter | KeyCode::Char(' ') | KeyCode::Right | KeyCode::Char('l') => { a.agent_popup_enter() } KeyCode::Esc | KeyCode::Left | KeyCode::Char('h') => a.agent_popup_back(), KeyCode::Char('A') | KeyCode::Char('q') => a.agent_popup = None, // Switch agents without a detour through the picker. KeyCode::Char('[') => a.agent_popup_cycle(false), KeyCode::Char(']') => a.agent_popup_cycle(true), KeyCode::PageUp => { let t = a.agent_popup_lane(); a.scroll_col(t, -20); } KeyCode::PageDown => { let t = a.agent_popup_lane(); a.scroll_col(t, 20); } KeyCode::Home | KeyCode::Char('g') => { let t = a.agent_popup_lane(); a.scroll_col_end(t, false); } KeyCode::End | KeyCode::Char('G') => { let t = a.agent_popup_lane(); a.scroll_col_end(t, true); } _ => {} } continue; } match k.code { KeyCode::Char('q') => return Ok(()), // Esc unwinds one layer: visual mode → turn tree → quit. KeyCode::Esc => match a.expanded.as_mut() { Some(e) if e.visual.is_some() => e.visual = None, Some(_) => a.expanded = None, None => return Ok(()), }, KeyCode::Char('f') => a.filter_popup = Some(0), // a → pick a model, then spawn a brand-new session (no need to // resume + /clear just to get a fresh chat). KeyCode::Char('a') => a.model_popup = Some(0), // n / N → jump the feed scroll to the next / previous user // prompt (honoured on the next draw, where entry heights live). KeyCode::Char('n') => { a.follow = false; a.prompt_jump = Some(true); } KeyCode::Char('N') => { a.follow = false; a.prompt_jump = Some(false); } KeyCode::Char('s') => a.show_sessions = !a.show_sessions, // c → attach the most recent past session (like `claude -c`): // select it (the scanner keeps disk_sessions newest-first), // then run the normal attach logic. KeyCode::Char('c') => { 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); } } continue; } // Turn tree (sessions panel): space toggles the selected // session's tree, →/l expands / steps into the turns, ←/h // steps out / collapses (yazi-style), v anchors a visual // range, b materializes a new decoupled session from the // highlighted turn (its chain) or the visual selection. 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 → {} (ctrl-↓ to start it)", u.chars().take(8).collect::() ); } Err(e) => a.status = e, }, // Tab / BackTab cycle sessions. `a` is the new-session picker; // `n`/`N` jump between user prompts; `p` is no longer a // back-tab mirror. KeyCode::Tab if nsess > 0 => { a.selected = (a.selected + 1) % nsess; a.follow = true; a.clear_turn_focus(); } KeyCode::BackTab if nsess > 0 => { a.selected = (a.selected + nsess - 1) % nsess; a.follow = true; a.clear_turn_focus(); } // j/k/↑/↓ drive the session/turn highlight, never the feed — // the feed scrolls with the wheel (or PgUp/PgDn/g/G). KeyCode::Up | KeyCode::Char('k') => a.nav(false), KeyCode::Down | KeyCode::Char('j') => a.nav(true), // A → the subagent popup: one agent opens straight into its // feed, several show the picker first. Subagents are shown // nowhere else, so this is also how a finished agent's output // is read back. KeyCode::Char('A') => a.toggle_agent_popup(), 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), _ => {} } } } } 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 only while the feed selection is on the embedded // session: tabbing away hides it (the child keeps running — hide, don't // kill), tabbing back reveals it instantly. ctrl-↓ attaches elsewhere. let selected_is_embed = a.embed_session.is_some() && a.selected_key().as_deref() == a.embed_session.as_deref(); // The pane stays visible while it holds keyboard focus even if the // selection isn't (yet) on its session: its real session id is learned // from the first request, and an involuntary selection move (another // session's traffic) must never yank focus out of a pane the user is // typing in. Intentional navigation still hides it — ctrl-↑ drops focus, // after which the selection rule applies and tabbing away hides the pane. let show_embed = eui.visible && eui.term.is_some() && (selected_is_embed || eui.claude_focused); if !show_embed { // An invisible pane must not swallow keystrokes (the selection can // move under us, e.g. a new session auto-jump). eui.claude_focused = false; eui.fullscreen = false; } // Mirror focus into shared state so the off-thread tap can avoid stealing // the selection from a pane the user is actively driving. a.pane_focused = eui.focused(); 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 = !eui.focused(); let [main, embed_area, footer] = Layout::vertical([ Constraint::Min(1), Constraint::Length(embed_h), Constraint::Length(1), ]) .areas(f.area()); // Uniform: the sessions panel is always half the width, so titles have // room to read the same whether or not a turn tree is expanded. let left_width = if a.show_sessions { main.width / 2 } else { 0 }; let [left, right] = Layout::horizontal([Constraint::Length(left_width), Constraint::Min(10)]) .areas(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.agent_popup = None; } // Session list: live sessions first, then this directory's past sessions // as dimmed stubs (kept fresh by the scanner thread — no I/O here). The // expanded session's turn rows render directly under its row, abandoned // branches indented one level under their fork point (⑂). if a.show_sessions { // Inner content width (panel minus its border): titles wrap to this, // turn labels truncate to it. let inner_w = left.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 }; (s.key.clone(), lead, live_title(s), 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.clone(), 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(border_style(feed_focused)), ) .highlight_style(ratatui::style::Style::new().reversed()), left, &mut ls, ); } // 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 agent popup pointed at a lane that exists (a session switch or // a rebuilt on-disk view can invalidate it). Done before the feed borrow, // which freezes `a`. a.validate_agent_popup(); 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: the main feed keeps // using App::scroll/follow, the popup's agent owns an entry in // App::lane_cols so it can follow its own tail independently. let mut main_col = (a.scroll, a.follow); let mut lane_writeback: Option<(LaneId, usize, bool)> = None; let agent_popup = a.agent_popup; if let Some(s) = feed_session { // The main feed always gets the whole area: a subagent never takes // space from it (nor interleaves into it — draw_feed filters by lane). // Agents live in the popup drawn on top, below. let out = draw_feed( f, caches.get(&s.key, MAIN_LANE), &FeedArgs { s, lane: MAIN_LANE, area: right, focused: feed_focused && agent_popup.is_none(), filters, live: feed_live, leaf: feed_leaf, scroll: main_col.0, follow: main_col.1, scroll_target, prompt_jump, title: main_title(s, a.show_sessions), minimap: true, }, ); main_col = (out.scroll, out.follow); // The subagent popup: 80% of the feed area, centred, drawn over the // main feed. Either the agent picker, or one agent's whole stream. match agent_popup { None => {} Some(AgentPopup::List(sel)) => { draw_agent_list(f, popup_rect(right), s, &App::agent_list_of(s), sel); } // `validate_agent_popup` already dropped a lane this session does // not have; the bound check makes any disagreement between it and // the session actually rendered here a blank frame instead of a // panic (the UI thread holds the mutex the proxy tap needs). Some(AgentPopup::Feed(lane)) if (lane as usize) < s.lanes.len() => { let area = popup_rect(right); // `2/3` in the title: which agent of the session this is, so // `[`/`]` has somewhere to walk from. let list = App::agent_list_of(s); let pos = list .iter() .position(|&x| x == lane) .map(|i| (i + 1, list.len())); let (scroll, follow) = a.lane_cols.get(&lane).copied().unwrap_or((0, true)); f.render_widget(Clear, area); let out = draw_feed( f, caches.get(&s.key, lane), &FeedArgs { s, lane, area, // The popup is modal: it is where the keys go, so it // always reads as focused. focused: true, filters, live: feed_live, leaf: feed_leaf, scroll, follow, // A subagent's stream has no user prompts and no turn // tree, so neither the minimap nor the turn/prompt // jumps apply to it. scroll_target: None, prompt_jump: None, title: lane_title(&s.lanes[lane as usize], pos), minimap: false, }, ); lane_writeback = Some((lane, out.scroll, out.follow)); } Some(AgentPopup::Feed(_)) => {} } } 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.scroll = main_col.0; a.follow = main_col.1; if let Some((lane, scroll, follow)) = lane_writeback { a.lane_cols.insert(lane, (scroll, follow)); } // Embedded claude pane let embed_focused = eui.focused(); // 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(); let title = if exited { format!(" claude · {id} · exited ") } 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. 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; let visual_on = a.expanded.as_ref().is_some_and(|e| e.visual.is_some()); let keys = if a.filter_popup.is_some() { "space toggle · j/k move · f/esc close" } else if a.model_popup.is_some() { "enter new session · j/k move · esc cancel" } else if matches!(a.agent_popup, Some(AgentPopup::List(_))) { "enter watch agent · j/k move · esc/A close" } else if a.agent_popup.is_some() { "j/k · PgUp/PgDn · g/G scroll · [/] agent · esc back · A close" } else if embed_focused { "ctrl-↑ feed · ctrl-f fullscreen · ctrl-q quit · F2 hide claude" } else if visual_on { "j/k extend · b branch selection · esc cancel" } else if a.on_turns() { "j/k turns · v visual · b branch · ←/space close · wheel scrolls feed" } else if show_embed { "ctrl-↓ claude · a add session · q quit · j/k move · space tree · f filter · F2 hide" } else { "q quit · n new · j/k move · space/→ tree · f filter · c continue · ctrl-↓ attach" }; // `A` only matters when the displayed session has agents at all — and it is // the *only* way to see one, so the hint leads the footer instead of // trailing it, where the long key list gets cut off on narrow terminals. // Not while the pane has focus: there every key belongs to the child. let n_agents = a.agent_list().len(); let keys = if a.agent_popup.is_some() || embed_focused || n_agents == 0 { keys.to_string() } else { format!("A agents ({n_agents}) · {keys}") }; f.render_widget( Paragraph::new(Line::from(format!(" {} | {keys}", a.status)).dark_gray()), footer, ); // Filter popup if let Some(fsel) = a.filter_popup { let w = 26u16.min(main.width); let h = (FILTER_LABELS.len() as u16 + 2).min(main.height); let area = Rect { x: main.x + (main.width.saturating_sub(w)) / 2, y: main.y + (main.height.saturating_sub(h)) / 2, width: w, height: h, }; f.render_widget(Clear, area); 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 ")) .highlight_style(ratatui::style::Style::new().reversed()), area, &mut ls, ); } // Model picker popup (n → choose a model → fresh session). if let Some(msel) = a.model_popup { let w = 30u16.min(main.width); let h = (a.model_choices.len() as u16 + 2).min(main.height); let area = Rect { x: main.x + (main.width.saturating_sub(w)) / 2, y: main.y + (main.height.saturating_sub(h)) / 2, width: w, height: h, }; f.render_widget(Clear, area); let items: Vec = a .model_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 ")) .highlight_style(ratatui::style::Style::new().reversed()), area, &mut ls, ); } 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 left.width > 0 && contains(left, s.start) { left } else 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); } } } /// Share of the feed area the subagent popup covers, per axis. const POPUP_PCT: u32 = 80; /// The subagent popup's rect: `POPUP_PCT` of the *feed* area on both axes, /// centred over it. Deliberately not the whole main area — the sessions panel /// keeps its half, and the ring of main feed left visible around the popup /// says "this is an overlay, not the conversation you were reading". Clamped /// to `area`, so a tiny terminal simply gets all of it. fn popup_rect(area: Rect) -> Rect { let pct = |v: u16, floor: u16| -> u16 { let scaled = (u32::from(v) * POPUP_PCT / 100) as u16; scaled.max(floor.min(v)) }; let (w, h) = (pct(area.width, 24), pct(area.height, 6)); Rect { x: area.x + (area.width.saturating_sub(w)) / 2, y: area.y + (area.height.saturating_sub(h)) / 2, width: w, height: h, } } /// Title of the main feed: id (unless the sessions panel already shows it), /// the main chain's model and its token counters. fn main_title(s: &Session, show_sessions: bool) -> String { let m = s.main(); let tokens = format!( "{} · in {} · out {} ", m.model, fmt_tokens(m.input_tokens), fmt_tokens(m.output_tokens) ); if show_sessions { format!(" {tokens}") } else { 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!( " {} {} · {} · out {}", lane_mark(l), l.title(), short_model(&l.model), fmt_tokens(l.output_tokens) ); if let Some((i, n)) = pos.filter(|&(_, n)| n > 1) { t.push_str(&format!(" · {i}/{n}")); } t.push(' '); t } /// 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 { 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_agent_list(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) }; let head = format!(" {} {}", lane_mark(l), l.title()); let meta = format!( " {} · {} tools · out {}", short_model(&l.model), l.tool_calls, fmt_tokens(l.output_tokens) ); ListItem::new(vec![ Line::from(truncate_str(&head, inner_w)).style(style), Line::from(truncate_str(&meta, inner_w)).dark_gray(), ]) }) .collect(); let running = lanes .iter() .filter(|&&i| 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!(" agents · {running} of {} running ", lanes.len())) .border_style(Style::new().fg(ACCENT).bold()), ) .highlight_style(Style::new().reversed()), area, &mut ls, ); } /// Everything one feed needs. Bundled because a feed is drawn from two places /// (the main chain, and one agent inside the popup) with only these differing. 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, /// `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); // (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); if cache.entries.get(i).is_none_or(|c| c.fingerprint != fp) { let lines = entry_lines(e, feed_width, args.focused); 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 { // Pin the highlighted turn's first entry to the viewport top. new_scroll = visible .iter() .take_while(|&&i| i < target) .map(|&i| cache.entries[i].height) .sum::() .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(); match a.agent_popup { Some(AgentPopup::List(_)) => a.agent_popup_move(dir), // follow re-engages automatically when draw() clamps the scroll to // the bottom. _ => { let target = a.agent_popup_lane(); a.scroll_col(target, dir * 3); } } } /// 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() } /// A human title for a live session: the first line of its first user prompt /// (what the session is *about*), falling back to the model / a placeholder /// before any prompt has 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); match name.to_ascii_lowercase().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); } } // Generic fallback: tool name header, inputs as `key: value` rows. _ => { out.push(Line::from(format!("⚙ {name}")).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); } } } /// 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; } 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 line = format!("{prefix}{}", sanitize(l)); out.push(if r.is_error { Line::from(line).red() } else { Line::from(line).dark_gray() }); } 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), ])), } } } /// 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). For a single visual row: expand tabs, drop the /// rest. Tab-indented code is the main offender. fn sanitize(l: &str) -> String { let mut s = String::with_capacity(l.len()); for c in l.chars() { match c { '\t' => s.push_str(" "), c if c.is_control() => {} c => s.push(c), } } s } /// Like [`sanitize`] but keeps `\n`, for multi-line content rendered as a block /// (markdown, where newlines carry structure). Same tab/control handling: the /// content is split into rows downstream, so a stray tab must already be gone. fn sanitize_md(s: &str) -> String { let mut out = String::with_capacity(s.len()); for c in s.chars() { match c { '\n' => out.push('\n'), '\t' => out.push_str(" "), c if c.is_control() => {} c => out.push(c), } } out } #[cfg(test)] mod tests { use super::{ SHRINK_DELAY, base64, color_on, entry_lines, popup_rect, sanitize_md, smooth_compact, truncate_str, wrap_words, }; use crate::app::{Entry, Kind}; use ratatui::layout::Rect; use ratatui::style::Color; use std::time::Instant; /// 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. #[test] fn agent_popup_covers_four_fifths_of_the_feed() { // Feed area offset inside the screen (sessions panel to its left). let feed = Rect::new(40, 1, 60, 30); let r = popup_rect(feed); assert_eq!((r.width, r.height), (48, 24), "80% on both axes"); // Centred over the feed, so the main feed shows around every edge. assert_eq!(r.x, 40 + 6); assert_eq!(r.y, 1 + 3); assert!(r.x >= feed.x && r.right() <= feed.right()); assert!(r.y >= feed.y && r.bottom() <= feed.bottom()); // Tiny feed: the popup takes all of it rather than collapsing to a // couple of unusable rows. let tiny = Rect::new(0, 0, 20, 5); let r = popup_rect(tiny); assert_eq!((r.width, r.height), (20, 5)); // A wide terminal must not overflow the percentage arithmetic. let wide = popup_rect(Rect::new(0, 0, u16::MAX, 100)); assert_eq!(wide.width, 52428); } /// The compact pane grows on the frame the prompt gets taller, but a /// smaller reading is held back until it has been stable for SHRINK_DELAY — /// so the per-frame wobble during subagent turns / menu filtering doesn't /// resize the PTY (which would make Claude Code repaint and flicker). A /// transient `None` reading keeps the last height. #[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); 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"); } }