From ee429be917526af85cc8d32b6b937e90c99c3f0b Mon Sep 17 00:00:00 2001 From: Jonas H Date: Thu, 11 Jun 2026 09:04:19 +0200 Subject: [PATCH] Crop claude chrome rows; size ask-tool pane from option count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/app.rs | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/term.rs | 52 ++++++++++++++++++++++++++++++++++++++++++++-------- src/ui.rs | 19 ++++++++++++------- 3 files changed, 107 insertions(+), 15 deletions(-) diff --git a/src/app.rs b/src/app.rs index 650fdf6..0e3c1b5 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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, /// 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 { + 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 = 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::(&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())); diff --git a/src/term.rs b/src/term.rs index 6ec5029..0e9baca 100644 --- a/src/term.rs +++ b/src/term.rs @@ -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 { 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)) } } diff --git a/src/ui.rs b/src/ui.rs index af9b6bf..caeec64 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -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