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); + }); +});