dynamic prompt framing

This commit is contained in:
Jonas H
2026-06-26 10:31:56 +02:00
parent f97828d7d1
commit 5669d30c27
3 changed files with 250 additions and 63 deletions

View File

@@ -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),

View File

@@ -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<String> = 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<String> = lines.iter().map(row_text).collect();
let last = rows.iter().rposition(|t| !t.trim().is_empty()).unwrap_or(0);
let h = area.height as usize;
let (start, end) = match view {
PaneView::Full => {
// The PTY is sized to the pane in fullscreen, but a resize may
// not have landed yet — clamp to whatever fits.
(0, lines.len().min(h).saturating_sub(1))
}
PaneView::Interactive => {
// 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<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];
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<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 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;

View File

@@ -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());
}