Compare commits

...
Author SHA1 Message Date
bincxz 2a6762324d fix(terminal): defer fit until synchronized output settles 2026-09-07 11:31:01 +08:00
netcatty-bot 948ecaa877 fix: address Codex review on PR #3300 2026-09-07 03:02:39 +00:00
netcatty-bot 5e7e2420f8 fix: address Codex review on PR #3300 2026-09-06 23:56:13 +00:00
netcatty-bot 2c94043f2e fix: address Codex review on PR #3300 2026-09-06 23:30:13 +00:00
netcatty-bot 0e8b404a09 fix: address Codex review on PR #3300 2026-09-06 22:53:29 +00:00
netcatty-bot d56b3d4194 fix: address Codex review on PR #3300 2026-09-06 22:30:50 +00:00
netcatty-bot 81313a6ef4 fix: address Codex review on PR #3300 2026-09-06 21:54:46 +00:00
netcatty-bot 673f306260 fix: address Codex review on PR #3300 2026-09-06 21:26:35 +00:00
netcatty-bot 3d560485fc fix: address Codex review on PR #3300 2026-09-06 20:54:34 +00:00
netcatty-bot e130305d61 fix: address Codex review on PR #3300 2026-09-06 20:28:26 +00:00
netcatty-bot 0932146777 fix: address Codex review on PR #3300 2026-09-06 19:55:09 +00:00
netcatty-bot ef51bcc5af fix: address Codex review on PR #3300 2026-09-06 19:23:07 +00:00
netcatty-bot cc773bceb8 fix: address Codex review on PR #3300 2026-09-06 18:56:06 +00:00
netcatty-bot b0ac1b3180 fix: address Codex review on PR #3300 2026-09-06 18:20:49 +00:00
netcatty-bot 36746f7339 fix: address Codex review on PR #3300 2026-09-06 18:01:28 +00:00
netcatty-bot bc50bad783 fix: address Codex review on PR #3300 2026-09-06 17:31:03 +00:00
netcatty-bot 47d89c6960 fix: address Codex review on PR #3300 2026-09-06 17:04:32 +00:00
netcatty-bot c5a9fb0223 fix: address Codex review on PR #3300 2026-09-06 16:36:21 +00:00
netcatty-bot e3c61388ee fix: address Codex review on PR #3300 2026-09-06 15:52:02 +00:00
netcatty-bot 0dd38fa5e4 fix: address Codex review on PR #3300 2026-09-06 15:33:30 +00:00
netcatty-bot 456ac6af2b fix: address Codex review on PR #3300 2026-09-06 14:56:24 +00:00
netcatty-bot 21cdd7953b fix: address Codex review on PR #3300 2026-09-06 14:35:55 +00:00
netcatty-bot a2532620d0 fix: address Codex review on PR #3300 2026-09-06 13:57:02 +00:00
netcatty-bot 87057e3569 fix: address Codex review on PR #3300 2026-09-06 13:34:51 +00:00
netcatty-bot 6301fe519b fix(#3299): automated AI fix 2026-09-06 13:10:52 +00:00
4 changed files with 313 additions and 0 deletions

No files matched your search

+21
View File
@@ -252,6 +252,8 @@ import {
shouldStartTerminalBackend,
} from "./terminal/restoredSessionGate";
import {
alignTerminalViewportScroll,
createSynchronizedOutputFitScheduler,
AUTO_RUN_SNIPPET_LINE_DELAY_MS,
forceSyncRenderAfterResize,
MAX_CONNECTION_LOG_DATA_CHARS,
@@ -2274,6 +2276,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
clearHibernateRetry();
clearAutoReconnect();
void cleanupSession();
synchronizedFitSchedulerRef.current?.dispose();
disposeRuntimeOnly();
};
@@ -2867,6 +2870,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
});
}, [reuseConnectionFromSessionId, sessionId, terminalBackend]);
const synchronizedFitSchedulerRef = useRef<ReturnType<typeof createSynchronizedOutputFitScheduler> | null>(null);
if (!synchronizedFitSchedulerRef.current) {
synchronizedFitSchedulerRef.current = createSynchronizedOutputFitScheduler();
}
useEffect(() => () => synchronizedFitSchedulerRef.current?.dispose(), []);
type SafeFitOptions = { force?: boolean; requireVisible?: boolean; immediate?: boolean; allowHidden?: boolean };
const pendingWriteSafeFitRef = useRef<{
term: XTerm;
@@ -2964,6 +2973,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
return;
}
// Keep the frozen buffer intact until synchronized output finishes.
// Re-enter safeFit to use the latest size, buffer and reading position.
if (synchronizedFitSchedulerRef.current?.defer(term, () => {
if (termRef.current === term) {
safeFitRef.current({ ...options, force: true, immediate: true });
}
})) return;
const buffer = term.buffer.active;
const wasPinnedToBottom = buffer.viewportY >= buffer.baseY;
const savedViewportY = buffer.viewportY;
@@ -2988,6 +3005,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
forceSyncRenderAfterResize(term);
}
// Reflow changes the buffer row before xterm updates its scroll range.
// Align first so the relative restore below does not apply that delta twice.
alignTerminalViewportScroll(term);
// Preserve scroll position across resize (superset/Tabby pattern).
if (wasPinnedToBottom) {
term.scrollToBottom();
@@ -0,0 +1,100 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import type { Terminal as XTerm } from "@xterm/xterm";
import { alignTerminalViewportScroll } from "./terminalHelpers";
type ViewportSpy = {
scrollToLine: (line: number, disableSmoothScroll?: boolean) => void;
_sync?: () => void;
calls: Array<{ line: number; disableSmoothScroll?: boolean }>;
};
const createTerm = (
viewportY: number,
viewport?: ViewportSpy | null,
): XTerm => ({
buffer: { active: { viewportY } },
_core: viewport === null ? undefined : { _viewport: viewport },
}) as unknown as XTerm;
test("alignTerminalViewportScroll snaps the viewport back to the buffer row without smooth scrolling", () => {
const calls: Array<{ line: number; disableSmoothScroll?: boolean }> = [];
const viewport: ViewportSpy = {
scrollToLine: (line, disableSmoothScroll) => {
calls.push({ line, disableSmoothScroll });
},
calls,
};
const term = createTerm(247, viewport);
alignTerminalViewportScroll(term);
assert.deepEqual(calls, [{ line: 247, disableSmoothScroll: true }]);
});
test("alignTerminalViewportScroll syncs viewport dimensions before setting the position", () => {
const calls: Array<{ line: number; disableSmoothScroll?: boolean }> = [];
const order: string[] = [];
const viewport: ViewportSpy = {
_sync: () => {
order.push("sync");
},
scrollToLine: (line, disableSmoothScroll) => {
order.push("scrollToLine");
calls.push({ line, disableSmoothScroll });
},
calls,
};
const term = createTerm(247, viewport);
alignTerminalViewportScroll(term);
assert.deepEqual(order, ["sync", "scrollToLine"]);
assert.deepEqual(calls, [{ line: 247, disableSmoothScroll: true }]);
});
test("alignTerminalViewportScroll survives a failing dimension sync", () => {
const calls: Array<{ line: number; disableSmoothScroll?: boolean }> = [];
const viewport: ViewportSpy = {
_sync: () => {
throw new Error("boom");
},
scrollToLine: (line, disableSmoothScroll) => {
calls.push({ line, disableSmoothScroll });
},
calls,
};
assert.doesNotThrow(() => alignTerminalViewportScroll(createTerm(5, viewport)));
assert.deepEqual(calls, [{ line: 5, disableSmoothScroll: true }]);
});
test("alignTerminalViewportScroll is a no-op when the private viewport is unavailable", () => {
const term = createTerm(10, null);
assert.doesNotThrow(() => alignTerminalViewportScroll(term));
});
test("alignTerminalViewportScroll survives a viewport without scrollToLine", () => {
const term = {
buffer: { active: { viewportY: 5 } },
_core: { _viewport: {} },
} as unknown as XTerm;
assert.doesNotThrow(() => alignTerminalViewportScroll(term));
});
test("alignTerminalViewportScroll swallows viewport errors instead of breaking the fit", () => {
const term = {
buffer: { active: { viewportY: 5 } },
_core: {
_viewport: {
scrollToLine: () => {
throw new Error("boom");
},
},
},
} as unknown as XTerm;
assert.doesNotThrow(() => alignTerminalViewportScroll(term));
});
+81
View File
@@ -539,3 +539,84 @@ export function forceSyncRenderAfterResize(term: XTerm): void {
logger.warn("Sync render after resize failed", err);
}
}
type XTermWithPrivateViewport = XTerm & {
_core?: {
_viewport?: {
scrollToLine?: (line: number, disableSmoothScroll?: boolean) => void;
_sync?: () => void;
};
};
};
/**
* Re-align the DOM scroll position with the buffer's viewport row.
*
* xterm's reflow adjusts the buffer's viewport row (ydisp) during resize, but
* the scrollable viewport keeps its stale pixel offset. Any subsequent
* relative scroll (wheel, scrollToLine) then applies its delta twice — once
* against the buffer and once against the stale DOM offset — drifting the
* reading position (all the way to the top while shrinking, #3299). Snapping
* the viewport back to the buffer row before a relative restore removes the
* desync.
*/
export function alignTerminalViewportScroll(term: XTerm): void {
const viewport = (term as XTermWithPrivateViewport)._core?._viewport;
const scrollToLine = viewport?.scrollToLine;
if (typeof scrollToLine !== "function") return;
// After a resize, xterm only refreshes the viewport's scroll dimensions on
// its queued render callback. Setting a scroll position against the stale
// dimensions gets clamped to the old maximum while xterm records the
// requested row, so the queued sync then assumes the position was already
// applied and the DOM offset stays stale — the next wheel scroll jumps
// upward by the resize delta. Sync the dimensions now, before positioning.
// If synchronized output (DECSET 2026) is active, _sync() above is a no-op
// that merely defers DOM scroll updates until the mode ends; positioning
// here would still record the requested row as _latestYDisp against the
// stale dimensions, and the deferred sync would then see
// ydisp === _latestYDisp and skip repositioning, leaving a stale DOM
// offset. Leave positioning to that deferred sync instead: after reflow the
// buffer's ydisp differs from the recorded _latestYDisp, so it repositions
// with fresh dimensions on its own.
if (typeof viewport._sync === "function") {
try {
viewport._sync.call(viewport);
} catch (err) {
logger.warn("Sync viewport dimensions after resize failed", err);
}
}
if (term.modes?.synchronizedOutputMode) return;
try {
scrollToLine.call(viewport, term.buffer.active.viewportY, true);
} catch (err) {
logger.warn("Align viewport scroll after resize failed", err);
}
}
/** Defer the whole fit while xterm keeps the visible frame frozen (DECSET 2026). */
export function createSynchronizedOutputFitScheduler() {
let timer: ReturnType<typeof setTimeout> | undefined;
const dispose = () => {
if (timer !== undefined) clearTimeout(timer);
timer = undefined;
};
return {
dispose,
defer(term: XTerm, fit: () => void): boolean {
dispose();
if (!term.modes.synchronizedOutputMode) return false;
// Arms xterm's own synchronized-output timeout even if the program
// only enabled the mode without writing visible content afterward.
term.refresh(0, Math.max(0, term.rows - 1));
timer = setTimeout(() => {
timer = undefined;
// The caller re-enters safeFit and checks the mode again, including
// when another synchronized frame started before this retry.
fit();
}, 32);
return true;
},
};
}
+111
View File
@@ -0,0 +1,111 @@
"use strict";
/* global process, __dirname, console */
if (!process.versions.electron) {
require("node:test")("fit preserves terminal reading position", {
skip: "run with Electron for real xterm viewport coverage",
}, () => {});
} else {
const { app, BrowserWindow } = require("electron");
const fs = require("node:fs");
const path = require("node:path");
const esbuild = require("esbuild");
const temp = require("../electron/bridges/tempDirBridge.cjs");
const root = path.resolve(__dirname, "..");
const userData = fs.mkdtempSync(`${temp.getTempFilePath("fit-scroll")}-`);
app.setPath("userData", userData);
app.on("window-all-closed", () => {});
const source = fs.readFileSync(path.join(root, "components/Terminal.tsx"), "utf8");
const start = source.indexOf(" const safeFit = (options?: SafeFitOptions) => {");
const end = source.indexOf(" const prevIsResizingRef", start);
if (start < 0 || end < 0) throw new Error("Cannot extract production safeFit");
const safeFitSource = esbuild.transformSync(source.slice(start, end), { loader: "ts" }).code;
const bundle = esbuild.buildSync({
stdin: {
contents: 'export {Terminal} from "@xterm/xterm"; export {alignTerminalViewportScroll, forceSyncRenderAfterResize, createSynchronizedOutputFitScheduler} from "./components/terminal/terminalHelpers";',
loader: "ts", resolveDir: root,
},
bundle: true, format: "cjs", platform: "browser", write: false,
define: { "import.meta.env.DEV": "false", "import.meta.env.PROD": "true", "import.meta": "{}" },
}).outputFiles[0].text;
let win;
const cleanup = code => {
win?.destroy();
fs.rmSync(userData, { recursive: true, force: true });
app.exit(code);
};
void app.whenReady().then(async () => {
win = new BrowserWindow({
show: true, width: 1000, height: 700,
webPreferences: { nodeIntegration: true, contextIsolation: false, sandbox: false, backgroundThrottling: false },
});
await win.loadURL("data:text/html,<body style='background:%23111;color:white'><h3>Issue 3299: resize reading position</h3><div id='terminal'></div>");
await win.webContents.insertCSS(fs.readFileSync(require.resolve("@xterm/xterm/css/xterm.css"), "utf8"));
const result = await win.webContents.executeJavaScript(`(async () => {
const assert = require('node:assert/strict');
const loaded = {exports:{}};
((module,exports)=>{${bundle}})(loaded,loaded.exports);
const {Terminal,forceSyncRenderAfterResize,createSynchronizedOutputFitScheduler} = loaded.exports;
const alignTerminalViewportScroll = process.env.NETCATTY_FIT_BASELINE ? ()=>{} : loaded.exports.alignTerminalViewportScroll;
const wait = ms => new Promise(resolve=>setTimeout(resolve,ms));
const term = new Terminal({cols:80,rows:25,scrollback:1000,fontSize:14,allowProposedApi:true,smoothScrollDuration:0});
const el = document.getElementById('terminal');
el.style.cssText='width:900px;height:560px'; term.open(el);
const write = data => new Promise(resolve=>term.write(data,resolve));
const ref = current => ({current});
const termRef=ref(term), containerRef=ref(el), isRendererActiveRef=ref(true), lastFittedSizeRef=ref(null);
const autocompleteRepositionRef=ref(null), xtermRuntimeRef=ref(null), pendingWriteSafeFitRef=ref(null);
const synchronizedFitSchedulerRef=ref(createSynchronizedOutputFitScheduler());
let dimensions={cols:80,rows:25};
const fitAddonRef=ref({proposeDimensions:()=>dimensions});
const hasPendingTerminalWrites=()=>false;
const XTERM_PERFORMANCE_CONFIG={resize:{useRAF:false}};
const logger={warn:(message,error)=>{throw error || new Error(message)}};
${safeFitSource}
const safeFitRef=ref(safeFit);
const fit=(cols,rows)=>{dimensions={cols,rows};safeFit({force:true,immediate:true});};
const domRow=()=>term._core._viewport._scrollableElement.getScrollPosition().scrollTop / term._core._renderService.dimensions.css.cell.height;
const check=(expected,label)=>{
assert.equal(term.buffer.active.viewportY,expected,label+' buffer');
assert.ok(Math.abs(domRow()-expected)<0.01,label+' DOM expected '+expected+' actual '+domRow());
};
await write(Array.from({length:200},(_,i)=>'line '+i+' '+('A B repeated '.repeat(7))).join('\\r\\n'));
await wait(100); term.scrollToLine(70); await wait(50);
fit(80,16); await wait(80); check(70,'shrink');
fit(80,30); await wait(80); check(70,'grow');
for(let rows=29;rows>=12;rows--){fit(80,rows);await wait(20);check(70,'drag '+rows);}
term.scrollToBottom();await wait(50);fit(80,8);await wait(80);check(term.buffer.active.baseY,'pinned shrink');
term.scrollLines(-1);await wait(50);check(term.buffer.active.baseY-1,'first scroll after shrink');
term.scrollToLine(70);await wait(50);
const oldSize=[term.cols,term.rows];
await write('\\x1b[?2026h');fit(100,20);fit(120,24);await wait(80);
assert.deepEqual([term.cols,term.rows],oldSize,'no reflow inside synchronized frame');
term.scrollLines(-3);await wait(40);const selected=term.buffer.active.viewportY;
await write('\\x1b[?2026l');await wait(120);
assert.deepEqual([term.cols,term.rows],[120,24]);check(Math.min(selected,term.buffer.active.baseY),'user selected position');
await write('\\x1b[?2026h');fit(90,18);await write('\\x1b[?2026l\\x1b[?2026h');await wait(70);
assert.equal(term.cols,120,'a new synchronized frame delays retry');
await write('\\x1b[?2026l');await wait(100);check(Math.min(selected,term.buffer.active.baseY),'mode reentry');
await write('\\r\\n'+Array.from({length:1200},()=> 'A B repeated').join('\\r\\n'));
await wait(80);term.scrollToLine(100);await wait(30);
await write('\\x1b[?2026h');fit(130,22);await write('\\r\\n'.repeat(5));
const afterTrim=term.buffer.active.viewportY;
await write('\\x1b[?2026l');await wait(100);check(afterTrim,'scrollback trim');
await write('\\x1b[?2026h\\x1b[?1049h');fit(100,20);
await write('alternate output\\x1b[?1049l\\x1b[?2026l');await wait(100);
check(Math.min(afterTrim,term.buffer.active.baseY),'alternate buffer round trip');
await write('\\x1b[?2026h');fit(110,21);await wait(1400);
assert.equal(term.modes.synchronizedOutputMode,false,'xterm timeout releases output');
assert.equal(term.cols,110,'fit retried after output timeout');
const beforeDispose=term.cols;
await write('\\x1b[?2026h');fit(140,25);synchronizedFitSchedulerRef.current.dispose();
await write('\\x1b[?2026l');await wait(100);assert.equal(term.cols,beforeDispose,'teardown cancels retry');
assert.ok(el.querySelector('.xterm-screen').getBoundingClientRect().height > 0,'real rendered terminal');
return {passed:['shrink','grow','18 drag steps','bottom and next scroll','synchronized repeated spaced text','user scroll','mode reentry','full scrollback trim','alternate buffer','output timeout','cleanup'],viewportY:term.buffer.active.viewportY,domRow:domRow()};
})()`);
console.log(JSON.stringify(result));
if (process.env.NETCATTY_FIT_SCREENSHOT) {
fs.writeFileSync(process.env.NETCATTY_FIT_SCREENSHOT, (await win.webContents.capturePage()).toPNG());
}
cleanup(0);
}).catch(error=>{console.error(error);cleanup(1);});
}