Files
claude-cloak/src/term.rs
Jonas H b9d7d2c969 Scroll the fullscreen pane's own scrollback
Claude Code grabs no mouse and stays off the alternate screen, so a plain
terminal answers the wheel with its own scrollback and the child never hears
about it. The fullscreen pane is that terminal, so it does the same job: the
view is a stable row index into wezterm-term's scrollback, which pins the rows
you scrolled to while the child keeps writing.

The ctrl-l transcript wipe is cancelled while fullscreen — there the pane is
not prompt-only, and its transcript is the thing being scrolled.
2026-08-27 14:34:48 +02:00

1719 lines
76 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.
//! Embedded terminal module: runs `claude` inside a PTY and renders it as a
//! pane in our TUI. Self-contained — all portable-pty / wezterm-term usage
//! lives here so the feature can be toggled (or removed) without touching the
//! proxy/tap pipeline.
//!
//! Data flow: a reader thread pumps PTY output into `wezterm_term::Terminal`
//! (a full terminal model that also *answers* terminal queries by writing back
//! through the PTY writer — important for Ink-based Claude Code). The UI
//! thread locks the model each frame to paint cells, and forwards keystrokes
//! via `key_down`, which encodes them respecting whatever modes the child has
//! configured (application cursor keys, kitty keyboard, bracketed paste…).
use anyhow::Context;
use portable_pty::{native_pty_system, ChildKiller, CommandBuilder, MasterPty, PtySize};
use ratatui::buffer::Buffer;
use ratatui::crossterm::cursor::SetCursorStyle;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier};
use std::io::{Read, Write};
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
pub use wezterm_surface::CursorShape;
use wezterm_surface::CursorVisibility;
use wezterm_term::color::{ColorAttribute, ColorPalette};
use wezterm_term::{
Intensity, KeyCode, KeyModifiers, StableRowIndex, Terminal, TerminalConfiguration, TerminalSize,
Underline,
};
/// Extra PTY rows beyond the visible pane window, so the child has room to
/// draw the rows we crop (the persistent hint / token / effort chrome below
/// the statusLine).
const PTY_PAD: u16 = 4;
/// Floor for the compact pane's inner height — enough for one context row, the
/// input box (top rule + one input line + bottom rule) and the statusLine.
const MIN_COMPACT_INNER: u16 = 5;
/// Fallback inner height when the input box hasn't been located yet (e.g. the
/// startup banner before the prompt is drawn). The UI seeds its hysteresis
/// state with this until `compact_rows` first locates the box.
pub const DEFAULT_COMPACT_INNER: u16 = 7;
/// How the compact/fullscreen pane frames the child's screen. The compact pane
/// is prompt-only (the feed above shows the transcript): `Compact` dynamically
/// frames Claude Code's input box, `Interactive` is the same pane grown by the
/// tap for an AskUserQuestion / ExitPlanMode prompt (which renders a selection
/// box above the input, so we top-anchor instead), `Full` is fullscreen.
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum PaneView {
Full,
Compact,
Interactive,
}
#[derive(Debug)]
struct Config;
impl TerminalConfiguration for Config {
fn color_palette(&self) -> ColorPalette {
ColorPalette::default()
}
// Claude Code can use the kitty keyboard protocol (shift+enter etc.);
// wezterm-term implements the encoding, so let the child enable it.
fn enable_kitty_keyboard(&self) -> bool {
true
}
}
pub struct EmbeddedTerm {
term: Arc<Mutex<Terminal>>,
master: Box<dyn MasterPty + Send>,
killer: Box<dyn ChildKiller + Send + Sync>,
exited: Arc<AtomicBool>,
/// Session UUID passed to `claude --session-id`; lets the tap recognise
/// which proxied session belongs to this pane.
pub session_id: String,
/// A fresh per-spawn token injected as the `x-claude-cloak-pane` request
/// header (via `ANTHROPIC_CUSTOM_HEADERS`). The proxy keys this pane's
/// traffic by the token, *not* by `session_id`: Claude Code's interactive
/// `--session-id` is not guaranteed to be the id it reports in request
/// metadata, so the real session id is *learned* from the first tagged
/// request rather than assumed. Unique per spawn so a killed child's
/// in-flight requests can never be misattributed to its replacement.
pub pane_token: String,
/// Actual PTY rows (visible rows + pad when cropping is active).
pty_rows: u16,
cols: u16,
/// Top row of the *fullscreen* pane's view, as a stable row index —
/// `None` (the normal state) means "follow the live screen".
///
/// Claude Code grabs no mouse and stays off the alternate screen, so in a
/// plain terminal the wheel scrolls that terminal's scrollback and the
/// child never hears about it. The fullscreen pane is that terminal, so it
/// does the same job (see `scroll`). A *stable* index rather than an offset
/// because the child keeps writing while you read: a terminal pins the rows
/// you scrolled to instead of sliding them up under you.
scroll_top: Mutex<Option<StableRowIndex>>,
/// The child's pid. Kept as a plain number because a hot reload
/// (`reload.rs`) execs us: the portable-pty `Child` handle dies with the
/// old image, but we stay the same process, so the *pid* is still ours to
/// wait on and signal after the exec.
child_pid: Option<u32>,
}
/// HTTP header carrying the pane token; the proxy reads it to bind this pane's
/// traffic and strips it before forwarding upstream.
pub const PANE_TOKEN_HEADER: &str = "x-claude-cloak-pane";
impl EmbeddedTerm {
/// Spawn `claude` in a fresh PTY, routed through our proxy. `model`, when
/// non-empty, is passed as `--model <model>` (a Claude Code alias like
/// `opus`/`sonnet`/`haiku` or a full model name).
pub fn spawn(port: u16, rows: u16, cols: u16, model: &str) -> anyhow::Result<Self> {
let session_id = uuid::Uuid::new_v4().to_string();
let pane_token = uuid::Uuid::new_v4().to_string();
let mut cmd = CommandBuilder::new("claude");
cmd.args(["--session-id", &session_id]);
if !model.is_empty() {
cmd.args(["--model", model]);
}
cmd.env("ANTHROPIC_BASE_URL", format!("http://127.0.0.1:{port}"));
cmd.env("ANTHROPIC_CUSTOM_HEADERS", format!("{PANE_TOKEN_HEADER}: {pane_token}"));
if let Ok(cwd) = std::env::current_dir() {
cmd.cwd(cwd);
}
Self::spawn_cmd(cmd, session_id, pane_token, rows, cols)
}
/// Spawn `claude --resume <session_id>` to continue a past session.
/// A resumed session usually keeps its original UUID in request metadata,
/// but Claude Code is not guaranteed to (it can mint a fresh id on resume),
/// so the tap still correlates by the injected pane token and *rebinds* the
/// embed to whatever id the traffic actually reports. `--session-id` must
/// NOT be passed alongside `--resume` (rejected without `--fork-session`);
/// `--model` may be, and carries the session's last model forward
/// (`App::resume_model`) so a resume doesn't drop back to the CLI default.
pub fn spawn_resume(
port: u16,
rows: u16,
cols: u16,
session_id: &str,
model: &str,
) -> anyhow::Result<Self> {
let pane_token = uuid::Uuid::new_v4().to_string();
let mut cmd = CommandBuilder::new("claude");
cmd.args(["--resume", session_id]);
if !model.is_empty() {
cmd.args(["--model", model]);
}
cmd.env("ANTHROPIC_BASE_URL", format!("http://127.0.0.1:{port}"));
cmd.env("ANTHROPIC_CUSTOM_HEADERS", format!("{PANE_TOKEN_HEADER}: {pane_token}"));
if let Ok(cwd) = std::env::current_dir() {
cmd.cwd(cwd);
}
Self::spawn_cmd(cmd, session_id.to_string(), pane_token, rows, cols)
}
fn spawn_cmd(
cmd: CommandBuilder,
session_id: String,
pane_token: String,
rows: u16,
cols: u16,
) -> anyhow::Result<Self> {
let pty = native_pty_system()
.openpty(PtySize {
rows: rows + PTY_PAD,
cols,
pixel_width: 0,
pixel_height: 0,
})
.context("openpty")?;
let child = pty.slave.spawn_command(cmd).context("spawn child")?;
let killer = child.clone_killer();
let child_pid = child.process_id();
drop(pty.slave);
// The terminal model writes query responses (DSR/DA/XTGETTCAP…) and
// key encodings back to the child through this writer.
let writer = pty.master.take_writer().context("pty writer")?;
let term = Arc::new(Mutex::new(Terminal::new(
TerminalSize {
rows: (rows + PTY_PAD) as usize,
cols: cols as usize,
pixel_width: 0,
pixel_height: 0,
dpi: 0,
},
Arc::new(Config),
"claude-cloak",
env!("CARGO_PKG_VERSION"),
writer,
)));
let exited = Arc::new(AtomicBool::new(false));
let mut reader = pty.master.try_clone_reader().context("pty reader")?;
{
let term = term.clone();
let exited = exited.clone();
std::thread::spawn(move || {
let mut child = child;
let mut buf = [0u8; 8192];
loop {
match reader.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => term.lock().unwrap().advance_bytes(&buf[..n]),
}
}
let _ = child.wait();
exited.store(true, Ordering::Relaxed);
});
}
Ok(Self {
term,
master: pty.master,
killer,
exited,
session_id,
pane_token,
pty_rows: rows + PTY_PAD,
cols,
scroll_top: Mutex::new(None),
child_pid,
})
}
/// Take a *running* child back over after a hot reload. The PTY master fd
/// came through the `execve` (see `reload::keep_open`), and the child never
/// noticed: same pid on our side, same pty, same session.
///
/// What does not survive is the wezterm screen model: it is rebuilt empty,
/// so the cells have to come from the child again. The trick is to adopt
/// the pty **one row short** of its real height and let the first
/// `ui::draw` frame restore it — `resize` then sees a changed geometry and
/// issues a real `TIOCSWINSZ`, which Linux only turns into a SIGWINCH when
/// the size actually differs, and Ink answers with a full repaint. Poking
/// the ioctl twice in a row here instead would coalesce into one signal
/// carrying the *unchanged* final size, and redraw nothing.
pub fn adopt(h: PtyHandoff) -> anyhow::Result<Self> {
let rows = h.pty_rows.saturating_sub(1).max(1);
let master = AdoptedMaster { fd: unsafe { OwnedFd::from_raw_fd(h.master_fd) } };
// The fd arrived non-CLOEXEC (that is how it survived the exec). Put
// the flag back so it isn't inherited by anything we spawn from here.
unsafe { libc::fcntl(h.master_fd, libc::F_SETFD, libc::FD_CLOEXEC) };
let writer = master.take_writer().context("pty writer")?;
let _ = master.resize(PtySize { rows, cols: h.cols, pixel_width: 0, pixel_height: 0 });
let term = Arc::new(Mutex::new(Terminal::new(
TerminalSize {
rows: rows as usize,
cols: h.cols as usize,
pixel_width: 0,
pixel_height: 0,
dpi: 0,
},
Arc::new(Config),
"claude-cloak",
env!("CARGO_PKG_VERSION"),
writer,
)));
let exited = Arc::new(AtomicBool::new(false));
let mut reader = master.try_clone_reader().context("pty reader")?;
let pid = h.child_pid;
{
let term = term.clone();
let exited = exited.clone();
std::thread::spawn(move || {
let mut buf = [0u8; 8192];
loop {
match reader.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => term.lock().unwrap().advance_bytes(&buf[..n]),
}
}
// Still the child's parent across the exec, so it is still
// ours to reap — the portable-pty `Child` that used to do it
// died with the old image. Retry on EINTR; anything else
// (notably ECHILD) means there is nothing left to wait for.
let mut status = 0;
while unsafe { libc::waitpid(pid as i32, &mut status, 0) } < 0
&& std::io::Error::last_os_error().raw_os_error() == Some(libc::EINTR)
{}
exited.store(true, Ordering::Relaxed);
});
}
Ok(Self {
term,
master: Box::new(master),
killer: Box::new(PidKiller(pid)),
exited,
session_id: h.session_id,
pane_token: h.pane_token,
// Deliberately the short height: the next frame's `resize` restores
// the real one and that is what triggers the repaint.
pty_rows: rows,
cols: h.cols,
scroll_top: Mutex::new(None),
child_pid: Some(pid),
})
}
pub fn exited(&self) -> bool {
self.exited.load(Ordering::Relaxed)
}
/// Describe this pane well enough for the post-exec image to re-adopt it
/// (`adopt`). `None` when the child's pid is unknown or the master has no
/// fd — either way the pane can't survive a reload and is killed instead.
pub fn handoff(&self) -> Option<PtyHandoff> {
Some(PtyHandoff {
master_fd: self.master.as_raw_fd()?,
child_pid: self.child_pid?,
session_id: self.session_id.clone(),
pane_token: self.pane_token.clone(),
pty_rows: self.pty_rows,
cols: self.cols,
})
}
/// Resize PTY + terminal model for a pane of `rows` visible rows.
/// With `crop` (the compact pane), the PTY gets `PTY_PAD` extra rows:
/// render() crops Claude Code's persistent status/hint rows, so the
/// child needs room to draw them somewhere we don't show. Without
/// `crop` (fullscreen), the PTY matches the pane exactly so nothing
/// is ever cut off.
pub fn resize(&mut self, rows: u16, cols: u16, crop: bool) {
let rows = rows + if crop { PTY_PAD } else { 0 };
if (rows, cols) == (self.pty_rows, self.cols) || rows == 0 || cols == 0 {
return;
}
self.pty_rows = rows;
self.cols = cols;
let _ = self.master.resize(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 });
self.term.lock().unwrap().resize(TerminalSize {
rows: rows as usize,
cols: cols as usize,
pixel_width: 0,
pixel_height: 0,
dpi: 0,
});
}
/// Ask Claude Code to wipe its transcript from the visible screen
/// (ctrl-l clears the terminal but keeps the conversation; the live
/// prompt UI redraws immediately). Used to keep the pane prompt-only —
/// the feed above already shows everything the transcript would.
pub fn clear_screen(&self) {
let _ = self
.term
.lock()
.unwrap()
.key_down(KeyCode::Char('l'), KeyModifiers::CTRL);
}
/// Forward pasted text to the child. wezterm-term wraps it in the
/// bracketed-paste markers (ESC[200~ … ESC[201~) iff the child enabled
/// bracketed paste — so Claude Code's input box treats a multiline paste
/// as one insert instead of submitting on the first embedded newline.
pub fn paste(&self, text: &str) {
let _ = self.term.lock().unwrap().send_paste(text);
}
/// Scroll the fullscreen pane's view by `delta` rows — negative up, into
/// the child's scrollback. Clamps to what the scrollback still holds;
/// arriving back at the live screen re-engages follow mode rather than
/// pinning to it.
///
/// Deliberately *our* scroll and not a forwarded mouse report: Claude Code
/// enables no mouse tracking and never leaves the normal screen, so a
/// plain terminal scrolls its own scrollback here too and the child sees
/// nothing. The fullscreen pane is that terminal.
pub fn scroll(&self, delta: isize) {
let term = self.term.lock().unwrap();
let screen = term.screen();
// Top row of the live screen, and the oldest row still held.
let live = screen.visible_row_to_stable_row(0);
let oldest = screen.phys_to_stable_row_index(0);
let mut top = self.scroll_top.lock().unwrap();
let next = (top.unwrap_or(live) + delta).clamp(oldest, live);
*top = (next < live).then_some(next);
}
/// One page of the pane, in rows: the child's screen height less a row of
/// overlap, so a page scroll keeps a line of context.
pub fn page_rows(&self) -> isize {
(self.term.lock().unwrap().screen().physical_rows as isize - 1).max(1)
}
/// Snap the view back to the live screen. A terminal does this on a
/// keypress (xterm's scroll-on-key); without it a scrolled-back pane looks
/// frozen the moment you start typing again.
pub fn follow_live(&self) {
*self.scroll_top.lock().unwrap() = None;
}
/// Rows the view sits above the live screen (0 = following). The pane
/// border shows this, so a scrolled-back view is never mistaken for a
/// stalled child.
pub fn scrolled_rows(&self) -> usize {
// `term` before `scroll_top`: the one lock order used here.
let term = self.term.lock().unwrap();
let Some(top) = *self.scroll_top.lock().unwrap() else {
return 0;
};
(term.screen().visible_row_to_stable_row(0) - top).max(0) as usize
}
/// The child's current cursor shape (set via DECSCUSR). We mirror it onto
/// the outer terminal so the pane shows a bar in insert mode and a block
/// only when Claude Code's vim normal mode asks for one.
pub fn cursor_shape(&self) -> CursorShape {
self.term.lock().unwrap().cursor_pos().shape
}
/// Forward a key press to the child. Returns false for keys we don't map.
pub fn key(&self, k: ratatui::crossterm::event::KeyEvent) -> bool {
use ratatui::crossterm::event::KeyCode as CK;
use ratatui::crossterm::event::KeyModifiers as CM;
let key = match k.code {
CK::Char(c) => KeyCode::Char(c),
CK::Enter => KeyCode::Enter,
CK::Backspace => KeyCode::Backspace,
CK::Tab => KeyCode::Tab,
CK::BackTab => KeyCode::Tab, // SHIFT carried via modifiers
CK::Esc => KeyCode::Escape,
CK::Left => KeyCode::LeftArrow,
CK::Right => KeyCode::RightArrow,
CK::Up => KeyCode::UpArrow,
CK::Down => KeyCode::DownArrow,
CK::Home => KeyCode::Home,
CK::End => KeyCode::End,
CK::PageUp => KeyCode::PageUp,
CK::PageDown => KeyCode::PageDown,
CK::Delete => KeyCode::Delete,
CK::Insert => KeyCode::Insert,
CK::F(n) => KeyCode::Function(n),
_ => return false,
};
let mut mods = KeyModifiers::NONE;
if k.modifiers.contains(CM::SHIFT) || k.code == CK::BackTab {
mods |= KeyModifiers::SHIFT;
}
if k.modifiers.contains(CM::CONTROL) {
mods |= KeyModifiers::CTRL;
}
if k.modifiers.contains(CM::ALT) {
mods |= KeyModifiers::ALT;
}
self.term.lock().unwrap().key_down(key, mods).is_ok()
}
/// Inner rows the compact pane wants in order to show its whole input
/// region (one context row above the box, the input box itself however
/// many lines it has grown to, and the statusLine — or a full `@`/`/`
/// menu when one is open). The UI uses this to size the pane so the
/// prompt auto-expands as you type and never scrolls out of view.
/// Independent of the terminal's row count, so resizing the pane to this
/// value can't feed back into the measurement.
///
/// Returns `None` when no input box can be located this frame — the
/// startup banner, but also a *transient* mid-repaint (a subagent turn or
/// a filtering `@`/`/` menu redraws heavily, so a single frame can catch
/// the box mid-rewrite with a border missing). The caller keeps its last
/// known height on `None` rather than snapping to a default, which is what
/// stops the pane from flickering during busy output.
pub fn compact_rows(&self) -> Option<u16> {
let (top, bottom) = compact_frame(&self.screen_rows())?;
Some(((bottom - top + 1) as u16).max(MIN_COMPACT_INNER))
}
/// Inner rows the pane wants for the interactive prompt Claude Code draws
/// for AskUserQuestion / ExitPlanMode (see `interactive_frame`). Measured
/// from the child's screen for the same reason `compact_rows` is: the tap's
/// row estimate from the tool JSON can only guess how far the question text
/// and option descriptions wrap, and guessing short is what cropped the top
/// of the prompt. `None` until the prompt has been drawn — the caller then
/// keeps its previous height (or the tap's estimate as a first guess).
pub fn interactive_rows(&self) -> Option<u16> {
let (top, bottom) = interactive_frame(&self.screen_rows())?;
Some(((bottom - top + 1) as u16).max(MIN_COMPACT_INNER))
}
/// Visible text of every screen row, top to bottom.
fn screen_rows(&self) -> Vec<String> {
let term = self.term.lock().unwrap();
let screen = term.screen();
let first = screen.phys_row(0);
let lines = screen.lines_in_phys_range(first..first + screen.physical_rows);
lines.iter().map(row_text).collect()
}
/// Paint a window of the child's screen into `area`. Returns the cursor
/// position (absolute buffer coordinates) when the child wants it shown.
///
/// The window depends on `view`:
/// - `Compact`: dynamically frame Claude Code's input box (see
/// `compact_frame`) — one context row above the box down to the
/// statusLine, cropping the persistent hint/token/effort chrome below it;
/// shows a whole `@`/`/` menu instead when one is open. Bottom-anchored
/// if the pane is shorter than the framed region.
/// - `Interactive`: the AskUserQuestion / ExitPlanMode prompt the tap grew
/// the pane for — framed from its own top border (see
/// `interactive_view_range`) so the question text is never cropped.
/// - `Full` (fullscreen): the screen verbatim from row 0, nothing cut off —
/// or, once the wheel has scrolled the pane back (`scroll`), a window of
/// the child's scrollback instead. Only fullscreen scrolls: the cropped
/// views frame the input box, which is always at the live bottom.
pub fn render(&self, area: Rect, buf: &mut Buffer, view: PaneView) -> Option<(u16, u16)> {
let term = self.term.lock().unwrap();
let screen = term.screen();
let h = area.height as usize;
// Scrolled back: take the window out of the scrollback instead. A
// stable index maps to `None` once its row has been trimmed away, and
// the live top is the floor — a view *at* it is just following.
let live_first = screen.phys_row(0);
let back = (view == PaneView::Full)
.then(|| *self.scroll_top.lock().unwrap())
.flatten()
.and_then(|top| screen.stable_row_to_phys(top))
.filter(|&p| p < live_first);
let (first, count) = match back {
Some(p) => (p, h.min(live_first - p + screen.physical_rows)),
None => (live_first, screen.physical_rows),
};
let lines = screen.lines_in_phys_range(first..first + count);
if lines.is_empty() {
return None; // zero-height pane: nothing to paint or index into
}
let rows: Vec<String> = lines.iter().map(row_text).collect();
let last = rows.iter().rposition(|t| !t.trim().is_empty()).unwrap_or(0);
let (start, end) = match view {
PaneView::Full => {
// The PTY is sized to the pane in fullscreen, but a resize may
// not have landed yet — clamp to whatever fits.
(0, lines.len().min(h).saturating_sub(1))
}
PaneView::Interactive => interactive_view_range(&rows, last, h),
PaneView::Compact => compact_view_range(&rows, last, h),
};
for (y, line) in lines[start..=end].iter().enumerate() {
if y as u16 >= area.height {
break;
}
for cell in line.visible_cells() {
let x = cell.cell_index() as u16;
if x >= area.width {
break;
}
let attrs = cell.attrs();
let dst = &mut buf[(area.x + x, area.y + y as u16)];
dst.set_symbol(cell.str());
if let Some(c) = conv_color(attrs.foreground()) {
dst.set_fg(c);
}
if let Some(c) = conv_color(attrs.background()) {
dst.set_bg(c);
}
let mut m = Modifier::empty();
if attrs.intensity() == Intensity::Bold {
m |= Modifier::BOLD;
}
if attrs.intensity() == Intensity::Half {
m |= Modifier::DIM;
}
if attrs.italic() {
m |= Modifier::ITALIC;
}
if attrs.underline() != Underline::None {
m |= Modifier::UNDERLINED;
}
if attrs.reverse() {
m |= Modifier::REVERSED;
}
if attrs.strikethrough() {
m |= Modifier::CROSSED_OUT;
}
dst.set_style(ratatui::style::Style::new().add_modifier(m));
}
}
let cursor = term.cursor_pos();
let cy = cursor.y as usize;
// A scrolled-back window is not the live screen, so the cursor row the
// child reports does not index into it — show none, exactly like a
// terminal scrolled away from its prompt.
(back.is_none()
&& cursor.visibility == CursorVisibility::Visible
&& (cursor.x as u16) < area.width
&& cy >= start
&& cy <= end
&& ((cy - start) as u16) < area.height)
.then(|| (area.x + cursor.x as u16, area.y + (cy - start) as u16))
}
}
/// Concatenated text of a screen row's visible cells.
fn row_text(line: &wezterm_term::Line) -> String {
line.visible_cells().map(|c| c.str().to_string()).collect()
}
/// A horizontal rule (`────…`) — Claude Code draws the input box's top and
/// bottom borders this way (the top one also carries the mode/agent label, so
/// match on a run of `─` rather than the whole row).
fn text_is_rule(t: &str) -> bool {
let t = t.trim_start();
t.starts_with('─') && t.chars().filter(|&c| c == '─').count() >= 10
}
/// A `@`-file / `/`-command menu row. When such a menu is open it replaces the
/// statusLine + hint/token/effort chrome with a list directly under the input
/// box's bottom rule.
///
/// Claude Code 2.1.x draws four shapes here, and **a glyph is not one of the
/// things they share** (captured from a real child):
/// - fuzzy match inside the project (`@bug`, `@src/`): `+ src/debug/`;
/// - a **directory listing** — the typed token holds a path that leaves the
/// project (`@../de`): plain padded paths, `../destinations/`, no marker at
/// all. The selected row differs only in colour, which `row_text` drops, so
/// text alone can never find a glyph here;
/// - a session/agent mention (`@CLA`): `@claude-cloak-a4 message session · …`;
/// - a `/` command, whose description wraps onto plain continuation rows.
///
/// So the last resort is the *shape* of a path: one whitespace-free token
/// holding a `/`. That is deliberately loose — `compact_frame_ex` votes over
/// two rows, and keeps the statusLine out of the vote, rather than trusting any
/// single row (see the menu detection there).
fn text_is_menu_item(t: &str) -> bool {
let t = t.trim();
if ["+ ", "* ", " ", " "].iter().any(|m| t.starts_with(m)) {
return true;
}
// `/command …` and `@agent …` rows carry their description inline.
if t.starts_with('/') || t.starts_with('@') {
return true;
}
// Bare listing row: `../destinations/`, `src/game/foo.gd`.
t.contains('/') && !t.contains(char::is_whitespace)
}
/// Status glyphs Claude Code prints in front of a task/todo row: pending
/// (`squareSmall`), in-progress (`squareSmallFilled`) and completed (`tick`).
/// The same `☐`/`◻` glyph also leads an AskUserQuestion header chip, which is
/// what `interactive_frame` keys on.
const TASK_GLYPHS: [char; 6] = ['◻', '◼', '✔', '✓', '☐', '☒'];
/// A row of Claude Code's task/todo panel — the block it keeps directly above
/// the input box while a task list is alive. Two shapes exist:
/// - standalone (turn finished): a `N tasks (x done, y open)` header followed
/// by up to 5 `◻/◼/✔ subject` rows and a dim `… +3 pending` overflow row;
/// - in-flight (turn running): the same rows hung under the spinner row with a
/// `⎿` tool-result gutter.
///
/// `⎿` alone is *every* tool result's gutter, so it only counts here when a
/// task glyph follows it — otherwise a plain `⎿ Read 20 lines` would grow the
/// pane on every tool call.
fn text_is_task_row(t: &str) -> bool {
let t = t.trim_start();
let after_gutter = t.strip_prefix('⎿').map(str::trim_start).unwrap_or(t);
if after_gutter.starts_with(TASK_GLYPHS) {
return true;
}
// Dim overflow tail: "… +3 pending, 2 completed".
if t.starts_with('…') {
return true;
}
// Standalone header: "268 tasks (0 done, 268 open)".
let digits: String = t.chars().take_while(char::is_ascii_digit).collect();
!digits.is_empty() && t[digits.len()..].starts_with(" tasks (")
}
/// Walk up from `from` (the row just above the input box) over Claude Code's
/// task panel and return its topmost row, or None when no task block sits
/// there. One blank row is tolerated on the way in (the panel is drawn with a
/// `marginTop`), and the walk is bounded so a screen full of glyph-ish text
/// can't swallow the whole pane.
fn task_block_top(rows: &[String], from: usize) -> Option<usize> {
/// Panel worst case: header + 5 task rows + 5 activity rows + overflow.
const MAX_TASK_BLOCK: usize = 14;
let mut i = from;
if rows[i].trim().is_empty() {
i = i.checked_sub(1)?;
}
if !text_is_task_row(&rows[i]) {
return None;
}
let floor = i.saturating_sub(MAX_TASK_BLOCK);
while i > floor {
if text_is_task_row(&rows[i - 1]) {
i -= 1;
continue;
}
// An in-progress task can carry a dim activity line under it, and a
// long subject wraps — neither starts with a glyph. Step over a single
// such row when a real task row sits above it.
if i >= floor + 2 && !rows[i - 1].trim().is_empty() && text_is_task_row(&rows[i - 2]) {
i -= 2;
continue;
}
break;
}
Some(i)
}
/// Step one row further up when `i` lands on a blank row, so the frame's top
/// context row carries text (the panel is drawn with a blank `marginTop` row
/// above it, and showing that blank instead of the spinner row wastes a line).
fn skip_blank_up(rows: &[String], i: usize) -> usize {
match i.checked_sub(1) {
Some(prev) if rows[i].trim().is_empty() && !rows[prev].trim().is_empty() => prev,
_ => i,
}
}
/// Locate Claude Code's input box in `rows` (the visible text of each screen
/// row) and return the inclusive range `(top, bottom)` the compact pane should
/// show: one context row above the box (the spinner / "✻ Worked…" row when
/// present) down to the statusLine just under the box, cropping the persistent
/// hint/token/effort chrome below it. When an `@`/`/` menu is open it has
/// replaced that chrome with a list, so the range extends to the last non-blank
/// row instead. Returns None when no box can be found (startup banner), so the
/// caller can fall back.
///
/// The box is delimited by the last two horizontal rules on screen
/// (`text_is_rule`); the prompt always sits at the bottom, so these are its
/// borders even when the transcript above still holds a rule. The box can be
/// many lines tall (a long or pasted prompt), which is exactly the auto-expand
/// we want.
fn compact_frame(rows: &[String]) -> Option<(usize, usize)> {
compact_frame_ex(rows).map(|f| (f.top, f.bottom))
}
/// Where the compact pane's window sits, plus what `compact_view_range` needs
/// to decide which end to sacrifice when the region is taller than the pane.
struct CompactFrame {
/// First row to show: the task panel's top when one is up, else the single
/// context row above the input box.
top: usize,
/// The context row above the input box — the top the pane falls back to
/// when the full region doesn't fit. The task panel is a nice-to-have;
/// the input box is not.
ess_top: usize,
bottom: usize,
/// The region ends on an open `@`/`/` menu rather than the statusLine.
menu_open: bool,
}
/// Same as `compact_frame`, but keeps the fields `compact_view_range` needs.
fn compact_frame_ex(rows: &[String]) -> Option<CompactFrame> {
let last = rows.iter().rposition(|t| !t.trim().is_empty())?;
let rules: Vec<usize> = (0..=last).filter(|&i| text_is_rule(&rows[i])).collect();
if rules.len() < 2 {
return None;
}
let bot_div = rules[rules.len() - 1];
let top_div = rules[rules.len() - 2];
// Normally one context row above the box (the spinner / "✻ Worked…" row).
// While a task list is alive Claude Code parks its task panel exactly
// there, so the frame swallows the whole panel plus the context row above
// it — that panel *is* the status of the run, and the compact pane is the
// only place the user sees it (the feed shows the API stream, not CC's UI).
let ctx_top = top_div.saturating_sub(1);
let view_top = match top_div.checked_sub(1).and_then(|i| task_block_top(rows, i)) {
Some(t) => skip_blank_up(rows, t.saturating_sub(1)),
None => ctx_top,
};
// An open `@`/`/` menu replaces the chrome below the bottom rule with a
// list. Deciding that takes *two* rows, because a menu row is not reliably
// marked (see `text_is_menu_item`) and the statusLine's text is the user's,
// so it can look like anything — a bare `~/projects/foo` included:
// - the row right under the rule (`head`) must be a menu row or blank. It
// is shown either way (it is the statusLine's slot when no menu is up),
// so a one-row menu needs no detection at all; blank counts because the
// chrome never leaves that row empty, while a menu may separate itself
// from the box.
// - at least one *further* row must look like a menu row too. This is what
// keeps a two-line statusLine (line 2 a bare path) from reading as a
// menu and dragging the whole hint/token/effort chrome into the pane.
// Scanning the whole tail (not just `bot_div + 1`) is also required: only
// the highlighted row of a fuzzy list carries a glyph, so checking one row
// collapsed the pane whenever that row was not the selected one.
let head = bot_div + 1;
let head_ok = last > head && (text_is_menu_item(&rows[head]) || rows[head].trim().is_empty());
let menu_open = head_ok && (head + 1..=last).any(|i| text_is_menu_item(&rows[i]));
let view_bottom = if menu_open { last } else { (bot_div + 1).min(last) };
Some(CompactFrame { top: view_top, ess_top: ctx_top, bottom: view_bottom, menu_open })
}
/// Pick the `(start, end)` window `render` shows for `PaneView::Compact`,
/// given the pane's available inner height `h`. Delegates to
/// `compact_frame_ex` for *where* the box/menu/task panel is, and decides what
/// to sacrifice when the framed region is taller than the pane:
/// - the task panel goes first. It is context about the run; the input box is
/// what the user is driving, so an overflowing region falls back to
/// `ess_top` (the single context row above the box) before cropping
/// anything else.
/// - menu open: top-anchor from there. The input box sits at the top of what
/// remains and the match list runs to the bottom, so overflow must crop the
/// *menu's tail* — bottom-anchoring would hide the line being typed behind a
/// wall of filenames.
/// - no menu: bottom-anchor on the statusLine, so a long pasted prompt keeps
/// its tail + cursor visible and only context rows are cropped.
/// - no box located yet (startup banner, or a transient mid-repaint):
/// bottom-anchor the raw content.
fn compact_view_range(rows: &[String], last: usize, h: usize) -> (usize, usize) {
let Some(f) = compact_frame_ex(rows) else {
return (last.saturating_sub(h.saturating_sub(1)), last);
};
// Drop the task panel before cropping the box itself.
let top = if f.bottom + 1 - f.top > h { f.ess_top } else { f.top };
if f.menu_open {
return (top, f.bottom.min(top + h.saturating_sub(1)));
}
let start = (f.bottom + 1).saturating_sub(h).max(top).min(f.bottom);
(start, f.bottom)
}
/// Locate the interactive prompt Claude Code draws for AskUserQuestion /
/// ExitPlanMode and return the inclusive row range the pane should show:
/// one context row above the prompt's top border down to its hint row
/// ("Enter to select · ↑/↓ to navigate · Esc to cancel"), which is the last
/// non-blank row on screen.
///
/// Shape of the question prompt in CC 2.1.x — note it *replaces* the input box
/// (there is no `` box on screen while it waits), and it draws its own borders
/// with the same `────` rules:
///
/// ```text
/// ● Let me check how you want this framed. <- context row
/// ───────────────────────────────────────── <- top border
/// ☐ Framing <- header chip
/// <- (blank)
/// The question text, wrapped over as many
/// rows as it needs.
///
/// 1. Option label <- selected option
/// option description
/// 2. …
/// ───────────────────────────────────────── <- separator above the tail
/// 5. Chat about this
///
/// Enter to select · ↑/↓ to navigate · Esc to cancel
/// ```
///
/// So the top border is *not* the last rule (that one is the separator near the
/// bottom). It is found by the header chip that follows it, and only failing
/// that by rule position. Returns None when no prompt is on screen yet — the
/// tap flips the pane to `Interactive` the moment the tool call completes,
/// which is a beat *before* Claude Code has drawn anything, so the caller keeps
/// its previous height until this starts reporting.
fn interactive_frame(rows: &[String]) -> Option<(usize, usize)> {
let last = rows.iter().rposition(|t| !t.trim().is_empty())?;
let rules: Vec<usize> = (0..=last).filter(|&i| text_is_rule(&rows[i])).collect();
// Preferred anchor: the rule immediately above the header chip row
// (`☐ Framing`), which is the prompt box's own top border.
let chip = rules
.iter()
.rev()
.find(|&&i| rows.get(i + 1).is_some_and(|t| t.trim_start().starts_with(TASK_GLYPHS)));
let top_div = match chip {
Some(&i) => i,
// No chip (ExitPlanMode, or a chip-less variant): the trailing pair of
// rules brackets the prompt body, so take the upper one.
None if rules.len() >= 2 => rules[rules.len() - 2],
None => *rules.last()?,
};
Some((skip_blank_up(rows, top_div.saturating_sub(1)), last))
}
/// Pick the `(start, end)` window `render` shows for `PaneView::Interactive`,
/// given the pane's inner height `h`.
///
/// The prompt is top-anchored: its question text is the part that explains what
/// is being asked, and cropping it (what a bottom anchor does) is exactly the
/// reported bug. When even the top-anchored window can't reach the highlighted
/// option, the window slides down just far enough to keep that option — plus
/// the hint row below it — in view, so the prompt is always operable.
fn interactive_view_range(rows: &[String], last: usize, h: usize) -> (usize, usize) {
let Some((top, bottom)) = interactive_frame(rows) else {
// Nothing framed yet: show from near the top, bottom-anchored.
let start = (last + 1).saturating_sub(h).max(2).min(last);
return (start, last);
};
let h = h.max(1);
if bottom - top < h {
return (top, bottom);
}
// Overflow: keep the selected option (` 2. …`) visible.
let sel = (top..=bottom).rev().find(|&i| rows[i].trim_start().starts_with(''));
let mut end = top + h - 1;
if let Some(sel) = sel
&& sel > end
{
end = (sel + 1).min(bottom);
}
(end + 1 - h, end)
}
/// Everything the post-exec image needs to take a running `claude` child back
/// over (`EmbeddedTerm::adopt`). An `execve` keeps our pid, our open fds and
/// our children, so the child never notices the reload — but every Rust-side
/// handle is gone, which is why the pane is rebuilt from a bare fd + pid.
#[derive(serde::Serialize, serde::Deserialize)]
pub struct PtyHandoff {
/// PTY master. `reload::keep_open` clears its FD_CLOEXEC before the exec,
/// so this number is still valid — and still the same pty — afterwards.
pub master_fd: RawFd,
pub child_pid: u32,
pub session_id: String,
pub pane_token: String,
pub pty_rows: u16,
pub cols: u16,
}
/// A PTY master rebuilt from an inherited fd. Implements just enough of
/// `MasterPty` to stand in for portable-pty's own master, so `EmbeddedTerm`
/// keeps one type for both the freshly spawned and the adopted pane.
///
/// Note its writer is a plain `File`: portable-pty's writer sends EOT to the
/// child when dropped, which would end the adopted session on every teardown.
#[derive(Debug)]
struct AdoptedMaster {
fd: OwnedFd,
}
impl AdoptedMaster {
/// Duplicate the master fd for an independent reader/writer handle.
/// `F_DUPFD_CLOEXEC` keeps the clone out of the *next* reload's exec —
/// only the one fd named in the handoff is meant to survive.
fn dup(&self) -> anyhow::Result<std::fs::File> {
let fd = unsafe { libc::fcntl(self.fd.as_raw_fd(), libc::F_DUPFD_CLOEXEC, 0) };
if fd < 0 {
return Err(std::io::Error::last_os_error()).context("dup pty master");
}
Ok(unsafe { std::fs::File::from_raw_fd(fd) })
}
}
impl MasterPty for AdoptedMaster {
fn resize(&self, size: PtySize) -> Result<(), anyhow::Error> {
let ws = libc::winsize {
ws_row: size.rows,
ws_col: size.cols,
ws_xpixel: size.pixel_width,
ws_ypixel: size.pixel_height,
};
let rc = unsafe { libc::ioctl(self.fd.as_raw_fd(), libc::TIOCSWINSZ as _, &ws) };
if rc != 0 {
return Err(std::io::Error::last_os_error()).context("ioctl(TIOCSWINSZ)");
}
Ok(())
}
fn get_size(&self) -> Result<PtySize, anyhow::Error> {
let mut ws: libc::winsize = unsafe { std::mem::zeroed() };
let rc = unsafe { libc::ioctl(self.fd.as_raw_fd(), libc::TIOCGWINSZ as _, &mut ws) };
if rc != 0 {
return Err(std::io::Error::last_os_error()).context("ioctl(TIOCGWINSZ)");
}
Ok(PtySize {
rows: ws.ws_row,
cols: ws.ws_col,
pixel_width: ws.ws_xpixel,
pixel_height: ws.ws_ypixel,
})
}
fn try_clone_reader(&self) -> Result<Box<dyn Read + Send>, anyhow::Error> {
Ok(Box::new(self.dup()?))
}
fn take_writer(&self) -> Result<Box<dyn Write + Send>, anyhow::Error> {
Ok(Box::new(self.dup()?))
}
fn process_group_leader(&self) -> Option<libc::pid_t> {
match unsafe { libc::tcgetpgrp(self.fd.as_raw_fd()) } {
pid if pid > 0 => Some(pid),
_ => None,
}
}
fn as_raw_fd(&self) -> Option<RawFd> {
Some(self.fd.as_raw_fd())
}
fn tty_name(&self) -> Option<std::path::PathBuf> {
None
}
}
/// Signals a child by pid. Stands in for portable-pty's killer, whose handle
/// doesn't survive the exec. SIGHUP matches what portable-pty sends, so an
/// adopted pane dies exactly like a spawned one.
#[derive(Debug)]
struct PidKiller(u32);
impl ChildKiller for PidKiller {
fn kill(&mut self) -> std::io::Result<()> {
if unsafe { libc::kill(self.0 as i32, libc::SIGHUP) } != 0 {
return Err(std::io::Error::last_os_error());
}
Ok(())
}
fn clone_killer(&self) -> Box<dyn ChildKiller + Send + Sync> {
Box::new(PidKiller(self.0))
}
}
impl Drop for EmbeddedTerm {
fn drop(&mut self) {
let _ = self.killer.kill();
}
}
/// Map the child's DECSCUSR cursor shape onto a crossterm cursor style so the
/// outer terminal renders the same shape over the pane.
///
/// `Default` maps to a blinking bar, not `DefaultUserShape`: Claude Code's
/// normal (non-vim) input leaves the cursor at the terminal default and relies
/// on that default being a bar caret. Forwarding `DefaultUserShape` would
/// instead pick up the *outer* terminal's default (often a block), so the pane
/// would show a block in insert mode. Vim normal mode still gets its explicit
/// `SteadyBlock`.
pub fn cursor_style(shape: CursorShape) -> SetCursorStyle {
match shape {
CursorShape::BlinkingBlock => SetCursorStyle::BlinkingBlock,
CursorShape::SteadyBlock => SetCursorStyle::SteadyBlock,
CursorShape::BlinkingUnderline => SetCursorStyle::BlinkingUnderScore,
CursorShape::SteadyUnderline => SetCursorStyle::SteadyUnderScore,
CursorShape::BlinkingBar => SetCursorStyle::BlinkingBar,
CursorShape::SteadyBar => SetCursorStyle::SteadyBar,
CursorShape::Default => SetCursorStyle::BlinkingBar,
}
}
/// termwiz color → ratatui color. `Default` maps to None (keep pane default).
fn conv_color(c: ColorAttribute) -> Option<Color> {
match c {
ColorAttribute::Default => None,
ColorAttribute::PaletteIndex(i) => Some(Color::Indexed(i)),
ColorAttribute::TrueColorWithDefaultFallback(c)
| ColorAttribute::TrueColorWithPaletteFallback(c, _) => {
let (r, g, b, _) = c.to_srgb_u8();
Some(Color::Rgb(r, g, b))
}
}
}
/// Claude Code's own default model, as it writes it (`opus`, `opus[1m]`, …).
/// Only the *model* is used: `App::spawn_arg` / `App::resume_arg` strip any
/// suffix and re-apply the 1M window themselves, so a `default` pick or a
/// session we know nothing about still names a model we can attach `[1m]` to.
///
/// Resolved the way Claude Code layers it: `ANTHROPIC_MODEL`, then
/// project-local, project, and user settings. `None` when nothing sets one
/// (Claude Code then picks for itself). Read fresh on every call — a `/model`
/// during the session must not be answered from a stale cache.
pub fn cc_default_model() -> Option<String> {
if let Ok(m) = std::env::var("ANTHROPIC_MODEL")
&& !m.is_empty()
{
return Some(m);
}
let user = std::env::var_os("HOME")
.map(|h| std::path::PathBuf::from(h).join(".claude/settings.json"));
[
Some(std::path::PathBuf::from(".claude/settings.local.json")),
Some(std::path::PathBuf::from(".claude/settings.json")),
user,
]
.into_iter()
.flatten()
.find_map(|p| settings_model(&p))
}
/// `model` field of one settings file (absent/unreadable/invalid → None).
fn settings_model(path: &std::path::Path) -> Option<String> {
let body = std::fs::read_to_string(path).ok()?;
let v: serde_json::Value = serde_json::from_str(&body).ok()?;
v.get("model")?
.as_str()
.filter(|m| !m.is_empty())
.map(str::to_string)
}
/// Background scan that replaces `App::models` with the live sets read from the
/// installed `claude` binary (see `discover_models`). Runs off the UI thread;
/// on failure the seeded fallback catalog stays in place.
pub fn spawn_model_discovery(app: crate::app::SharedApp) {
std::thread::spawn(move || {
if let Some(models) = discover_models() {
crate::app::lock_app(&app).models = models;
}
});
}
/// Best-effort discovery of the models the installed `claude` accepts (aliases
/// like `opus`, `sonnet`, `haiku`, `fable`, and which of them ship a
/// `<alias>[1m]` long-context variant), so the `a` picker and the always-1M
/// policy track new models without us hardcoding a list that drifts.
///
/// Claude Code ships as one self-contained executable with its (minified) JS
/// bundle embedded; the alias set appears verbatim as a JSON array literal like
/// `["sonnet","opus","haiku","fable"]` and each long-context variant as its own
/// quoted `"sonnet[1m]"` literal. We resolve the `claude` binary on PATH and
/// read it once. This issues **no API request** (the project's core constraint)
/// and never executes claude. Returns None if the binary can't be found/read or
/// nothing matches — the caller keeps its built-in fallback catalog.
fn discover_models() -> Option<crate::app::Models> {
let bytes = std::fs::read(claude_binary_path()?).ok()?;
let aliases = longest_alias_array(&bytes)?;
Some(models_from(&bytes, aliases))
}
/// Build the catalog: every alias, plus every model the binary really ships a
/// `[1m]` variant for — never assumed. `long` keeps the *base* name of each
/// `"<name>[1m]"` literal (see `long_context_tokens`), so it covers full model
/// ids as well as aliases: a session whose transcript names a model with no
/// alias still resumes at the long window.
fn models_from(bytes: &[u8], aliases: Vec<String>) -> crate::app::Models {
let mut long: Vec<String> = long_context_tokens(bytes)
.iter()
.map(|t| crate::app::base_model(t).to_string())
.collect();
long.sort();
crate::app::Models { aliases, long }
}
/// Resolve `claude` on `PATH` to a readable file path (symlinks followed).
fn claude_binary_path() -> Option<std::path::PathBuf> {
let path = std::env::var_os("PATH")?;
std::env::split_paths(&path)
.map(|d| d.join("claude"))
.find(|c| c.is_file())
.map(|c| std::fs::canonicalize(&c).unwrap_or(c))
}
/// Scan a byte buffer for JSON array literals of short lowercase tokens and
/// return the longest one that contains both `opus` and `sonnet` (the stable
/// anchors of Claude Code's model-alias list).
fn longest_alias_array(bytes: &[u8]) -> Option<Vec<String>> {
let mut best: Option<Vec<String>> = None;
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'['
&& let Some((arr, end)) = parse_str_array(bytes, i)
{
let anchored = arr.iter().any(|s| s == "opus") && arr.iter().any(|s| s == "sonnet");
if anchored && best.as_ref().is_none_or(|b| arr.len() > b.len()) {
best = Some(arr);
}
i = end;
continue;
}
i += 1;
}
best
}
/// Collect every quoted `"<token>[1m]"` literal in the buffer. `[1m]` is Claude
/// Code's suffix for the 1M-context variant of a model, accepted by `--model`
/// both on aliases (`sonnet[1m]`) and on full ids (`claude-opus-4-8[1m]`). Only
/// some models have one, so we take the set from the binary instead of assuming
/// every alias supports it. One pass: find each `[1m]"` and walk back over the
/// token to its opening quote.
fn long_context_tokens(bytes: &[u8]) -> std::collections::HashSet<String> {
const SUFFIX: &[u8] = b"[1m]\"";
let mut out = std::collections::HashSet::new();
let mut i = 0;
while i + SUFFIX.len() <= bytes.len() {
if &bytes[i..i + SUFFIX.len()] != SUFFIX {
i += 1;
continue;
}
let mut s = i;
while s > 0 && is_alias_byte(bytes[s - 1]) {
s -= 1;
}
// Needs a non-empty token behind an opening quote.
if s < i && s > 0 && bytes[s - 1] == b'"' {
let end = i + SUFFIX.len() - 1; // keep `[1m]`, drop the quote
if let Ok(tok) = std::str::from_utf8(&bytes[s..end]) {
out.insert(tok.to_string());
}
}
i += SUFFIX.len();
}
out
}
/// Bytes allowed inside a model alias/id token.
fn is_alias_byte(c: u8) -> bool {
c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'-'
}
/// Parse `["a","b",...]` of lowercase-`[a-z0-9-]` tokens starting at `start`
/// (which must be `[`). Returns the tokens and the index just past the closing
/// `]`, or None if the bytes there aren't exactly such an array.
fn parse_str_array(bytes: &[u8], start: usize) -> Option<(Vec<String>, usize)> {
let n = bytes.len();
let mut i = start + 1; // past '['
let mut out = Vec::new();
loop {
if i >= n {
return None;
}
if bytes[i] == b']' {
return (!out.is_empty()).then_some((out, i + 1));
}
if bytes[i] != b'"' {
return None;
}
i += 1;
let tok_start = i;
while i < n && bytes[i] != b'"' {
if !is_alias_byte(bytes[i]) {
return None;
}
i += 1;
}
let tok = bytes.get(tok_start..i)?;
if tok.is_empty() || tok.len() > 24 {
return None;
}
out.push(String::from_utf8(tok.to_vec()).ok()?);
i += 1; // past closing quote
match bytes.get(i)? {
b',' => i += 1,
b']' => return Some((out, i + 1)),
_ => return None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{Duration, Instant};
#[test]
fn picks_longest_anchored_alias_array() {
let bytes = br#"junk["opus","sonnet"]more["sonnet","opus","haiku","fable"]tail"#;
let got = longest_alias_array(bytes).unwrap();
assert_eq!(got, ["sonnet", "opus", "haiku", "fable"]);
}
#[test]
fn collects_quoted_1m_variants() {
let bytes = br#"x"sonnet[1m]"y"claude-opus-4-8[1m]"z"[1m]"q"#;
let got = long_context_tokens(bytes);
assert!(got.contains("sonnet[1m]"));
assert!(got.contains("claude-opus-4-8[1m]"));
// The bare `"[1m]"` label string carries no model name, so it is dropped.
assert_eq!(got.len(), 2);
}
#[test]
fn catalog_marks_only_aliases_with_a_real_1m_variant() {
let bytes =
br#"["sonnet","opus","haiku"] "sonnet[1m]" "opus[1m]" "claude-mythos-1[1m]""#;
let aliases = longest_alias_array(bytes).unwrap();
let models = models_from(bytes, aliases);
assert_eq!(models.aliases, ["sonnet", "opus", "haiku"]);
// Full ids count too: a transcript can name a model no alias covers.
assert_eq!(models.long, ["claude-mythos-1", "opus", "sonnet"]);
assert_eq!(models.arg("claude-mythos-1"), "claude-mythos-1[1m]");
assert_eq!(models.arg("haiku"), "haiku");
// Picker rows: every model at the window `Models::arg` gives it —
// 1M where the binary ships one, plain where it does not.
let args: Vec<String> = models.choices().into_iter().map(|c| c.1).collect();
assert_eq!(args, ["", "sonnet[1m]", "opus[1m]", "haiku"]);
}
/// Build a `rows` fixture (visible text per screen row) from string slices.
fn rows(v: &[&str]) -> Vec<String> {
v.iter().map(|s| s.to_string()).collect()
}
const RULE: &str = "──────────────────────────────────────";
#[test]
fn frames_idle_input_box_to_statusline() {
// Banner, blank, top rule, input line, bottom rule, statusLine, then
// the persistent hint/token/effort chrome we crop.
let screen = rows(&[
" banner", "", "", "",
&format!("{RULE} minimal ──"), // 4: top rule
"", // 5: input
RULE, // 6: bottom rule
"Session: ▓▓░ Context | Opus", // 7: statusLine (kept, last shown)
"⏵⏵ bypass permissions", // 8: hint (cropped)
" 0 tokens", // 9: tokens (cropped)
" ◉ xhigh · /effort", // 10: effort (cropped)
]);
// top = top_rule - 1 = 3 (a context row), bottom = bottom_rule + 1 = 7.
assert_eq!(compact_frame(&screen), Some((3, 7)));
}
#[test]
fn grows_with_a_multiline_prompt() {
let screen = rows(&[
"", "",
&format!("{RULE} minimal ──"), // 2: top rule
" first line", // 3
" second line", // 4
" third line", // 5
RULE, // 6: bottom rule
"Session: ▓▓░ Context | Opus", // 7: statusLine
"? for shortcuts", // 8: chrome (cropped)
]);
// The box is taller, so the framed region grows: top 1 .. statusLine 7.
assert_eq!(compact_frame(&screen), Some((1, 7)));
}
#[test]
fn shows_whole_menu_when_one_is_open() {
// An `@`/`/` menu replaces the chrome with a list under the bottom rule.
let screen = rows(&[
"", "",
&format!("{RULE} minimal ──"), // 2: top rule
" @s", // 3: input
RULE, // 4: bottom rule
"+ src/", // 5: menu item (so: keep it all)
"+ src/app.rs", // 6
"+ src/ui.rs", // 7: last non-blank
"", "",
]);
assert_eq!(compact_frame(&screen), Some((1, 7)));
}
#[test]
fn shows_menu_when_first_row_isnt_the_selected_item() {
// Real `@` menus render only the highlighted item with a glyph; the
// rows above it are plain indented filenames. A blank separator can
// also sit between the bottom rule and the list. The frame must still
// extend to the whole menu (regression: only rows[bot_div+1] was
// checked, so the pane collapsed unless the first item was selected).
let screen = rows(&[
"", "",
&format!("{RULE} minimal ──"), // 2: top rule
" @s", // 3: input
RULE, // 4: bottom rule
"", // 5: blank separator
" src/app.rs", // 6: unselected item (no glyph)
" src/ui.rs", // 7: selected item
" src/term.rs", // 8: last non-blank
"", "",
]);
assert_eq!(compact_frame(&screen), Some((1, 8)));
}
#[test]
fn shows_unmarked_path_listing_menu() {
// Real capture, `@../de` typed in ~/projects/destinations: a path that
// leaves the project switches CC to a *listing*, whose rows carry no
// `+`/`` marker at all (the selected one differs only in colour).
// Regression: the pane showed the first hit and cropped the rest.
let screen = rows(&[
"", "",
&format!("{RULE} minimal ──"), // 2: top rule
" @../de", // 3: input
RULE, // 4: bottom rule
" ../destinations/", // 5: selected (colour only)
" ../destinations-player-host/", // 6
" ../destinations-terrain-unify/", // 7: last non-blank
"", "",
]);
assert_eq!(compact_frame(&screen), Some((1, 7)));
}
#[test]
fn shows_agent_mention_menu() {
// `@CLA` lists sessions/agents: `@name` + an inline description.
let screen = rows(&[
"", "",
&format!("{RULE} minimal ──"), // 2: top rule
" @CLA", // 3: input
RULE, // 4: bottom rule
" @claude-cloak-a4 message session · active 30s ago", // 5
" @claude-cloak-10 message session · active 2m ago", // 6
"", "",
]);
assert_eq!(compact_frame(&screen), Some((1, 6)));
}
#[test]
fn single_item_menu_needs_no_detection() {
// One match, and it sits in the statusLine's own slot — always shown.
let screen = rows(&[
"", "",
&format!("{RULE} minimal ──"), // 2: top rule
" @../destinations-t", // 3: input
RULE, // 4: bottom rule
" ../destinations-terrain-unify/", // 5: the only hit
"", "",
]);
assert_eq!(compact_frame(&screen), Some((1, 5)));
// A blank separator before a lone item still shows the item.
let screen = rows(&[
"", "",
&format!("{RULE} minimal ──"),
" @../destinations-t",
RULE,
"", // 5: separator
" ../destinations-terrain-unify/", // 6
"", "",
]);
assert_eq!(compact_frame(&screen), Some((1, 6)));
}
#[test]
fn path_shaped_statusline_is_not_a_menu() {
// The statusLine is the user's own text, so it can be a bare path —
// which is exactly the shape an unmarked menu row has. Detection votes
// over a second row for this reason: a path-ish statusLine (even a
// two-line one) must not drag the hint/token chrome into the pane.
let screen = rows(&[
"", "",
&format!("{RULE} minimal ──"), // 2: top rule
"", // 3: input
RULE, // 4: bottom rule
"~/projects/destinations", // 5: statusLine (kept)
"⏵⏵ bypass permissions (shift+tab)", // 6: chrome (cropped)
" 0 tokens", // 7: chrome (cropped)
]);
assert_eq!(compact_frame(&screen), Some((1, 5)));
let screen = rows(&[
"", "",
&format!("{RULE} minimal ──"),
"",
RULE,
"Opus 5 · xhigh", // 5: statusLine line 1 (kept)
"~/projects/destinations", // 6: statusLine line 2 (cropped)
"? for shortcuts", // 7
]);
assert_eq!(compact_frame(&screen), Some((1, 5)));
}
#[test]
fn overflowing_menu_keeps_input_box_visible() {
// Regression: when a long `@`/`/` match list doesn't fit in the pane's
// (capped) height, the pane must keep the input box on screen and
// truncate the menu's tail — not the reverse. Bottom-anchoring here
// (as the idle statusLine case does) hid the line you're typing behind
// a wall of filenames, which is what this bug report was about.
let screen = rows(&[
"", "",
&format!("{RULE} minimal ──"), // 2: top rule
" @s", // 3: input — must stay visible
RULE, // 4: bottom rule
"+ src/", // 5
"+ src/app.rs", // 6
"+ src/main.rs", // 7
"+ src/sse.rs", // 8
"+ src/term.rs", // 9
"+ src/ui.rs", // 10
]);
let last = 10;
// Pane only has room for 5 rows (top-anchored: context row through the
// bottom rule + first item), so the last two menu items get cropped —
// but the input box (rows 1..=4) must still be in view.
let (start, end) = compact_view_range(&screen, last, 5);
assert_eq!((start, end), (1, 5));
// A pane tall enough for everything still shows the whole menu.
assert_eq!(compact_view_range(&screen, last, 20), (1, 10));
}
/// The AskUserQuestion prompt exactly as Claude Code 2.1.229 draws it
/// (captured from a real child through a fake upstream — see
/// `dev/fake_upstream.py`). Note it replaces the input box: no `` box, and
/// the *last* rule is a separator near the bottom, not the top border.
fn ask_prompt_screen() -> Vec<String> {
rows(&[
"", // 0
" go", // 1
"", // 2
"● Let me check how you want this framed.", // 3: context row
RULE, // 4: top border
" ☐ Framing", // 5: header chip
"", // 6
"The compact pane currently crops the top of this", // 7: question
"prompt. Which framing should the pane use?", // 8
"", // 9
" 1. Measure the box", // 10: selected
" Frame from the question box's own top border.", // 11
" 2. Fixed 75% height", // 12
" Always give the pane three quarters.", // 13
" 3. Estimate from JSON", // 14
" Keep guessing the row count.", // 15
" 4. Type something.", // 16
RULE, // 17: separator
" 5. Chat about this", // 18
"", // 19
"Enter to select · ↑/↓ to navigate · Esc to cancel", // 20: hint
"", "",
])
}
#[test]
fn frames_ask_prompt_from_its_own_top_border() {
// Regression: the old code bottom-anchored on the last non-blank row,
// so a pane shorter than the prompt cropped the *question text* — the
// part that says what is being asked. The frame now starts one context
// row above the prompt's top border and runs to the hint row.
assert_eq!(interactive_frame(&ask_prompt_screen()), Some((3, 20)));
}
#[test]
fn overflowing_ask_prompt_keeps_the_selected_option() {
let screen = ask_prompt_screen();
// Tall enough: the whole prompt, top-anchored on the context row.
assert_eq!(interactive_view_range(&screen, 20, 20), (3, 20));
// Too short: still starts at the top (question first), cropping the
// tail — the selected option (row 10) is inside the window.
assert_eq!(interactive_view_range(&screen, 20, 9), (3, 11));
}
#[test]
fn ask_prompt_window_slides_to_a_far_down_selection() {
// Same prompt with the highlight on the last option: a strictly
// top-anchored window would leave the user steering a selection they
// cannot see, so the window slides down just far enough to show it.
let mut screen = ask_prompt_screen();
screen[10] = " 1. Measure the box".into();
screen[16] = " 4. Type something.".into();
let (start, end) = interactive_view_range(&screen, 20, 8);
assert!((start..=end).contains(&16), "selected option must be visible");
assert_eq!((start, end), (10, 17));
}
#[test]
fn no_prompt_on_screen_yields_none() {
// Only the idle input box: `interactive_frame` still reports the box
// (both callers only use it while the tap says a prompt is up), but a
// blank screen has nothing to frame at all.
assert_eq!(interactive_frame(&rows(&["", "", ""])), None);
}
/// The task panel Claude Code parks above the input box while a task list
/// is alive (standalone form, turn finished).
fn task_panel_screen() -> Vec<String> {
rows(&[
"● Done.", // 0
"", // 1
"✻ Crunched for 41s", // 2: context row
"", // 3: panel marginTop
" 5 tasks (1 done, 1 in progress, 3 open)", // 4: panel header
" ✔ Capture ground truth screens", // 5
" ◼ Fix interactive pane sizing", // 6
" measuring the rendered box…", // 7: activity row
" ◻ Expand pane while a task list is active", // 8
" … +2 pending", // 9: overflow tail
"", // 10
&format!("{RULE} minimal ──"), // 11: top rule
"", // 12: input
RULE, // 13: bottom rule
"Session: ▓▓░ Context | Opus", // 14: statusLine
"⏵⏵ bypass permissions", // 15: chrome (cropped)
])
}
#[test]
fn frames_task_panel_above_the_input_box() {
// The pane grows over the whole panel (plus the context row above it)
// so the run's task status is visible, instead of showing the single
// context row that used to land on the panel's blank margin.
assert_eq!(compact_frame(&task_panel_screen()), Some((2, 14)));
}
#[test]
fn task_panel_frame_survives_the_in_flight_shape() {
// While the turn runs the same rows hang under the spinner row with a
// `⎿` gutter and carry no header.
let screen = rows(&[
"● Setting up the task list.", // 0
"", // 1
"· Swirling… (9s · ↓ 2.3k tokens)", // 2: context row
" ⎿ ◻ Capture ground truth screens", // 3
" ◻ Fix interactive pane sizing", // 4
" … +59 pending", // 5
"", // 6
&format!("{RULE} minimal ──"), // 7: top rule
"", // 8
RULE, // 9
"Session: ▓▓░ Context | Opus", // 10
]);
assert_eq!(compact_frame(&screen), Some((2, 10)));
}
#[test]
fn task_panel_yields_to_the_input_box_when_the_pane_is_short() {
// The panel is context about the run; the input box is what the user
// drives. A pane too short for both must drop the panel, not the box.
let screen = task_panel_screen();
// Room for everything: panel included (rows 2..14).
assert_eq!(compact_view_range(&screen, 15, 13), (2, 14));
// Room for 5 rows: falls back to one context row above the box, so the
// input line (row 12) and the statusLine (row 14) stay visible.
assert_eq!(compact_view_range(&screen, 15, 5), (10, 14));
}
#[test]
fn plain_tool_result_gutter_does_not_grow_the_pane() {
// `⎿` is every tool result's gutter. Only a task glyph behind it counts
// as the task panel — otherwise the pane would grow on every Read/Bash.
let screen = rows(&[
"● Reading the file.", // 0
" ⎿ Read 20 lines", // 1
"", // 2
&format!("{RULE} minimal ──"), // 3: top rule
"", // 4
RULE, // 5
"Session: ▓▓░ Context | Opus", // 6
]);
// One context row above the box, as before.
assert_eq!(compact_frame(&screen), Some((2, 6)));
}
#[test]
fn no_input_box_yields_none() {
// Startup banner only — no rules, so the caller falls back.
assert_eq!(compact_frame(&rows(&[" ▐▛██▜▌ Claude", "", ""])), None);
}
#[test]
fn ignores_arrays_without_both_anchors() {
// Missing "sonnet" → not a model-alias array.
assert!(longest_alias_array(br#"["opus","haiku","fable"]"#).is_none());
// Non-token content (uppercase/spaces) → rejected.
assert!(longest_alias_array(br#"["Opus","sonnet"]"#).is_none());
}
/// First rendered row of `view`, trimmed.
fn top_row(et: &EmbeddedTerm, area: Rect, view: PaneView) -> String {
let mut buf = Buffer::empty(area);
et.render(area, &mut buf, view);
(0..area.width).map(|x| buf[(x, 0)].symbol()).collect::<String>().trim().to_string()
}
/// The fullscreen pane scrolls its own scrollback (Claude Code grabs no
/// mouse, so there is nothing to forward to it): a real child writes more
/// rows than the screen holds, and `scroll` has to reach the ones that left
/// it — then hand the view back to the live screen at the bottom.
#[test]
fn fullscreen_pane_scrolls_into_the_scrollback() {
let mut cmd = CommandBuilder::new("sh");
cmd.args(["-c", "seq 1 60; sleep 5"]);
let area = Rect::new(0, 0, 40, 10);
let et = EmbeddedTerm::spawn_cmd(cmd, "test-session".into(), "test-token".into(), 6, 40).unwrap();
// Wait for the tail of the child's output to reach the live screen.
let deadline = Instant::now() + Duration::from_secs(5);
loop {
let mut buf = Buffer::empty(area);
et.render(area, &mut buf, PaneView::Full);
let text: String = (0..area.height)
.flat_map(|y| (0..area.width).map(move |x| (x, y)))
.map(|(x, y)| buf[(x, y)].symbol())
.collect();
if text.contains("60") {
break;
}
assert!(Instant::now() < deadline, "child output never arrived: {text:?}");
std::thread::sleep(Duration::from_millis(50));
}
let live = top_row(&et, area, PaneView::Full);
assert_eq!(et.scrolled_rows(), 0, "starts out following the live screen");
// Up 40 rows: a window of rows that have left the screen.
et.scroll(-40);
assert_eq!(et.scrolled_rows(), 40);
let scrolled = top_row(&et, area, PaneView::Full);
assert_ne!(scrolled, live, "the view did not move");
let n: usize = scrolled.parse().expect("a `seq` line number");
assert!(n < live.parse::<usize>().unwrap(), "scrolled the wrong way: {n}");
// No cursor while scrolled away — its row does not index this window.
let mut buf = Buffer::empty(area);
assert_eq!(et.render(area, &mut buf, PaneView::Full), None);
// A cropped view never scrolls: it frames the input box at the bottom.
assert_eq!(top_row(&et, area, PaneView::Compact), live);
// Past the bottom re-engages follow mode rather than pinning to it.
et.scroll(1000);
assert_eq!(et.scrolled_rows(), 0);
assert_eq!(top_row(&et, area, PaneView::Full), live);
// …as does `follow_live`, from anywhere.
et.scroll(-10);
assert_eq!(et.scrolled_rows(), 10);
et.follow_live();
assert_eq!(et.scrolled_rows(), 0);
assert_eq!(top_row(&et, area, PaneView::Full), live);
}
/// Full pipeline: PTY spawn → reader thread → wezterm-term model →
/// ratatui buffer. Headless-safe: the *child* gets the tty, not us.
#[test]
fn pty_output_reaches_rendered_buffer() {
let mut cmd = CommandBuilder::new("sh");
cmd.args(["-c", "printf 'hello-embed'; sleep 1"]);
let area = Rect::new(0, 0, 40, 5);
let et = EmbeddedTerm::spawn_cmd(cmd, "test-session".into(), "test-token".into(), 5, 40).unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
loop {
let mut buf = Buffer::empty(area);
et.render(area, &mut buf, PaneView::Compact);
let row: String = (0..area.width).map(|x| buf[(x, 0)].symbol()).collect();
if row.contains("hello-embed") {
break;
}
assert!(Instant::now() < deadline, "never rendered output: {row:?}");
std::thread::sleep(Duration::from_millis(50));
}
}
}