diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 24f2d6628..f2bcd359b 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -255,7 +255,7 @@ ### Changed -- Improved native scrollback history management by introducing an optional erase-and-replay mechanism to rebuild scrollback when mutated rows (such as finalized tool blocks or collapsed transcripts) diverge. This is now gated behind the `tui.scrollbackRebuild` setting and defaults to off. + - Improved native scrollback history management by introducing an optional erase-and-replay mechanism to rebuild scrollback when mutated rows (such as finalized tool blocks or collapsed transcripts) diverge. This is now gated behind the `tui.scrollbackRebuild` setting and defaults to off. ### Fixed @@ -604,7 +604,7 @@ - Added `ctrl+j` as a second default binding for the `tui.input.newLine` action alongside `shift+enter`, so terminals that cannot emit `shift+enter` still have a newline key. On terminals with Kitty-protocol / `modifyOtherKeys` disambiguation `ctrl+j` inserts a newline while `Enter` still submits; on legacy terminals where `ctrl+j` and `Enter` are both byte-identical `LF` it submits (documented limitation). User keybinding overrides still take precedence ([#2473](https://github.com/can1357/oh-my-pi/issues/2473)) - Added an `Editor.onLargePaste(text, lineCount)` hook, fired for a "marker-sized" paste (the point where the editor would otherwise collapse it into a `[Paste #N]` token). Returning `true` lets the host intercept the paste — e.g. to offer wrap-in-code-block / wrap-in-XML / attach-as-file choices — and suppresses the default marker (no undo state is recorded). Added `Editor.insertPaste(content)` so the host can re-insert a (possibly transformed) collapsed paste marker without re-triggering the hook. - Added `Editor.deleteBeforeCursor(count)`, which removes up to `count` characters immediately before the cursor on the current line (capped at the cursor column, single line, records one undo state). Hosts use it to "track back" optimistically-inserted characters — e.g. the coding-agent hold-`Space` push-to-talk gesture deleting the space-bar auto-repeat burst. -- Added an optional `getNativeScrollbackSnapshotSafeEnd()` to the `NativeScrollbackLiveRegion` contract: a _durable_ commit boundary (D ≥ the byte-stable `commitSafeEnd`) for live rows whose current snapshot is permanent content but may still drift bytes later (a streaming markdown table re-aligning its columns). The engine commits these rows when they scroll above the window — never dropping them — but **audit-exempt** (tracked via a new byte-stable `auditRows` prefix), so a later layout change of an already-committed row freezes a stale row in history (duplication never loss) instead of re-anchoring the committed-prefix audit and spraying duplicate snapshots. Components that omit it are unchanged: `durableBoundary === byteStableBoundary` and `auditRows === committedRows`, so the ledger math is byte-identical. +- Added an optional `getNativeScrollbackSnapshotSafeEnd()` to the `NativeScrollbackLiveRegion` contract: a *durable* commit boundary (D ≥ the byte-stable `commitSafeEnd`) for live rows whose current snapshot is permanent content but may still drift bytes later (a streaming markdown table re-aligning its columns). The engine commits these rows when they scroll above the window — never dropping them — but **audit-exempt** (tracked via a new byte-stable `auditRows` prefix), so a later layout change of an already-committed row freezes a stale row in history (duplication never loss) instead of re-anchoring the committed-prefix audit and spraying duplicate snapshots. Components that omit it are unchanged: `durableBoundary === byteStableBoundary` and `auditRows === committedRows`, so the ledger math is byte-identical. ### Fixed @@ -845,7 +845,7 @@ ### Fixed -- Fixed Windows ConPTY session-resume painting the transcript with the last several rows truncated below the viewport until Alt+Tab forced a host repaint. After `sessionReplace`/`historyRebuild`/`overlayRebuild` paints that scroll-push content into native scrollback, the renderer now arms a 150 ms ConPTY settle window that coalesces spinner/blink-driven `requestRender(false)` calls into a single trailing render — Windows Terminal's viewport-follow logic no longer falls further behind the cursor on every tick of the post-paint storm. The arm also reclaims any render request queued _during_ the in-flight composition (notably `ImageBudget.endPass()` calling `requestRender()` synchronously when a frame trips the live-graphics cap): without that, the queued request sat on the standard 30 Hz throttle and fired at ~33 ms — well inside the 150 ms quiet window — defeating the coalescing. Bumped the ConPTY per-`WriteFile` chunk cap from 8 KiB to 16 KiB so a multi-megabyte resume paint emits half as many writes (still well under the ~32 KiB threshold from #2034 that the original cap defends against), and made the cap measure encoded UTF-8 bytes instead of JS code units so a CJK-heavy transcript can't silently inflate a 16-KiB-of-code-units chunk into ~48 KiB of `WriteFile` traffic and reintroduce the #2034 viewport bug ([#2095](https://github.com/can1357/oh-my-pi/issues/2095)). +- Fixed Windows ConPTY session-resume painting the transcript with the last several rows truncated below the viewport until Alt+Tab forced a host repaint. After `sessionReplace`/`historyRebuild`/`overlayRebuild` paints that scroll-push content into native scrollback, the renderer now arms a 150 ms ConPTY settle window that coalesces spinner/blink-driven `requestRender(false)` calls into a single trailing render — Windows Terminal's viewport-follow logic no longer falls further behind the cursor on every tick of the post-paint storm. The arm also reclaims any render request queued *during* the in-flight composition (notably `ImageBudget.endPass()` calling `requestRender()` synchronously when a frame trips the live-graphics cap): without that, the queued request sat on the standard 30 Hz throttle and fired at ~33 ms — well inside the 150 ms quiet window — defeating the coalescing. Bumped the ConPTY per-`WriteFile` chunk cap from 8 KiB to 16 KiB so a multi-megabyte resume paint emits half as many writes (still well under the ~32 KiB threshold from #2034 that the original cap defends against), and made the cap measure encoded UTF-8 bytes instead of JS code units so a CJK-heavy transcript can't silently inflate a 16-KiB-of-code-units chunk into ~48 KiB of `WriteFile` traffic and reintroduce the #2034 viewport bug ([#2095](https://github.com/can1357/oh-my-pi/issues/2095)). ## [15.10.3] - 2026-06-08 @@ -1088,7 +1088,7 @@ ### Fixed -- Fixed native Windows + Windows Terminal freezing the editor on the wrap keystroke, on `/plan`/`/resume`/model-switch/status-line toggles, and on any other offscreen structural mutation until the next prompt submit. The `15.7.5` `#1635` fix routed every viewport-saturating pure-append and structural mutation through `deferredMutation` (a literal no-op) whenever `isNativeViewportAtBottom()` returned `undefined` — which it always does under `WT_SESSION` because the kernel32 probe can't see WT host scrollback. The deferral was only ever meant for the _confirmed-scrolled_ case; an unknown viewport now falls back to a non-destructive `viewportRepaint` instead, so the live UI keeps updating without emitting `\x1b[3J` and without yanking a possibly-scrolled reader. Confirmed-scrolled frames (probe returns `false`) still defer. +- Fixed native Windows + Windows Terminal freezing the editor on the wrap keystroke, on `/plan`/`/resume`/model-switch/status-line toggles, and on any other offscreen structural mutation until the next prompt submit. The `15.7.5` `#1635` fix routed every viewport-saturating pure-append and structural mutation through `deferredMutation` (a literal no-op) whenever `isNativeViewportAtBottom()` returned `undefined` — which it always does under `WT_SESSION` because the kernel32 probe can't see WT host scrollback. The deferral was only ever meant for the *confirmed-scrolled* case; an unknown viewport now falls back to a non-destructive `viewportRepaint` instead, so the live UI keeps updating without emitting `\x1b[3J` and without yanking a possibly-scrolled reader. Confirmed-scrolled frames (probe returns `false`) still defer. - Removed the hard-coded 20-result cap on `@`-prefixed fuzzy file completion in `CombinedAutocompleteProvider.#getFuzzyFileSuggestions`. The dropdown now honors the existing `maxResults: 100` ceiling already configured for `fuzzyFind`, so projects with many files sharing a common stem (e.g. `@controller`, `@test`) surface all relevant matches instead of being silently truncated. ([#1652](https://github.com/can1357/oh-my-pi/issues/1652)) ## [15.7.5] - 2026-06-01 diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 55f91d547..b4bcdf376 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -559,11 +559,7 @@ export class Container component: Component; childBoundary: unknown; sourceIndex: number; - leading: ReadonlyArray<{ - component: Component; - revision: number | undefined; - rowCount: number; - }>; + leading: ReadonlyArray<{ component: Component; revision: number | undefined; rowCount: number }>; trailing: ReadonlyArray<{ component: Component; revision: number | undefined; @@ -1429,16 +1425,8 @@ export class TUI extends Container { component: Component; childBoundary: unknown; sourceIndex: number; - leading: ReadonlyArray<{ - component: Component; - revision: number | undefined; - rowCount: number; - }>; - trailing: ReadonlyArray<{ - component: Component; - revision: number | undefined; - rowCount: number; - }>; + leading: ReadonlyArray<{ component: Component; revision: number | undefined; rowCount: number }>; + trailing: ReadonlyArray<{ component: Component; revision: number | undefined; rowCount: number }>; hasTrailingRows: boolean; } >(); @@ -1942,12 +1930,7 @@ export class TUI extends Container { */ showOverlay(component: Component, options?: OverlayOptions): OverlayHandle { component.setIgnoreTight?.(true); - const entry = { - component, - options, - preFocus: this.#focusedComponent, - hidden: false, - }; + const entry = { component, options, preFocus: this.#focusedComponent, hidden: false }; this.overlayStack.push(entry); // Only focus if overlay is actually visible if (this.#isOverlayVisible(entry)) { @@ -2137,9 +2120,7 @@ export class TUI extends Container { this.#recordHardwareCursorHidden(); this.#querySixelSupport(); this.#queryCellSize(); - this.requestRender(true, { - clearScrollback: options?.clearScrollback === true, - }); + this.requestRender(true, { clearScrollback: options?.clearScrollback === true }); } addStartListener(listener: StartListener): () => void { @@ -2404,10 +2385,7 @@ export class TUI extends Container { // the same `#prepareForcedRender(!isMultiplexerSession())` path via // `requestRender(true)`, so the clear-scrollback intent is preserved. if (this.#multiplexerResizeTimer) { - this.#armMultiplexerResizeTimer({ - clearScrollback: !isMultiplexerSession(), - hasPendingRender: true, - }); + this.#armMultiplexerResizeTimer({ clearScrollback: !isMultiplexerSession(), hasPendingRender: true }); return; } this.#prepareForcedRender(!isMultiplexerSession()); @@ -3017,12 +2995,7 @@ export class TUI extends Container { // Parse margin (clamp to non-negative) const margin = typeof opt.margin === "number" - ? { - top: opt.margin, - right: opt.margin, - bottom: opt.margin, - left: opt.margin, - } + ? { top: opt.margin, right: opt.margin, bottom: opt.margin, left: opt.margin } : (opt.margin ?? {}); const marginTop = Math.max(0, margin.top ?? 0); const marginRight = Math.max(0, margin.right ?? 0); @@ -3852,10 +3825,7 @@ export class TUI extends Container { window = this.#compositeOverlaysIntoWindow(window, width, height); const overlayMarkers = this.#extractCursorMarkers(window); if (overlayMarkers.length > 0) { - cursorPos = { - row: windowTop + overlayMarkers[0]!.row, - col: overlayMarkers[0]!.col, - }; + cursorPos = { row: windowTop + overlayMarkers[0]!.row, col: overlayMarkers[0]!.col }; } window = this.#prepareLinesArray(window, width); } @@ -4447,10 +4417,7 @@ export class TUI extends Container { this.#hardwareCursorVisible = false; this.#hardwareCursorVisibilityKnown = true; if (!this.#hardwareCursorState) return; - this.#hardwareCursorState = { - ...this.#hardwareCursorState, - visible: false, - }; + this.#hardwareCursorState = { ...this.#hardwareCursorState, visible: false }; } #forgetHardwareCursorState(): void { @@ -4625,10 +4592,7 @@ export class TUI extends Container { if (cursorPos.row < chunkTo) { paintCursorPos = cursorPos; } else if (cursorPos.row >= windowTop && cursorPos.row < windowTop + height) { - paintCursorPos = { - row: chunkTo + cursorPos.row - windowTop, - col: cursorPos.col, - }; + paintCursorPos = { row: chunkTo + cursorPos.row - windowTop, col: cursorPos.col }; } } // ConPTY hosts bound bulk transcript-replacement replays (resume, handoff, @@ -4890,11 +4854,7 @@ export class TUI extends Container { const framed: string[] = new Array(extra + height); for (let k = 0; k < extra; k++) framed[k] = tail[tail.length - 1 - k]!; for (let screenRow = 0; screenRow < height; screenRow++) framed[extra + screenRow] = window[screenRow]!; - return { - framed: this.#prepareLinesArray(framed, width), - viewportTop: extra, - contentRows, - }; + return { framed: this.#prepareLinesArray(framed, width), viewportTop: extra, contentRows }; } /** @@ -5325,13 +5285,7 @@ export class TUI extends Container { // No IME target or no content — hide cursor regardless of preference. const target = this.#targetHardwareCursorState(cursorPos, totalLines); if (!target) { - return { - seq: "\x1b[?25l", - toRow: fromRow, - toCol: 0, - visible: false, - state: null, - }; + return { seq: "\x1b[?25l", toRow: fromRow, toCol: 0, visible: false, state: null }; } // Move cursor from current position to target. @@ -5346,13 +5300,7 @@ export class TUI extends Container { seq += `\x1b[${target.col + 1}G`; seq += target.visible ? "\x1b[?25h" : "\x1b[?25l"; - return { - seq, - toRow: target.row, - toCol: target.col, - visible: target.visible, - state: target, - }; + return { seq, toRow: target.row, toCol: target.col, visible: target.visible, state: target }; } #isHiddenCursorKnown(): boolean { diff --git a/packages/tui/test/issue-2088-repro.test.ts b/packages/tui/test/issue-2088-repro.test.ts index 3604ccfd3..040223c75 100644 --- a/packages/tui/test/issue-2088-repro.test.ts +++ b/packages/tui/test/issue-2088-repro.test.ts @@ -414,11 +414,7 @@ class ManualRenderScheduler implements RenderScheduler { } scheduleRender(callback: () => void, delayMs: number): RenderTimer { - const timer = { - at: this.#now + Math.max(0, delayMs), - callback, - canceled: false, - }; + const timer = { at: this.#now + Math.max(0, delayMs), callback, canceled: false }; this.#timers.push(timer); return { cancel: () => { @@ -523,27 +519,10 @@ const MULTIPLEXER_ENV_KEYS = [ const NO_MULTIPLEXER_ENV: Record = Object.fromEntries( MULTIPLEXER_ENV_KEYS.map(key => [key, undefined]), ); -const TMUX_ENV: Record = { - ...NO_MULTIPLEXER_ENV, - TMUX: "1", -}; +const TMUX_ENV: Record = { ...NO_MULTIPLEXER_ENV, TMUX: "1" }; const MULTIPLEXER_ENV_CASES: Array<[string, Record]> = [ - [ - "CMUX_WORKSPACE_ID", - { - ...NO_MULTIPLEXER_ENV, - TERM: "dumb", - CMUX_WORKSPACE_ID: "workspace:cmux-2088", - }, - ], - [ - "CMUX_SURFACE_ID", - { - ...NO_MULTIPLEXER_ENV, - TERM: "dumb", - CMUX_SURFACE_ID: "surface:cmux-2088", - }, - ], + ["CMUX_WORKSPACE_ID", { ...NO_MULTIPLEXER_ENV, TERM: "dumb", CMUX_WORKSPACE_ID: "workspace:cmux-2088" }], + ["CMUX_SURFACE_ID", { ...NO_MULTIPLEXER_ENV, TERM: "dumb", CMUX_SURFACE_ID: "surface:cmux-2088" }], ]; const CMUX_SOCKET_ONLY_ENV: Record = { ...NO_MULTIPLEXER_ENV, diff --git a/packages/tui/test/render-stress-harness.ts b/packages/tui/test/render-stress-harness.ts index 07280d2fc..508357d3b 100644 --- a/packages/tui/test/render-stress-harness.ts +++ b/packages/tui/test/render-stress-harness.ts @@ -369,11 +369,7 @@ function contentOperation( checksRowAccounting: boolean, overrides: AppliedOperationOverrides = {}, ): AppliedOperation { - return appliedOperation(kind, detail, { - mutatesContent: true, - checksRowAccounting, - ...overrides, - }); + return appliedOperation(kind, detail, { mutatesContent: true, checksRowAccounting, ...overrides }); } function viewOperation( @@ -418,78 +414,18 @@ interface BurstStepMetadata { } const BURST_STEP_METADATA = { - appendSmall: { - mutatesContent: true, - geometryChanged: false, - forcedRender: false, - mutatesViewport: false, - }, - streamOne: { - mutatesContent: true, - geometryChanged: false, - forcedRender: false, - mutatesViewport: false, - }, - appendRepeatedTail: { - mutatesContent: true, - geometryChanged: false, - forcedRender: false, - mutatesViewport: false, - }, - injectBlankCluster: { - mutatesContent: true, - geometryChanged: false, - forcedRender: false, - mutatesViewport: false, - }, - editVisibleLine: { - mutatesContent: true, - geometryChanged: false, - forcedRender: false, - mutatesViewport: false, - }, - editOffscreenLine: { - mutatesContent: true, - geometryChanged: false, - forcedRender: false, - mutatesViewport: false, - }, - tickStatusHeader: { - mutatesContent: true, - geometryChanged: false, - forcedRender: false, - mutatesViewport: false, - }, - resizeWidth: { - mutatesContent: false, - geometryChanged: true, - forcedRender: false, - mutatesViewport: true, - }, - resizeHeight: { - mutatesContent: false, - geometryChanged: true, - forcedRender: false, - mutatesViewport: true, - }, - scrollPartial: { - mutatesContent: false, - geometryChanged: false, - forcedRender: false, - mutatesViewport: true, - }, - scrollToBottom: { - mutatesContent: false, - geometryChanged: false, - forcedRender: false, - mutatesViewport: true, - }, - forceRender: { - mutatesContent: false, - geometryChanged: false, - forcedRender: true, - mutatesViewport: true, - }, + appendSmall: { mutatesContent: true, geometryChanged: false, forcedRender: false, mutatesViewport: false }, + streamOne: { mutatesContent: true, geometryChanged: false, forcedRender: false, mutatesViewport: false }, + appendRepeatedTail: { mutatesContent: true, geometryChanged: false, forcedRender: false, mutatesViewport: false }, + injectBlankCluster: { mutatesContent: true, geometryChanged: false, forcedRender: false, mutatesViewport: false }, + editVisibleLine: { mutatesContent: true, geometryChanged: false, forcedRender: false, mutatesViewport: false }, + editOffscreenLine: { mutatesContent: true, geometryChanged: false, forcedRender: false, mutatesViewport: false }, + tickStatusHeader: { mutatesContent: true, geometryChanged: false, forcedRender: false, mutatesViewport: false }, + resizeWidth: { mutatesContent: false, geometryChanged: true, forcedRender: false, mutatesViewport: true }, + resizeHeight: { mutatesContent: false, geometryChanged: true, forcedRender: false, mutatesViewport: true }, + scrollPartial: { mutatesContent: false, geometryChanged: false, forcedRender: false, mutatesViewport: true }, + scrollToBottom: { mutatesContent: false, geometryChanged: false, forcedRender: false, mutatesViewport: true }, + forceRender: { mutatesContent: false, geometryChanged: false, forcedRender: true, mutatesViewport: true }, } satisfies Record; class UnknownViewportTerminal extends VirtualTerminal { @@ -854,11 +790,7 @@ class StressModel { const offscreenLimit = Math.max(1, this.lines.length - height - count); const index = this.#rng.int(1, Math.max(1, offscreenLimit)); const removed = this.lines.splice(index, count); - return { - index, - count: removed.length, - firstRemoved: removed[0]?.text ?? null, - }; + return { index, count: removed.length, firstRemoved: removed[0]?.text ?? null }; } replaceAll(): JsonObject { @@ -907,17 +839,11 @@ class StressModel { rotateUp(): JsonObject { if (this.lines.length < 2) { this.lines.push(this.#freshLine("t")); - return { - dropped: null, - appended: this.lines[this.lines.length - 1]?.text ?? "", - }; + return { dropped: null, appended: this.lines[this.lines.length - 1]?.text ?? "" }; } const dropped = this.lines.shift(); this.lines.push(this.#randomLine("t")); - return { - dropped: dropped?.text ?? null, - appended: this.lines[this.lines.length - 1]?.text ?? "", - }; + return { dropped: dropped?.text ?? null, appended: this.lines[this.lines.length - 1]?.text ?? "" }; } collapseToFew(): JsonObject { @@ -955,13 +881,7 @@ class StressModel { const editedIndex = this.lines.length - 1; const before = this.lines[editedIndex]?.text ?? ""; this.lines[editedIndex] = this.#freshLine("done"); - return { - start, - count: removed.length, - editedIndex, - before, - after: this.lines[editedIndex]?.text ?? "", - }; + return { start, count: removed.length, editedIndex, before, after: this.lines[editedIndex]?.text ?? "" }; } swapOffscreenRows(height: number): JsonObject { @@ -1126,12 +1046,7 @@ class StressOverlayModel { const index = this.#rng.int(0, this.lines.length - 1); const before = this.lines[index]?.text ?? ""; this.lines[index] = this.#freshLine("oe"); - return { - action: "edit", - index, - before, - after: this.lines[index]?.text ?? "", - }; + return { action: "edit", index, before, after: this.lines[index]?.text ?? "" }; } if (action === 2) { const index = this.#rng.int(0, this.lines.length - 1); @@ -1268,12 +1183,7 @@ class StressDriver { scenario.uniqueContent, `child${id}-`, ); - return { - id, - model, - component: new StressComponent(model, scenario.reflow), - active: false, - }; + return { id, model, component: new StressComponent(model, scenario.reflow), active: false }; }); this.#term = createTerminal(scenario); // Capture every byte written to the terminal so per-op oracles can audit @@ -1494,18 +1404,9 @@ class StressDriver { const weighted: readonly WeightedCandidate[] = [ { item: "appendSmall", weight: 14 }, { item: "streamOne", weight: 12 }, - { - item: "appendExactWidth", - weight: this.#scenario.uniqueContent ? 0 : 5, - }, - { - item: "appendRepeatedTail", - weight: this.#scenario.uniqueContent ? 2 : 8, - }, - { - item: "appendDuplicateOfExisting", - weight: this.#scenario.uniqueContent ? 2 : 8, - }, + { item: "appendExactWidth", weight: this.#scenario.uniqueContent ? 0 : 5 }, + { item: "appendRepeatedTail", weight: this.#scenario.uniqueContent ? 2 : 8 }, + { item: "appendDuplicateOfExisting", weight: this.#scenario.uniqueContent ? 2 : 8 }, { item: "injectBlankCluster", weight: 5 }, { item: "appendBulk", weight: 3 }, { item: "editVisibleLine", weight: 8 }, @@ -1532,10 +1433,7 @@ class StressDriver { { item: "moveCursorOffscreen", weight: 2 }, { item: "showOverlay", weight: this.#overlays.length < 2 ? 3 : 1 }, { item: "hideOverlay", weight: this.#overlays.length > 0 ? 2 : 0 }, - { - item: "toggleOverlayHidden", - weight: this.#overlays.length > 0 ? 2 : 0, - }, + { item: "toggleOverlayHidden", weight: this.#overlays.length > 0 ? 2 : 0 }, { item: "editOverlay", weight: this.#overlays.length > 0 ? 4 : 0 }, { item: "moveOverlayCursor", weight: this.#overlays.length > 0 ? 2 : 0 }, { item: "coalescedBurst", weight: 6 }, @@ -1553,22 +1451,10 @@ class StressDriver { { item: "resizeBoth", weight: 2 }, { item: "resizeNoop", weight: 1 }, { item: "resizeWithAppend", weight: 2 }, - { - item: "attachChild", - weight: this.#children.some(child => !child.active) ? 2 : 0, - }, - { - item: "detachChild", - weight: this.#children.some(child => child.active) ? 2 : 0, - }, - { - item: "reorderChildren", - weight: this.#children.filter(child => child.active).length > 1 ? 1 : 0, - }, - { - item: "mutateChild", - weight: this.#children.some(child => child.active) ? 3 : 0, - }, + { item: "attachChild", weight: this.#children.some(child => !child.active) ? 2 : 0 }, + { item: "detachChild", weight: this.#children.some(child => child.active) ? 2 : 0 }, + { item: "reorderChildren", weight: this.#children.filter(child => child.active).length > 1 ? 1 : 0 }, + { item: "mutateChild", weight: this.#children.some(child => child.active) ? 3 : 0 }, ]; return weightedPick(this.#streams.ops, weighted); } @@ -1868,10 +1754,7 @@ class StressDriver { this.#overlays = this.#overlays.filter(overlay => overlay !== entry); this.#hiddenOverlaySentinels.add(entry.sentinel); await this.#settle(); - return this.#viewOperation("hideOverlay", { - id: entry.id, - sentinel: entry.sentinel, - }); + return this.#viewOperation("hideOverlay", { id: entry.id, sentinel: entry.sentinel }); } async #toggleOverlayHidden(): Promise { @@ -1991,10 +1874,7 @@ class StressDriver { async #resizeNoop(): Promise { this.#term.resize(this.#term.columns, this.#term.rows); await this.#settle(); - return viewOperation("resizeNoop", { - columns: this.#term.columns, - rows: this.#term.rows, - }); + return viewOperation("resizeNoop", { columns: this.#term.columns, rows: this.#term.rows }); } async #scrollUp(): Promise { @@ -2006,9 +1886,7 @@ class StressDriver { async #scrollToBottom(): Promise { this.#term.scrollLines(LARGE_SCROLL); - this.#tui.requestRender(true, { - clearScrollback: this.#traits.strictNativeScrollback, - }); + this.#tui.requestRender(true, { clearScrollback: this.#traits.strictNativeScrollback }); await this.#settle(); return forceRenderOperation( "scrollToBottom", @@ -2079,12 +1957,7 @@ class StressDriver { this.#term.scrollLines(LARGE_SCROLL); this.#tui.requestRender(true, { clearScrollback: true }); await this.#settle(); - return { - ...this.#forceOperation("forceRenderClearScrollback", { - clearScrollback: true, - }), - checkpoint: true, - }; + return { ...this.#forceOperation("forceRenderClearScrollback", { clearScrollback: true }), checkpoint: true }; } async #forceRenderAfterEmptyOverflow(): Promise { @@ -2107,11 +1980,7 @@ class StressDriver { this.#tui.requestRender(true); await this.#settle(); return { - ...this.#forceOperation("forceRenderAfterEmptyOverflow", { - detachedChildren, - empty, - overflow, - }), + ...this.#forceOperation("forceRenderAfterEmptyOverflow", { detachedChildren, empty, overflow }), mutatesContent: true, // In multiplexers everything written during this op scrolls into pane // history on top of whatever was already there. @@ -2137,10 +2006,7 @@ class StressDriver { } this.#tui.requestRender(); await this.#settle(); - return viewOperation("toggleFocusInput", { - focused: this.#component.focused, - cursor, - }); + return viewOperation("toggleFocusInput", { focused: this.#component.focused, cursor }); } // Container.addChild appends and Container.render walks children in array @@ -2189,9 +2055,7 @@ class StressDriver { await this.#settle(); return contentOperation( "reorderChildren", - { - activeOrder: this.#children.filter(child => child.active).map(child => child.id), - }, + { activeOrder: this.#children.filter(child => child.active).map(child => child.id) }, false, ); } @@ -2548,9 +2412,7 @@ class StressDriver { #assertCursor(op: AppliedOperation, before: Snapshot, after: Snapshot, index: number): void { if (this.#hasVisibleOverlay()) return; if (after.cursor.row < 0 || after.cursor.row >= after.height || after.cursor.col < 0) { - this.#fail("cursor bounds", op, before, after, index, { - cursor: cursorObject(after), - }); + this.#fail("cursor bounds", op, before, after, index, { cursor: cursorObject(after) }); } const expectedCursor = after.expectedCursor; if (expectedCursor === null || !after.atBottom) return; @@ -2900,9 +2762,7 @@ class StressDriver { for (const sentinel of this.#hiddenOverlaySentinels) { if (visibleSentinels.has(sentinel)) continue; if (nativeText.includes(sentinel)) { - this.#fail("stale overlay sentinel", op, before, after, index, { - sentinel, - }); + this.#fail("stale overlay sentinel", op, before, after, index, { sentinel }); } } } @@ -3204,18 +3064,11 @@ export function expectedFrameFromLines(lines: readonly string[], width: number, const cleanLine = markerIndex === -1 ? line : removeCursorMarkers(line); backgroundColumns[row] = expectedBackgroundColumns(cleanLine, width); if (markerIndex !== -1 && cursor === null && row >= viewportTop) { - cursor = { - row: row - viewportTop, - col: visibleWidth(line.slice(0, markerIndex)), - }; + cursor = { row: row - viewportTop, col: visibleWidth(line.slice(0, markerIndex)) }; } stripped[row] = cleanLine; } - return { - frame: stripped.map(line => expectedTerminalLine(line, width)), - cursor, - backgroundColumns, - }; + return { frame: stripped.map(line => expectedTerminalLine(line, width)), cursor, backgroundColumns }; } function expectedBackgroundColumns(line: string, width: number): number[] { @@ -3282,12 +3135,7 @@ function compositeExpectedOverlays( ): string[] { if (overlays.length === 0) return [...lines]; const result = [...lines]; - const rendered: { - overlayLines: string[]; - row: number; - col: number; - w: number; - }[] = []; + const rendered: { overlayLines: string[]; row: number; col: number; w: number }[] = []; let minLinesNeeded = result.length; for (const entry of overlays) { if (!isExpectedOverlayVisible(entry, termWidth, termHeight)) continue; @@ -3297,12 +3145,7 @@ function compositeExpectedOverlays( overlayLines = overlayLines.slice(0, firstLayout.maxHeight); } const layout = resolveExpectedOverlayLayout(entry.options, overlayLines.length, termWidth, termHeight); - rendered.push({ - overlayLines, - row: layout.row, - col: layout.col, - w: layout.width, - }); + rendered.push({ overlayLines, row: layout.row, col: layout.col, w: layout.width }); minLinesNeeded = Math.max(minLinesNeeded, layout.row + overlayLines.length); } const workingHeight = Math.max(result.length, minLinesNeeded); @@ -3337,12 +3180,7 @@ export function resolveExpectedOverlayLayout( const opt = options ?? {}; const margin = typeof opt.margin === "number" - ? { - top: opt.margin, - right: opt.margin, - bottom: opt.margin, - left: opt.margin, - } + ? { top: opt.margin, right: opt.margin, bottom: opt.margin, left: opt.margin } : (opt.margin ?? {}); const marginTop = Math.max(0, margin.top ?? 0); const marginRight = Math.max(0, margin.right ?? 0); @@ -3572,18 +3410,12 @@ function snapshotDump(snapshot: Snapshot): JsonObject { view: snapshot.view, viewBackgroundColumns: snapshot.viewBackgroundColumns, frameBackgroundColumns: snapshot.frameBackgroundColumns, - position: { - baseY: snapshot.position.baseY, - viewportY: snapshot.position.viewportY, - }, + position: { baseY: snapshot.position.baseY, viewportY: snapshot.position.viewportY }, cursor: cursorObject(snapshot), expectedCursor: snapshot.expectedCursor === null ? null - : { - row: snapshot.expectedCursor.row, - col: snapshot.expectedCursor.col, - }, + : { row: snapshot.expectedCursor.row, col: snapshot.expectedCursor.col }, redraws: snapshot.redraws, width: snapshot.width, height: snapshot.height, @@ -3597,18 +3429,12 @@ function snapshotSummary(snapshot: Snapshot): JsonObject { bufferLength: snapshot.buffer.length, view: snapshot.view, viewBackgroundColumns: snapshot.viewBackgroundColumns, - position: { - baseY: snapshot.position.baseY, - viewportY: snapshot.position.viewportY, - }, + position: { baseY: snapshot.position.baseY, viewportY: snapshot.position.viewportY }, cursor: cursorObject(snapshot), expectedCursor: snapshot.expectedCursor === null ? null - : { - row: snapshot.expectedCursor.row, - col: snapshot.expectedCursor.col, - }, + : { row: snapshot.expectedCursor.row, col: snapshot.expectedCursor.col }, redraws: snapshot.redraws, width: snapshot.width, height: snapshot.height, @@ -3794,9 +3620,7 @@ function parseReplayOperations(): readonly OperationKind[] | null { try { parsed = JSON.parse(fs.readFileSync(path, "utf8")); } catch (error) { - throw new Error(`Invalid TUI_STRESS_REPLAY_LOG JSON: ${path}`, { - cause: error, - }); + throw new Error(`Invalid TUI_STRESS_REPLAY_LOG JSON: ${path}`, { cause: error }); } const entries = Array.isArray(parsed) ? parsed @@ -3932,9 +3756,9 @@ function coreTemplates(): ScenarioTemplate[] { heightChoices: [3, 4, 6], }, { - // Direct HerdR follows the in-place multiplexer resize policy. - // Streaming updates may race the resize but must survive the settled - // repaint exactly once. + // Direct HerdR implements ED3, so a settled width change clears and + // replays the source-owned transcript at its new wrap. Streaming + // updates may race the resize but must survive that replay exactly once. name: "darwin-normal-herdr-reflow-stream-small", platform: "darwin", terminalMode: "normal", @@ -4105,10 +3929,7 @@ function coreTemplates(): ScenarioTemplate[] { function soakTemplates(): ScenarioTemplate[] { const templates: ScenarioTemplate[] = []; - const platformEnvModes: readonly { - platform: TestPlatform; - envModes: readonly EnvMode[]; - }[] = [ + const platformEnvModes: readonly { platform: TestPlatform; envModes: readonly EnvMode[] }[] = [ { platform: "darwin", envModes: ["plain", "tmux", "herdr"] }, { platform: "linux", envModes: ["plain", "tmux", "termux", "vteNoSync"] }, { platform: "win32", envModes: ["plain"] }, @@ -4273,10 +4094,7 @@ async function withPatchedPlatform(platform: Scenario["platform"], run: () => if (platformPatchDepth > 0) throw new Error("Nested stress platform patching is not supported"); platformPatchDepth += 1; const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); - Object.defineProperty(process, "platform", { - configurable: true, - value: platform, - }); + Object.defineProperty(process, "platform", { configurable: true, value: platform }); try { return await run(); } finally { @@ -4325,11 +4143,7 @@ export async function runStressScenario(scenario: Scenario, options?: { patchEnv export async function runWidthEpochOverlayReplayRegression(): Promise { const base = coreTemplates().find(candidate => candidate.name === "darwin-normal-herdr-reflow-stream-small"); if (base === undefined) throw new Error("Missing reflow-stream stress template"); - const template: ScenarioTemplate = { - ...base, - name: "darwin-normal-tmux-reflow-stream-small", - envMode: "tmux", - }; + const template: ScenarioTemplate = { ...base, name: "darwin-normal-tmux-reflow-stream-small", envMode: "tmux" }; const operations: readonly OperationKind[] = ["resizeWidth", "showOverlay", "streamOne", "streamOne", "hideOverlay"]; const scenario = materializeScenario( template, @@ -4346,11 +4160,7 @@ export async function runWidthEpochOverlayReplayRegression(): Promise { export async function runWidthEpochHeightAppendReplayRegression(): Promise { const source = coreTemplates().find(candidate => candidate.name === "darwin-normal-herdr-reflow-stream-small"); if (source === undefined) throw new Error("Missing reflow-stream stress template"); - const base: ScenarioTemplate = { - ...source, - name: "darwin-normal-tmux-reflow-stream-small", - envMode: "tmux", - }; + const base: ScenarioTemplate = { ...source, name: "darwin-normal-tmux-reflow-stream-small", envMode: "tmux" }; const template: ScenarioTemplate = { ...base, columns: 40, @@ -4394,38 +4204,14 @@ export async function runNoReflowResizeNotificationRegression(): Promise { const processKill = Object.getOwnPropertyDescriptor(process, "kill"); const writes: string[] = []; - Object.defineProperty(process.stdin, "isTTY", { - value: true, - configurable: true, - }); - Object.defineProperty(process.stdout, "isTTY", { - value: true, - configurable: true, - }); - Object.defineProperty(process.stdout, "columns", { - value: 100, - configurable: true, - }); - Object.defineProperty(process.stdout, "rows", { - value: 30, - configurable: true, - }); - Object.defineProperty(process.stdin, "setRawMode", { - value: () => process.stdin, - configurable: true, - }); - Object.defineProperty(process.stdin, "setEncoding", { - value: () => process.stdin, - configurable: true, - }); - Object.defineProperty(process.stdin, "resume", { - value: () => process.stdin, - configurable: true, - }); - Object.defineProperty(process.stdin, "pause", { - value: () => process.stdin, - configurable: true, - }); + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); + Object.defineProperty(process.stdout, "columns", { value: 100, configurable: true }); + Object.defineProperty(process.stdout, "rows", { value: 30, configurable: true }); + Object.defineProperty(process.stdin, "setRawMode", { value: () => process.stdin, configurable: true }); + Object.defineProperty(process.stdin, "setEncoding", { value: () => process.stdin, configurable: true }); + Object.defineProperty(process.stdin, "resume", { value: () => process.stdin, configurable: true }); + Object.defineProperty(process.stdin, "pause", { value: () => process.stdin, configurable: true }); Object.defineProperty(process.stdout, "write", { value: (chunk: string | Uint8Array) => { writes.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString()); @@ -4433,10 +4219,7 @@ export async function runNoReflowResizeNotificationRegression(): Promise { }, configurable: true, }); - Object.defineProperty(process, "kill", { - value: () => true, - configurable: true, - }); + Object.defineProperty(process, "kill", { value: () => true, configurable: true }); // Exercises the real ProcessTerminal stdin/stdout pipeline; opt out of // the test-default headless suppression inside the try so the finally