From 597eb76eaf6d3fa2157fd04ae9e1dbf53982de28 Mon Sep 17 00:00:00 2001 From: snowykr Date: Sun, 2 Aug 2026 00:30:00 +0900 Subject: [PATCH 01/46] feat(tui): add stable iTerm2 pet rendering Add lease-based OSC 1337 GIF rendering for iTerm2 while preserving Sixel, Kitty, tmux, viewport, and ordinary renderer behavior. --- .gitignore | 7 +++++++ .../test/modes/components/iterm-pet-transport.test.ts | 3 +++ packages/tui/artifacts/g015-qa-report.json | 8 ++++---- packages/tui/src/tui.ts | 1 + packages/tui/test/g003-qa-report.test.ts | 7 ++++--- packages/tui/test/g011-batched-natives-redteam.test.ts | 7 +++++-- .../tui/test/g014-editor-layout-cache-redteam.test.ts | 7 ++++--- packages/tui/test/g015-debug-width-redteam.test.ts | 5 +++-- 8 files changed, 31 insertions(+), 14 deletions(-) diff --git a/.gitignore b/.gitignore index 4deb6a4e40..abb6ad3eb1 100644 --- a/.gitignore +++ b/.gitignore @@ -86,3 +86,10 @@ packages/coding-agent/binaries/ # Python SDK build output python/gjc-sdk/build/ +/artifacts/g003-qa-report.json +/artifacts/g011-qa-report.json +/artifacts/g014-qa-report.json +/artifacts/g015-qa-report.json +/artifacts/ultragoal-g003-iterm-size-test-report.json +/artifacts/ultragoal-g003-quality-gate.json +/artifacts/ultragoal-g003-review-receipts.json diff --git a/packages/coding-agent/test/modes/components/iterm-pet-transport.test.ts b/packages/coding-agent/test/modes/components/iterm-pet-transport.test.ts index 52370f1e55..807685bf34 100644 --- a/packages/coding-agent/test/modes/components/iterm-pet-transport.test.ts +++ b/packages/coding-agent/test/modes/components/iterm-pet-transport.test.ts @@ -349,7 +349,10 @@ describe("iTerm Pet transport", () => { expect(split(ack)).toEqual({ consume: true }); expect(split("\x1b]1337;Cap")).toEqual({ consume: true }); expect(split("abilities=F\x07")).toEqual({ consume: true }); +<<<<<<< HEAD expect(split("\x1b")).toEqual({ data: "\x1b" }); +======= +>>>>>>> 074002d17 (feat(tui): add stable iTerm2 pet rendering) }); it("classifies completed replies as missing F only when syntax is valid", async () => { diff --git a/packages/tui/artifacts/g015-qa-report.json b/packages/tui/artifacts/g015-qa-report.json index 74eb9f5d4e..a57bfa029f 100644 --- a/packages/tui/artifacts/g015-qa-report.json +++ b/packages/tui/artifacts/g015-qa-report.json @@ -32,8 +32,8 @@ "differentialGuardVisibleWidthCalls": 0 }, "writes": [ - "[2026-07-17T07:22:55.913Z] fullRender: terminal width changed (-1 -> 20) (prev=0, new=2, height=4)\n", - "[2026-07-17T07:22:55.941Z] fullRender: terminal width changed (-1 -> 20) (prev=0, new=2, height=4)\n" + "[2026-07-23T03:26:49.359Z] fullRender: terminal width changed (-1 -> 20) (prev=0, new=2, height=4)\n", + "[2026-07-23T03:26:49.388Z] fullRender: terminal width changed (-1 -> 20) (prev=0, new=2, height=4)\n" ] } }, @@ -246,8 +246,8 @@ "differentialGuardVisibleWidthCalls": 0 }, "writes": [ - "[2026-07-17T07:22:55.913Z] fullRender: terminal width changed (-1 -> 20) (prev=0, new=2, height=4)\n", - "[2026-07-17T07:22:55.941Z] fullRender: terminal width changed (-1 -> 20) (prev=0, new=2, height=4)\n" + "[2026-07-23T03:26:49.359Z] fullRender: terminal width changed (-1 -> 20) (prev=0, new=2, height=4)\n", + "[2026-07-23T03:26:49.388Z] fullRender: terminal width changed (-1 -> 20) (prev=0, new=2, height=4)\n" ] } }, diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index ba34b4f0b8..3f32904f40 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -4723,6 +4723,7 @@ export class TUI extends Container { if (this.#writeCursorPosition(cursorPos, newLines.length)) this.#refreshPaintedLiveViewportObservation(height); return; } + const nextLiveViewportTop = Math.max(0, newLines.length - height); if ( this.#rasterLeases.size > 0 && diff --git a/packages/tui/test/g003-qa-report.test.ts b/packages/tui/test/g003-qa-report.test.ts index 242aaf2fa6..6054ae0ac5 100644 --- a/packages/tui/test/g003-qa-report.test.ts +++ b/packages/tui/test/g003-qa-report.test.ts @@ -1,10 +1,12 @@ import { afterAll, beforeEach, describe, expect, it, vi } from "bun:test"; -import { mkdirSync, writeFileSync } from "node:fs"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import { type Component, renderMetrics, TUI } from "@gajae-code/tui"; import { VirtualTerminal } from "./virtual-terminal"; const FLAG = "PI_TUI_VIRTUAL_VIEWPORT"; +const REPORT_PATH = join(mkdtempSync(join(tmpdir(), "g003-qa-")), "g003-qa-report.json"); const ROWS = 12; const OVERSCAN = 8; @@ -167,9 +169,8 @@ describe("G003 virtual viewport adversarial parity QA", () => { const passed = cases.filter(c => c.status === "passed").length; const failed = cases.filter(c => c.status === "failed").length; - mkdirSync("artifacts", { recursive: true }); writeFileSync( - join("artifacts", "g003-qa-report.json"), + REPORT_PATH, `${JSON.stringify({ schemaVersion: 1, kind: "tui-parity-test-report", cases, summary: { total: cases.length, passed, failed } }, null, 2)}\n`, ); }); diff --git a/packages/tui/test/g011-batched-natives-redteam.test.ts b/packages/tui/test/g011-batched-natives-redteam.test.ts index 7d6ac3ebd4..0850d72cae 100644 --- a/packages/tui/test/g011-batched-natives-redteam.test.ts +++ b/packages/tui/test/g011-batched-natives-redteam.test.ts @@ -1,4 +1,7 @@ import { afterEach, describe, expect, it } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { __textHelperPerfCounters, type Component, @@ -14,7 +17,7 @@ import { ImageProtocol, TERMINAL } from "@gajae-code/tui/terminal-capabilities"; import { getDefaultTabWidth, setDefaultTabWidth } from "@gajae-code/utils"; import { VirtualTerminal } from "./virtual-terminal"; -const REPORT_PATH = "artifacts/g011-qa-report.json"; +const REPORT_PATH = join(mkdtempSync(join(tmpdir(), "g011-qa-")), "g011-qa-report.json"); const SEGMENT_RESET = "\x1b[0m"; const LINE_TERMINATOR = "\x1b[0m\x1b]8;;\x1b\\"; @@ -315,7 +318,7 @@ describe("G011 batched text natives red-team", () => { }); }); - it("writes artifacts/g011-qa-report.json", async () => { + it("writes a temporary QA report", async () => { const blockers = cases .filter(entry => entry.verdict === "failed") .map(entry => ({ diff --git a/packages/tui/test/g014-editor-layout-cache-redteam.test.ts b/packages/tui/test/g014-editor-layout-cache-redteam.test.ts index 48c95b8860..517894d094 100644 --- a/packages/tui/test/g014-editor-layout-cache-redteam.test.ts +++ b/packages/tui/test/g014-editor-layout-cache-redteam.test.ts @@ -1,5 +1,7 @@ import { afterEach, describe, expect, it } from "bun:test"; -import { mkdirSync, writeFileSync } from "node:fs"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { stripVTControlCharacters } from "node:util"; import type { AutocompleteItem, AutocompleteProvider } from "@gajae-code/tui/autocomplete"; import { __editorPerfCounters, Editor } from "@gajae-code/tui/components/editor"; @@ -19,7 +21,7 @@ type CaseResult = { }; const WIDTH = 72; -const reportPath = "artifacts/g014-qa-report.json"; +const reportPath = join(mkdtempSync(join(tmpdir(), "g014-qa-")), "g014-qa-report.json"); const originalTabWidth = getDefaultTabWidth(); afterEach(() => { @@ -411,7 +413,6 @@ describe("G014 editor layout cache red-team", () => { artifactRefs: [{ id: "g014-qa-report", kind: "api-package-test-report", description: reportPath }], blockers, }; - mkdirSync("artifacts", { recursive: true }); writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`); expect(results.map(result => result.id)).toEqual([ "CURSOR-PARITY-FUZZ", diff --git a/packages/tui/test/g015-debug-width-redteam.test.ts b/packages/tui/test/g015-debug-width-redteam.test.ts index 822b74ab51..a4ab46182c 100644 --- a/packages/tui/test/g015-debug-width-redteam.test.ts +++ b/packages/tui/test/g015-debug-width-redteam.test.ts @@ -1,11 +1,13 @@ import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; import { type Component, TUI } from "@gajae-code/tui"; import { Ellipsis, truncateToWidth, visibleWidth } from "@gajae-code/tui/utils"; import { getDefaultTabWidth, setDefaultTabWidth } from "@gajae-code/utils"; import { VirtualTerminal } from "./virtual-terminal"; -const REPORT_PATH = "artifacts/g015-qa-report.json"; +const REPORT_PATH = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "g015-qa-")), "g015-qa-report.json"); const originalTabWidth = getDefaultTabWidth(); type CaseResult = { @@ -207,7 +209,6 @@ afterEach(() => { }); afterAll(async () => { - await fs.promises.mkdir("artifacts", { recursive: true }); await fs.promises.writeFile(REPORT_PATH, `${JSON.stringify(makeReport(), null, "\t")}\n`); }); From 0251f421e61311e9a9045fa31b5431d8189c4da1 Mon Sep 17 00:00:00 2001 From: snowykr Date: Sun, 2 Aug 2026 04:25:41 +0900 Subject: [PATCH 02/46] feat(iterm): preserve Pet through native scrollback Bind the iTerm Pet's existing raster lease to the fixed-suffix DECSTBM owner.\n\nTranscript growth now enters native scrollback without erasing or reuploading the GIF, while other raster protocols retain their existing renderer path.\n\nLore-id: c07f1e5b\nConstraint: only preserve the sole current bound raster lease\nConstraint: reset DECSTBM in every transaction\nConstraint: preserve manual viewport and managed tmux behavior\nTested: focused TUI fixed-suffix/raster/render tests; iTerm widget/transport/QA tests; package type checks\nNot-tested: direct and managed iTerm human session\nScope-risk: narrow\nReversibility: straightforward --- .../src/modes/components/gajae-pet-widget.ts | 40 ++++++- .../test/gajae-pet-widget.test.ts | 19 +++ packages/tui/src/tui.ts | 111 +++++++++++------- .../test/fixed-suffix-scroll-region.test.ts | 47 ++++++++ 4 files changed, 170 insertions(+), 47 deletions(-) diff --git a/packages/coding-agent/src/modes/components/gajae-pet-widget.ts b/packages/coding-agent/src/modes/components/gajae-pet-widget.ts index 30eff97efc..d514229cc3 100644 --- a/packages/coding-agent/src/modes/components/gajae-pet-widget.ts +++ b/packages/coding-agent/src/modes/components/gajae-pet-widget.ts @@ -5,6 +5,7 @@ import { type CellRect, type Component, type Container, + type FixedSuffixScrollRegionToken, type GajaePixelFrameName, type GajaePixelFrames, getCellDimensions, @@ -187,6 +188,7 @@ export class GajaePetWidget { /** Shared-emitter epoch from the last time this widget owned its TUI. */ #ownedOverlayEpoch = 0; #itermLease: RasterLeaseToken | undefined; + #fixedSuffixScrollRegionToken: FixedSuffixScrollRegionToken | undefined; #disposePromise: Promise | undefined; /** Raster invalidation must settle before disposeAsync starts lifecycle recovery. */ #disposeRasterBarrier: Promise = Promise.resolve(); @@ -249,9 +251,9 @@ export class GajaePetWidget { async suspendItermCapability(): Promise { if (!this.#isActiveOwner()) return; this.#itermGeneration++; + this.#releaseFixedSuffixScrollRegion(); const lease = this.#itermLease; this.#itermLease = undefined; - this.#itermLastSemantic = ""; if (lease) await this.#ui.invalidateRasterLease({ token: lease, cause: "capability-loss" }); this.#ui.requestRender(true); @@ -277,6 +279,7 @@ export class GajaePetWidget { if (mode === "off") { if (!this.#canMutateSharedUi()) return; this.#itermGeneration++; + this.#releaseFixedSuffixScrollRegion(); if (this.#itermLease) { void this.#ui.invalidateRasterLease({ token: this.#itermLease, cause: "mode-off" }); this.#itermLease = undefined; @@ -303,6 +306,7 @@ export class GajaePetWidget { const predecessor = petOverlayEmitterOwners.get(this.#ui); if (predecessor && predecessor !== this) predecessor.#retireForSuccessor(); this.#itermGeneration++; + this.#releaseFixedSuffixScrollRegion(); if (this.#itermLease) { void this.#ui.invalidateRasterLease({ token: this.#itermLease, cause: "explicit" }); this.#itermLease = undefined; @@ -390,6 +394,7 @@ export class GajaePetWidget { this.#disposeNeedsLifecycle = canMutateSharedUi; this.#disposed = true; this.#itermGeneration++; + this.#releaseFixedSuffixScrollRegion(); const lease = this.#itermLease; this.#itermLease = undefined; if (lease) @@ -439,6 +444,12 @@ export class GajaePetWidget { } } + #releaseFixedSuffixScrollRegion(): void { + const token = this.#fixedSuffixScrollRegionToken; + this.#fixedSuffixScrollRegionToken = undefined; + if (token) this.#ui.releaseFixedSuffixScrollRegion(token); + } + #mountEditor(framed: boolean): void { this.#editorContainer.clear(); this.#editorContainer.addChild(framed ? this.#framedEditor : this.#editor); @@ -477,7 +488,10 @@ export class GajaePetWidget { } #tickIterm(now: number): void { - if (!this.#isActiveOwner() || this.#ui.manualViewportActive) return; + if (!this.#isActiveOwner() || this.#ui.manualViewportActive) { + this.#releaseFixedSuffixScrollRegion(); + return; + } const cell = getCellDimensions(); const pixelColumns = Math.max(1, Math.ceil((PET_ART_ROWS * cell.heightPx) / cell.widthPx)); const pixelRows = ITERM_CANVAS_ROWS; @@ -485,6 +499,7 @@ export class GajaePetWidget { if (cell.widthPx !== this.#builtCellW || cell.heightPx !== this.#builtCellH) { metricsChanged = true; this.#itermGeneration++; + this.#releaseFixedSuffixScrollRegion(); const lease = this.#itermLease; this.#itermLease = undefined; @@ -499,6 +514,7 @@ export class GajaePetWidget { if (!this.#framedEditor.canFit(this.#ui.terminal.columns)) { if (!metricsChanged) { this.#itermGeneration++; + this.#releaseFixedSuffixScrollRegion(); const lease = this.#itermLease; this.#itermLease = undefined; @@ -513,6 +529,7 @@ export class GajaePetWidget { if (terminalRows < ITERM_CANVAS_ROWS + PET_RAISE_ROWS) { if (!metricsChanged) { this.#itermGeneration++; + this.#releaseFixedSuffixScrollRegion(); const lease = this.#itermLease; this.#itermLease = undefined; @@ -537,7 +554,10 @@ export class GajaePetWidget { height: pixelRows, }; const availability = getVerifiedItermPetAvailability(); - if (!availability?.available || getItermPetUnavailableReason() || !this.#ui.terminalAvailable) return; + if (!availability?.available || getItermPetUnavailableReason() || !this.#ui.terminalAvailable) { + this.#releaseFixedSuffixScrollRegion(); + return; + } const working = this.#isWorking(); const flexing = this.#flexUntil > now; const semantic = `${this.#mode}:${availability.mode}:${availability.epoch}:${working}:${flexing}:${rect.column},${rect.row}:${cell.widthPx},${cell.heightPx}:${this.#ui.terminal.columns},${this.#ui.terminal.rows}`; @@ -618,6 +638,7 @@ export class GajaePetWidget { token.rect.width !== rect.width || token.rect.height !== rect.height) ) { + this.#releaseFixedSuffixScrollRegion(); await this.#ui.invalidateRasterLease({ token, cause: "resize" }); if (this.#itermLease === token) { this.#itermLease = undefined; @@ -640,13 +661,14 @@ export class GajaePetWidget { }, onInvalidated: notice => { if (this.#itermLease === notice.token) { + this.#releaseFixedSuffixScrollRegion(); this.#itermLease = undefined; - this.#itermLastSemantic = ""; } }, }); if (!current() || acquired.status !== "acquired") { + this.#releaseFixedSuffixScrollRegion(); if (acquired.status === "acquired") await this.#ui.invalidateRasterLease({ token: acquired.token, @@ -709,12 +731,22 @@ export class GajaePetWidget { }, }); if (!current() || submit.status !== "written") { + this.#releaseFixedSuffixScrollRegion(); await this.#ui.invalidateRasterLease({ token, cause: "capability-loss" }); if (this.#itermLease === token) { this.#itermLease = undefined; } return; } + this.#releaseFixedSuffixScrollRegion(); + const fixedSuffixScrollRegionToken = this.#ui.acquireFixedSuffixScrollRegion(this.#itermOwner); + if (!fixedSuffixScrollRegionToken || !current() || this.#itermLease !== token) { + if (fixedSuffixScrollRegionToken) this.#ui.releaseFixedSuffixScrollRegion(fixedSuffixScrollRegionToken); + return; + } + this.#fixedSuffixScrollRegionToken = fixedSuffixScrollRegionToken; + if (this.#ui.armFixedSuffixScrollRegion(fixedSuffixScrollRegionToken, token) === undefined) + this.#releaseFixedSuffixScrollRegion(); } #scheduleAutoFlex(now: number): void { if (!this.#autoFlexGapMs) return; diff --git a/packages/coding-agent/test/gajae-pet-widget.test.ts b/packages/coding-agent/test/gajae-pet-widget.test.ts index 555a728ef1..de69ddf436 100644 --- a/packages/coding-agent/test/gajae-pet-widget.test.ts +++ b/packages/coding-agent/test/gajae-pet-widget.test.ts @@ -31,6 +31,8 @@ function makeStubs(columns = 80, rows = 30) { let failWrites = false; let manualViewportActive = false; let rasterToken = 0; + let fixedSuffixScrollRegionToken = 0; + const fixedSuffixScrollRegionOwners = new Map(); const rasterOutputs: Uint8Array[] = []; const rasterCursorVisibilityRestores: Array = []; const invalidatedRasterLeases: Array<{ token: unknown; cause?: string }> = []; @@ -85,6 +87,19 @@ function makeStubs(columns = 80, rows = 30) { return manualViewportActive; }, terminal, + acquireFixedSuffixScrollRegion: (ownerId: string) => { + const token = { ownerId, generation: ++fixedSuffixScrollRegionToken }; + fixedSuffixScrollRegionOwners.set(ownerId, token); + return token; + }, + releaseFixedSuffixScrollRegion: (token: { ownerId: string; generation: number }) => { + if (fixedSuffixScrollRegionOwners.get(token.ownerId) === token) + fixedSuffixScrollRegionOwners.delete(token.ownerId); + }, + armFixedSuffixScrollRegion: (token: { ownerId: string; generation: number }) => { + if (fixedSuffixScrollRegionOwners.get(token.ownerId) !== token) return undefined; + return ++renderRequests; + }, acquireRasterLease: async (request: { ownerId: string; rect: { column: number; row: number; width: number; height: number }; @@ -162,6 +177,7 @@ function makeStubs(columns = 80, rows = 30) { getInvalidatedRasterLeases: () => invalidatedRasterLeases, getRasterLeaseRequests: () => rasterLeaseRequests, getRasterCursorVisibilityRestores: () => rasterCursorVisibilityRestores, + getFixedSuffixScrollRegionOwnerCount: () => fixedSuffixScrollRegionOwners.size, getPendingRasterAcquireCount: () => rasterAcquireWaiters.length, setRasterAcquireDelayed: (value: boolean) => { delayRasterAcquire = value; @@ -1196,6 +1212,7 @@ describe("GajaePetWidget", () => { stubs.widget.setMode("red"); vi.advanceTimersByTime(80); await flushAsyncChain(); + expect(stubs.getFixedSuffixScrollRegionOwnerCount()).toBe(1); // composerBottom = 28; the three-row canvas starts at zero-based row 25. // Its transparent half-cell insets center the two-row sprite in that canvas. @@ -1524,6 +1541,7 @@ describe("GajaePetWidget", () => { vi.advanceTimersByTime(160); await flushAsyncChain(); expect(stubs.getRasterOutputs()).toHaveLength(0); + expect(stubs.getFixedSuffixScrollRegionOwnerCount()).toBe(0); stubs.setManualViewportActive(false); vi.advanceTimersByTime(80); @@ -1534,6 +1552,7 @@ describe("GajaePetWidget", () => { .map(record => new TextDecoder().decode(record)) .some(record => record.includes("MultipartFile=")), ).toBe(true); + expect(stubs.getFixedSuffixScrollRegionOwnerCount()).toBe(1); } finally { setVerifiedItermPetAvailability(undefined); stubs.widget.dispose(); diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 3f32904f40..961ea391e0 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -1023,6 +1023,7 @@ export class TUI extends Container { #fixedSuffixScrollRegionGeneration = 0; #fixedSuffixScrollRegionOwners = new Map(); #armedFixedSuffixScrollRegionToken: FixedSuffixScrollRegionToken | undefined; + #armedFixedSuffixScrollRegionRasterLease: RasterLeaseToken | undefined; #fixedSuffixScrollRegionResetPending = false; #unsubscribeTabWidthChange?: () => void; @@ -1294,28 +1295,34 @@ export class TUI extends Container { this.#fixedSuffixScrollRegionOwners.delete(token.ownerId); if (this.#armedFixedSuffixScrollRegionToken === token) { this.#armedFixedSuffixScrollRegionToken = undefined; + this.#armedFixedSuffixScrollRegionRasterLease = undefined; } } /** * Keep a current owner armed for eligible transcript appends. Every DECSTBM - * transaction still establishes and resets its own terminal state. + * transaction still establishes and resets its own terminal state. A supplied + * raster lease must be current for the same owner and is preserved only when + * it is the sole active lease. */ - armFixedSuffixScrollRegion(token: FixedSuffixScrollRegionToken): number | undefined { + armFixedSuffixScrollRegion(token: FixedSuffixScrollRegionToken, rasterLease?: RasterLeaseToken): number | undefined { if ( this.#fixedSuffixScrollRegionOwners.get(token.ownerId) !== token || !this.terminalAvailable || this.#stopped || this.manualViewportActive || - this.#bottomPinnedComponent === null + this.#bottomPinnedComponent === null || + (rasterLease !== undefined && this.#rasterLeases.get(token.ownerId)?.token !== rasterLease) ) return undefined; this.#armedFixedSuffixScrollRegionToken = token; + this.#armedFixedSuffixScrollRegionRasterLease = rasterLease; return this.requestRenderWithGeneration(false, "fixed-suffix-scroll-region"); } #resetFixedSuffixScrollRegions(): void { this.#armedFixedSuffixScrollRegionToken = undefined; + this.#armedFixedSuffixScrollRegionRasterLease = undefined; this.#fixedSuffixScrollRegionOwners.clear(); } @@ -3990,6 +3997,7 @@ export class TUI extends Container { #doRender(): void { const fixedSuffixScrollRegionToken = this.#armedFixedSuffixScrollRegionToken; + const fixedSuffixScrollRegionRasterLease = this.#armedFixedSuffixScrollRegionRasterLease; if (this.#stopped || !this.terminalAvailable) return; const transcriptIdentityReplaced = this.#transcriptIdentityReplaced; const restartViewportRepaintPending = this.#restartViewportRepaintPending; @@ -4725,7 +4733,43 @@ export class TUI extends Container { } const nextLiveViewportTop = Math.max(0, newLines.length - height); + const fixedSuffixNativeAppend = + fixedSuffixScrollRegionToken !== undefined && + this.#fixedSuffixScrollRegionOwners.get(fixedSuffixScrollRegionToken.ownerId) === + fixedSuffixScrollRegionToken && + appendedLines && + hasStickySuffix && + !widthChanged && + !heightChanged && + !transcriptIdentityReplaced && + !restartViewportRepaintPending && + !resizeRenderMutationQueued && + !widthSettleRenderQueued && + !tabWidthRepairPending && + !forcedRenderQueued && + !anchorRenderFailed && + this.overlayStack.length === 0 && + previousKittyPlacementSpans.length === 0 && + nextKittyPlacementSpans.length === 0 && + this.#scrollbackResumeViewportTop === undefined && + previousSuffixLineCount > 0 && + previousSuffixLineCount === nextSuffixLineCount && + nextSuffixLineCount < height && + previousLogicalFrame.length === previousTranscriptLineCount + previousSuffixLineCount && + nextTranscriptLineCount > previousTranscriptLineCount && + firstChanged === previousTranscriptLineCount && + newLines.slice(0, previousTranscriptLineCount).every((line, index) => line === previousLogicalFrame[index]); + const fixedSuffixNativeAppendPreservesRasterLease = + fixedSuffixNativeAppend && + this.#rasterCleanup.size === 0 && + (this.#rasterLeases.size === 0 || + (this.#rasterLeases.size === 1 && + fixedSuffixScrollRegionToken !== undefined && + fixedSuffixScrollRegionRasterLease !== undefined && + this.#rasterLeases.get(fixedSuffixScrollRegionToken.ownerId)?.token === + fixedSuffixScrollRegionRasterLease)); if ( + !fixedSuffixNativeAppendPreservesRasterLease && this.#rasterLeases.size > 0 && this.#rasterCleanup.size === 0 && !newLines.slice(Math.max(0, newLines.length - height)).some(line => TERMINAL.isImageLine(line)) @@ -4932,33 +4976,7 @@ export class TUI extends Container { } return; } - const fixedSuffixNativeAppend = - fixedSuffixScrollRegionToken !== undefined && - this.#fixedSuffixScrollRegionOwners.get(fixedSuffixScrollRegionToken.ownerId) === - fixedSuffixScrollRegionToken && - appendedLines && - hasStickySuffix && - !widthChanged && - !heightChanged && - !transcriptIdentityReplaced && - !restartViewportRepaintPending && - !resizeRenderMutationQueued && - !widthSettleRenderQueued && - !tabWidthRepairPending && - !forcedRenderQueued && - !anchorRenderFailed && - this.overlayStack.length === 0 && - previousKittyPlacementSpans.length === 0 && - nextKittyPlacementSpans.length === 0 && - this.#scrollbackResumeViewportTop === undefined && - previousSuffixLineCount > 0 && - previousSuffixLineCount === nextSuffixLineCount && - nextSuffixLineCount < height && - previousLogicalFrame.length === previousTranscriptLineCount + previousSuffixLineCount && - nextTranscriptLineCount > previousTranscriptLineCount && - firstChanged === previousTranscriptLineCount && - newLines.slice(0, previousTranscriptLineCount).every((line, index) => line === previousLogicalFrame[index]); - if (fixedSuffixNativeAppend) { + if (fixedSuffixNativeAppendPreservesRasterLease) { const regionBottom = height - nextSuffixLineCount; let fixedSuffixBuffer = `\x1b[?2026h\x1b7\x1b[?6l\x1b[1;${regionBottom}r\x1b[${regionBottom};1H`; for (let lineIndex = previousTranscriptLineCount; lineIndex < nextTranscriptLineCount; lineIndex += 1) { @@ -4976,19 +4994,25 @@ export class TUI extends Container { fixedSuffixBuffer += `\x1b8\x1b[r\x1b[?6l${seq}\x1b[?2026l`; this.#fixedSuffixScrollRegionResetPending = true; if ( - !this.#writeRenderBufferAndReanchorImeCursor(fixedSuffixBuffer, cursorPos, newLines.length, () => { - this.#hardwareCursorRow = toRow; - this.#cursorRow = Math.max(0, newLines.length - 1); - this.#maxLinesRendered = newLines.length; - this.#viewportTopRow = Math.max(0, newLines.length - height); - this.#nativeScrollbackViewportTop = Math.max(this.#nativeScrollbackViewportTop, this.#viewportTopRow); - this.#previousLines = newLines; - this.#previousWidth = width; - this.#previousHeight = height; - this.#manualTranscriptLineCount = nextTranscriptLineCount; - this.#manualSuffixLineCount = nextSuffixLineCount; - this.#refreshPaintedLiveViewportObservation(height); - }) + !this.#writeRenderBufferAndReanchorImeCursor( + fixedSuffixBuffer, + cursorPos, + newLines.length, + () => { + this.#hardwareCursorRow = toRow; + this.#cursorRow = Math.max(0, newLines.length - 1); + this.#maxLinesRendered = newLines.length; + this.#viewportTopRow = Math.max(0, newLines.length - height); + this.#nativeScrollbackViewportTop = Math.max(this.#nativeScrollbackViewportTop, this.#viewportTopRow); + this.#previousLines = newLines; + this.#previousWidth = width; + this.#previousHeight = height; + this.#manualTranscriptLineCount = nextTranscriptLineCount; + this.#manualSuffixLineCount = nextSuffixLineCount; + this.#refreshPaintedLiveViewportObservation(height); + }, + fixedSuffixScrollRegionRasterLease !== undefined, + ) ) return; this.#fixedSuffixScrollRegionResetPending = false; @@ -5031,6 +5055,7 @@ export class TUI extends Container { : firstChanged; const appendWillScroll = appendStart && moveTargetRow >= prevViewportBottom; if ( + !fixedSuffixNativeAppendPreservesRasterLease && (moveTargetRow > prevViewportBottom || appendWillScroll || renderEnd > prevViewportBottom) && this.#rasterLeases.size > 0 && this.#rasterCleanup.size === 0 && diff --git a/packages/tui/test/fixed-suffix-scroll-region.test.ts b/packages/tui/test/fixed-suffix-scroll-region.test.ts index 1cd453a1ba..c5a0fa4280 100644 --- a/packages/tui/test/fixed-suffix-scroll-region.test.ts +++ b/packages/tui/test/fixed-suffix-scroll-region.test.ts @@ -80,6 +80,53 @@ describe("TUI fixed suffix scroll region", () => { } }); + it("preserves a bound raster lease while advancing native scrollback", async () => { + const { term, transcript, tui } = createPinnedTui(); + let invalidated = 0; + try { + tui.start(); + await term.waitForRender(); + const lease = await tui.acquireRasterLease({ + ownerId: "iterm-owner", + rect: { column: 36, row: 2, width: 3, height: 3 }, + erase: { type: "raster-erase", bytes: new TextEncoder().encode("ITERM_ERASE") }, + onInvalidated: () => invalidated++, + }); + expect(lease.status).toBe("acquired"); + if (lease.status !== "acquired") throw new Error("Expected iTerm lease"); + const token = tui.acquireFixedSuffixScrollRegion("iterm-owner"); + expect(token).toBeDefined(); + if (token === undefined) throw new Error("Expected fixed suffix token"); + + transcript.setLines(["line-1", "line-2", "line-3", "line-4"]); + term.clearWriteLog(); + expect(tui.armFixedSuffixScrollRegion(token, lease.token)).toBeGreaterThan(0); + await term.waitForRender(); + + const output = term.getWriteLog().join(""); + expect(output).toContain("\x1b[1;3r"); + expect(output).toContain("\x1bD\r\x1b[2Kline-4"); + expect(output).not.toContain("ITERM_ERASE"); + expect(invalidated).toBe(0); + await term.flush(); + expect(term.getScrollBuffer().map(line => line.trimEnd())).toContain("line-1"); + transcript.setLines(["line-1", "line-2", "line-3", "line-4", "line-5"]); + term.clearWriteLog(); + tui.requestRender(); + await term.waitForRender(); + const streamingOutput = term.getWriteLog().join(""); + expect(streamingOutput).toContain("\x1b[1;3r"); + expect(streamingOutput).toContain("\x1bD\r\x1b[2Kline-5"); + expect(streamingOutput).not.toContain("ITERM_ERASE"); + expect(invalidated).toBe(0); + await term.flush(); + const scrollback = term.getScrollBuffer().map(line => line.trimEnd()); + expect(scrollback.filter(line => line === "line-1")).toHaveLength(1); + expect(scrollback.filter(line => line === "line-2")).toHaveLength(1); + } finally { + tui.stop(); + } + }); it("uses the existing renderer unless a current owner arms the fixed suffix region", async () => { const { term, transcript, tui } = createPinnedTui(); try { From 4c54525424884aa686c592b80923c6c4fc3248d5 Mon Sep 17 00:00:00 2001 From: snowykr Date: Sun, 2 Aug 2026 04:45:29 +0900 Subject: [PATCH 03/46] fix(iterm): preserve Pet cells during suffix repaint A bound iTerm raster lease now keeps its suffix cells free of erase-line controls while the padded suffix repaint preserves native scrollback. Lore-id: decstbm-fix-02 Constraint: retain generic suffix clearing without a raster binding Tested: fixed suffix, raster lease, and render commit regressions Scope-risk: narrow --- packages/tui/src/tui.ts | 3 ++- packages/tui/test/fixed-suffix-scroll-region.test.ts | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 961ea391e0..45773df3c0 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -4983,10 +4983,11 @@ export class TUI extends Container { fixedSuffixBuffer += `\x1bD\r\x1b[2K${this.#padLineToWidth(newLines[lineIndex]!, width)}`; } fixedSuffixBuffer += "\x1b[r\x1b[?6l"; + const suffixLinePrefix = fixedSuffixScrollRegionRasterLease === undefined ? "\x1b[2K" : ""; for (let suffixIndex = 0; suffixIndex < nextSuffixLineCount; suffixIndex += 1) { const suffixRow = regionBottom + suffixIndex + 1; const suffixLine = newLines[nextTranscriptLineCount + suffixIndex] ?? ""; - fixedSuffixBuffer += `\x1b[${suffixRow};1H\x1b[2K${this.#padLineToWidth(suffixLine, width)}`; + fixedSuffixBuffer += `\x1b[${suffixRow};1H${suffixLinePrefix}${this.#padLineToWidth(suffixLine, width)}`; } const transcriptDelta = nextTranscriptLineCount - previousTranscriptLineCount; const restoredHardwareCursorRow = this.#hardwareCursorRow + transcriptDelta; diff --git a/packages/tui/test/fixed-suffix-scroll-region.test.ts b/packages/tui/test/fixed-suffix-scroll-region.test.ts index c5a0fa4280..11d55632ef 100644 --- a/packages/tui/test/fixed-suffix-scroll-region.test.ts +++ b/packages/tui/test/fixed-suffix-scroll-region.test.ts @@ -108,6 +108,8 @@ describe("TUI fixed suffix scroll region", () => { expect(output).toContain("\x1bD\r\x1b[2Kline-4"); expect(output).not.toContain("ITERM_ERASE"); expect(invalidated).toBe(0); + expect(output).not.toContain("\x1b[4;1H\x1b[2K"); + expect(output).not.toContain("\x1b[5;1H\x1b[2K"); await term.flush(); expect(term.getScrollBuffer().map(line => line.trimEnd())).toContain("line-1"); transcript.setLines(["line-1", "line-2", "line-3", "line-4", "line-5"]); @@ -119,6 +121,8 @@ describe("TUI fixed suffix scroll region", () => { expect(streamingOutput).toContain("\x1bD\r\x1b[2Kline-5"); expect(streamingOutput).not.toContain("ITERM_ERASE"); expect(invalidated).toBe(0); + expect(streamingOutput).not.toContain("\x1b[4;1H\x1b[2K"); + expect(streamingOutput).not.toContain("\x1b[5;1H\x1b[2K"); await term.flush(); const scrollback = term.getScrollBuffer().map(line => line.trimEnd()); expect(scrollback.filter(line => line === "line-1")).toHaveLength(1); From edecaa0cd793e393acc61b39c5ad0edbdf66807b Mon Sep 17 00:00:00 2001 From: snowykr Date: Sun, 2 Aug 2026 04:48:22 +0900 Subject: [PATCH 04/46] docs(iterm): record native scrollback behavior Release notes now cover the raster-bound suffix primitive and the iTerm Pet behavior it enables. Lore-id: decstbm-docs-01 Tested: focused TUI and coding-agent checks Scope-risk: narrow --- packages/coding-agent/CHANGELOG.md | 1 + packages/tui/CHANGELOG.md | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index e18ff0e2ab..b7bae5bf0a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -13,6 +13,7 @@ - Notification settings now expose first-class Telegram, Discord, and Slack configuration, desired-intent toggles, provider-local quarantine and repair guidance, explicit `keep | replace | remove` secret actions, provider-specific health/test diagnostics, and truthful saved-but-runtime-degraded outcomes. The global master preserves provider credentials and intent, `GJC_NOTIFICATIONS=0` suppresses only automatic generic-session admission, and blocked Telegram ownership uses an isolated chat-only endpoint so verified Discord or Slack siblings can continue without exposing the shared endpoint. - Added capability-gated iTerm2 Pet GIF rendering with managed tmux transport, manual-history suspension, and lifecycle-safe raster cleanup. +- iTerm2 Pet rendering now keeps the composer and inline GIF stable while streaming transcript rows enter native terminal scrollback. ### Fixed - Windows automatic tmux resolution now selects `psmux` then `pmux` by canonical command order without rejecting distinct lower-priority aliases; it probes `tmux` only when neither named provider is available (#3725). diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index a8ec9e672f..62438caffd 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -6,6 +6,7 @@ ### Added - Added an opt-in fixed-suffix DECSTBM transaction API for terminal-native transcript scrollback while a bottom-pinned suffix remains stable. +- The fixed-suffix scroll-region API can bind its current sole raster lease, preserving inline iTerm Pet cells while transcript rows enter native scrollback. ### Fixed - Fixed a tool block being rendered two or three times in the transcript (a pending `⏳` copy stranded above its own completed `✓` copy, with the rows between duplicated). When a block above the live viewport top grows in place — the bash tool's compact call render becoming a partial box and then a final box, with the editor/status chrome keeping it off-screen — the "commit only the changed visible suffix" path emitted rows by index even though the growth had shifted committed content down across the native-scrollback frontier, so rows already in scrollback were appended a second time under their new content. The suffix commit is now taken only when the last committed row is unchanged (a same-length off-screen substitution, such as a streaming status line); growth that shifts the committed boundary repaints the live viewport instead. From 7541354dd479e1aa55a515a06d1cd1ebb4cd3f37 Mon Sep 17 00:00:00 2001 From: snowykr Date: Sun, 2 Aug 2026 05:27:51 +0900 Subject: [PATCH 05/46] fix(iterm): retain native admission on lease fallback An ineligible fixed-suffix raster binding now releases the exceptional lease through protected ingress and keeps the DECSTBM transcript transaction, instead of repainting past rows that host scrollback never received. Lore-id: decstbm-fix-03 Constraint: preserve the normal sole iTerm lease no-flicker path Tested: fixed suffix, raster lease, and render commit regressions; TUI check Scope-risk: narrow --- packages/tui/src/tui.ts | 17 ++++++++-- .../test/fixed-suffix-scroll-region.test.ts | 33 +++++++++++++++++++ 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 45773df3c0..11380eb855 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -4768,8 +4768,14 @@ export class TUI extends Container { fixedSuffixScrollRegionRasterLease !== undefined && this.#rasterLeases.get(fixedSuffixScrollRegionToken.ownerId)?.token === fixedSuffixScrollRegionRasterLease)); + // A fixed suffix owner promises native transcript admission. If its raster + // binding is no longer sole/current, erase that exceptional lease through + // the protected ingress rather than repainting rows that host history misses. + const fixedSuffixScrollbackFallback = + fixedSuffixScrollRegionToken !== undefined && appendedLines && !fixedSuffixNativeAppendPreservesRasterLease; if ( !fixedSuffixNativeAppendPreservesRasterLease && + !fixedSuffixScrollbackFallback && this.#rasterLeases.size > 0 && this.#rasterCleanup.size === 0 && !newLines.slice(Math.max(0, newLines.length - height)).some(line => TERMINAL.isImageLine(line)) @@ -4976,14 +4982,17 @@ export class TUI extends Container { } return; } - if (fixedSuffixNativeAppendPreservesRasterLease) { + if (fixedSuffixNativeAppend) { const regionBottom = height - nextSuffixLineCount; let fixedSuffixBuffer = `\x1b[?2026h\x1b7\x1b[?6l\x1b[1;${regionBottom}r\x1b[${regionBottom};1H`; for (let lineIndex = previousTranscriptLineCount; lineIndex < nextTranscriptLineCount; lineIndex += 1) { fixedSuffixBuffer += `\x1bD\r\x1b[2K${this.#padLineToWidth(newLines[lineIndex]!, width)}`; } fixedSuffixBuffer += "\x1b[r\x1b[?6l"; - const suffixLinePrefix = fixedSuffixScrollRegionRasterLease === undefined ? "\x1b[2K" : ""; + const suffixLinePrefix = + fixedSuffixNativeAppendPreservesRasterLease && fixedSuffixScrollRegionRasterLease !== undefined + ? "" + : "\x1b[2K"; for (let suffixIndex = 0; suffixIndex < nextSuffixLineCount; suffixIndex += 1) { const suffixRow = regionBottom + suffixIndex + 1; const suffixLine = newLines[nextTranscriptLineCount + suffixIndex] ?? ""; @@ -5012,7 +5021,7 @@ export class TUI extends Container { this.#manualSuffixLineCount = nextSuffixLineCount; this.#refreshPaintedLiveViewportObservation(height); }, - fixedSuffixScrollRegionRasterLease !== undefined, + fixedSuffixNativeAppendPreservesRasterLease, ) ) return; @@ -5057,6 +5066,7 @@ export class TUI extends Container { const appendWillScroll = appendStart && moveTargetRow >= prevViewportBottom; if ( !fixedSuffixNativeAppendPreservesRasterLease && + !fixedSuffixScrollbackFallback && (moveTargetRow > prevViewportBottom || appendWillScroll || renderEnd > prevViewportBottom) && this.#rasterLeases.size > 0 && this.#rasterCleanup.size === 0 && @@ -5103,6 +5113,7 @@ export class TUI extends Container { // Only render changed lines (firstChanged to lastChanged), not all lines to end. // This reduces flicker when only a single line changes (e.g., spinner animation). const preserveRasterLeases = + !fixedSuffixScrollbackFallback && this.#rasterLeases.size > 0 && this.#rasterCleanup.size === 0 && moveTargetRow <= prevViewportBottom && diff --git a/packages/tui/test/fixed-suffix-scroll-region.test.ts b/packages/tui/test/fixed-suffix-scroll-region.test.ts index 11d55632ef..a240015970 100644 --- a/packages/tui/test/fixed-suffix-scroll-region.test.ts +++ b/packages/tui/test/fixed-suffix-scroll-region.test.ts @@ -131,6 +131,39 @@ describe("TUI fixed suffix scroll region", () => { tui.stop(); } }); + it("releases an ineligible raster lease before admitting a fixed-suffix append", async () => { + const { term, transcript, tui } = createPinnedTui(); + let invalidated = 0; + try { + tui.start(); + await term.waitForRender(); + const lease = await tui.acquireRasterLease({ + ownerId: "other-owner", + rect: { column: 36, row: 2, width: 3, height: 3 }, + erase: { type: "raster-erase", bytes: new TextEncoder().encode("ITERM_ERASE") }, + onInvalidated: () => invalidated++, + }); + expect(lease.status).toBe("acquired"); + const token = tui.acquireFixedSuffixScrollRegion("iterm-owner"); + expect(token).toBeDefined(); + if (token === undefined) throw new Error("Expected fixed suffix token"); + + transcript.setLines(["line-1", "line-2", "line-3", "line-4"]); + term.clearWriteLog(); + expect(tui.armFixedSuffixScrollRegion(token)).toBeGreaterThan(0); + await term.waitForRender(); + + const output = term.getWriteLog().join(""); + expect(output).toContain("ITERM_ERASE"); + expect(output).toContain("\x1b[1;3r"); + expect(output).toContain("\x1bD\r\x1b[2Kline-4"); + expect(invalidated).toBe(1); + await term.flush(); + expect(term.getScrollBuffer().map(line => line.trimEnd())).toContain("line-1"); + } finally { + tui.stop(); + } + }); it("uses the existing renderer unless a current owner arms the fixed suffix region", async () => { const { term, transcript, tui } = createPinnedTui(); try { From 87003ee776455b26d76d47103ffec8e53d72b8ac Mon Sep 17 00:00:00 2001 From: snowykr Date: Sun, 2 Aug 2026 05:40:53 +0900 Subject: [PATCH 06/46] fix(iterm): preserve native admission before Pet arm A raster lease exists while the iTerm GIF upload is still pending, before its fixed-suffix token is armed. Mark only that lease as eligible so overflow yields through protected native admission instead of silently repainting away host history.\n\nConstraint: preserve Kitty, Sixel, and generic raster lease behavior\nTested: focused TUI raster/fixed-suffix/render tests; focused Pet transport/QA tests; package checks\nScope-risk: narrow\nReversibility: straightforward --- .../src/modes/components/gajae-pet-widget.ts | 1 + .../test/gajae-pet-widget.test.ts | 9 +++- packages/tui/src/tui.ts | 28 +++++++++---- packages/tui/test/raster-lease.test.ts | 41 +++++++++++++++++++ 4 files changed, 69 insertions(+), 10 deletions(-) diff --git a/packages/coding-agent/src/modes/components/gajae-pet-widget.ts b/packages/coding-agent/src/modes/components/gajae-pet-widget.ts index d514229cc3..b5c7e8be08 100644 --- a/packages/coding-agent/src/modes/components/gajae-pet-widget.ts +++ b/packages/coding-agent/src/modes/components/gajae-pet-widget.ts @@ -659,6 +659,7 @@ export class GajaePetWidget { ).join("")}`, ), }, + nativeScrollbackEligible: true, onInvalidated: notice => { if (this.#itermLease === notice.token) { this.#releaseFixedSuffixScrollRegion(); diff --git a/packages/coding-agent/test/gajae-pet-widget.test.ts b/packages/coding-agent/test/gajae-pet-widget.test.ts index de69ddf436..af37de96dc 100644 --- a/packages/coding-agent/test/gajae-pet-widget.test.ts +++ b/packages/coding-agent/test/gajae-pet-widget.test.ts @@ -39,6 +39,7 @@ function makeStubs(columns = 80, rows = 30) { const rasterLeaseRequests: Array<{ rect: { column: number; row: number; width: number; height: number }; erase: { type: string; bytes: Uint8Array }; + nativeScrollbackEligible?: boolean; }> = []; let delayRasterAcquire = false; const rasterAcquireWaiters: Array<() => void> = []; @@ -104,8 +105,13 @@ function makeStubs(columns = 80, rows = 30) { ownerId: string; rect: { column: number; row: number; width: number; height: number }; erase: { type: string; bytes: Uint8Array }; + nativeScrollbackEligible?: boolean; }) => { - rasterLeaseRequests.push({ rect: request.rect, erase: request.erase }); + rasterLeaseRequests.push({ + rect: request.rect, + erase: request.erase, + nativeScrollbackEligible: request.nativeScrollbackEligible, + }); const result = { status: "acquired", token: { ownerId: request.ownerId, generation: ++rasterToken, rect: request.rect }, @@ -1319,6 +1325,7 @@ describe("GajaePetWidget", () => { expect(new TextDecoder().decode(lease?.erase.bytes)).toBe( "\x1b[0m\x1b[1;76H\x1b[4X\x1b[2;76H\x1b[4X\x1b[3;76H\x1b[4X", ); + expect(lease?.nativeScrollbackEligible).toBe(true); } finally { setVerifiedItermPetAvailability(undefined); stubs.widget.dispose(); diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 11380eb855..a0497a882e 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -55,6 +55,7 @@ export type RasterLeaseRequest = Readonly<{ rect: CellRect; erase: Readonly<{ type: "raster-erase"; bytes: Uint8Array }>; onInvalidated?: (notice: RasterLeaseInvalidatedNotification) => void; + nativeScrollbackEligible?: boolean; }>; export type TerminalOutputOperation = | Readonly<{ type: "generic-render"; rect: CellRect; bytes: Uint8Array }> @@ -1002,6 +1003,7 @@ export class TUI extends Container { token: RasterLeaseToken; erase: Uint8Array; callback?: (n: RasterLeaseInvalidatedNotification) => void; + nativeScrollbackEligible: boolean; revoked: boolean; } >(); @@ -1809,7 +1811,8 @@ export class TUI extends Container { typeof request.erase !== "object" || request.erase.type !== "raster-erase" || !(request.erase.bytes instanceof Uint8Array) || - (request.onInvalidated !== undefined && typeof request.onInvalidated !== "function") + (request.onInvalidated !== undefined && typeof request.onInvalidated !== "function") || + (request.nativeScrollbackEligible !== undefined && typeof request.nativeScrollbackEligible !== "boolean") ) return { status: "rejected", reason: "invalid-geometry" }; if (!this.#validRect(request.rect)) return { status: "rejected", reason: "invalid-geometry" }; @@ -1831,6 +1834,7 @@ export class TUI extends Container { token, erase: new Uint8Array(request.erase.bytes), callback: request.onInvalidated, + nativeScrollbackEligible: request.nativeScrollbackEligible === true, revoked: false, }); return { status: "acquired", token }; @@ -4768,14 +4772,20 @@ export class TUI extends Container { fixedSuffixScrollRegionRasterLease !== undefined && this.#rasterLeases.get(fixedSuffixScrollRegionToken.ownerId)?.token === fixedSuffixScrollRegionRasterLease)); - // A fixed suffix owner promises native transcript admission. If its raster - // binding is no longer sole/current, erase that exceptional lease through - // the protected ingress rather than repainting rows that host history misses. - const fixedSuffixScrollbackFallback = - fixedSuffixScrollRegionToken !== undefined && appendedLines && !fixedSuffixNativeAppendPreservesRasterLease; + const soleRasterLease = this.#rasterLeases.size === 1 ? this.#rasterLeases.values().next().value : undefined; + // A verified iTerm lease must yield before an overflow can overwrite a row + // that native history has not received. The fixed token covers an armed + // owner; the explicit capability covers the pre-arm upload window. + const rasterMustYieldForNativeAdmission = + appendedLines && + nextLiveViewportTop > prevViewportTop && + !fixedSuffixNativeAppendPreservesRasterLease && + this.#rasterLeases.size > 0 && + this.#rasterCleanup.size === 0 && + (fixedSuffixScrollRegionToken !== undefined || soleRasterLease?.nativeScrollbackEligible === true); if ( !fixedSuffixNativeAppendPreservesRasterLease && - !fixedSuffixScrollbackFallback && + !rasterMustYieldForNativeAdmission && this.#rasterLeases.size > 0 && this.#rasterCleanup.size === 0 && !newLines.slice(Math.max(0, newLines.length - height)).some(line => TERMINAL.isImageLine(line)) @@ -5066,7 +5076,7 @@ export class TUI extends Container { const appendWillScroll = appendStart && moveTargetRow >= prevViewportBottom; if ( !fixedSuffixNativeAppendPreservesRasterLease && - !fixedSuffixScrollbackFallback && + !rasterMustYieldForNativeAdmission && (moveTargetRow > prevViewportBottom || appendWillScroll || renderEnd > prevViewportBottom) && this.#rasterLeases.size > 0 && this.#rasterCleanup.size === 0 && @@ -5113,7 +5123,7 @@ export class TUI extends Container { // Only render changed lines (firstChanged to lastChanged), not all lines to end. // This reduces flicker when only a single line changes (e.g., spinner animation). const preserveRasterLeases = - !fixedSuffixScrollbackFallback && + !rasterMustYieldForNativeAdmission && this.#rasterLeases.size > 0 && this.#rasterCleanup.size === 0 && moveTargetRow <= prevViewportBottom && diff --git a/packages/tui/test/raster-lease.test.ts b/packages/tui/test/raster-lease.test.ts index e02db6c74f..f6a302c510 100644 --- a/packages/tui/test/raster-lease.test.ts +++ b/packages/tui/test/raster-lease.test.ts @@ -445,6 +445,47 @@ describe("TUI raster lease public boundary", () => { expect(calls).toBe(0); tui.stop(); }); + it("yields an eligible pre-arm lease before repeated native overflow admission", async () => { + const { tui, terminal } = await setup(); + let lines = ["one", "two", "three", "four"]; + let calls = 0; + const component: Component = { render: () => lines, invalidate() {} }; + tui.addChild(component); + tui.start(); + await terminal.waitForRender(); + + const lease = await tui.acquireRasterLease({ + ...request("iterm-pre-arm", rect(8, 3, 2, 1), "ERASE", () => calls++), + nativeScrollbackEligible: true, + }); + expect(lease.status).toBe("acquired"); + terminal.clearWriteLog(); + lines = [...lines, "five"]; + tui.requestRender(); + await terminal.waitForRender(); + + const output = terminal.getWriteLog().join(""); + expect(output).toContain("ERASE"); + expect(output).toContain("\r\n"); + expect(calls).toBe(1); + await terminal.flush(); + expect( + terminal + .getScrollBuffer() + .map(line => line.trimEnd()) + .filter(line => line === "one"), + ).toHaveLength(1); + + lines = [...lines, "six"]; + terminal.clearWriteLog(); + tui.requestRender(); + await terminal.waitForRender(); + await terminal.flush(); + const scrollback = terminal.getScrollBuffer().map(line => line.trimEnd()); + expect(scrollback.filter(line => line === "one")).toHaveLength(1); + expect(scrollback.filter(line => line === "two")).toHaveLength(1); + tui.stop(); + }); it("repaints rewritten streaming output without scrolling an active raster", async () => { const { tui, terminal } = await setup(); let lines = ["one", "two", "three", "four"]; From 16a6f303cc97034b4588fd06ca765cb4df477526 Mon Sep 17 00:00:00 2001 From: snowykr Date: Sun, 2 Aug 2026 15:10:52 +0900 Subject: [PATCH 07/46] fix(tui): preserve raster cells in fixed suffix DECSTBM kept the composer suffix fixed but rewrote every suffix cell, including the live iTerm raster footprint. Paint only complementary cell spans while the bound lease remains active.\n\nConstraint: retain native scrollback without GIF reupload\nTested: fixed-suffix, raster-lease, and render-commit tests; TUI package check\nScope-risk: narrow\nReversibility: straightforward --- packages/tui/src/tui.ts | 15 ++++++++++----- .../tui/test/fixed-suffix-scroll-region.test.ts | 12 ++++++++++++ 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index a0497a882e..69fa62df70 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -4999,14 +4999,19 @@ export class TUI extends Container { fixedSuffixBuffer += `\x1bD\r\x1b[2K${this.#padLineToWidth(newLines[lineIndex]!, width)}`; } fixedSuffixBuffer += "\x1b[r\x1b[?6l"; - const suffixLinePrefix = - fixedSuffixNativeAppendPreservesRasterLease && fixedSuffixScrollRegionRasterLease !== undefined - ? "" - : "\x1b[2K"; + const preserveFixedSuffixRaster = + fixedSuffixNativeAppendPreservesRasterLease && fixedSuffixScrollRegionRasterLease !== undefined; for (let suffixIndex = 0; suffixIndex < nextSuffixLineCount; suffixIndex += 1) { const suffixRow = regionBottom + suffixIndex + 1; const suffixLine = newLines[nextTranscriptLineCount + suffixIndex] ?? ""; - fixedSuffixBuffer += `\x1b[${suffixRow};1H${suffixLinePrefix}${this.#padLineToWidth(suffixLine, width)}`; + if (preserveFixedSuffixRaster) { + for (const segment of this.#unleasedRowSegments(suffixRow - 1, width)) { + fixedSuffixBuffer += `\x1b[${suffixRow};${segment.column + 1}H\x1b[${segment.width}X`; + fixedSuffixBuffer += `${sliceByColumn(suffixLine, segment.column, segment.width, true)}${SEGMENT_RESET}`; + } + continue; + } + fixedSuffixBuffer += `\x1b[${suffixRow};1H\x1b[2K${this.#padLineToWidth(suffixLine, width)}`; } const transcriptDelta = nextTranscriptLineCount - previousTranscriptLineCount; const restoredHardwareCursorRow = this.#hardwareCursorRow + transcriptDelta; diff --git a/packages/tui/test/fixed-suffix-scroll-region.test.ts b/packages/tui/test/fixed-suffix-scroll-region.test.ts index a240015970..89652f81f6 100644 --- a/packages/tui/test/fixed-suffix-scroll-region.test.ts +++ b/packages/tui/test/fixed-suffix-scroll-region.test.ts @@ -110,6 +110,12 @@ describe("TUI fixed suffix scroll region", () => { expect(invalidated).toBe(0); expect(output).not.toContain("\x1b[4;1H\x1b[2K"); expect(output).not.toContain("\x1b[5;1H\x1b[2K"); + expect(output).toContain("\x1b[4;1H\x1b[36Xstatus"); + expect(output).toContain("\x1b[4;40H\x1b[1X"); + expect(output).toContain("\x1b[5;1H\x1b[36Xcomposer"); + expect(output).toContain("\x1b[5;40H\x1b[1X"); + expect(output).not.toContain("\x1b[4;37H"); + expect(output).not.toContain("\x1b[5;37H"); await term.flush(); expect(term.getScrollBuffer().map(line => line.trimEnd())).toContain("line-1"); transcript.setLines(["line-1", "line-2", "line-3", "line-4", "line-5"]); @@ -123,6 +129,12 @@ describe("TUI fixed suffix scroll region", () => { expect(invalidated).toBe(0); expect(streamingOutput).not.toContain("\x1b[4;1H\x1b[2K"); expect(streamingOutput).not.toContain("\x1b[5;1H\x1b[2K"); + expect(streamingOutput).toContain("\x1b[4;1H\x1b[36Xstatus"); + expect(streamingOutput).toContain("\x1b[4;40H\x1b[1X"); + expect(streamingOutput).toContain("\x1b[5;1H\x1b[36Xcomposer"); + expect(streamingOutput).toContain("\x1b[5;40H\x1b[1X"); + expect(streamingOutput).not.toContain("\x1b[4;37H"); + expect(streamingOutput).not.toContain("\x1b[5;37H"); await term.flush(); const scrollback = term.getScrollBuffer().map(line => line.trimEnd()); expect(scrollback.filter(line => line === "line-1")).toHaveLength(1); From c8739dd4abe9a95b631cef178ecb0f6d4684bb95 Mon Sep 17 00:00:00 2001 From: snowykr Date: Sun, 2 Aug 2026 15:35:03 +0900 Subject: [PATCH 08/46] fix(iterm): exclude Pet rows from scroll region A transparent iTerm canvas may extend above rendered suffix lines. Bound DECSTBM at the current raster lease top so IND never scrolls a Pet row while the physical composer stays at its original rows.\n\nConstraint: preserve native scrollback and no GIF flicker\nTested: fixed-suffix, raster-lease, and render-commit tests; TUI package check\nScope-risk: narrow\nReversibility: straightforward --- packages/tui/src/tui.ts | 19 +++++++++++++++---- .../test/fixed-suffix-scroll-region.test.ts | 6 ++++-- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 69fa62df70..f186527be2 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -4763,6 +4763,10 @@ export class TUI extends Container { nextTranscriptLineCount > previousTranscriptLineCount && firstChanged === previousTranscriptLineCount && newLines.slice(0, previousTranscriptLineCount).every((line, index) => line === previousLogicalFrame[index]); + const fixedSuffixRasterLease = + fixedSuffixScrollRegionToken === undefined + ? undefined + : this.#rasterLeases.get(fixedSuffixScrollRegionToken.ownerId); const fixedSuffixNativeAppendPreservesRasterLease = fixedSuffixNativeAppend && this.#rasterCleanup.size === 0 && @@ -4770,8 +4774,8 @@ export class TUI extends Container { (this.#rasterLeases.size === 1 && fixedSuffixScrollRegionToken !== undefined && fixedSuffixScrollRegionRasterLease !== undefined && - this.#rasterLeases.get(fixedSuffixScrollRegionToken.ownerId)?.token === - fixedSuffixScrollRegionRasterLease)); + fixedSuffixRasterLease?.token === fixedSuffixScrollRegionRasterLease && + fixedSuffixRasterLease.token.rect.row > 0)); const soleRasterLease = this.#rasterLeases.size === 1 ? this.#rasterLeases.values().next().value : undefined; // A verified iTerm lease must yield before an overflow can overwrite a row // that native history has not received. The fixed token covers an armed @@ -4993,7 +4997,14 @@ export class TUI extends Container { return; } if (fixedSuffixNativeAppend) { - const regionBottom = height - nextSuffixLineCount; + const suffixRegionBottom = height - nextSuffixLineCount; + // iTerm raster cells are not ordinary text cells. Keep the entire lease + // outside DECSTBM even when its transparent canvas reaches above the + // rendered suffix, so IND cannot scroll a Pet row. + const regionBottom = + fixedSuffixNativeAppendPreservesRasterLease && fixedSuffixRasterLease !== undefined + ? Math.min(suffixRegionBottom, fixedSuffixRasterLease.token.rect.row) + : suffixRegionBottom; let fixedSuffixBuffer = `\x1b[?2026h\x1b7\x1b[?6l\x1b[1;${regionBottom}r\x1b[${regionBottom};1H`; for (let lineIndex = previousTranscriptLineCount; lineIndex < nextTranscriptLineCount; lineIndex += 1) { fixedSuffixBuffer += `\x1bD\r\x1b[2K${this.#padLineToWidth(newLines[lineIndex]!, width)}`; @@ -5002,7 +5013,7 @@ export class TUI extends Container { const preserveFixedSuffixRaster = fixedSuffixNativeAppendPreservesRasterLease && fixedSuffixScrollRegionRasterLease !== undefined; for (let suffixIndex = 0; suffixIndex < nextSuffixLineCount; suffixIndex += 1) { - const suffixRow = regionBottom + suffixIndex + 1; + const suffixRow = suffixRegionBottom + suffixIndex + 1; const suffixLine = newLines[nextTranscriptLineCount + suffixIndex] ?? ""; if (preserveFixedSuffixRaster) { for (const segment of this.#unleasedRowSegments(suffixRow - 1, width)) { diff --git a/packages/tui/test/fixed-suffix-scroll-region.test.ts b/packages/tui/test/fixed-suffix-scroll-region.test.ts index 89652f81f6..16d78bd460 100644 --- a/packages/tui/test/fixed-suffix-scroll-region.test.ts +++ b/packages/tui/test/fixed-suffix-scroll-region.test.ts @@ -104,7 +104,7 @@ describe("TUI fixed suffix scroll region", () => { await term.waitForRender(); const output = term.getWriteLog().join(""); - expect(output).toContain("\x1b[1;3r"); + expect(output).toContain("\x1b[1;2r"); expect(output).toContain("\x1bD\r\x1b[2Kline-4"); expect(output).not.toContain("ITERM_ERASE"); expect(invalidated).toBe(0); @@ -116,6 +116,7 @@ describe("TUI fixed suffix scroll region", () => { expect(output).toContain("\x1b[5;40H\x1b[1X"); expect(output).not.toContain("\x1b[4;37H"); expect(output).not.toContain("\x1b[5;37H"); + expect(output).not.toContain("\x1b[3;1H\x1bD"); await term.flush(); expect(term.getScrollBuffer().map(line => line.trimEnd())).toContain("line-1"); transcript.setLines(["line-1", "line-2", "line-3", "line-4", "line-5"]); @@ -123,7 +124,7 @@ describe("TUI fixed suffix scroll region", () => { tui.requestRender(); await term.waitForRender(); const streamingOutput = term.getWriteLog().join(""); - expect(streamingOutput).toContain("\x1b[1;3r"); + expect(streamingOutput).toContain("\x1b[1;2r"); expect(streamingOutput).toContain("\x1bD\r\x1b[2Kline-5"); expect(streamingOutput).not.toContain("ITERM_ERASE"); expect(invalidated).toBe(0); @@ -135,6 +136,7 @@ describe("TUI fixed suffix scroll region", () => { expect(streamingOutput).toContain("\x1b[5;40H\x1b[1X"); expect(streamingOutput).not.toContain("\x1b[4;37H"); expect(streamingOutput).not.toContain("\x1b[5;37H"); + expect(streamingOutput).not.toContain("\x1b[3;1H\x1bD"); await term.flush(); const scrollback = term.getScrollBuffer().map(line => line.trimEnd()); expect(scrollback.filter(line => line === "line-1")).toHaveLength(1); From b6fae7c047ee1b175de4722e86f36625cc7ac79b Mon Sep 17 00:00:00 2001 From: snowykr Date: Sun, 2 Aug 2026 15:53:55 +0900 Subject: [PATCH 09/46] fix(iterm): preserve lease across wrapped stream output Streaming Markdown can rewrite the prior tail row while appending a wrapped row. Admit that bounded tail rewrite inside the fixed scroll transaction instead of falling through to protected lease erasure.\n\nConstraint: retain native scrollback without Pet flicker\nTested: fixed-suffix, raster-lease, and render-commit tests; TUI package check\nScope-risk: narrow\nReversibility: straightforward --- packages/tui/src/tui.ts | 14 ++++++++++++-- .../tui/test/fixed-suffix-scroll-region.test.ts | 10 ++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index f186527be2..608b452702 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -4737,6 +4737,12 @@ export class TUI extends Container { } const nextLiveViewportTop = Math.max(0, newLines.length - height); + const fixedSuffixTailRewrite = + previousTranscriptLineCount > 0 && + firstChanged === previousTranscriptLineCount - 1 && + newLines + .slice(0, previousTranscriptLineCount - 1) + .every((line, index) => line === previousLogicalFrame[index]); const fixedSuffixNativeAppend = fixedSuffixScrollRegionToken !== undefined && this.#fixedSuffixScrollRegionOwners.get(fixedSuffixScrollRegionToken.ownerId) === @@ -4761,8 +4767,10 @@ export class TUI extends Container { nextSuffixLineCount < height && previousLogicalFrame.length === previousTranscriptLineCount + previousSuffixLineCount && nextTranscriptLineCount > previousTranscriptLineCount && - firstChanged === previousTranscriptLineCount && - newLines.slice(0, previousTranscriptLineCount).every((line, index) => line === previousLogicalFrame[index]); + (firstChanged === previousTranscriptLineCount || fixedSuffixTailRewrite) && + newLines + .slice(0, fixedSuffixTailRewrite ? previousTranscriptLineCount - 1 : previousTranscriptLineCount) + .every((line, index) => line === previousLogicalFrame[index]); const fixedSuffixRasterLease = fixedSuffixScrollRegionToken === undefined ? undefined @@ -5006,6 +5014,8 @@ export class TUI extends Container { ? Math.min(suffixRegionBottom, fixedSuffixRasterLease.token.rect.row) : suffixRegionBottom; let fixedSuffixBuffer = `\x1b[?2026h\x1b7\x1b[?6l\x1b[1;${regionBottom}r\x1b[${regionBottom};1H`; + if (fixedSuffixTailRewrite) + fixedSuffixBuffer += `\r\x1b[2K${this.#padLineToWidth(newLines[previousTranscriptLineCount - 1]!, width)}`; for (let lineIndex = previousTranscriptLineCount; lineIndex < nextTranscriptLineCount; lineIndex += 1) { fixedSuffixBuffer += `\x1bD\r\x1b[2K${this.#padLineToWidth(newLines[lineIndex]!, width)}`; } diff --git a/packages/tui/test/fixed-suffix-scroll-region.test.ts b/packages/tui/test/fixed-suffix-scroll-region.test.ts index 16d78bd460..37aa0335ac 100644 --- a/packages/tui/test/fixed-suffix-scroll-region.test.ts +++ b/packages/tui/test/fixed-suffix-scroll-region.test.ts @@ -141,6 +141,16 @@ describe("TUI fixed suffix scroll region", () => { const scrollback = term.getScrollBuffer().map(line => line.trimEnd()); expect(scrollback.filter(line => line === "line-1")).toHaveLength(1); expect(scrollback.filter(line => line === "line-2")).toHaveLength(1); + transcript.setLines(["line-1", "line-2", "line-3", "line-4", "line-5 revised", "line-6"]); + term.clearWriteLog(); + tui.requestRender(); + await term.waitForRender(); + const tailRewriteOutput = term.getWriteLog().join(""); + expect(tailRewriteOutput).toContain("\x1b[1;2r"); + expect(tailRewriteOutput).toContain("\r\x1b[2Kline-5 revised"); + expect(tailRewriteOutput).toContain("\x1bD\r\x1b[2Kline-6"); + expect(tailRewriteOutput).not.toContain("ITERM_ERASE"); + expect(invalidated).toBe(0); } finally { tui.stop(); } From ad4f1fd855e931e4879825040eb6c41eefe3afdc Mon Sep 17 00:00:00 2001 From: snowykr Date: Sun, 2 Aug 2026 15:58:21 +0900 Subject: [PATCH 10/46] fix(tui): record rewritten fixed-suffix tail A wrapped append repaints the preceding tail inside the DECSTBM transaction. Record that row at the durable frontier so later reflow decisions do not observe stale output.\n\nConstraint: retain no-flicker iTerm native admission\nTested: fixed-suffix, raster-lease, and render-commit tests; TUI package check\nScope-risk: narrow\nReversibility: straightforward --- packages/tui/src/tui.ts | 7 ++++++- packages/tui/test/fixed-suffix-scroll-region.test.ts | 9 +++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 608b452702..5a1c566b9d 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -5065,7 +5065,12 @@ export class TUI extends Container { this.#latestRenderedLines = newLines; if (this.#virtualViewport) this.#latestRaw = rawLines; this.#durableLineCount = Math.max(this.#durableLineCount, newLines.length); - this.#recordDurableLines(newLines, rawLines, previousTranscriptLineCount, newLines.length - 1); + this.#recordDurableLines( + newLines, + rawLines, + previousTranscriptLineCount - (fixedSuffixTailRewrite ? 1 : 0), + newLines.length - 1, + ); this.#nativeScrollbackAdmissionPending = false; this.#transcriptIdentityReplaced = false; return; diff --git a/packages/tui/test/fixed-suffix-scroll-region.test.ts b/packages/tui/test/fixed-suffix-scroll-region.test.ts index 37aa0335ac..1b2a3dd4e3 100644 --- a/packages/tui/test/fixed-suffix-scroll-region.test.ts +++ b/packages/tui/test/fixed-suffix-scroll-region.test.ts @@ -151,6 +151,15 @@ describe("TUI fixed suffix scroll region", () => { expect(tailRewriteOutput).toContain("\x1bD\r\x1b[2Kline-6"); expect(tailRewriteOutput).not.toContain("ITERM_ERASE"); expect(invalidated).toBe(0); + transcript.setLines(["line-1", "line-2", "line-3", "line-4", "line-5 revised", "line-6", "line-7"]); + term.clearWriteLog(); + tui.requestRender(); + await term.waitForRender(); + const postRewriteOutput = term.getWriteLog().join(""); + expect(postRewriteOutput).toContain("\x1b[1;2r"); + expect(postRewriteOutput).toContain("\x1bD\r\x1b[2Kline-7"); + expect(postRewriteOutput).not.toContain("ITERM_ERASE"); + expect(invalidated).toBe(0); } finally { tui.stop(); } From 0f8d9d9ba2bdecc9fd47d79802aaffbb5b9e8325 Mon Sep 17 00:00:00 2001 From: snowykr Date: Sun, 2 Aug 2026 16:09:28 +0900 Subject: [PATCH 11/46] fix(tui): serialize cursor moves behind raster uploads A no-op render could move the terminal cursor between an iTerm multipart prefix and its image records. Queue that cursor move behind raster ingress so the placement stays valid.\n\nConstraint: preserve iTerm GIF placement during streaming\nTested: raster-lease, fixed-suffix, and render-commit tests; TUI package check\nScope-risk: narrow\nReversibility: straightforward --- packages/tui/src/tui.ts | 14 ++++++++- packages/tui/test/raster-lease.test.ts | 41 ++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 5a1c566b9d..465f532500 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -4729,9 +4729,21 @@ export class TUI extends Container { if (firstChanged !== changedTop) appendStart = false; } - // No changes - but still need to update hardware cursor position if it moved + // No changes - but still need to update hardware cursor position if it moved. + // A multipart raster prefix owns the terminal cursor until its records and + // restore suffix are delivered; queue a no-op cursor update behind it rather + // than moving the GIF placement between prefix and records. if (firstChanged === -1) { this.#viewportTopRow = Math.max(0, this.#maxLinesRendered - height); + if (this.#rasterPending > 0) { + void this.#enqueueRaster(() => { + if (this.#stopped || !this.terminalAvailable) return false; + const written = this.#writeCursorPosition(cursorPos, newLines.length); + if (written) this.#refreshPaintedLiveViewportObservation(height); + return written; + }); + return; + } if (this.#writeCursorPosition(cursorPos, newLines.length)) this.#refreshPaintedLiveViewportObservation(height); return; } diff --git a/packages/tui/test/raster-lease.test.ts b/packages/tui/test/raster-lease.test.ts index f6a302c510..2f4748a443 100644 --- a/packages/tui/test/raster-lease.test.ts +++ b/packages/tui/test/raster-lease.test.ts @@ -202,6 +202,47 @@ describe("TUI raster lease public boundary", () => { expect(output).not.toContain("\x1b[1;1H"); expect(output).toEndWith("\x1b[?25h"); }); + it("queues a no-op cursor move behind a multipart placement barrier", async () => { + const { tui, terminal } = await setup(true); + let cursorColumn = 0; + const component: Component = { + render: () => (cursorColumn === 0 ? [`${CURSOR_MARKER}x`] : [`x${CURSOR_MARKER}`]), + invalidate() {}, + }; + tui.addChild(component); + tui.start(); + await terminal.waitForRender(); + const lease = await tui.acquireRasterLease(request("multipart-cursor", rect(8, 3, 2, 1))); + if (lease.status !== "acquired") throw new Error("lease not acquired"); + const prefixEntered = Promise.withResolvers(); + const releaseBarrier = Promise.withResolvers(); + terminal.clearWriteLog(); + const multipart = tui.submitTerminalOutput({ + token: lease.token, + operation: { + type: "raster-multipart-batch", + prefix: bytes("PREFIX"), + afterPrefix: async () => { + prefixEntered.resolve(); + return releaseBarrier.promise; + }, + records: [bytes("RECORD")], + suffix: bytes("RESTORE"), + }, + }); + await prefixEntered.promise; + cursorColumn = 1; + tui.requestRender(); + await Bun.sleep(20); + expect(terminal.getWriteLog().join("")).toBe("PREFIX"); + releaseBarrier.resolve(true); + expect((await multipart).status).toBe("written"); + await Bun.sleep(20); + const output = terminal.getWriteLog().join(""); + expect(output).toContain("PREFIXRECORDRESTORE"); + expect(output.indexOf("PREFIXRECORDRESTORE")).toBeLessThan(output.indexOf("\x1b[2G")); + tui.stop(); + }); it("guards raster invalidation so erase placement cannot steal an active cursor", async () => { const { tui, terminal } = await setup(true); const component: Component = { render: () => [`input${CURSOR_MARKER}`], invalidate() {} }; From e4a34b8814aee9786a51282b8991b4a3feb9fe0d Mon Sep 17 00:00:00 2001 From: snowykr Date: Sun, 2 Aug 2026 16:16:06 +0900 Subject: [PATCH 12/46] fix(iterm): retain GIF across work transitions OSC 1337 cannot replace inline GIF frames without flashing transparent cells. Keep an armed iTerm lease on its initial timeline through working and auto-flex changes; resubmit only for real placement or geometry changes.\n\nConstraint: no visible Pet flicker during streaming work\nTested: focused Pet, transport, and QA tests; coding-agent package check\nScope-risk: narrow\nReversibility: straightforward --- .../src/modes/components/gajae-pet-widget.ts | 6 +++- .../test/gajae-pet-widget.test.ts | 31 +++++++++---------- 2 files changed, 20 insertions(+), 17 deletions(-) diff --git a/packages/coding-agent/src/modes/components/gajae-pet-widget.ts b/packages/coding-agent/src/modes/components/gajae-pet-widget.ts index b5c7e8be08..6e3f4c329c 100644 --- a/packages/coding-agent/src/modes/components/gajae-pet-widget.ts +++ b/packages/coding-agent/src/modes/components/gajae-pet-widget.ts @@ -560,7 +560,11 @@ export class GajaePetWidget { } const working = this.#isWorking(); const flexing = this.#flexUntil > now; - const semantic = `${this.#mode}:${availability.mode}:${availability.epoch}:${working}:${flexing}:${rect.column},${rect.row}:${cell.widthPx},${cell.heightPx}:${this.#ui.terminal.columns},${this.#ui.terminal.rows}`; + // OSC 1337 has no image-frame replacement primitive. Re-uploading a GIF + // for ordinary working/idle or auto-flex transitions visibly flashes its + // transparent canvas, so an armed iTerm lease keeps its initial timeline + // until a real placement or geometry change requires a new submission. + const semantic = `${this.#mode}:${availability.mode}:${availability.epoch}:${rect.column},${rect.row}:${cell.widthPx},${cell.heightPx}:${this.#ui.terminal.columns},${this.#ui.terminal.rows}`; if (this.#itermSubmitPending || (semantic === this.#itermLastSemantic && this.#itermLease)) return; this.#itermLastSemantic = semantic; this.#itermSubmitPending = true; diff --git a/packages/coding-agent/test/gajae-pet-widget.test.ts b/packages/coding-agent/test/gajae-pet-widget.test.ts index af37de96dc..7862415172 100644 --- a/packages/coding-agent/test/gajae-pet-widget.test.ts +++ b/packages/coding-agent/test/gajae-pet-widget.test.ts @@ -1421,7 +1421,7 @@ describe("GajaePetWidget", () => { stubs.widget.dispose(); } }); - it("runs scheduled auto-flex bursts on the iTerm raster path", async () => { + it("keeps the initial iTerm GIF during scheduled auto-flex bursts", async () => { vi.useFakeTimers(); const stubs = makeWidget(80, 30, { protocol: null, @@ -1443,9 +1443,7 @@ describe("GajaePetWidget", () => { .getRasterOutputs() .map(record => new TextDecoder().decode(record)) .filter(record => record.includes("MultipartFile=")); - expect(headers).toHaveLength(2); - const sizes = headers.map(header => Number(/;size=(\d+);/u.exec(header)?.[1])); - expect(sizes[1]).toBeGreaterThan(sizes[0]); + expect(headers).toHaveLength(1); } finally { setVerifiedItermPetAvailability(undefined); stubs.widget.dispose(); @@ -1601,7 +1599,7 @@ describe("GajaePetWidget", () => { stubs.widget.dispose(); } }); - it("reuses one raster lease and applies cursor visibility for idle-working-idle transitions", async () => { + it("keeps one iTerm GIF across idle-working-idle transitions", async () => { vi.useFakeTimers(); let working = false; const stubs = makeWidget(80, 30, { protocol: null, isWorking: () => working }); @@ -1620,24 +1618,25 @@ describe("GajaePetWidget", () => { working = true; vi.advanceTimersByTime(80); await flushAsyncChain(); - const replacementPrefix = stubs - .getRasterOutputs() - .map(record => new TextDecoder().decode(record)) - .find(record => record === "\x1b[?2026h\x1b7\x1b[?25l\x1b[28;76H"); - expect(replacementPrefix).toBe("\x1b[?2026h\x1b7\x1b[?25l\x1b[28;76H"); - expect(stubs.getRasterCursorVisibilityRestores()).toEqual([true, true]); + expect( + stubs + .getRasterOutputs() + .map(record => new TextDecoder().decode(record)) + .filter(record => record.includes("MultipartFile=")), + ).toHaveLength(1); + expect(stubs.getRasterCursorVisibilityRestores()).toEqual([true]); working = false; vi.advanceTimersByTime(80); await flushAsyncChain(); - expect(stubs.getRasterCursorVisibilityRestores()).toEqual([true, true, true]); + expect(stubs.getRasterCursorVisibilityRestores()).toEqual([true]); expect(stubs.getInvalidatedRasterLeases()).toHaveLength(0); } finally { setVerifiedItermPetAvailability(undefined); stubs.widget.dispose(); } }); - it("replaces the managed iTerm GIF without blanking its footprint", async () => { + it("keeps the managed iTerm GIF across a working transition", async () => { vi.useFakeTimers(); let working = false; const stubs = makeWidget(80, 30, { protocol: null, isWorking: () => working }); @@ -1657,14 +1656,14 @@ describe("GajaePetWidget", () => { .getRasterOutputs() .map(record => new TextDecoder().decode(record)) .filter(record => record === expectedPrefix), - ).toHaveLength(2); + ).toHaveLength(1); expect(stubs.getInvalidatedRasterLeases()).toHaveLength(0); } finally { setVerifiedItermPetAvailability(undefined); stubs.widget.dispose(); } }); - it("settles after replacing the idle raster with the working raster", async () => { + it("keeps the initial iTerm raster settled while work starts", async () => { vi.useFakeTimers(); let working = false; const stubs = makeWidget(80, 30, { protocol: null, isWorking: () => working }); @@ -1684,7 +1683,7 @@ describe("GajaePetWidget", () => { .getRasterOutputs() .map(record => new TextDecoder().decode(record)) .filter(record => record.includes("MultipartFile=")); - expect(headers).toHaveLength(2); + expect(headers).toHaveLength(1); expect(stubs.getInvalidatedRasterLeases()).toHaveLength(0); } finally { setVerifiedItermPetAvailability(undefined); From 0d1b4c04f7a2d82fd260cc2c8f88035058b63219 Mon Sep 17 00:00:00 2001 From: snowykr Date: Sun, 2 Aug 2026 16:25:07 +0900 Subject: [PATCH 13/46] fix(iterm): keep pending GIF uploads across activity changes Working and auto-flex state no longer changes the stable iTerm placement semantic. Aborting an upload when either flips releases and reuploads the same GIF, producing a visible flash during streaming output.\n\nConstraint: preserve geometry, capability, mode, ownership, and manual-viewport invalidation\nTested: bun test test/gajae-pet-widget.test.ts; bun run check\nScope-risk: narrow\nReversibility: direct --- .../src/modes/components/gajae-pet-widget.ts | 3 --- .../coding-agent/test/gajae-pet-widget.test.ts | 14 +++++++------- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/packages/coding-agent/src/modes/components/gajae-pet-widget.ts b/packages/coding-agent/src/modes/components/gajae-pet-widget.ts index 6e3f4c329c..a33e8dce67 100644 --- a/packages/coding-agent/src/modes/components/gajae-pet-widget.ts +++ b/packages/coding-agent/src/modes/components/gajae-pet-widget.ts @@ -602,7 +602,6 @@ export class GajaePetWidget { ): Promise { const current = () => { const availability = getVerifiedItermPetAvailability(); - const flexingNow = this.#flexUntil > performance.now(); const terminal = this.#ui.terminal; const cell = getCellDimensions(); const liveComposerBottomOffset = this.#getComposerBottomOffset(); @@ -620,8 +619,6 @@ export class GajaePetWidget { availability.epoch === epoch && availability.mode === mode && !this.#ui.manualViewportActive && - this.#isWorking() === working && - flexingNow === flexing && this.#framedEditor.canFit(terminal.columns) && terminal.columns === geometry.columns && terminal.rows === geometry.rows && diff --git a/packages/coding-agent/test/gajae-pet-widget.test.ts b/packages/coding-agent/test/gajae-pet-widget.test.ts index 7862415172..2b97c6c3c5 100644 --- a/packages/coding-agent/test/gajae-pet-widget.test.ts +++ b/packages/coding-agent/test/gajae-pet-widget.test.ts @@ -1472,7 +1472,7 @@ describe("GajaePetWidget", () => { stubs.widget.dispose(); } }); - it("drops a stale iTerm worker GIF when activity ends during lease acquisition", async () => { + it("keeps an iTerm upload current when activity changes during lease acquisition", async () => { vi.useFakeTimers(); let working = true; const stubs = makeWidget(80, 30, { @@ -1490,12 +1490,12 @@ describe("GajaePetWidget", () => { working = false; stubs.setRasterAcquireDelayed(false); await flushAsyncChain(); - expect( - stubs - .getRasterOutputs() - .map(record => new TextDecoder().decode(record)) - .some(record => record.includes("MultipartFile=")), - ).toBe(false); + const headers = stubs + .getRasterOutputs() + .map(record => new TextDecoder().decode(record)) + .filter(record => record.includes("MultipartFile=")); + expect(headers).toHaveLength(1); + expect(stubs.getInvalidatedRasterLeases()).toHaveLength(0); vi.advanceTimersByTime(80); await flushAsyncChain(); From d180529ec1cfbcfbb656c87275b7554aa2b8a6e8 Mon Sep 17 00:00:00 2001 From: snowykr Date: Sun, 2 Aug 2026 16:30:50 +0900 Subject: [PATCH 14/46] fix(tui): retain raster lease through streamed reflow An armed fixed suffix only recognized strict append and one tail rewrite. Wider Markdown reflow fell into the generic raster path, which erased and reuploaded the iTerm GIF. Keep the lower plane fixed by scrolling and repainting only the bounded upper DECSTBM region for otherwise eligible transcript growth.\n\nConstraint: retain manual, resize, image, Kitty, and generic-raster fallbacks\nTested: bun test test/fixed-suffix-scroll-region.test.ts test/raster-lease.test.ts test/render-commit.test.ts; bun run check\nScope-risk: focused\nReversibility: direct --- packages/tui/src/tui.ts | 32 +++++++++++++------ .../test/fixed-suffix-scroll-region.test.ts | 21 ++++++++++++ 2 files changed, 43 insertions(+), 10 deletions(-) diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 465f532500..3d7cd548cf 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -4755,6 +4755,7 @@ export class TUI extends Container { newLines .slice(0, previousTranscriptLineCount - 1) .every((line, index) => line === previousLogicalFrame[index]); + const fixedSuffixAppendOnly = firstChanged === previousTranscriptLineCount || fixedSuffixTailRewrite; const fixedSuffixNativeAppend = fixedSuffixScrollRegionToken !== undefined && this.#fixedSuffixScrollRegionOwners.get(fixedSuffixScrollRegionToken.ownerId) === @@ -4773,16 +4774,13 @@ export class TUI extends Container { this.overlayStack.length === 0 && previousKittyPlacementSpans.length === 0 && nextKittyPlacementSpans.length === 0 && + !newLines.some(line => TERMINAL.isImageLine(line)) && this.#scrollbackResumeViewportTop === undefined && previousSuffixLineCount > 0 && previousSuffixLineCount === nextSuffixLineCount && nextSuffixLineCount < height && previousLogicalFrame.length === previousTranscriptLineCount + previousSuffixLineCount && - nextTranscriptLineCount > previousTranscriptLineCount && - (firstChanged === previousTranscriptLineCount || fixedSuffixTailRewrite) && - newLines - .slice(0, fixedSuffixTailRewrite ? previousTranscriptLineCount - 1 : previousTranscriptLineCount) - .every((line, index) => line === previousLogicalFrame[index]); + nextTranscriptLineCount > previousTranscriptLineCount; const fixedSuffixRasterLease = fixedSuffixScrollRegionToken === undefined ? undefined @@ -5026,10 +5024,22 @@ export class TUI extends Container { ? Math.min(suffixRegionBottom, fixedSuffixRasterLease.token.rect.row) : suffixRegionBottom; let fixedSuffixBuffer = `\x1b[?2026h\x1b7\x1b[?6l\x1b[1;${regionBottom}r\x1b[${regionBottom};1H`; - if (fixedSuffixTailRewrite) - fixedSuffixBuffer += `\r\x1b[2K${this.#padLineToWidth(newLines[previousTranscriptLineCount - 1]!, width)}`; - for (let lineIndex = previousTranscriptLineCount; lineIndex < nextTranscriptLineCount; lineIndex += 1) { - fixedSuffixBuffer += `\x1bD\r\x1b[2K${this.#padLineToWidth(newLines[lineIndex]!, width)}`; + const fixedSuffixFullRepaint = !fixedSuffixAppendOnly; + if (fixedSuffixFullRepaint) { + const scrollCount = nextTranscriptLineCount - previousTranscriptLineCount; + for (let scrollIndex = 0; scrollIndex < scrollCount; scrollIndex += 1) + fixedSuffixBuffer += `\x1b[${regionBottom};1H\x1bD`; + const visibleTranscriptStart = Math.max(0, nextTranscriptLineCount - regionBottom); + for (let row = 0; row < regionBottom; row += 1) { + const line = newLines[visibleTranscriptStart + row] ?? ""; + fixedSuffixBuffer += `\x1b[${row + 1};1H\x1b[2K${this.#padLineToWidth(line, width)}`; + } + } else { + if (fixedSuffixTailRewrite) + fixedSuffixBuffer += `\r\x1b[2K${this.#padLineToWidth(newLines[previousTranscriptLineCount - 1]!, width)}`; + for (let lineIndex = previousTranscriptLineCount; lineIndex < nextTranscriptLineCount; lineIndex += 1) { + fixedSuffixBuffer += `\x1bD\r\x1b[2K${this.#padLineToWidth(newLines[lineIndex]!, width)}`; + } } fixedSuffixBuffer += "\x1b[r\x1b[?6l"; const preserveFixedSuffixRaster = @@ -5080,7 +5090,9 @@ export class TUI extends Container { this.#recordDurableLines( newLines, rawLines, - previousTranscriptLineCount - (fixedSuffixTailRewrite ? 1 : 0), + fixedSuffixFullRepaint + ? Math.max(0, nextTranscriptLineCount - regionBottom) + : previousTranscriptLineCount - (fixedSuffixTailRewrite ? 1 : 0), newLines.length - 1, ); this.#nativeScrollbackAdmissionPending = false; diff --git a/packages/tui/test/fixed-suffix-scroll-region.test.ts b/packages/tui/test/fixed-suffix-scroll-region.test.ts index 1b2a3dd4e3..cc69d2c886 100644 --- a/packages/tui/test/fixed-suffix-scroll-region.test.ts +++ b/packages/tui/test/fixed-suffix-scroll-region.test.ts @@ -160,6 +160,27 @@ describe("TUI fixed suffix scroll region", () => { expect(postRewriteOutput).toContain("\x1bD\r\x1b[2Kline-7"); expect(postRewriteOutput).not.toContain("ITERM_ERASE"); expect(invalidated).toBe(0); + transcript.setLines([ + "line-1 revised", + "line-2", + "line-3", + "line-4", + "line-5 revised", + "line-6", + "line-7", + "line-8", + ]); + term.clearWriteLog(); + tui.requestRender(); + await term.waitForRender(); + const reflowOutput = term.getWriteLog().join(""); + expect(reflowOutput).toContain("\x1b[1;2r"); + expect(reflowOutput).toContain("\x1b[1;1H\x1b[2Kline-7"); + expect(reflowOutput).toContain("\x1b[2;1H\x1b[2Kline-8"); + expect(reflowOutput).not.toContain("ITERM_ERASE"); + expect(invalidated).toBe(0); + await term.flush(); + expect(term.getScrollBuffer().map(line => line.trimEnd())).toContain("line-3"); } finally { tui.stop(); } From 0ecce8eed2953a79955e62b2fd6995fc6456f4b2 Mon Sep 17 00:00:00 2001 From: snowykr Date: Sun, 2 Aug 2026 16:40:37 +0900 Subject: [PATCH 15/46] fix(iterm): rearm scroll ownership after history Manual history releases fixed-suffix ownership while retaining the valid GIF lease. Reacquire the owner on live follow without a second OSC 1337 upload so the Pet remains present and native scrolling resumes.\n\nConstraint: preserve manual history suspension and avoid GIF reupload\nTested: bun test test/gajae-pet-widget.test.ts; bun run check\nScope-risk: narrow\nReversibility: direct --- .../src/modes/components/gajae-pet-widget.ts | 22 +++++++------ .../test/gajae-pet-widget.test.ts | 31 +++++++++++++++++++ 2 files changed, 44 insertions(+), 9 deletions(-) diff --git a/packages/coding-agent/src/modes/components/gajae-pet-widget.ts b/packages/coding-agent/src/modes/components/gajae-pet-widget.ts index a33e8dce67..49464a053c 100644 --- a/packages/coding-agent/src/modes/components/gajae-pet-widget.ts +++ b/packages/coding-agent/src/modes/components/gajae-pet-widget.ts @@ -449,6 +449,13 @@ export class GajaePetWidget { this.#fixedSuffixScrollRegionToken = undefined; if (token) this.#ui.releaseFixedSuffixScrollRegion(token); } + #armFixedSuffixScrollRegion(lease: RasterLeaseToken): void { + if (this.#fixedSuffixScrollRegionToken || this.#itermLease !== lease) return; + const token = this.#ui.acquireFixedSuffixScrollRegion(this.#itermOwner); + if (!token) return; + this.#fixedSuffixScrollRegionToken = token; + if (this.#ui.armFixedSuffixScrollRegion(token, lease) === undefined) this.#releaseFixedSuffixScrollRegion(); + } #mountEditor(framed: boolean): void { this.#editorContainer.clear(); @@ -565,7 +572,11 @@ export class GajaePetWidget { // transparent canvas, so an armed iTerm lease keeps its initial timeline // until a real placement or geometry change requires a new submission. const semantic = `${this.#mode}:${availability.mode}:${availability.epoch}:${rect.column},${rect.row}:${cell.widthPx},${cell.heightPx}:${this.#ui.terminal.columns},${this.#ui.terminal.rows}`; - if (this.#itermSubmitPending || (semantic === this.#itermLastSemantic && this.#itermLease)) return; + if (this.#itermSubmitPending) return; + if (semantic === this.#itermLastSemantic && this.#itermLease) { + this.#armFixedSuffixScrollRegion(this.#itermLease); + return; + } this.#itermLastSemantic = semantic; this.#itermSubmitPending = true; const generation = this.#itermGeneration; @@ -741,14 +752,7 @@ export class GajaePetWidget { return; } this.#releaseFixedSuffixScrollRegion(); - const fixedSuffixScrollRegionToken = this.#ui.acquireFixedSuffixScrollRegion(this.#itermOwner); - if (!fixedSuffixScrollRegionToken || !current() || this.#itermLease !== token) { - if (fixedSuffixScrollRegionToken) this.#ui.releaseFixedSuffixScrollRegion(fixedSuffixScrollRegionToken); - return; - } - this.#fixedSuffixScrollRegionToken = fixedSuffixScrollRegionToken; - if (this.#ui.armFixedSuffixScrollRegion(fixedSuffixScrollRegionToken, token) === undefined) - this.#releaseFixedSuffixScrollRegion(); + this.#armFixedSuffixScrollRegion(token); } #scheduleAutoFlex(now: number): void { if (!this.#autoFlexGapMs) return; diff --git a/packages/coding-agent/test/gajae-pet-widget.test.ts b/packages/coding-agent/test/gajae-pet-widget.test.ts index 2b97c6c3c5..a83a96fefd 100644 --- a/packages/coding-agent/test/gajae-pet-widget.test.ts +++ b/packages/coding-agent/test/gajae-pet-widget.test.ts @@ -1563,6 +1563,37 @@ describe("GajaePetWidget", () => { stubs.widget.dispose(); } }); + it("rearms the fixed suffix after manual history without reuploading the iTerm GIF", async () => { + vi.useFakeTimers(); + const stubs = makeWidget(80, 30, { protocol: null }); + try { + setVerifiedItermPetAvailability({ available: true, mode: "direct", epoch: 1 }); + stubs.widget.setMode("red"); + vi.advanceTimersByTime(80); + await flushAsyncChain(); + expect(stubs.getFixedSuffixScrollRegionOwnerCount()).toBe(1); + const headers = () => + stubs + .getRasterOutputs() + .map(record => new TextDecoder().decode(record)) + .filter(record => record.includes("MultipartFile=")); + expect(headers()).toHaveLength(1); + + stubs.setManualViewportActive(true); + vi.advanceTimersByTime(80); + await flushAsyncChain(); + expect(stubs.getFixedSuffixScrollRegionOwnerCount()).toBe(0); + + stubs.setManualViewportActive(false); + vi.advanceTimersByTime(80); + await flushAsyncChain(); + expect(stubs.getFixedSuffixScrollRegionOwnerCount()).toBe(1); + expect(headers()).toHaveLength(1); + } finally { + setVerifiedItermPetAvailability(undefined); + stubs.widget.dispose(); + } + }); it("drops an iTerm lease acquired after manual history begins", async () => { vi.useFakeTimers(); const stubs = makeWidget(80, 30, { protocol: null }); From f0d99f7c009398b18c95b62308e10bf703d95b58 Mon Sep 17 00:00:00 2001 From: snowykr Date: Sun, 2 Aug 2026 16:41:10 +0900 Subject: [PATCH 16/46] Revert "fix(tui): retain raster lease through streamed reflow" This reverts commit 2315ddc01b60233ffebbf5e7febdc9fb13db1d6c. --- packages/tui/src/tui.ts | 32 ++++++------------- .../test/fixed-suffix-scroll-region.test.ts | 21 ------------ 2 files changed, 10 insertions(+), 43 deletions(-) diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 3d7cd548cf..465f532500 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -4755,7 +4755,6 @@ export class TUI extends Container { newLines .slice(0, previousTranscriptLineCount - 1) .every((line, index) => line === previousLogicalFrame[index]); - const fixedSuffixAppendOnly = firstChanged === previousTranscriptLineCount || fixedSuffixTailRewrite; const fixedSuffixNativeAppend = fixedSuffixScrollRegionToken !== undefined && this.#fixedSuffixScrollRegionOwners.get(fixedSuffixScrollRegionToken.ownerId) === @@ -4774,13 +4773,16 @@ export class TUI extends Container { this.overlayStack.length === 0 && previousKittyPlacementSpans.length === 0 && nextKittyPlacementSpans.length === 0 && - !newLines.some(line => TERMINAL.isImageLine(line)) && this.#scrollbackResumeViewportTop === undefined && previousSuffixLineCount > 0 && previousSuffixLineCount === nextSuffixLineCount && nextSuffixLineCount < height && previousLogicalFrame.length === previousTranscriptLineCount + previousSuffixLineCount && - nextTranscriptLineCount > previousTranscriptLineCount; + nextTranscriptLineCount > previousTranscriptLineCount && + (firstChanged === previousTranscriptLineCount || fixedSuffixTailRewrite) && + newLines + .slice(0, fixedSuffixTailRewrite ? previousTranscriptLineCount - 1 : previousTranscriptLineCount) + .every((line, index) => line === previousLogicalFrame[index]); const fixedSuffixRasterLease = fixedSuffixScrollRegionToken === undefined ? undefined @@ -5024,22 +5026,10 @@ export class TUI extends Container { ? Math.min(suffixRegionBottom, fixedSuffixRasterLease.token.rect.row) : suffixRegionBottom; let fixedSuffixBuffer = `\x1b[?2026h\x1b7\x1b[?6l\x1b[1;${regionBottom}r\x1b[${regionBottom};1H`; - const fixedSuffixFullRepaint = !fixedSuffixAppendOnly; - if (fixedSuffixFullRepaint) { - const scrollCount = nextTranscriptLineCount - previousTranscriptLineCount; - for (let scrollIndex = 0; scrollIndex < scrollCount; scrollIndex += 1) - fixedSuffixBuffer += `\x1b[${regionBottom};1H\x1bD`; - const visibleTranscriptStart = Math.max(0, nextTranscriptLineCount - regionBottom); - for (let row = 0; row < regionBottom; row += 1) { - const line = newLines[visibleTranscriptStart + row] ?? ""; - fixedSuffixBuffer += `\x1b[${row + 1};1H\x1b[2K${this.#padLineToWidth(line, width)}`; - } - } else { - if (fixedSuffixTailRewrite) - fixedSuffixBuffer += `\r\x1b[2K${this.#padLineToWidth(newLines[previousTranscriptLineCount - 1]!, width)}`; - for (let lineIndex = previousTranscriptLineCount; lineIndex < nextTranscriptLineCount; lineIndex += 1) { - fixedSuffixBuffer += `\x1bD\r\x1b[2K${this.#padLineToWidth(newLines[lineIndex]!, width)}`; - } + if (fixedSuffixTailRewrite) + fixedSuffixBuffer += `\r\x1b[2K${this.#padLineToWidth(newLines[previousTranscriptLineCount - 1]!, width)}`; + for (let lineIndex = previousTranscriptLineCount; lineIndex < nextTranscriptLineCount; lineIndex += 1) { + fixedSuffixBuffer += `\x1bD\r\x1b[2K${this.#padLineToWidth(newLines[lineIndex]!, width)}`; } fixedSuffixBuffer += "\x1b[r\x1b[?6l"; const preserveFixedSuffixRaster = @@ -5090,9 +5080,7 @@ export class TUI extends Container { this.#recordDurableLines( newLines, rawLines, - fixedSuffixFullRepaint - ? Math.max(0, nextTranscriptLineCount - regionBottom) - : previousTranscriptLineCount - (fixedSuffixTailRewrite ? 1 : 0), + previousTranscriptLineCount - (fixedSuffixTailRewrite ? 1 : 0), newLines.length - 1, ); this.#nativeScrollbackAdmissionPending = false; diff --git a/packages/tui/test/fixed-suffix-scroll-region.test.ts b/packages/tui/test/fixed-suffix-scroll-region.test.ts index cc69d2c886..1b2a3dd4e3 100644 --- a/packages/tui/test/fixed-suffix-scroll-region.test.ts +++ b/packages/tui/test/fixed-suffix-scroll-region.test.ts @@ -160,27 +160,6 @@ describe("TUI fixed suffix scroll region", () => { expect(postRewriteOutput).toContain("\x1bD\r\x1b[2Kline-7"); expect(postRewriteOutput).not.toContain("ITERM_ERASE"); expect(invalidated).toBe(0); - transcript.setLines([ - "line-1 revised", - "line-2", - "line-3", - "line-4", - "line-5 revised", - "line-6", - "line-7", - "line-8", - ]); - term.clearWriteLog(); - tui.requestRender(); - await term.waitForRender(); - const reflowOutput = term.getWriteLog().join(""); - expect(reflowOutput).toContain("\x1b[1;2r"); - expect(reflowOutput).toContain("\x1b[1;1H\x1b[2Kline-7"); - expect(reflowOutput).toContain("\x1b[2;1H\x1b[2Kline-8"); - expect(reflowOutput).not.toContain("ITERM_ERASE"); - expect(invalidated).toBe(0); - await term.flush(); - expect(term.getScrollBuffer().map(line => line.trimEnd())).toContain("line-3"); } finally { tui.stop(); } From 5fcb12bffb6748bafb1d21dcd6d312ea2efb9bbf Mon Sep 17 00:00:00 2001 From: snowykr Date: Sun, 2 Aug 2026 16:49:30 +0900 Subject: [PATCH 17/46] fix(tui): keep an armed iTerm scroll plane persistent A per-render DECSTBM transaction still routed ordinary transcript updates through generic raster fallback. Maintain a bounded upper scroll plane, advance it one rendered row per IND, and repaint only its upper rows while the lower composer and Pet plane stay outside the region.\n\nConstraint: only native-scrollback-eligible sole iTerm lease may preserve the fixed plane\nTested: bun test test/fixed-suffix-scroll-region.test.ts test/raster-lease.test.ts test/render-commit.test.ts; bun run check\nScope-risk: focused\nReversibility: direct --- packages/tui/src/tui.ts | 128 +++++++++++++++++- .../test/fixed-suffix-scroll-region.test.ts | 41 +++++- 2 files changed, 160 insertions(+), 9 deletions(-) diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 465f532500..97bc2b3acc 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -1027,6 +1027,9 @@ export class TUI extends Container { #armedFixedSuffixScrollRegionToken: FixedSuffixScrollRegionToken | undefined; #armedFixedSuffixScrollRegionRasterLease: RasterLeaseToken | undefined; #fixedSuffixScrollRegionResetPending = false; + #fixedSuffixScrollPlane: + | Readonly<{ token: FixedSuffixScrollRegionToken; upperBottom: number; transcriptTop: number }> + | undefined; #unsubscribeTabWidthChange?: () => void; static #renderCounters: TuiRenderCounterSnapshot = { @@ -1299,6 +1302,10 @@ export class TUI extends Container { this.#armedFixedSuffixScrollRegionToken = undefined; this.#armedFixedSuffixScrollRegionRasterLease = undefined; } + if (this.#fixedSuffixScrollPlane?.token === token) { + this.#fixedSuffixScrollPlane = undefined; + this.#fixedSuffixScrollRegionResetPending = true; + } } /** @@ -1323,6 +1330,8 @@ export class TUI extends Container { } #resetFixedSuffixScrollRegions(): void { + if (this.#fixedSuffixScrollPlane !== undefined) this.#fixedSuffixScrollRegionResetPending = true; + this.#fixedSuffixScrollPlane = undefined; this.#armedFixedSuffixScrollRegionToken = undefined; this.#armedFixedSuffixScrollRegionRasterLease = undefined; this.#fixedSuffixScrollRegionOwners.clear(); @@ -4733,7 +4742,7 @@ export class TUI extends Container { // A multipart raster prefix owns the terminal cursor until its records and // restore suffix are delivered; queue a no-op cursor update behind it rather // than moving the GIF placement between prefix and records. - if (firstChanged === -1) { + if (firstChanged === -1 && fixedSuffixScrollRegionToken === undefined) { this.#viewportTopRow = Math.max(0, this.#maxLinesRendered - height); if (this.#rasterPending > 0) { void this.#enqueueRaster(() => { @@ -4797,6 +4806,118 @@ export class TUI extends Container { fixedSuffixRasterLease?.token === fixedSuffixScrollRegionRasterLease && fixedSuffixRasterLease.token.rect.row > 0)); const soleRasterLease = this.#rasterLeases.size === 1 ? this.#rasterLeases.values().next().value : undefined; + const fixedPlaneUpperBottom = + fixedSuffixRasterLease === undefined + ? 0 + : Math.min(height - nextSuffixLineCount, fixedSuffixRasterLease.token.rect.row); + const fixedPlaneEligible = + fixedSuffixScrollRegionToken !== undefined && + this.#fixedSuffixScrollRegionOwners.get(fixedSuffixScrollRegionToken.ownerId) === + fixedSuffixScrollRegionToken && + fixedSuffixScrollRegionRasterLease !== undefined && + fixedSuffixRasterLease?.token === fixedSuffixScrollRegionRasterLease && + fixedSuffixRasterLease.nativeScrollbackEligible && + this.#rasterLeases.size === 1 && + this.#rasterCleanup.size === 0 && + this.#rasterPending === 0 && + fixedPlaneUpperBottom > 0 && + hasStickySuffix && + !widthChanged && + !heightChanged && + !transcriptIdentityReplaced && + !restartViewportRepaintPending && + !resizeRenderMutationQueued && + !widthSettleRenderQueued && + !tabWidthRepairPending && + !forcedRenderQueued && + !anchorRenderFailed && + this.overlayStack.length === 0 && + previousKittyPlacementSpans.length === 0 && + nextKittyPlacementSpans.length === 0 && + !newLines.some(line => TERMINAL.isImageLine(line)) && + this.#scrollbackResumeViewportTop === undefined && + previousSuffixLineCount === nextSuffixLineCount && + nextSuffixLineCount < height; + if (fixedPlaneEligible) { + const plane = this.#fixedSuffixScrollPlane; + const physicalTop = + plane?.token === fixedSuffixScrollRegionToken && plane.upperBottom === fixedPlaneUpperBottom + ? plane.transcriptTop + : prevViewportTop; + const desiredTop = Math.max(0, nextTranscriptLineCount - fixedPlaneUpperBottom); + if (physicalTop > desiredTop) { + this.#fixedSuffixScrollPlane = undefined; + this.#fixedSuffixScrollRegionResetPending = true; + fullRender(true, "fixed scroll plane contraction"); + return; + } + + let fixedPlaneBuffer = "\x1b[?2026h\x1b7\x1b[?6l"; + if (plane?.token !== fixedSuffixScrollRegionToken || plane.upperBottom !== fixedPlaneUpperBottom) + fixedPlaneBuffer += `\x1b[1;${fixedPlaneUpperBottom}r`; + for (let scrollIndex = 0; scrollIndex < desiredTop - physicalTop; scrollIndex += 1) { + const lineIndex = physicalTop + fixedPlaneUpperBottom + scrollIndex; + fixedPlaneBuffer += `\x1b[${fixedPlaneUpperBottom};1H\x1bD\r\x1b[2K${this.#padLineToWidth( + newLines[lineIndex] ?? "", + width, + )}`; + } + for (let row = 0; row < fixedPlaneUpperBottom; row += 1) { + fixedPlaneBuffer += `\x1b[${row + 1};1H\x1b[2K${this.#padLineToWidth( + newLines[desiredTop + row] ?? "", + width, + )}`; + } + const suffixRegionBottom = height - nextSuffixLineCount; + for (let suffixIndex = 0; suffixIndex < nextSuffixLineCount; suffixIndex += 1) { + const suffixRow = suffixRegionBottom + suffixIndex + 1; + const suffixLine = newLines[nextTranscriptLineCount + suffixIndex] ?? ""; + for (const segment of this.#unleasedRowSegments(suffixRow - 1, width)) { + fixedPlaneBuffer += `\x1b[${suffixRow};${segment.column + 1}H\x1b[${segment.width}X`; + fixedPlaneBuffer += `${sliceByColumn(suffixLine, segment.column, segment.width, true)}${SEGMENT_RESET}`; + } + } + const { seq, toRow } = this.#cursorControlSequence( + cursorPos, + newLines.length, + this.#hardwareCursorRow + desiredTop - physicalTop, + ); + fixedPlaneBuffer += `\x1b8\x1b[?6l${seq}\x1b[?2026l`; + if ( + !this.#writeRenderBufferAndReanchorImeCursor( + fixedPlaneBuffer, + cursorPos, + newLines.length, + () => { + this.#fixedSuffixScrollPlane = { + token: fixedSuffixScrollRegionToken, + upperBottom: fixedPlaneUpperBottom, + transcriptTop: desiredTop, + }; + this.#hardwareCursorRow = toRow; + this.#cursorRow = Math.max(0, newLines.length - 1); + this.#maxLinesRendered = newLines.length; + this.#viewportTopRow = Math.max(0, newLines.length - height); + this.#nativeScrollbackViewportTop = Math.max(this.#nativeScrollbackViewportTop, desiredTop); + this.#previousLines = newLines; + this.#previousWidth = width; + this.#previousHeight = height; + this.#manualTranscriptLineCount = nextTranscriptLineCount; + this.#manualSuffixLineCount = nextSuffixLineCount; + this.#refreshPaintedLiveViewportObservation(height); + }, + true, + ) + ) + return; + this.#latestRenderedLines = newLines; + if (this.#virtualViewport) this.#latestRaw = rawLines; + this.#durableLineCount = Math.max(this.#durableLineCount, newLines.length); + this.#recordDurableLines(newLines, rawLines, desiredTop, newLines.length - 1); + this.#nativeScrollbackAdmissionPending = false; + this.#transcriptIdentityReplaced = false; + return; + } // A verified iTerm lease must yield before an overflow can overwrite a row // that native history has not received. The fixed token covers an armed // owner; the explicit capability covers the pre-arm upload window. @@ -5416,7 +5537,10 @@ export class TUI extends Container { : (bytes: string) => this.#writeProtectedRenderIngress(bytes); const renderGeneration = this.#renderGenerationInProgress; const write = () => { - if (!writeIngress(buffer)) return false; + const needsFixedSuffixReset = this.#fixedSuffixScrollRegionResetPending && !preserveRasterLeases; + const bytes = needsFixedSuffixReset ? `\x1b[r\x1b[?6l${buffer}` : buffer; + if (!writeIngress(bytes)) return false; + if (needsFixedSuffixReset) this.#fixedSuffixScrollRegionResetPending = false; onBufferWritten?.(); this.#lastRenderWriteSucceeded = true; if (renderGeneration > 0) this.#settleRenderCommitWaiters(true, renderGeneration); diff --git a/packages/tui/test/fixed-suffix-scroll-region.test.ts b/packages/tui/test/fixed-suffix-scroll-region.test.ts index 1b2a3dd4e3..bc492e704e 100644 --- a/packages/tui/test/fixed-suffix-scroll-region.test.ts +++ b/packages/tui/test/fixed-suffix-scroll-region.test.ts @@ -91,6 +91,7 @@ describe("TUI fixed suffix scroll region", () => { rect: { column: 36, row: 2, width: 3, height: 3 }, erase: { type: "raster-erase", bytes: new TextEncoder().encode("ITERM_ERASE") }, onInvalidated: () => invalidated++, + nativeScrollbackEligible: true, }); expect(lease.status).toBe("acquired"); if (lease.status !== "acquired") throw new Error("Expected iTerm lease"); @@ -124,8 +125,8 @@ describe("TUI fixed suffix scroll region", () => { tui.requestRender(); await term.waitForRender(); const streamingOutput = term.getWriteLog().join(""); - expect(streamingOutput).toContain("\x1b[1;2r"); - expect(streamingOutput).toContain("\x1bD\r\x1b[2Kline-5"); + expect(streamingOutput).not.toContain("\x1b[1;2r"); + expect(streamingOutput).toContain("\x1b[2;1H\x1bD\r\x1b[2Kline-5"); expect(streamingOutput).not.toContain("ITERM_ERASE"); expect(invalidated).toBe(0); expect(streamingOutput).not.toContain("\x1b[4;1H\x1b[2K"); @@ -146,9 +147,9 @@ describe("TUI fixed suffix scroll region", () => { tui.requestRender(); await term.waitForRender(); const tailRewriteOutput = term.getWriteLog().join(""); - expect(tailRewriteOutput).toContain("\x1b[1;2r"); - expect(tailRewriteOutput).toContain("\r\x1b[2Kline-5 revised"); - expect(tailRewriteOutput).toContain("\x1bD\r\x1b[2Kline-6"); + expect(tailRewriteOutput).not.toContain("\x1b[1;2r"); + expect(tailRewriteOutput).toContain("\x1b[1;1H\x1b[2Kline-5 revised"); + expect(tailRewriteOutput).toContain("\x1b[2;1H\x1bD\r\x1b[2Kline-6"); expect(tailRewriteOutput).not.toContain("ITERM_ERASE"); expect(invalidated).toBe(0); transcript.setLines(["line-1", "line-2", "line-3", "line-4", "line-5 revised", "line-6", "line-7"]); @@ -156,10 +157,36 @@ describe("TUI fixed suffix scroll region", () => { tui.requestRender(); await term.waitForRender(); const postRewriteOutput = term.getWriteLog().join(""); - expect(postRewriteOutput).toContain("\x1b[1;2r"); - expect(postRewriteOutput).toContain("\x1bD\r\x1b[2Kline-7"); + expect(postRewriteOutput).not.toContain("\x1b[1;2r"); + expect(postRewriteOutput).toContain("\x1b[2;1H\x1bD\r\x1b[2Kline-7"); expect(postRewriteOutput).not.toContain("ITERM_ERASE"); expect(invalidated).toBe(0); + transcript.setLines([ + "line-1", + "line-2", + "line-3", + "line-4", + "line-5 revised", + "line-6", + "line-7", + "line-8", + "line-9", + "line-10", + ]); + term.clearWriteLog(); + tui.requestRender(); + await term.waitForRender(); + const batchedOutput = term.getWriteLog().join(""); + expect(batchedOutput).not.toContain("\x1b[1;2r"); + expect(batchedOutput).toContain("\x1b[2;1H\x1bD\r\x1b[2Kline-8"); + expect(batchedOutput).toContain("\x1b[2;1H\x1bD\r\x1b[2Kline-9"); + expect(batchedOutput).toContain("\x1b[2;1H\x1bD\r\x1b[2Kline-10"); + expect(batchedOutput).not.toContain("ITERM_ERASE"); + await term.flush(); + const batchedScrollback = term.getScrollBuffer().map(line => line.trimEnd()); + expect(batchedScrollback.filter(line => line === "line-5 revised")).toHaveLength(1); + expect(batchedScrollback.filter(line => line === "line-6")).toHaveLength(1); + expect(batchedScrollback.filter(line => line === "line-7")).toHaveLength(1); } finally { tui.stop(); } From 5272d17a0fe0d778ce480a00f237d283f2338101 Mon Sep 17 00:00:00 2001 From: snowykr Date: Sun, 2 Aug 2026 16:52:30 +0900 Subject: [PATCH 18/46] fix(tui): reset fixed margins before generic output A released persistent scroll plane can have a cursor-only follow-up render. Deliver the DECSTBM reset before that write so generic terminal behavior never inherits stale margins.\n\nConstraint: reset only after fixed-plane lifecycle release\nTested: bun test test/fixed-suffix-scroll-region.test.ts test/raster-lease.test.ts test/render-commit.test.ts; bun run check\nScope-risk: narrow\nReversibility: direct --- packages/tui/src/tui.ts | 8 +++++++- packages/tui/test/fixed-suffix-scroll-region.test.ts | 5 +++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 97bc2b3acc..e0f62c41f2 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -5575,15 +5575,21 @@ export class TUI extends Container { deferRenderFailure = false, ): boolean { if (!cursorPos || totalLines <= 0) { + const reset = this.#fixedSuffixScrollRegionResetPending ? "\x1b[r\x1b[?6l" : ""; + const resetWritten = reset.length === 0 || this.#writeTerminal(reset, deferRenderFailure); + if (resetWritten) this.#fixedSuffixScrollRegionResetPending = false; + if (!resetWritten) return false; return deferRenderFailure ? this.#guardTerminalOperation(() => this.terminal.hideCursor(), false) : this.#hideCursor(); } const { seq, toRow } = this.#cursorControlSequence(cursorPos, totalLines, this.#hardwareCursorRow); // No \x1b[?2026h/l wrapper: synchronized output flushes terminal state and discards macOS IME composition. - if (!this.#writeTerminal(seq, deferRenderFailure)) { + const reset = this.#fixedSuffixScrollRegionResetPending ? "\x1b[r\x1b[?6l" : ""; + if (!this.#writeTerminal(`${reset}${seq}`, deferRenderFailure)) { return false; } + if (reset.length > 0) this.#fixedSuffixScrollRegionResetPending = false; this.#hardwareCursorRow = toRow; return true; } diff --git a/packages/tui/test/fixed-suffix-scroll-region.test.ts b/packages/tui/test/fixed-suffix-scroll-region.test.ts index bc492e704e..5957735135 100644 --- a/packages/tui/test/fixed-suffix-scroll-region.test.ts +++ b/packages/tui/test/fixed-suffix-scroll-region.test.ts @@ -187,6 +187,11 @@ describe("TUI fixed suffix scroll region", () => { expect(batchedScrollback.filter(line => line === "line-5 revised")).toHaveLength(1); expect(batchedScrollback.filter(line => line === "line-6")).toHaveLength(1); expect(batchedScrollback.filter(line => line === "line-7")).toHaveLength(1); + tui.releaseFixedSuffixScrollRegion(token); + term.clearWriteLog(); + tui.requestRender(); + await term.waitForRender(); + expect(term.getWriteLog().join("")).toContain("\x1b[r\x1b[?6l"); } finally { tui.stop(); } From d2d9560c95fd8566f13301effefe2bb714cc50bc Mon Sep 17 00:00:00 2001 From: snowykr Date: Sun, 2 Aug 2026 16:54:32 +0900 Subject: [PATCH 19/46] fix(tui): stage manual scrollback before fixed admission Manual live-follow rows have an explicit native-admission frontier. Keep the persistent fixed plane out of that staged transition so it cannot clear an uncommitted host-history boundary.\n\nConstraint: retain existing manual follow admission semantics\nTested: bun test test/fixed-suffix-scroll-region.test.ts test/raster-lease.test.ts test/render-commit.test.ts; bun run check\nScope-risk: narrow\nReversibility: direct --- packages/tui/src/tui.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index e0f62c41f2..d8b3b8c908 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -4836,6 +4836,7 @@ export class TUI extends Container { nextKittyPlacementSpans.length === 0 && !newLines.some(line => TERMINAL.isImageLine(line)) && this.#scrollbackResumeViewportTop === undefined && + !this.#nativeScrollbackAdmissionPending && previousSuffixLineCount === nextSuffixLineCount && nextSuffixLineCount < height; if (fixedPlaneEligible) { From 52c9573a408e01ed1cdbfba7dcfac028cbdb373b Mon Sep 17 00:00:00 2001 From: snowykr Date: Sun, 2 Aug 2026 16:57:20 +0900 Subject: [PATCH 20/46] fix(tui): limit fixed plane to eligible raster leases A fixed-suffix token alone must not opt a generic raster lease into DECSTBM admission. Require native scrollback intent on the bound lease and prove default leases remain on their existing renderer path.\n\nConstraint: preserve Kitty, Sixel, and generic raster behavior\nTested: bun test test/fixed-suffix-scroll-region.test.ts test/raster-lease.test.ts test/render-commit.test.ts; bun run check\nScope-risk: narrow\nReversibility: direct --- packages/tui/src/tui.ts | 4 +++- packages/tui/test/fixed-suffix-scroll-region.test.ts | 12 +++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index d8b3b8c908..cc4be966ad 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -4804,6 +4804,7 @@ export class TUI extends Container { fixedSuffixScrollRegionToken !== undefined && fixedSuffixScrollRegionRasterLease !== undefined && fixedSuffixRasterLease?.token === fixedSuffixScrollRegionRasterLease && + fixedSuffixRasterLease.nativeScrollbackEligible && fixedSuffixRasterLease.token.rect.row > 0)); const soleRasterLease = this.#rasterLeases.size === 1 ? this.#rasterLeases.values().next().value : undefined; const fixedPlaneUpperBottom = @@ -4928,7 +4929,8 @@ export class TUI extends Container { !fixedSuffixNativeAppendPreservesRasterLease && this.#rasterLeases.size > 0 && this.#rasterCleanup.size === 0 && - (fixedSuffixScrollRegionToken !== undefined || soleRasterLease?.nativeScrollbackEligible === true); + (fixedSuffixRasterLease?.nativeScrollbackEligible === true || + soleRasterLease?.nativeScrollbackEligible === true); if ( !fixedSuffixNativeAppendPreservesRasterLease && !rasterMustYieldForNativeAdmission && diff --git a/packages/tui/test/fixed-suffix-scroll-region.test.ts b/packages/tui/test/fixed-suffix-scroll-region.test.ts index 5957735135..bbf12765e9 100644 --- a/packages/tui/test/fixed-suffix-scroll-region.test.ts +++ b/packages/tui/test/fixed-suffix-scroll-region.test.ts @@ -196,7 +196,7 @@ describe("TUI fixed suffix scroll region", () => { tui.stop(); } }); - it("releases an ineligible raster lease before admitting a fixed-suffix append", async () => { + it("keeps an ineligible raster lease on its generic renderer path", async () => { const { term, transcript, tui } = createPinnedTui(); let invalidated = 0; try { @@ -219,12 +219,10 @@ describe("TUI fixed suffix scroll region", () => { await term.waitForRender(); const output = term.getWriteLog().join(""); - expect(output).toContain("ITERM_ERASE"); - expect(output).toContain("\x1b[1;3r"); - expect(output).toContain("\x1bD\r\x1b[2Kline-4"); - expect(invalidated).toBe(1); - await term.flush(); - expect(term.getScrollBuffer().map(line => line.trimEnd())).toContain("line-1"); + expect(output).not.toContain("ITERM_ERASE"); + expect(output).not.toContain("\x1b[1;3r"); + expect(output).not.toContain("\x1bD\r\x1b[2Kline-4"); + expect(invalidated).toBe(0); } finally { tui.stop(); } From e44d3de7fe4ac59463ad4b96f6aef8835e3a496f Mon Sep 17 00:00:00 2001 From: snowykr Date: Sun, 2 Aug 2026 17:01:28 +0900 Subject: [PATCH 21/46] fix(tui): retain lower cursor position in fixed plane DECSTBM scrolls only the upper plane, so a saved composer cursor does not move with admitted transcript rows. Restore from the physical hardware row instead of shifting it by the scroll frontier.\n\nConstraint: keep lower composer cursor outside the persistent scroll plane\nTested: bun test test/fixed-suffix-scroll-region.test.ts test/raster-lease.test.ts test/render-commit.test.ts; bun run check\nScope-risk: narrow\nReversibility: direct --- packages/tui/src/tui.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index cc4be966ad..4dbe1ac280 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -4879,11 +4879,7 @@ export class TUI extends Container { fixedPlaneBuffer += `${sliceByColumn(suffixLine, segment.column, segment.width, true)}${SEGMENT_RESET}`; } } - const { seq, toRow } = this.#cursorControlSequence( - cursorPos, - newLines.length, - this.#hardwareCursorRow + desiredTop - physicalTop, - ); + const { seq, toRow } = this.#cursorControlSequence(cursorPos, newLines.length, this.#hardwareCursorRow); fixedPlaneBuffer += `\x1b8\x1b[?6l${seq}\x1b[?2026l`; if ( !this.#writeRenderBufferAndReanchorImeCursor( From 5d86025e3098361d3a62232ba5c239279a111ab5 Mon Sep 17 00:00:00 2001 From: snowykr Date: Sun, 2 Aug 2026 17:04:46 +0900 Subject: [PATCH 22/46] fix(tui): retire fixed planes before generic rendering A persistent DECSTBM plane is valid only for append frames with a current iTerm lease. Retire it before non-append output, reset margins ahead of generic/shutdown writes, and record every batch-admission row in durable state.\n\nConstraint: preserve native history, manual follow, graphics isolation, and lower cursor ownership\nTested: bun test test/fixed-suffix-scroll-region.test.ts test/raster-lease.test.ts test/render-commit.test.ts; bun run check\nScope-risk: focused\nReversibility: direct --- packages/tui/src/tui.ts | 12 +++++++++-- .../test/fixed-suffix-scroll-region.test.ts | 21 ++++++++++++++++++- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 4dbe1ac280..f0f444a500 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -2559,6 +2559,8 @@ export class TUI extends Container { stop(): void { this.#resetFixedSuffixScrollRegions(); + if (this.#fixedSuffixScrollRegionResetPending && this.#writeTerminal("\x1b[r\x1b[?6l")) + this.#fixedSuffixScrollRegionResetPending = false; this.#flushRasterLeasesBeforeStop("terminal-loss"); this.flushTerminalCleanup(); const placementCleanup = this.#kittyPlacementDeletePlan(this.#kittyPlacementSpans, [], [], true).output; @@ -4742,7 +4744,7 @@ export class TUI extends Container { // A multipart raster prefix owns the terminal cursor until its records and // restore suffix are delivered; queue a no-op cursor update behind it rather // than moving the GIF placement between prefix and records. - if (firstChanged === -1 && fixedSuffixScrollRegionToken === undefined) { + if (firstChanged === -1) { this.#viewportTopRow = Math.max(0, this.#maxLinesRendered - height); if (this.#rasterPending > 0) { void this.#enqueueRaster(() => { @@ -4799,6 +4801,7 @@ export class TUI extends Container { const fixedSuffixNativeAppendPreservesRasterLease = fixedSuffixNativeAppend && this.#rasterCleanup.size === 0 && + !this.#fixedSuffixScrollRegionResetPending && (this.#rasterLeases.size === 0 || (this.#rasterLeases.size === 1 && fixedSuffixScrollRegionToken !== undefined && @@ -4822,6 +4825,7 @@ export class TUI extends Container { this.#rasterCleanup.size === 0 && this.#rasterPending === 0 && fixedPlaneUpperBottom > 0 && + appendedLines && hasStickySuffix && !widthChanged && !heightChanged && @@ -4840,6 +4844,10 @@ export class TUI extends Container { !this.#nativeScrollbackAdmissionPending && previousSuffixLineCount === nextSuffixLineCount && nextSuffixLineCount < height; + if (!fixedPlaneEligible && this.#fixedSuffixScrollPlane !== undefined) { + this.#fixedSuffixScrollPlane = undefined; + this.#fixedSuffixScrollRegionResetPending = true; + } if (fixedPlaneEligible) { const plane = this.#fixedSuffixScrollPlane; const physicalTop = @@ -4911,7 +4919,7 @@ export class TUI extends Container { this.#latestRenderedLines = newLines; if (this.#virtualViewport) this.#latestRaw = rawLines; this.#durableLineCount = Math.max(this.#durableLineCount, newLines.length); - this.#recordDurableLines(newLines, rawLines, desiredTop, newLines.length - 1); + this.#recordDurableLines(newLines, rawLines, physicalTop, newLines.length - 1); this.#nativeScrollbackAdmissionPending = false; this.#transcriptIdentityReplaced = false; return; diff --git a/packages/tui/test/fixed-suffix-scroll-region.test.ts b/packages/tui/test/fixed-suffix-scroll-region.test.ts index bbf12765e9..d6f239042b 100644 --- a/packages/tui/test/fixed-suffix-scroll-region.test.ts +++ b/packages/tui/test/fixed-suffix-scroll-region.test.ts @@ -187,11 +187,30 @@ describe("TUI fixed suffix scroll region", () => { expect(batchedScrollback.filter(line => line === "line-5 revised")).toHaveLength(1); expect(batchedScrollback.filter(line => line === "line-6")).toHaveLength(1); expect(batchedScrollback.filter(line => line === "line-7")).toHaveLength(1); + transcript.setLines([ + "line-1", + "line-2", + "line-3", + "line-4", + "line-5 revised", + "line-6", + "line-7", + "line-8", + "line-9", + "line-10 revised", + ]); + term.clearWriteLog(); + tui.requestRender(); + await term.waitForRender(); + const reflowOutput = term.getWriteLog().join(""); + expect(reflowOutput).toContain("\x1b[r\x1b[?6l"); + expect(reflowOutput).not.toContain("\x1b[1;2r"); + expect(reflowOutput).not.toContain("ITERM_ERASE"); tui.releaseFixedSuffixScrollRegion(token); term.clearWriteLog(); tui.requestRender(); await term.waitForRender(); - expect(term.getWriteLog().join("")).toContain("\x1b[r\x1b[?6l"); + expect(term.getWriteLog().join("")).not.toContain("\x1b[r\x1b[?6l"); } finally { tui.stop(); } From a399e5c4759d14f993aeed7cfdf1ba345fd4d97c Mon Sep 17 00:00:00 2001 From: snowykr Date: Sun, 2 Aug 2026 17:08:47 +0900 Subject: [PATCH 23/46] fix(tui): reset retired fixed scroll margins A preserved raster render could otherwise retain DECSTBM margins after the fixed plane retired. Prefix every next render ingress with the margin reset so generic rendering always resumes full-terminal semantics.\n\nConstraint: preserve live raster leases while restoring full margins\nTested: bun test test/fixed-suffix-scroll-region.test.ts test/raster-lease.test.ts test/render-commit.test.ts; bun run check; git diff --check\nScope-risk: narrow\nReversibility: direct --- packages/tui/src/tui.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index f0f444a500..f4738de779 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -5544,7 +5544,7 @@ export class TUI extends Container { : (bytes: string) => this.#writeProtectedRenderIngress(bytes); const renderGeneration = this.#renderGenerationInProgress; const write = () => { - const needsFixedSuffixReset = this.#fixedSuffixScrollRegionResetPending && !preserveRasterLeases; + const needsFixedSuffixReset = this.#fixedSuffixScrollRegionResetPending; const bytes = needsFixedSuffixReset ? `\x1b[r\x1b[?6l${buffer}` : buffer; if (!writeIngress(bytes)) return false; if (needsFixedSuffixReset) this.#fixedSuffixScrollRegionResetPending = false; From 1ecbd22a9dd9a64080eeaee68d27b9b4760dbe21 Mon Sep 17 00:00:00 2001 From: snowykr Date: Sun, 2 Aug 2026 17:14:50 +0900 Subject: [PATCH 24/46] fix(tui): admit rewritten fixed-plane rows A row leaving the DECSTBM plane must be painted with its latest content before IND commits it to host scrollback. Reject frames that would remap already-admitted history, and reset active margins during terminal teardown.\n\nConstraint: preserve the lower Pet/composer plane without duplicate or stale host history\nTested: bun test test/fixed-suffix-scroll-region.test.ts test/raster-lease.test.ts test/render-commit.test.ts; bun run check; git diff --check\nScope-risk: narrow\nReversibility: direct --- packages/tui/src/tui.ts | 15 ++++-- .../test/fixed-suffix-scroll-region.test.ts | 47 +++++++++++++++++-- 2 files changed, 54 insertions(+), 8 deletions(-) diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index f4738de779..07180ac532 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -4814,6 +4814,11 @@ export class TUI extends Container { fixedSuffixRasterLease === undefined ? 0 : Math.min(height - nextSuffixLineCount, fixedSuffixRasterLease.token.rect.row); + const fixedPlanePhysicalTop = + this.#fixedSuffixScrollPlane?.token === fixedSuffixScrollRegionToken && + this.#fixedSuffixScrollPlane?.upperBottom === fixedPlaneUpperBottom + ? this.#fixedSuffixScrollPlane.transcriptTop + : prevViewportTop; const fixedPlaneEligible = fixedSuffixScrollRegionToken !== undefined && this.#fixedSuffixScrollRegionOwners.get(fixedSuffixScrollRegionToken.ownerId) === @@ -4842,6 +4847,7 @@ export class TUI extends Container { !newLines.some(line => TERMINAL.isImageLine(line)) && this.#scrollbackResumeViewportTop === undefined && !this.#nativeScrollbackAdmissionPending && + newLines.slice(0, fixedPlanePhysicalTop).every((line, index) => line === previousLogicalFrame[index]) && previousSuffixLineCount === nextSuffixLineCount && nextSuffixLineCount < height; if (!fixedPlaneEligible && this.#fixedSuffixScrollPlane !== undefined) { @@ -4850,10 +4856,7 @@ export class TUI extends Container { } if (fixedPlaneEligible) { const plane = this.#fixedSuffixScrollPlane; - const physicalTop = - plane?.token === fixedSuffixScrollRegionToken && plane.upperBottom === fixedPlaneUpperBottom - ? plane.transcriptTop - : prevViewportTop; + const physicalTop = fixedPlanePhysicalTop; const desiredTop = Math.max(0, nextTranscriptLineCount - fixedPlaneUpperBottom); if (physicalTop > desiredTop) { this.#fixedSuffixScrollPlane = undefined; @@ -4866,6 +4869,10 @@ export class TUI extends Container { if (plane?.token !== fixedSuffixScrollRegionToken || plane.upperBottom !== fixedPlaneUpperBottom) fixedPlaneBuffer += `\x1b[1;${fixedPlaneUpperBottom}r`; for (let scrollIndex = 0; scrollIndex < desiredTop - physicalTop; scrollIndex += 1) { + const displacedLineIndex = physicalTop + scrollIndex; + if (newLines[displacedLineIndex] !== previousLogicalFrame[displacedLineIndex]) { + fixedPlaneBuffer += `\x1b[1;1H\x1b[2K${this.#padLineToWidth(newLines[displacedLineIndex] ?? "", width)}`; + } const lineIndex = physicalTop + fixedPlaneUpperBottom + scrollIndex; fixedPlaneBuffer += `\x1b[${fixedPlaneUpperBottom};1H\x1bD\r\x1b[2K${this.#padLineToWidth( newLines[lineIndex] ?? "", diff --git a/packages/tui/test/fixed-suffix-scroll-region.test.ts b/packages/tui/test/fixed-suffix-scroll-region.test.ts index d6f239042b..25c24d38bf 100644 --- a/packages/tui/test/fixed-suffix-scroll-region.test.ts +++ b/packages/tui/test/fixed-suffix-scroll-region.test.ts @@ -196,8 +196,33 @@ describe("TUI fixed suffix scroll region", () => { "line-6", "line-7", "line-8", - "line-9", - "line-10 revised", + "line-9 revised", + "line-10", + "line-11", + ]); + term.clearWriteLog(); + tui.requestRender(); + await term.waitForRender(); + const outgoingRewriteOutput = term.getWriteLog().join(""); + expect(outgoingRewriteOutput).toContain("\x1b[1;1H\x1b[2Kline-9 revised"); + expect(outgoingRewriteOutput).toContain("\x1b[2;1H\x1bD\r\x1b[2Kline-11"); + expect(outgoingRewriteOutput).not.toContain("ITERM_ERASE"); + await term.flush(); + const outgoingRewriteScrollback = term.getScrollBuffer().map(line => line.trimEnd()); + expect(outgoingRewriteScrollback.filter(line => line === "line-9 revised")).toHaveLength(1); + expect(outgoingRewriteScrollback).not.toContain("line-9"); + transcript.setLines([ + "line-1", + "line-2", + "line-3", + "line-4", + "line-5 revised", + "line-6", + "line-7", + "line-8", + "line-9 revised", + "line-10", + "line-11 revised", ]); term.clearWriteLog(); tui.requestRender(); @@ -298,14 +323,28 @@ describe("TUI fixed suffix scroll region", () => { tui.stop(); } }); - it("does not acquire or arm a fixed suffix owner after stop", async () => { - const { term, tui } = createPinnedTui(); + it("resets an active fixed scroll plane before stop", async () => { + const { term, transcript, tui } = createPinnedTui(); tui.start(); await term.waitForRender(); + const lease = await tui.acquireRasterLease({ + ownerId: "test-owner", + rect: { column: 36, row: 2, width: 3, height: 3 }, + erase: { type: "raster-erase", bytes: new TextEncoder().encode("ITERM_ERASE") }, + nativeScrollbackEligible: true, + }); + expect(lease.status).toBe("acquired"); + if (lease.status !== "acquired") throw new Error("Expected iTerm lease"); const token = tui.acquireFixedSuffixScrollRegion("test-owner"); expect(token).toBeDefined(); if (token === undefined) throw new Error("Expected fixed suffix token"); + transcript.setLines(["line-1", "line-2", "line-3", "line-4"]); + term.clearWriteLog(); + expect(tui.armFixedSuffixScrollRegion(token, lease.token)).toBeGreaterThan(0); + await term.waitForRender(); + term.clearWriteLog(); tui.stop(); + expect(term.getWriteLog().join("")).toContain("\x1b[r\x1b[?6l"); expect(tui.acquireFixedSuffixScrollRegion("new-owner")).toBeUndefined(); expect(tui.armFixedSuffixScrollRegion(token)).toBeUndefined(); }); From 28b55fe2ea4eb9baca0b1be84c0520cd15f96d7f Mon Sep 17 00:00:00 2001 From: snowykr Date: Sun, 2 Aug 2026 17:21:38 +0900 Subject: [PATCH 25/46] fix(tui): reset retired planes before queued output Queued terminal output bypassed the render ingress that restores full DECSTBM margins after a fixed plane released. Restore them at the shared queue boundary before any subsequent terminal bytes.\n\nConstraint: queued protocol output must never inherit a retired scroll region\nTested: bun test test/fixed-suffix-scroll-region.test.ts test/raster-lease.test.ts test/render-commit.test.ts; bun run check; git diff --check\nScope-risk: narrow\nReversibility: direct --- packages/tui/src/tui.ts | 6 ++++ .../test/fixed-suffix-scroll-region.test.ts | 29 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 07180ac532..fcdb68ccfc 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -1919,6 +1919,12 @@ export class TUI extends Container { !op.shouldWrite() ) return { queueId: id, operation: op.type, status: "stale-token" }; + if ( + this.#fixedSuffixScrollRegionResetPending && + !this.#guardTerminalOperation(() => this.terminal.write("\x1b[r\x1b[?6l")) + ) + return failed(); + this.#fixedSuffixScrollRegionResetPending = false; if (op.type === "raster-multipart-batch" && op.prefix !== undefined && op.afterPrefix !== undefined) { const prefixWritten = this.#guardTerminalOperation(() => this.terminal.write(new TextDecoder().decode(op.prefix)), diff --git a/packages/tui/test/fixed-suffix-scroll-region.test.ts b/packages/tui/test/fixed-suffix-scroll-region.test.ts index 25c24d38bf..40011a33c5 100644 --- a/packages/tui/test/fixed-suffix-scroll-region.test.ts +++ b/packages/tui/test/fixed-suffix-scroll-region.test.ts @@ -348,4 +348,33 @@ describe("TUI fixed suffix scroll region", () => { expect(tui.acquireFixedSuffixScrollRegion("new-owner")).toBeUndefined(); expect(tui.armFixedSuffixScrollRegion(token)).toBeUndefined(); }); + it("resets a released fixed plane before queued terminal output", async () => { + const { term, transcript, tui } = createPinnedTui(); + try { + tui.start(); + await term.waitForRender(); + const lease = await tui.acquireRasterLease({ + ownerId: "test-owner", + rect: { column: 36, row: 2, width: 3, height: 3 }, + erase: { type: "raster-erase", bytes: new TextEncoder().encode("ITERM_ERASE") }, + nativeScrollbackEligible: true, + }); + expect(lease.status).toBe("acquired"); + if (lease.status !== "acquired") throw new Error("Expected iTerm lease"); + const token = tui.acquireFixedSuffixScrollRegion("test-owner"); + expect(token).toBeDefined(); + if (token === undefined) throw new Error("Expected fixed suffix token"); + transcript.setLines(["line-1", "line-2", "line-3", "line-4"]); + expect(tui.armFixedSuffixScrollRegion(token, lease.token)).toBeGreaterThan(0); + await term.waitForRender(); + + tui.releaseFixedSuffixScrollRegion(token); + term.clearWriteLog(); + expect(await tui.queueTerminalOutput("after-release")).toMatchObject({ status: "written" }); + expect(term.getWriteLog().join("")).toContain("\x1b[r\x1b[?6lafter-release"); + expect(term.getWriteLog().join("")).not.toContain("ITERM_ERASE"); + } finally { + tui.stop(); + } + }); }); From 0e39ec60857da9a59b0e654c93e7ad09103c2ab3 Mon Sep 17 00:00:00 2001 From: snowykr Date: Sun, 2 Aug 2026 17:27:18 +0900 Subject: [PATCH 26/46] fix(tui): reset planes before every queued ingress Lease invalidation and lifecycle cleanup bypassed queued output's DECSTBM reset. Centralize the pending-margin guard across terminal, multipart, cleanup, and lifecycle writes.\n\nConstraint: no queued terminal byte may inherit a retired fixed scroll region\nTested: bun test test/fixed-suffix-scroll-region.test.ts test/raster-lease.test.ts test/render-commit.test.ts; bun run check; git diff --check\nScope-risk: narrow\nReversibility: direct --- packages/tui/src/tui.ts | 53 +++++++++++-------- .../test/fixed-suffix-scroll-region.test.ts | 29 ++++++++++ 2 files changed, 61 insertions(+), 21 deletions(-) diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index fcdb68ccfc..2feb0af87a 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -1920,11 +1920,9 @@ export class TUI extends Container { ) return { queueId: id, operation: op.type, status: "stale-token" }; if ( - this.#fixedSuffixScrollRegionResetPending && - !this.#guardTerminalOperation(() => this.terminal.write("\x1b[r\x1b[?6l")) + !this.#writeFixedSuffixResetBefore(bytes => this.#guardTerminalOperation(() => this.terminal.write(bytes))) ) return failed(); - this.#fixedSuffixScrollRegionResetPending = false; if (op.type === "raster-multipart-batch" && op.prefix !== undefined && op.afterPrefix !== undefined) { const prefixWritten = this.#guardTerminalOperation(() => this.terminal.write(new TextDecoder().decode(op.prefix)), @@ -1996,7 +1994,7 @@ export class TUI extends Container { lease.revoked = true; this.#rasterLeases.delete(request.token.ownerId); const erase = this.#cursorGuardedRasterSequence(new TextDecoder().decode(lease.erase)); - const ok = this.#guardTerminalOperation(() => this.terminal.write(erase)); + const ok = this.#writeTerminal(erase); if (!ok) this.#rasterCleanup.set(request.token.ownerId, { token: lease.token, @@ -2347,8 +2345,17 @@ export class TUI extends Container { this.#clearSixelProbeState(); } + #writeFixedSuffixResetBefore(write: (data: string) => boolean): boolean { + if (!this.#fixedSuffixScrollRegionResetPending) return true; + if (!write("\x1b[r\x1b[?6l")) return false; + this.#fixedSuffixScrollRegionResetPending = false; + return true; + } #writeTerminal(data: string, deferRenderFailure = false): boolean { - return this.#guardTerminalOperation(() => this.terminal.write(data), !deferRenderFailure); + const write = (bytes: string) => + this.#guardTerminalOperation(() => this.terminal.write(bytes), !deferRenderFailure); + if (data !== "\x1b[r\x1b[?6l" && !this.#writeFixedSuffixResetBefore(write)) return false; + return write(data); } #hideCursor(): boolean { @@ -2378,22 +2385,26 @@ export class TUI extends Container { } #writeLifecycleCleanup(data: string): boolean { - if (!this.terminal.available) { - this.#markTerminalUnavailable(); - return false; - } - try { - this.terminal.write(data); - } catch { - this.#markTerminalUnavailable(); - return false; - } - if (!this.terminal.available) { - this.#markTerminalUnavailable(); - return false; - } - this.#terminalUnavailable = false; - return true; + const write = (bytes: string): boolean => { + if (!this.terminal.available) { + this.#markTerminalUnavailable(); + return false; + } + try { + this.terminal.write(bytes); + } catch { + this.#markTerminalUnavailable(); + return false; + } + if (!this.terminal.available) { + this.#markTerminalUnavailable(); + return false; + } + this.#terminalUnavailable = false; + return true; + }; + if (!this.#writeFixedSuffixResetBefore(write)) return false; + return write(data); } addInputListener(listener: InputListener): () => void { diff --git a/packages/tui/test/fixed-suffix-scroll-region.test.ts b/packages/tui/test/fixed-suffix-scroll-region.test.ts index 40011a33c5..625cb0dd2d 100644 --- a/packages/tui/test/fixed-suffix-scroll-region.test.ts +++ b/packages/tui/test/fixed-suffix-scroll-region.test.ts @@ -377,4 +377,33 @@ describe("TUI fixed suffix scroll region", () => { tui.stop(); } }); + it("resets a released fixed plane before queued terminal cleanup", async () => { + const { term, transcript, tui } = createPinnedTui(); + try { + tui.start(); + await term.waitForRender(); + const lease = await tui.acquireRasterLease({ + ownerId: "test-owner", + rect: { column: 36, row: 2, width: 3, height: 3 }, + erase: { type: "raster-erase", bytes: new TextEncoder().encode("ITERM_ERASE") }, + nativeScrollbackEligible: true, + }); + expect(lease.status).toBe("acquired"); + if (lease.status !== "acquired") throw new Error("Expected iTerm lease"); + const token = tui.acquireFixedSuffixScrollRegion("test-owner"); + expect(token).toBeDefined(); + if (token === undefined) throw new Error("Expected fixed suffix token"); + transcript.setLines(["line-1", "line-2", "line-3", "line-4"]); + expect(tui.armFixedSuffixScrollRegion(token, lease.token)).toBeGreaterThan(0); + await term.waitForRender(); + + tui.releaseFixedSuffixScrollRegion(token); + term.clearWriteLog(); + await tui.queueTerminalCleanup("after-release"); + expect(term.getWriteLog().join("")).toContain("\x1b[r\x1b[?6lafter-release"); + expect(term.getWriteLog().join("")).not.toContain("ITERM_ERASE"); + } finally { + tui.stop(); + } + }); }); From 59a6cdb2176873b1359cc222680ffd67b7e7a9b6 Mon Sep 17 00:00:00 2001 From: snowykr Date: Sun, 2 Aug 2026 17:32:15 +0900 Subject: [PATCH 27/46] fix(tui): reset margins before deferred renders Lifecycle recovery dispatched retained disjoint generic bytes without the fixed-plane reset. Route that final ingress through the shared writer.\n\nConstraint: deferred terminal output must not inherit retired DECSTBM margins\nTested: bun test test/fixed-suffix-scroll-region.test.ts test/raster-lease.test.ts test/render-commit.test.ts; bun run check; git diff --check\nScope-risk: narrow\nReversibility: direct --- packages/tui/src/tui.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 2feb0af87a..30e4963eb8 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -2153,7 +2153,7 @@ export class TUI extends Container { ) ) return false; - return this.#guardTerminalOperation(() => this.terminal.write(buffer)); + return this.#writeTerminal(buffer); } #writeProtectedRenderIngress(buffer: string): boolean { const affected = [...this.#rasterLeases.values()]; From b7127d604bd9cdd7101fb0f9a9d88335975eb67d Mon Sep 17 00:00:00 2001 From: snowykr Date: Sun, 2 Aug 2026 17:54:38 +0900 Subject: [PATCH 28/46] fix(pet): keep iTerm GIF on idle timeline An iTerm working or burst GIF cannot be replaced without flashing its transparent canvas. When it reached an opaque intermediate frame, the idle Pet remained a solid orange box. Use the stable idle loop for iTerm submissions.\n\nConstraint: preserve iTerm lease continuity without opaque idle frames\nTested: bun test test/gajae-pet-widget.test.ts test/modes/components/iterm-pet-transport.test.ts test/qa-iterm-pet.test.ts; bun run check; git diff --check\nScope-risk: narrow\nReversibility: direct --- .../src/modes/components/gajae-pet-widget.ts | 21 +++++-------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/packages/coding-agent/src/modes/components/gajae-pet-widget.ts b/packages/coding-agent/src/modes/components/gajae-pet-widget.ts index 49464a053c..8a57a02ea5 100644 --- a/packages/coding-agent/src/modes/components/gajae-pet-widget.ts +++ b/packages/coding-agent/src/modes/components/gajae-pet-widget.ts @@ -1,7 +1,6 @@ import { type AnimationRegistration, buildGajaePixelFrames, - burstTimeline, type CellRect, type Component, type Container, @@ -20,7 +19,6 @@ import { type RasterLeaseToken, registerAnimationCallback, type TUI, - workingTimeline, wrapITerm2RecordForTmux, } from "@gajae-code/tui"; import type { CustomEditor } from "./custom-editor"; @@ -494,7 +492,7 @@ export class GajaePetWidget { return "base"; } - #tickIterm(now: number): void { + #tickIterm(_now: number): void { if (!this.#isActiveOwner() || this.#ui.manualViewportActive) { this.#releaseFixedSuffixScrollRegion(); return; @@ -565,12 +563,11 @@ export class GajaePetWidget { this.#releaseFixedSuffixScrollRegion(); return; } - const working = this.#isWorking(); - const flexing = this.#flexUntil > now; // OSC 1337 has no image-frame replacement primitive. Re-uploading a GIF // for ordinary working/idle or auto-flex transitions visibly flashes its - // transparent canvas, so an armed iTerm lease keeps its initial timeline - // until a real placement or geometry change requires a new submission. + // transparent canvas, so an armed iTerm lease keeps one stable idle loop. + // A working or burst GIF can end on an opaque intermediate frame and leave + // an idle Pet as a solid box. const semantic = `${this.#mode}:${availability.mode}:${availability.epoch}:${rect.column},${rect.row}:${cell.widthPx},${cell.heightPx}:${this.#ui.terminal.columns},${this.#ui.terminal.rows}`; if (this.#itermSubmitPending) return; if (semantic === this.#itermLastSemantic && this.#itermLease) { @@ -586,8 +583,6 @@ export class GajaePetWidget { availability.epoch, availability.mode, semantic, - working, - flexing, { columns: this.#ui.terminal.columns, rows: terminalRows, @@ -606,8 +601,6 @@ export class GajaePetWidget { epoch: number, mode: "direct" | "managed", semantic: string, - working: boolean, - flexing: boolean, geometry: Readonly<{ columns: number; rows: number; cellWidthPx: number; cellHeightPx: number }>, composerBottomOffset: number, ): Promise { @@ -693,11 +686,7 @@ export class GajaePetWidget { this.#itermLease = token; } this.#itermLastSemantic = semantic; - const frames = flexing - ? burstTimeline(this.#mode === "off" ? "red" : this.#mode) - : working - ? workingTimeline() - : idleTimeline(); + const frames = idleTimeline(); const cell = getCellDimensions(); const gif = getGajaePetGifCached({ skin: this.#mode === "off" ? "red" : this.#mode, From f1a2af188032213c8d158b989745bf2da5fc2948 Mon Sep 17 00:00:00 2001 From: snowykr Date: Sun, 2 Aug 2026 18:05:30 +0900 Subject: [PATCH 29/46] fix(tui): preserve raster while iTerm upload arms A native-eligible lease yielded during its GIF upload window, repeatedly erasing the Pet before fixed-suffix ownership could arm. Separate authorization from armed native admission so pre-arm output preserves the raster.\n\nConstraint: native admission begins only after the iTerm GIF and fixed suffix owner commit\nTested: bun test test/fixed-suffix-scroll-region.test.ts test/raster-lease.test.ts test/render-commit.test.ts; bun run check; git diff --check\nScope-risk: narrow\nReversibility: direct --- packages/tui/src/tui.ts | 18 +++++++++++------- packages/tui/test/raster-lease.test.ts | 22 ++++++++-------------- 2 files changed, 19 insertions(+), 21 deletions(-) diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 30e4963eb8..5e1ce24dc0 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -1004,6 +1004,7 @@ export class TUI extends Container { erase: Uint8Array; callback?: (n: RasterLeaseInvalidatedNotification) => void; nativeScrollbackEligible: boolean; + nativeScrollbackArmed: boolean; revoked: boolean; } >(); @@ -1324,6 +1325,9 @@ export class TUI extends Container { (rasterLease !== undefined && this.#rasterLeases.get(token.ownerId)?.token !== rasterLease) ) return undefined; + const lease = rasterLease === undefined ? undefined : this.#rasterLeases.get(token.ownerId); + if (rasterLease !== undefined && (!lease || lease.token !== rasterLease)) return undefined; + if (lease?.nativeScrollbackEligible) lease.nativeScrollbackArmed = true; this.#armedFixedSuffixScrollRegionToken = token; this.#armedFixedSuffixScrollRegionRasterLease = rasterLease; return this.requestRenderWithGeneration(false, "fixed-suffix-scroll-region"); @@ -1844,6 +1848,7 @@ export class TUI extends Container { erase: new Uint8Array(request.erase.bytes), callback: request.onInvalidated, nativeScrollbackEligible: request.nativeScrollbackEligible === true, + nativeScrollbackArmed: false, revoked: false, }); return { status: "acquired", token }; @@ -4824,7 +4829,7 @@ export class TUI extends Container { fixedSuffixScrollRegionToken !== undefined && fixedSuffixScrollRegionRasterLease !== undefined && fixedSuffixRasterLease?.token === fixedSuffixScrollRegionRasterLease && - fixedSuffixRasterLease.nativeScrollbackEligible && + fixedSuffixRasterLease.nativeScrollbackArmed && fixedSuffixRasterLease.token.rect.row > 0)); const soleRasterLease = this.#rasterLeases.size === 1 ? this.#rasterLeases.values().next().value : undefined; const fixedPlaneUpperBottom = @@ -4842,7 +4847,7 @@ export class TUI extends Container { fixedSuffixScrollRegionToken && fixedSuffixScrollRegionRasterLease !== undefined && fixedSuffixRasterLease?.token === fixedSuffixScrollRegionRasterLease && - fixedSuffixRasterLease.nativeScrollbackEligible && + fixedSuffixRasterLease.nativeScrollbackArmed && this.#rasterLeases.size === 1 && this.#rasterCleanup.size === 0 && this.#rasterPending === 0 && @@ -4948,17 +4953,16 @@ export class TUI extends Container { this.#transcriptIdentityReplaced = false; return; } - // A verified iTerm lease must yield before an overflow can overwrite a row - // that native history has not received. The fixed token covers an armed - // owner; the explicit capability covers the pre-arm upload window. + // Only an armed iTerm lease yields for native admission. Before the GIF + // upload commits and arms the lease, preserve its raster rather than + // repeatedly erasing and re-uploading it under streaming output. const rasterMustYieldForNativeAdmission = appendedLines && nextLiveViewportTop > prevViewportTop && !fixedSuffixNativeAppendPreservesRasterLease && this.#rasterLeases.size > 0 && this.#rasterCleanup.size === 0 && - (fixedSuffixRasterLease?.nativeScrollbackEligible === true || - soleRasterLease?.nativeScrollbackEligible === true); + (fixedSuffixRasterLease?.nativeScrollbackArmed === true || soleRasterLease?.nativeScrollbackArmed === true); if ( !fixedSuffixNativeAppendPreservesRasterLease && !rasterMustYieldForNativeAdmission && diff --git a/packages/tui/test/raster-lease.test.ts b/packages/tui/test/raster-lease.test.ts index 2f4748a443..3c0baf94bf 100644 --- a/packages/tui/test/raster-lease.test.ts +++ b/packages/tui/test/raster-lease.test.ts @@ -486,7 +486,7 @@ describe("TUI raster lease public boundary", () => { expect(calls).toBe(0); tui.stop(); }); - it("yields an eligible pre-arm lease before repeated native overflow admission", async () => { + it("preserves an eligible pre-arm lease until its fixed suffix owner arms native admission", async () => { const { tui, terminal } = await setup(); let lines = ["one", "two", "three", "four"]; let calls = 0; @@ -494,6 +494,7 @@ describe("TUI raster lease public boundary", () => { tui.addChild(component); tui.start(); await terminal.waitForRender(); + const initialScrollbackLength = terminal.getScrollBuffer().length; const lease = await tui.acquireRasterLease({ ...request("iterm-pre-arm", rect(8, 3, 2, 1), "ERASE", () => calls++), @@ -506,25 +507,18 @@ describe("TUI raster lease public boundary", () => { await terminal.waitForRender(); const output = terminal.getWriteLog().join(""); - expect(output).toContain("ERASE"); - expect(output).toContain("\r\n"); - expect(calls).toBe(1); + expect(output).not.toContain("ERASE"); + expect(output).not.toContain("\r\n"); + expect(calls).toBe(0); await terminal.flush(); - expect( - terminal - .getScrollBuffer() - .map(line => line.trimEnd()) - .filter(line => line === "one"), - ).toHaveLength(1); + expect(terminal.getScrollBuffer()).toHaveLength(initialScrollbackLength); lines = [...lines, "six"]; terminal.clearWriteLog(); tui.requestRender(); await terminal.waitForRender(); - await terminal.flush(); - const scrollback = terminal.getScrollBuffer().map(line => line.trimEnd()); - expect(scrollback.filter(line => line === "one")).toHaveLength(1); - expect(scrollback.filter(line => line === "two")).toHaveLength(1); + expect(terminal.getWriteLog().join("")).not.toContain("ERASE"); + expect(calls).toBe(0); tui.stop(); }); it("repaints rewritten streaming output without scrolling an active raster", async () => { From c6195fa6d31d1b04e85f93aedce37f7a7157ae7a Mon Sep 17 00:00:00 2001 From: snowykr Date: Sun, 2 Aug 2026 18:21:49 +0900 Subject: [PATCH 30/46] fix(pet): keep iTerm working loop visible A static idle GIF avoided stale frames but removed the ParaPara working animation. Keep one working loop resident and append a safe base frame for iTerm implementations that stop after one pass.\n\nConstraint: preserve iTerm continuity without an opaque terminal frame\nTested: bun test test/gajae-pet-widget.test.ts test/modes/components/iterm-pet-transport.test.ts test/qa-iterm-pet.test.ts; bun run check; git diff --check\nScope-risk: narrow\nReversibility: direct --- .../src/modes/components/gajae-pet-widget.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/coding-agent/src/modes/components/gajae-pet-widget.ts b/packages/coding-agent/src/modes/components/gajae-pet-widget.ts index 8a57a02ea5..66bccf76c7 100644 --- a/packages/coding-agent/src/modes/components/gajae-pet-widget.ts +++ b/packages/coding-agent/src/modes/components/gajae-pet-widget.ts @@ -9,7 +9,6 @@ import { type GajaePixelFrames, getCellDimensions, getGajaePetGifCached, - idleTimeline, PARA_PARA_STEPS, PET_SKINS, type PetMode, @@ -19,6 +18,7 @@ import { type RasterLeaseToken, registerAnimationCallback, type TUI, + workingTimeline, wrapITerm2RecordForTmux, } from "@gajae-code/tui"; import type { CustomEditor } from "./custom-editor"; @@ -565,9 +565,8 @@ export class GajaePetWidget { } // OSC 1337 has no image-frame replacement primitive. Re-uploading a GIF // for ordinary working/idle or auto-flex transitions visibly flashes its - // transparent canvas, so an armed iTerm lease keeps one stable idle loop. - // A working or burst GIF can end on an opaque intermediate frame and leave - // an idle Pet as a solid box. + // transparent canvas. Keep one loop resident; its final base frame remains + // safe when iTerm ignores the GIF loop extension. const semantic = `${this.#mode}:${availability.mode}:${availability.epoch}:${rect.column},${rect.row}:${cell.widthPx},${cell.heightPx}:${this.#ui.terminal.columns},${this.#ui.terminal.rows}`; if (this.#itermSubmitPending) return; if (semantic === this.#itermLastSemantic && this.#itermLease) { @@ -686,7 +685,7 @@ export class GajaePetWidget { this.#itermLease = token; } this.#itermLastSemantic = semantic; - const frames = idleTimeline(); + const frames = [...workingTimeline(), { name: "base" as const, delayMs: 700 }]; const cell = getCellDimensions(); const gif = getGajaePetGifCached({ skin: this.#mode === "off" ? "red" : this.#mode, From 63703c666c6be3e4b44ada45a7c1a9ae1d6ea1fb Mon Sep 17 00:00:00 2001 From: snowykr Date: Sun, 2 Aug 2026 18:36:39 +0900 Subject: [PATCH 31/46] fix(iterm): scope fixed-plane scroll margins Restore the full terminal margin before lower-pane and multipart iTerm output.\nKeep an armed plane from expanding when the composer changes size, avoiding raster erasure during streaming updates.\n\nLore-id: 5f913e2d\nConstraint: preserve native scrollback without iTerm Pet flicker\nConstraint: keep Kitty/Sixel and manual viewport paths unchanged\nConfidence: medium\nScope-risk: focused\nReversibility: easy\nTested: focused fixed-suffix, raster lease, render commit, and iTerm pet widget suites\nTested: TUI and coding-agent package checks --- .../test/gajae-pet-widget.test.ts | 26 +++++-- packages/tui/src/tui.ts | 39 +++++++---- .../test/fixed-suffix-scroll-region.test.ts | 69 ++++++++++++++++--- 3 files changed, 107 insertions(+), 27 deletions(-) diff --git a/packages/coding-agent/test/gajae-pet-widget.test.ts b/packages/coding-agent/test/gajae-pet-widget.test.ts index a83a96fefd..7e9dc80fdb 100644 --- a/packages/coding-agent/test/gajae-pet-widget.test.ts +++ b/packages/coding-agent/test/gajae-pet-widget.test.ts @@ -3,8 +3,10 @@ import { __animationSchedulerTestHooks, Container, getCellDimensions, + getGajaePetGifCached, setCellDimensions, type TUI, + workingTimeline, wrapITerm2RecordForTmux, } from "@gajae-code/tui"; import type { CustomEditor } from "../src/modes/components/custom-editor"; @@ -1439,11 +1441,25 @@ describe("GajaePetWidget", () => { await flushAsyncChain(); expect(stubs.widget.isFlexing).toBe(true); - const headers = stubs - .getRasterOutputs() - .map(record => new TextDecoder().decode(record)) - .filter(record => record.includes("MultipartFile=")); - expect(headers).toHaveLength(1); + const records = stubs.getRasterOutputs(); + const request = stubs.getRasterLeaseRequests()[0]; + expect(request).toBeDefined(); + if (request === undefined) throw new Error("Expected iTerm raster lease request"); + const cell = getCellDimensions(); + const expectedGif = getGajaePetGifCached({ + skin: "red", + timeline: [...workingTimeline(), { name: "base", delayMs: 700 }], + targetRows: 2, + rectangle: { width: request.rect.width * cell.widthPx, height: request.rect.height * cell.heightPx }, + contentInset: { topPx: Math.floor(cell.heightPx / 2), bottomPx: Math.ceil(cell.heightPx / 2) }, + displaySize: { width: request.rect.width, height: request.rect.height }, + }); + expect(records.slice(1, -1).map(record => new TextDecoder().decode(record))).toEqual([ + ...expectedGif.multipart, + ]); + expect( + records.map(record => new TextDecoder().decode(record)).filter(record => record.includes("MultipartFile=")), + ).toHaveLength(1); } finally { setVerifiedItermPetAvailability(undefined); stubs.widget.dispose(); diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 5e1ce24dc0..b7549d4d41 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -4832,15 +4832,22 @@ export class TUI extends Container { fixedSuffixRasterLease.nativeScrollbackArmed && fixedSuffixRasterLease.token.rect.row > 0)); const soleRasterLease = this.#rasterLeases.size === 1 ? this.#rasterLeases.values().next().value : undefined; - const fixedPlaneUpperBottom = + const fixedPlaneRequestedUpperBottom = fixedSuffixRasterLease === undefined ? 0 : Math.min(height - nextSuffixLineCount, fixedSuffixRasterLease.token.rect.row); - const fixedPlanePhysicalTop = - this.#fixedSuffixScrollPlane?.token === fixedSuffixScrollRegionToken && - this.#fixedSuffixScrollPlane?.upperBottom === fixedPlaneUpperBottom - ? this.#fixedSuffixScrollPlane.transcriptTop - : prevViewportTop; + const previousFixedPlane = + this.#fixedSuffixScrollPlane?.token === fixedSuffixScrollRegionToken + ? this.#fixedSuffixScrollPlane + : undefined; + // A live plane may shrink as the composer grows, but never expands until + // it is re-armed. Expanding would require replaying rows already admitted + // to host history. + const fixedPlaneUpperBottom = + previousFixedPlane === undefined + ? fixedPlaneRequestedUpperBottom + : Math.min(previousFixedPlane.upperBottom, fixedPlaneRequestedUpperBottom); + const fixedPlanePhysicalTop = previousFixedPlane?.transcriptTop ?? prevViewportTop; const fixedPlaneEligible = fixedSuffixScrollRegionToken !== undefined && this.#fixedSuffixScrollRegionOwners.get(fixedSuffixScrollRegionToken.ownerId) === @@ -4870,14 +4877,14 @@ export class TUI extends Container { this.#scrollbackResumeViewportTop === undefined && !this.#nativeScrollbackAdmissionPending && newLines.slice(0, fixedPlanePhysicalTop).every((line, index) => line === previousLogicalFrame[index]) && - previousSuffixLineCount === nextSuffixLineCount && + nextTranscriptLineCount >= previousTranscriptLineCount && nextSuffixLineCount < height; if (!fixedPlaneEligible && this.#fixedSuffixScrollPlane !== undefined) { this.#fixedSuffixScrollPlane = undefined; this.#fixedSuffixScrollRegionResetPending = true; } if (fixedPlaneEligible) { - const plane = this.#fixedSuffixScrollPlane; + const plane = previousFixedPlane; const physicalTop = fixedPlanePhysicalTop; const desiredTop = Math.max(0, nextTranscriptLineCount - fixedPlaneUpperBottom); if (physicalTop > desiredTop) { @@ -4887,26 +4894,30 @@ export class TUI extends Container { return; } - let fixedPlaneBuffer = "\x1b[?2026h\x1b7\x1b[?6l"; - if (plane?.token !== fixedSuffixScrollRegionToken || plane.upperBottom !== fixedPlaneUpperBottom) - fixedPlaneBuffer += `\x1b[1;${fixedPlaneUpperBottom}r`; + const previousUpperBottom = plane?.upperBottom ?? fixedPlaneUpperBottom; + let fixedPlaneBuffer = `\x1b[?2026h\x1b7\x1b[?6l\x1b[1;${previousUpperBottom}r`; for (let scrollIndex = 0; scrollIndex < desiredTop - physicalTop; scrollIndex += 1) { const displacedLineIndex = physicalTop + scrollIndex; if (newLines[displacedLineIndex] !== previousLogicalFrame[displacedLineIndex]) { fixedPlaneBuffer += `\x1b[1;1H\x1b[2K${this.#padLineToWidth(newLines[displacedLineIndex] ?? "", width)}`; } - const lineIndex = physicalTop + fixedPlaneUpperBottom + scrollIndex; - fixedPlaneBuffer += `\x1b[${fixedPlaneUpperBottom};1H\x1bD\r\x1b[2K${this.#padLineToWidth( + const lineIndex = physicalTop + previousUpperBottom + scrollIndex; + fixedPlaneBuffer += `\x1b[${previousUpperBottom};1H\x1bD\r\x1b[2K${this.#padLineToWidth( newLines[lineIndex] ?? "", width, )}`; } + if (previousUpperBottom !== fixedPlaneUpperBottom) fixedPlaneBuffer += `\x1b[1;${fixedPlaneUpperBottom}r`; for (let row = 0; row < fixedPlaneUpperBottom; row += 1) { fixedPlaneBuffer += `\x1b[${row + 1};1H\x1b[2K${this.#padLineToWidth( newLines[desiredTop + row] ?? "", width, )}`; } + // Keep DECSTBM scoped to transcript admission and repaint. Post-render + // emitters (including iTerm multipart GIF records) and lower suffix + // writes must always observe the normal full-screen margin. + fixedPlaneBuffer += "\x1b[r\x1b[?6l"; const suffixRegionBottom = height - nextSuffixLineCount; for (let suffixIndex = 0; suffixIndex < nextSuffixLineCount; suffixIndex += 1) { const suffixRow = suffixRegionBottom + suffixIndex + 1; @@ -4917,7 +4928,7 @@ export class TUI extends Container { } } const { seq, toRow } = this.#cursorControlSequence(cursorPos, newLines.length, this.#hardwareCursorRow); - fixedPlaneBuffer += `\x1b8\x1b[?6l${seq}\x1b[?2026l`; + fixedPlaneBuffer += `\x1b8\x1b[r\x1b[?6l${seq}\x1b[?2026l`; if ( !this.#writeRenderBufferAndReanchorImeCursor( fixedPlaneBuffer, diff --git a/packages/tui/test/fixed-suffix-scroll-region.test.ts b/packages/tui/test/fixed-suffix-scroll-region.test.ts index 625cb0dd2d..f8e4bbddb2 100644 --- a/packages/tui/test/fixed-suffix-scroll-region.test.ts +++ b/packages/tui/test/fixed-suffix-scroll-region.test.ts @@ -19,7 +19,7 @@ class LinesComponent implements Component { function createPinnedTui( rows = 5, transcriptLines = ["line-1", `line-2${CURSOR_MARKER}`, "line-3"], -): { term: VirtualTerminal; transcript: LinesComponent; tui: TUI } { +): { term: VirtualTerminal; transcript: LinesComponent; suffix: LinesComponent; tui: TUI } { const term = new VirtualTerminal(40, rows); const tui = new TUI(term); const transcript = new LinesComponent(transcriptLines); @@ -27,7 +27,7 @@ function createPinnedTui( tui.addChild(transcript); tui.addChild(suffix); tui.setBottomPinnedComponent(suffix); - return { term, transcript, tui }; + return { term, transcript, suffix, tui }; } describe("TUI fixed suffix scroll region", () => { @@ -81,7 +81,7 @@ describe("TUI fixed suffix scroll region", () => { }); it("preserves a bound raster lease while advancing native scrollback", async () => { - const { term, transcript, tui } = createPinnedTui(); + const { term, transcript, suffix, tui } = createPinnedTui(); let invalidated = 0; try { tui.start(); @@ -125,7 +125,7 @@ describe("TUI fixed suffix scroll region", () => { tui.requestRender(); await term.waitForRender(); const streamingOutput = term.getWriteLog().join(""); - expect(streamingOutput).not.toContain("\x1b[1;2r"); + expect(streamingOutput).toContain("\x1b[1;2r"); expect(streamingOutput).toContain("\x1b[2;1H\x1bD\r\x1b[2Kline-5"); expect(streamingOutput).not.toContain("ITERM_ERASE"); expect(invalidated).toBe(0); @@ -147,7 +147,7 @@ describe("TUI fixed suffix scroll region", () => { tui.requestRender(); await term.waitForRender(); const tailRewriteOutput = term.getWriteLog().join(""); - expect(tailRewriteOutput).not.toContain("\x1b[1;2r"); + expect(tailRewriteOutput).toContain("\x1b[1;2r"); expect(tailRewriteOutput).toContain("\x1b[1;1H\x1b[2Kline-5 revised"); expect(tailRewriteOutput).toContain("\x1b[2;1H\x1bD\r\x1b[2Kline-6"); expect(tailRewriteOutput).not.toContain("ITERM_ERASE"); @@ -157,7 +157,7 @@ describe("TUI fixed suffix scroll region", () => { tui.requestRender(); await term.waitForRender(); const postRewriteOutput = term.getWriteLog().join(""); - expect(postRewriteOutput).not.toContain("\x1b[1;2r"); + expect(postRewriteOutput).toContain("\x1b[1;2r"); expect(postRewriteOutput).toContain("\x1b[2;1H\x1bD\r\x1b[2Kline-7"); expect(postRewriteOutput).not.toContain("ITERM_ERASE"); expect(invalidated).toBe(0); @@ -177,7 +177,7 @@ describe("TUI fixed suffix scroll region", () => { tui.requestRender(); await term.waitForRender(); const batchedOutput = term.getWriteLog().join(""); - expect(batchedOutput).not.toContain("\x1b[1;2r"); + expect(batchedOutput).toContain("\x1b[1;2r"); expect(batchedOutput).toContain("\x1b[2;1H\x1bD\r\x1b[2Kline-8"); expect(batchedOutput).toContain("\x1b[2;1H\x1bD\r\x1b[2Kline-9"); expect(batchedOutput).toContain("\x1b[2;1H\x1bD\r\x1b[2Kline-10"); @@ -231,11 +231,64 @@ describe("TUI fixed suffix scroll region", () => { expect(reflowOutput).toContain("\x1b[r\x1b[?6l"); expect(reflowOutput).not.toContain("\x1b[1;2r"); expect(reflowOutput).not.toContain("ITERM_ERASE"); + suffix.setLines(["status", "progress", "composer"]); + transcript.setLines([ + "line-1", + "line-2", + "line-3", + "line-4", + "line-5 revised", + "line-6", + "line-7", + "line-8", + "line-9 revised", + "line-10", + "line-11 revised", + "line-12", + ]); + term.clearWriteLog(); + tui.requestRender(); + await term.waitForRender(); + const suffixGrowthOutput = term.getWriteLog().join(""); + expect(suffixGrowthOutput).toContain("\x1b[1;2r"); + expect(suffixGrowthOutput).toContain("\x1bD\r\x1b[2Kline-12"); + expect(suffixGrowthOutput).not.toContain("ITERM_ERASE"); + expect(invalidated).toBe(0); + await term.flush(); + const suffixGrowthScrollback = term.getScrollBuffer().map(line => line.trimEnd()); + expect(suffixGrowthScrollback.filter(line => line === "line-10")).toHaveLength(1); + transcript.setLines([ + "line-1", + "line-2", + "line-3", + "line-4", + "line-5 revised", + "line-6", + "line-7", + "line-8", + "line-9 revised", + "line-10", + "line-11 revised", + "line-12", + "line-13", + ]); + term.clearWriteLog(); + tui.requestRender(); + await term.waitForRender(); + const postSuffixGrowthOutput = term.getWriteLog().join(""); + expect(postSuffixGrowthOutput).toContain("\x1b[1;2r"); + expect(postSuffixGrowthOutput).toContain("\x1bD\r\x1b[2Kline-13"); + expect(postSuffixGrowthOutput).not.toContain("ITERM_ERASE"); + expect(invalidated).toBe(0); + await term.flush(); + const postSuffixGrowthScrollback = term.getScrollBuffer().map(line => line.trimEnd()); + expect(postSuffixGrowthScrollback.filter(line => line === "line-11 revised")).toHaveLength(1); + expect(postSuffixGrowthScrollback.filter(line => line === "line-12")).toHaveLength(1); tui.releaseFixedSuffixScrollRegion(token); term.clearWriteLog(); tui.requestRender(); await term.waitForRender(); - expect(term.getWriteLog().join("")).not.toContain("\x1b[r\x1b[?6l"); + expect(term.getWriteLog().join("")).toContain("\x1b[r\x1b[?6l"); } finally { tui.stop(); } From f9df35ce0e7437825a5a524d9521388090c52cb0 Mon Sep 17 00:00:00 2001 From: snowykr Date: Sun, 2 Aug 2026 18:39:27 +0900 Subject: [PATCH 32/46] test(tui): cover shrinking fixed scroll planes A growing composer reduces the usable transcript plane.\nExercise the old margin for ordered admission before repainting within the smaller region.\n\nLore-id: 8a4ce190\nConstraint: raster lease must stay resident during composer growth\nTested: focused fixed-suffix, raster lease, and render commit suite\nTested: TUI package check --- .../test/fixed-suffix-scroll-region.test.ts | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/packages/tui/test/fixed-suffix-scroll-region.test.ts b/packages/tui/test/fixed-suffix-scroll-region.test.ts index f8e4bbddb2..54cea6094b 100644 --- a/packages/tui/test/fixed-suffix-scroll-region.test.ts +++ b/packages/tui/test/fixed-suffix-scroll-region.test.ts @@ -284,6 +284,36 @@ describe("TUI fixed suffix scroll region", () => { const postSuffixGrowthScrollback = term.getScrollBuffer().map(line => line.trimEnd()); expect(postSuffixGrowthScrollback.filter(line => line === "line-11 revised")).toHaveLength(1); expect(postSuffixGrowthScrollback.filter(line => line === "line-12")).toHaveLength(1); + suffix.setLines(["status", "progress", "hint", "composer"]); + transcript.setLines([ + "line-1", + "line-2", + "line-3", + "line-4", + "line-5 revised", + "line-6", + "line-7", + "line-8", + "line-9 revised", + "line-10", + "line-11 revised", + "line-12", + "line-13", + "line-14", + ]); + term.clearWriteLog(); + tui.requestRender(); + await term.waitForRender(); + const shrinkingPlaneOutput = term.getWriteLog().join(""); + expect(shrinkingPlaneOutput).toContain("\x1b[1;2r"); + expect(shrinkingPlaneOutput).toContain("\x1b[1;1r"); + expect(shrinkingPlaneOutput).toContain("\x1bD\r\x1b[2Kline-14"); + expect(shrinkingPlaneOutput).not.toContain("ITERM_ERASE"); + expect(invalidated).toBe(0); + await term.flush(); + const shrinkingPlaneScrollback = term.getScrollBuffer().map(line => line.trimEnd()); + expect(shrinkingPlaneScrollback.filter(line => line === "line-12")).toHaveLength(1); + expect(shrinkingPlaneScrollback.filter(line => line === "line-13")).toHaveLength(1); tui.releaseFixedSuffixScrollRegion(token); term.clearWriteLog(); tui.requestRender(); From f8f6fa4d46f5678f25d22da65fdcfda93d3da98d Mon Sep 17 00:00:00 2001 From: snowykr Date: Sun, 2 Aug 2026 18:42:27 +0900 Subject: [PATCH 33/46] test(tui): cover fixed-plane multipart ordering Prove real TUI post-render and queued multipart bytes follow a full DECSTBM reset.\n\nLore-id: 7c2b481f\nConstraint: iTerm multipart bytes must not inherit a scroll margin\nTested: focused fixed-suffix, raster lease, and render commit suite\nTested: TUI package check --- .../test/fixed-suffix-scroll-region.test.ts | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/packages/tui/test/fixed-suffix-scroll-region.test.ts b/packages/tui/test/fixed-suffix-scroll-region.test.ts index 54cea6094b..fc3cc070e5 100644 --- a/packages/tui/test/fixed-suffix-scroll-region.test.ts +++ b/packages/tui/test/fixed-suffix-scroll-region.test.ts @@ -79,6 +79,52 @@ describe("TUI fixed suffix scroll region", () => { tui.stop(); } }); + it("resets the fixed plane before iTerm post-render and queued multipart bytes", async () => { + const { term, transcript, tui } = createPinnedTui(); + try { + tui.start(); + await term.waitForRender(); + const lease = await tui.acquireRasterLease({ + ownerId: "iterm-owner", + rect: { column: 36, row: 2, width: 3, height: 3 }, + erase: { type: "raster-erase", bytes: new TextEncoder().encode("ITERM_ERASE") }, + nativeScrollbackEligible: true, + }); + expect(lease.status).toBe("acquired"); + if (lease.status !== "acquired") throw new Error("Expected iTerm lease"); + const token = tui.acquireFixedSuffixScrollRegion("iterm-owner"); + expect(token).toBeDefined(); + if (token === undefined) throw new Error("Expected fixed suffix token"); + + tui.setPostRenderEmitter(() => "POST_RENDER_GIF"); + transcript.setLines(["line-1", "line-2", "line-3", "line-4"]); + term.clearWriteLog(); + expect(tui.armFixedSuffixScrollRegion(token, lease.token)).toBeGreaterThan(0); + await term.waitForRender(); + + const rendered = term.getWriteLog().join(""); + const restore = "\x1b8\x1b[r\x1b[?6l"; + expect(rendered.indexOf(restore)).toBeGreaterThanOrEqual(0); + expect(rendered.indexOf(restore)).toBeLessThan(rendered.indexOf("POST_RENDER_GIF")); + expect(rendered).not.toContain("ITERM_ERASE"); + + tui.releaseFixedSuffixScrollRegion(token); + term.clearWriteLog(); + const multipart = await tui.submitTerminalOutput({ + token: lease.token, + operation: { + type: "raster-multipart-batch", + prefix: new TextEncoder().encode("MULTIPART_PREFIX"), + records: [new TextEncoder().encode("MULTIPART_GIF")], + suffix: new TextEncoder().encode("MULTIPART_SUFFIX"), + }, + }); + expect(multipart.status).toBe("written"); + expect(term.getWriteLog().join("")).toBe("\x1b[r\x1b[?6lMULTIPART_PREFIXMULTIPART_GIFMULTIPART_SUFFIX"); + } finally { + tui.stop(); + } + }); it("preserves a bound raster lease while advancing native scrollback", async () => { const { term, transcript, suffix, tui } = createPinnedTui(); From d68cd43eb826551e736c823010e246c597a47c90 Mon Sep 17 00:00:00 2001 From: snowykr Date: Sun, 2 Aug 2026 19:17:53 +0900 Subject: [PATCH 34/46] fix(iterm): retain lease across stream reflow Historical Markdown rewrites above the live DECSTBM plane are already immutable host history.\nContinue the fixed-plane transaction and repair each departing row instead of erasing the iTerm raster through generic rendering.\n\nLore-id: 0d97be4a\nConstraint: streaming reflow must not erase the resident iTerm Pet\nConstraint: preserve ordered native admission for displaced live rows\nConfidence: high\nScope-risk: focused\nReversibility: easy\nTested: focused fixed-suffix, raster lease, and render commit suite\nTested: TUI package check --- packages/tui/src/tui.ts | 6 ++- .../test/fixed-suffix-scroll-region.test.ts | 42 +++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index b7549d4d41..35acb5d725 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -4876,7 +4876,11 @@ export class TUI extends Container { !newLines.some(line => TERMINAL.isImageLine(line)) && this.#scrollbackResumeViewportTop === undefined && !this.#nativeScrollbackAdmissionPending && - newLines.slice(0, fixedPlanePhysicalTop).every((line, index) => line === previousLogicalFrame[index]) && + // Rows above the physical plane already belong to host scrollback. A + // historical Markdown rewrite cannot update that immutable history, but + // it must not force a generic raster erase. The departing plane row is + // repaired immediately before its IND below, so native admission stays + // correct for every newly displaced live row. nextTranscriptLineCount >= previousTranscriptLineCount && nextSuffixLineCount < height; if (!fixedPlaneEligible && this.#fixedSuffixScrollPlane !== undefined) { diff --git a/packages/tui/test/fixed-suffix-scroll-region.test.ts b/packages/tui/test/fixed-suffix-scroll-region.test.ts index fc3cc070e5..9fdee60ed1 100644 --- a/packages/tui/test/fixed-suffix-scroll-region.test.ts +++ b/packages/tui/test/fixed-suffix-scroll-region.test.ts @@ -126,6 +126,48 @@ describe("TUI fixed suffix scroll region", () => { } }); + it("keeps an armed iTerm lease across an append with an immutable historical rewrite", async () => { + const { term, transcript, tui } = createPinnedTui(); + let invalidated = 0; + try { + tui.start(); + await term.waitForRender(); + const lease = await tui.acquireRasterLease({ + ownerId: "iterm-owner", + rect: { column: 36, row: 2, width: 3, height: 3 }, + erase: { type: "raster-erase", bytes: new TextEncoder().encode("ITERM_ERASE") }, + onInvalidated: () => invalidated++, + nativeScrollbackEligible: true, + }); + expect(lease.status).toBe("acquired"); + if (lease.status !== "acquired") throw new Error("Expected iTerm lease"); + const token = tui.acquireFixedSuffixScrollRegion("iterm-owner"); + expect(token).toBeDefined(); + if (token === undefined) throw new Error("Expected fixed suffix token"); + + transcript.setLines(["line-1", "line-2", "line-3", "line-4"]); + expect(tui.armFixedSuffixScrollRegion(token, lease.token)).toBeGreaterThan(0); + await term.waitForRender(); + transcript.setLines(["line-1", "line-2", "line-3", "line-4", "line-5", "line-6"]); + await term.waitForRender(); + + transcript.setLines(["line-1 revised", "line-2", "line-3", "line-4", "line-5", "line-6", "line-7"]); + term.clearWriteLog(); + tui.requestRender(); + await term.waitForRender(); + + const output = term.getWriteLog().join(""); + expect(output).toContain("\x1b[1;2r"); + expect(output).toContain("\x1bD\r\x1b[2Kline-7"); + expect(output).not.toContain("ITERM_ERASE"); + expect(invalidated).toBe(0); + await term.flush(); + const scrollback = term.getScrollBuffer().map(line => line.trimEnd()); + expect(scrollback.filter(line => line === "line-6")).toHaveLength(1); + } finally { + tui.stop(); + } + }); it("preserves a bound raster lease while advancing native scrollback", async () => { const { term, transcript, suffix, tui } = createPinnedTui(); let invalidated = 0; From e6d0454db6a1ae965f8444a9bc6dc1fe92c09f74 Mon Sep 17 00:00:00 2001 From: snowykr Date: Sun, 2 Aug 2026 19:40:21 +0900 Subject: [PATCH 35/46] fix(iterm): keep resident Pet through renderer fallbacks An armed DECSTBM lease previously yielded to generic or forced rendering whenever a transient frame could not form a fixed-plane transaction. Protected ingress erased the GIF before drawing that frame.\n\nKeep current armed iTerm leases on clipped rendering, stage later native admission, clear stale arm state, and re-arm reset owners without uploading another GIF. Lore-id: b7c3e91a\nConstraint: preserve native iTerm scrollback and resident Pet animation\nConstraint: keep Kitty/Sixel and generic raster behavior unchanged\nConfidence: high\nScope-risk: focused\nReversibility: straightforward\nTested: focused fixed-suffix, raster-lease, render-commit, and iTerm widget suites\nTested: packages/tui and packages/coding-agent checks --- .../src/modes/components/gajae-pet-widget.ts | 4 + .../test/gajae-pet-widget.test.ts | 35 ++++ packages/tui/src/tui.ts | 78 ++++++-- .../test/fixed-suffix-scroll-region.test.ts | 172 ++++++++++++++++++ 4 files changed, 274 insertions(+), 15 deletions(-) diff --git a/packages/coding-agent/src/modes/components/gajae-pet-widget.ts b/packages/coding-agent/src/modes/components/gajae-pet-widget.ts index 66bccf76c7..75d62ac9ac 100644 --- a/packages/coding-agent/src/modes/components/gajae-pet-widget.ts +++ b/packages/coding-agent/src/modes/components/gajae-pet-widget.ts @@ -448,6 +448,10 @@ export class GajaePetWidget { if (token) this.#ui.releaseFixedSuffixScrollRegion(token); } #armFixedSuffixScrollRegion(lease: RasterLeaseToken): void { + const existing = this.#fixedSuffixScrollRegionToken; + if (existing && !this.#ui.isFixedSuffixScrollRegionCurrent(existing)) { + this.#fixedSuffixScrollRegionToken = undefined; + } if (this.#fixedSuffixScrollRegionToken || this.#itermLease !== lease) return; const token = this.#ui.acquireFixedSuffixScrollRegion(this.#itermOwner); if (!token) return; diff --git a/packages/coding-agent/test/gajae-pet-widget.test.ts b/packages/coding-agent/test/gajae-pet-widget.test.ts index 7e9dc80fdb..be2fb8515f 100644 --- a/packages/coding-agent/test/gajae-pet-widget.test.ts +++ b/packages/coding-agent/test/gajae-pet-widget.test.ts @@ -99,6 +99,8 @@ function makeStubs(columns = 80, rows = 30) { if (fixedSuffixScrollRegionOwners.get(token.ownerId) === token) fixedSuffixScrollRegionOwners.delete(token.ownerId); }, + isFixedSuffixScrollRegionCurrent: (token: { ownerId: string; generation: number }) => + fixedSuffixScrollRegionOwners.get(token.ownerId) === token, armFixedSuffixScrollRegion: (token: { ownerId: string; generation: number }) => { if (fixedSuffixScrollRegionOwners.get(token.ownerId) !== token) return undefined; return ++renderRequests; @@ -186,6 +188,7 @@ function makeStubs(columns = 80, rows = 30) { getRasterLeaseRequests: () => rasterLeaseRequests, getRasterCursorVisibilityRestores: () => rasterCursorVisibilityRestores, getFixedSuffixScrollRegionOwnerCount: () => fixedSuffixScrollRegionOwners.size, + resetFixedSuffixScrollRegions: () => fixedSuffixScrollRegionOwners.clear(), getPendingRasterAcquireCount: () => rasterAcquireWaiters.length, setRasterAcquireDelayed: (value: boolean) => { delayRasterAcquire = value; @@ -1423,6 +1426,38 @@ describe("GajaePetWidget", () => { stubs.widget.dispose(); } }); + it("rearms a reset fixed suffix owner without re-uploading the iTerm GIF", async () => { + vi.useFakeTimers(); + const stubs = makeWidget(80, 30, { protocol: null }); + try { + setVerifiedItermPetAvailability({ available: true, mode: "direct", epoch: 1 }); + stubs.widget.setMode("red"); + vi.advanceTimersByTime(80); + await flushAsyncChain(); + expect(stubs.getFixedSuffixScrollRegionOwnerCount()).toBe(1); + expect( + stubs + .getRasterOutputs() + .map(record => new TextDecoder().decode(record)) + .filter(record => record.includes("MultipartFile=")), + ).toHaveLength(1); + + stubs.resetFixedSuffixScrollRegions(); + vi.advanceTimersByTime(80); + await flushAsyncChain(); + + expect(stubs.getFixedSuffixScrollRegionOwnerCount()).toBe(1); + expect( + stubs + .getRasterOutputs() + .map(record => new TextDecoder().decode(record)) + .filter(record => record.includes("MultipartFile=")), + ).toHaveLength(1); + } finally { + setVerifiedItermPetAvailability(undefined); + stubs.widget.dispose(); + } + }); it("keeps the initial iTerm GIF during scheduled auto-flex bursts", async () => { vi.useFakeTimers(); const stubs = makeWidget(80, 30, { diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 35acb5d725..d965c5a9f0 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -1295,11 +1295,20 @@ export class TUI extends Container { this.#fixedSuffixScrollRegionOwners.set(ownerId, token); return token; } + /** Whether a fixed-suffix owner token still belongs to the current TUI generation. */ + isFixedSuffixScrollRegionCurrent(token: FixedSuffixScrollRegionToken): boolean { + return this.#fixedSuffixScrollRegionOwners.get(token.ownerId) === token; + } releaseFixedSuffixScrollRegion(token: FixedSuffixScrollRegionToken): void { if (this.#fixedSuffixScrollRegionOwners.get(token.ownerId) !== token) return; this.#fixedSuffixScrollRegionOwners.delete(token.ownerId); if (this.#armedFixedSuffixScrollRegionToken === token) { + const rasterLease = this.#armedFixedSuffixScrollRegionRasterLease; + if (rasterLease !== undefined) { + const lease = this.#rasterLeases.get(rasterLease.ownerId); + if (lease?.token === rasterLease) lease.nativeScrollbackArmed = false; + } this.#armedFixedSuffixScrollRegionToken = undefined; this.#armedFixedSuffixScrollRegionRasterLease = undefined; } @@ -1327,7 +1336,7 @@ export class TUI extends Container { return undefined; const lease = rasterLease === undefined ? undefined : this.#rasterLeases.get(token.ownerId); if (rasterLease !== undefined && (!lease || lease.token !== rasterLease)) return undefined; - if (lease?.nativeScrollbackEligible) lease.nativeScrollbackArmed = true; + if (lease?.nativeScrollbackEligible && lease.token.rect.row > 0) lease.nativeScrollbackArmed = true; this.#armedFixedSuffixScrollRegionToken = token; this.#armedFixedSuffixScrollRegionRasterLease = rasterLease; return this.requestRenderWithGeneration(false, "fixed-suffix-scroll-region"); @@ -1335,6 +1344,11 @@ export class TUI extends Container { #resetFixedSuffixScrollRegions(): void { if (this.#fixedSuffixScrollPlane !== undefined) this.#fixedSuffixScrollRegionResetPending = true; + const rasterLease = this.#armedFixedSuffixScrollRegionRasterLease; + if (rasterLease !== undefined) { + const lease = this.#rasterLeases.get(rasterLease.ownerId); + if (lease?.token === rasterLease) lease.nativeScrollbackArmed = false; + } this.#fixedSuffixScrollPlane = undefined; this.#armedFixedSuffixScrollRegionToken = undefined; this.#armedFixedSuffixScrollRegionRasterLease = undefined; @@ -4349,6 +4363,27 @@ export class TUI extends Container { allowPastLiveBottom?: boolean, ) => boolean; const fullRender = (clear: boolean, reason = "full render", forceScrollbackClear = false): void => { + const fixedSuffixLease = + fixedSuffixScrollRegionToken === undefined + ? undefined + : this.#rasterLeases.get(fixedSuffixScrollRegionToken.ownerId); + const preserveArmedFixedSuffixLease = + fixedSuffixScrollRegionToken !== undefined && + this.#fixedSuffixScrollRegionOwners.get(fixedSuffixScrollRegionToken.ownerId) === + fixedSuffixScrollRegionToken && + fixedSuffixScrollRegionRasterLease !== undefined && + fixedSuffixLease?.token === fixedSuffixScrollRegionRasterLease && + fixedSuffixLease.nativeScrollbackEligible && + fixedSuffixLease.nativeScrollbackArmed && + fixedSuffixLease.token.rect.row > 0 && + this.#rasterLeases.size === 1 && + this.#rasterCleanup.size === 0; + if (preserveArmedFixedSuffixLease) { + this.#fixedSuffixScrollPlane = undefined; + this.#fixedSuffixScrollRegionResetPending = true; + viewportRepaint(`armed fixed suffix lease blocked full render: ${reason}`); + return; + } if ( clear && !forceScrollbackClear && @@ -4829,9 +4864,9 @@ export class TUI extends Container { fixedSuffixScrollRegionToken !== undefined && fixedSuffixScrollRegionRasterLease !== undefined && fixedSuffixRasterLease?.token === fixedSuffixScrollRegionRasterLease && + fixedSuffixRasterLease.nativeScrollbackEligible && fixedSuffixRasterLease.nativeScrollbackArmed && fixedSuffixRasterLease.token.rect.row > 0)); - const soleRasterLease = this.#rasterLeases.size === 1 ? this.#rasterLeases.values().next().value : undefined; const fixedPlaneRequestedUpperBottom = fixedSuffixRasterLease === undefined ? 0 @@ -4854,6 +4889,7 @@ export class TUI extends Container { fixedSuffixScrollRegionToken && fixedSuffixScrollRegionRasterLease !== undefined && fixedSuffixRasterLease?.token === fixedSuffixScrollRegionRasterLease && + fixedSuffixRasterLease.nativeScrollbackEligible && fixedSuffixRasterLease.nativeScrollbackArmed && this.#rasterLeases.size === 1 && this.#rasterCleanup.size === 0 && @@ -4875,7 +4911,6 @@ export class TUI extends Container { nextKittyPlacementSpans.length === 0 && !newLines.some(line => TERMINAL.isImageLine(line)) && this.#scrollbackResumeViewportTop === undefined && - !this.#nativeScrollbackAdmissionPending && // Rows above the physical plane already belong to host scrollback. A // historical Markdown rewrite cannot update that immutable history, but // it must not force a generic raster erase. The departing plane row is @@ -4968,19 +5003,34 @@ export class TUI extends Container { this.#transcriptIdentityReplaced = false; return; } - // Only an armed iTerm lease yields for native admission. Before the GIF - // upload commits and arms the lease, preserve its raster rather than - // repeatedly erasing and re-uploading it under streaming output. - const rasterMustYieldForNativeAdmission = - appendedLines && - nextLiveViewportTop > prevViewportTop && + const activeArmedFixedSuffixLease = + fixedSuffixScrollRegionToken !== undefined && + this.#fixedSuffixScrollRegionOwners.get(fixedSuffixScrollRegionToken.ownerId) === + fixedSuffixScrollRegionToken && + fixedSuffixScrollRegionRasterLease !== undefined && + fixedSuffixRasterLease?.token === fixedSuffixScrollRegionRasterLease && + fixedSuffixRasterLease.nativeScrollbackEligible && + fixedSuffixRasterLease.nativeScrollbackArmed && + fixedSuffixRasterLease.token.rect.row > 0 && + this.#rasterLeases.size === 1 && + this.#rasterCleanup.size === 0; + if ( + activeArmedFixedSuffixLease && !fixedSuffixNativeAppendPreservesRasterLease && - this.#rasterLeases.size > 0 && - this.#rasterCleanup.size === 0 && - (fixedSuffixRasterLease?.nativeScrollbackArmed === true || soleRasterLease?.nativeScrollbackArmed === true); + !fixedPlaneEligible && + !newLines.slice(Math.max(0, newLines.length - height)).some(line => TERMINAL.isImageLine(line)) + ) { + if (appendedLines && nextLiveViewportTop > prevViewportTop) { + // The clipped repaint keeps the resident iTerm raster intact while this + // transient frame cannot safely form a DECSTBM transaction. The next + // eligible append uses its painted live viewport as the physical frontier. + this.#nativeScrollbackAdmissionPending = true; + } + viewportRepaint("armed fixed suffix lease awaiting a safe native append"); + return; + } if ( !fixedSuffixNativeAppendPreservesRasterLease && - !rasterMustYieldForNativeAdmission && this.#rasterLeases.size > 0 && this.#rasterCleanup.size === 0 && !newLines.slice(Math.max(0, newLines.length - height)).some(line => TERMINAL.isImageLine(line)) @@ -5290,7 +5340,6 @@ export class TUI extends Container { const appendWillScroll = appendStart && moveTargetRow >= prevViewportBottom; if ( !fixedSuffixNativeAppendPreservesRasterLease && - !rasterMustYieldForNativeAdmission && (moveTargetRow > prevViewportBottom || appendWillScroll || renderEnd > prevViewportBottom) && this.#rasterLeases.size > 0 && this.#rasterCleanup.size === 0 && @@ -5337,7 +5386,6 @@ export class TUI extends Container { // Only render changed lines (firstChanged to lastChanged), not all lines to end. // This reduces flicker when only a single line changes (e.g., spinner animation). const preserveRasterLeases = - !rasterMustYieldForNativeAdmission && this.#rasterLeases.size > 0 && this.#rasterCleanup.size === 0 && moveTargetRow <= prevViewportBottom && diff --git a/packages/tui/test/fixed-suffix-scroll-region.test.ts b/packages/tui/test/fixed-suffix-scroll-region.test.ts index 9fdee60ed1..2287039f11 100644 --- a/packages/tui/test/fixed-suffix-scroll-region.test.ts +++ b/packages/tui/test/fixed-suffix-scroll-region.test.ts @@ -168,6 +168,77 @@ describe("TUI fixed suffix scroll region", () => { tui.stop(); } }); + it("keeps an armed iTerm lease through a multipart barrier reflow", async () => { + const { term, transcript, tui } = createPinnedTui(); + let invalidated = 0; + try { + tui.start(); + await term.waitForRender(); + const lease = await tui.acquireRasterLease({ + ownerId: "iterm-owner", + rect: { column: 36, row: 2, width: 3, height: 3 }, + erase: { type: "raster-erase", bytes: new TextEncoder().encode("ITERM_ERASE") }, + onInvalidated: () => invalidated++, + nativeScrollbackEligible: true, + }); + expect(lease.status).toBe("acquired"); + if (lease.status !== "acquired") throw new Error("Expected iTerm lease"); + const token = tui.acquireFixedSuffixScrollRegion("iterm-owner"); + expect(token).toBeDefined(); + if (token === undefined) throw new Error("Expected fixed suffix token"); + + transcript.setLines(["line-1", "line-2", "line-3", "line-4"]); + expect(tui.armFixedSuffixScrollRegion(token, lease.token)).toBeGreaterThan(0); + await term.waitForRender(); + + const prefixEntered = Promise.withResolvers(); + const releaseBarrier = Promise.withResolvers(); + term.clearWriteLog(); + const multipart = tui.submitTerminalOutput({ + token: lease.token, + operation: { + type: "raster-multipart-batch", + prefix: new TextEncoder().encode("MULTIPART_PREFIX"), + afterPrefix: async () => { + prefixEntered.resolve(); + return releaseBarrier.promise; + }, + records: [new TextEncoder().encode("MULTIPART_GIF")], + suffix: new TextEncoder().encode("MULTIPART_SUFFIX"), + }, + }); + await prefixEntered.promise; + + transcript.setLines(["line-1 revised", "line-2", "line-3", "line-4", "line-5"]); + tui.requestRender(); + await Bun.sleep(20); + expect(term.getWriteLog().join("")).toBe("MULTIPART_PREFIX"); + releaseBarrier.resolve(true); + expect((await multipart).status).toBe("written"); + await term.waitForRender(); + + const fallbackOutput = term.getWriteLog().join(""); + expect(fallbackOutput).toContain("MULTIPART_PREFIXMULTIPART_GIFMULTIPART_SUFFIX"); + expect(fallbackOutput).not.toContain("ITERM_ERASE"); + expect(invalidated).toBe(0); + + transcript.setLines(["line-1 revised", "line-2", "line-3", "line-4", "line-5", "line-6"]); + term.clearWriteLog(); + tui.requestRender(); + await term.waitForRender(); + + const resumedOutput = term.getWriteLog().join(""); + expect(resumedOutput).toContain("\x1b[1;2r"); + expect(resumedOutput).toContain("\x1bD\r\x1b[2Kline-6"); + expect(resumedOutput).not.toContain("ITERM_ERASE"); + await term.flush(); + const scrollback = term.getScrollBuffer().map(line => line.trimEnd()); + expect(scrollback.filter(line => line === "line-3")).toHaveLength(1); + expect(invalidated).toBe(0); + } finally { + tui.stop(); + } + }); it("preserves a bound raster lease while advancing native scrollback", async () => { const { term, transcript, suffix, tui } = createPinnedTui(); let invalidated = 0; @@ -442,6 +513,71 @@ describe("TUI fixed suffix scroll region", () => { tui.stop(); } }); + it("keeps a row-zero native lease on the clipped renderer path", async () => { + const { term, transcript, tui } = createPinnedTui(); + let invalidated = 0; + try { + tui.start(); + await term.waitForRender(); + const lease = await tui.acquireRasterLease({ + ownerId: "iterm-owner", + rect: { column: 36, row: 0, width: 3, height: 3 }, + erase: { type: "raster-erase", bytes: new TextEncoder().encode("ITERM_ERASE") }, + onInvalidated: () => invalidated++, + nativeScrollbackEligible: true, + }); + expect(lease.status).toBe("acquired"); + if (lease.status !== "acquired") throw new Error("Expected iTerm lease"); + const token = tui.acquireFixedSuffixScrollRegion("iterm-owner"); + expect(token).toBeDefined(); + if (token === undefined) throw new Error("Expected fixed suffix token"); + + transcript.setLines(["line-1", "line-2", "line-3", "line-4"]); + term.clearWriteLog(); + expect(tui.armFixedSuffixScrollRegion(token, lease.token)).toBeGreaterThan(0); + await term.waitForRender(); + + const output = term.getWriteLog().join(""); + expect(output).not.toContain("ITERM_ERASE"); + expect(output).not.toContain("\x1b[1;2r"); + expect(invalidated).toBe(0); + } finally { + tui.stop(); + } + }); + it("keeps an armed fixed-suffix lease across a forced render", async () => { + const { term, transcript, tui } = createPinnedTui(); + let invalidated = 0; + try { + tui.start(); + await term.waitForRender(); + const lease = await tui.acquireRasterLease({ + ownerId: "iterm-owner", + rect: { column: 36, row: 2, width: 3, height: 3 }, + erase: { type: "raster-erase", bytes: new TextEncoder().encode("ITERM_ERASE") }, + onInvalidated: () => invalidated++, + nativeScrollbackEligible: true, + }); + expect(lease.status).toBe("acquired"); + if (lease.status !== "acquired") throw new Error("Expected iTerm lease"); + const token = tui.acquireFixedSuffixScrollRegion("iterm-owner"); + expect(token).toBeDefined(); + if (token === undefined) throw new Error("Expected fixed suffix token"); + transcript.setLines(["line-1", "line-2", "line-3", "line-4"]); + expect(tui.armFixedSuffixScrollRegion(token, lease.token)).toBeGreaterThan(0); + await term.waitForRender(); + + term.clearWriteLog(); + tui.requestRender(true, "test armed fixed suffix render"); + await term.waitForRender(); + + const output = term.getWriteLog().join(""); + expect(output).not.toContain("ITERM_ERASE"); + expect(invalidated).toBe(0); + } finally { + tui.stop(); + } + }); it("uses the existing renderer unless a current owner arms the fixed suffix region", async () => { const { term, transcript, tui } = createPinnedTui(); try { @@ -519,6 +655,42 @@ describe("TUI fixed suffix scroll region", () => { expect(tui.acquireFixedSuffixScrollRegion("new-owner")).toBeUndefined(); expect(tui.armFixedSuffixScrollRegion(token)).toBeUndefined(); }); + it("keeps a released fixed-plane lease on the clipped renderer path", async () => { + const { term, transcript, tui } = createPinnedTui(); + let invalidated = 0; + try { + tui.start(); + await term.waitForRender(); + const lease = await tui.acquireRasterLease({ + ownerId: "test-owner", + rect: { column: 36, row: 2, width: 3, height: 3 }, + erase: { type: "raster-erase", bytes: new TextEncoder().encode("ITERM_ERASE") }, + onInvalidated: () => invalidated++, + nativeScrollbackEligible: true, + }); + expect(lease.status).toBe("acquired"); + if (lease.status !== "acquired") throw new Error("Expected iTerm lease"); + const token = tui.acquireFixedSuffixScrollRegion("test-owner"); + expect(token).toBeDefined(); + if (token === undefined) throw new Error("Expected fixed suffix token"); + transcript.setLines(["line-1", "line-2", "line-3", "line-4"]); + expect(tui.armFixedSuffixScrollRegion(token, lease.token)).toBeGreaterThan(0); + await term.waitForRender(); + + tui.releaseFixedSuffixScrollRegion(token); + transcript.setLines(["line-1", "line-2", "line-3", "line-4", "line-5"]); + term.clearWriteLog(); + tui.requestRender(); + await term.waitForRender(); + + const output = term.getWriteLog().join(""); + expect(output).toContain("\x1b[r\x1b[?6l"); + expect(output).not.toContain("ITERM_ERASE"); + expect(invalidated).toBe(0); + } finally { + tui.stop(); + } + }); it("resets a released fixed plane before queued terminal output", async () => { const { term, transcript, tui } = createPinnedTui(); try { From b8d4fa2e52076c71c2ce7543c7854d4dd5cdc081 Mon Sep 17 00:00:00 2001 From: snowykr Date: Sun, 2 Aug 2026 19:45:03 +0900 Subject: [PATCH 36/46] fix(tui): defer unsafe iTerm lease renders A current native fixed-suffix lease still reached protected ingress through a non-preserving append branch while a multipart barrier was active.\n\nRestrict that branch to preserving transactions and defer unsafe frames until raster cleanup settles, keeping the resident GIF intact. Lore-id: e4d721b9\nConstraint: never erase an armed iTerm lease for ordinary streaming fallback\nConstraint: preserve generic raster and non-iTerm behavior\nConfidence: high\nScope-risk: focused\nReversibility: straightforward\nTested: focused fixed-suffix, raster-lease, and render-commit suites\nTested: packages/tui check --- packages/tui/src/tui.ts | 40 +++++++++++++------ .../test/fixed-suffix-scroll-region.test.ts | 9 +++-- 2 files changed, 33 insertions(+), 16 deletions(-) diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index d965c5a9f0..747180a835 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -1336,7 +1336,13 @@ export class TUI extends Container { return undefined; const lease = rasterLease === undefined ? undefined : this.#rasterLeases.get(token.ownerId); if (rasterLease !== undefined && (!lease || lease.token !== rasterLease)) return undefined; - if (lease?.nativeScrollbackEligible && lease.token.rect.row > 0) lease.nativeScrollbackArmed = true; + if (lease?.nativeScrollbackEligible && lease.token.rect.row === 0) return undefined; + const previousRasterLease = this.#armedFixedSuffixScrollRegionRasterLease; + if (previousRasterLease !== rasterLease && previousRasterLease !== undefined) { + const previousLease = this.#rasterLeases.get(previousRasterLease.ownerId); + if (previousLease?.token === previousRasterLease) previousLease.nativeScrollbackArmed = false; + } + if (lease?.nativeScrollbackEligible) lease.nativeScrollbackArmed = true; this.#armedFixedSuffixScrollRegionToken = token; this.#armedFixedSuffixScrollRegionRasterLease = rasterLease; return this.requestRenderWithGeneration(false, "fixed-suffix-scroll-region"); @@ -4376,9 +4382,14 @@ export class TUI extends Container { fixedSuffixLease.nativeScrollbackEligible && fixedSuffixLease.nativeScrollbackArmed && fixedSuffixLease.token.rect.row > 0 && - this.#rasterLeases.size === 1 && - this.#rasterCleanup.size === 0; + this.#rasterLeases.size === 1; if (preserveArmedFixedSuffixLease) { + if (this.#rasterCleanup.size > 0) { + void this.#rasterIngress.then(() => { + if (!this.#stopped && this.terminalAvailable) this.requestRender(); + }); + return; + } this.#fixedSuffixScrollPlane = undefined; this.#fixedSuffixScrollRegionResetPending = true; viewportRepaint(`armed fixed suffix lease blocked full render: ${reason}`); @@ -5012,14 +5023,19 @@ export class TUI extends Container { fixedSuffixRasterLease.nativeScrollbackEligible && fixedSuffixRasterLease.nativeScrollbackArmed && fixedSuffixRasterLease.token.rect.row > 0 && - this.#rasterLeases.size === 1 && - this.#rasterCleanup.size === 0; - if ( - activeArmedFixedSuffixLease && - !fixedSuffixNativeAppendPreservesRasterLease && - !fixedPlaneEligible && - !newLines.slice(Math.max(0, newLines.length - height)).some(line => TERMINAL.isImageLine(line)) - ) { + this.#rasterLeases.size === 1; + if (activeArmedFixedSuffixLease && !fixedSuffixNativeAppendPreservesRasterLease && !fixedPlaneEligible) { + if (this.#rasterCleanup.size > 0) { + void this.#rasterIngress.then(() => { + if (!this.#stopped && this.terminalAvailable) this.requestRender(); + }); + return; + } + if (newLines.slice(Math.max(0, newLines.length - height)).some(line => TERMINAL.isImageLine(line))) { + // A competing image protocol cannot be clipped safely around the iTerm + // raster. Preserve the current frame rather than erasing the resident GIF. + return; + } if (appendedLines && nextLiveViewportTop > prevViewportTop) { // The clipped repaint keeps the resident iTerm raster intact while this // transient frame cannot safely form a DECSTBM transaction. The next @@ -5237,7 +5253,7 @@ export class TUI extends Container { } return; } - if (fixedSuffixNativeAppend) { + if (fixedSuffixNativeAppend && (this.#rasterLeases.size === 0 || fixedSuffixNativeAppendPreservesRasterLease)) { const suffixRegionBottom = height - nextSuffixLineCount; // iTerm raster cells are not ordinary text cells. Keep the entire lease // outside DECSTBM even when its transparent canvas reaches above the diff --git a/packages/tui/test/fixed-suffix-scroll-region.test.ts b/packages/tui/test/fixed-suffix-scroll-region.test.ts index 2287039f11..b8a0ba8668 100644 --- a/packages/tui/test/fixed-suffix-scroll-region.test.ts +++ b/packages/tui/test/fixed-suffix-scroll-region.test.ts @@ -168,7 +168,7 @@ describe("TUI fixed suffix scroll region", () => { tui.stop(); } }); - it("keeps an armed iTerm lease through a multipart barrier reflow", async () => { + it("keeps an armed iTerm lease through a multipart barrier append", async () => { const { term, transcript, tui } = createPinnedTui(); let invalidated = 0; try { @@ -209,7 +209,7 @@ describe("TUI fixed suffix scroll region", () => { }); await prefixEntered.promise; - transcript.setLines(["line-1 revised", "line-2", "line-3", "line-4", "line-5"]); + transcript.setLines(["line-1", "line-2", "line-3", "line-4", "line-5"]); tui.requestRender(); await Bun.sleep(20); expect(term.getWriteLog().join("")).toBe("MULTIPART_PREFIX"); @@ -222,7 +222,7 @@ describe("TUI fixed suffix scroll region", () => { expect(fallbackOutput).not.toContain("ITERM_ERASE"); expect(invalidated).toBe(0); - transcript.setLines(["line-1 revised", "line-2", "line-3", "line-4", "line-5", "line-6"]); + transcript.setLines(["line-1", "line-2", "line-3", "line-4", "line-5", "line-6"]); term.clearWriteLog(); tui.requestRender(); await term.waitForRender(); @@ -534,7 +534,8 @@ describe("TUI fixed suffix scroll region", () => { transcript.setLines(["line-1", "line-2", "line-3", "line-4"]); term.clearWriteLog(); - expect(tui.armFixedSuffixScrollRegion(token, lease.token)).toBeGreaterThan(0); + expect(tui.armFixedSuffixScrollRegion(token, lease.token)).toBeUndefined(); + tui.requestRender(); await term.waitForRender(); const output = term.getWriteLog().join(""); From 2e671ac7d721b60332e5fda0cae08127d949fdb6 Mon Sep 17 00:00:00 2001 From: snowykr Date: Sun, 2 Aug 2026 19:57:55 +0900 Subject: [PATCH 37/46] fix(tui): preserve iTerm lease around image frames A frame containing another terminal image protocol cannot be safely repainted through the fixed iTerm plane. Preserve the resident iTerm GIF instead of routing that frame through an ingress that erases its lease. Lore-id: 92e1ad3c Constraint: keep Kitty and Sixel paths outside the iTerm native-scrollback plane Constraint: do not erase or re-upload a resident iTerm GIF for image-bearing frames Confidence: high Scope-risk: narrow Reversibility: straightforward Tested: bun test test/fixed-suffix-scroll-region.test.ts test/raster-lease.test.ts test/render-commit.test.ts; bun run check --- packages/tui/src/tui.ts | 2 + .../test/fixed-suffix-scroll-region.test.ts | 52 ++++++++++++++++++- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 747180a835..58a9332b95 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -4390,6 +4390,7 @@ export class TUI extends Container { }); return; } + if (newLines.some(line => TERMINAL.isImageLine(line))) return; this.#fixedSuffixScrollPlane = undefined; this.#fixedSuffixScrollRegionResetPending = true; viewportRepaint(`armed fixed suffix lease blocked full render: ${reason}`); @@ -4868,6 +4869,7 @@ export class TUI extends Container { : this.#rasterLeases.get(fixedSuffixScrollRegionToken.ownerId); const fixedSuffixNativeAppendPreservesRasterLease = fixedSuffixNativeAppend && + !newLines.some(line => TERMINAL.isImageLine(line)) && this.#rasterCleanup.size === 0 && !this.#fixedSuffixScrollRegionResetPending && (this.#rasterLeases.size === 0 || diff --git a/packages/tui/test/fixed-suffix-scroll-region.test.ts b/packages/tui/test/fixed-suffix-scroll-region.test.ts index b8a0ba8668..579f07e3f5 100644 --- a/packages/tui/test/fixed-suffix-scroll-region.test.ts +++ b/packages/tui/test/fixed-suffix-scroll-region.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { type Component, CURSOR_MARKER, TUI } from "@gajae-code/tui"; +import { type Component, CURSOR_MARKER, ImageProtocol, setTerminalImageProtocol, TERMINAL, TUI } from "@gajae-code/tui"; import { VirtualTerminal } from "./virtual-terminal"; class LinesComponent implements Component { @@ -579,6 +579,56 @@ describe("TUI fixed suffix scroll region", () => { tui.stop(); } }); + it("preserves an armed iTerm lease when the frame contains an image", async () => { + const { term, transcript, tui } = createPinnedTui(); + let invalidated = 0; + const previousImageProtocol = TERMINAL.imageProtocol; + setTerminalImageProtocol(ImageProtocol.Iterm2); + try { + tui.start(); + await term.waitForRender(); + const lease = await tui.acquireRasterLease({ + ownerId: "iterm-owner", + rect: { column: 36, row: 2, width: 3, height: 3 }, + erase: { type: "raster-erase", bytes: new TextEncoder().encode("ITERM_ERASE") }, + onInvalidated: () => invalidated++, + nativeScrollbackEligible: true, + }); + expect(lease.status).toBe("acquired"); + if (lease.status !== "acquired") throw new Error("Expected iTerm lease"); + const token = tui.acquireFixedSuffixScrollRegion("iterm-owner"); + expect(token).toBeDefined(); + if (token === undefined) throw new Error("Expected fixed suffix token"); + + transcript.setLines(["line-1", "line-2", "line-3", "line-4"]); + expect(tui.armFixedSuffixScrollRegion(token, lease.token)).toBeGreaterThan(0); + await term.waitForRender(); + + transcript.setLines([ + "line-1", + "line-2", + "line-3", + "line-4", + "\x1b]1337;MultipartFile=;name=test;size=1;width=1;height=1;inline=1:\x07", + ]); + term.clearWriteLog(); + tui.requestRender(); + await term.waitForRender(); + const appendOutput = term.getWriteLog().join(""); + expect(appendOutput).not.toContain("\x1b[1;2r"); + expect(appendOutput).not.toContain("ITERM_ERASE"); + expect(invalidated).toBe(0); + + term.clearWriteLog(); + tui.requestRender(true, "test armed image render"); + await term.waitForRender(); + expect(term.getWriteLog().join("")).not.toContain("ITERM_ERASE"); + expect(invalidated).toBe(0); + } finally { + tui.stop(); + setTerminalImageProtocol(previousImageProtocol); + } + }); it("uses the existing renderer unless a current owner arms the fixed suffix region", async () => { const { term, transcript, tui } = createPinnedTui(); try { From f8c1737605eaf928530ae576aa17518ac1c551ed Mon Sep 17 00:00:00 2001 From: snowykr Date: Sun, 2 Aug 2026 19:59:39 +0900 Subject: [PATCH 38/46] test(tui): cover fixed plane rearm Releasing a fixed-suffix owner disarms its lease. A later arm must restore native admission without erasing the resident iTerm raster. Lore-id: 760e9c3f Constraint: preserve the resident iTerm GIF across release and re-arm Confidence: high Scope-risk: narrow Reversibility: straightforward Tested: bun test test/fixed-suffix-scroll-region.test.ts test/raster-lease.test.ts test/render-commit.test.ts --- packages/tui/test/fixed-suffix-scroll-region.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/tui/test/fixed-suffix-scroll-region.test.ts b/packages/tui/test/fixed-suffix-scroll-region.test.ts index 579f07e3f5..cb8b6c7854 100644 --- a/packages/tui/test/fixed-suffix-scroll-region.test.ts +++ b/packages/tui/test/fixed-suffix-scroll-region.test.ts @@ -738,6 +738,17 @@ describe("TUI fixed suffix scroll region", () => { expect(output).toContain("\x1b[r\x1b[?6l"); expect(output).not.toContain("ITERM_ERASE"); expect(invalidated).toBe(0); + const rearmed = tui.acquireFixedSuffixScrollRegion("test-owner"); + expect(rearmed).toBeDefined(); + if (rearmed === undefined) throw new Error("Expected rearmed fixed suffix token"); + expect(tui.armFixedSuffixScrollRegion(rearmed, lease.token)).toBeGreaterThan(0); + transcript.setLines(["line-1", "line-2", "line-3", "line-4", "line-5", "line-6"]); + term.clearWriteLog(); + tui.requestRender(); + await term.waitForRender(); + expect(term.getWriteLog().join("")).toContain("\x1b[1;2r"); + expect(term.getWriteLog().join("")).not.toContain("ITERM_ERASE"); + expect(invalidated).toBe(0); } finally { tui.stop(); } From 4cdfc1049ce1de4aa71e5820985877cbd9c47cb9 Mon Sep 17 00:00:00 2001 From: snowykr Date: Sun, 2 Aug 2026 20:04:57 +0900 Subject: [PATCH 39/46] fix(tui): defer image viewport repaints under iTerm lease Viewport-repaint branches run before fixed-plane admission. When another image is visible, those branches must not route a resident iTerm Pet through protected ingress and erase it. Lore-id: 39b2e6a1 Constraint: preserve a current armed iTerm lease when a visible frame contains another image protocol Constraint: keep generic raster and Kitty/Sixel behavior unchanged Confidence: high Scope-risk: narrow Reversibility: straightforward Tested: bun test test/fixed-suffix-scroll-region.test.ts test/raster-lease.test.ts test/render-commit.test.ts; bun run check; git diff --check --- packages/tui/src/tui.ts | 24 +++++++++-- .../test/fixed-suffix-scroll-region.test.ts | 43 ++++++++++++++++++- 2 files changed, 63 insertions(+), 4 deletions(-) diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 58a9332b95..496eaf34ba 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -1360,7 +1360,22 @@ export class TUI extends Container { this.#armedFixedSuffixScrollRegionRasterLease = undefined; this.#fixedSuffixScrollRegionOwners.clear(); } + #hasCurrentArmedFixedSuffixRasterLease(): boolean { + const token = this.#armedFixedSuffixScrollRegionToken; + const rasterLease = this.#armedFixedSuffixScrollRegionRasterLease; + if (token === undefined || rasterLease === undefined || this.#rasterLeases.size !== 1) return false; + const lease = this.#rasterLeases.get(token.ownerId); + return ( + this.#fixedSuffixScrollRegionOwners.get(token.ownerId) === token && + rasterLease.ownerId === token.ownerId && + lease?.token === rasterLease && + lease.nativeScrollbackEligible && + lease.nativeScrollbackArmed && + lease.token.rect.row > 0 + ); + } + /** Report the logical output producer revision without coupling TUI to message types. */ /** Report the logical output producer revision without coupling TUI to message types. */ setViewportOutputSource(source: ViewportOutputSource | null): void { const previous = this.#viewportOutputSource; @@ -3911,10 +3926,13 @@ export class TUI extends Container { : (lines[lineIndex] ?? ""); }; const visibleLines = Array.from({ length: height }, (_, screenRow) => lineForScreenRow(screenRow)); + const containsVisibleImage = visibleLines.some(line => TERMINAL.isImageLine(line)); + // A fixed iTerm raster and another terminal image protocol cannot share an + // unleased viewport repaint. Leave the resident raster in place until a + // later frame can render without protected-ingress lease invalidation. + if (containsVisibleImage && this.#hasCurrentArmedFixedSuffixRasterLease()) return false; const preserveRasterLeases = - this.#rasterLeases.size > 0 && - this.#rasterCleanup.size === 0 && - !visibleLines.some(line => TERMINAL.isImageLine(line)); + this.#rasterLeases.size > 0 && this.#rasterCleanup.size === 0 && !containsVisibleImage; let buffer = `\x1b[?2026h${deletePlan.output}${preserveRasterLeases ? "\x1b[?25l" : ""}`; if (!preserveRasterLeases) buffer += "\x1b[H"; const committedTranscriptRows: Array = []; diff --git a/packages/tui/test/fixed-suffix-scroll-region.test.ts b/packages/tui/test/fixed-suffix-scroll-region.test.ts index cb8b6c7854..2905f0021a 100644 --- a/packages/tui/test/fixed-suffix-scroll-region.test.ts +++ b/packages/tui/test/fixed-suffix-scroll-region.test.ts @@ -19,8 +19,9 @@ class LinesComponent implements Component { function createPinnedTui( rows = 5, transcriptLines = ["line-1", `line-2${CURSOR_MARKER}`, "line-3"], + processTerminal = false, ): { term: VirtualTerminal; transcript: LinesComponent; suffix: LinesComponent; tui: TUI } { - const term = new VirtualTerminal(40, rows); + const term = new VirtualTerminal(40, rows, { isProcessTerminal: processTerminal }); const tui = new TUI(term); const transcript = new LinesComponent(transcriptLines); const suffix = new LinesComponent(["status", "composer"]); @@ -629,6 +630,46 @@ describe("TUI fixed suffix scroll region", () => { setTerminalImageProtocol(previousImageProtocol); } }); + it("preserves an armed iTerm lease when a viewport repaint frame contains an image", async () => { + const { term, transcript, tui } = createPinnedTui(5, ["line-1", "line-2", "line-3"], true); + let invalidated = 0; + const previousImageProtocol = TERMINAL.imageProtocol; + setTerminalImageProtocol(ImageProtocol.Iterm2); + try { + tui.start(); + await term.waitForRender(); + const lease = await tui.acquireRasterLease({ + ownerId: "iterm-owner", + rect: { column: 36, row: 2, width: 3, height: 3 }, + erase: { type: "raster-erase", bytes: new TextEncoder().encode("ITERM_ERASE") }, + onInvalidated: () => invalidated++, + nativeScrollbackEligible: true, + }); + expect(lease.status).toBe("acquired"); + if (lease.status !== "acquired") throw new Error("Expected iTerm lease"); + const token = tui.acquireFixedSuffixScrollRegion("iterm-owner"); + expect(token).toBeDefined(); + if (token === undefined) throw new Error("Expected fixed suffix token"); + + transcript.setLines(["line-1", "line-2", "line-3", "line-4"]); + expect(tui.armFixedSuffixScrollRegion(token, lease.token)).toBeGreaterThan(0); + await term.waitForRender(); + + transcript.setLines([ + "line-1", + "line-2", + "\x1b]1337;MultipartFile=;name=test;size=1;width=1;height=1;inline=1:\x07", + ]); + term.clearWriteLog(); + tui.requestRender(); + await term.waitForRender(); + expect(term.getWriteLog().join("")).not.toContain("ITERM_ERASE"); + expect(invalidated).toBe(0); + } finally { + tui.stop(); + setTerminalImageProtocol(previousImageProtocol); + } + }); it("uses the existing renderer unless a current owner arms the fixed suffix region", async () => { const { term, transcript, tui } = createPinnedTui(); try { From c0991bbb50e72de9542d4baaff73dd2c3b2f2d1c Mon Sep 17 00:00:00 2001 From: snowykr Date: Sun, 2 Aug 2026 20:10:43 +0900 Subject: [PATCH 40/46] test(tui): assert image frames bypass fixed plane A viewport-repaint image frame must neither erase the resident iTerm lease nor enter the DECSTBM fixed plane. Lore-id: 6fb5291a Constraint: retain raster lease across competing image viewport frames Confidence: high Scope-risk: narrow Reversibility: straightforward Tested: bun test test/fixed-suffix-scroll-region.test.ts test/raster-lease.test.ts test/render-commit.test.ts; bun run check; git diff --check --- packages/tui/test/fixed-suffix-scroll-region.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/tui/test/fixed-suffix-scroll-region.test.ts b/packages/tui/test/fixed-suffix-scroll-region.test.ts index 2905f0021a..6558b7a4bc 100644 --- a/packages/tui/test/fixed-suffix-scroll-region.test.ts +++ b/packages/tui/test/fixed-suffix-scroll-region.test.ts @@ -663,7 +663,9 @@ describe("TUI fixed suffix scroll region", () => { term.clearWriteLog(); tui.requestRender(); await term.waitForRender(); - expect(term.getWriteLog().join("")).not.toContain("ITERM_ERASE"); + const output = term.getWriteLog().join(""); + expect(output).not.toContain("\x1b[1;2r"); + expect(output).not.toContain("ITERM_ERASE"); expect(invalidated).toBe(0); } finally { tui.stop(); From 3a6fa633f214115d17febdc4a28f246aff7364ed Mon Sep 17 00:00:00 2001 From: snowykr Date: Sun, 2 Aug 2026 20:29:28 +0900 Subject: [PATCH 41/46] fix(iterm): emit canonical multipart file records MultipartFile has no payload delimiter. The prior leading separator and colon were not part of iTerm2's multipart protocol and could leave its image decoder with malformed transfer metadata. Lore-id: c4d920ab Constraint: retain 256-byte managed-tmux records Constraint: preserve standard direct and managed iTerm2 multipart parsing Confidence: high Scope-risk: narrow Reversibility: straightforward Tested: bun test test/gajae-pet.test.ts test/iterm2-protocol.test.ts test/fixed-suffix-scroll-region.test.ts test/raster-lease.test.ts test/render-commit.test.ts; bun run check; git diff --check --- packages/tui/src/terminal-capabilities.ts | 4 +++- packages/tui/test/fixed-suffix-scroll-region.test.ts | 4 ++-- packages/tui/test/gajae-pet.test.ts | 2 +- packages/tui/test/iterm2-protocol.test.ts | 4 ++-- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/tui/src/terminal-capabilities.ts b/packages/tui/src/terminal-capabilities.ts index b364165884..084282021d 100644 --- a/packages/tui/src/terminal-capabilities.ts +++ b/packages/tui/src/terminal-capabilities.ts @@ -910,8 +910,10 @@ export function encodeITerm2Multipart( const height = validate(options.height ?? "auto", "height"); const size = Buffer.from(base64Data, "base64").byteLength; const name = Buffer.from("gajae-pet.gif").toString("base64"); + // MultipartFile carries arguments only; unlike File it has neither a + // leading argument separator nor a colon payload delimiter. const records = [ - `\x1b]1337;MultipartFile=;name=${name};size=${size};width=${width};height=${height};inline=1;preserveAspectRatio=0:\x07`, + `\x1b]1337;MultipartFile=name=${name};size=${size};width=${width};height=${height};inline=1;preserveAspectRatio=0\x07`, ]; for (let i = 0; i < base64Data.length; i += 200) { records.push(`\x1b]1337;FilePart=${base64Data.slice(i, i + 200)}\x07`); diff --git a/packages/tui/test/fixed-suffix-scroll-region.test.ts b/packages/tui/test/fixed-suffix-scroll-region.test.ts index 6558b7a4bc..1810f4e4c3 100644 --- a/packages/tui/test/fixed-suffix-scroll-region.test.ts +++ b/packages/tui/test/fixed-suffix-scroll-region.test.ts @@ -610,7 +610,7 @@ describe("TUI fixed suffix scroll region", () => { "line-2", "line-3", "line-4", - "\x1b]1337;MultipartFile=;name=test;size=1;width=1;height=1;inline=1:\x07", + "\x1b]1337;MultipartFile=name=test;size=1;width=1;height=1;inline=1\x07", ]); term.clearWriteLog(); tui.requestRender(); @@ -658,7 +658,7 @@ describe("TUI fixed suffix scroll region", () => { transcript.setLines([ "line-1", "line-2", - "\x1b]1337;MultipartFile=;name=test;size=1;width=1;height=1;inline=1:\x07", + "\x1b]1337;MultipartFile=name=test;size=1;width=1;height=1;inline=1\x07", ]); term.clearWriteLog(); tui.requestRender(); diff --git a/packages/tui/test/gajae-pet.test.ts b/packages/tui/test/gajae-pet.test.ts index 756224e3d9..cec8da43f9 100644 --- a/packages/tui/test/gajae-pet.test.ts +++ b/packages/tui/test/gajae-pet.test.ts @@ -199,7 +199,7 @@ describe("GIF artifacts and helpers", () => { expect(red.multipart.slice(1)).toEqual(encodeITerm2Multipart(red.base64).slice(1)); expect(red.tmuxDcs).toEqual(wrapITerm2RecordsForTmux(red.multipart)); expect(red.multipart[0]).toBe( - `\x1b]1337;MultipartFile=;name=Z2FqYWUtcGV0LmdpZg==;size=${red.bytes.byteLength};width=${red.width}px;height=${red.height}px;inline=1;preserveAspectRatio=0:\x07`, + `\x1b]1337;MultipartFile=name=Z2FqYWUtcGV0LmdpZg==;size=${red.bytes.byteLength};width=${red.width}px;height=${red.height}px;inline=1;preserveAspectRatio=0\x07`, ); expect(red.multipart.at(-1)).toBe("\x1b]1337;FileEnd\x07"); expect(red.multipart.slice(1, -1).every(record => record.length <= 220)).toBe(true); diff --git a/packages/tui/test/iterm2-protocol.test.ts b/packages/tui/test/iterm2-protocol.test.ts index 0a00d8b2d2..b161e071bf 100644 --- a/packages/tui/test/iterm2-protocol.test.ts +++ b/packages/tui/test/iterm2-protocol.test.ts @@ -20,7 +20,7 @@ describe("iTerm2 multipart protocol", () => { const data = "ABCD".repeat(151); const records = encodeITerm2Multipart(data, { width: 80, height: "auto" }); expect(records[0]).toBe( - "\x1b]1337;MultipartFile=;name=Z2FqYWUtcGV0LmdpZg==;size=453;width=80;height=auto;inline=1;preserveAspectRatio=0:\x07", + "\x1b]1337;MultipartFile=name=Z2FqYWUtcGV0LmdpZg==;size=453;width=80;height=auto;inline=1;preserveAspectRatio=0\x07", ); expect(records.at(-1)).toBe("\x1b]1337;FileEnd\x07"); const parts = records.slice(1, -1).map(record => record.slice("\x1b]1337;FilePart=".length, -1)); @@ -53,7 +53,7 @@ describe("iTerm2 multipart protocol", () => { expect(TERMINAL.isImageLine(sequence)).toBe(true); expect(sequence.endsWith("\x07")).toBe(true); expect(sequence.endsWith("\x1b[K")).toBe(false); - expect(TERMINAL.isImageLine("\x1b]1337;MultipartFile=;name=pet;size=1;width=1;height=1;inline=1:\x07")).toBe( + expect(TERMINAL.isImageLine("\x1b]1337;MultipartFile=name=pet;size=1;width=1;height=1;inline=1\x07")).toBe( true, ); } finally { From 795528d533d7d02f586f8d4ec416f7694166bc64 Mon Sep 17 00:00:00 2001 From: snowykr Date: Sun, 2 Aug 2026 20:34:50 +0900 Subject: [PATCH 42/46] test(iterm): verify multipart metadata terminator The widget-level direct transport must retain the metadata-only MultipartFile header rather than accepting File's colon delimiter. Lore-id: 182f07cb Constraint: direct and managed iTerm records use canonical MultipartFile metadata Confidence: high Scope-risk: narrow Reversibility: straightforward Tested: bun test test/gajae-pet-widget.test.ts; bun run check; git diff --check --- packages/coding-agent/test/gajae-pet-widget.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/coding-agent/test/gajae-pet-widget.test.ts b/packages/coding-agent/test/gajae-pet-widget.test.ts index be2fb8515f..12250a019d 100644 --- a/packages/coding-agent/test/gajae-pet-widget.test.ts +++ b/packages/coding-agent/test/gajae-pet-widget.test.ts @@ -1248,7 +1248,7 @@ describe("GajaePetWidget", () => { expect(directRecords[1]).toContain("\x1b]1337;MultipartFile="); expect(directRecords[1]).toContain("width=4;height=3;"); expect(directRecords[1]).toContain("size="); - expect(directRecords[1]).toContain("inline=1;preserveAspectRatio=0:"); + expect(directRecords[1]).toContain("inline=1;preserveAspectRatio=0\x07"); expect(directRecords.slice(1).filter(record => record.includes("\x1b[28;76H")).length).toBe(0); expect(directRecords.slice(1).every(record => !record.includes("\x1b[28;76H"))).toBe(true); expect(directRecords.at(-2)).toBe("\x1b]1337;FileEnd\x07"); From f62dd898ebf0b9a0c24f8b58fa025290aa52c8a1 Mon Sep 17 00:00:00 2001 From: snowykr Date: Sun, 2 Aug 2026 20:57:38 +0900 Subject: [PATCH 43/46] fix(iterm): switch pet timeline at work boundaries A resident iTerm GIF cannot react to later activity changes. Keying the existing lease-backed upload by idle or working state restores idle after work without frame-by-frame uploads. Lore-id: 89de42c7 Constraint: retain canonical MultipartFile transport Constraint: retain native scrollback and raster lease behavior Constraint: do not erase the resident iTerm raster on activity transitions Confidence: medium Scope-risk: narrow Reversibility: straightforward Tested: bun test test/gajae-pet-widget.test.ts; bun --cwd=packages/coding-agent run check; bun --cwd=packages/tui test test/gajae-pet.test.ts test/iterm2-protocol.test.ts test/fixed-suffix-scroll-region.test.ts test/raster-lease.test.ts test/render-commit.test.ts; bun --cwd=packages/tui run check; git diff --check --- .../src/modes/components/gajae-pet-widget.ts | 34 ++++++++------ .../test/gajae-pet-widget.test.ts | 45 +++++++++++++------ 2 files changed, 52 insertions(+), 27 deletions(-) diff --git a/packages/coding-agent/src/modes/components/gajae-pet-widget.ts b/packages/coding-agent/src/modes/components/gajae-pet-widget.ts index 75d62ac9ac..00769ede2a 100644 --- a/packages/coding-agent/src/modes/components/gajae-pet-widget.ts +++ b/packages/coding-agent/src/modes/components/gajae-pet-widget.ts @@ -9,6 +9,7 @@ import { type GajaePixelFrames, getCellDimensions, getGajaePetGifCached, + idleTimeline, PARA_PARA_STEPS, PET_SKINS, type PetMode, @@ -496,7 +497,7 @@ export class GajaePetWidget { return "base"; } - #tickIterm(_now: number): void { + #tickIterm(_now: number, working: boolean): void { if (!this.#isActiveOwner() || this.#ui.manualViewportActive) { this.#releaseFixedSuffixScrollRegion(); return; @@ -567,11 +568,11 @@ export class GajaePetWidget { this.#releaseFixedSuffixScrollRegion(); return; } - // OSC 1337 has no image-frame replacement primitive. Re-uploading a GIF - // for ordinary working/idle or auto-flex transitions visibly flashes its - // transparent canvas. Keep one loop resident; its final base frame remains - // safe when iTerm ignores the GIF loop extension. - const semantic = `${this.#mode}:${availability.mode}:${availability.epoch}:${rect.column},${rect.row}:${cell.widthPx},${cell.heightPx}:${this.#ui.terminal.columns},${this.#ui.terminal.rows}`; + // iTerm replaces a resident GIF only through another MultipartFile transfer. + // Include the activity phase so it changes exactly once per work boundary; + // steady ticks retain the existing lease and image. + const animationPhase = working ? "working" : "idle"; + const semantic = `${this.#mode}:${animationPhase}:${availability.mode}:${availability.epoch}:${rect.column},${rect.row}:${cell.widthPx},${cell.heightPx}:${this.#ui.terminal.columns},${this.#ui.terminal.rows}`; if (this.#itermSubmitPending) return; if (semantic === this.#itermLastSemantic && this.#itermLease) { this.#armFixedSuffixScrollRegion(this.#itermLease); @@ -586,6 +587,7 @@ export class GajaePetWidget { availability.epoch, availability.mode, semantic, + animationPhase, { columns: this.#ui.terminal.columns, rows: terminalRows, @@ -604,10 +606,11 @@ export class GajaePetWidget { epoch: number, mode: "direct" | "managed", semantic: string, + animationPhase: "idle" | "working", geometry: Readonly<{ columns: number; rows: number; cellWidthPx: number; cellHeightPx: number }>, composerBottomOffset: number, ): Promise { - const current = () => { + const currentGeometry = () => { const availability = getVerifiedItermPetAvailability(); const terminal = this.#ui.terminal; const cell = getCellDimensions(); @@ -653,7 +656,7 @@ export class GajaePetWidget { } token = undefined; } - if (!current()) return; + if (!currentGeometry()) return; if (!token) { const acquired = await this.#ui.acquireRasterLease({ ownerId: this.#itermOwner, @@ -676,7 +679,7 @@ export class GajaePetWidget { } }, }); - if (!current() || acquired.status !== "acquired") { + if (!currentGeometry() || acquired.status !== "acquired") { this.#releaseFixedSuffixScrollRegion(); if (acquired.status === "acquired") await this.#ui.invalidateRasterLease({ @@ -689,7 +692,10 @@ export class GajaePetWidget { this.#itermLease = token; } this.#itermLastSemantic = semantic; - const frames = [...workingTimeline(), { name: "base" as const, delayMs: 700 }]; + const frames = + animationPhase === "working" + ? [...workingTimeline(), { name: "base" as const, delayMs: 700 }] + : [...idleTimeline(), { name: "base" as const, delayMs: 700 }]; const cell = getCellDimensions(); const gif = getGajaePetGifCached({ skin: this.#mode === "off" ? "red" : this.#mode, @@ -725,17 +731,17 @@ export class GajaePetWidget { ), afterPrefix: mode === "managed" - ? async () => (current() ? await this.#syncManagedItermCursor(rect.row, rect.column) : false) + ? async () => (currentGeometry() ? await this.#syncManagedItermCursor(rect.row, rect.column) : false) : undefined, replayPrefix: mode === "managed" ? new TextEncoder().encode(cursorPosition) : undefined, records: encodedRecords, suffix: new TextEncoder().encode(cursorRestore), abortSuffix: mode === "managed" ? new TextEncoder().encode(cursorRestore) : undefined, restoreCursorVisibility: true, - shouldWrite: current, + shouldWrite: currentGeometry, }, }); - if (!current() || submit.status !== "written") { + if (!currentGeometry() || submit.status !== "written") { this.#releaseFixedSuffixScrollRegion(); await this.#ui.invalidateRasterLease({ token, cause: "capability-loss" }); if (this.#itermLease === token) { @@ -785,7 +791,7 @@ export class GajaePetWidget { } } if (this.#itermProtocol) { - this.#tickIterm(now); + this.#tickIterm(now, working); return; } if (this.#mode === "off" || !this.#pixel) return; diff --git a/packages/coding-agent/test/gajae-pet-widget.test.ts b/packages/coding-agent/test/gajae-pet-widget.test.ts index 12250a019d..8b2bfca3a8 100644 --- a/packages/coding-agent/test/gajae-pet-widget.test.ts +++ b/packages/coding-agent/test/gajae-pet-widget.test.ts @@ -4,6 +4,7 @@ import { Container, getCellDimensions, getGajaePetGifCached, + idleTimeline, setCellDimensions, type TUI, workingTimeline, @@ -1500,7 +1501,7 @@ describe("GajaePetWidget", () => { stubs.widget.dispose(); } }); - it("keeps the iTerm GIF on its idle timeline while inactive", async () => { + it("uploads an idle iTerm GIF while inactive", async () => { vi.useFakeTimers(); const stubs = makeWidget(80, 30, { protocol: null, autoFlexGapMs: [500, 500] }); try { @@ -1512,12 +1513,24 @@ describe("GajaePetWidget", () => { vi.advanceTimersByTime(700); await flushAsyncChain(); expect(stubs.widget.isFlexing).toBe(false); + const request = stubs.getRasterLeaseRequests()[0]; + expect(request).toBeDefined(); + if (request === undefined) throw new Error("Expected iTerm raster lease request"); + const cell = getCellDimensions(); + const expectedGif = getGajaePetGifCached({ + skin: "blue", + timeline: [...idleTimeline(), { name: "base", delayMs: 700 }], + targetRows: 2, + rectangle: { width: request.rect.width * cell.widthPx, height: request.rect.height * cell.heightPx }, + contentInset: { topPx: Math.floor(cell.heightPx / 2), bottomPx: Math.ceil(cell.heightPx / 2) }, + displaySize: { width: request.rect.width, height: request.rect.height }, + }); expect( stubs .getRasterOutputs() - .map(record => new TextDecoder().decode(record)) - .filter(record => record.includes("MultipartFile=")), - ).toHaveLength(1); + .slice(1, -1) + .map(record => new TextDecoder().decode(record)), + ).toEqual([...expectedGif.multipart]); } finally { setVerifiedItermPetAvailability(undefined); stubs.widget.dispose(); @@ -1555,7 +1568,7 @@ describe("GajaePetWidget", () => { .getRasterOutputs() .map(record => new TextDecoder().decode(record)) .filter(record => record.includes("MultipartFile=")), - ).toHaveLength(1); + ).toHaveLength(2); } finally { setVerifiedItermPetAvailability(undefined); stubs.widget.dispose(); @@ -1681,7 +1694,7 @@ describe("GajaePetWidget", () => { stubs.widget.dispose(); } }); - it("keeps one iTerm GIF across idle-working-idle transitions", async () => { + it("changes the resident iTerm GIF once per idle-working-idle boundary", async () => { vi.useFakeTimers(); let working = false; const stubs = makeWidget(80, 30, { protocol: null, isWorking: () => working }); @@ -1705,20 +1718,26 @@ describe("GajaePetWidget", () => { .getRasterOutputs() .map(record => new TextDecoder().decode(record)) .filter(record => record.includes("MultipartFile=")), - ).toHaveLength(1); - expect(stubs.getRasterCursorVisibilityRestores()).toEqual([true]); + ).toHaveLength(2); + expect(stubs.getRasterCursorVisibilityRestores()).toEqual([true, true]); working = false; vi.advanceTimersByTime(80); await flushAsyncChain(); - expect(stubs.getRasterCursorVisibilityRestores()).toEqual([true]); + expect(stubs.getRasterCursorVisibilityRestores()).toEqual([true, true, true]); + expect( + stubs + .getRasterOutputs() + .map(record => new TextDecoder().decode(record)) + .filter(record => record.includes("MultipartFile=")), + ).toHaveLength(3); expect(stubs.getInvalidatedRasterLeases()).toHaveLength(0); } finally { setVerifiedItermPetAvailability(undefined); stubs.widget.dispose(); } }); - it("keeps the managed iTerm GIF across a working transition", async () => { + it("replaces the managed iTerm GIF once when work starts", async () => { vi.useFakeTimers(); let working = false; const stubs = makeWidget(80, 30, { protocol: null, isWorking: () => working }); @@ -1738,14 +1757,14 @@ describe("GajaePetWidget", () => { .getRasterOutputs() .map(record => new TextDecoder().decode(record)) .filter(record => record === expectedPrefix), - ).toHaveLength(1); + ).toHaveLength(2); expect(stubs.getInvalidatedRasterLeases()).toHaveLength(0); } finally { setVerifiedItermPetAvailability(undefined); stubs.widget.dispose(); } }); - it("keeps the initial iTerm raster settled while work starts", async () => { + it("replaces an idle iTerm GIF when work starts without invalidating its lease", async () => { vi.useFakeTimers(); let working = false; const stubs = makeWidget(80, 30, { protocol: null, isWorking: () => working }); @@ -1765,7 +1784,7 @@ describe("GajaePetWidget", () => { .getRasterOutputs() .map(record => new TextDecoder().decode(record)) .filter(record => record.includes("MultipartFile=")); - expect(headers).toHaveLength(1); + expect(headers).toHaveLength(2); expect(stubs.getInvalidatedRasterLeases()).toHaveLength(0); } finally { setVerifiedItermPetAvailability(undefined); From 1883e1b09912b8fe4486b6929cff9045526d8851 Mon Sep 17 00:00:00 2001 From: snowykr Date: Sun, 2 Aug 2026 21:15:23 +0900 Subject: [PATCH 44/46] test(iterm): retain terminal escape parser coverage The release rebase kept the current parser expectation when the historical iTerm transport test conflicted. Lore-id: b1c9a3e4 Constraint: preserve release 0.12.8 alignment Confidence: high Scope-risk: narrow Reversibility: straightforward Tested: bun --cwd=packages/coding-agent run check; bun --cwd=packages/tui run check; focused iTerm TUI and widget tests; git diff --check --- .../test/modes/components/iterm-pet-transport.test.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/coding-agent/test/modes/components/iterm-pet-transport.test.ts b/packages/coding-agent/test/modes/components/iterm-pet-transport.test.ts index 807685bf34..52370f1e55 100644 --- a/packages/coding-agent/test/modes/components/iterm-pet-transport.test.ts +++ b/packages/coding-agent/test/modes/components/iterm-pet-transport.test.ts @@ -349,10 +349,7 @@ describe("iTerm Pet transport", () => { expect(split(ack)).toEqual({ consume: true }); expect(split("\x1b]1337;Cap")).toEqual({ consume: true }); expect(split("abilities=F\x07")).toEqual({ consume: true }); -<<<<<<< HEAD expect(split("\x1b")).toEqual({ data: "\x1b" }); -======= ->>>>>>> 074002d17 (feat(tui): add stable iTerm2 pet rendering) }); it("classifies completed replies as missing F only when syntax is valid", async () => { From ede469c7ad75033e3b4010b6e640fa64f349695a Mon Sep 17 00:00:00 2001 From: snowykr Date: Sun, 2 Aug 2026 21:51:00 +0900 Subject: [PATCH 45/46] fix(iterm): restore prior plane between working GIF frames Working GIF frames use transparent pixels and iTerm rendered their restore-to-background disposal as an orange canvas during transcript scroll. Restore the prior terminal plane between working frames while leaving idle and non-iTerm artifacts unchanged. Lore-id: f7c2d4a1 Constraint: preserve native scrollback and resident raster lease Constraint: preserve idle GIF and Kitty/Sixel behavior Confidence: medium Scope-risk: narrow Reversibility: straightforward Tested: bun --cwd=packages/tui test test/gajae-pet.test.ts test/iterm2-protocol.test.ts test/fixed-suffix-scroll-region.test.ts test/raster-lease.test.ts test/render-commit.test.ts; bun --cwd=packages/tui run check; bun --cwd=packages/coding-agent test test/gajae-pet-widget.test.ts; bun --cwd=packages/coding-agent run check; git diff --check --- .../src/modes/components/gajae-pet-widget.ts | 1 + .../test/gajae-pet-widget.test.ts | 1 + packages/tui/src/components/gajae-pet.ts | 20 ++++++++++++++++--- packages/tui/test/gajae-pet.test.ts | 15 ++++++++++++++ 4 files changed, 34 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/src/modes/components/gajae-pet-widget.ts b/packages/coding-agent/src/modes/components/gajae-pet-widget.ts index 00769ede2a..dbcfc0f072 100644 --- a/packages/coding-agent/src/modes/components/gajae-pet-widget.ts +++ b/packages/coding-agent/src/modes/components/gajae-pet-widget.ts @@ -700,6 +700,7 @@ export class GajaePetWidget { const gif = getGajaePetGifCached({ skin: this.#mode === "off" ? "red" : this.#mode, timeline: frames, + disposal: animationPhase === "working" ? "restore-previous" : "restore-background", targetRows: PET_ART_ROWS, rectangle: { width: rect.width * cell.widthPx, height: rect.height * cell.heightPx }, // Reserve a three-cell canvas but keep the two-cell sprite vertically centered: diff --git a/packages/coding-agent/test/gajae-pet-widget.test.ts b/packages/coding-agent/test/gajae-pet-widget.test.ts index 8b2bfca3a8..83c5b7c968 100644 --- a/packages/coding-agent/test/gajae-pet-widget.test.ts +++ b/packages/coding-agent/test/gajae-pet-widget.test.ts @@ -1485,6 +1485,7 @@ describe("GajaePetWidget", () => { const expectedGif = getGajaePetGifCached({ skin: "red", timeline: [...workingTimeline(), { name: "base", delayMs: 700 }], + disposal: "restore-previous", targetRows: 2, rectangle: { width: request.rect.width * cell.widthPx, height: request.rect.height * cell.heightPx }, contentInset: { topPx: Math.floor(cell.heightPx / 2), bottomPx: Math.ceil(cell.heightPx / 2) }, diff --git a/packages/tui/src/components/gajae-pet.ts b/packages/tui/src/components/gajae-pet.ts index b9d0433a01..914f70f1ab 100644 --- a/packages/tui/src/components/gajae-pet.ts +++ b/packages/tui/src/components/gajae-pet.ts @@ -322,6 +322,8 @@ export interface GajaePetGifArtifact { readonly multipart: readonly string[]; readonly tmuxDcs: readonly string[]; } +export type GajaeGifDisposal = "restore-background" | "restore-previous"; + export interface GajaePetGifOptions { readonly skin?: PetSkinId; readonly timeline?: GajaeGifTimeline; @@ -331,6 +333,8 @@ export interface GajaePetGifOptions { readonly rectangle?: GajaeGifRectangle; readonly displaySize?: GajaeGifDisplaySize; readonly contentInset?: GajaeGifContentInset; + /** Controls how a transparent animated frame is removed before its successor. */ + readonly disposal?: GajaeGifDisposal; } const GIF_CLEAR = 256, GIF_END = 257; @@ -383,14 +387,21 @@ function isGifTimeline(input: GajaePetGifOptions | GajaeGifTimeline): input is G return Array.isArray(input); } function gifOptions(input: GajaePetGifOptions | GajaeGifTimeline): Required< - Pick + Pick > & { rectangle?: GajaeGifRectangle; displaySize?: GajaeGifDisplaySize; contentInset?: GajaeGifContentInset; } { if (isGifTimeline(input)) { - return { skin: "red", timeline: input, cellWidthPx: 1, cellHeightPx: 1, targetRows: 16 }; + return { + skin: "red", + timeline: input, + cellWidthPx: 1, + cellHeightPx: 1, + targetRows: 16, + disposal: "restore-background", + }; } return { skin: input.skin ?? "red", @@ -401,6 +412,7 @@ function gifOptions(input: GajaePetGifOptions | GajaeGifTimeline): Required< rectangle: input.rectangle, displaySize: input.displaySize, contentInset: input.contentInset, + disposal: input.disposal ?? "restore-background", }; } export function encodeGajaePetGif(input: GajaePetGifOptions | GajaeGifTimeline = {}): GajaePetGifArtifact { @@ -467,11 +479,12 @@ export function encodeGajaePetGif(input: GajaePetGifOptions | GajaeGifTimeline = pixels.push(ch === "." ? 0 : Math.max(1, paletteKeys.indexOf(ch) + 1)); } const delay = Math.round(frame.delayMs / 10); + const graphicsControlPacked = o.disposal === "restore-previous" ? 0x0d : 0x09; chunks.push( 33, 249, 4, - 0x09, + graphicsControlPacked, delay & 255, delay >> 8, 0, @@ -529,6 +542,7 @@ export function getGajaePetGifCached(input: GajaePetGifOptions | GajaeGifTimelin o.rectangle, o.displaySize, o.contentInset, + o.disposal, ]), hit = gifCache.get(key); if (hit) { diff --git a/packages/tui/test/gajae-pet.test.ts b/packages/tui/test/gajae-pet.test.ts index cec8da43f9..eba7bb6e9f 100644 --- a/packages/tui/test/gajae-pet.test.ts +++ b/packages/tui/test/gajae-pet.test.ts @@ -205,6 +205,21 @@ describe("GIF artifacts and helpers", () => { expect(red.multipart.slice(1, -1).every(record => record.length <= 220)).toBe(true); expect(red.tmuxDcs.every(record => Buffer.byteLength(record, "utf8") <= 256)).toBe(true); }); + it("uses restore-previous disposal for transparent animated frame compatibility", () => { + const artifact = encodeGajaePetGif({ + timeline: [ + { name: "danceL", delayMs: 300 }, + { name: "danceR", delayMs: 300 }, + ], + disposal: "restore-previous", + }); + const graphicsControlPacked = Array.from(artifact.bytes).flatMap((value, index) => + value === 0x21 && artifact.bytes[index + 1] === 0xf9 && artifact.bytes[index + 2] === 0x04 + ? [artifact.bytes[index + 3]!] + : [], + ); + expect(graphicsControlPacked).toEqual([0x0d, 0x0d]); + }); it("supports rectangle geometry and all public timeline helpers", () => { const rectangle = encodeGajaePetGif({ rectangle: { width: 7, height: 5 }, timeline: idleTimeline() }); From 127be4905455f14bf40920cc6759872daf2a6543 Mon Sep 17 00:00:00 2001 From: snowykr Date: Sun, 2 Aug 2026 22:02:27 +0900 Subject: [PATCH 46/46] test(iterm): prove delayed phase convergence An activity edge may finish its captured same-lease upload before the next timer tick corrects the resident GIF. Assert both artifacts so the bounded transition window cannot regress into a stale steady state. Lore-id: c4f7319a Constraint: preserve same-lease activity transitions Confidence: high Scope-risk: narrow Reversibility: straightforward Tested: bun --cwd=packages/coding-agent test test/gajae-pet-widget.test.ts; bun --cwd=packages/coding-agent run check; git diff --check --- .../test/gajae-pet-widget.test.ts | 32 +++++++++++++++---- 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/packages/coding-agent/test/gajae-pet-widget.test.ts b/packages/coding-agent/test/gajae-pet-widget.test.ts index 83c5b7c968..2347cbd280 100644 --- a/packages/coding-agent/test/gajae-pet-widget.test.ts +++ b/packages/coding-agent/test/gajae-pet-widget.test.ts @@ -1560,16 +1560,36 @@ describe("GajaePetWidget", () => { .map(record => new TextDecoder().decode(record)) .filter(record => record.includes("MultipartFile=")); expect(headers).toHaveLength(1); + const request = stubs.getRasterLeaseRequests()[0]; + expect(request).toBeDefined(); + if (request === undefined) throw new Error("Expected iTerm raster lease request"); + const cell = getCellDimensions(); + const gifOptions = { + skin: "red" as const, + targetRows: 2, + rectangle: { width: request.rect.width * cell.widthPx, height: request.rect.height * cell.heightPx }, + contentInset: { topPx: Math.floor(cell.heightPx / 2), bottomPx: Math.ceil(cell.heightPx / 2) }, + displaySize: { width: request.rect.width, height: request.rect.height }, + }; + const workingGif = getGajaePetGifCached({ + ...gifOptions, + timeline: [...workingTimeline(), { name: "base", delayMs: 700 }], + disposal: "restore-previous", + }); + const idleGif = getGajaePetGifCached({ + ...gifOptions, + timeline: [...idleTimeline(), { name: "base", delayMs: 700 }], + }); + const firstRecords = stubs.getRasterOutputs().map(record => new TextDecoder().decode(record)); + expect(firstRecords.slice(1, -1)).toEqual([...workingGif.multipart]); expect(stubs.getInvalidatedRasterLeases()).toHaveLength(0); vi.advanceTimersByTime(80); await flushAsyncChain(); - expect( - stubs - .getRasterOutputs() - .map(record => new TextDecoder().decode(record)) - .filter(record => record.includes("MultipartFile=")), - ).toHaveLength(2); + const records = stubs.getRasterOutputs().map(record => new TextDecoder().decode(record)); + const firstSubmissionLength = workingGif.multipart.length + 2; + expect(records.slice(firstSubmissionLength + 1, -1)).toEqual([...idleGif.multipart]); + expect(records.filter(record => record.includes("MultipartFile="))).toHaveLength(2); } finally { setVerifiedItermPetAvailability(undefined); stubs.widget.dispose();