fix(workbench): Keep tool window resizing smooth over a terminal or long conversation (#137)

Dragging the bottom tool window edge over a terminal refit xterm on every
pointer move and sent each intermediate size to the shell, which redrew its
prompt every time. Terminals now refit at most every 100 ms while their host
keeps resizing, plus once when it stops, and the process only learns the size
once it has held for 150 ms. This covers the Terminal tool window and terminals
embedded in Run consoles.

Resizing the width of an agent conversation reflowed every entry, including the
off-screen ones (14-16 ms per move in a long session). Entries now use
content-visibility: auto, so only the visible ones are laid out.
This commit is contained in:
l0ng-ai
2026-10-28 19:04:18 +08:00
committed by GitHub
parent 630d436e18
commit 995ea886ab
5 changed files with 129 additions and 32 deletions
+5
View File
@@ -117,6 +117,11 @@
.stream:focus {
outline: none;
}
/* Off-screen entries skip layout, so resizing the pane doesn't reflow a long conversation. */
.stream > * {
content-visibility: auto;
contain-intrinsic-block-size: auto 80px;
}
/* Your message with its actions, shown on hover. */
.message {
+9 -13
View File
@@ -10,6 +10,7 @@ import "@xterm/xterm/css/xterm.css";
import type { EmbeddedTerminal, EmbeddedTerminalOptions } from "@/workbench/contributions";
import { useProject } from "@/platform/project";
import { workflowSettings } from "@/platform/workflowSettings";
import { fitScheduler, settledResize } from "./fit";
import { findTerminalLinks } from "./links";
import { LINK_HANDLER, fontFor, openExternal, openFileLink } from "./session";
import { readTerminalTheme, remeasureWhenFontsLoad } from "./theme";
@@ -49,10 +50,11 @@ export function createEmbeddedTerminal(host: HTMLElement, opts: EmbeddedTerminal
const fit = new FitAddon();
term.loadAddon(fit);
live.add(term);
const processResize = settledResize((cols, rows) => opts.onResize(cols, rows));
const disposables: IDisposable[] = [
term.onData((d) => opts.onData(d)),
term.onBinary((d) => opts.onData(d)),
term.onResize(({ cols, rows }) => opts.onResize(cols, rows)),
term.onResize(({ cols, rows }) => processResize.resized(cols, rows)),
term.registerLinkProvider({
provideLinks(y, callback) {
const line = term.buffer.active.getLine(y - 1);
@@ -76,17 +78,13 @@ export function createEmbeddedTerminal(host: HTMLElement, opts: EmbeddedTerminal
term.open(host);
if (opts["aria-label"]) term.textarea?.setAttribute("aria-label", opts["aria-label"]);
let raf = 0;
const doFit = () => {
raf = 0;
const fitter = fitScheduler(() => {
if (!host.isConnected || host.offsetWidth === 0 || host.offsetHeight === 0) return;
const dims = fit.proposeDimensions();
if (!dims || !Number.isFinite(dims.cols) || !Number.isFinite(dims.rows)) return;
if (dims.cols !== term.cols || dims.rows !== term.rows) term.resize(dims.cols, dims.rows);
};
const scheduleFit = () => {
if (!raf) raf = requestAnimationFrame(doFit);
};
});
const scheduleFit = () => fitter.schedule();
const observer = new ResizeObserver(scheduleFit);
observer.observe(host);
scheduleFit();
@@ -98,12 +96,10 @@ export function createEmbeddedTerminal(host: HTMLElement, opts: EmbeddedTerminal
write: (data) => term.write(data),
clear: () => term.reset(),
focus: () => term.focus(),
fit: () => {
cancelAnimationFrame(raf);
doFit();
},
fit: () => fitter.flush(),
dispose: () => {
cancelAnimationFrame(raf);
fitter.cancel();
processResize.cancel();
observer.disconnect();
for (const d of disposables) d.dispose();
live.delete(term);
+63
View File
@@ -0,0 +1,63 @@
/** Minimum time between two fits while the host keeps resizing (a tool window drag). */
export const FIT_INTERVAL_MS = 100;
/** How long the size must stay put before the process behind the terminal is told. */
export const PROCESS_RESIZE_DELAY_MS = 150;
export interface FitScheduler {
schedule(): void;
/** Fits now, dropping any scheduled fit. */
flush(): void;
cancel(): void;
}
/**
* Coalesces fit requests. An idle terminal fits on the next frame; while its host keeps
* resizing it refits at most every FIT_INTERVAL_MS, and once more when the resizing
* stops. Re-rendering xterm on every pointer move of a drag costs a frame each time.
*/
export function fitScheduler(fit: () => void): FitScheduler {
let raf = 0;
let timer: ReturnType<typeof setTimeout> | undefined;
let pending = false;
let last = -Infinity;
const cancel = () => {
cancelAnimationFrame(raf);
clearTimeout(timer);
raf = 0;
timer = undefined;
pending = false;
};
const run = () => {
cancel();
last = performance.now();
fit();
};
return {
schedule() {
if (pending) return;
pending = true;
const wait = last + FIT_INTERVAL_MS - performance.now();
if (wait > 0) {
timer = setTimeout(run, wait);
return;
}
raf = requestAnimationFrame(run);
// WebKit pauses rAF in background windows; don't leave the terminal unfitted until it is shown.
timer = setTimeout(run, 100);
},
flush: run,
cancel,
};
}
/** Calls `report` once the size has stayed the same for PROCESS_RESIZE_DELAY_MS. */
export function settledResize(report: (cols: number, rows: number) => void) {
let timer: ReturnType<typeof setTimeout> | undefined;
return {
resized(cols: number, rows: number) {
clearTimeout(timer);
timer = setTimeout(() => report(cols, rows), PROCESS_RESIZE_DELAY_MS);
},
cancel: () => clearTimeout(timer),
};
}
+15 -18
View File
@@ -10,6 +10,7 @@ import "@xterm/xterm/css/xterm.css";
import { Channel } from "@tauri-apps/api/core";
import { call, commands, isTauri, type TerminalId } from "@/platform/ipc";
import { editorService, notifications } from "@/workbench/contributions";
import { fitScheduler, settledResize } from "./fit";
import { findTerminalLinks, resolvePath, type TerminalLink } from "./links";
import { splitCommandLine } from "@/platform/commandLine";
import { useProject } from "@/platform/project";
@@ -158,8 +159,8 @@ export class TerminalSession {
private disposed = false;
private disposables: IDisposable[] = [];
private resizeObserver: ResizeObserver | null = null;
private fitRaf = 0;
private fitTimer: ReturnType<typeof setTimeout> | undefined;
private readonly fitter = fitScheduler(() => this.doFit());
private readonly ptyResize = settledResize((cols, rows) => this.resizePty(cols, rows));
private inputRaf = 0;
private pendingInput = "";
private lastSize = { cols: 0, rows: 0 };
@@ -326,17 +327,7 @@ export class TerminalSession {
}
private scheduleFit() {
if (this.fitRaf) return;
const run = () => {
if (!this.fitRaf) return;
cancelAnimationFrame(this.fitRaf);
clearTimeout(this.fitTimer);
this.fitRaf = 0;
this.doFit();
};
this.fitRaf = requestAnimationFrame(run);
// WebKit pauses rAF in background windows; don't leave the shell unstarted until the window is shown.
this.fitTimer = setTimeout(run, 100);
this.fitter.schedule();
}
private doFit() {
@@ -347,12 +338,18 @@ export class TerminalSession {
if (dims.cols !== this.term.cols || dims.rows !== this.term.rows) this.term.resize(dims.cols, dims.rows);
if (this.snapshot.status === "idle") {
void this.start();
} else if (this.ptyId !== null && (this.lastSize.cols !== dims.cols || this.lastSize.rows !== dims.rows)) {
this.lastSize = { cols: dims.cols, rows: dims.rows };
void commands.termResize(this.ptyId, dims.cols, dims.rows);
} else if (this.ptyId !== null) {
this.ptyResize.resized(dims.cols, dims.rows);
}
}
private resizePty(cols: number, rows: number) {
if (this.disposed || this.ptyId === null) return;
if (this.lastSize.cols === cols && this.lastSize.rows === rows) return;
this.lastSize = { cols, rows };
void commands.termResize(this.ptyId, cols, rows);
}
/** Spawns the PTY with the current size. */
async start() {
if (this.disposed || this.snapshot.status === "starting" || this.snapshot.status === "running") return;
@@ -519,8 +516,8 @@ export class TerminalSession {
dispose() {
if (this.disposed) return;
this.disposed = true;
cancelAnimationFrame(this.fitRaf);
clearTimeout(this.fitTimer);
this.fitter.cancel();
this.ptyResize.cancel();
cancelAnimationFrame(this.inputRaf);
this.resizeObserver?.disconnect();
this.host.removeEventListener("mousedown", this.onMiddleClick);
+37 -1
View File
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { FIT_INTERVAL_MS, PROCESS_RESIZE_DELAY_MS, fitScheduler, settledResize } from "./fit";
import { isAllowedIdeShortcut, terminalKeyAction } from "./keys";
import { matchCounter } from "./FindBar";
import { buildSearchDecorations } from "./theme";
@@ -279,3 +280,38 @@ describe("terminal tab titles", () => {
expect(trimMiddle("abc", 5)).toBe("abc");
});
});
describe("fit scheduling", () => {
afterEach(() => vi.useRealTimers());
it("refits at most once per interval while resizing, and once after it stops", () => {
vi.useFakeTimers();
const fit = vi.fn();
const f = fitScheduler(fit);
f.schedule();
vi.advanceTimersToNextFrame();
expect(fit).toHaveBeenCalledTimes(1);
for (let i = 0; i < 6; i++) {
f.schedule();
vi.advanceTimersByTime(16);
}
expect(fit).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(FIT_INTERVAL_MS);
expect(fit).toHaveBeenCalledTimes(2);
f.schedule();
f.cancel();
vi.advanceTimersByTime(FIT_INTERVAL_MS * 2);
expect(fit).toHaveBeenCalledTimes(2);
});
it("reports only the size the terminal settles on", () => {
vi.useFakeTimers();
const report = vi.fn();
const r = settledResize(report);
r.resized(80, 20);
vi.advanceTimersByTime(PROCESS_RESIZE_DELAY_MS / 2);
r.resized(80, 24);
vi.advanceTimersByTime(PROCESS_RESIZE_DELAY_MS);
expect(report).toHaveBeenCalledExactlyOnceWith(80, 24);
});
});