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

View File

@@ -24,7 +24,8 @@ pub use wezterm_surface::CursorShape;
use wezterm_surface::CursorVisibility;
use wezterm_term::color::{ColorAttribute, ColorPalette};
use wezterm_term::{
Intensity, KeyCode, KeyModifiers, Terminal, TerminalConfiguration, TerminalSize, Underline,
Intensity, KeyCode, KeyModifiers, StableRowIndex, Terminal, TerminalConfiguration, TerminalSize,
Underline,
};
/// Extra PTY rows beyond the visible pane window, so the child has room to
@@ -85,6 +86,16 @@ pub struct EmbeddedTerm {
/// Actual PTY rows (visible rows + pad when cropping is active).
pty_rows: u16,
cols: u16,
/// Top row of the *fullscreen* pane's view, as a stable row index —
/// `None` (the normal state) means "follow the live screen".
///
/// Claude Code grabs no mouse and stays off the alternate screen, 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 (see `scroll`). A *stable* index rather than an offset
/// because the child keeps writing while you read: a terminal pins the rows
/// you scrolled to instead of sliding them up under you.
scroll_top: Mutex<Option<StableRowIndex>>,
/// The child's pid. Kept as a plain number because a hot reload
/// (`reload.rs`) execs us: the portable-pty `Child` handle dies with the
/// old image, but we stay the same process, so the *pid* is still ours to
@@ -210,6 +221,7 @@ impl EmbeddedTerm {
pane_token,
pty_rows: rows + PTY_PAD,
cols,
scroll_top: Mutex::new(None),
child_pid,
})
}
@@ -285,6 +297,7 @@ impl EmbeddedTerm {
// the real one and that is what triggers the repaint.
pty_rows: rows,
cols: h.cols,
scroll_top: Mutex::new(None),
child_pid: Some(pid),
})
}
@@ -350,6 +363,51 @@ impl EmbeddedTerm {
let _ = self.term.lock().unwrap().send_paste(text);
}
/// Scroll the fullscreen pane's view by `delta` rows — negative up, into
/// the child's scrollback. Clamps to what the scrollback still holds;
/// arriving back at the live screen re-engages follow mode rather than
/// pinning to it.
///
/// Deliberately *our* scroll and not a forwarded mouse report: Claude Code
/// enables no mouse tracking and never leaves the normal screen, so a
/// plain terminal scrolls its own scrollback here too and the child sees
/// nothing. The fullscreen pane is that terminal.
pub fn scroll(&self, delta: isize) {
let term = self.term.lock().unwrap();
let screen = term.screen();
// Top row of the live screen, and the oldest row still held.
let live = screen.visible_row_to_stable_row(0);
let oldest = screen.phys_to_stable_row_index(0);
let mut top = self.scroll_top.lock().unwrap();
let next = (top.unwrap_or(live) + delta).clamp(oldest, live);
*top = (next < live).then_some(next);
}
/// One page of the pane, in rows: the child's screen height less a row of
/// overlap, so a page scroll keeps a line of context.
pub fn page_rows(&self) -> isize {
(self.term.lock().unwrap().screen().physical_rows as isize - 1).max(1)
}
/// Snap the view back to the live screen. A terminal does this on a
/// keypress (xterm's scroll-on-key); without it a scrolled-back pane looks
/// frozen the moment you start typing again.
pub fn follow_live(&self) {
*self.scroll_top.lock().unwrap() = None;
}
/// Rows the view sits above the live screen (0 = following). The pane
/// border shows this, so a scrolled-back view is never mistaken for a
/// stalled child.
pub fn scrolled_rows(&self) -> usize {
// `term` before `scroll_top`: the one lock order used here.
let term = self.term.lock().unwrap();
let Some(top) = *self.scroll_top.lock().unwrap() else {
return 0;
};
(term.screen().visible_row_to_stable_row(0) - top).max(0) as usize
}
/// 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.
@@ -446,15 +504,33 @@ impl EmbeddedTerm {
/// - `Interactive`: the AskUserQuestion / ExitPlanMode prompt the tap grew
/// the pane for — framed from its own top border (see
/// `interactive_view_range`) so the question text is never cropped.
/// - `Full` (fullscreen): the screen verbatim from row 0, nothing cut off.
/// - `Full` (fullscreen): the screen verbatim from row 0, nothing cut off
/// or, once the wheel has scrolled the pane back (`scroll`), a window of
/// the child's scrollback instead. Only fullscreen scrolls: the cropped
/// views frame the input box, which is always at the live bottom.
pub fn render(&self, area: Rect, buf: &mut Buffer, view: PaneView) -> Option<(u16, u16)> {
let term = self.term.lock().unwrap();
let screen = term.screen();
let first = screen.phys_row(0);
let lines = screen.lines_in_phys_range(first..first + screen.physical_rows);
let h = area.height as usize;
// Scrolled back: take the window out of the scrollback instead. A
// stable index maps to `None` once its row has been trimmed away, and
// the live top is the floor — a view *at* it is just following.
let live_first = screen.phys_row(0);
let back = (view == PaneView::Full)
.then(|| *self.scroll_top.lock().unwrap())
.flatten()
.and_then(|top| screen.stable_row_to_phys(top))
.filter(|&p| p < live_first);
let (first, count) = match back {
Some(p) => (p, h.min(live_first - p + screen.physical_rows)),
None => (live_first, screen.physical_rows),
};
let lines = screen.lines_in_phys_range(first..first + count);
if lines.is_empty() {
return None; // zero-height pane: nothing to paint or index into
}
let rows: Vec<String> = lines.iter().map(row_text).collect();
let last = rows.iter().rposition(|t| !t.trim().is_empty()).unwrap_or(0);
let h = area.height as usize;
let (start, end) = match view {
PaneView::Full => {
// The PTY is sized to the pane in fullscreen, but a resize may
@@ -506,7 +582,11 @@ impl EmbeddedTerm {
}
let cursor = term.cursor_pos();
let cy = cursor.y as usize;
(cursor.visibility == CursorVisibility::Visible
// A scrolled-back window is not the live screen, so the cursor row the
// child reports does not index into it — show none, exactly like a
// terminal scrolled away from its prompt.
(back.is_none()
&& cursor.visibility == CursorVisibility::Visible
&& (cursor.x as u16) < area.width
&& cy >= start
&& cy <= end
@@ -1554,6 +1634,67 @@ mod tests {
assert!(longest_alias_array(br#"["Opus","sonnet"]"#).is_none());
}
/// First rendered row of `view`, trimmed.
fn top_row(et: &EmbeddedTerm, area: Rect, view: PaneView) -> String {
let mut buf = Buffer::empty(area);
et.render(area, &mut buf, view);
(0..area.width).map(|x| buf[(x, 0)].symbol()).collect::<String>().trim().to_string()
}
/// The fullscreen pane scrolls its own scrollback (Claude Code grabs no
/// mouse, so there is nothing to forward to it): a real child writes more
/// rows than the screen holds, and `scroll` has to reach the ones that left
/// it — then hand the view back to the live screen at the bottom.
#[test]
fn fullscreen_pane_scrolls_into_the_scrollback() {
let mut cmd = CommandBuilder::new("sh");
cmd.args(["-c", "seq 1 60; sleep 5"]);
let area = Rect::new(0, 0, 40, 10);
let et = EmbeddedTerm::spawn_cmd(cmd, "test-session".into(), "test-token".into(), 6, 40).unwrap();
// Wait for the tail of the child's output to reach the live screen.
let deadline = Instant::now() + Duration::from_secs(5);
loop {
let mut buf = Buffer::empty(area);
et.render(area, &mut buf, PaneView::Full);
let text: String = (0..area.height)
.flat_map(|y| (0..area.width).map(move |x| (x, y)))
.map(|(x, y)| buf[(x, y)].symbol())
.collect();
if text.contains("60") {
break;
}
assert!(Instant::now() < deadline, "child output never arrived: {text:?}");
std::thread::sleep(Duration::from_millis(50));
}
let live = top_row(&et, area, PaneView::Full);
assert_eq!(et.scrolled_rows(), 0, "starts out following the live screen");
// Up 40 rows: a window of rows that have left the screen.
et.scroll(-40);
assert_eq!(et.scrolled_rows(), 40);
let scrolled = top_row(&et, area, PaneView::Full);
assert_ne!(scrolled, live, "the view did not move");
let n: usize = scrolled.parse().expect("a `seq` line number");
assert!(n < live.parse::<usize>().unwrap(), "scrolled the wrong way: {n}");
// No cursor while scrolled away — its row does not index this window.
let mut buf = Buffer::empty(area);
assert_eq!(et.render(area, &mut buf, PaneView::Full), None);
// A cropped view never scrolls: it frames the input box at the bottom.
assert_eq!(top_row(&et, area, PaneView::Compact), live);
// Past the bottom re-engages follow mode rather than pinning to it.
et.scroll(1000);
assert_eq!(et.scrolled_rows(), 0);
assert_eq!(top_row(&et, area, PaneView::Full), live);
// …as does `follow_live`, from anywhere.
et.scroll(-10);
assert_eq!(et.scrolled_rows(), 10);
et.follow_live();
assert_eq!(et.scrolled_rows(), 0);
assert_eq!(top_row(&et, area, PaneView::Full), live);
}
/// Full pipeline: PTY spawn → reader thread → wezterm-term model →
/// ratatui buffer. Headless-safe: the *child* gets the tty, not us.
#[test]

View File

@@ -59,6 +59,10 @@ struct EmbedUi {
shrink_pending: Option<(u16, Instant)>,
}
/// Rows one wheel notch scrolls. The feed and the fullscreen pane share the
/// step, so the wheel feels the same either side of ctrl-f.
const WHEEL_ROWS: isize = 3;
/// How long a smaller compact-pane height must persist before the pane
/// actually shrinks. Long enough to ride out the repaint churn of a subagent
/// turn or a filtering `@`/`/` menu, short enough to feel responsive.
@@ -83,6 +87,24 @@ impl EmbedUi {
&& self.term.as_ref().is_some_and(|t| !t.exited())
}
/// Scroll target while the pane is fullscreen: the pane's own scrollback,
/// exactly as a plain terminal running `claude` would scroll (Claude Code
/// grabs no mouse, so nothing is forwarded to the child — see
/// `EmbeddedTerm::scroll`). Returns false when the scroll belongs to the
/// feed instead: any non-fullscreen pane, and a dead child.
fn scroll_pane(&self, rows: isize) -> bool {
if !self.fullscreen {
return false;
}
match self.term.as_ref().filter(|t| !t.exited()) {
Some(t) => {
t.scroll(rows);
true
}
None => false,
}
}
/// Smoothed compact-pane inner height. `measured` is this frame's raw
/// reading (`None` when the input box couldn't be located — a transient
/// mid-repaint, so keep what we have). Grows immediately so the prompt
@@ -875,12 +897,17 @@ fn event_loop(
let _ = execute!(std::io::stdout(), style);
}
// Scheduled transcript wipe (set by the tap when an embedded-session
// turn finishes): keeps the pane prompt-only.
// turn finishes): keeps the *compact* pane prompt-only, because the
// feed above it carries the context. Fullscreen is the opposite case —
// the pane is all there is on screen and its transcript is what the
// wheel scrolls back through — so the wipe is cancelled there rather
// than deferred: firing it later would delete that history the moment
// ctrl-f dropped out of fullscreen.
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
!eui.fullscreen
} else {
false
}
@@ -906,15 +933,35 @@ fn event_loop(
}
continue;
}
// Wheel scroll always drives the feed, regardless of which pane has
// keyboard focus (the embedded pane gets no mouse forwarding anyway).
// Wheel scroll drives the feed, regardless of which pane has keyboard
// focus — except in fullscreen, where the feed is not on screen at all
// and the wheel scrolls the pane's own scrollback instead, the way it
// would in a plain terminal (`EmbedUi::scroll_pane`).
// The agent popup is modal, so while it is up the wheel is *its*:
// it moves the picker highlight or scrolls the agent's feed.
// Left drag = text selection; the copy happens on release in draw().
if let Event::Mouse(m) = &ev {
// `CT_DEBUG_KEYS=1` covers the mouse too: it separates "the
// terminal sends no wheel event" from "the pane has no scrollback
// to move into", which look identical on screen.
if std::env::var_os("CT_DEBUG_KEYS").is_some() {
let back = eui.term.as_ref().map_or(0, |t| t.scrolled_rows());
app.lock().unwrap().status = format!(
"mouse: {:?} mods={:?} fullscreen={} back={back}",
m.kind, m.modifiers, eui.fullscreen
);
}
match m.kind {
MouseEventKind::ScrollUp => wheel(&app, -1),
MouseEventKind::ScrollDown => wheel(&app, 1),
MouseEventKind::ScrollUp => {
if !eui.scroll_pane(-WHEEL_ROWS) {
wheel(&app, -1);
}
}
MouseEventKind::ScrollDown => {
if !eui.scroll_pane(WHEEL_ROWS) {
wheel(&app, 1);
}
}
MouseEventKind::Down(MouseButton::Left) => {
sel = Some(Selection {
start: (m.column, m.row),
@@ -1002,9 +1049,24 @@ fn event_loop(
}
continue;
}
// While the claude pane has focus, everything else belongs to it.
// While the claude pane has focus, everything else belongs to it
// except the terminal-level scroll keys in fullscreen, which a real
// terminal also keeps for its own scrollback instead of sending to
// the app. Every other key snaps the view back to the live screen
// first (xterm's scroll-on-key), so typing can never leave you
// reading history while the child answers off-screen.
if eui.focused() {
if k.modifiers.contains(KeyModifiers::SHIFT)
&& matches!(k.code, KeyCode::PageUp | KeyCode::PageDown)
{
let page = eui.term.as_ref().map_or(1, |t| t.page_rows());
let dir = if k.code == KeyCode::PageUp { -1 } else { 1 };
if eui.scroll_pane(dir * page) {
continue;
}
}
if let Some(et) = &eui.term {
et.follow_live();
et.key(k);
}
continue;
@@ -1593,8 +1655,13 @@ fn draw(
let et = eui.term.as_mut().unwrap();
let exited = et.exited();
let id: String = et.session_id.chars().take(8).collect();
// A scrolled-back fullscreen pane says how far up it sits, so a screen
// that stopped moving reads as "you scrolled", not "the child stalled".
let back = et.scrolled_rows();
let title = if exited {
format!(" claude · {id} · exited ")
} else if back > 0 {
format!(" claude · {id} · ↑{back} rows · any key = live ")
} else {
format!(" claude · {id} ")
};
@@ -1629,6 +1696,12 @@ fn draw(
// no amount of framing on our side could bring them back. A
// screen-tall PTY lets Ink draw the prompt in full; the render
// window still shows only the framed region.
// Only fullscreen has a scrollback view, so leaving it (ctrl-f,
// ctrl-↑, F2, a session switch — every exit route) drops back to
// the live screen here, rather than at each of those call sites.
if pane_view != PaneView::Full {
et.follow_live();
}
let pty_rows = if pane_view == PaneView::Full {
inner.height
} else {
@@ -1652,6 +1725,10 @@ fn draw(
"enter open · j/k move · esc/A close"
} else if a.agent_popup.is_some() {
"j/k · PgUp/PgDn · g/G scroll · [/] agent · esc back · A close"
} else if embed_focused && pane_view == PaneView::Full {
// Fullscreen owns the wheel: the feed is off screen, and scrolling
// walks the child's own scrollback instead.
"wheel/shift-PgUp scroll claude · ctrl-f exit fullscreen · ctrl-q quit"
} else if embed_focused {
"ctrl-↑ feed · ctrl-f fullscreen · ctrl-q quit · F2 hide claude"
} else if visual_on {
@@ -2241,7 +2318,7 @@ fn wheel(app: &SharedApp, dir: isize) {
// the bottom.
_ => {
let target = a.agent_popup_lane();
a.scroll_col(target, dir * 3);
a.scroll_col(target, dir * WHEEL_ROWS);
}
}
}