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.
1719 lines
76 KiB
Rust
1719 lines
76 KiB
Rust
//! 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));
|
||
}
|
||
}
|
||
}
|