-
Notifications
You must be signed in to change notification settings - Fork 494
Fluid full-screen animated TUIs: drop superseded frames to keep 60 fps and a responsive keyboard #2489
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
s-celles
wants to merge
36
commits into
binaricat:main
Choose a base branch
from
s-celles:perf/fluid-terminal-v2
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Fluid full-screen animated TUIs: drop superseded frames to keep 60 fps and a responsive keyboard #2489
Changes from 27 commits
Commits
Show all changes
36 commits
Select commit
Hold shift + click to select a range
e6e17a7
fix(terminal): recover the write queue from a lost xterm write callback
s-celles 2358d28
feat(terminal): expose allowTransparency as a setting
s-celles f05615f
fix(terminal): hold back only the split escape at a chunk boundary
s-celles 0ff11a9
fix(terminal): keep DEC 2026 frames whole when slicing coalesced output
s-celles 7a16b73
build(terminal): render DEC 2026 frames at full rate via an xterm patch
s-celles 5a6a6d5
perf(terminal): relax output back-pressure for local shells
s-celles b30766c
perf(terminal): drop superseded animation frames to cap latency
s-celles 44a74df
fix(terminal): harden the animation frame gate (review follow-ups)
s-celles c34c6e2
fix(terminal): prove full repaint by cell coverage, and fail open
s-celles 651c447
fix(terminal): frame-gate review follow-ups (ED3, reset flush, split …
s-celles bfca22f
fix(terminal): close remaining fluid-terminal-v2 review findings
binaricat 75ce231
fix(terminal): do not treat ED3/C1 as droppable full repaints
binaricat 91c47c9
fix(terminal): keep DEC 2026 close markers whole; require near-full r…
binaricat 507304d
fix(terminal): harden DEC 2026 mid-close slice and 0.99 repaint bar
binaricat bdc7dbd
fix(terminal): ignore OSC/DCS payloads in repaint coverage
binaricat 7bc6a0d
fix(terminal): flush deferred IPC acks on write-queue watchdog recovery
binaricat dd606f3
fix(terminal): never drop frames that contain line feeds
binaricat fcd6bbf
fix(terminal): keep SGR frames unless successor reestablishes SGR
binaricat a2eaefb
fix(terminal): do not credit ED2 as frame-gate repaint coverage
binaricat efe412a
test(terminal): update ED2 coverage expectations after gate fix
binaricat d2e6999
fix(terminal): scope drop ACKs and lifecycle write dropBytes
binaricat 506b0d3
fix(terminal): exclude SU/SD from droppable frame payloads
binaricat 4dc189c
fix(terminal): flush held DEC 2026 frames before hibernate snapshot
binaricat e8c4956
fix(terminal): ACK frame-gate ingress flushed during hibernate
binaricat 3b9f068
fix(terminal): filter hibernate frame-gate flush like live writes
binaricat 81692b0
fix(terminal): requeue unstarted flood-merge steps on watchdog recovery
binaricat 3690e6e
fix(terminal): recognize C1 CSI forms of DEC 2026 frame markers
binaricat 1628318
fix(terminal): engage frame gate on C1 DEC 2026 openers
binaricat 741a911
fix(terminal): find C1 closers in frameSafeSliceEnd; refine gate flush
binaricat 6e92729
fix(terminal): flush sync-filter pending bytes on hibernate drain
binaricat 6b54464
fix(terminal): require known cursor origin for full-repaint drops
binaricat 8d6c89e
fix(terminal): count repaint coverage only after in-order origin reset
binaricat 775c246
fix(terminal): treat allowTransparency as new-session only
binaricat e837aa1
fix(terminal): do not treat ED2 as a known cursor origin
binaricat 9ba81f4
fix(terminal): reject autowrap-scroll frames; tolerate unmarked xterm…
binaricat f6e359e
fix(terminal): use frame-gate state buffer when draining
binaricat File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,114 @@ | ||
| import assert from "node:assert/strict"; | ||
| import test from "node:test"; | ||
|
|
||
| import { frameSafeSliceEnd, isInsideSyncBlockAt } from "./syncFrameBoundary"; | ||
|
|
||
| const H = "\x1b[?2026h"; | ||
| const L = "\x1b[?2026l"; | ||
|
|
||
| test("isInsideSyncBlockAt: open before close is inside", () => { | ||
| const data = `${H}frame body`; | ||
| assert.equal(isInsideSyncBlockAt(data, 0, data.length), true); | ||
| }); | ||
|
|
||
| test("isInsideSyncBlockAt: closed block is not inside", () => { | ||
| const data = `${H}frame${L}`; | ||
| assert.equal(isInsideSyncBlockAt(data, 0, data.length), false); | ||
| }); | ||
|
|
||
| test("isInsideSyncBlockAt: point between close and next open is not inside", () => { | ||
| const data = `${H}a${L}XX${H}b${L}`; | ||
| const between = `${H}a${L}X`.length; | ||
| assert.equal(isInsideSyncBlockAt(data, 0, between), false); | ||
| }); | ||
|
|
||
| test("isInsideSyncBlockAt: a DECRQM query is not a frame boundary", () => { | ||
| const data = "\x1b[?2026$pplain text"; | ||
| assert.equal(isInsideSyncBlockAt(data, 0, data.length), false); | ||
| }); | ||
|
|
||
| test("isInsideSyncBlockAt: a cut mid-close still counts as inside", () => { | ||
| // startsWith matches whole SYNC_CLOSE in data even when `to` lands inside the | ||
| // marker. The scan must not flip to closed until the full closer is before `to`. | ||
| const frame = `${H}${"x".repeat(20)}${L}`; | ||
| const closeStart = frame.length - L.length; | ||
| for (let mid = 1; mid < L.length; mid++) { | ||
| const to = closeStart + mid; | ||
| assert.equal( | ||
| isInsideSyncBlockAt(frame, 0, to), | ||
| true, | ||
| `mid-close at +${mid} must still be inside the open block`, | ||
| ); | ||
| } | ||
| assert.equal(isInsideSyncBlockAt(frame, 0, frame.length), false); | ||
| }); | ||
|
|
||
| test("frameSafeSliceEnd: a cut inside a frame extends to past its close", () => { | ||
| const frame = `${H}${"x".repeat(100)}${L}`; | ||
| const data = `${frame}${frame}`; | ||
| // Desired cut lands inside the first frame. | ||
| const cut = H.length + 50; | ||
| const end = frameSafeSliceEnd(data, 0, cut); | ||
| assert.equal(end, frame.length, "must extend to the end of the open frame"); | ||
| assert.equal(isInsideSyncBlockAt(data, 0, end), false); | ||
| }); | ||
|
|
||
| test("frameSafeSliceEnd: a cut between frames is left untouched", () => { | ||
| const frame = `${H}${"x".repeat(100)}${L}`; | ||
| const data = `${frame}${frame}`; | ||
| const cut = frame.length; // exactly on the boundary | ||
| assert.equal(frameSafeSliceEnd(data, 0, cut), cut); | ||
| }); | ||
|
|
||
| test("frameSafeSliceEnd: an unterminated frame is held to the end", () => { | ||
| const data = `${H}${"x".repeat(100)}`; // no close | ||
| const cut = H.length + 50; | ||
| assert.equal(frameSafeSliceEnd(data, 0, cut), data.length); | ||
| }); | ||
|
|
||
| test("frameSafeSliceEnd: plain output is never adjusted", () => { | ||
| const data = "just some normal terminal output with no sync blocks"; | ||
| assert.equal(frameSafeSliceEnd(data, 0, 20), 20); | ||
| }); | ||
|
|
||
| test("frameSafeSliceEnd: end at data.length is returned as-is", () => { | ||
| const data = `${H}x${L}`; | ||
| assert.equal(frameSafeSliceEnd(data, 0, data.length), data.length); | ||
| }); | ||
|
|
||
| test("frameSafeSliceEnd: never moves the end backwards", () => { | ||
| const frame = `${H}${"x".repeat(100)}${L}`; | ||
| const data = `${frame}tail`; | ||
| const cut = H.length + 10; | ||
| const end = frameSafeSliceEnd(data, 0, cut); | ||
| assert.ok(end >= cut, "the adjusted end must not precede the desired end"); | ||
| }); | ||
|
|
||
| test("frameSafeSliceEnd: a cut inside the close marker includes the full marker", () => { | ||
| const frame = `${H}${"x".repeat(20)}${L}`; | ||
| const data = `${frame}tail`; | ||
| // Land mid-close: after ESC[?2026 but before the final `l`. | ||
| const closeStart = frame.length - L.length; | ||
| for (let mid = 1; mid < L.length; mid++) { | ||
| const cut = closeStart + mid; | ||
| const end = frameSafeSliceEnd(data, 0, cut); | ||
| assert.equal( | ||
| end, | ||
| frame.length, | ||
| `mid-close cut at +${mid} must extend to full close marker`, | ||
| ); | ||
| assert.equal( | ||
| data.slice(0, end).endsWith(L), | ||
| true, | ||
| "slice must end on a complete close marker", | ||
| ); | ||
| } | ||
| }); | ||
|
|
||
| test("frameSafeSliceEnd: a cut exactly at the close start still completes the frame", () => { | ||
| const frame = `${H}${"x".repeat(20)}${L}`; | ||
| const data = `${frame}next`; | ||
| const closeStart = frame.length - L.length; | ||
| // At the start of the close the block is still open, so extend past it. | ||
| assert.equal(frameSafeSliceEnd(data, 0, closeStart), frame.length); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,131 @@ | ||
| /** | ||
| * DEC 2026 synchronized-output frame boundaries, for slicing terminal output | ||
| * without tearing a frame. | ||
| * | ||
| * A modern full-screen TUI (Tachikoma, and most others) enters the alternate | ||
| * screen once, then delimits every rendered frame with a DEC private mode 2026 | ||
| * synchronized-output block: `\x1b[?2026h` … full frame … `\x1b[?2026l`. xterm | ||
| * buffers rendering while the block is open and paints once on close, so a | ||
| * frame is coherent as long as its whole block reaches xterm before xterm's | ||
| * 1000ms synchronized-output timeout expires (RenderService SyncOutputHandler). | ||
| * | ||
| * The write coalescer/slicer, however, is only aware of alt-screen DECSET | ||
| * toggles — it never sees 2026 — so it slices a continuous frame stream by | ||
| * byte size and hands the shards to xterm across `setTimeout` gaps. A shard | ||
| * boundary that lands inside an open block leaves xterm mid-frame; if the rest | ||
| * of the frame arrives after the 1000ms timeout, xterm force-flushes a partial | ||
| * frame and the display tears. | ||
| * | ||
| * These helpers let the slicer keep every 2026 block whole. | ||
| */ | ||
|
|
||
| const SYNC_OPEN = "\x1b[?2026h"; | ||
| const SYNC_CLOSE = "\x1b[?2026l"; | ||
| /** 8-bit C1 CSI form of the same markers (`CSI` = 0x9B). xterm accepts both. */ | ||
| const SYNC_OPEN_C1 = "\x9b?2026h"; | ||
| const SYNC_CLOSE_C1 = "\x9b?2026l"; | ||
|
|
||
| const indexOfSyncMarkerPrefix = (data: string, from: number): number => { | ||
| const a = data.indexOf("\x1b[?2026", from); | ||
| const b = data.indexOf("\x9b?2026", from); | ||
| if (a === -1) return b; | ||
| if (b === -1) return a; | ||
| return Math.min(a, b); | ||
| }; | ||
|
|
||
| /** | ||
| * Sync-block nesting state at `to`, scanning `data` from `from`. | ||
| * | ||
| * DEC 2026 does not nest in practice (a frame is one open/close pair), so this | ||
| * tracks "open" as a boolean latched by the most recent marker rather than a | ||
| * depth count. Returns whether an open block is still unclosed at `to`. | ||
| * Recognizes both 7-bit ESC CSI and 8-bit C1 CSI forms (Codex P2). | ||
| */ | ||
| export function isInsideSyncBlockAt(data: string, from: number, to: number): boolean { | ||
| let open = false; | ||
| let i = indexOfSyncMarkerPrefix(data, from); | ||
| while (i !== -1 && i < to) { | ||
| if (data.startsWith(SYNC_OPEN, i) || data.startsWith(SYNC_OPEN_C1, i)) { | ||
| const len = data.startsWith(SYNC_OPEN, i) ? SYNC_OPEN.length : SYNC_OPEN_C1.length; | ||
| // Only latch open once the whole opener is before `to`. A cut mid-open | ||
| // leaves the block not yet entered for this scan. | ||
| if (i + len > to) break; | ||
| open = true; | ||
| i += len; | ||
| } else if (data.startsWith(SYNC_CLOSE, i) || data.startsWith(SYNC_CLOSE_C1, i)) { | ||
| const len = data.startsWith(SYNC_CLOSE, i) ? SYNC_CLOSE.length : SYNC_CLOSE_C1.length; | ||
| // Only latch closed once the whole closer is before `to`. `startsWith` | ||
| // matches against full `data`, so a mid-close cut would otherwise see | ||
| // the complete marker and report "outside" while the trailing `l` is | ||
| // still past `to` — splitting the close sequence itself. | ||
| if (i + len > to) break; | ||
| open = false; | ||
| i += len; | ||
| } else { | ||
| // A different `?2026` sequence (e.g. a DECRQM query `\x1b[?2026$p`) — not | ||
| // a frame boundary; step past this ESC/C1 and keep scanning. | ||
| i += 1; | ||
| } | ||
| i = indexOfSyncMarkerPrefix(data, i); | ||
| } | ||
| return open; | ||
| } | ||
|
|
||
| /** | ||
| * If `pos` lands strictly inside a DEC 2026 close marker (`ESC[?2026l`), return | ||
| * the index just past that full marker; otherwise return `pos` unchanged. | ||
| * | ||
| * Defence in depth with {@link isInsideSyncBlockAt}: that helper already keeps | ||
| * a mid-close cut "inside" so the open-block path extends past the closer, but | ||
| * this also catches mid-close cuts if a caller ever uses a looser inside check. | ||
| * Without either fix, a large-write slicer can split `\x1b[?2026` from the | ||
| * trailing `l` and leave xterm stuck in synchronized-output mode. | ||
| */ | ||
| function extendPastCloseMarkerIfSplit( | ||
| data: string, | ||
| offset: number, | ||
| pos: number, | ||
| ): number { | ||
| if (pos <= offset || pos >= data.length) return pos; | ||
| // pos is strictly inside SYNC_CLOSE when some candidateStart < pos and | ||
| // candidateStart + SYNC_CLOSE.length > pos, and data starts with SYNC_CLOSE | ||
| // there. Check each proper prefix length that ends at pos. | ||
| for (const closer of [SYNC_CLOSE, SYNC_CLOSE_C1]) { | ||
| for (let k = 1; k < closer.length; k++) { | ||
| const candidateStart = pos - k; | ||
| if (candidateStart < offset) break; | ||
| if (data.startsWith(closer, candidateStart)) { | ||
| return candidateStart + closer.length; | ||
| } | ||
| } | ||
| } | ||
| return pos; | ||
| } | ||
|
|
||
| /** | ||
| * A slice end at or after `desiredEnd` that never falls strictly inside an open | ||
| * DEC 2026 block, and never splits a close marker. | ||
| * | ||
| * If `desiredEnd` lands inside an open frame, it is pushed forward to just past | ||
| * that frame's `\x1b[?2026l`. If the frame never closes within `data`, the end | ||
| * is pushed to `data.length` so the incomplete frame is held for the next | ||
| * write rather than emitted in pieces. If `desiredEnd` lands mid-close marker | ||
| * after a completed block, the cut is extended to include the full marker. | ||
| * | ||
| * Never moves the end backwards, so it composes with the slicer's other | ||
| * boundary rules (which only ever shrink a slice). | ||
| */ | ||
| export function frameSafeSliceEnd( | ||
| data: string, | ||
| offset: number, | ||
| desiredEnd: number, | ||
| ): number { | ||
| if (desiredEnd >= data.length) return data.length; | ||
| // Never leave a half-written close marker at a slice boundary — even when | ||
| // the block is already considered closed at desiredEnd. | ||
| const end = extendPastCloseMarkerIfSplit(data, offset, desiredEnd); | ||
| if (!isInsideSyncBlockAt(data, offset, end)) return end; | ||
| const close = data.indexOf(SYNC_CLOSE, end); | ||
| if (close === -1) return data.length; | ||
| return close + SYNC_CLOSE.length; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fresh evidence in this revision is that
isInsideSyncBlockAt()now recognizes C1 open/close markers, butframeSafeSliceEnd()still searches only for the 7-bit closer here. For a C1 synchronized frame whose desired cut lands inside the frame, this returnsdata.lengthinstead of the C1 close, sowriteLargeTerminalBatch()sends the entire remaining batch as one slice and loses the cooperative yield/backpressure behavior for long C1 animations; search both closer forms before falling back to the end of the data.Useful? React with 👍 / 👎.