diff --git a/CLAUDE.md b/CLAUDE.md index db61b9f..1fdaf24 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -150,12 +150,27 @@ UI thread redraws on its own tick (no channel; just the mutex). heading markers) are compensated in `src/markdown.rs`, not by upgrading. - `wezterm-term`/`wezterm-surface` are not on crates.io: pinned to a git rev of the wezterm monorepo (keep both revs identical). -- The pane's render window crops Claude Code chrome by *position* - (`term.rs`: `BOTTOM_CROP`, start ≥ 2, `PTY_PAD`) — tuned to the current - Claude Code UI; retune there if an update adds/removes chrome rows. - Cropping only applies to the compact pane: fullscreen renders the child's - screen verbatim from row 0 with the PTY sized exactly to the pane - (`crop` flag on `resize`/`render`). +- The compact pane *dynamically frames* Claude Code's input box rather than + cropping by fixed offsets (`term.rs`: `compact_frame` + `PaneView`). It + locates the box by its two horizontal-rule borders (`text_is_rule`) — the + last two `────` rules on screen, since the prompt always sits at the bottom — + and shows one context row above the top rule (the spinner / "✻ Worked…" row) + down to the statusLine just under the bottom rule, cropping the persistent + hint/token/effort chrome below it. When an `@`/`/` menu is open it has + replaced that chrome with a list (`text_is_menu_item`, CC-2.1.x glyphs — + retune there if an update changes them), so the frame extends to the last + non-blank row instead. The framed region drives the pane height too: + `compact_rows` (called from `ui::draw`) measures box-height + tail so the + pane auto-expands as the prompt gains lines or a menu opens and shrinks back + when idle (floor `MIN_COMPACT_INNER`, cap = screen − 6); `PTY_PAD` keeps the + PTY taller than the visible window so the child can still draw the rows we + crop. `PaneView::Interactive` (the tap-grown AskUserQuestion / ExitPlanMode + pane, whose selection box renders *above* the input) top-anchors from row 2 + instead so the prompt stays visible. `PaneView::Full` (fullscreen) renders + the child's screen verbatim from row 0 with the PTY sized exactly to the + pane. Permission-prompt boxes (rounded borders, not rules, and not in the + API stream) aren't expanded in the compact pane — consistent with the + known "permission prompts aren't detected" limit. - Keybindings avoid Alt entirely: on layouts like dk_mac_fixed, Alt composes characters (alt-c = ©) and never reaches the app as a modifier. Pane keys: F2 toggle, ctrl-↓ attach pane to selected session (resume/spawn/focus), diff --git a/src/term.rs b/src/term.rs index 02a4215..a1f28d3 100644 --- a/src/term.rs +++ b/src/term.rs @@ -26,13 +26,28 @@ use wezterm_term::{ Intensity, KeyCode, KeyModifiers, Terminal, TerminalConfiguration, TerminalSize, Underline, }; -/// Rows cropped above the last non-blank screen row — hides Claude Code's -/// persistent hint row ("? for shortcuts" / permission mode) while keeping -/// the statusLine that renders directly under the input box. -const BOTTOM_CROP: usize = 1; /// Extra PTY rows beyond the visible pane window, so the child has room to -/// draw the rows we crop. +/// 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). +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; @@ -243,40 +258,71 @@ impl EmbeddedTerm { self.term.lock().unwrap().key_down(key, mods).is_ok() } - /// Paint a window of the child's screen into `area`. Returns the cursor - /// position (absolute buffer coordinates) when the child wants it shown. - /// - /// With `crop`, the window is content-anchored rather than the raw top of - /// the screen, tuned to keep the pane prompt-only against Claude Code's UI: - /// - it *ends* `BOTTOM_CROP` rows above the last non-blank row, hiding - /// the persistent hint row ("? for shortcuts", permission mode) but - /// keeping the statusLine just below the input box; - /// - it *starts* no higher than row 2 when content allows, hiding the - /// status line ("✻ Worked for 1s" / spinner row sits right above the - /// input box and stays visible since the window ends near it). - /// - /// Without `crop` (fullscreen), the screen is shown verbatim from row 0 - /// so nothing is ever cut off. - pub fn render(&self, area: Rect, buf: &mut Buffer, crop: bool) -> Option<(u16, u16)> { + /// 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. Falls + /// back to a small default before the box has been drawn. Independent of + /// the terminal's row count, so resizing the pane to this value can't feed + /// back into the measurement. + pub fn compact_rows(&self) -> 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 (start, end) = if crop { - let last = lines - .iter() - .rposition(|l| l.visible_cells().any(|c| !c.str().trim().is_empty())) - .unwrap_or(0); - let end = last.saturating_sub(BOTTOM_CROP); - let start = (end + 1) - .saturating_sub(area.height as usize) - .max(2) - .min(end); - (start, end) - } else { - // 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(area.height as usize).saturating_sub(1)) + let rows: Vec = lines.iter().map(row_text).collect(); + let n = match compact_frame(&rows) { + Some((top, bottom)) => (bottom - top + 1) as u16, + None => DEFAULT_COMPACT_INNER, + }; + n.max(MIN_COMPACT_INNER) + } + + /// 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 same pane grown by the tap for an + /// AskUserQuestion / ExitPlanMode prompt, whose selection box renders + /// *above* the input — top-anchored (from row 2) so it stays visible. + /// - `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 => { + // The prompt box renders above the input; keep showing from + // near the top so it's visible, bottom-anchored if it overflows. + let end = last; + let start = (end + 1).saturating_sub(h).max(2).min(end); + (start, end) + } + PaneView::Compact => { + let (top, bottom) = compact_frame(&rows) + // No box found yet: bottom-anchor the raw content. + .unwrap_or((last.saturating_sub(h.saturating_sub(1)), last)); + // Bottom-anchor `bottom` (the statusLine / last menu row); + // if the pane can't fit the whole region, drop context rows + // from the top rather than the prompt. + let start = (bottom + 1).saturating_sub(h).max(top).min(bottom); + (start, bottom) + } }; for (y, line) in lines[start..=end].iter().enumerate() { if y as u16 >= area.height { @@ -329,6 +375,59 @@ impl EmbeddedTerm { } } +/// 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('/') +} + +/// 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)> { + 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]; + let view_top = top_div.saturating_sub(1); + let view_bottom = if last > bot_div && text_is_menu_item(&rows[bot_div + 1]) { + last + } else { + (bot_div + 1).min(last) + }; + Some((view_top, view_bottom)) +} + impl Drop for EmbeddedTerm { fn drop(&mut self) { let _ = self.killer.kill(); @@ -482,6 +581,69 @@ mod tests { assert_eq!(got, ["sonnet", "opus", "haiku", "fable"]); } + /// 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 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. @@ -501,7 +663,7 @@ mod tests { let deadline = Instant::now() + Duration::from_secs(5); loop { let mut buf = Buffer::empty(area); - et.render(area, &mut buf, true); + 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; diff --git a/src/ui.rs b/src/ui.rs index c6d21b3..47c09fd 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,7 +1,7 @@ use crate::app::{ filter_index, fmt_tokens, Entry, Kind, Session, SharedApp, ToolResult, FILTER_LABELS, }; -use crate::term::EmbeddedTerm; +use crate::term::{EmbeddedTerm, PaneView}; use ratatui::crossterm::cursor::SetCursorStyle; use ratatui::crossterm::event::{ self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, @@ -836,11 +836,13 @@ fn draw( let mut a = app.lock().unwrap(); // Embedded pane height: nothing when hidden. The pane is meant to be // prompt-only (the feed above shows the context; term.rs crops Claude - // Code's status/hint rows), so the default is just tall enough for the - // input box plus the statusLine. Interactive prompts grow it: sized from - // the question's option count when the tap could estimate it, 75% as - // fallback. - const EMBED_COMPACT: u16 = 9; // 7 inner rows + borders + // Code's hint/token/effort chrome), so a Compact pane auto-sizes to the + // input box: it grows as the prompt gains lines or an `@`/`/` menu opens, + // and shrinks back when idle (term::compact_rows measures it). An + // Interactive prompt (AskUserQuestion / ExitPlanMode) is grown by the tap: + // sized from the question's option count when it could estimate it, 75% of + // the screen as fallback. + const EMBED_MIN: u16 = 7; // MIN_COMPACT_INNER + borders // The pane is drawn only while the feed selection is on the embedded // session: tabbing away hides it (the child keeps running — hide, don't // kill), tabbing back reveals it instantly. ctrl-↓ attaches elsewhere. @@ -853,22 +855,30 @@ fn draw( eui.claude_focused = false; eui.fullscreen = false; } + let pane_view = if eui.fullscreen { + PaneView::Full + } else if a.embed_grow { + PaneView::Interactive + } else { + PaneView::Compact + }; let embed_h = if show_embed { let total = f.area().height; - if eui.fullscreen { - // Whole screen minus the footer and the 1-row Min(1) the feed - // area keeps (layout below still reserves it). - total.saturating_sub(2) - } else { - let cap = total.saturating_sub(6).max(1); - if a.embed_grow { + let cap = total.saturating_sub(6).max(1); + match pane_view { + // Whole screen minus the footer and the 1-row Min(1) the feed area + // keeps (layout below still reserves it). + PaneView::Full => total.saturating_sub(2), + PaneView::Interactive => { let h = a .embed_grow_rows .map(|r| r.saturating_add(3)) // + borders + statusLine row .unwrap_or((total as u32 * 75 / 100) as u16); - h.clamp(EMBED_COMPACT.min(cap), cap) - } else { - EMBED_COMPACT.min(cap) + h.clamp(EMBED_MIN.min(cap), cap) + } + PaneView::Compact => { + let inner = eui.term.as_ref().map_or(EMBED_MIN, EmbeddedTerm::compact_rows); + inner.saturating_add(2).clamp(EMBED_MIN.min(cap), cap) } } } else { @@ -1303,11 +1313,11 @@ fn draw( inner, ); } else { - // Fullscreen shows the child's screen verbatim (no chrome crop, - // PTY sized exactly); the compact pane crops Claude Code chrome. - let crop = !eui.fullscreen; - et.resize(inner.height, inner.width, crop); - if let Some(pos) = et.render(inner, f.buffer_mut(), crop) { + // Fullscreen shows the child's screen verbatim (PTY sized exactly, + // no chrome crop); the compact/interactive pane crops Claude Code + // chrome, so the PTY gets pad rows to draw what we hide. + et.resize(inner.height, inner.width, pane_view != PaneView::Full); + if let Some(pos) = et.render(inner, f.buffer_mut(), pane_view) { f.set_cursor_position(pos); want_cursor = Some(et.cursor_shape()); }