Fullscreen the pane while an editor holds it

ctrl-g opens the prompt in $EDITOR, which takes the child's pty over via
the alternate screen — something Claude Code never does itself. The
compact frame looks for the input box's two rules, so an editor could
only ever be cropped by it. Follow the alternate screen instead: give
the pane the whole screen for as long as the editor lasts, then put it
back. Edge-triggered, so ctrl-f still wins.
This commit is contained in:
Jonas H
2026-09-07 11:52:43 +02:00
parent 043c99cdeb
commit ae454a3d2d
4 changed files with 152 additions and 4 deletions

View File

@@ -160,7 +160,10 @@ src/term.rs embedded claude pane: spawns `claude --session-id <uuid>` 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: <hex>`), 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

View File

@@ -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<String>,
pub compact_inner: u16,
}

View File

@@ -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.

105
src/ui.rs
View File

@@ -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);
}
}