diff --git a/CLAUDE.md b/CLAUDE.md index 07ddb4d..067300b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -160,7 +160,10 @@ src/term.rs embedded claude pane: spawns `claude --session-id ` in a see the pane-scroll invariant. `shows_error` reports whether the compact frame is currently holding an error Claude Code printed, which is what stops the ctrl-l wipe from deleting it — - see the pane-error invariant + see the pane-error invariant. `alt_screen` reports the + *alternate* screen — an `$EDITOR` (nvim, `git commit`, a pager) + Claude Code launched into the same pty — which auto-fullscreens + the pane; see the alt-screen invariant src/reload.rs hot reload: ctrl-r `execve`s the binary now on disk *into this process* — same pid, so the listener socket, the `claude` child and (via a JSON snapshot) the live feed all cross over. Builds @@ -571,6 +574,33 @@ agentId: `), and the real completion is injected into the parent's next that already ran *before* you went fullscreen is gone for good though — Ink redraws only the live frame, so fullscreen shows history from that point on. + +- **An editor on the child's alternate screen owns the whole pane.** ctrl-g + opens the prompt in `$EDITOR` (so do `/memory` and a `git commit` a tool + runs), and that program takes the pty over via the alternate buffer — which + Claude Code itself never does (the pane-scroll invariant leans on the same + fact). So `EmbeddedTerm::alt_screen` means exactly one thing: what is on + screen is not a prompt to frame, and framing it can only crop it + (`compact_frame` looks for the input box's two rules, which nvim never + draws). `ui::sync_alt_screen` therefore fullscreens the pane for as long as + the editor lasts and puts it back after. Four rules: + 1. **Edge-triggered, never re-asserted per frame**, which is what leaves + ctrl-f in charge: a manual toggle mid-edit sticks instead of being undone + on the next draw, and it clears the restore flag + (`EmbedUi::alt_fullscreen`) so quitting the editor doesn't reverse it. A + pane that was *already* fullscreen stays fullscreen afterwards — only a + fullscreen we entered ourselves is undone. + 2. **Gated on pane focus**, keeping fullscreen ⇔ focused: ctrl-↑ hands the + screen back to the feed mid-edit, ctrl-↓ returns it to the editor. + 3. **The ctrl-l wipe is cancelled** while the alternate screen is up, for a + different reason than fullscreen's: that keystroke is meant for Claude + Code's input box, and sending it into nvim is not ours to do. The scroll + view also resets on both edges — the two screens index stable rows + differently, and the alternate one holds no scrollback at all. + 4. **A hot reload reads the flag off the adopted child** before the first + draw, so an editor still open at ctrl-r raises no edge and the + snapshotted `PaneState::alt_fullscreen` stays meaningful. + - Tool input streams as raw JSON fragments; pretty-printed only on `content_block_stop`. Streaming text re-renders markdown on every change (FeedCache fingerprints by content length + done + result), so partial diff --git a/src/reload.rs b/src/reload.rs index df4c764..7e0b1c8 100644 --- a/src/reload.rs +++ b/src/reload.rs @@ -179,6 +179,10 @@ pub struct PaneState { pub visible: bool, pub focused: bool, pub fullscreen: bool, + /// That fullscreen was entered for an editor on the child's alternate + /// screen, so it is undone when the editor exits (`ui::sync_alt_screen`). + /// Carried because the editor usually outlives the reload. + pub alt_fullscreen: bool, pub past_embeds: Vec, pub compact_inner: u16, } diff --git a/src/term.rs b/src/term.rs index 418237a..97f73de 100644 --- a/src/term.rs +++ b/src/term.rs @@ -408,6 +408,21 @@ impl EmbeddedTerm { (term.screen().visible_row_to_stable_row(0) - top).max(0) as usize } + /// True while the child sits on the **alternate screen** — i.e. a + /// full-screen program Claude Code launched (an `$EDITOR` like nvim, a + /// `git commit` message, a pager) has taken the terminal over. + /// + /// Claude Code itself never leaves the normal screen (verified on the wire: + /// `alternate_on=0` on 2.1.247), so the flag means exactly one thing — what + /// is on screen is not Claude Code's prompt. Framing it as one can only + /// crop it: `compact_frame` locates the input box by its two rules, which + /// an editor does not draw. The UI reads this to fullscreen the pane for as + /// long as the editor lasts (`ui::sync_alt_screen`). The alternate screen + /// also carries no scrollback, so `scroll` is a no-op there by itself. + pub fn alt_screen(&self) -> bool { + self.term.lock().unwrap().is_alt_screen_active() + } + /// The child's current cursor shape (set via DECSCUSR). We mirror it onto /// the outer terminal so the pane shows a bar in insert mode and a block /// only when Claude Code's vim normal mode asks for one. diff --git a/src/ui.rs b/src/ui.rs index ea15944..97163c2 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -34,6 +34,15 @@ struct EmbedUi { /// Pane takes (nearly) the whole screen. Toggled with ctrl-f while the /// pane has focus; cleared when focus leaves it or it is hidden. fullscreen: bool, + /// The focused child was on the **alternate screen** last frame + /// (`EmbeddedTerm::alt_screen`): an editor it launched — nvim, a + /// `git commit`, a pager — owns the terminal, so the pane is fullscreen and + /// the ctrl-l wipe is off (that keystroke belongs to the editor now). + alt_screen: bool, + /// Fullscreen was entered *for* that editor, so leaving the alternate + /// screen puts the pane back the way it was. A manual ctrl-f clears it: + /// the last explicit choice wins over the restore. + alt_fullscreen: bool, port: u16, /// Sessions that were embedded earlier in this process: their instances /// are known dead (we killed them), so resuming needs no liveness guard. @@ -68,6 +77,30 @@ const WHEEL_ROWS: isize = 3; /// turn or a filtering `@`/`/` menu, short enough to feel responsive. const SHRINK_DELAY: Duration = Duration::from_millis(400); +/// Follow the child in and out of the alternate screen: entering fullscreens +/// the pane, leaving puts it back — but only if *we* were the one that +/// fullscreened it (`auto`). Returns true on either edge, so the caller can +/// reset the scroll view (the two screens index stable rows differently, and +/// the alternate one holds no scrollback at all). +/// +/// Edge-triggered, never re-asserted per frame, which is what leaves ctrl-f in +/// charge: a manual toggle while the editor is open sticks instead of being +/// overridden on the next draw. Pure over its `&mut` state so it can be +/// unit-tested without an `EmbedUi`. +fn sync_alt_screen(alt: bool, last: &mut bool, fullscreen: &mut bool, auto: &mut bool) -> bool { + if alt == *last { + return false; + } + *last = alt; + if alt { + *auto = !*fullscreen; + *fullscreen = true; + } else if std::mem::take(auto) { + *fullscreen = false; + } + true +} + /// Mouse text selection over the whole screen (mimics Claude Code: drag to /// select with a reversed-video highlight, the underlying screen text is /// copied to the system clipboard on release via OSC 52). @@ -525,10 +558,17 @@ pub fn run( } }); let has_pane = adopted.is_some(); + // Read the alternate-screen flag *before* the first draw: seeding it from + // the live child means an editor that was open across the reload raises no + // edge, so the snapshotted `alt_fullscreen` survives and quitting the + // editor still restores the compact pane. + let on_alt = adopted.as_ref().is_some_and(EmbeddedTerm::alt_screen); let mut eui = EmbedUi { visible: pane_ui.visible && has_pane, claude_focused: pane_ui.focused && has_pane, fullscreen: pane_ui.fullscreen && has_pane, + alt_screen: on_alt, + alt_fullscreen: pane_ui.alt_fullscreen && has_pane, past_embeds: pane_ui.past_embeds.into_iter().collect(), compact_inner: if pane_ui.compact_inner == 0 { crate::term::DEFAULT_COMPACT_INNER @@ -608,6 +648,7 @@ fn try_reload(app: &SharedApp, eui: &mut EmbedUi, terminal: &mut ratatui::Defaul visible: eui.visible, focused: eui.claude_focused, fullscreen: eui.fullscreen, + alt_fullscreen: eui.alt_fullscreen, past_embeds: eui.past_embeds.iter().cloned().collect(), compact_inner: eui.compact_inner, }; @@ -907,7 +948,10 @@ fn event_loop( 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; - !eui.fullscreen + // …and dropped outright while an editor holds the child's + // screen (`alt_screen`): ctrl-l is meant for Claude Code's own + // input, and sending it into nvim is not ours to do. + !eui.fullscreen && !eui.alt_screen } else { false } @@ -1054,6 +1098,9 @@ fn event_loop( if ctrl && k.code == KeyCode::Char('f') && eui.focused() { if k.kind == KeyEventKind::Press { eui.fullscreen = !eui.fullscreen; + // An explicit choice outranks the alternate-screen + // restore: don't undo it when the editor exits. + eui.alt_fullscreen = false; } continue; } @@ -1296,6 +1343,18 @@ fn draw( // Mirror focus into shared state so the off-thread tap can avoid stealing // the selection from a pane the user is actively driving. a.pane_focused = eui.focused(); + // An editor Claude Code launched (nvim, `git commit`, a pager) takes the + // child's screen over via the alternate buffer, which Claude Code never + // does itself. So there is no input box left to frame — give the pane the + // whole screen for as long as the editor lasts, and put it back after. + // Gated on focus, keeping fullscreen ⇔ focused: ctrl-↑ hands the screen + // back to the feed, ctrl-↓ returns it to the editor. + let on_alt = eui.focused() && eui.term.as_ref().is_some_and(EmbeddedTerm::alt_screen); + if sync_alt_screen(on_alt, &mut eui.alt_screen, &mut eui.fullscreen, &mut eui.alt_fullscreen) + && let Some(et) = &eui.term + { + et.follow_live(); + } let pane_view = if eui.fullscreen { PaneView::Full } else if a.embed_grow { @@ -3072,8 +3131,8 @@ fn sanitize_md(s: &str) -> String { mod tests { use super::{ SHRINK_DELAY, base64, color_on, entry_lines, fmt_ms, lane_dur, lane_mark, lane_title, - lane_tokens, mcp_header, popup_rect, sanitize_md, smooth_compact, truncate_str, - user_block_style, wrap_words, + lane_tokens, mcp_header, popup_rect, sanitize_md, smooth_compact, sync_alt_screen, + truncate_str, user_block_style, wrap_words, }; use crate::app::{Entry, Kind, Lane, MAIN_LANE, ToolResult}; use ratatui::layout::Rect; @@ -3635,4 +3694,44 @@ mod tests { assert_eq!(fmt_ms(400), "400ms"); assert_eq!(fmt_ms(0), "0ms"); } + + /// An editor on the child's alternate screen fullscreens the pane, and + /// quitting it gives the compact pane back. + #[test] + fn alt_screen_fullscreens_the_pane_and_restores_it() { + let (mut last, mut full, mut auto) = (false, false, false); + assert!(sync_alt_screen(true, &mut last, &mut full, &mut auto)); + assert!(full && auto); + // Steady state raises no edge, so nothing is re-asserted per frame. + assert!(!sync_alt_screen(true, &mut last, &mut full, &mut auto)); + assert!(sync_alt_screen(false, &mut last, &mut full, &mut auto)); + assert!(!full && !auto); + } + + /// A pane that was already fullscreen stays fullscreen after the editor + /// exits — the restore only undoes a fullscreen we entered ourselves. + #[test] + fn alt_screen_keeps_a_fullscreen_the_user_chose() { + let (mut last, mut full, mut auto) = (false, true, false); + sync_alt_screen(true, &mut last, &mut full, &mut auto); + assert!(full && !auto); + sync_alt_screen(false, &mut last, &mut full, &mut auto); + assert!(full); + } + + /// ctrl-f while the editor is open clears the restore flag (the key + /// handler does that), so quitting the editor leaves the choice alone. + #[test] + fn manual_toggle_outranks_the_alt_screen_restore() { + let (mut last, mut full, mut auto) = (false, false, false); + sync_alt_screen(true, &mut last, &mut full, &mut auto); + // ctrl-f, ctrl-f: back to fullscreen, explicitly. + for _ in 0..2 { + full = !full; + auto = false; + } + sync_alt_screen(false, &mut last, &mut full, &mut auto); + assert!(full); + } + }