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:
66
CLAUDE.md
66
CLAUDE.md
@@ -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
|
||||||
|
|||||||
153
src/term.rs
153
src/term.rs
@@ -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]
|
||||||
|
|||||||
93
src/ui.rs
93
src/ui.rs
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user