//! 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>, master: Box, killer: Box, exited: Arc, /// 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, } /// 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 ` (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 { 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 ` 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 = 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 { /// 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 { let last = rows.iter().rposition(|t| !t.trim().is_empty())?; let rules: Vec = (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 = (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 { 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 { 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, anyhow::Error> { Ok(Box::new(self.dup()?)) } fn take_writer(&self) -> Result, anyhow::Error> { Ok(Box::new(self.dup()?)) } fn process_group_leader(&self) -> Option { match unsafe { libc::tcgetpgrp(self.fd.as_raw_fd()) } { pid if pid > 0 => Some(pid), _ => None, } } fn as_raw_fd(&self) -> Option { Some(self.fd.as_raw_fd()) } fn tty_name(&self) -> Option { 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 { 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 { 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 { 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 { 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 `[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> { 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 `[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 { 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> { let mut best: Option> = 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 `"[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 { 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, 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 { 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 { 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 { 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)); } } }