Files
claude-cloak/src/term.rs
Jonas H 1f483ad3d1 Hot-reload onto a rebuilt binary with ctrl-r
Restarting to pick up a code change costs the three things this app
exists to hold: the proxy port, the embedded claude pane, and the live
feed. So ctrl-r does not restart — it execs the binary now on disk into
this same process. execve keeps the pid, the open fds and the child
processes, so the listener socket, the pty and its claude child all
simply carry on; the feed travels in a JSON snapshot.

Build nothing, watch nothing. A build is the user's business, and a
running instance must not decide on its own when to become different
code. Rebuild outside, then press ctrl-r in each instance.

Drain before the exec. It destroys the tokio tasks relaying in-flight
responses, so the proxy stops accepting and finishes what it has first.
The socket stays open throughout (App::listener_fd is a dup), so
requests made during the swap queue in the kernel backlog and are served
by the new image — verified end to end: nothing refused, nothing cut.

Treat the snapshot as advisory. It is written by the old binary and read
by the new one, whose types usually just changed — that is the normal
case, not the edge case. The fd numbers stay in plain fields and the
feed is decoded per session behind a sanitiser, so a schema change costs
the feed and never the port or the pane.
2026-08-27 11:46:15 +02:00

1457 lines
64 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, 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,
/// 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,
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,
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);
}
/// 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.
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 first = screen.phys_row(0);
let lines = screen.lines_in_phys_range(first..first + screen.physical_rows);
let rows: Vec<String> = lines.iter().map(row_text).collect();
let last = rows.iter().rposition(|t| !t.trim().is_empty()).unwrap_or(0);
let h = area.height as usize;
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;
(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. These markers are the Claude Code 2.1.x list glyphs;
/// retune here if a CC update changes them.
fn text_is_menu_item(t: &str) -> bool {
let t = t.trim_start();
["+ ", "* ", " ", " "].iter().any(|m| t.starts_with(m)) || t.starts_with('/')
}
/// 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. Scan the *whole* region under the rule for a menu row, not just the
// one immediately below it: the list can start after a blank separator or a
// header row, and only the highlighted item carries a recognisable glyph
// (unselected file rows are plain indented names), so checking a single row
// missed the menu whenever that row happened not to be the selected one.
// The persistent chrome rows (statusLine / hint / tokens / effort) never
// match `text_is_menu_item`, so scanning stays free of false positives.
let menu_open = last > bot_div && (bot_div + 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 a `--model` argument (`opus`,
/// `opus[1m]`, …). Its settings files are the one place the **1M-context**
/// choice is written down — `/model` saves the pick there, suffix and all,
/// while a transcript records the same base model id either way.
///
/// 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::model_choices` with the live alias set
/// read from the installed `claude` binary (see `discover_model_aliases`).
/// Runs off the UI thread; on failure the seeded fallback list stays in place.
pub fn spawn_model_discovery(app: crate::app::SharedApp) {
std::thread::spawn(move || {
if let Some(choices) = discover_model_choices() {
crate::app::lock_app(&app).model_choices = choices;
}
});
}
/// Best-effort discovery of the models the installed `claude` accepts (aliases
/// like `opus`, `sonnet`, `haiku`, `fable`, plus their `<alias>[1m]`
/// long-context variants), so the `a` picker tracks 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 list.
fn discover_model_choices() -> Option<Vec<(String, String)>> {
let bytes = std::fs::read(claude_binary_path()?).ok()?;
let aliases = longest_alias_array(&bytes)?;
Some(model_choices_from(&bytes, &aliases))
}
/// Assemble picker entries `(label, --model arg)`: `default` (no `--model`
/// flag) first, then every alias, then the `<alias>[1m]` long-context variants
/// the binary actually ships (see `long_context_tokens`).
fn model_choices_from(bytes: &[u8], aliases: &[String]) -> Vec<(String, String)> {
let long = long_context_tokens(bytes);
let mut choices: Vec<(String, String)> = vec![("default".into(), String::new())];
choices.extend(aliases.iter().map(|a| (a.clone(), a.clone())));
choices.extend(
aliases
.iter()
.map(|a| format!("{a}[1m]"))
.filter(|v| long.contains(v))
.map(|v| (format!("{v} (1M context)"), v)),
);
choices
}
/// 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 appends_1m_choices_for_aliases_that_have_them() {
let bytes = br#"["sonnet","opus","haiku"] "sonnet[1m]" "opus[1m]""#;
let aliases = longest_alias_array(bytes).unwrap();
let got = model_choices_from(bytes, &aliases);
let args: Vec<&str> = got.iter().map(|c| c.1.as_str()).collect();
// default (no flag), the plain aliases, then only the real 1M variants.
assert_eq!(args, ["", "sonnet", "opus", "haiku", "sonnet[1m]", "opus[1m]"]);
assert_eq!(got[4].0, "sonnet[1m] (1M context)");
}
/// 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 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());
}
/// 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));
}
}
}