Crop claude chrome rows; size ask-tool pane from option count

Render window is now content-anchored instead of the raw screen top:
ends BOTTOM_CROP(2) rows above the last non-blank row (hides the
"? for shortcuts"/mode hint rows) and starts no higher than row 2
(hides "✻ Worked for 1s"; the streaming spinner stays visible since it
sits next to the input box). The PTY gets PTY_PAD(4) extra rows so
Claude Code still has room to draw what we crop. Compact pane shrinks
12 → 8 rows.

AskUserQuestion pane height is now estimated from the tool input
(max over questions of: question text + 2 rows per option incl. the
implicit "Other" + submit/hint rows + tab header), replacing the flat
75% — which remains the fallback for ExitPlanMode/parse failures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jonas H
2026-06-11 09:04:19 +02:00
parent b05706a51e
commit ee429be917
3 changed files with 107 additions and 15 deletions

View File

@@ -27,6 +27,9 @@ pub struct App {
/// (AskUserQuestion/ExitPlanMode seen in the stream, answer not yet
/// echoed back) — the UI gives the pane more rows while set.
pub embed_grow: bool,
/// Content-based row estimate for the prompt (AskUserQuestion: derived
/// from question/option counts). None → percentage fallback.
pub embed_grow_rows: Option<u16>,
/// When set, the UI should send ctrl-l to the embedded claude at this
/// instant (a beat after a turn ends, once Claude Code has printed its
/// final transcript lines) so the pane stays prompt-only.
@@ -38,6 +41,31 @@ fn is_interactive_tool(name: &str) -> bool {
matches!(name, "AskUserQuestion" | "ExitPlanMode")
}
/// Rows Claude Code's question UI needs, estimated from the tool input:
/// per question (they're tabbed, so take the max) — question text + spacing,
/// 2 rows per option (label + description) incl. the implicit "Other",
/// submit row for multi-select, hint row. Slightly generous beats clipped.
fn ask_question_rows(input: &Value) -> Option<u16> {
let qs = input.get("questions")?.as_array()?;
let per_q = qs
.iter()
.map(|q| {
let opts = q
.get("options")
.and_then(Value::as_array)
.map_or(4, Vec::len) as u16;
let multi = q
.get("multiSelect")
.and_then(Value::as_bool)
.unwrap_or(false);
3 + (opts + 1) * 2 + u16::from(multi) + 2
})
.max()
.unwrap_or(20);
// Tab header row when there are multiple questions.
Some(per_q + if qs.len() > 1 { 2 } else { 0 })
}
impl App {
pub fn new() -> Self {
Self {
@@ -51,6 +79,7 @@ impl App {
show_sessions: true,
embed_session: None,
embed_grow: false,
embed_grow_rows: None,
embed_clear_at: None,
}
}
@@ -149,6 +178,7 @@ pub fn attach_tool_results(app: &SharedApp, key: &str, body: &Value) {
}
if is_embed && answered {
a.embed_grow = false;
a.embed_grow_rows = None;
}
}
@@ -212,6 +242,7 @@ impl Tap {
// Set when a completed block means the embedded pane is about to show
// a big interactive prompt (checked against embed_session below).
let mut interactive = false;
let mut grow_rows: Option<u16> = None;
let s = &mut a.sessions[self.sidx];
s.last_activity = Instant::now();
match ev {
@@ -290,7 +321,11 @@ impl Tap {
// Tool input arrives as JSON fragments; pretty-print once complete.
if let Kind::Tool { name } = &e.kind {
interactive = is_interactive_tool(name);
let is_ask = name == "AskUserQuestion";
if let Ok(v) = serde_json::from_str::<Value>(&e.content) {
if is_ask {
grow_rows = ask_question_rows(&v);
}
if let Ok(p) = serde_json::to_string_pretty(&v) {
e.content = p;
}
@@ -331,6 +366,7 @@ impl Tap {
&& a.embed_session.as_deref() == Some(a.sessions[self.sidx].key.as_str())
{
a.embed_grow = true;
a.embed_grow_rows = grow_rows;
}
}
}
@@ -424,6 +460,21 @@ mod tests {
assert!(!app.lock().unwrap().embed_grow);
}
#[test]
fn ask_rows_scale_with_options() {
let two = ask_question_rows(&json!({"questions": [
{"question": "?", "options": [{"label": "a"}, {"label": "b"}]}
]}))
.unwrap();
let four = ask_question_rows(&json!({"questions": [
{"question": "?", "multiSelect": true,
"options": [{"label": "a"}, {"label": "b"}, {"label": "c"}, {"label": "d"}]}
]}))
.unwrap();
assert!(four > two, "more options → more rows ({four} vs {two})");
assert!(ask_question_rows(&json!({"nope": 1})).is_none());
}
#[test]
fn non_embed_session_never_grows() {
let app: SharedApp = Arc::new(Mutex::new(App::new()));

View File

@@ -24,6 +24,13 @@ 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 rows ("? for shortcuts" + permission/mode line).
const BOTTOM_CROP: usize = 2;
/// Extra PTY rows beyond the visible pane window, so the child has room to
/// draw the rows we crop.
const PTY_PAD: u16 = 4;
#[derive(Debug)]
struct Config;
@@ -71,7 +78,12 @@ impl EmbeddedTerm {
cols: u16,
) -> anyhow::Result<Self> {
let pty = native_pty_system()
.openpty(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 })
.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();
@@ -82,7 +94,7 @@ impl EmbeddedTerm {
let writer = pty.master.take_writer().context("pty writer")?;
let term = Arc::new(Mutex::new(Terminal::new(
TerminalSize {
rows: rows as usize,
rows: (rows + PTY_PAD) as usize,
cols: cols as usize,
pixel_width: 0,
pixel_height: 0,
@@ -120,13 +132,17 @@ impl EmbeddedTerm {
self.exited.load(Ordering::Relaxed)
}
/// Resize PTY + terminal model to the pane's inner size (no-op if unchanged).
/// Resize PTY + terminal model for a pane of `rows` visible rows.
/// 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.
pub fn resize(&mut self, rows: u16, cols: u16) {
if (rows, cols) == (self.rows, self.cols) || rows == 0 || cols == 0 {
return;
}
self.rows = rows;
self.cols = cols;
let rows = rows + PTY_PAD;
let _ = self.master.resize(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 });
self.term.lock().unwrap().resize(TerminalSize {
rows: rows as usize,
@@ -186,14 +202,31 @@ impl EmbeddedTerm {
self.term.lock().unwrap().key_down(key, mods).is_ok()
}
/// Paint the visible screen into `area`. Returns the cursor position
/// (absolute buffer coordinates) when the child wants it shown.
/// 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 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 rows ("? for shortcuts", permission mode);
/// - 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).
pub fn render(&self, area: Rect, buf: &mut Buffer) -> 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);
for (y, line) in lines.iter().enumerate() {
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);
for (y, line) in lines[start..=end].iter().enumerate() {
if y as u16 >= area.height {
break;
}
@@ -234,10 +267,13 @@ impl EmbeddedTerm {
}
}
let cursor = term.cursor_pos();
let cy = cursor.y as usize;
(cursor.visibility == CursorVisibility::Visible
&& (cursor.x as u16) < area.width
&& (cursor.y as u16) < area.height)
.then(|| (area.x + cursor.x as u16, area.y + cursor.y as u16))
&& cy >= start
&& cy <= end
&& ((cy - start) as u16) < area.height)
.then(|| (area.x + cursor.x as u16, area.y + (cy - start) as u16))
}
}

View File

@@ -212,18 +212,23 @@ fn event_loop(
fn draw(f: &mut Frame, app: &SharedApp, eui: &mut EmbedUi) {
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), so the default is just
// tall enough for Claude Code's input box + status rows; while the tap
// says an interactive prompt is on screen it grows to 75% so the option
// list isn't clipped.
const EMBED_COMPACT: u16 = 12; // 10 inner rows + borders
// 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. Interactive prompts grow it: sized from the question's
// option count when the tap could estimate it, 75% as fallback.
const EMBED_COMPACT: u16 = 8; // 6 inner rows + borders
let show_embed = eui.visible && eui.term.is_some();
let embed_h = if show_embed {
let total = f.area().height;
let cap = total.saturating_sub(6).max(1);
if a.embed_grow {
((total as u32 * 75 / 100) as u16).clamp(10, total.saturating_sub(6))
let h = a
.embed_grow_rows
.map(|r| r + 2) // + borders
.unwrap_or((total as u32 * 75 / 100) as u16);
h.clamp(EMBED_COMPACT.min(cap), cap)
} else {
EMBED_COMPACT.min(total.saturating_sub(6))
EMBED_COMPACT.min(cap)
}
} else {
0