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:
32
CLAUDE.md
32
CLAUDE.md
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
15
src/term.rs
15
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.
|
||||
|
||||
105
src/ui.rs
105
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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user