dynamic prompt framing
This commit is contained in:
27
CLAUDE.md
27
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),
|
||||
|
||||
234
src/term.rs
234
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<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;
|
||||
|
||||
52
src/ui.rs
52
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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user