Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/renderer/src/components/PtyTerminalView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 */ }
}
};
Expand Down
26 changes: 26 additions & 0 deletions src/renderer/src/components/terminalAutomation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
83 changes: 77 additions & 6 deletions src/renderer/src/components/terminalPool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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);
}));
Expand Down
7 changes: 7 additions & 0 deletions src/renderer/src/design/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions test/terminal-automation.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ const {
isStaleTerminalDraft,
opensInteractiveTerminalUi,
shouldFollowTerminalOutput,
scrollbackRestoreLine,
SCROLLBACK_CLEAR_RESTORE_MS,
terminalAutomationBlock,
isStaleTerminalPicker,
STALE_INPUT_MS,
Expand Down Expand Up @@ -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);
});
Loading