From 995ea886ab904d4d8c1f8baf3f8f6b0b58c8a403 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:04:18 +0800 Subject: [PATCH] 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. --- src/features/agents/Session.module.css | 5 ++ src/features/terminal/embedded.ts | 22 ++++----- src/features/terminal/fit.ts | 63 ++++++++++++++++++++++++++ src/features/terminal/session.ts | 33 ++++++-------- src/features/terminal/terminal.test.ts | 38 +++++++++++++++- 5 files changed, 129 insertions(+), 32 deletions(-) create mode 100644 src/features/terminal/fit.ts diff --git a/src/features/agents/Session.module.css b/src/features/agents/Session.module.css index fb627a2b..8480a1f6 100644 --- a/src/features/agents/Session.module.css +++ b/src/features/agents/Session.module.css @@ -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 { diff --git a/src/features/terminal/embedded.ts b/src/features/terminal/embedded.ts index 4fa7cb0e..fba2d8fc 100644 --- a/src/features/terminal/embedded.ts +++ b/src/features/terminal/embedded.ts @@ -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); diff --git a/src/features/terminal/fit.ts b/src/features/terminal/fit.ts new file mode 100644 index 00000000..8ae9a77b --- /dev/null +++ b/src/features/terminal/fit.ts @@ -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 | 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 | undefined; + return { + resized(cols: number, rows: number) { + clearTimeout(timer); + timer = setTimeout(() => report(cols, rows), PROCESS_RESIZE_DELAY_MS); + }, + cancel: () => clearTimeout(timer), + }; +} diff --git a/src/features/terminal/session.ts b/src/features/terminal/session.ts index cfccf603..8de81d1e 100644 --- a/src/features/terminal/session.ts +++ b/src/features/terminal/session.ts @@ -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 | 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); diff --git a/src/features/terminal/terminal.test.ts b/src/features/terminal/terminal.test.ts index 8f88524c..a643c06e 100644 --- a/src/features/terminal/terminal.test.ts +++ b/src/features/terminal/terminal.test.ts @@ -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); + }); +});