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:
51
src/app.rs
51
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<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()));
|
||||
|
||||
52
src/term.rs
52
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<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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
19
src/ui.rs
19
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
|
||||
|
||||
Reference in New Issue
Block a user