From 21d2c2271a4f84a11467568c8d2ccc71667d1f73 Mon Sep 17 00:00:00 2001 From: can1357 Date: Sat, 4 Jul 2026 05:12:18 +0200 Subject: [PATCH] Revert "fix(tui): merged scrollback offer boundary repair" This reverts commit ae89b3ff087feb44dddf4826772e0838d39cb128, reversing changes made to 227874dcc60faaead6b38f76082b6e843466344c. --- packages/coding-agent/CHANGELOG.md | 6 + .../modes/components/transcript-container.ts | 26 ---- .../transcript-streaming-commit-repro.test.ts | 55 -------- packages/tui/CHANGELOG.md | 1 + packages/tui/src/tui.ts | 129 ++++-------------- .../test/streaming-scrollback-defer.test.ts | 94 ------------- 6 files changed, 36 insertions(+), 275 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 9eadd1311..8c5cb2a2f 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -51,6 +51,12 @@ - Fixed models.yml schema validation to surface warnings for invalid custom provider configurations instead of silently ignoring them - Fixed potential network hangs in omp update, Hindsight recall, and Smithery registry lookups by adding fetch timeouts - Reduced TUI CPU overhead during streaming and idle waits by dropping the redundant pre-render on every session event, iterating shimmer text in place instead of allocating a code-point array per animation frame, and coalescing the live-tool spinner render cadence to its glyph-advance rate ([#4353](https://github.com/can1357/oh-my-pi/issues/4353)). +- Improved reliability of edits when file snapshots share identical 16-bit hash tags +- Fixed ACP `terminal/create` sending the bash tool's full shell line in `command` with no `args`, which broke spec-conformant clients that spawn `command`+`args` directly (no implicit shell) — any command containing a space, pipe, `&&`, redirect, or `$(...)` failed with `ENOENT` and the agent silently degraded to read-only tools. The bash tool now wraps the shell line before calling `clientBridge.createTerminal`, reusing the same shell binary + args the local `bash-executor` resolves via `settings.getShellConfig()` (Git Bash / `bash.exe` on Windows, `$SHELL` with `sh` fallback on POSIX) so bash semantics — `$VAR`, `$(...)`, `source`, POSIX quoting, `-l` — are preserved on both platforms. ([#4333](https://github.com/can1357/oh-my-pi/issues/4333)) +- Fixed inference worker subprocesses (TTS, STT, tiny-model, mnemopi embeddings) discarding stderr, which left every unexpected exit — most visibly the local Kokoro TTS worker's recurring `exit code 7` crash loop — undiagnosable from the parent's logs. `createWorkerSubprocess` now pipes stderr without starting a live read while the worker is idle, then drains the stream after `onExit`, emits captured lines to `logger.debug` under an ` stderr` message, and keeps the last 16 KiB in a bounded ring that gets appended to the `Error` surfaced through `onError`. The exit surface is synchronized with the post-exit drain via `SpawnedSubprocess.stderrDrained`, so the full native trace shows up on the `tts: worker error` line without reintroducing event-loop liveness from unref'd workers. ([#4324](https://github.com/can1357/oh-my-pi/issues/4324)) +### Fixed + +- Fixed Windows session tail loss after atomic compaction rewrites by fencing append writers during full-file replacement and gating the atomic publish on a `commitGuard` that the storage backend checks synchronously before rename, so a concurrent `flushSync` (Ctrl+C / session-exit) is not overwritten by the stale body serialized before it ran. Covers post-compaction prompts, tool results, title changes, and exit diagnostics on the current JSONL path ([#4338](https://github.com/can1357/oh-my-pi/issues/4338)). ## [16.3.2] - 2026-07-02 diff --git a/packages/coding-agent/src/modes/components/transcript-container.ts b/packages/coding-agent/src/modes/components/transcript-container.ts index d344934c7..a4abc2379 100644 --- a/packages/coding-agent/src/modes/components/transcript-container.ts +++ b/packages/coding-agent/src/modes/components/transcript-container.ts @@ -443,10 +443,6 @@ export class TranscriptContainer // drift after commit; the engine commits them audit-exempt. Provisional // (commit-unstable) blocks never extend it. #nativeScrollbackSnapshotSafeEnd: number | undefined; - // Local line index through which lower finalized siblings are safe to OFFER to - // native scrollback while still audited. Unlike snapshotSafeEnd, rows below a - // live block are not durable: growth above them must repair stale history. - #nativeScrollbackOfferSafeEnd: number | undefined; // Persistent assembled transcript rows. Rows before the stable floor are // byte-identical to the previous render; rows at/after it were re-pushed. #lines: string[] = []; @@ -495,10 +491,6 @@ export class TranscriptContainer return this.#nativeScrollbackSnapshotSafeEnd; } - getNativeScrollbackOfferSafeEnd(): number | undefined { - return this.#nativeScrollbackOfferSafeEnd; - } - /** * Whether `component` sits below a still-mutating block — i.e. inside the * live region, where its rows cannot have been committed to native @@ -591,7 +583,6 @@ export class TranscriptContainer this.#nativeScrollbackLiveRegionStart = undefined; this.#nativeScrollbackCommitSafeEnd = undefined; this.#nativeScrollbackSnapshotSafeEnd = undefined; - this.#nativeScrollbackOfferSafeEnd = undefined; const count = this.children.length; @@ -638,14 +629,6 @@ export class TranscriptContainer // liveStartIndex; empty leading blocks (or a separator) must not claim it // early. let liveRecorded = false; - // Prefix boundary for finalized siblings rendered below the first live - // block. These rows may be offered to native scrollback, but they cannot - // extend snapshotSafeEnd because a live block above can still move them. - let offerSafeEnd: number | undefined; - // Offer rows must be a contiguous finalized run below the first live - // block. A later live/provisional block can still push rows below it, so - // finalized siblings after that barrier must stay forced-overflow. - let offerSafeOpen = true; // Frame row cursor: rows emitted (reused or pushed) so far. let row = 0; let stableRows = 0; @@ -718,7 +701,6 @@ export class TranscriptContainer // everything below it. if (contribution.length === 0) { if (i >= liveStartIndex && commitSafeOpen && !finalized) commitSafeOpen = false; - if (i > liveStartIndex && !finalized) offerSafeOpen = false; if (chainStable && !(reusable && previous.rowCount === 0 && previous.startRow === row)) { chainStable = false; lines.length = row; @@ -788,13 +770,6 @@ export class TranscriptContainer // rows around as it grows, so the run closes there. if (!(finalized && safeLength >= contribution.length)) commitSafeOpen = false; } - if (i > liveStartIndex) { - if (offerSafeOpen && finalized) { - offerSafeEnd = blockStart + contribution.length; - } else if (!finalized) { - offerSafeOpen = false; - } - } segments[i] = { component: child, @@ -813,7 +788,6 @@ export class TranscriptContainer // Trailing shrink: blocks removed from the tail leave stale rows behind // when every surviving segment was reused. if (lines.length !== row) lines.length = row; - this.#nativeScrollbackOfferSafeEnd = offerSafeEnd; this.#segments = segments; this.#stableRowsFloor = Math.min(stableFloorBefore, stableRows, row); return lines; diff --git a/packages/coding-agent/test/transcript-streaming-commit-repro.test.ts b/packages/coding-agent/test/transcript-streaming-commit-repro.test.ts index 46061509f..25532f3f2 100644 --- a/packages/coding-agent/test/transcript-streaming-commit-repro.test.ts +++ b/packages/coding-agent/test/transcript-streaming-commit-repro.test.ts @@ -2,16 +2,6 @@ import { describe, expect, it } from "bun:test"; import { TranscriptContainer } from "@oh-my-pi/pi-coding-agent/modes/components/transcript-container"; import type { Component } from "@oh-my-pi/pi-tui"; -class FinalizedBlock implements Component { - #lines: string[]; - constructor(lines: string[]) { - this.#lines = [...lines]; - } - render(width: number): string[] { - return this.#lines.map(line => line.slice(0, width)); - } -} - class MutableLiveBlock implements Component { #lines: string[]; constructor(lines: string[]) { @@ -44,49 +34,4 @@ describe("transcript streaming commit (assistant text)", () => { // off head must be committable to native scrollback (tmux pane history). expect(chat.getNativeScrollbackCommitSafeEnd()).toBe(3); }); - - it("offers lower finalized siblings without making them durable", () => { - const chat = new TranscriptContainer(); - const top = new FinalizedBlock(["top-0"]); - const live = new MutableLiveBlock(["live-0"]); - const tail = new FinalizedBlock(["tail-0", "tail-1"]); - chat.addChild(top); - chat.addChild(live); - chat.addChild(tail); - - expect(chat.render(80)).toEqual(["top-0", "", "live-0", "", "tail-0", "tail-1"]); - expect(chat.getNativeScrollbackLiveRegionStart()).toBe(2); - expect(chat.getNativeScrollbackSnapshotSafeEnd()).toBe(3); - expect(chat.getNativeScrollbackOfferSafeEnd()).toBe(6); - }); - - it("stops offered siblings at an intervening live block", () => { - const chat = new TranscriptContainer(); - const top = new FinalizedBlock(["top-0"]); - const firstLive = new MutableLiveBlock(["live-a"]); - const firstTail = new FinalizedBlock(["tail-a-0", "tail-a-1"]); - const secondLive = new MutableLiveBlock(["live-b"]); - const laterTail = new FinalizedBlock(["tail-b-0"]); - chat.addChild(top); - chat.addChild(firstLive); - chat.addChild(firstTail); - chat.addChild(secondLive); - chat.addChild(laterTail); - - expect(chat.render(80)).toEqual([ - "top-0", - "", - "live-a", - "", - "tail-a-0", - "tail-a-1", - "", - "live-b", - "", - "tail-b-0", - ]); - expect(chat.getNativeScrollbackLiveRegionStart()).toBe(2); - expect(chat.getNativeScrollbackSnapshotSafeEnd()).toBe(3); - expect(chat.getNativeScrollbackOfferSafeEnd()).toBe(6); - }); }); diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 6aed26819..8dae4c01c 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -10,6 +10,7 @@ - Fixed a native scrollback rendering bug where finalized transcript rows below an active block would duplicate when the active block expanded. - Fixed autocomplete popups remaining active with stale suggestions after destructive text editing (such as Ctrl+W, Ctrl+U, Ctrl+K, Alt+Backspace, Alt+D, paste, or yank), preventing input corruption when pressing Tab or Enter. - Skipped Markdown re-lex + re-wrap when `setText` receives the identical text, mirroring the equality guard on `Text.setText` — cuts one of the top streaming CPU hotspots when providers re-emit unchanged content ([#4353](https://github.com/can1357/oh-my-pi/issues/4353)). +- Fixed the modifyOtherKeys keyboard fallback enabling on unknown SSH terminals, avoiding broken Shift input in iOS SSH clients such as Redock ([#4325](https://github.com/can1357/oh-my-pi/issues/4325)). ## [16.3.0] - 2026-07-02 diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index ddaef7f67..d59015ab2 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -212,11 +212,6 @@ export interface OverlayFocusOwner { * dropped row or an audit re-anchor spray. Provisional live blocks (collapsing * tool/edit previews whose head is a throwaway tail window) omit it. Defaults to * `commitSafeEnd ?? liveRegionStart` when absent. - * `getNativeScrollbackOfferSafeEnd` optionally reports the deepest prefix row - * that may physically enter native scrollback while still remaining audited. - * This is for finalized lower siblings under a live block: the rows may scroll - * off, but a later live-block insertion above them must trigger repair instead - * of becoming durable audit-exempt history. * * When several root children report a seam in the same frame, the topmost * one (and its commit-safe / snapshot-safe extension) defines the boundary: @@ -227,7 +222,6 @@ export interface NativeScrollbackLiveRegion { getNativeScrollbackLiveRegionStart(): number | undefined; getNativeScrollbackCommitSafeEnd?(): number | undefined; getNativeScrollbackSnapshotSafeEnd?(): number | undefined; - getNativeScrollbackOfferSafeEnd?(): number | undefined; } export interface NativeScrollbackCommittedRows { @@ -257,10 +251,6 @@ function getNativeScrollbackSnapshotSafeEnd(component: Component): number | unde return (component as Component & Partial).getNativeScrollbackSnapshotSafeEnd?.(); } -function getNativeScrollbackOfferSafeEnd(component: Component): number | undefined { - return (component as Component & Partial).getNativeScrollbackOfferSafeEnd?.(); -} - /** * Opt-in stability report for components that mutate their returned render * array in place across frames (instead of returning a fresh array per @@ -638,7 +628,6 @@ interface FrameSegment { liveLocalStart?: number; commitLocalEnd?: number; snapshotLocalEnd?: number; - offerLocalEnd?: number; } /** Depth-first identity search through `Container`-shaped children. */ @@ -1020,27 +1009,23 @@ export class TUI extends Container { // #auditCommittedPrefix). Holds references to component-cached strings, so // the audit is a pointer walk in the common case. #committedPrefix: string[] = []; - // The committed prefix [0, committedRows) splits into four zones by three - // monotone marks auditRows ≤ durableRows ≤ offerRows ≤ committedRows: + // The committed prefix [0, committedRows) splits into three audit zones by + // two monotone marks auditRows ≤ durableRows ≤ committedRows: // [0, auditRows) BYTE-STABLE — audited (re-anchor on any shift). // [auditRows, durableRows) DURABLE snapshot — exempt: rows may drift in // place (a streaming table widening) without re-anchoring, so their // expected drift never sprays duplicate snapshots. - // [durableRows, offerRows) OFFERED — audited: rows were allowed into - // native scrollback while a live block above could still shift them. - // A mismatch here requires a destructive replay, not a duplicate tail. - // [offerRows, committedRows) FORCED-overflow — audited: rows committed + // [durableRows, committedRows) FORCED-overflow — audited: rows committed // only because they scrolled above the window under a commit-unstable // barrier; auditing them re-anchors (duplication, never loss) when the // barrier later shifts/finalizes/removes, instead of stranding a stale // prefix that silently drops the rows beneath it. - // Marks re-base on a wholesale re-slice (full paint / shrink / geometry) and - // otherwise advance per the persistence rules in #updateCommittedAuditRows. + // Both marks re-base on a wholesale re-slice (full paint / shrink / geometry) + // and otherwise advance per the persistence rules in #updateCommittedAuditRows. // #auditCommittedPrefix audits [0, committedRows) skipping the exempt window // [auditRows, durableRows). #committedPrefixAuditRows = 0; #committedPrefixDurableRows = 0; - #committedPrefixOfferRows = 0; // Frame row currently mapped to screen row 0. Monotonic between full // paints: a shrink never re-exposes scrolled-off rows (they cannot be // un-scrolled without rewriting history); live rows repaint at fixed @@ -1051,7 +1036,6 @@ export class TUI extends Container { #nativeScrollbackLiveRegionStart: number | undefined; #nativeScrollbackCommitSafeEnd: number | undefined; #nativeScrollbackSnapshotSafeEnd: number | undefined; - #nativeScrollbackOfferSafeEnd: number | undefined; #fullRedrawCount = 0; // Caps how many inline images render as live graphics; older ones fall back // to text via a purge + full redraw. Cap is configured by the host app. @@ -1171,7 +1155,6 @@ export class TUI extends Container { this.#nativeScrollbackLiveRegionStart = undefined; this.#nativeScrollbackCommitSafeEnd = undefined; this.#nativeScrollbackSnapshotSafeEnd = undefined; - this.#nativeScrollbackOfferSafeEnd = undefined; const children = this.children; const previousSegments = this.#frameSegments; const segments: FrameSegment[] = new Array(children.length); @@ -1194,14 +1177,12 @@ export class TUI extends Container { let liveLocalStart: number | undefined; let commitLocalEnd: number | undefined; let snapshotLocalEnd: number | undefined; - let offerLocalEnd: number | undefined; let reported: number | undefined; if (reuse) { childLines = previous.lines; liveLocalStart = previous.liveLocalStart; commitLocalEnd = previous.commitLocalEnd; snapshotLocalEnd = previous.snapshotLocalEnd; - offerLocalEnd = previous.offerLocalEnd; } else { // Feed the engine's committed-row claim (from the previous frame's // emit) before rendering so the child can skip re-deriving blocks @@ -1230,13 +1211,6 @@ export class TUI extends Container { ? Math.max(snapshotFloor, Math.min(childLines.length, Math.trunc(snapshotSafeEnd))) : childLines.length; } - const offerSafeEnd = getNativeScrollbackOfferSafeEnd(child); - if (offerSafeEnd !== undefined) { - const offerFloor = snapshotLocalEnd ?? commitLocalEnd ?? liveLocalStart; - offerLocalEnd = Number.isFinite(offerSafeEnd) - ? Math.max(offerFloor, Math.min(childLines.length, Math.trunc(offerSafeEnd))) - : childLines.length; - } } // Consume the stability report unconditionally for implementers: // reading re-bases the component's baseline to the state this @@ -1260,9 +1234,6 @@ export class TUI extends Container { if (snapshotLocalEnd !== undefined) { this.#nativeScrollbackSnapshotSafeEnd = offset + snapshotLocalEnd; } - if (offerLocalEnd !== undefined) { - this.#nativeScrollbackOfferSafeEnd = offset + offerLocalEnd; - } } if (chainStable) { if (previous !== undefined && previous.component === child && previous.start === offset) { @@ -1293,7 +1264,6 @@ export class TUI extends Container { liveLocalStart, commitLocalEnd, snapshotLocalEnd, - offerLocalEnd, }; offset += childLines.length; } @@ -2694,7 +2664,6 @@ export class TUI extends Container { const liveRegionStart = this.#nativeScrollbackLiveRegionStart; const commitSafeEnd = this.#nativeScrollbackCommitSafeEnd; const snapshotSafeEnd = this.#nativeScrollbackSnapshotSafeEnd; - const offerSafeEnd = this.#nativeScrollbackOfferSafeEnd; // Commit boundaries (also used by the window/commit math in section 3), // hoisted above the audit gate because the resync needs byteStableBoundary @@ -2702,27 +2671,22 @@ export class TUI extends Container { // The commit floor is windowTop in every non-frozen path (see chunkTo), so // whatever scrolls above the window is committed — never committed nowhere // AND painted nowhere (the loss bug). The boundaries no longer gate the - // commit; they define audit and repair spans. byteStableBoundary: rows below + // commit; they define the audit-exempt span. byteStableBoundary: rows below // it are byte-stable (never re-layout), audited. durableBoundary: rows in // [byteStableBoundary, durableBoundary) are durable — permanent on scroll-off // but may drift in place (a streaming table re-aligning), committed - // audit-EXEMPT. offerBoundary: rows in [durableBoundary, offerBoundary) - // were explicitly allowed to enter native scrollback while remaining - // audited; if they later shift, the stale physical history is repaired by a - // destructive replay instead of duplicate recommit. Rows at/beyond - // offerBoundary committed only because they scrolled above the window (a - // commit-unstable barrier over a long tail) are forced-overflow rows: - // audited, so a later shift/finalize/removal re-anchors (duplication, never - // loss) instead of stranding a stale prefix. Built on the finalized prefix - // (live-region start); the whole frame when the root reports no seam (shell - // semantics: whatever scrolls is final). + // audit-EXEMPT. Rows at/beyond durableBoundary committed only because they + // scrolled above the window (a commit-unstable barrier over a long tail) are + // forced-overflow rows: audited, so a later shift/finalize/removal re-anchors + // (duplication, never loss) instead of stranding a stale prefix. Built on the + // finalized prefix (live-region start); the whole frame when the root reports + // no seam (shell semantics: whatever scrolls is final). const frameLength = rawFrame.length; const byteStableBoundary = Math.max(0, Math.min(frameLength, commitSafeEnd ?? liveRegionStart ?? frameLength)); const durableBoundary = Math.max( byteStableBoundary, Math.min(frameLength, snapshotSafeEnd ?? byteStableBoundary), ); - const offerBoundary = Math.max(durableBoundary, Math.min(frameLength, offerSafeEnd ?? durableBoundary)); // 2. Transition state captured before any emitter runs. const prevWindowTop = this.#windowTopRow; @@ -2755,28 +2719,18 @@ export class TUI extends Container { let committedRowsResynced = false; // Audit covers [0, auditRows) and the forced suffix [durableRows, // committedRows); the durable middle [auditRows, durableRows) is exempt - // (in-place drift). Three reasons to run the audit this frame: + // (in-place drift). Two reasons to run the audit this frame: // - the stable prefix does not cover every audited row (auditUpper); or // - a forced-overflow row this frame became durable/permanent - // (committedPrefixDurableRows < min(committed, durableBoundary)): the - // barrier above it finalized, so its committed bytes must be re-checked - // even though the stable prefix says nothing moved — a stale committed - // copy there would silently drop the row; or - // - a forced-overflow row this frame joined the offered zone - // (committedPrefixOfferRows < min(committed, offerBoundary)): a - // finalized sibling under a live block asks for destructive-replay - // repair, so a single-row shift there must be caught even if - // tail-sample tolerance would otherwise skip it. - // The hard scan in findCommittedPrefixResync covers [durableRows, - // hardAuditEnd) in full (no tail-sample miss). + // (committedPrefixDurableRows < hardAuditEnd): the barrier above it + // finalized, so its committed bytes must be re-checked even though the + // stable prefix says nothing moved — a stale committed copy there would + // silently drop the row. The hard scan in findCommittedPrefixResync + // covers [durableRows, hardAuditEnd) in full (no tail-sample miss). const auditUpper = this.#committedPrefixDurableRows < this.#committedRows ? this.#committedRows : this.#committedPrefixAuditRows; - const durableHardEnd = Math.min(this.#committedRows, durableBoundary); - const offerHardEnd = Math.min(this.#committedRows, offerBoundary); - const hardAuditEnd = Math.max(durableHardEnd, offerHardEnd); - const needHardAudit = - this.#committedPrefixDurableRows < durableHardEnd || this.#committedPrefixOfferRows < offerHardEnd; - let repairOfferedScrollback = false; + const hardAuditEnd = Math.min(this.#committedRows, durableBoundary); + const needHardAudit = this.#committedPrefixDurableRows < hardAuditEnd; const auditRan = this.#hasEverRendered && !geometryChanged && @@ -2784,21 +2738,14 @@ export class TUI extends Container { (this.#renderStablePrefixRows < auditUpper || needHardAudit); if (auditRan) { const committedRowsBeforeAudit = this.#committedRows; - const offeredRowsBeforeAudit = this.#committedPrefixOfferRows; - const durableRowsBeforeAudit = this.#committedPrefixDurableRows; - this.#auditCommittedPrefix(rawFrame, hardAuditEnd); + this.#auditCommittedPrefix(rawFrame, durableBoundary); committedRowsResynced = this.#committedRows !== committedRowsBeforeAudit; - repairOfferedScrollback = - offeredRowsBeforeAudit > durableRowsBeforeAudit && - committedRowsResynced && - this.#committedRows < offeredRowsBeforeAudit; } // Committed-prefix state this frame's commit math extends from (post-audit). // Drives the audit-rows / durable-rows caps recomputed after the emit. const preCommitRows = this.#committedRows; const preCommitAuditRows = this.#committedPrefixAuditRows; const preCommitDurableRows = this.#committedPrefixDurableRows; - const preCommitOfferRows = this.#committedPrefixOfferRows; // 3. Window and commit math (lengths only; content prepared below). let hasVisibleOverlay = false; @@ -2816,7 +2763,7 @@ export class TUI extends Container { // place, because an ED3 rewrap is unsafe (pane scrollback / alt-screen // feedback loop), so committed history keeps its old wrap. const firstPaint = !this.#hasEverRendered; - const replaceRequested = this.#clearScrollbackOnNextRender || repairOfferedScrollback; + const replaceRequested = this.#clearScrollbackOnNextRender; const geometryRebuild = geometryChanged && !resizeRepaintsInPlace(); const fullPaint = firstPaint || replaceRequested || geometryRebuild; let windowTop: number; @@ -2928,10 +2875,8 @@ export class TUI extends Container { preCommitRows, preCommitAuditRows, preCommitDurableRows, - preCommitOfferRows, byteStableBoundary, durableBoundary, - offerBoundary, false, ); this.#clearScrollbackOnNextRender = false; @@ -2959,10 +2904,8 @@ export class TUI extends Container { preCommitRows, preCommitAuditRows, preCommitDurableRows, - preCommitOfferRows, byteStableBoundary, durableBoundary, - offerBoundary, auditRan, ); } @@ -2989,7 +2932,6 @@ export class TUI extends Container { this.#committedRows = resyncTo; this.#committedPrefixAuditRows = Math.min(this.#committedPrefixAuditRows, resyncTo); this.#committedPrefixDurableRows = Math.min(this.#committedPrefixDurableRows, resyncTo); - this.#committedPrefixOfferRows = Math.min(this.#committedPrefixOfferRows, resyncTo); prefix.length = resyncTo; if ($flag("PI_DEBUG_REDRAW")) { const msg = `[${new Date().toISOString()}] commit resync: committed prefix diverged at row ${resyncTo}; recommitting\n`; @@ -3002,24 +2944,21 @@ export class TUI extends Container { * #committedPrefixAuditRows field doc for the three audit zones). * * auditRows tracks the byte-stable boundary; durableRows the durable snapshot - * boundary; offerRows the deepest explicit audited-offer boundary. A wholesale - * re-slice (full paint / shrink / geometry) re-bases each mark from the - * current frame. An incremental extend keeps a mark once a row past it has - * committed (mark < committed): a later RISE in a boundary (a table finalizing) - * must neither pull already-committed stale snapshots back under the - * byte-stable cap nor retroactively exempt forced-overflow rows already - * audited. durableRows is floored at auditRows; offerRows is floored at - * durableRows. + * boundary. A wholesale re-slice (full paint / shrink / geometry) re-bases + * each mark from the current frame (min(committed, boundary)). An incremental + * extend keeps a mark once a row past it has committed (mark < committed): a + * later RISE in a boundary (a table finalizing) must neither pull + * already-committed stale snapshots back under the byte-stable cap nor + * retroactively exempt forced-overflow rows already audited. durableRows is + * floored at auditRows so the exempt window can never invert. */ #updateCommittedAuditRows( resliced: boolean, preCommittedRows: number, preAuditRows: number, preDurableRows: number, - preOfferRows: number, byteStableBoundary: number, durableBoundary: number, - offerBoundary: number, hardAudited: boolean, ): void { const committed = this.#committedRows; @@ -3036,18 +2975,8 @@ export class TUI extends Container { resliced || preDurableRows >= preCommittedRows || hardAudited ? Math.min(committed, durableBoundary) : Math.min(preDurableRows, committed); - // offerRows may EXTEND to include forced-overflow rows within a - // newly-visible offerBoundary — unlike auditRows/durableRows, retroactive - // promotion is safe: both offered and forced-overflow zones stay - // audited; offered only switches divergence repair from tolerant recommit - // to destructive replay (stronger, not weaker). A dropped offerBoundary - // still keeps the durability rule via `preOfferRows` (never demote - // already-offered rows to forced-overflow). - const offerCap = Math.min(committed, offerBoundary); - const offerRows = resliced ? offerCap : Math.max(Math.min(preOfferRows, committed), offerCap); this.#committedPrefixAuditRows = auditRows; this.#committedPrefixDurableRows = Math.max(auditRows, durableRows); - this.#committedPrefixOfferRows = Math.max(this.#committedPrefixDurableRows, offerRows); } /** diff --git a/packages/tui/test/streaming-scrollback-defer.test.ts b/packages/tui/test/streaming-scrollback-defer.test.ts index dda8219b7..1fdd86018 100644 --- a/packages/tui/test/streaming-scrollback-defer.test.ts +++ b/packages/tui/test/streaming-scrollback-defer.test.ts @@ -645,7 +645,6 @@ class SeamComponent implements Component, NativeScrollbackLiveRegion { liveStart: number | undefined; commitSafe: number | undefined; snapSafe: number | undefined; - offerSafe: number | undefined; invalidate(): void {} @@ -664,9 +663,6 @@ class SeamComponent implements Component, NativeScrollbackLiveRegion { getNativeScrollbackSnapshotSafeEnd(): number | undefined { return this.snapSafe; } - getNativeScrollbackOfferSafeEnd(): number | undefined { - return this.offerSafe; - } } /** Indices in `buffer` where `needle` begins as a contiguous run. */ @@ -958,96 +954,6 @@ describe("scrollback commit gap — commit-unstable barriers", () => { } }); - it("repairs offered finalized tail rows when a live block above grows", async () => { - if (process.platform === "win32") return; - const term = new VirtualTerminal(20, 5); - overrideProbe(term, undefined); - const tui = new TUI(term); - const root = new SeamComponent(); - - try { - tui.addChild(root); - tui.start(); - await settle(term); - const writes = capture(term); - - const f1 = ["live-0", ...rows("tail-", 10)]; - root.lines = f1; - root.liveStart = 0; - root.snapSafe = 1; - root.offerSafe = f1.length; - tui.requestRender(); - await settle(term); - - const f2 = ["live-0", "live-1", ...rows("tail-", 10)]; - root.lines = f2; - root.offerSafe = f2.length; - tui.requestRender(); - await settle(term); - - const buffer = term.getScrollBuffer().map(line => line.trimEnd()); - expect(buffer).toEqual(f2); - for (const row of rows("tail-", 10)) expect(buffer.filter(line => line === row)).toHaveLength(1); - expect(eraseScrollbackCount(writes)).toBeGreaterThan(0); - } finally { - tui.stop(); - } - }); - - it("promotes forced-overflow rows to offered when the offer boundary appears after commit", async () => { - if (process.platform === "win32") return; - const term = new VirtualTerminal(20, 4); - overrideProbe(term, undefined); - const tui = new TUI(term); - const root = new SeamComponent(); - - try { - tui.addChild(root); - tui.start(); - await settle(term); - const writes = capture(term); - - // Phase 1: only the live barrier overflows the viewport. Rows scroll - // off as forced-overflow (no offer boundary). - const f1 = rows("live-", 10); - root.lines = f1; - root.liveStart = 0; - root.offerSafe = undefined; - tui.requestRender(); - await settle(term); - const commitAfterF1 = term.getScrollBuffer().length; - expect(commitAfterF1).toBeGreaterThan(0); - - // Phase 2: finalized siblings are appended below the still-live block; - // the component now reports an offer boundary covering the whole frame. - // The engine must retroactively classify the already-committed rows - // under the offer boundary as offered so the next divergence takes the - // destructive-replay repair path, not tolerant recommit. - const f2 = [...rows("live-", 10), ...rows("tail-", 5)]; - root.lines = f2; - root.liveStart = 0; - root.offerSafe = f2.length; - tui.requestRender(); - await settle(term); - - // Phase 3: live block grows by one row, shifting the whole tail up. - const f3 = [...rows("live-", 11), ...rows("tail-", 5)]; - root.lines = f3; - root.liveStart = 0; - root.offerSafe = f3.length; - tui.requestRender(); - await settle(term); - - const buffer = term.getScrollBuffer().map(line => line.trimEnd()); - for (const row of rows("tail-", 5)) { - expect(buffer.filter(line => line === row)).toHaveLength(1); - } - expect(eraseScrollbackCount(writes)).toBeGreaterThan(0); - } finally { - tui.stop(); - } - }); - it("does not lose a single-row finalize edit above an unchanged tail (reviewer repro)", async () => { if (process.platform === "win32") return; const term = new VirtualTerminal(20, 4);