Files
claude-cloak/src/ui.rs
Jonas H 15b20a8af1 Put every app key behind one prefix
The keybindings had no red thread because there were three focus states
and every key had to ask "is this mine or the child's" — hence ctrl-q,
because plain q was forwarded. Replace the whole model: unprefixed keys
belong to the embedded claude, always, and everything cloak owns sits
behind ctrl-space (CT_PREFIX to change), tmux-style, in one table that
the which-key popup, the footer hint and the dispatch all read.

Esc is the only key with a rule of its own, and it is about reachability
rather than modes: it closes the topmost overlay, and with nothing open
it goes to the child, so interrupt and Esc-Esc rewind keep working. View
state — filters, the lane the feed shows — is a setting, not a mode, and
is deliberately not escapable.

That removes the focus model entirely, which lets the two feeds collapse
into one: the feed renders whichever lane feed_lane names, at full
width, and the stream picker chooses it. Subagents used to live in a
modal popup holding a second draw_feed with its own cache and scroll
model, rendering the same thing twice. The sessions panel loses its half
of the screen the same way. Both pickers become bottom strips with the
feed readable above them, so walking the list previews each row — which
is the view-without-resuming that /resume cannot do, and frees enter to
attach the pane.

Adds a find bar with in-place highlighting, scrolling to the matching
line rather than the containing entry, and drops the two keys that
served the old layout.
2026-09-10 14:16:34 +02:00

4280 lines
182 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<EmbeddedTerm>,
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<String>,
/// 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<crate::term::CursorShape>,
/// 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>) -> 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>,
) -> 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<LaneId, FeedCache>,
}
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<usize>,
/// 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<CachedEntry>,
}
struct CachedEntry {
fingerprint: (usize, bool, usize, bool, bool, u64),
lines: Vec<Line<'static>>,
/// 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 `<task-notification>` 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<usize> {
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<Line<'static>>, 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<Span<'static>> = 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<Line<'static>> {
let mut lines: Vec<Line<'static>> = 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 `<task-notification>`, 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 `<event>` 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<char> = 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<Span<'static>> = 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
// `<local-command-stdout>` 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::<Value>(&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<crate::term::PtyHandoff>,
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 <new uuid>`
/// (optionally `--model <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 <new uuid>`.
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<Selection> = 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<crate::term::CursorShape> = 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::<String>()
);
}
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<Selection>,
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<usize> = 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<usize>)> = 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<usize> = 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<usize>) = 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<String> = 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<crate::term::CursorShape> = 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<ListItem> = 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<ListItem> = 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<String> = 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<ListItem> = 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<Line> = 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<Line> = Vec::new();
for r in 0..rows as usize {
let mut spans: Vec<Span> = 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 `<task-notification>`'s `<usage>` 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 `<task-notification>` 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 `<task-notification>` 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<ListItem> = 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 `<usage>`
// 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<usize>,
scroll: usize,
follow: bool,
/// Pin a turn's first entry to the top (main feed only).
scroll_target: Option<usize>,
/// 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<bool>,
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<usize> = 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::<usize>();
// 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<usize> = 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<Line<'static>> = 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<String> {
let width = width.max(1);
let mut out: Vec<String> = Vec::new();
let mut cur = String::new();
let mut cur_len = 0usize;
let push_word = |out: &mut Vec<String>, 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<Line<'a>>,
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<String> = 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<String> = 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: <name>" — 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__<server>__<tool>`; 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<usize> {
(!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<Line<'a>>, 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<Line<'a>>, 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__<server>__<tool>` → `⚙ MCP <server>/<tool>`; `None` for every other
/// name, so the caller falls back to the plain `⚙ <name>` header.
fn mcp_header(name: &str) -> Option<String> {
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<Line<'a>>, 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::<Value>(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::<Vec<_>>().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<Line<'a>>, result: Option<&ToolResult>, limit: Option<usize>) {
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<Line<'a>>, 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<usize> {
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<Line<'a>>,
text: &str,
bg: Option<Color>,
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::<Vec<_>>().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<Color> {
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<Line<'static>> = (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<_>>(),
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<char> = 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));
}
/// `<local-command-stdout>` 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,
"<local-command-stdout>Set model to \u{1b}[1mOpus 5\u{1b}[22m</local-command-stdout>"
.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 `⚙ <name>` 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(&note(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(&note("▸ 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 `<usage>` 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);
}
}