diff --git a/CLAUDE.md b/CLAUDE.md index a87da00..0b68da3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -151,7 +151,10 @@ src/term.rs embedded claude pane: spawns `claude --session-id ` 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: `), 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: `), 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: `), 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: `), 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 ]`. The bare `[]` 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 diff --git a/src/term.rs b/src/term.rs index 0b7d603..9e21fe2 100644 --- a/src/term.rs +++ b/src/term.rs @@ -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>, /// 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 = 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::().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::().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] diff --git a/src/ui.rs b/src/ui.rs index d87722f..7ccf5d7 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -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); } } }