diff --git a/src/app.rs b/src/app.rs index 8417ebd..650fdf6 100644 --- a/src/app.rs +++ b/src/app.rs @@ -27,6 +27,10 @@ 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, + /// 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. + pub embed_clear_at: Option, } /// Client-side tools that render a large interactive UI in Claude Code. @@ -47,6 +51,7 @@ impl App { show_sessions: true, embed_session: None, embed_grow: false, + embed_clear_at: None, } } } @@ -338,6 +343,11 @@ impl Drop for Tap { if let Some(i) = self.cur.take() { s.entries[i].done = true; } + // A turn of the embedded session just finished streaming: schedule a + // transcript wipe shortly after Claude Code prints its final lines. + if a.embed_session.as_deref() == Some(a.sessions[self.sidx].key.as_str()) { + a.embed_clear_at = Some(Instant::now() + std::time::Duration::from_millis(400)); + } } } diff --git a/src/term.rs b/src/term.rs index 0ac483e..6ec5029 100644 --- a/src/term.rs +++ b/src/term.rs @@ -137,6 +137,18 @@ impl EmbeddedTerm { }); } + /// Ask Claude Code to wipe its transcript from the visible screen + /// (ctrl-l clears the terminal but keeps the conversation; the live + /// prompt UI redraws immediately). Used to keep the pane prompt-only — + /// the feed above already shows everything the transcript would. + pub fn clear_screen(&self) { + let _ = self + .term + .lock() + .unwrap() + .key_down(KeyCode::Char('l'), KeyModifiers::CTRL); + } + /// Forward a key press to the child. Returns false for keys we don't map. pub fn key(&self, k: ratatui::crossterm::event::KeyEvent) -> bool { use ratatui::crossterm::event::KeyCode as CK; diff --git a/src/ui.rs b/src/ui.rs index 97e9730..af9b6bf 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -83,6 +83,24 @@ fn event_loop( ) -> anyhow::Result<()> { loop { terminal.draw(|f| draw(f, &app, eui))?; + // Scheduled transcript wipe (set by the tap when an embedded-session + // turn finishes): keeps the pane prompt-only. + let clear_due = { + let mut a = app.lock().unwrap(); + if a.embed_clear_at.is_some_and(|t| std::time::Instant::now() >= t) { + a.embed_clear_at = None; + true + } else { + false + } + }; + if clear_due { + if let Some(et) = &eui.term { + if !et.exited() { + et.clear_screen(); + } + } + } if !event::poll(Duration::from_millis(33))? { continue; } @@ -193,15 +211,20 @@ 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; ~35% of the screen normally, - // 75% while the tap says an interactive prompt is on screen — Claude - // Code's question UI clips its option list to the rows it gets, so the - // prompt wants as much height as we can spare. + // 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 let show_embed = eui.visible && eui.term.is_some(); let embed_h = if show_embed { let total = f.area().height; - let pct = if a.embed_grow { 75 } else { 35 }; - ((total as u32 * pct / 100) as u16).clamp(10, total.saturating_sub(6)) + if a.embed_grow { + ((total as u32 * 75 / 100) as u16).clamp(10, total.saturating_sub(6)) + } else { + EMBED_COMPACT.min(total.saturating_sub(6)) + } } else { 0 };