diff --git a/src/renderer/src/components/PtyTerminalView.tsx b/src/renderer/src/components/PtyTerminalView.tsx index b9da3732fa..9b71ccbdec 100644 --- a/src/renderer/src/components/PtyTerminalView.tsx +++ b/src/renderer/src/components/PtyTerminalView.tsx @@ -152,6 +152,7 @@ export function PtyTerminalView({ ptyId, onStreamData, onUserPrompt, onToggleFul // Snap to bottom immediately on re-attach before fit settles try { entry.term.scrollToBottom(); } catch { /* not yet open */ } + entry.followOutput = true; // `scrollToEnd` is true only for the initial attach (switching agents / // toggling fullscreen) so we land on the most recent output. Re-parenting @@ -201,6 +202,7 @@ export function PtyTerminalView({ ptyId, onStreamData, onUserPrompt, onToggleFul // re-syncs the DOM scrollTop itself, atomically with its flag. entry.term.scrollLines(-1); entry.term.scrollToBottom(); + entry.followOutput = true; } catch { /* noop */ } } }; diff --git a/src/renderer/src/components/terminalAutomation.ts b/src/renderer/src/components/terminalAutomation.ts index a43a71db53..31e6149a17 100644 --- a/src/renderer/src/components/terminalAutomation.ts +++ b/src/renderer/src/components/terminalAutomation.ts @@ -35,6 +35,32 @@ export function shouldFollowTerminalOutput(viewportY: number, baseY: number): bo return baseY - viewportY <= 1; } +/** How long after a program clears the scrollback (CSI 3 J) we keep re-pinning + * a scrolled-up reader at the same distance from the bottom. A full-screen + * redraw (clear screen + clear scrollback + home, then the whole transcript + * again) arrives as several pty chunks; once it has landed, new output must + * stop dragging the reader along. */ +export const SCROLLBACK_CLEAR_RESTORE_MS = 250; + +/** Where a scrolled-up reader was when the program cleared the scrollback. */ +export interface ScrollbackClearAnchor { + viewportY: number; + baseY: number; +} + +/** The viewport line that puts a reader back where they were after the program + * cleared the scrollback (CSI 3 J). xterm clamps a scrolled-up viewport to line + * 0 and leaves it there while the redraw refills the buffer, which is the + * "jumps to the top" bug. While the redraw is still refilling, keep the reader + * the same distance above the bottom; once the buffer is back to its old length + * the old line holds the same text again, so return to it exactly (`done`). A + * redraw that reprints less than the reader had scrolled past lands on line 0, + * the nearest text still there. */ +export function scrollbackRestoreLine(baseY: number, anchor: ScrollbackClearAnchor): { line: number; done: boolean } { + if (baseY >= anchor.baseY) return { line: Math.max(0, anchor.viewportY), done: true }; + return { line: Math.max(0, baseY - Math.max(0, anchor.baseY - anchor.viewportY)), done: false }; +} + /** How long an untouched draft on the prompt keeps blocking queue delivery. * * The block exists so automation can't fuse its text onto a half-written line. diff --git a/src/renderer/src/components/terminalPool.ts b/src/renderer/src/components/terminalPool.ts index 10f26c65f9..e7cd4640f5 100644 --- a/src/renderer/src/components/terminalPool.ts +++ b/src/renderer/src/components/terminalPool.ts @@ -34,8 +34,11 @@ import { import { canAutomateTerminal, opensInteractiveTerminalUi, + SCROLLBACK_CLEAR_RESTORE_MS, + scrollbackRestoreLine, shouldFollowTerminalOutput, terminalAutomationBlock, + type ScrollbackClearAnchor, type TerminalAutomationBlock } from './terminalAutomation'; import { sanitizeTerminalSelection } from './terminalSelection'; @@ -83,6 +86,15 @@ export interface TerminalEntry { * the OLD process carry the generation they were registered under, so they can * be recognised and dropped instead of corrupting the replacement. */ generation: number; + /** Stick-to-bottom: true while the user is reading the live tail. Changed + * only by the user's own scrolling (wheel, keys, scrollbar) and by the views' + * explicit snap-to-bottom, never by output, so output queued before the user + * scrolled up cannot yank them back down when it lands. */ + followOutput: boolean; + /** Set when the program cleared the scrollback while the user was scrolled + * up: where they were, and until when to put them back there while the + * redraw lands (see SCROLLBACK_CLEAR_RESTORE_MS). */ + scrollRestore: { anchor: ScrollbackClearAnchor; until: number } | null; webgl?: WebglAddon; /** Live Arabic/RTL rendering handles, present only while it is ON. Held so * the mode can be switched off again without disposing the terminal. */ @@ -179,20 +191,79 @@ export function acquireTerminal(ptyId: string, theme?: ThemeMap, fontSize = 14): inputDirtyAt: 0, automationSettleUntil: 0, lineBuf: '', - generation: 0 + generation: 0, + followOutput: true, + scrollRestore: null }; + // Stick-to-bottom follows the USER's scrolling. The follow decision used to be + // sampled when a chunk was queued and applied after xterm parsed it, so every + // chunk already in flight when the user scrolled up snapped them straight back + // to the bottom: a streaming reply could not be read. Now only the user's own + // scrolling changes it, and returning to the bottom resumes following. + const syncFollow = (): void => { + const active = term.buffer.active; + entry.followOutput = shouldFollowTerminalOutput(active.viewportY, active.baseY); + entry.scrollRestore = null; + }; + // The wheel goes through xterm's buffer, not the DOM scrollTop. xterm's own + // wheel path only moves scrollTop and reads it back on the next scroll event; + // output that repaints in between writes scrollTop back to the old bottom and + // swallows that event, so a quick flick upward during a stream was simply lost. + // A program that tracks the mouse, or the alternate screen (no scrollback), + // still gets xterm's default handling. + let wheelLines = 0; + term.attachCustomWheelEventHandler((ev) => { + if (term.buffer.active.type !== 'normal' || term.modes.mouseTrackingMode !== 'none' || ev.deltaY === 0) return true; + const screenHeight = term.element?.querySelector('.xterm-screen')?.clientHeight ?? 0; + const rowPx = screenHeight > 0 ? screenHeight / term.rows : 16; + const perLine = ev.deltaMode === WheelEvent.DOM_DELTA_LINE ? 1 : ev.deltaMode === WheelEvent.DOM_DELTA_PAGE ? term.rows : 1 / rowPx; + wheelLines += ev.deltaY * perLine * (term.options.scrollSensitivity ?? 1); + const lines = Math.trunc(wheelLines); + wheelLines -= lines; + if (lines !== 0) term.scrollLines(lines); + syncFollow(); + ev.preventDefault(); + return false; + }); + // Keys (PageUp, typing, which scrolls to the bottom) and scrollbar drags. + const onUserScroll = (): void => { requestAnimationFrame(syncFollow); }; + host.addEventListener('keydown', onUserScroll, true); + host.addEventListener('pointerup', onUserScroll, true); + + // CSI 3 J (clear scrollback) while the user is scrolled up: xterm clamps the + // viewport to line 0 and, because the user is still "scrolling", keeps it there + // while the redraw refills the buffer, so the view snaps to the top of the + // transcript. Remember where the reader was; the write callback below puts + // them back. Return false so xterm still clears. + term.parser.registerCsiHandler({ final: 'J' }, (params) => { + const active = term.buffer.active; + if (params[0] === 3 && !entry.followOutput && active.type === 'normal') { + entry.scrollRestore = { + anchor: entry.scrollRestore?.anchor ?? { viewportY: active.viewportY, baseY: active.baseY }, + until: Date.now() + SCROLLBACK_CLEAR_RESTORE_MS + }; + } + return false; + }); + // Subscribe to the pty stream ONCE for the terminal's whole lifetime, so the // buffer keeps filling even while this terminal isn't mounted in any view. entry.unsub.push(window.cth.onPtyData(ptyId, (rawChunk) => { const chunk = normalizePtyChunk(rawChunk); if (!chunk) return; - const active = term.buffer.active; - const follow = shouldFollowTerminalOutput(active.viewportY, active.baseY); term.write(chunk, () => { - if (follow) { - try { term.scrollToBottom(); } catch { /* terminal may be detaching */ } - } + try { + const restore = entry.scrollRestore; + if (restore && Date.now() <= restore.until) { + const { line, done } = scrollbackRestoreLine(term.buffer.active.baseY, restore.anchor); + term.scrollToLine(line); + if (done) entry.scrollRestore = null; + } else { + entry.scrollRestore = null; + if (entry.followOutput) term.scrollToBottom(); + } + } catch { /* terminal may be detaching */ } }); entry.onData?.(chunk); })); diff --git a/src/renderer/src/design/global.css b/src/renderer/src/design/global.css index 91d2785d15..9cd96e8e64 100644 --- a/src/renderer/src/design/global.css +++ b/src/renderer/src/design/global.css @@ -199,6 +199,13 @@ canvas, img, svg { image-rendering: pixelated; image-rendering: crisp-edges; } font-feature-settings: "liga" 0, "clig" 0, "calt" 0 !important; } +/* xterm owns the viewport's scrollTop (terminalPool.ts keeps the reader's place + * while output streams). Browser scroll anchoring would adjust it on its own as + * the scroll area grows, so it is switched off for the terminal viewport. */ +.xterm-viewport { + overflow-anchor: none; +} + /* Arabic/RTL in terminals (see terminal/arabicJoiner.ts for the whole recipe). `plaintext` gives each ROW its own base direction from its first strong character — an Arabic row lays out right-to-left and right-aligned, a Latin diff --git a/test/terminal-automation.test.cjs b/test/terminal-automation.test.cjs index 69434dcb54..33f222cca0 100644 --- a/test/terminal-automation.test.cjs +++ b/test/terminal-automation.test.cjs @@ -9,6 +9,8 @@ const { isStaleTerminalDraft, opensInteractiveTerminalUi, shouldFollowTerminalOutput, + scrollbackRestoreLine, + SCROLLBACK_CLEAR_RESTORE_MS, terminalAutomationBlock, isStaleTerminalPicker, STALE_INPUT_MS, @@ -120,3 +122,15 @@ test('terminal output follows only when already at the bottom', () => { assert.equal(shouldFollowTerminalOutput(99, 100), true); assert.equal(shouldFollowTerminalOutput(80, 100), false); }); + +test('a reader scrolled up when the scrollback is cleared is put back where they were', () => { + const anchor = { viewportY: 950, baseY: 1000 }; + // Redraw still refilling: same distance above the bottom. + assert.deepEqual(scrollbackRestoreLine(400, anchor), { line: 350, done: false }); + // Less reprinted than the reader had scrolled past: nearest text left is line 0. + assert.deepEqual(scrollbackRestoreLine(30, anchor), { line: 0, done: false }); + // Buffer back to its old length (a full reprint): the exact old line, then stop. + assert.deepEqual(scrollbackRestoreLine(1000, anchor), { line: 950, done: true }); + assert.deepEqual(scrollbackRestoreLine(1006, anchor), { line: 950, done: true }); + assert.ok(SCROLLBACK_CLEAR_RESTORE_MS > 0); +});