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` ship a `[1m]` variant — see the 1M invariant). `EmbeddedTerm::adopt`
rebuilds a pane around an inherited pty fd + pid after a hot rebuilds a pane around an inherited pty fd + pid after a hot
reload (`AdoptedMaster` / `PidKiller` stand in for the 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 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 process* — same pid, so the listener socket, the `claude` child
and (via a JSON snapshot) the live feed all cross over. Builds 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 ExitPlanMode (sized from the question's option count) before Claude Code
renders the prompt, shrinks when the tool_result echoes back, and schedules 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; 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 - Tool input streams as raw JSON fragments; pretty-printed only on
`content_block_stop`. Streaming text re-renders markdown on every change `content_block_stop`. Streaming text re-renders markdown on every change
(FeedCache fingerprints by content length + done + result), so partial (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: characters (alt-c = ©) and never reaches the app as a modifier. Pane keys:
F2 toggle, ctrl-↓ attach pane to selected session (resume/spawn/focus), F2 toggle, ctrl-↓ attach pane to selected session (resume/spawn/focus),
ctrl-↑ focus feed, ctrl-f fullscreen toggle (only while the pane is ctrl-↑ focus feed, ctrl-f fullscreen toggle (only while the pane is
focused), ctrl-q quit (global; needed while the pane is focused, where focused), shift+PgUp/PgDn page the pane's scrollback while it is fullscreen
plain `q` is forwarded to the child), c attach most-recent past session. (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/ 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 enter/leave the turn tree, a opens the model picker popup and spawns a
brand-new `claude --session-id … [--model …]` (kills any current pane — 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 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 swaps the running instance onto it) — global like ctrl-q, because it has to
work while the pane holds focus. work while the pane holds focus.
`CT_DEBUG_KEYS=1` shows raw key events in the status bar. `CT_DEBUG_KEYS=1` shows raw key *and* mouse events in the status bar
- Mouse is captured: wheel always scrolls the feed (regardless of focus), and (`mouse: ScrollUp … fullscreen=true back=0`) — the wheel's two silent
left-drag selects screen text, copied on release via OSC 52 (like Claude failure modes look identical on screen otherwise: no event delivered at all
Code). Native terminal selection therefore needs shift held. (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`): - Bracketed paste is enabled on the outer terminal (`EnableBracketedPaste`):
a multiline paste arrives as one `Event::Paste` and, when the claude pane a multiline paste arrives as one `Event::Paste` and, when the claude pane
has focus, is handed to the child via `EmbeddedTerm::paste` 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 under the app mutex; `source.type == "url"` shows the url) and a
`tool_reference` as `[tool <name>]`. The bare `[<type>]` placeholder remains `tool_reference` as `[tool <name>]`. The bare `[<type>]` placeholder remains
the fallback for everything else. No terminal graphics protocol. the fallback for everything else. No terminal graphics protocol.
- Embedded pane: no mouse forwarding yet; no scrollback view (live screen - Embedded pane: no mouse forwarding to the child (it asks for none — see the
only); shift+enter needs kitty keyboard protocol pushed on the outer pane-scroll invariant); the scrollback view is fullscreen-only, and the
terminal (not done); permission prompts aren't detected for pane growth 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 (not visible in the API stream — would need a Notification hook hitting a
local control endpoint). local control endpoint).
- Materialized branch files satisfy our own parser (round-trip tested) but - 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_surface::CursorVisibility;
use wezterm_term::color::{ColorAttribute, ColorPalette}; use wezterm_term::color::{ColorAttribute, ColorPalette};
use wezterm_term::{ 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 /// 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). /// Actual PTY rows (visible rows + pad when cropping is active).
pty_rows: u16, pty_rows: u16,
cols: 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 /// 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 /// (`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 /// old image, but we stay the same process, so the *pid* is still ours to
@@ -210,6 +221,7 @@ impl EmbeddedTerm {
pane_token, pane_token,
pty_rows: rows + PTY_PAD, pty_rows: rows + PTY_PAD,
cols, cols,
scroll_top: Mutex::new(None),
child_pid, child_pid,
}) })
} }
@@ -285,6 +297,7 @@ impl EmbeddedTerm {
// the real one and that is what triggers the repaint. // the real one and that is what triggers the repaint.
pty_rows: rows, pty_rows: rows,
cols: h.cols, cols: h.cols,
scroll_top: Mutex::new(None),
child_pid: Some(pid), child_pid: Some(pid),
}) })
} }
@@ -350,6 +363,51 @@ impl EmbeddedTerm {
let _ = self.term.lock().unwrap().send_paste(text); 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 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 /// 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. /// 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 /// - `Interactive`: the AskUserQuestion / ExitPlanMode prompt the tap grew
/// the pane for — framed from its own top border (see /// the pane for — framed from its own top border (see
/// `interactive_view_range`) so the question text is never cropped. /// `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)> { pub fn render(&self, area: Rect, buf: &mut Buffer, view: PaneView) -> Option<(u16, u16)> {
let term = self.term.lock().unwrap(); let term = self.term.lock().unwrap();
let screen = term.screen(); let screen = term.screen();
let first = screen.phys_row(0); let h = area.height as usize;
let lines = screen.lines_in_phys_range(first..first + screen.physical_rows); // 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 rows: Vec<String> = lines.iter().map(row_text).collect();
let last = rows.iter().rposition(|t| !t.trim().is_empty()).unwrap_or(0); let last = rows.iter().rposition(|t| !t.trim().is_empty()).unwrap_or(0);
let h = area.height as usize;
let (start, end) = match view { let (start, end) = match view {
PaneView::Full => { PaneView::Full => {
// The PTY is sized to the pane in fullscreen, but a resize may // 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 cursor = term.cursor_pos();
let cy = cursor.y as usize; 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 && (cursor.x as u16) < area.width
&& cy >= start && cy >= start
&& cy <= end && cy <= end
@@ -1554,6 +1634,67 @@ mod tests {
assert!(longest_alias_array(br#"["Opus","sonnet"]"#).is_none()); 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 → /// Full pipeline: PTY spawn → reader thread → wezterm-term model →
/// ratatui buffer. Headless-safe: the *child* gets the tty, not us. /// ratatui buffer. Headless-safe: the *child* gets the tty, not us.
#[test] #[test]

View File

@@ -59,6 +59,10 @@ struct EmbedUi {
shrink_pending: Option<(u16, Instant)>, 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 /// 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 /// actually shrinks. Long enough to ride out the repaint churn of a subagent
/// turn or a filtering `@`/`/` menu, short enough to feel responsive. /// 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()) && 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 /// Smoothed compact-pane inner height. `measured` is this frame's raw
/// reading (`None` when the input box couldn't be located — a transient /// reading (`None` when the input box couldn't be located — a transient
/// mid-repaint, so keep what we have). Grows immediately so the prompt /// 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); let _ = execute!(std::io::stdout(), style);
} }
// Scheduled transcript wipe (set by the tap when an embedded-session // 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 clear_due = {
let mut a = app.lock().unwrap(); let mut a = app.lock().unwrap();
if a.embed_clear_at.is_some_and(|t| std::time::Instant::now() >= t) { if a.embed_clear_at.is_some_and(|t| std::time::Instant::now() >= t) {
a.embed_clear_at = None; a.embed_clear_at = None;
true !eui.fullscreen
} else { } else {
false false
} }
@@ -906,15 +933,35 @@ fn event_loop(
} }
continue; continue;
} }
// Wheel scroll always drives the feed, regardless of which pane has // Wheel scroll drives the feed, regardless of which pane has keyboard
// keyboard focus (the embedded pane gets no mouse forwarding anyway). // 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*: // 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. // it moves the picker highlight or scrolls the agent's feed.
// Left drag = text selection; the copy happens on release in draw(). // Left drag = text selection; the copy happens on release in draw().
if let Event::Mouse(m) = &ev { 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 { match m.kind {
MouseEventKind::ScrollUp => wheel(&app, -1), MouseEventKind::ScrollUp => {
MouseEventKind::ScrollDown => wheel(&app, 1), if !eui.scroll_pane(-WHEEL_ROWS) {
wheel(&app, -1);
}
}
MouseEventKind::ScrollDown => {
if !eui.scroll_pane(WHEEL_ROWS) {
wheel(&app, 1);
}
}
MouseEventKind::Down(MouseButton::Left) => { MouseEventKind::Down(MouseButton::Left) => {
sel = Some(Selection { sel = Some(Selection {
start: (m.column, m.row), start: (m.column, m.row),
@@ -1002,9 +1049,24 @@ fn event_loop(
} }
continue; 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 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 { if let Some(et) = &eui.term {
et.follow_live();
et.key(k); et.key(k);
} }
continue; continue;
@@ -1593,8 +1655,13 @@ fn draw(
let et = eui.term.as_mut().unwrap(); let et = eui.term.as_mut().unwrap();
let exited = et.exited(); let exited = et.exited();
let id: String = et.session_id.chars().take(8).collect(); 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 { let title = if exited {
format!(" claude · {id} · exited ") format!(" claude · {id} · exited ")
} else if back > 0 {
format!(" claude · {id} · ↑{back} rows · any key = live ")
} else { } else {
format!(" claude · {id} ") format!(" claude · {id} ")
}; };
@@ -1629,6 +1696,12 @@ fn draw(
// no amount of framing on our side could bring them back. A // no amount of framing on our side could bring them back. A
// screen-tall PTY lets Ink draw the prompt in full; the render // screen-tall PTY lets Ink draw the prompt in full; the render
// window still shows only the framed region. // 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 { let pty_rows = if pane_view == PaneView::Full {
inner.height inner.height
} else { } else {
@@ -1652,6 +1725,10 @@ fn draw(
"enter open · j/k move · esc/A close" "enter open · j/k move · esc/A close"
} else if a.agent_popup.is_some() { } else if a.agent_popup.is_some() {
"j/k · PgUp/PgDn · g/G scroll · [/] agent · esc back · A close" "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 { } else if embed_focused {
"ctrl-↑ feed · ctrl-f fullscreen · ctrl-q quit · F2 hide claude" "ctrl-↑ feed · ctrl-f fullscreen · ctrl-q quit · F2 hide claude"
} else if visual_on { } else if visual_on {
@@ -2241,7 +2318,7 @@ fn wheel(app: &SharedApp, dir: isize) {
// the bottom. // the bottom.
_ => { _ => {
let target = a.agent_popup_lane(); let target = a.agent_popup_lane();
a.scroll_col(target, dir * 3); a.scroll_col(target, dir * WHEEL_ROWS);
} }
} }
} }