Scroll the fullscreen pane's own scrollback

Claude Code grabs no mouse and stays off the alternate screen, so a plain
terminal answers the wheel with its own scrollback and the child never hears
about it. The fullscreen pane is that terminal, so it does the same job: the
view is a stable row index into wezterm-term's scrollback, which pins the rows
you scrolled to while the child keeps writing.

The ctrl-l transcript wipe is cancelled while fullscreen — there the pane is
not prompt-only, and its transcript is the thing being scrolled.
This commit is contained in:
Jonas H
2026-08-27 14:34:48 +02:00
parent 73871cb1dc
commit b9d7d2c969
3 changed files with 287 additions and 25 deletions

View File

@@ -151,7 +151,10 @@ src/term.rs embedded claude pane: spawns `claude --session-id <uuid>` in a
ship a `[1m]` variant — see the 1M invariant). `EmbeddedTerm::adopt`
rebuilds a pane around an inherited pty fd + pid after a hot
reload (`AdoptedMaster` / `PidKiller` stand in for the
portable-pty handles, which do not survive an exec)
portable-pty handles, which do not survive an exec).
`scroll` / `follow_live` / `scrolled_rows` give the
**fullscreen** pane the scrollback a plain terminal would —
see the pane-scroll 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
@@ -480,7 +483,38 @@ agentId: <hex>`), and the real completion is injected into the parent's next
ExitPlanMode (sized from the question's option count) before Claude Code
renders the prompt, shrinks when the tool_result echoes back, and schedules
a ctrl-l transcript wipe 400ms after each turn (the pane is prompt-only;
the feed shows the context).
the feed shows the context)**except in fullscreen**, where that wipe is
*cancelled*, not deferred (see the pane-scroll invariant).
- **In fullscreen the scroll is the pane's own scrollback, never a forwarded
mouse event.** Claude Code enables no mouse tracking and never leaves the
normal screen (verified on the wire: for 2.1.247 tmux reports every mouse
flag clear and `alternate_on=0`), so in a plain terminal the wheel scrolls
*that terminal's* scrollback and the child never hears about it. The
fullscreen pane is that terminal, so it does the same job:
`EmbeddedTerm::scroll` moves a view into wezterm-term's scrollback (3500
rows, the crate default) and `render` reads the window from there instead of
the live screen. Four rules keep it honest:
1. The view is a **`StableRowIndex`, not an offset** — the child keeps
writing while you read, and a terminal pins the rows you scrolled to
rather than sliding them up under you. Reaching the live top re-engages
follow mode instead of pinning to it, and no cursor is reported while
scrolled away (its row does not index that window).
2. **Only `PaneView::Full` scrolls.** The cropped views frame Claude Code's
input box, which is always at the live bottom, so `draw` calls
`follow_live` for them — one place, instead of at each of ctrl-f /
ctrl-↑ / F2 / session-switch.
3. **Any key snaps back to live** (xterm's scroll-on-key) before it is
forwarded, so typing can never leave you reading history while the child
answers off-screen. `shift`+PgUp/PgDn is the exception: a real terminal
keeps those for its own scrollback too, so they page the pane and are not
forwarded.
4. The **ctrl-l wipe is cancelled while fullscreen**, because there the pane
is not prompt-only — its transcript is the whole context, and the thing
being scrolled. Cancelled rather than deferred: firing it later would
delete that history the moment ctrl-f dropped out of fullscreen. A wipe
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.
- 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
@@ -590,8 +624,10 @@ agentId: <hex>`), and the real completion is injected into the parent's next
characters (alt-c = ©) and never reaches the app as a modifier. Pane keys:
F2 toggle, ctrl-↓ attach pane to selected session (resume/spawn/focus),
ctrl-↑ focus feed, ctrl-f fullscreen toggle (only while the pane is
focused), ctrl-q quit (global; needed while the pane is focused, where
plain `q` is forwarded to the child), c attach most-recent past session.
focused), shift+PgUp/PgDn page the pane's scrollback while it is fullscreen
(any other key snaps back to live), ctrl-q quit (global; needed while the
pane is focused, where plain `q` is forwarded to the child), c attach
most-recent past session.
List keys: j/k/↑/↓ move the session/turn highlight, space/→/← expand/
enter/leave the turn tree, a opens the model picker popup and spawns a
brand-new `claude --session-id … [--model …]` (kills any current pane —
@@ -629,10 +665,17 @@ agentId: <hex>`), and the real completion is injected into the parent's next
ctrl-r hot-reloads onto the binary now on disk (you rebuild outside; this
swaps the running instance onto it) — global like ctrl-q, because it has to
work while the pane holds focus.
`CT_DEBUG_KEYS=1` shows raw key events in the status bar.
- Mouse is captured: wheel always scrolls the feed (regardless of focus), and
left-drag selects screen text, copied on release via OSC 52 (like Claude
Code). Native terminal selection therefore needs shift held.
`CT_DEBUG_KEYS=1` shows raw key *and* mouse events in the status bar
(`mouse: ScrollUp … fullscreen=true back=0`) — the wheel's two silent
failure modes look identical on screen otherwise: no event delivered at all
(an outer tmux without `set -g mouse on` swallows them) versus an event
delivered to a pane with no scrollback above the live screen.
- Mouse is captured: the wheel scrolls the feed regardless of focus — except
while the pane is fullscreen, where the feed is off screen and the wheel
scrolls the child's scrollback instead (`EmbedUi::scroll_pane`, the same
`WHEEL_ROWS` step either side of ctrl-f). Left-drag selects screen text,
copied on release via OSC 52 (like Claude Code). Native terminal selection
therefore needs shift held.
- Bracketed paste is enabled on the outer terminal (`EnableBracketedPaste`):
a multiline paste arrives as one `Event::Paste` and, when the claude pane
has focus, is handed to the child via `EmbeddedTerm::paste`
@@ -735,9 +778,10 @@ agentId: <hex>`), and the real completion is injected into the parent's next
under the app mutex; `source.type == "url"` shows the url) and a
`tool_reference` as `[tool <name>]`. The bare `[<type>]` placeholder remains
the fallback for everything else. No terminal graphics protocol.
- Embedded pane: no mouse forwarding yet; no scrollback view (live screen
only); shift+enter needs kitty keyboard protocol pushed on the outer
terminal (not done); permission prompts aren't detected for pane growth
- Embedded pane: no mouse forwarding to the child (it asks for none — see the
pane-scroll invariant); the scrollback view is fullscreen-only, and the
cropped views stay live-screen-only by design; shift+enter needs kitty
keyboard protocol pushed on the outer terminal (not done); permission prompts aren't detected for pane growth
(not visible in the API stream — would need a Notification hook hitting a
local control endpoint).
- Materialized branch files satisfy our own parser (round-trip tested) but