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.
This commit is contained in:
Jinjing
2026-07-15 16:59:41 -07:00
parent 0ab2e001d1
commit 99780c4fbe
6 changed files with 850 additions and 60 deletions
@@ -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.
@@ -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(
@@ -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<string, { previousAccountLabel: string; nextAccountLabel: string }>
): 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 ||
@@ -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<typeof setTimeout> | 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
}
}
@@ -51,6 +51,39 @@ function createForegroundTerminal() {
}
}
function installManualAnimationFrames(): {
pendingCount: () => number
runNext: (timestamp?: number) => void
} {
let nextId = 1
const callbacks = new Map<number, FrameRequestCallback>()
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')
})
@@ -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.