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