Files
claude-cloak/src/ui.rs
2026-08-26 11:17:43 +02:00

2533 lines
104 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::{
AgentPopup, App, Entry, FILTER_LABELS, Kind, Lane, LaneId, MAIN_LANE, Session, SharedApp,
ToolResult, filter_index, fmt_tokens,
};
use crate::term::{EmbeddedTerm, PaneView};
use ratatui::Frame;
use ratatui::crossterm::cursor::SetCursorStyle;
use ratatui::crossterm::event::{
self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
Event, KeyCode, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind,
};
use ratatui::crossterm::execute;
use ratatui::layout::{Constraint, Layout, Position, Rect};
use ratatui::style::{Color, Modifier, Style, Stylize};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Block, Clear, List, ListItem, ListState, Paragraph, Wrap};
use serde_json::Value;
use std::collections::{HashMap, HashSet};
use std::time::{Duration, Instant};
/// Embedded `claude` pane state (UI-thread only; the shared App carries just
/// the session id + grow flag so the proxy tap can talk to it).
///
/// One app instance hosts at most one embedded claude (`term`). The pane is
/// drawn only while the feed selection is on its session: tabbing away hides
/// it without killing the child, so tabbing back is instant. ctrl-↓ is the
/// commit point that may kill + respawn (`attach_selected`).
struct EmbedUi {
term: Option<EmbeddedTerm>,
visible: bool,
/// Keyboard focus is on the claude pane (vs. the feed above it).
/// Directional: ctrl-↓ moves focus into the pane, ctrl-↑ back to the feed.
claude_focused: bool,
/// Pane takes (nearly) the whole screen. Toggled with ctrl-f while the
/// pane has focus; cleared when focus leaves it or it is hidden.
fullscreen: bool,
port: u16,
/// Sessions that were embedded earlier in this process: their instances
/// are known dead (we killed them), so resuming needs no liveness guard.
past_embeds: HashSet<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)>,
}
/// How long a smaller compact-pane height must persist before the pane
/// actually shrinks. Long enough to ride out the repaint churn of a subagent
/// turn or a filtering `@`/`/` menu, short enough to feel responsive.
const SHRINK_DELAY: Duration = Duration::from_millis(400);
/// Mouse text selection over the whole screen (mimics Claude Code: drag to
/// select with a reversed-video highlight, the underlying screen text is
/// copied to the system clipboard on release via OSC 52).
struct Selection {
start: (u16, u16), // (x, y) anchor cell
end: (u16, u16), // (x, y) current cell (inclusive)
dragging: bool,
/// Set on mouse release: the next draw extracts + copies the text.
copy_pending: bool,
}
impl EmbedUi {
/// Pane is visible, child alive, and holds keyboard focus → it gets keys.
fn focused(&self) -> bool {
self.visible
&& self.claude_focused
&& self.term.as_ref().is_some_and(|t| !t.exited())
}
/// Smoothed compact-pane inner height. `measured` is this frame's raw
/// reading (`None` when the input box couldn't be located — a transient
/// mid-repaint, so keep what we have). Grows immediately so the prompt
/// never scrolls out of view, but shrinks only after the smaller height
/// has held for `SHRINK_DELAY`. Without this the height oscillates every
/// frame while a subagent streams or an `@`/`/` menu filters, and each
/// change resizes the PTY, making Claude Code repaint and flicker.
fn compact_height(&mut self, measured: Option<u16>) -> 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),
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),
lines: Vec::new(),
height: 0,
}
}
}
/// Cheap change-detector for a cached entry: content only ever grows (or is
/// swapped for the pretty-printed form along with `done`), results attach
/// once — length + flags capture every mutation the app performs. The trailing
/// bool folds in feed focus, but *only* for `Kind::User` entries (their block
/// color tracks focus): toggling focus then re-renders just the user blocks,
/// not the whole transcript.
fn fingerprint(e: &Entry, focused: bool) -> (usize, bool, usize, bool, bool) {
let (rlen, rerr) = e
.result
.as_ref()
.map_or((usize::MAX, false), |r| (r.content.len(), r.is_error));
let focus_bit = matches!(e.kind, Kind::User) && focused;
(e.content.len(), e.done, rlen, rerr, focus_bit)
}
/// Detach a `Line` from the text it borrows so it can outlive the app lock.
fn own_line(l: Line<'_>) -> Line<'static> {
Line {
spans: l
.spans
.into_iter()
.map(|s| Span::styled(s.content.into_owned(), s.style))
.collect(),
style: l.style,
alignment: l.alignment,
}
}
/// Background used to highlight a visual-mode turn range in the session list.
const USER_BG: Color = Color::Indexed(17); // deep blue
/// Focus accent — Claude's orange (indexed so 256-color terminals match it).
/// This is the single source of truth for "the feed/pane has keyboard focus":
/// borders, the scroll thumb, the user-message markers and the user-message
/// blocks all use it when focused.
const ACCENT: Color = Color::Indexed(208); // orange
/// Background of a focused user-prompt block (the active accent).
const USER_BG_ACTIVE: Color = Color::Indexed(208); // orange
/// Background of an unfocused user-prompt block (dimmed grey).
const USER_BG_DIM: Color = Color::Indexed(238); // dark grey
/// Style for a user-prompt block: a filled orange box when the feed is focused,
/// a dim grey box when it isn't (focus is folded into the per-entry fingerprint
/// for `Kind::User` only, so toggling focus re-renders just these blocks). The
/// foreground is chosen by `color_on` so the text stays legible on either bg.
fn user_block_style(focused: bool) -> Style {
let bg = if focused { USER_BG_ACTIVE } else { USER_BG_DIM };
let s = Style::new().bg(bg).fg(color_on(bg));
if focused { s.bold() } else { s }
}
/// Pick a legible foreground (black or white) for text drawn on `bg`, from the
/// background's perceived luminance. A filled block sets its own bg, so this
/// keeps the text readable regardless of the user's light/dark terminal theme.
fn color_on(bg: Color) -> Color {
let (r, g, b) = rgb_of(bg);
let luma = 0.299 * f32::from(r) + 0.587 * f32::from(g) + 0.114 * f32::from(b);
if luma > 140.0 { Color::Black } else { Color::White }
}
/// Approximate 8-bit RGB for a ratatui `Color`, enough to judge brightness:
/// RGB passes through; the xterm-256 indexed palette is decoded (16 base + the
/// 6×6×6 cube + the 24-step grey ramp); anything else falls back to mid grey.
fn rgb_of(c: Color) -> (u8, u8, u8) {
match c {
Color::Rgb(r, g, b) => (r, g, b),
Color::Black => (0, 0, 0),
Color::White => (255, 255, 255),
Color::Indexed(i) => indexed_rgb(i),
_ => (128, 128, 128),
}
}
fn indexed_rgb(i: u8) -> (u8, u8, u8) {
const BASE: [(u8, u8, u8); 16] = [
(0, 0, 0), (128, 0, 0), (0, 128, 0), (128, 128, 0),
(0, 0, 128), (128, 0, 128), (0, 128, 128), (192, 192, 192),
(128, 128, 128), (255, 0, 0), (0, 255, 0), (255, 255, 0),
(0, 0, 255), (255, 0, 255), (0, 255, 255), (255, 255, 255),
];
match i {
0..=15 => BASE[i as usize],
16..=231 => {
const STEP: [u8; 6] = [0, 95, 135, 175, 215, 255];
let n = i - 16;
(STEP[(n / 36) as usize], STEP[((n / 6) % 6) as usize], STEP[(n % 6) as usize])
}
_ => {
let v = 8 + 10 * (i - 232);
(v, v, v)
}
}
}
/// Render one entry to owned lines, including the blank separator row that
/// follows every entry in the feed. `focused` only affects user-prompt blocks.
fn entry_lines(e: &Entry, width: u16, focused: bool) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
match &e.kind {
Kind::Meta => lines.push(Line::from(e.content.clone()).dark_gray()),
// Request-context metadata the model received (the system prompt is too
// long to show verbatim, so only its size; tools as a name list). Dim,
// wrapping is handled by the feed Paragraph.
Kind::System => lines.push(Line::from(format!("{}", e.content)).dark_gray()),
Kind::ToolDefs => lines.push(Line::from(format!("🔧 {}", e.content)).dark_gray()),
Kind::User => {
let style = user_block_style(focused);
let w = (width as usize).max(1);
// Wrap to the inner feed width ourselves and pad every row to
// *exactly* w, so the block is a clean filled rectangle that ends
// flush with the borders — no reliance on the Paragraph's own
// wrapping (which left a stray, half-empty continuation row), and
// no trailing blank: the content is right-trimmed first.
let mut first = true;
for raw in e.content.trim_end().lines() {
let prefixed = format!("{}{}", if first { " " } else { " " }, sanitize(raw));
for seg in wrap_words(&prefixed, w) {
let mut row = seg;
let pad = w.saturating_sub(row.chars().count());
row.extend(std::iter::repeat_n(' ', pad));
lines.push(Line::from(Span::styled(row, style)));
}
first = false;
}
if lines.is_empty() {
lines.push(Line::from(Span::styled(" ".repeat(w), style)));
}
}
Kind::Reminder => {
// Injected context Claude Code received — shown dim, like thinking.
lines.push(Line::from("⌁ system reminder").dark_gray().italic());
for l in e.content.lines() {
lines.push(Line::from(format!(" {}", sanitize(l))).dark_gray().italic());
}
}
Kind::Thinking => {
let head = if e.done { "✻ thought" } else { "✻ thinking…" };
lines.push(Line::from(head).magenta().italic());
for l in e.content.lines() {
lines.push(Line::from(sanitize(l)).dark_gray().italic());
}
}
Kind::Text => {
// Sanitize first (preserving newlines): a literal tab in a code
// block would otherwise survive as a `\t` cell symbol and shift the
// terminal cursor to the next tab stop, scattering the line.
let clean = sanitize_md(&e.content);
lines.extend(crate::markdown::render(&clean, width).into_iter().map(own_line));
}
Kind::Tool { name } => {
// Once the input JSON is complete, every tool gets a
// human-readable rendering; partial streams fall back to
// the raw JSON-fragment view.
let parsed = e
.done
.then(|| serde_json::from_str::<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());
}
}
}
lines.push(Line::default());
lines
}
/// Rows the lines occupy after wrapping to `width` (must match the wrap
/// configuration of the feed Paragraph).
fn wrapped_height(lines: &[Line<'static>], width: u16) -> usize {
Paragraph::new(Text::from(lines.to_vec()))
.wrap(Wrap { trim: false })
.line_count(width)
}
pub fn run(app: SharedApp, port: u16) -> anyhow::Result<()> {
// Keep the session list populated with this directory's on-disk history.
crate::sessions::spawn_scanner(app.clone());
// Refresh the `n` model picker from the live `claude` alias set.
crate::term::spawn_model_discovery(app.clone());
let mut terminal = ratatui::init();
// Mouse capture: wheel events scroll the feed (regardless of pane focus).
// Trade-off: text selection in the outer terminal now needs shift held.
// Bracketed paste: a multiline paste arrives as one `Event::Paste` we hand
// to the child as a bracketed paste, instead of N keystrokes whose first
// newline would submit the prompt.
let _ = execute!(std::io::stdout(), EnableMouseCapture, EnableBracketedPaste);
let mut eui = EmbedUi {
term: None,
visible: false,
claude_focused: false,
fullscreen: false,
port,
past_embeds: HashSet::new(),
force_resume: None,
cursor_shape: None,
compact_inner: crate::term::DEFAULT_COMPACT_INNER,
shrink_pending: None,
};
let res = event_loop(&mut terminal, app, &mut eui);
let _ = execute!(
std::io::stdout(),
SetCursorStyle::DefaultUserShape,
DisableBracketedPaste,
DisableMouseCapture
);
ratatui::restore();
res
}
fn toggle_embed(eui: &mut EmbedUi, app: &SharedApp) {
if eui.visible {
eui.visible = false;
eui.claude_focused = false;
eui.fullscreen = false;
// A dead child is dropped on hide so the next toggle respawns.
if eui.term.as_ref().is_some_and(|t| t.exited()) {
kill_current_embed(eui, app);
}
return;
}
show_embed_pane(eui, app);
}
/// Tear down the current embedded child (if any): dropping it kills the
/// process (`EmbeddedTerm::drop`), the learned session id is retired into
/// `past_embeds` (so a later `--resume` of it skips the liveness guard — we
/// know it's dead), and every per-pane flag is cleared. The single teardown
/// path every spawn/replace routes through, so pane identity and the
/// grow/clear flags can never drift between the four call sites.
fn kill_current_embed(eui: &mut EmbedUi, app: &SharedApp) {
if eui.term.take().is_none() {
return;
}
let mut a = app.lock().unwrap();
if let Some(old) = a.embed_session.take() {
eui.past_embeds.insert(old);
}
a.embed_token = None;
a.embed_grow = false;
a.embed_grow_rows = None;
a.embed_clear_at = None;
}
/// Register a freshly spawned pane as the embedded child: record its token
/// (so the tap recognises the pane's traffic and learns its real session id),
/// reset per-pane flags, and store the child. `session` is `Some(uuid)` only
/// for a resume — where we know the id up front and pre-load its transcript;
/// a fresh spawn passes `None` and lets the first tagged request bind the id.
fn bind_new_pane(eui: &mut EmbedUi, app: &SharedApp, t: EmbeddedTerm, session: Option<&str>) {
let mut a = app.lock().unwrap();
a.embed_token = Some(t.pane_token.clone());
a.embed_session = session.map(str::to_string);
a.embed_grow = false;
a.embed_grow_rows = None;
a.embed_clear_at = None;
if let Some(key) = session {
a.select_key(key);
}
a.follow = true;
a.clear_turn_focus();
drop(a);
eui.term = Some(t);
}
/// Register a freshly spawned pane *and* switch the feed to it immediately:
/// create a provisional, empty session row keyed by the spawned `--session-id`
/// and select it, so a fresh `a` / first spawn clears the feed to the new blank
/// session at once instead of waiting for the first prompt's traffic to bind
/// it. If Claude Code later reports a different id, the tap renames this row
/// onto it (the same provisional-rename path a resume uses), so we never end up
/// with two rows for the one session.
fn bind_fresh_pane(eui: &mut EmbedUi, app: &SharedApp, t: EmbeddedTerm, model: &str) {
let sid = t.session_id.clone();
{
let mut a = app.lock().unwrap();
if !a.sessions.iter().any(|s| s.key == sid) {
a.sessions.push(Session::new(sid.clone(), model.to_string()));
}
// Remember the exact `--model` argument: the transcript only records
// the base model id, so this is the one place a `[1m]` (1M-context)
// pick can survive into a later resume.
a.set_spawn_model(&sid, model);
}
bind_new_pane(eui, app, t, Some(&sid));
}
/// Show (spawning if needed) the claude pane and give it keyboard focus.
/// A live child is reused (instant reveal); a dead one is replaced.
fn show_embed_pane(eui: &mut EmbedUi, app: &SharedApp) {
if eui.term.as_ref().is_some_and(|t| t.exited()) {
kill_current_embed(eui, app);
}
if eui.term.is_none() {
// Real dimensions are applied on the first draw via resize().
match EmbeddedTerm::spawn(eui.port, 20, 80, "") {
Ok(t) => bind_fresh_pane(eui, app, t, ""),
Err(e) => {
app.lock().unwrap().status = format!("claude spawn failed: {e}");
return;
}
}
} else {
// Reusing a live child: re-select its session if we've learned it.
let mut a = app.lock().unwrap();
if let Some(key) = a.embed_session.clone() {
a.select_key(&key);
a.follow = true;
}
}
eui.visible = true;
eui.claude_focused = true;
}
/// `a` model picker: always spawn a *fresh* `claude --session-id <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.
fn show_embed_new(eui: &mut EmbedUi, app: &SharedApp, model: &str) {
kill_current_embed(eui, app);
match EmbeddedTerm::spawn(eui.port, 20, 80, model) {
Ok(t) => bind_fresh_pane(eui, app, t, model),
Err(e) => {
app.lock().unwrap().status = format!("claude spawn failed: {e}");
return;
}
}
eui.visible = true;
eui.claude_focused = true;
}
/// Spawn (or replace) the embedded pane resuming a past session by UUID.
/// Any existing pane (live or dead) is killed and replaced — this is the
/// only expensive path, and it only runs from an explicit ctrl-↓ / `c`.
///
/// The resume carries the session's own model forward (`App::resume_model`,
/// read from its transcript) instead of falling back to the CLI default.
fn show_embed_resume(eui: &mut EmbedUi, app: &SharedApp, session_id: &str) {
kill_current_embed(eui, app);
let model = app.lock().unwrap().resume_model(session_id);
match EmbeddedTerm::spawn_resume(eui.port, 20, 80, session_id, &model) {
Ok(t) => {
{
// Pre-fill the feed from the on-disk transcript so it isn't
// blank until the next turn streams. Always re-read (drop any
// cached path view) — the resume continues the trunk, which a
// dead-branch view wouldn't show. The bind below provisionally
// points `embed_session` at this uuid; if Claude Code reports a
// different id, the tap rebinds (renaming this row onto it).
let mut a = app.lock().unwrap();
a.history.remove(session_id);
if !a.sessions.iter().any(|s| s.key == session_id)
&& let Some(s) = crate::sessions::load_history(session_id)
{
a.sessions.push(s);
}
a.set_spawn_model(session_id, &model);
a.status = if model.is_empty() {
format!("resumed {}", &session_id[..8.min(session_id.len())])
} else {
format!(
"resumed {} on {model}",
&session_id[..8.min(session_id.len())]
)
};
}
bind_new_pane(eui, app, t, Some(session_id));
}
Err(e) => {
app.lock().unwrap().status = format!("claude spawn failed: {e}");
return;
}
}
eui.visible = true;
eui.claude_focused = true;
}
/// ctrl-↓ / `c`: attach the embedded pane to the *selected* session. The
/// cheap cases (reveal/focus the live pane, spawn the first instance) are
/// instant; only attaching to a different session kills + respawns claude.
fn attach_selected(eui: &mut EmbedUi, app: &SharedApp) {
enum Plan {
Fresh,
Reveal,
Resume(String),
Guard(String),
}
let plan = {
let mut a = app.lock().unwrap();
a.filter_popup = None;
match a.selected_key() {
// Nothing anywhere yet → fresh `claude --session-id <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.claude_focused = true;
}
Plan::Resume(key) => {
eui.force_resume = None;
show_embed_resume(eui, app, &key);
}
Plan::Guard(key) => {
let armed = eui
.force_resume
.as_ref()
.is_some_and(|(k, t)| *k == key && t.elapsed() < Duration::from_secs(3));
if armed {
eui.force_resume = None;
show_embed_resume(eui, app, &key);
} else {
eui.force_resume = Some((key, Instant::now()));
app.lock().unwrap().status =
"session may be live in another claude instance — ctrl-↓ again to resume anyway"
.into();
}
}
}
}
fn event_loop(
terminal: &mut ratatui::DefaultTerminal,
app: SharedApp,
eui: &mut EmbedUi,
) -> anyhow::Result<()> {
let mut sel: Option<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))?;
if eui.cursor_shape != applied_cursor {
applied_cursor = eui.cursor_shape;
let style = eui
.cursor_shape
.map_or(SetCursorStyle::DefaultUserShape, crate::term::cursor_style);
let _ = execute!(std::io::stdout(), style);
}
// Scheduled transcript wipe (set by the tap when an embedded-session
// turn finishes): keeps the pane prompt-only.
let clear_due = {
let mut a = app.lock().unwrap();
if a.embed_clear_at.is_some_and(|t| std::time::Instant::now() >= t) {
a.embed_clear_at = None;
true
} else {
false
}
};
if clear_due
&& let Some(et) = &eui.term
&& !et.exited()
{
et.clear_screen();
}
if !event::poll(Duration::from_millis(33))? {
continue;
}
let ev = event::read()?;
// Bracketed paste: forward the whole blob to the child as one paste
// when the pane has focus (no submit on embedded newlines). Nowhere
// else accepts text input, so ignore it otherwise.
if let Event::Paste(text) = &ev {
if eui.focused()
&& let Some(et) = &eui.term
{
et.paste(text);
}
continue;
}
// Wheel scroll always drives the feed, regardless of which pane has
// keyboard focus (the embedded pane gets no mouse forwarding anyway).
// The agent popup is modal, so while it is up the wheel is *its*:
// it moves the picker highlight or scrolls the agent's feed.
// Left drag = text selection; the copy happens on release in draw().
if let Event::Mouse(m) = &ev {
match m.kind {
MouseEventKind::ScrollUp => wheel(&app, -1),
MouseEventKind::ScrollDown => wheel(&app, 1),
MouseEventKind::Down(MouseButton::Left) => {
sel = Some(Selection {
start: (m.column, m.row),
end: (m.column, m.row),
dragging: true,
copy_pending: false,
});
}
MouseEventKind::Drag(MouseButton::Left) => {
if let Some(s) = sel.as_mut()
&& s.dragging
{
s.end = (m.column, m.row);
}
}
MouseEventKind::Up(MouseButton::Left) => {
if let Some(s) = sel.as_mut()
&& s.dragging
{
s.dragging = false;
if s.start == s.end {
sel = None; // plain click, nothing to copy
} else {
s.copy_pending = true;
}
}
}
_ => {}
}
continue;
}
if let Event::Key(k) = ev {
if k.kind == KeyEventKind::Release {
continue;
}
// `CT_DEBUG_KEYS=1`: surface every key event in the status bar,
// for diagnosing what the outer terminal actually delivers.
if std::env::var_os("CT_DEBUG_KEYS").is_some() {
app.lock().unwrap().status =
format!("key: {:?} mods={:?} kind={:?}", k.code, k.modifiers, k.kind);
}
// Pane controls, available in every state (alt-keys are out:
// they compose characters on some keyboard layouts):
// F2 show/hide the claude pane
// ctrl-↓ focus the claude pane (showing it if hidden)
// ctrl-↑ focus the feed
// ctrl-f toggle pane fullscreen (only while the pane is focused)
let ctrl = k.modifiers.contains(KeyModifiers::CONTROL);
// ctrl-q quits from anywhere — in particular while the claude pane
// is focused, where plain `q` is forwarded to the child.
if ctrl && k.code == KeyCode::Char('q') {
return Ok(());
}
if k.code == KeyCode::F(2) {
if k.kind == KeyEventKind::Press {
toggle_embed(eui, &app);
}
continue;
}
if ctrl && k.code == KeyCode::Down {
if k.kind == KeyEventKind::Press {
attach_selected(eui, &app);
}
continue;
}
if ctrl && k.code == KeyCode::Up {
eui.claude_focused = false;
// The feed would be invisible behind a fullscreen pane.
eui.fullscreen = false;
continue;
}
if ctrl && k.code == KeyCode::Char('f') && eui.focused() {
if k.kind == KeyEventKind::Press {
eui.fullscreen = !eui.fullscreen;
}
continue;
}
// While the claude pane has focus, everything else belongs to it.
if eui.focused() {
if let Some(et) = &eui.term {
et.key(k);
}
continue;
}
if k.kind != KeyEventKind::Press {
continue;
}
let mut a = app.lock().unwrap();
let nsess = a.merged_len();
if k.code == KeyCode::Char('c') && ctrl {
return Ok(());
}
// Filter popup captures input while open.
if let Some(sel) = a.filter_popup {
let n = FILTER_LABELS.len();
match k.code {
KeyCode::Char(' ') => a.filters[sel] = !a.filters[sel],
KeyCode::Up | KeyCode::Char('k') => a.filter_popup = Some((sel + n - 1) % n),
KeyCode::Down | KeyCode::Char('j') => a.filter_popup = Some((sel + 1) % n),
KeyCode::Char('f') | KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => {
a.filter_popup = None
}
_ => {}
}
continue;
}
// Model picker (opened with `a`): j/k move, Enter spawns a fresh
// session with the chosen model, esc/a/q cancels.
if let Some(msel) = a.model_popup {
let n = a.model_choices.len().max(1);
match k.code {
KeyCode::Up | KeyCode::Char('k') => a.model_popup = Some((msel + n - 1) % n),
KeyCode::Down | KeyCode::Char('j') => a.model_popup = Some((msel + 1) % n),
KeyCode::Esc | KeyCode::Char('a') | KeyCode::Char('q') => a.model_popup = None,
KeyCode::Enter => {
a.model_popup = None;
let model = a.model_choices.get(msel).map(|c| c.1.clone());
drop(a);
if let Some(model) = model {
show_embed_new(eui, &app, &model);
}
}
_ => {}
}
continue;
}
// Agent popup (`A`): modal, so it takes every key while open —
// the picker navigates, an agent feed scrolls. Esc steps back one
// layer (feed → picker → closed), `A`/`q` closes outright.
if a.agent_popup.is_some() {
match k.code {
KeyCode::Up | KeyCode::Char('k') => match a.agent_popup {
Some(AgentPopup::List(_)) => a.agent_popup_move(-1),
_ => {
let t = a.agent_popup_lane();
a.scroll_col(t, -1);
}
},
KeyCode::Down | KeyCode::Char('j') => match a.agent_popup {
Some(AgentPopup::List(_)) => a.agent_popup_move(1),
_ => {
let t = a.agent_popup_lane();
a.scroll_col(t, 1);
}
},
KeyCode::Enter | KeyCode::Char(' ') | KeyCode::Right | KeyCode::Char('l') => {
a.agent_popup_enter()
}
KeyCode::Esc | KeyCode::Left | KeyCode::Char('h') => a.agent_popup_back(),
KeyCode::Char('A') | KeyCode::Char('q') => a.agent_popup = None,
// Switch agents without a detour through the picker.
KeyCode::Char('[') => a.agent_popup_cycle(false),
KeyCode::Char(']') => a.agent_popup_cycle(true),
KeyCode::PageUp => {
let t = a.agent_popup_lane();
a.scroll_col(t, -20);
}
KeyCode::PageDown => {
let t = a.agent_popup_lane();
a.scroll_col(t, 20);
}
KeyCode::Home | KeyCode::Char('g') => {
let t = a.agent_popup_lane();
a.scroll_col_end(t, false);
}
KeyCode::End | KeyCode::Char('G') => {
let t = a.agent_popup_lane();
a.scroll_col_end(t, true);
}
_ => {}
}
continue;
}
match k.code {
KeyCode::Char('q') => return Ok(()),
// Esc unwinds one layer: visual mode → turn tree → quit.
KeyCode::Esc => match a.expanded.as_mut() {
Some(e) if e.visual.is_some() => e.visual = None,
Some(_) => a.expanded = None,
None => return Ok(()),
},
KeyCode::Char('f') => a.filter_popup = Some(0),
// a → pick a model, then spawn a brand-new session (no need to
// resume + /clear just to get a fresh chat).
KeyCode::Char('a') => a.model_popup = Some(0),
// n / N → jump the feed scroll to the next / previous user
// prompt (honoured on the next draw, where entry heights live).
KeyCode::Char('n') => {
a.follow = false;
a.prompt_jump = Some(true);
}
KeyCode::Char('N') => {
a.follow = false;
a.prompt_jump = Some(false);
}
KeyCode::Char('s') => a.show_sessions = !a.show_sessions,
// c → attach the most recent past session (like `claude -c`):
// select it (the scanner keeps disk_sessions newest-first),
// then run the normal attach logic.
KeyCode::Char('c') => {
match a.disk_sessions.first().map(|d| d.uuid.clone()) {
None => a.status = "no past sessions found for this directory".into(),
Some(uuid) => {
a.select_key(&uuid);
a.clear_turn_focus();
drop(a);
attach_selected(eui, &app);
}
}
continue;
}
// Turn tree (sessions panel): space toggles the selected
// session's tree, →/l expands / steps into the turns, ←/h
// steps out / collapses (yazi-style), v anchors a visual
// range, b materializes a new decoupled session from the
// highlighted turn (its chain) or the visual selection.
KeyCode::Char(' ') => a.toggle_expand(),
KeyCode::Right | KeyCode::Char('l') => a.tree_right(),
KeyCode::Left | KeyCode::Char('h') => a.tree_left(),
KeyCode::Char('v') => a.toggle_visual(),
KeyCode::Char('b') => match a.branch_selected() {
Ok(u) => {
a.status = format!(
"branched → {} (ctrl-↓ to start it)",
u.chars().take(8).collect::<String>()
);
}
Err(e) => a.status = e,
},
// Tab / BackTab cycle sessions. `a` is the new-session picker;
// `n`/`N` jump between user prompts; `p` is no longer a
// back-tab mirror.
KeyCode::Tab if nsess > 0 => {
a.selected = (a.selected + 1) % nsess;
a.follow = true;
a.clear_turn_focus();
}
KeyCode::BackTab if nsess > 0 => {
a.selected = (a.selected + nsess - 1) % nsess;
a.follow = true;
a.clear_turn_focus();
}
// j/k/↑/↓ drive the session/turn highlight, never the feed —
// the feed scrolls with the wheel (or PgUp/PgDn/g/G).
KeyCode::Up | KeyCode::Char('k') => a.nav(false),
KeyCode::Down | KeyCode::Char('j') => a.nav(true),
// A → the subagent popup: one agent opens straight into its
// feed, several show the picker first. Subagents are shown
// nowhere else, so this is also how a finished agent's output
// is read back.
KeyCode::Char('A') => a.toggle_agent_popup(),
KeyCode::PageUp => a.scroll_col(None, -20),
KeyCode::PageDown => a.scroll_col(None, 20),
KeyCode::Home | KeyCode::Char('g') => a.scroll_col_end(None, false),
KeyCode::End | KeyCode::Char('G') => a.scroll_col_end(None, true),
_ => {}
}
}
}
}
fn draw(
f: &mut Frame,
app: &SharedApp,
eui: &mut EmbedUi,
selection: &mut Option<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 only while the feed selection is on the embedded
// session: tabbing away hides it (the child keeps running — hide, don't
// kill), tabbing back reveals it instantly. ctrl-↓ attaches elsewhere.
let selected_is_embed =
a.embed_session.is_some() && a.selected_key().as_deref() == a.embed_session.as_deref();
// The pane stays visible while it holds keyboard focus even if the
// selection isn't (yet) on its session: its real session id is learned
// from the first request, and an involuntary selection move (another
// session's traffic) must never yank focus out of a pane the user is
// typing in. Intentional navigation still hides it — ctrl-↑ drops focus,
// after which the selection rule applies and tabbing away hides the pane.
let show_embed =
eui.visible && eui.term.is_some() && (selected_is_embed || eui.claude_focused);
if !show_embed {
// An invisible pane must not swallow keystrokes (the selection can
// move under us, e.g. a new session auto-jump).
eui.claude_focused = false;
eui.fullscreen = false;
}
// Mirror focus into shared state so the off-thread tap can avoid stealing
// the selection from a pane the user is actively driving.
a.pane_focused = eui.focused();
let pane_view = if eui.fullscreen {
PaneView::Full
} else if a.embed_grow {
PaneView::Interactive
} else {
PaneView::Compact
};
let embed_h = if show_embed {
let total = f.area().height;
let cap = total.saturating_sub(6).max(1);
match pane_view {
// Whole screen minus the footer and the 1-row Min(1) the feed area
// keeps (layout below still reserves it).
PaneView::Full => total.saturating_sub(2),
// Both cropped views size themselves from what the child actually
// drew, smoothed by the same hysteresis so the PTY isn't resized
// (→ Ink repaint → flicker) on every per-frame wobble.
PaneView::Interactive => {
// Until the prompt appears on screen (the tap grows the pane a
// beat early) fall back to the tap's estimate from the tool
// JSON, then to three quarters of the screen.
let measured = eui.term.as_ref().and_then(EmbeddedTerm::interactive_rows).or(a
.embed_grow_rows
.map(|r| r.saturating_add(3))); // + borders + hint row
let inner = eui.compact_height(measured);
inner.saturating_add(2).clamp(EMBED_MIN.min(cap), cap)
}
PaneView::Compact => {
let measured = eui.term.as_ref().and_then(EmbeddedTerm::compact_rows);
let inner = eui.compact_height(measured);
inner.saturating_add(2).clamp(EMBED_MIN.min(cap), cap)
}
}
} else {
0
};
// Keyboard focus lives in exactly one place: the claude pane (when it has
// focus) or the feed/sessions area otherwise. One rule for every panel:
// focused → bold accent border, unfocused → dimmed. So the feed (and the
// sessions panel, if shown) and the pane all read the same way, and the
// dimming tells you at a glance which side ctrl-↑/ctrl-↓ left focus on.
let border_style = |focused: bool| {
if focused {
Style::new().fg(ACCENT).bold()
} else {
Style::new().dark_gray()
}
};
let feed_focused = !eui.focused();
let [main, embed_area, footer] = Layout::vertical([
Constraint::Min(1),
Constraint::Length(embed_h),
Constraint::Length(1),
])
.areas(f.area());
// Uniform: the sessions panel is always half the width, so titles have
// room to read the same whether or not a turn tree is expanded.
let left_width = if a.show_sessions { main.width / 2 } else { 0 };
let [left, right] = Layout::horizontal([Constraint::Length(left_width), Constraint::Min(10)])
.areas(main);
let live_n = a.sessions.len();
let stubs = a.visible_stubs();
let sel = a.selected.min((live_n + stubs.len()).saturating_sub(1));
a.selected = sel;
let sel_key = a.selected_key();
// Agent scroll state belongs to the displayed session: a switch drops it
// (and closes the popup), so a lane id can never inherit another session's
// offset or, worse, point into another session's lane vec.
if a.cols_session != sel_key.clone().unwrap_or_default() {
a.cols_session = sel_key.clone().unwrap_or_default();
a.lane_cols.clear();
a.agent_popup = None;
}
// Session list: live sessions first, then this directory's past sessions
// as dimmed stubs (kept fresh by the scanner thread — no I/O here). The
// expanded session's turn rows render directly under its row, abandoned
// branches indented one level under their fork point (⑂).
if a.show_sessions {
// Inner content width (panel minus its border): titles wrap to this,
// turn labels truncate to it.
let inner_w = left.width.saturating_sub(2).max(1) as usize;
let mut items: Vec<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
};
(s.key.clone(), lead, live_title(s), meta, title_style)
} else {
let d = &a.disk_sessions[stubs[m - live_n]];
let id: String = d.uuid.chars().take(8).collect();
// ⑂N = subagent transcripts recorded next to this session.
let meta = if d.agents > 0 {
format!("{id} · ⑂{}", d.agents)
} else {
id
};
(
d.uuid.clone(),
"· ".dark_gray(),
d.label.clone(),
meta,
white,
)
};
let mut rows: Vec<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(border_style(feed_focused)),
)
.highlight_style(ratatui::style::Style::new().reversed()),
left,
&mut ls,
);
}
// What the feed shows: a highlighted turn views the on-disk transcript
// along the path *through that turn* (works for live sessions too — pure
// viewing); a stub selection views its whole file; otherwise the live
// in-memory session. Path views are cached per uuid and rebuilt only
// when the requested leaf changes (a few ms even for MB-sized files).
let turn_view: Option<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 agent popup pointed at a lane that exists (a session switch or
// a rebuilt on-disk view can invalidate it). Done before the feed borrow,
// which freezes `a`.
a.validate_agent_popup();
let (feed_session, feed_leaf): (Option<&Session>, Option<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: the main feed keeps
// using App::scroll/follow, the popup's agent owns an entry in
// App::lane_cols so it can follow its own tail independently.
let mut main_col = (a.scroll, a.follow);
let mut lane_writeback: Option<(LaneId, usize, bool)> = None;
let agent_popup = a.agent_popup;
if let Some(s) = feed_session {
// The main feed always gets the whole area: a subagent never takes
// space from it (nor interleaves into it — draw_feed filters by lane).
// Agents live in the popup drawn on top, below.
let out = draw_feed(
f,
caches.get(&s.key, MAIN_LANE),
&FeedArgs {
s,
lane: MAIN_LANE,
area: right,
focused: feed_focused && agent_popup.is_none(),
filters,
live: feed_live,
leaf: feed_leaf,
scroll: main_col.0,
follow: main_col.1,
scroll_target,
prompt_jump,
title: main_title(s, a.show_sessions),
minimap: true,
},
);
main_col = (out.scroll, out.follow);
// The subagent popup: 80% of the feed area, centred, drawn over the
// main feed. Either the agent picker, or one agent's whole stream.
match agent_popup {
None => {}
Some(AgentPopup::List(sel)) => {
draw_agent_list(f, popup_rect(right), s, &App::agent_list_of(s), sel);
}
// `validate_agent_popup` already dropped a lane this session does
// not have; the bound check makes any disagreement between it and
// the session actually rendered here a blank frame instead of a
// panic (the UI thread holds the mutex the proxy tap needs).
Some(AgentPopup::Feed(lane)) if (lane as usize) < s.lanes.len() => {
let area = popup_rect(right);
// `2/3` in the title: which agent of the session this is, so
// `[`/`]` has somewhere to walk from.
let list = App::agent_list_of(s);
let pos = list
.iter()
.position(|&x| x == lane)
.map(|i| (i + 1, list.len()));
let (scroll, follow) = a.lane_cols.get(&lane).copied().unwrap_or((0, true));
f.render_widget(Clear, area);
let out = draw_feed(
f,
caches.get(&s.key, lane),
&FeedArgs {
s,
lane,
area,
// The popup is modal: it is where the keys go, so it
// always reads as focused.
focused: true,
filters,
live: feed_live,
leaf: feed_leaf,
scroll,
follow,
// A subagent's stream has no user prompts and no turn
// tree, so neither the minimap nor the turn/prompt
// jumps apply to it.
scroll_target: None,
prompt_jump: None,
title: lane_title(&s.lanes[lane as usize], pos),
minimap: false,
},
);
lane_writeback = Some((lane, out.scroll, out.follow));
}
Some(AgentPopup::Feed(_)) => {}
}
} else {
f.render_widget(
Paragraph::new(format!(
"\n\n waiting for traffic…\n\n make sure claude runs with:\n ANTHROPIC_BASE_URL=http://127.0.0.1:{}",
eui.port
))
.dark_gray()
.block(Block::bordered().border_style(border_style(feed_focused))),
right,
);
}
a.scroll = main_col.0;
a.follow = main_col.1;
if let Some((lane, scroll, follow)) = lane_writeback {
a.lane_cols.insert(lane, (scroll, follow));
}
// Embedded claude pane
let embed_focused = eui.focused();
// Cursor shape the pane wants this frame (None unless it draws a cursor).
let mut want_cursor: Option<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();
let title = if exited {
format!(" claude · {id} · exited ")
} else {
format!(" claude · {id} ")
};
// Bold accent border = keyboard focus; dimmed = unfocused (same rule
// as the feed/sessions panels above).
let block = Block::bordered().title(title).border_style(border_style(embed_focused));
let inner = block.inner(embed_area);
f.render_widget(block, embed_area);
if exited {
f.render_widget(
Paragraph::new("\n claude exited — F2 to close this pane")
.dark_gray(),
inner,
);
} else {
// Fullscreen shows the child's screen verbatim (PTY sized exactly,
// no chrome crop); the compact/interactive pane crops Claude Code
// chrome, so the PTY gets pad rows to draw what we hide.
//
// Only fullscreen ties the PTY to the visible pane. Every cropped
// view derives its height by measuring what's already on the
// child's screen, so tying the PTY to that height is a feedback
// loop — Ink only ever draws as many rows as the PTY reports, so a
// view that suddenly needs more rows than PTY+pad can never draw
// them, can never be measured, and stays stuck small.
//
// Compact hits this with an `@`/`/` menu or a big paste.
// Interactive hits it harder: Claude Code *lays out* its
// AskUserQuestion / ExitPlanMode prompt against the reported rows
// and switches to a truncated rendering when they are few, so a
// pane-sized PTY made the child itself hide parts of the prompt —
// no amount of framing on our side could bring them back. A
// screen-tall PTY lets Ink draw the prompt in full; the render
// window still shows only the framed region.
let pty_rows = if pane_view == PaneView::Full {
inner.height
} else {
f.area().height
};
et.resize(pty_rows, inner.width, pane_view != PaneView::Full);
if let Some(pos) = et.render(inner, f.buffer_mut(), pane_view) {
f.set_cursor_position(pos);
want_cursor = Some(et.cursor_shape());
}
}
}
eui.cursor_shape = want_cursor;
let visual_on = a.expanded.as_ref().is_some_and(|e| e.visual.is_some());
let keys = if a.filter_popup.is_some() {
"space toggle · j/k move · f/esc close"
} else if a.model_popup.is_some() {
"enter new session · j/k move · esc cancel"
} else if matches!(a.agent_popup, Some(AgentPopup::List(_))) {
"enter watch agent · j/k move · esc/A close"
} else if a.agent_popup.is_some() {
"j/k · PgUp/PgDn · g/G scroll · [/] agent · esc back · A close"
} else if embed_focused {
"ctrl-↑ feed · ctrl-f fullscreen · ctrl-q quit · F2 hide claude"
} else if visual_on {
"j/k extend · b branch selection · esc cancel"
} else if a.on_turns() {
"j/k turns · v visual · b branch · ←/space close · wheel scrolls feed"
} else if show_embed {
"ctrl-↓ claude · a add session · q quit · j/k move · space tree · f filter · F2 hide"
} else {
"q quit · n new · j/k move · space/→ tree · f filter · c continue · ctrl-↓ attach"
};
// `A` only matters when the displayed session has agents at all — and it is
// the *only* way to see one, so the hint leads the footer instead of
// trailing it, where the long key list gets cut off on narrow terminals.
// Not while the pane has focus: there every key belongs to the child.
let n_agents = a.agent_list().len();
let keys = if a.agent_popup.is_some() || embed_focused || n_agents == 0 {
keys.to_string()
} else {
format!("A agents ({n_agents}) · {keys}")
};
f.render_widget(
Paragraph::new(Line::from(format!(" {} | {keys}", a.status)).dark_gray()),
footer,
);
// Filter popup
if let Some(fsel) = a.filter_popup {
let w = 26u16.min(main.width);
let h = (FILTER_LABELS.len() as u16 + 2).min(main.height);
let area = Rect {
x: main.x + (main.width.saturating_sub(w)) / 2,
y: main.y + (main.height.saturating_sub(h)) / 2,
width: w,
height: h,
};
f.render_widget(Clear, area);
let items: Vec<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 "))
.highlight_style(ratatui::style::Style::new().reversed()),
area,
&mut ls,
);
}
// Model picker popup (n → choose a model → fresh session).
if let Some(msel) = a.model_popup {
let w = 30u16.min(main.width);
let h = (a.model_choices.len() as u16 + 2).min(main.height);
let area = Rect {
x: main.x + (main.width.saturating_sub(w)) / 2,
y: main.y + (main.height.saturating_sub(h)) / 2,
width: w,
height: h,
};
f.render_widget(Clear, area);
let items: Vec<ListItem> = a
.model_choices
.iter()
.map(|(label, _)| ListItem::new(format!(" {label}")))
.collect();
let mut ls = ListState::default();
ls.select(Some(msel));
f.render_stateful_widget(
List::new(items)
.block(Block::bordered().title(" new session "))
.highlight_style(ratatui::style::Style::new().reversed()),
area,
&mut ls,
);
}
drop(a); // release the app lock before the mouse-selection pass
// Mouse selection: while dragging, paint a reversed-video highlight over
// the (fully rendered) buffer; on release, read the selected cells out of
// the buffer instead and copy them to the clipboard.
if let Some(s) = selection.take() {
// Linear (terminal-style) selection: order endpoints by (row, col).
let (mut from, mut to) = (s.start, s.end);
if (from.1, from.0) > (to.1, to.0) {
std::mem::swap(&mut from, &mut to);
}
let buf = f.buffer_mut();
// Clip the selection to one panel's *inner* content box so a multi-row
// sweep grabs only that panel's text — never an adjacent panel or the
// box-drawing borders between them (a linear selection otherwise spans
// the full screen width on its middle rows, which is what produced the
// border/padding artefacts). The panel is chosen by where the drag
// began, hit-tested against each panel's *outer* rect so starting on a
// border or in the margin still resolves to the panel; the feed is the
// default, since that is what prose gets copied from.
let contains = |r: Rect, p: (u16, u16)| {
p.0 >= r.left() && p.0 < r.right() && p.1 >= r.top() && p.1 < r.bottom()
};
let outer = if left.width > 0 && contains(left, s.start) {
left
} else if embed_h > 0 && contains(embed_area, s.start) {
embed_area
} else {
right
};
// Inner content box = the bordered rect minus its 1-cell border.
let region = Rect {
x: outer.x.saturating_add(1),
y: outer.y.saturating_add(1),
width: outer.width.saturating_sub(2),
height: outer.height.saturating_sub(2),
};
let (clip_l, clip_r, clip_t, clip_b) = (
region.left(),
region.right().saturating_sub(1),
region.top(),
region.bottom().saturating_sub(1),
);
let y_start = from.1.max(clip_t);
let y_end = to.1.min(clip_b);
let mut lines: Vec<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);
}
}
}
/// Share of the feed area the subagent popup covers, per axis.
const POPUP_PCT: u32 = 80;
/// The subagent popup's rect: `POPUP_PCT` of the *feed* area on both axes,
/// centred over it. Deliberately not the whole main area — the sessions panel
/// keeps its half, and the ring of main feed left visible around the popup
/// says "this is an overlay, not the conversation you were reading". Clamped
/// to `area`, so a tiny terminal simply gets all of it.
fn popup_rect(area: Rect) -> Rect {
let pct = |v: u16, floor: u16| -> u16 {
let scaled = (u32::from(v) * POPUP_PCT / 100) as u16;
scaled.max(floor.min(v))
};
let (w, h) = (pct(area.width, 24), pct(area.height, 6));
Rect {
x: area.x + (area.width.saturating_sub(w)) / 2,
y: area.y + (area.height.saturating_sub(h)) / 2,
width: w,
height: h,
}
}
/// Title of the main feed: id (unless the sessions panel already shows it),
/// the main chain's model and its token counters.
fn main_title(s: &Session, show_sessions: bool) -> String {
let m = s.main();
let tokens = format!(
"{} · in {} · out {} ",
m.model,
fmt_tokens(m.input_tokens),
fmt_tokens(m.output_tokens)
);
if show_sessions {
format!(" {tokens}")
} else {
let id: String = s.key.chars().take(8).collect();
format!(" {id} · {tokens}")
}
}
/// Title of an agent's popup feed: activity mark, agent type · description,
/// model, output tokens — plus `2/3` when there are siblings for `[`/`]` to
/// walk to.
fn lane_title(l: &Lane, pos: Option<(usize, usize)>) -> String {
let mut t = format!(
" {} {} · {} · out {}",
lane_mark(l),
l.title(),
short_model(&l.model),
fmt_tokens(l.output_tokens)
);
if let Some((i, n)) = pos.filter(|&(_, n)| n > 1) {
t.push_str(&format!(" · {i}/{n}"));
}
t.push(' ');
t
}
/// Activity mark, the same three-way answer the picker sorts on
/// (`Lane::running`): running now, known finished, or neither (idle without a
/// finish signal, or a lane read from disk).
fn lane_mark(l: &Lane) -> &'static str {
if l.running() {
""
} else if l.finished() {
// A `<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_agent_list(f: &mut Frame, area: Rect, s: &Session, lanes: &[LaneId], sel: usize) {
f.render_widget(Clear, area);
let inner_w = area.width.saturating_sub(3) as usize;
let items: Vec<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)
};
let head = format!(" {} {}", lane_mark(l), l.title());
let meta = format!(
" {} · {} tools · out {}",
short_model(&l.model),
l.tool_calls,
fmt_tokens(l.output_tokens)
);
ListItem::new(vec![
Line::from(truncate_str(&head, inner_w)).style(style),
Line::from(truncate_str(&meta, inner_w)).dark_gray(),
])
})
.collect();
let running = lanes
.iter()
.filter(|&&i| s.lanes[i as usize].running())
.count();
let mut ls = ListState::default();
ls.select(Some(sel.min(lanes.len().saturating_sub(1))));
f.render_stateful_widget(
List::new(items)
.block(
Block::bordered()
.title(format!(" agents · {running} of {} running ", lanes.len()))
.border_style(Style::new().fg(ACCENT).bold()),
)
.highlight_style(Style::new().reversed()),
area,
&mut ls,
);
}
/// Everything one feed needs. Bundled because a feed is drawn from two places
/// (the main chain, and one agent inside the popup) with only these differing.
struct FeedArgs<'a> {
s: &'a Session,
lane: LaneId,
area: Rect,
focused: bool,
filters: [bool; FILTER_LABELS.len()],
live: bool,
leaf: Option<usize>,
scroll: usize,
follow: bool,
/// Pin a turn's first entry to the top (main feed only).
scroll_target: Option<usize>,
/// `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);
// (In)validate the render cache: a width change, session switch, or
// a different transcript view of the same session (live vs on-disk,
// another tree path) invalidates everything, otherwise only entries
// whose fingerprint changed (the in-flight one, or a tool entry
// whose result attached) are re-rendered.
if cache.session_key != s.key
|| cache.width != feed_width
|| cache.leaf != args.leaf
|| cache.live != args.live
|| cache.lane != args.lane
{
cache.session_key = s.key.clone();
cache.width = feed_width;
cache.leaf = args.leaf;
cache.live = args.live;
cache.lane = args.lane;
cache.entries.clear();
}
for (i, e) in s.entries.iter().enumerate() {
if e.lane != args.lane {
if cache.entries.len() <= i {
cache.entries.push(CachedEntry::blank());
}
continue;
}
let fp = fingerprint(e, args.focused);
if cache.entries.get(i).is_none_or(|c| c.fingerprint != fp) {
let lines = entry_lines(e, feed_width, args.focused);
let height = wrapped_height(&lines, feed_width);
let ce = CachedEntry {
fingerprint: fp,
lines,
height,
};
if i < cache.entries.len() {
cache.entries[i] = ce;
} else {
cache.entries.push(ce);
}
}
}
// Visible (filter-passing) entries of this lane and their total height.
let visible: Vec<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 {
// Pin the highlighted turn's first entry to the viewport top.
new_scroll = visible
.iter()
.take_while(|&&i| i < target)
.map(|&i| cache.entries[i].height)
.sum::<usize>()
.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();
match a.agent_popup {
Some(AgentPopup::List(_)) => a.agent_popup_move(dir),
// follow re-engages automatically when draw() clamps the scroll to
// the bottom.
_ => {
let target = a.agent_popup_lane();
a.scroll_col(target, dir * 3);
}
}
}
/// Copy text to the system clipboard via the OSC 52 escape sequence (works
/// over SSH; the outer terminal must support it, as it must for Claude Code).
fn osc52_copy(text: &str) {
use std::io::Write;
let mut out = std::io::stdout();
let _ = write!(out, "\x1b]52;c;{}\x07", base64(text.as_bytes()));
let _ = out.flush();
}
fn base64(data: &[u8]) -> String {
const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut s = String::with_capacity(data.len().div_ceil(3) * 4);
for chunk in data.chunks(3) {
let n = (u32::from(chunk[0]) << 16)
| (u32::from(*chunk.get(1).unwrap_or(&0)) << 8)
| u32::from(*chunk.get(2).unwrap_or(&0));
s.push(A[(n >> 18) as usize & 63] as char);
s.push(A[(n >> 12) as usize & 63] as char);
s.push(if chunk.len() > 1 { A[(n >> 6) as usize & 63] as char } else { '=' });
s.push(if chunk.len() > 2 { A[n as usize & 63] as char } else { '=' });
}
s
}
fn short_model(m: &str) -> String {
m.strip_prefix("claude-").unwrap_or(m).to_string()
}
/// A human title for a live session: the first line of its first user prompt
/// (what the session is *about*), falling back to the model / a placeholder
/// before any prompt has streamed in.
fn live_title(s: &Session) -> String {
if let Some(e) = s.entries.iter().find(|e| matches!(e.kind, Kind::User)) {
let first = e.content.lines().map(str::trim).find(|l| !l.is_empty());
if let Some(t) = first.filter(|t| !t.is_empty()) {
return t.to_string();
}
}
if s.entries.is_empty() {
"(new session)".into()
} else {
short_model(&s.main().model)
}
}
/// Greedy word-wrap to `width` columns (char-counted). Words longer than the
/// width are hard-split. Always returns at least one (possibly empty) row.
fn wrap_words(text: &str, width: usize) -> Vec<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);
match name.to_ascii_lowercase().as_str() {
// Diff/content view; success confirmations are noise, only surface failures.
"write" | "edit" if render_file_tool(name, input, out, width) => {
if result.is_some_and(|r| r.is_error) {
push_result(out, result, None);
}
}
"read" => {
let mut head = vec![
"⚙ Read ".yellow().bold(),
sf("file_path").unwrap_or("?").to_string().bold(),
];
// offset/limit (or pages) = an explicit range: show the whole
// result. Plain reads get clipped to 5 lines.
let ranged = input.get("offset").is_some()
|| input.get("limit").is_some()
|| input.get("pages").is_some();
if ranged {
let mut parts = Vec::new();
if let Some(o) = input.get("offset").and_then(Value::as_u64) {
parts.push(format!("offset {o}"));
}
if let Some(l) = input.get("limit").and_then(Value::as_u64) {
parts.push(format!("limit {l}"));
}
if let Some(p) = sf("pages") {
parts.push(format!("pages {p}"));
}
head.push(format!(" ({})", parts.join(", ")).dark_gray());
}
out.push(Line::from(head));
push_result(out, result, if ranged { None } else { Some(5) });
}
"bash" => {
let cmd = sf("command").unwrap_or("?");
let mut cmd_lines = cmd.lines();
out.push(Line::from(vec![
"⚙ Bash ".yellow().bold(),
sanitize(cmd_lines.next().unwrap_or("")).cyan(),
]));
for l in cmd_lines {
out.push(Line::from(format!(" {}", sanitize(l))).cyan());
}
push_result(out, result, None);
}
// Subagent spawn. The child's own stream is never in this feed (it is
// the `A` popup's), so the main chain shows only what was delegated
// (type, description, opening line of the prompt) and the report that
// came back — which is all the parent model ever saw of it.
"agent" | "task" => {
let kind = sf("subagent_type").unwrap_or("agent");
let mut head = vec![
format!("⚙ Agent({kind}) ").yellow().bold(),
sf("description").unwrap_or_default().to_string().bold(),
];
if input.get("run_in_background").and_then(Value::as_bool) == Some(true) {
head.push(" (background)".dark_gray());
}
out.push(Line::from(head));
if let Some(prompt) = sf("prompt") {
out.push(Line::from(format!("{}", one_line(prompt))).dark_gray());
}
push_result(out, result, Some(20));
}
"glob" | "grep" => {
out.push(Line::from(vec![
format!("{name} ").yellow().bold(),
format!("\"{}\"", sf("pattern").unwrap_or("?")).cyan(),
" in ".dark_gray(),
sf("path").unwrap_or(".").to_string().into(),
]));
push_result(out, result, None);
}
"todowrite" => {
out.push(Line::from("⚙ Todos").yellow().bold());
for t in input
.get("todos")
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or_default()
{
let content = t.get("content").and_then(Value::as_str).unwrap_or("?");
let row = |mark: &str| format!(" {mark} {}", sanitize(content));
out.push(match t.get("status").and_then(Value::as_str) {
Some("completed") => Line::from(row("")).green(),
Some("in_progress") => Line::from(row("")).yellow(),
_ => Line::from(row("")).dark_gray(),
});
}
// The result just echoes the list back; only surface failures.
if result.is_some_and(|r| r.is_error) {
push_result(out, result, None);
}
}
// Generic fallback: tool name header, inputs as `key: value` rows.
_ => {
out.push(Line::from(format!("{name}")).yellow().bold());
match input.as_object() {
Some(obj) => {
for (k, v) in obj {
let val = match v {
Value::String(s) => s.clone(),
other => other.to_string(),
};
out.push(Line::from(vec![
format!(" {k}: ").dark_gray(),
one_line(&val).cyan(),
]));
}
}
None => {
for l in input.to_string().lines() {
out.push(Line::from(format!(" {}", sanitize(l))).cyan());
}
}
}
push_result(out, result, None);
}
}
}
/// Renders a tool_result under its tool entry: `⎿`-marked, dimmed (red when
/// `is_error`). `limit` clips to the first N lines with a "N more lines" tail.
fn push_result<'a>(out: &mut Vec<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;
}
let total = r.content.lines().count();
let shown = limit.map_or(total, |n| n.min(total));
for (i, l) in r.content.lines().take(shown).enumerate() {
let prefix = if i == 0 { "" } else { " " };
let line = format!("{prefix}{}", sanitize(l));
out.push(if r.is_error {
Line::from(line).red()
} else {
Line::from(line).dark_gray()
});
}
if shown < total {
out.push(
Line::from(format!(" {} more lines", total - shown))
.dark_gray()
.italic(),
);
}
}
/// First line only, clipped, with an ellipsis when anything was dropped.
fn one_line(s: &str) -> String {
const MAX: usize = 120;
let first = s.lines().next().unwrap_or("");
let truncated = first.chars().count() > MAX;
let multiline = s.contains('\n');
let clipped: String = first.chars().take(MAX).collect();
if truncated || multiline {
format!("{}", sanitize(&clipped))
} else {
sanitize(&clipped)
}
}
/// Dark diff backgrounds that stay readable under white/default text.
const DIFF_DEL: Color = Color::Indexed(52); // dark red
const DIFF_ADD: Color = Color::Indexed(22); // dark green
/// Human-readable rendering for file-mutating tools: `file_path` as header,
/// body as numbered lines (Edit shows old/new as a red/green diff).
/// Returns false when the tool/input isn't one we special-case.
fn render_file_tool<'a>(name: &str, input: &Value, out: &mut Vec<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),
])),
}
}
}
/// A literal tab survives ratatui as a `\t` cell symbol that the terminal then
/// renders by jumping to the next tab stop, desyncing the per-cell cursor and
/// scattering everything painted after it (other control chars render
/// zero-width and smear too). For a single visual row: expand tabs, drop the
/// rest. Tab-indented code is the main offender.
fn sanitize(l: &str) -> String {
let mut s = String::with_capacity(l.len());
for c in l.chars() {
match c {
'\t' => s.push_str(" "),
c if c.is_control() => {}
c => s.push(c),
}
}
s
}
/// Like [`sanitize`] but keeps `\n`, for multi-line content rendered as a block
/// (markdown, where newlines carry structure). Same tab/control handling: the
/// content is split into rows downstream, so a stray tab must already be gone.
fn sanitize_md(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'\n' => out.push('\n'),
'\t' => out.push_str(" "),
c if c.is_control() => {}
c => out.push(c),
}
}
out
}
#[cfg(test)]
mod tests {
use super::{
SHRINK_DELAY, base64, color_on, entry_lines, popup_rect, sanitize_md, smooth_compact,
truncate_str, wrap_words,
};
use crate::app::{Entry, Kind};
use ratatui::layout::Rect;
use ratatui::style::Color;
use std::time::Instant;
/// The subagent popup covers 80% of the *feed* area, centred — never the
/// sessions panel, and never so small that a tiny terminal loses the view.
#[test]
fn agent_popup_covers_four_fifths_of_the_feed() {
// Feed area offset inside the screen (sessions panel to its left).
let feed = Rect::new(40, 1, 60, 30);
let r = popup_rect(feed);
assert_eq!((r.width, r.height), (48, 24), "80% on both axes");
// Centred over the feed, so the main feed shows around every edge.
assert_eq!(r.x, 40 + 6);
assert_eq!(r.y, 1 + 3);
assert!(r.x >= feed.x && r.right() <= feed.right());
assert!(r.y >= feed.y && r.bottom() <= feed.bottom());
// Tiny feed: the popup takes all of it rather than collapsing to a
// couple of unusable rows.
let tiny = Rect::new(0, 0, 20, 5);
let r = popup_rect(tiny);
assert_eq!((r.width, r.height), (20, 5));
// A wide terminal must not overflow the percentage arithmetic.
let wide = popup_rect(Rect::new(0, 0, u16::MAX, 100));
assert_eq!(wide.width, 52428);
}
/// The compact pane grows on the frame the prompt gets taller, but a
/// smaller reading is held back until it has been stable for SHRINK_DELAY —
/// so the per-frame wobble during subagent turns / menu filtering doesn't
/// resize the PTY (which would make Claude Code repaint and flicker). A
/// transient `None` reading keeps the last height.
#[test]
fn compact_height_grows_fast_shrinks_slow() {
let mut applied = 7u16;
let mut pending = None;
// Grow: adopted immediately, no pending shrink.
assert_eq!(smooth_compact(&mut applied, &mut pending, Some(12)), 12);
assert!(pending.is_none());
// Transient miss: height unchanged.
assert_eq!(smooth_compact(&mut applied, &mut pending, None), 12);
// A smaller reading is not applied yet — it only arms a pending shrink.
assert_eq!(smooth_compact(&mut applied, &mut pending, Some(6)), 12);
assert!(pending.is_some());
// A bounce back up while shrink is pending cancels it.
assert_eq!(smooth_compact(&mut applied, &mut pending, Some(12)), 12);
assert!(pending.is_none());
// Re-arm the shrink, then backdate its timestamp past the delay so the
// next matching reading commits it (no real sleep needed).
assert_eq!(smooth_compact(&mut applied, &mut pending, Some(6)), 12);
pending = Some((6, Instant::now() - SHRINK_DELAY - std::time::Duration::from_millis(1)));
assert_eq!(smooth_compact(&mut applied, &mut pending, Some(6)), 6);
assert!(pending.is_none());
}
#[test]
fn sanitize_md_expands_tabs_keeps_newlines() {
assert_eq!(sanitize_md("a\tb\nc"), "a b\nc");
// other control chars (here a CR) are dropped, newlines survive
assert_eq!(sanitize_md("x\r\ny"), "x\ny");
}
/// A tab-indented code block in an assistant message must not leave literal
/// tabs (or any other control char) in the rendered cells — a `\t` symbol
/// reaches the terminal verbatim and shifts the cursor, scattering the row.
#[test]
fn feed_text_strips_control_chars() {
let e = Entry::done(Kind::Text, "```\n\tif self.queued:\n\t\treturn\n```".into());
let lines = entry_lines(&e, 60, false);
for line in &lines {
for span in &line.spans {
assert!(
!span.content.chars().any(|c| c.is_control()),
"rendered span still contains a control char: {:?}",
span.content
);
}
}
}
#[test]
fn color_on_contrasts_with_background() {
// Bright orange / white → black text; dark grey / blue → white text.
assert_eq!(color_on(Color::Indexed(208)), Color::Black);
assert_eq!(color_on(Color::White), Color::Black);
assert_eq!(color_on(Color::Indexed(238)), Color::White);
assert_eq!(color_on(Color::Indexed(17)), Color::White);
assert_eq!(color_on(Color::Rgb(0, 0, 0)), Color::White);
}
#[test]
fn wrap_words_greedy_and_hard_splits() {
assert_eq!(wrap_words("", 10), vec![""]);
assert_eq!(wrap_words("a b c", 3), vec!["a b", "c"]);
// an over-long word is hard-split at the width boundary
assert_eq!(wrap_words("abcdef", 2), vec!["ab", "cd", "ef"]);
}
#[test]
fn truncate_str_adds_ellipsis() {
assert_eq!(truncate_str("hello", 10), "hello");
assert_eq!(truncate_str("hello", 3), "he…");
assert_eq!(truncate_str("hello", 0), "");
}
#[test]
fn base64_matches_rfc4648_vectors() {
assert_eq!(base64(b""), "");
assert_eq!(base64(b"f"), "Zg==");
assert_eq!(base64(b"fo"), "Zm8=");
assert_eq!(base64(b"foo"), "Zm9v");
assert_eq!(base64(b"foobar"), "Zm9vYmFy");
}
}