From 99780c4fbed9295c092061da01bac64d723df8ea Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:59:41 -0700 Subject: [PATCH] Batch passive terminal output to animation-frame cadence Replace the byte-size-based latency classifier with evidence-based classification (recent input or a reply-eliciting terminal query), and gate passive foreground drains behind a shared requestAnimationFrame (with a 32ms fallback) instead of a zero-delay drain. This coalesces bursts of tiny passive chunks into one xterm write per frame, cutting renderer wakeups for streaming agent output while preserving immediate echo/redraw and protocol-reply latency. Adds the design doc and test coverage for frame batching, ordered flush-behind-prefix, and fallback draining. --- ...-13-terminal-output-energy-optimization.md | 397 ++++++++++++++++++ .../terminal-pane/pty-connection.test.ts | 128 +++++- .../terminal-pane/pty-connection.ts | 70 ++- .../pane-terminal-output-frame-gate.ts | 65 +++ .../pane-terminal-output-scheduler.test.ts | 172 +++++++- .../pane-terminal-output-scheduler.ts | 78 +++- 6 files changed, 850 insertions(+), 60 deletions(-) create mode 100644 docs/reference/plans/2026-07-13-terminal-output-energy-optimization.md create mode 100644 src/renderer/src/lib/pane-manager/pane-terminal-output-frame-gate.ts diff --git a/docs/reference/plans/2026-07-13-terminal-output-energy-optimization.md b/docs/reference/plans/2026-07-13-terminal-output-energy-optimization.md new file mode 100644 index 00000000000..0f9f5b8f4fc --- /dev/null +++ b/docs/reference/plans/2026-07-13-terminal-output-energy-optimization.md @@ -0,0 +1,397 @@ +# Terminal Output Energy Optimization + +Date: 2026-07-13 + +Status: approved for implementation by the analysis below + +## Problem and evidence + +Visible terminal output converges in +`src/renderer/src/components/terminal-pane/pty-connection.ts`. Native, WSL, and SSH terminals +arrive through `createIpcPtyTransport`; remote-runtime terminals arrive through +`createRemoteRuntimePtyTransport`. Both transports invoke the same `dataCallback`, which preserves +renderer sequence handling, hidden-output recovery, protocol-query extraction, and then calls +`writePtyOutputToXterm`. + +`writePtyOutputToXterm` currently classifies foreground output with +`isLatencySensitiveForegroundOutput` before calling +`src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts`: + +- Every active-pane chunk at or below 2 KiB is classified as latency-sensitive, independent of + whether the user recently typed or the bytes contain a terminal query. +- Larger ANSI redraws are also latency-sensitive for 150 ms after accepted terminal input. +- A rolling budget permits up to 128 KiB of immediate foreground writes in 500 ms. Inactive split + panes have a separate 32 KiB immediate budget. +- Once the budget is exhausted, `latencySensitive: false` queues output, but the scheduler arms a + zero-delay high-priority drain. A continuing series of separately delivered tiny chunks can + therefore cause one renderer task and one xterm write per chunk instead of one write per visual + frame. + +The existing scheduler already supplies the safety mechanisms that a new cadence must retain: + +- Per-terminal queues preserve byte order and coalesce adjacent chunks without changing their + bytes. +- Parse-deferred PTY ACK credits are attached to chunks and fire exactly once after the final xterm + slice parses, or on every discard/drop path. Main-process producer flow control therefore follows + actual renderer parse progress. +- Foreground queues are capped, sliced into 16 KiB writes, drained cooperatively with an 8 ms task + budget, and parse-clocked during large visible backlogs. +- Background/hidden terminals use a shared delayed drain and bounded backlog. Snapshot-backed + hidden panes may park delivery in main and restore from authoritative terminal state. +- Native Windows synchronized output has separate hold/coalesce state, cursor-restore detection, + and latency-sensitive fallbacks for frames opened near accepted input. +- Visibility resume, shutdown capture, scroll handling, and terminal disposal call the scheduler's + flush/discard APIs rather than writing around its queue. + +The main-process runtime RPC also batches output for remote subscribers, but that does not solve +this renderer issue: the PTY delivery boundaries seen by the active desktop renderer can still be +small and repeated, and the current <=2 KiB rule turns each one into immediate xterm activity. + +## Goals + +- Preserve immediate echo/redraw behavior when there is evidence of accepted user input. +- Preserve immediate parsing of reply-eliciting terminal protocol queries so DA, DSR/CPR, DECRQM, + OSC color, and related replies meet programs' raw-mode read windows. +- Coalesce passive visible output to at most one initial scheduler drain per animation frame under + normal visible-window operation. +- Keep continuous foreground output visually smooth at the display's animation-frame cadence; do + not impose a fixed 30 fps throttle or limit an established backlog to one slice per frame. +- Materially reduce renderer tasks, xterm writes, repaints, and CPU wakeups for streaming agents + that emit many tiny chunks, with or without GPU acceleration. +- Preserve exact bytes, ordering, parse-deferred ACK accounting, bounded memory, visibility + transitions, synchronized output, and large-backlog throughput. +- Apply the same policy to local, SSH, WSL, and remote-runtime output without host-specific timing + assumptions. + +## Non-goals + +- Do not disable or change `caffeinate`, agents, terminal processes, or shell behavior. +- Do not change GPU settings or assume WebGL is enabled. +- Do not alter main-process PTY batching, terminal stream protocol framing, mobile subscriptions, or + Git polling. +- Do not change hidden-terminal parking, snapshot restoration, backlog loss policy, or terminal + protocol reply contents. +- Do not introduce a user setting, UI, telemetry upload, or platform-specific cadence. +- Do not optimize xterm parsing or renderer internals beyond reducing how often Orca feeds passive + chunks into them. +- Do not change agent/worktree status animation behavior in this rollout. Status animations are a + separate renderer-energy follow-up documented below so their cost is measured independently from + terminal writes. + +## Alternatives considered + +### Keep the 2 KiB rule and lower its byte budget + +This reduces the longest immediate burst but preserves the incorrect signal: passive output remains +"interactive" solely because transport framing happened to be small. It also still produces many +immediate writes below the new budget. + +### Add a fixed debounce such as 8-16 ms + +A timer can coalesce chunks, but its wakeup is not aligned with the renderer's paint opportunity and +can add a second task immediately before or after a frame. Chromium timer clamping also varies when +the app is backgrounded. The repository already distinguishes visible from hidden delivery, so an +animation-frame boundary is the more direct visible-output signal. + +### Cap passive output at approximately 30 fps or add battery/idle modes + +A 30 fps cap could reduce wakeups further, but it requires a second cadence source, a reliable +definition of idle/battery state across macOS, Linux, and Windows, and policy for active prompts and +watched split panes. There is no measurement yet showing that once-per-display-frame batching is +insufficient. Starting at one animation frame is less risky and still collapses potentially many PTY +deliveries into one xterm write. A 30 fps policy should be considered only after the measurement plan +below shows a remaining material cost. + +### Batch all foreground output, including input-driven and protocol traffic + +This is simple but adds avoidable latency to typing and can make terminal protocol clients time out. +The existing input timestamp and shared renderer-query grammar provide stronger evidence than byte +length and allow the fast path to remain narrow. + +### Change main-process delivery batching instead + +Main batching affects IPC frequency but cannot safely infer renderer visibility, active-pane input, +or xterm reply ownership, and remote-runtime output has a different transport. Renderer scheduling is +the common point where all execution paths and paint cadence are known. + +### Animate only the selected foreground agent + +This could reduce the number of animated indicators, but it breaks an important status affordance: +users must be able to see that agents in other worktrees, tabs, and split panes are still working. +Selection is also not literal keyboard focus; Orca's selected terminal pane remains selected while +the user interacts with the sidebar or another window. Any status-animation optimization must be +based on actual visibility, not active-worktree or active-pane identity. + +## Chosen design + +### Evidence-based latency classification + +Remove the rule that makes every active-pane chunk <=2 KiB latency-sensitive. Foreground output is +immediate only when one of these facts is present: + +1. The bytes complete a reply-eliciting terminal query recognized by the existing shared query + grammar. A small per-connection scan tail carries split CSI/OSC queries across PTY chunks. +2. The active split pane accepted terminal input within the existing 150 ms interactive redraw + window, subject to the existing 128 KiB/500 ms safety budget. +3. A native Windows synchronized-output frame was opened within its existing interactive window; + the current frame latch and 16/32 ms cursor-protection fallbacks remain unchanged. + +Protocol-query evidence bypasses the byte budget because withholding a reply can block the program +that issued the query. Inactive visible split panes do not get an input fast path, but protocol +queries remain immediate. Output without one of these facts is passive even when it is one byte. +Prompts and ordinary command completion can wait until the next frame (normally <= one refresh +interval); direct keyboard echo and the redraw it triggers remain immediate. + +### Shared animation-frame gate + +`latencySensitive: false` foreground output remains in the existing per-terminal scheduler queue, +but its first drain waits for one shared `requestAnimationFrame`. All passive chunks and terminals +arriving before that callback share the frame. The frame callback releases those entries and invokes +the existing high-priority drain, so slicing, time budgets, parse pacing, refresh handling, and queue +caps are unchanged after the initial gate. + +This aligns new passive output with the next available paint rather than introducing a coarse timer. +On a continuously rendering 60 Hz display the normal initial wait is at most one approximately +16.7 ms frame, and higher-refresh displays naturally get shorter waits. Once a backlog is released, +the existing cooperative continuation keeps making progress between renderer tasks; it is not +restricted to one chunk or one 16 KiB slice per animation frame. The design therefore removes +redundant within-frame writes without intentionally skipping frames or making a streaming terminal +advance at a fixed low cadence. + +A one-shot 32 ms fallback is armed with the frame request. It exists only to bound starvation when +`requestAnimationFrame` is unavailable or suspended despite the pane being treated as foreground +(including stale Electron visibility recovery). The frame callback cancels the fallback and the +fallback cancels the frame. This is not a polling loop and does not enforce 30 fps during normal +visible rendering. + +If latency-sensitive bytes arrive behind passive bytes waiting for a frame, the scheduler cancels +that terminal's frame wait and synchronously flushes the ordered queue before the new query/echo can +be parsed. Explicit flush, visibility resume, shutdown capture, and discard likewise release or +cancel frame-wait state so no callback can strand or replay an entry. + +## Invariants + +### Ordering and exact semantics + +- Each terminal has one ordered queue. Coalescing concatenates bytes in arrival order without + decoding, rewriting, dropping, or inserting bytes. +- Immediate traffic never overtakes earlier passive traffic. It first releases and flushes the + earlier frame-waiting queue. +- Existing `beforeWrite`, `onParsed`, refresh, cursor-show stripping, and scroll-intent boundaries + remain associated with the same ordered chunks. +- Scheduler flush/discard and hidden restore continue to be the only exceptional delivery paths. + +### ACK credits and backpressure + +- Every delivered PTY chunk contributes at most one fire-once ACK credit. +- Coalescing may attach multiple credits to one xterm write; all fire only after the corresponding + bytes' final slices parse. +- Drop, overflow, disposal, write failure, and snapshot-recovery paths continue to fire credits for + consumed deliveries exactly once. +- The frame wait delays credits by at most one visible frame or the 32 ms fallback. Main may briefly + reach its in-flight cap and pause the PTY; the frame drain parses bytes, returns credits, and resumes + production. No credit depends on a future chunk, so an idle producer cannot deadlock the queue. + +### Latency and starvation + +- Accepted active-pane input and complete reply-eliciting terminal queries take the immediate path, + except that prior bytes must be parsed first to preserve ordering. +- Passive visible output starts draining on the next animation frame under normal operation. +- Continuous passive output may merge deliveries within a frame, but is never deliberately held to + alternate frames or a 30 fps cadence. +- If no frame runs, the one-shot fallback releases it within 32 ms. +- Once a large passive backlog starts, the current high-priority parse-clocked drain continues; it is + not limited to 16 KiB per frame and retains the existing 8 ms cooperative task budget. +- Hidden/background output retains its existing 50 ms shared delay and 16 ms continuation cadence. + +### Synchronized output + +- DEC 2026 hold/coalesce, cursor restoration, native Windows-only protection, and interactive frame + latching are unchanged. +- Frame gating applies only when the existing synchronized-output state machine has released an entry + as ordinary non-latency-sensitive foreground output. + +## SSH and cross-platform implications + +- Native macOS/Linux/Windows, WSL, and SSH PTYs use the IPC transport but converge on the same + renderer callback. Remote-runtime PTYs use a separate subscription transport and converge before + classification. No host clock, path syntax, shell, or Git behavior changes. +- Remote input debounce is not used for protocol replies today and remains bypassed. Query bytes are + parsed immediately so the existing `sendDesktopQueryReplyImmediate` path can answer local and + remote programs within their read windows. +- Native Windows ConPTY retains its synchronized-output and renderer-refresh repairs. The cadence + does not depend on WebGL, so DOM rendering/GPU-disabled configurations benefit from fewer writes + too. +- Hidden documents and hidden panes remain background delivery. The 32 ms one-shot fallback covers + the exceptional stale-visibility case where Orca deliberately treats a pane as foreground even if + Chromium may suppress animation frames. + +## Related agent-status animation follow-up + +This terminal-output change reduces xterm work but does not make the renderer fully idle if visible +status indicators continue animating. The follow-up is documented here because it is a measurement +confounder and may become the dominant avoidable activity while a working agent is quiet or waiting +on a remote tool. It is not implemented as part of this rollout. + +### Current behavior and evidence + +- `StatusIndicator` renders the aggregate working state on a worktree as an infinite stepped CSS + rotation: `spin 1s steps(12, end)`. +- `AgentStateDot` uses the same 12-step rotation for working agent rows and terminal tabs. A visible + worktree can therefore show both an aggregate working spinner and one or more agent-status + spinners. Compact agent summaries group identities that share a state, so the adjacent provider + icons are static and do not each represent another animation. +- The worktree list is virtualized with ten overscan rows. Collapsing the sidebar unmounts the list, + but overscan-only rows can remain mounted outside the clipped viewport. +- Individual `AgentStateDot` spinners honor reduced-motion preferences. The aggregate + `StatusIndicator` spinner does not currently have equivalent behavior. +- A stepped animation changes visible orientation 12 times per second, but that alone does not prove + Chromium schedules only 12 compositor wakeups. Separately mounted spinners can also start out of + phase, spreading their visual transitions across more frame opportunities. The actual renderer and + GPU-process cost must be measured, especially with GPU acceleration disabled. + +### Visibility and status invariants + +- Every actually visible `working` agent remains animated regardless of which worktree, tab, or pane + is active. Background work must not look idle merely to save energy. +- Aggregate worktree status remains live when visible; optimization must not replace visible + background-agent motion with a selected-pane-only policy. +- Indicators may pause only when they cannot be seen: the document is hidden, the sidebar is + collapsed, or a virtualized row is outside the real viewport rather than merely mounted as + overscan. They resume when visible again. +- Non-working states remain static. A reduced-motion preference may intentionally override animation + because it is an explicit accessibility choice. +- Visibility changes must be event-driven. Do not add a polling interval or perpetual JavaScript + animation-frame loop to manage spinner cadence. + +### Least-risk follow-up design + +1. Move the working-ring animation into one shared status-indicator primitive/class used by + aggregate worktree status, individual agent rows, compact summaries, and terminal tabs. +2. Phase-lock visible spinners to one shared stepped cadence, using the CSS/document timeline or a + one-time phase offset rather than a recurring JavaScript clock. All visible working agents still + move, but their steps occur together instead of creating staggered paint opportunities. +3. Pause overscan-only worktree rows using the virtualizer's real visible range, while preserving the + existing mounted overscan needed for smooth scrolling. +4. Pause on document-hidden transitions and resume on visibility restoration. Sidebar collapse + already unmounts its indicators and should keep doing so. +5. Apply reduced-motion behavior consistently to both `AgentStateDot` and the aggregate + `StatusIndicator`. +6. Retain the current 12 visible steps per second initially. Compare 12 and 8 phase-locked steps in + visual and energy measurements; adopt 8 only if it remains perceptually smooth and produces a + material reduction. Lowering the CSS step count is not assumed to lower Chromium wakeups without + profiler evidence. + +Avoid `setInterval`, a throttled perpetual `requestAnimationFrame`, per-spinner timers, animated +image substitutions, and unconditional `will-change` layer promotion. Those approaches can add +main-thread wakeups, decoding, memory, or compositor layers without proving a net energy win. + +## Test plan + +Use deterministic fake timers and a manually controlled `requestAnimationFrame` queue. + +Scheduler tests will prove: + +- many passive tiny foreground chunks produce no write before the frame and one ordered coalesced + write when it runs; +- interactive traffic stays immediate, and traffic arriving behind a frame-waiting passive prefix + preserves order while cancelling the stale frame; +- multiple ACK credits coalesced into one write remain deferred until parse and fire exactly once; +- the 32 ms fallback drains when no animation frame runs; +- explicit flush/visibility transitions release frame-waiting output and stale frame callbacks do + nothing; +- existing overflow, discard, background, synchronized-output, fairness, and large-throughput tests + continue to pass. + +PTY connection tests will prove: + +- passive <=2 KiB chunks are frame-batched instead of treated as interactive by size; +- accepted keyboard input keeps echo/redraw output immediate; +- complete and split renderer-query sequences are immediate without recent input, including in an + inactive split pane; +- native Windows synchronized-output fast and protected paths still meet their existing bounds; +- local/SSH/WSL/remote-runtime transports continue to share the same host-neutral classification + boundary, with existing transport suites retained for runtime-specific framing and input. + +Run focused Vitest suites for the scheduler and PTY connection, then lint/typecheck and the +repository max-lines ratchet in proportion to changed files. Run the existing scheduler e2e spec if +the local Electron test environment is available; otherwise record that remaining validation. + +The separate status-animation follow-up should add focused tests proving that visible working agents +animate in active and inactive worktrees, overscan-only rows and hidden documents pause, visibility +restoration resumes at the shared phase, collapsed-sidebar behavior remains unchanged, and +reduced-motion disables both aggregate and per-agent animation. Tests should also prove that no +recurring JavaScript timer or animation-frame loop is introduced. + +## Measurement plan + +Use a representative visible agent workload that emits timestamped 20-200 byte chunks continuously +for at least five minutes, once with GPU acceleration enabled and once disabled. Keep `caffeinate` +and the agent configuration unchanged. Repeat each baseline and candidate run at least three times +after an idle settling period. + +Enable the existing e2e terminal scheduler debug API and record: + +- PTY deliveries/chunks generated by the workload; +- `foregroundWriteCount`, `deferredForegroundEnqueueCount`, + `deferredForegroundWriteCount`, `scheduledDrainCount`, and `drainWrites`; +- xterm `write` calls and viewport refresh/render calls using a local diagnostic wrapper or the + existing e2e instrumentation; +- peak queued characters and any dropped-backlog count; +- input-to-echo and query-to-reply latency samples, including p50/p95/max; +- renderer CPU time, wakeups/idle wakeups, and energy impact from the platform profiler (macOS + Instruments Energy Log/Time Profiler, Windows WPA, or Linux `perf`/PowerTOP as available). + +Expected result: for passive tiny chunks, xterm writes and foreground drain activations approach the +number of displayed frames rather than the number of PTY deliveries, with byte-for-byte output, +zero additional drops, stable ACK progress, and no material regression in interactive latency. + +Status animations must be controlled as a separate variable. Repeat the steady-state portion of the +measurement with: + +- one and many working agents, including agents in inactive but visible worktrees; +- compact and full agent rows, with the sidebar open and collapsed; +- the current status animations running and temporarily paused as a diagnostic baseline; +- current staggered phases and the proposed shared phase; +- overscan-only rows present, then explicitly paused; and +- GPU acceleration enabled and disabled. + +Measure both a streaming workload and a quiet `working` state where the agent is waiting on a remote +tool and produces no terminal output. Record running animation counts, compositor/BeginFrame cadence, +renderer and GPU-process CPU, raster/paint activity, idle wakeups, and platform energy impact. Multiple +spinners may share compositor frames, so do not extrapolate linearly from element count. If pausing +status animations does not change the quiet baseline, further spinner work is not justified; if it +does, phase synchronization and invisible-row pausing should be evaluated before reducing visible +cadence. + +## Rollout risks and mitigations + +- **Misclassified protocol query:** use the existing shared query grammar with a cross-chunk tail and + targeted complete/split-query tests. Do not infer queries from generic escape bytes. +- **ACK starvation:** retain parse-deferred credits and add coalesced-credit/fallback tests. The + one-shot fallback provides an independent progress bound. +- **Ordering regression when input follows passive output:** release and flush the existing ordered + queue before parsing the immediate suffix; test both bytes and callbacks. +- **Animation frames suppressed by Electron visibility state:** retain the current foreground/ + background visibility decision and add the 32 ms one-shot fallback. +- **Throughput regression for large floods:** gate only the initial drain; keep current slicing, + high-priority parse pacing, caps, and throughput benchmark. +- **Windows TUI cursor regression:** leave synchronized-output and refresh policy unchanged and run + its focused ConPTY tests. +- **Extra frame callbacks after flush/dispose:** cancel the shared gate when no entry is waiting and + make stale callbacks generation-safe. +- **Misattributed residual wakeups:** visible working indicators can keep the compositor active after + terminal writes are batched. Hold their state constant and run the status-animation measurement + matrix before attributing total-process energy changes to this scheduler. +- **Future status visibility regression:** a follow-up must never gate animation on active worktree or + selected pane. Test inactive visible worktrees and offscreen-to-visible transitions explicitly. + +## Rollback strategy + +The change is renderer-only and does not alter persisted data, IPC schemas, snapshots, or PTY wire +protocols. Rollback consists of removing the animation-frame wait state and restoring the prior +`scheduleDrain(0)` call for `latencySensitive: false`, plus restoring the <=2 KiB classification +branch. No migration or data repair is required. If classification alone is implicated, retain the +frame gate and temporarily restore the small-chunk fast path; if scheduling is implicated, retain the +evidence-based classifier and return passive output to the current zero-delay drain while collecting +measurements. diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 6c72e259c72..596ea58dcfa 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -1760,7 +1760,7 @@ describe('connectPanePty', () => { capturedDataCallback.current?.('x'.repeat(16 * 1024)) expect(pane.terminal.write).not.toHaveBeenCalled() - vi.advanceTimersByTime(0) + vi.advanceTimersByTime(32) expect(pane.terminal.write).toHaveBeenCalledWith('x'.repeat(16 * 1024), expect.any(Function)) }) @@ -1818,6 +1818,117 @@ describe('connectPanePty', () => { expect(pane.terminal.write).toHaveBeenCalledWith(redraw, expect.any(Function)) }) + it('batches passive tiny chunks into one foreground frame write', async () => { + const { connectPanePty } = await import('./pty-connection') + const pane = createPane(1) + const transport = createMockTransport('pty-1') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-1' + }) + transportFactoryQueue.push(transport) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks() + vi.useFakeTimers() + + capturedDataCallback.current?.('passive ') + capturedDataCallback.current?.('agent ') + capturedDataCallback.current?.('output\r\n') + + expect(pane.terminal.write).not.toHaveBeenCalled() + vi.advanceTimersByTime(32) + expect(pane.terminal.write).toHaveBeenCalledTimes(1) + expect(pane.terminal.write).toHaveBeenCalledWith( + 'passive agent output\r\n', + expect.any(Function) + ) + }) + + it('parses a complete terminal reply query immediately without recent input', async () => { + const { connectPanePty } = await import('./pty-connection') + const pane = createPane(1) + const transport = createMockTransport('pty-1') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-1' + }) + transportFactoryQueue.push(transport) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks() + capturedDataCallback.current?.('\x1b[6n') + + expect(pane.terminal.write).toHaveBeenCalledWith('\x1b[6n', expect.any(Function)) + }) + + it('flushes an ordered split terminal query as soon as it completes', async () => { + const { connectPanePty } = await import('./pty-connection') + const pane = createPane(1) + const transport = createMockTransport('pty-1') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-1' + }) + transportFactoryQueue.push(transport) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks() + vi.useFakeTimers() + + capturedDataCallback.current?.('\x1b[') + expect(pane.terminal.write).not.toHaveBeenCalled() + capturedDataCallback.current?.('6n') + + expect(pane.terminal.write).toHaveBeenCalledTimes(1) + expect(pane.terminal.write).toHaveBeenCalledWith('\x1b[6n', expect.any(Function)) + vi.advanceTimersByTime(32) + expect(pane.terminal.write).toHaveBeenCalledTimes(1) + }) + + it.each(['ssh', 'remote-runtime'] as const)( + 'keeps terminal reply queries immediate for inactive %s panes', + async (runtimeKind) => { + if (runtimeKind === 'ssh') { + mockStoreState = { + ...mockStoreState, + repos: [{ id: 'repo1', connectionId: 'conn-1' }], + sshConnectionStates: new Map([['conn-1', { status: 'connected' }]]) + } as StoreState + } else { + enableActiveRuntimeEnvironment() + } + const { connectPanePty } = await import('./pty-connection') + const pane = createPane(1) + const transport = createMockTransport('pty-1') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-1' + } + ) + transportFactoryQueue.push(transport) + const manager = { + ...createManager(2), + getActivePane: vi.fn(() => ({ id: 2 })) + } + + connectPanePty( + pane as never, + manager as never, + createDeps({ isActiveRef: { current: false } }) as never + ) + await flushAsyncTicks() + capturedDataCallback.current?.('\x1b[c') + + expect(pane.terminal.write).toHaveBeenCalledWith('\x1b[c', expect.any(Function)) + } + ) + it('does not let OpenTUI-style small ANSI redraw bursts monopolize foreground writes', async () => { const { connectPanePty } = await import('./pty-connection') const pane = createPane(1) @@ -1852,7 +1963,7 @@ describe('connectPanePty', () => { } expect(pane.terminal.write.mock.calls.length).toBeLessThan(frames.length) - vi.advanceTimersByTime(0) + vi.advanceTimersByTime(32) expect(pane.terminal.write.mock.calls.length).toBeGreaterThan(0) }) @@ -8365,6 +8476,7 @@ describe('connectPanePty', () => { // must not re-arm restores — the post-gap bytes write through. dataCallback('', { droppedOutput: true }) dataCallback('AFTER-FLOOD', { seq: 700 * 1024, rawLength: 11 }) + vi.advanceTimersByTime(32) await flushAsyncTicks(8) expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) expect(writtenFloodData(pane)).toContain('AFTER-FLOOD') @@ -8933,7 +9045,7 @@ describe('connectPanePty', () => { capturedDataCallback.current?.(redraw) expect(pane.terminal.write).not.toHaveBeenCalledWith(redraw, expect.any(Function)) - vi.advanceTimersByTime(0) + vi.advanceTimersByTime(32) expect(pane.terminal.write).toHaveBeenCalledWith(redraw, expect.any(Function)) }) @@ -8966,7 +9078,7 @@ describe('connectPanePty', () => { capturedDataCallback.current?.(redraw) expect(pane.terminal.write).not.toHaveBeenCalledWith(redraw, expect.any(Function)) - vi.advanceTimersByTime(0) + vi.advanceTimersByTime(32) expect(pane.terminal.write).toHaveBeenCalledWith(redraw, expect.any(Function)) }) @@ -10886,7 +10998,7 @@ describe('connectPanePty', () => { ) vi.advanceTimersByTime(1) - vi.advanceTimersByTime(0) + vi.advanceTimersByTime(32) await flushAsyncTicks(10) const written = pane.terminal.write.mock.calls.map(([data]) => data as string) @@ -10953,6 +11065,7 @@ describe('connectPanePty', () => { vi.advanceTimersByTime(0) await flushAsyncTicks(10) } + vi.advanceTimersByTime(32) const written = pane.terminal.write.mock.calls.map(([data]) => data as string) const warningIndex = written.findIndex((data) => data.includes('main recovery was unavailable')) @@ -11056,7 +11169,7 @@ describe('connectPanePty', () => { await flushAsyncTicks(4) vi.advanceTimersByTime(750) - vi.advanceTimersByTime(0) + vi.advanceTimersByTime(32) await flushAsyncTicks(10) const written = pane.terminal.write.mock.calls.map(([data]) => data as string) @@ -14324,6 +14437,7 @@ describe('connectPanePty', () => { bellHandler() idleHandler('* Codex done') vi.advanceTimersByTime(AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS) + vi.advanceTimersByTime(32) await flushAsyncTicks() expect(window.api.notifications.dispatch).toHaveBeenCalledTimes(1) @@ -16304,6 +16418,7 @@ describe('connectPanePty', () => { lastAssistantMessage: 'Done.' }) vi.advanceTimersByTime(AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS) + vi.advanceTimersByTime(32) expect(deps.dispatchNotification).not.toHaveBeenCalled() expect(deps.setCacheTimerStartedAt).toHaveBeenCalledWith(paneKey, expect.any(Number)) @@ -16572,6 +16687,7 @@ describe('connectPanePty', () => { agentType: 'claude', lastAssistantMessage: 'Done.' }) + vi.advanceTimersByTime(32) expect(deps.setCacheTimerStartedAt).toHaveBeenCalledWith(paneKey, expect.any(Number)) expect(pane.terminal.write).toHaveBeenCalledWith( diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 48aa2775ba6..4f6dd2df7d8 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -282,7 +282,6 @@ const TERMINAL_FOCUS_IN_SEQUENCE = '\x1b[I' const FOCUS_REPORTING_DISABLE_SEQUENCE = '\x1b[?1004l' const REATTACH_IDLE_AGENT_CURSOR_RESET_DELAY_MS = 250 const SHIFT_ENTER_RECONFIRM_IDLE_MS = 350 -const FOREGROUND_THROUGHPUT_IMMEDIATE_CHARS = 2048 const FOREGROUND_INTERACTIVE_REDRAW_CHARS = 128 * 1024 const FOREGROUND_INTERACTIVE_REDRAW_WINDOW_MS = 150 // Why: a submit repaint can take longer than one keystroke echo to fully @@ -295,7 +294,6 @@ const FOREGROUND_SYNCHRONIZED_FRAME_INTERACTIVE_WINDOW_MS = 400 // collectively starve timers unless foreground writes have a rolling budget. const FOREGROUND_IMMEDIATE_BUDGET_CHARS = 128 * 1024 const FOREGROUND_BUDGET_WINDOW_MS = 500 -const INACTIVE_FOREGROUND_IMMEDIATE_BUDGET_CHARS = 32 * 1024 const FOREGROUND_GRID_DRIFT_CHECK_MIN_MS = 250 // Why: this is only shown if hidden renderer output was skipped and main-owned // terminal state is unavailable, so the user has an explicit loss signal. @@ -590,8 +588,6 @@ let codexRestartNoticePresenceSource: Record< { previousAccountLabel: string; nextAccountLabel: string } > | null = null let codexRestartNoticePresence = false -let inactiveForegroundImmediateBudgetChars = 0 -let inactiveForegroundImmediateBudgetWindowStart = 0 type PanePtyBinding = IDisposable & { syncProcessTracking: () => void @@ -697,22 +693,6 @@ function isRemoteRuntimePtyId(ptyId: string | null | undefined): boolean { return typeof ptyId === 'string' && ptyId.startsWith(REMOTE_PTY_ID_PREFIX) } -function consumeInactiveForegroundImmediateBudget(dataLength: number): boolean { - const now = performance.now() - if (now - inactiveForegroundImmediateBudgetWindowStart > FOREGROUND_BUDGET_WINDOW_MS) { - inactiveForegroundImmediateBudgetChars = 0 - inactiveForegroundImmediateBudgetWindowStart = now - } - if ( - inactiveForegroundImmediateBudgetChars + dataLength > - INACTIVE_FOREGROUND_IMMEDIATE_BUDGET_CHARS - ) { - return false - } - inactiveForegroundImmediateBudgetChars += dataLength - return true -} - function hasCodexRestartNotices( noticesByPtyId: Record ): boolean { @@ -4855,6 +4835,8 @@ export function connectPanePty( } let foregroundImmediateBudgetChars = 0 let foregroundImmediateBudgetWindowStart = 0 + let foregroundRendererQueryPtyId: string | null = null + let foregroundRendererQueryScanTail = '' let foregroundRewriteChunkEndedWithCarriageReturn = false let foregroundRewriteCsiScanTail = '' let hiddenMode2031ScanTail = '' @@ -5093,29 +5075,34 @@ export function connectPanePty( return activePane ? activePane.id === pane.id : true } - function isLatencySensitiveForegroundOutput(data: string): boolean { - if (!isActiveSplitPane()) { - // Why: many visible split panes can each emit tiny TUI frames. A shared - // budget keeps watched panes live while preventing aggregate xterm work - // from starving typing in the active pane. - if (data.includes('\x1b[')) { - return false - } - return consumeInactiveForegroundImmediateBudget(data.length) + function containsForegroundRendererReplyQuery(data: string): boolean { + const ptyId = transport.getPtyId() + if (foregroundRendererQueryPtyId !== ptyId) { + foregroundRendererQueryPtyId = ptyId + foregroundRendererQueryScanTail = '' } - if (data.length <= FOREGROUND_THROUGHPUT_IMMEDIATE_CHARS) { - return consumeForegroundImmediateBudget(data.length) + const extracted = extractHiddenStartupRendererQueryData(data, foregroundRendererQueryScanTail) + foregroundRendererQueryScanTail = extracted.pending + return Boolean( + extracted.statelessQueryData || extracted.statefulQueryData || extracted.oscColorQueryData + ) + } + + function isLatencySensitiveForegroundOutput( + data: string, + containsRendererReplyQuery: boolean + ): boolean { + if (containsRendererReplyQuery) { + return true + } + if (!isActiveSplitPane()) { + return false } const recentInput = performance.now() - lastTerminalInputAt <= FOREGROUND_INTERACTIVE_REDRAW_WINDOW_MS - if ( - recentInput && - data.length <= FOREGROUND_INTERACTIVE_REDRAW_CHARS && - data.includes('\x1b[') - ) { - return consumeForegroundImmediateBudget(data.length) - } - return false + // Why: accepted input is evidence of echo/redraw work; transport chunk + // size alone is not, so passive agent streams wait for the next frame. + return recentInput && consumeForegroundImmediateBudget(data.length) } function containsNonAsciiOutput(data: string): boolean { @@ -5306,6 +5293,8 @@ export function connectPanePty( const synchronizedFrameLatencySensitive = synchronizedForegroundOutput && synchronizedForegroundFrameInteractive synchronizedForegroundOutputActive = nextSynchronizedForegroundOutputActive + const foregroundContainsRendererReplyQuery = + foreground && containsForegroundRendererReplyQuery(data) if (!foreground && hiddenMode2031ScanTail) { respondToSkippedMode2031Subscribe(data) } @@ -5321,7 +5310,8 @@ export function connectPanePty( latencySensitive: !foreground || parseHiddenStartupOutput ? true - : synchronizedFrameLatencySensitive || isLatencySensitiveForegroundOutput(data), + : synchronizedFrameLatencySensitive || + isLatencySensitiveForegroundOutput(data, foregroundContainsRendererReplyQuery), forceForegroundRefresh: foregroundOutput && (synchronizedForegroundOutput || diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-output-frame-gate.ts b/src/renderer/src/lib/pane-manager/pane-terminal-output-frame-gate.ts new file mode 100644 index 00000000000..02baef8b435 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-terminal-output-frame-gate.ts @@ -0,0 +1,65 @@ +export const TERMINAL_OUTPUT_FRAME_FALLBACK_MS = 32 + +export type PaneTerminalOutputFrameGate = { + schedule: () => void + cancel: () => void + isPending: () => boolean +} + +export function createPaneTerminalOutputFrameGate( + onFrame: () => void +): PaneTerminalOutputFrameGate { + let frameId: number | null = null + let fallbackTimer: ReturnType | null = null + let generation = 0 + + const cancelHandles = (): void => { + if (frameId !== null && typeof globalThis.cancelAnimationFrame === 'function') { + globalThis.cancelAnimationFrame(frameId) + } + frameId = null + if (fallbackTimer !== null) { + clearTimeout(fallbackTimer) + fallbackTimer = null + } + } + + const release = (expectedGeneration: number): void => { + if (expectedGeneration !== generation || (frameId === null && fallbackTimer === null)) { + return + } + cancelHandles() + onFrame() + } + + return { + schedule: () => { + if (frameId !== null || fallbackTimer !== null) { + return + } + generation += 1 + const scheduledGeneration = generation + fallbackTimer = setTimeout( + () => release(scheduledGeneration), + TERMINAL_OUTPUT_FRAME_FALLBACK_MS + ) + if (typeof globalThis.requestAnimationFrame === 'function') { + const requestedFrameId = globalThis.requestAnimationFrame(() => + release(scheduledGeneration) + ) + // Test and embedded runtimes may invoke the callback synchronously. + // Do not resurrect an already-released frame with its returned id. + if (fallbackTimer !== null) { + frameId = requestedFrameId + } + } + // Why: Electron can suppress rAF after stale visibility state; one timer + // bounds ACK and output latency without creating a recurring wakeup loop. + }, + cancel: () => { + generation += 1 + cancelHandles() + }, + isPending: () => frameId !== null || fallbackTimer !== null + } +} diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts index e3e35134a8b..972e3b2466b 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts @@ -51,6 +51,39 @@ function createForegroundTerminal() { } } +function installManualAnimationFrames(): { + pendingCount: () => number + runNext: (timestamp?: number) => void +} { + let nextId = 1 + const callbacks = new Map() + vi.stubGlobal( + 'requestAnimationFrame', + vi.fn((callback: FrameRequestCallback) => { + const id = nextId++ + callbacks.set(id, callback) + return id + }) + ) + vi.stubGlobal( + 'cancelAnimationFrame', + vi.fn((id: number) => { + callbacks.delete(id) + }) + ) + return { + pendingCount: () => callbacks.size, + runNext: (timestamp = 16) => { + const next = callbacks.entries().next().value as [number, FrameRequestCallback] | undefined + if (!next) { + throw new Error('No animation frame is pending') + } + callbacks.delete(next[0]) + next[1](timestamp) + } + } +} + async function loadScheduler() { vi.resetModules() return import('./pane-terminal-output-scheduler') @@ -80,6 +113,7 @@ describe('pane terminal output scheduler', () => { it('credits when a queued chunk finishes parsing', async () => { vi.useFakeTimers() + const frames = installManualAnimationFrames() const { writeTerminalOutput } = await loadScheduler() const terminal = createTerminal() let parsed: (() => void) | undefined @@ -95,7 +129,7 @@ describe('pane terminal output scheduler', () => { }) expect(credit.count()).toBe(0) - vi.advanceTimersByTime(0) + frames.runNext() expect(terminal.write).toHaveBeenCalledWith('queued', expect.any(Function)) expect(credit.count()).toBe(0) parsed?.() @@ -124,6 +158,7 @@ describe('pane terminal output scheduler', () => { it('defers split-chunk credit and onParsed until the final slice parses', async () => { vi.useFakeTimers() + const frames = installManualAnimationFrames() const { writeTerminalOutput } = await loadScheduler() const terminal = createTerminal() const parseCallbacks: (() => void)[] = [] @@ -141,7 +176,7 @@ describe('pane terminal output scheduler', () => { ackCredit: credit.fire, onParsed }) - vi.advanceTimersByTime(0) + frames.runNext() expect(parseCallbacks).toHaveLength(3) parseCallbacks[0]() @@ -261,6 +296,7 @@ describe('pane terminal output scheduler', () => { it('runs parsed callbacks after queued foreground output parses', async () => { vi.useFakeTimers() + const frames = installManualAnimationFrames() const { writeTerminalOutput } = await loadScheduler() const terminal = createTerminal() let parseCallback: (() => void) | undefined @@ -275,7 +311,7 @@ describe('pane terminal output scheduler', () => { onParsed }) - vi.advanceTimersByTime(0) + frames.runNext() expect(terminal.write).toHaveBeenCalledWith('queued', expect.any(Function)) expect(onParsed).not.toHaveBeenCalled() @@ -570,8 +606,9 @@ describe('pane terminal output scheduler', () => { expect(onParsed).toHaveBeenCalledTimes(1) }) - it('defers throughput foreground output to the shared high-priority drain', async () => { + it('defers throughput foreground output to the shared frame-oriented drain', async () => { vi.useFakeTimers() + const frames = installManualAnimationFrames() const { writeTerminalOutput } = await loadScheduler() const terminal = createTerminal() @@ -585,7 +622,8 @@ describe('pane terminal output scheduler', () => { }) expect(terminal.write).not.toHaveBeenCalled() - vi.advanceTimersByTime(0) + expect(frames.pendingCount()).toBe(1) + frames.runNext() expect(terminal.write).toHaveBeenCalledTimes(2) expect(terminal.write.mock.calls.map(([data]) => data).join('')).toBe( @@ -593,6 +631,116 @@ describe('pane terminal output scheduler', () => { ) }) + it('coalesces passive tiny chunks and their ACK credits into one frame write', async () => { + vi.useFakeTimers() + const frames = installManualAnimationFrames() + const { writeTerminalOutput } = await loadScheduler() + const terminal = createTerminal() + const parseCallbacks: (() => void)[] = [] + terminal.write.mockImplementation((_data: string, callback?: () => void) => { + if (callback) { + parseCallbacks.push(callback) + } + }) + const credits = [vi.fn(), vi.fn(), vi.fn()] + + for (const [index, data] of ['tiny-', 'passive-', 'stream'].entries()) { + writeTerminalOutput(terminal, data, { + foreground: true, + latencySensitive: false, + ackCredit: credits[index] + }) + } + + expect(terminal.write).not.toHaveBeenCalled() + expect(frames.pendingCount()).toBe(1) + frames.runNext() + + expect(terminal.write).toHaveBeenCalledTimes(1) + expect(terminal.write).toHaveBeenCalledWith('tiny-passive-stream', expect.any(Function)) + expect(credits.every((credit) => credit.mock.calls.length === 0)).toBe(true) + parseCallbacks[0]?.() + expect(credits.every((credit) => credit.mock.calls.length === 1)).toBe(true) + }) + + it('shares one animation-frame request across passive foreground terminals', async () => { + vi.useFakeTimers() + const frames = installManualAnimationFrames() + const requestFrame = vi.mocked(globalThis.requestAnimationFrame) + const { writeTerminalOutput } = await loadScheduler() + const first = createTerminal() + const second = createTerminal() + + writeTerminalOutput(first, 'first', { foreground: true, latencySensitive: false }) + writeTerminalOutput(second, 'second', { foreground: true, latencySensitive: false }) + + expect(requestFrame).toHaveBeenCalledTimes(1) + frames.runNext() + expect(first.write).toHaveBeenCalledWith('first', expect.any(Function)) + expect(second.write).toHaveBeenCalledWith('second', expect.any(Function)) + }) + + it('flushes an immediate suffix behind its ordered frame-waiting prefix', async () => { + vi.useFakeTimers() + const frames = installManualAnimationFrames() + const cancelFrame = vi.mocked(globalThis.cancelAnimationFrame) + const { writeTerminalOutput } = await loadScheduler() + const terminal = createTerminal() + + writeTerminalOutput(terminal, 'passive-', { + foreground: true, + latencySensitive: false + }) + writeTerminalOutput(terminal, 'reply-query', { + foreground: true, + latencySensitive: true + }) + + expect(terminal.write).toHaveBeenCalledTimes(1) + expect(terminal.write).toHaveBeenCalledWith('passive-reply-query', expect.any(Function)) + expect(cancelFrame).toHaveBeenCalledTimes(1) + expect(frames.pendingCount()).toBe(0) + }) + + it('uses the one-shot fallback when an animation frame does not run', async () => { + vi.useFakeTimers() + vi.stubGlobal( + 'requestAnimationFrame', + vi.fn(() => 41) + ) + vi.stubGlobal('cancelAnimationFrame', vi.fn()) + const { writeTerminalOutput } = await loadScheduler() + const terminal = createTerminal() + + writeTerminalOutput(terminal, 'fallback', { + foreground: true, + latencySensitive: false + }) + + vi.advanceTimersByTime(31) + expect(terminal.write).not.toHaveBeenCalled() + vi.advanceTimersByTime(1) + expect(terminal.write).toHaveBeenCalledWith('fallback', expect.any(Function)) + vi.advanceTimersByTime(32) + expect(terminal.write).toHaveBeenCalledTimes(1) + }) + + it('releases frame-waiting output on an explicit visibility flush', async () => { + vi.useFakeTimers() + const frames = installManualAnimationFrames() + const { flushTerminalOutput, writeTerminalOutput } = await loadScheduler() + const terminal = createTerminal() + + writeTerminalOutput(terminal, 'visible-again', { + foreground: true, + latencySensitive: false + }) + flushTerminalOutput(terminal) + + expect(terminal.write).toHaveBeenCalledWith('visible-again', expect.any(Function)) + expect(frames.pendingCount()).toBe(0) + }) + it('coalesces synchronized foreground frame endings with immediate cursor restore bytes', async () => { vi.useFakeTimers() const { writeTerminalOutput } = await loadScheduler() @@ -1024,6 +1172,7 @@ describe('pane terminal output scheduler', () => { it('ignores unforced chunks when resolving a coalesced forced refresh', async () => { vi.useFakeTimers() + const frames = installManualAnimationFrames() const { writeTerminalOutput } = await loadScheduler() const terminal = createForegroundTerminal() @@ -1037,7 +1186,7 @@ describe('pane terminal output scheduler', () => { foreground: true, latencySensitive: false }) - vi.advanceTimersByTime(0) + frames.runNext() expect(terminal.refresh).toHaveBeenCalledWith(0, 23) expect(terminal._core.refresh).not.toHaveBeenCalled() @@ -1096,6 +1245,7 @@ describe('pane terminal output scheduler', () => { it('drains active foreground backlog before older background terminal backlog', async () => { vi.useFakeTimers() + const frames = installManualAnimationFrames() const { writeTerminalOutput } = await loadScheduler() const backgroundA = createTerminal() const backgroundB = createTerminal() @@ -1108,7 +1258,7 @@ describe('pane terminal output scheduler', () => { latencySensitive: false }) - vi.advanceTimersByTime(0) + frames.runNext() expect(active.write).toHaveBeenCalledWith('active', expect.any(Function)) expect(active.write.mock.invocationCallOrder[0]).toBeLessThan( @@ -1308,6 +1458,7 @@ describe('pane terminal output scheduler', () => { // TUI on a starved renderer grew queuedChars without bound (field // reports of ~1.5 GB renderer RSS before terminals froze). vi.useFakeTimers() + const frames = installManualAnimationFrames() const { writeTerminalOutput } = await loadScheduler() const terminal = createTerminal() const chunk = 'x'.repeat(512 * 1024) @@ -1317,7 +1468,7 @@ describe('pane terminal output scheduler', () => { } writeTerminalOutput(terminal, 'after-cap\r\n', { foreground: true, latencySensitive: false }) - vi.advanceTimersByTime(0) + frames.runNext() const output = terminal.write.mock.calls.map(([data]) => data).join('') expect(output).toContain('Orca skipped a burst of terminal output') @@ -1347,6 +1498,7 @@ describe('pane terminal output scheduler', () => { it('scales the backlog cap with the scrollback setting', async () => { vi.useFakeTimers() + const frames = installManualAnimationFrames() const { writeTerminalOutput, configureTerminalOutputBacklogCap } = await loadScheduler() const terminal = createTerminal() const chunk = 'x'.repeat(512 * 1024) @@ -1357,7 +1509,7 @@ describe('pane terminal output scheduler', () => { for (let i = 0; i < 5; i++) { writeTerminalOutput(terminal, chunk, { foreground: true, latencySensitive: false }) } - vi.advanceTimersByTime(0) + frames.runNext() let output = terminal.write.mock.calls.map(([data]) => data).join('') expect(output).not.toContain('Orca skipped') @@ -1368,7 +1520,7 @@ describe('pane terminal output scheduler', () => { for (let i = 0; i < 13; i++) { writeTerminalOutput(terminal, chunk, { foreground: true, latencySensitive: false }) } - vi.advanceTimersByTime(0) + frames.runNext() output = terminal.write.mock.calls.map(([data]) => data).join('') expect(output).toContain('Orca skipped a burst of terminal output') }) diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts index 5f604996757..7f6d005cd93 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts @@ -25,6 +25,7 @@ import { TERMINAL_OUTPUT_BACKLOG_MIN_CAP_CHARS, terminalOutputBacklogCapChars } from '../../../../shared/terminal-scrollback-policy' +import { createPaneTerminalOutputFrameGate } from './pane-terminal-output-frame-gate' type TerminalOutputTarget = ForegroundTerminalOutputTarget @@ -85,6 +86,7 @@ type QueueEntry = { onBackgroundBacklogDropped?: () => void backgroundBacklogDropped: boolean highPriority: boolean + foregroundFrameWait: boolean foregroundHold: boolean foregroundHoldSafetyDelayMs: number foregroundCoalesce: boolean @@ -158,6 +160,7 @@ let drainImmediatePending = false let drainImmediateGeneration = 0 let useMessageChannelDrain = typeof MessageChannel !== 'undefined' && !isVitestEnv() let drainChannel: MessageChannel | null = null +const foregroundFrameGate = createPaneTerminalOutputFrameGate(drainFrameDeferredForegroundOutput) function isVitestEnv(): boolean { // Why: vitest fake timers cannot advance MessageChannel macrotasks; the @@ -184,6 +187,15 @@ function cancelImmediateDrain(): void { drainImmediatePending = false } +function cancelScheduledDrain(): void { + if (drainTimer !== null) { + clearTimeout(drainTimer) + drainTimer = null + drainTimerDelayMs = null + } + cancelImmediateDrain() +} + export function setUseMessageChannelDrainForTesting(value: boolean | null): void { cancelImmediateDrain() useMessageChannelDrain = value ?? (typeof MessageChannel !== 'undefined' && !isVitestEnv()) @@ -347,6 +359,7 @@ function createQueueEntry( onBackgroundBacklogDropped: options.onBackgroundBacklogDropped, backgroundBacklogDropped: false, highPriority: true, + foregroundFrameWait: false, foregroundHold: false, foregroundHoldSafetyDelayMs: FOREGROUND_HOLD_SAFETY_DELAY_MS, foregroundCoalesce: false, @@ -409,7 +422,46 @@ function scheduleForegroundCoalesceRelease( } function isEntryDrainable(entry: QueueEntry): boolean { - return !entry.foregroundHold && !entry.foregroundCoalesce + return !entry.foregroundFrameWait && !entry.foregroundHold && !entry.foregroundCoalesce +} + +function hasForegroundFrameWait(): boolean { + for (const entry of queuedByTerminal.values()) { + if (entry.foregroundFrameWait) { + return true + } + } + return false +} + +function releaseForegroundFrameWait(entry: QueueEntry): void { + if (!entry.foregroundFrameWait) { + return + } + entry.foregroundFrameWait = false + if (!hasForegroundFrameWait()) { + foregroundFrameGate.cancel() + } +} + +function scheduleForegroundFrameDrain(entry: QueueEntry): void { + entry.foregroundFrameWait = true + if (debugEnabled && !foregroundFrameGate.isPending()) { + debugState.scheduledDrainCount++ + } + foregroundFrameGate.schedule() +} + +function drainFrameDeferredForegroundOutput(): void { + for (const entry of queuedByTerminal.values()) { + entry.foregroundFrameWait = false + } + // Why: the frame is the drain opportunity; avoid posting a second renderer + // task while preserving the existing cooperative continuation for backlogs. + cancelScheduledDrain() + if (queuedByTerminal.size > 0 && hasDrainableBacklog()) { + drainQueuedOutput() + } } function findCursorPositionSequenceEnd( @@ -1091,6 +1143,7 @@ export function writeTerminalOutput( if (options.foreground) { const entry = queuedByTerminal.get(terminal) if (entry?.highPriority || options.coalesceForeground || options.holdForeground) { + const wasWaitingForForegroundFrame = entry?.foregroundFrameWait === true const queued = entry ?? createQueueEntry(terminal, options) queued.onBackgroundBacklogDropped = options.onBackgroundBacklogDropped queued.highPriority = true @@ -1117,6 +1170,7 @@ export function writeTerminalOutput( return } if (options.holdForeground) { + releaseForegroundFrameWait(queued) // Why: synchronized-output start/body chunks contain transient cursor // moves. Holding them prevents Chromium from rasterizing those states. if (options.latencySensitive === true) { @@ -1136,6 +1190,7 @@ export function writeTerminalOutput( return } if (options.coalesceForeground || queued.foregroundCoalesce) { + releaseForegroundFrameWait(queued) queued.foregroundHold = false clearForegroundHoldSafety(queued) const shouldShortenCoalesceForLatencySensitiveForeground = options.latencySensitive === true @@ -1166,6 +1221,17 @@ export function writeTerminalOutput( queued.foregroundHold = false clearForegroundCoalesce(queued) clearForegroundHoldSafety(queued) + if (options.latencySensitive === false) { + scheduleForegroundFrameDrain(queued) + return + } + releaseForegroundFrameWait(queued) + if (wasWaitingForForegroundFrame) { + // Why: a query or input echo cannot overtake passive bytes that were + // already waiting for paint; flush the ordered prefix and suffix now. + flushTerminalOutput(terminal) + return + } scheduleDrain(0) return } @@ -1221,9 +1287,9 @@ export function writeTerminalOutput( replaceBacklogWithWarning(queued, FOREGROUND_BACKLOG_WARNING) } // Why: visible command floods are throughput work, not keystroke echo. - // Queue them behind a zero-delay drain so one IPC callback cannot pin - // the renderer in xterm.write while input and paint are waiting. - scheduleDrain(0) + // Wait for the next paint opportunity so tiny passive deliveries share + // one xterm write; large backlogs keep the existing fast continuation. + scheduleForegroundFrameDrain(queued) return } flushTerminalOutput(terminal) @@ -1295,6 +1361,7 @@ export function flushTerminalOutput( if (!entry) { return } + releaseForegroundFrameWait(entry) queuedByTerminal.delete(terminal) if (!isEntryDrainable(entry)) { queuedByTerminal.set(terminal, entry) @@ -1439,6 +1506,9 @@ export function discardTerminalOutput(terminal: TerminalOutputTarget): void { } discardInFlightTerminalOutputAckCredits(terminal) queuedByTerminal.delete(terminal) + if (!hasForegroundFrameWait()) { + foregroundFrameGate.cancel() + } discardForegroundRenderSettle(terminal) // Why: a legitimately disposed pane must not leave a pending stall watch to // probe the dead terminal later and report a false wedge.