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/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/coding-agent/src/modes/components/gajae-pet-widget.ts b/packages/coding-agent/src/modes/components/gajae-pet-widget.ts index 30eff97efc..dbcfc0f072 100644 --- a/packages/coding-agent/src/modes/components/gajae-pet-widget.ts +++ b/packages/coding-agent/src/modes/components/gajae-pet-widget.ts @@ -1,10 +1,10 @@ import { type AnimationRegistration, buildGajaePixelFrames, - burstTimeline, type CellRect, type Component, type Container, + type FixedSuffixScrollRegionToken, type GajaePixelFrameName, type GajaePixelFrames, getCellDimensions, @@ -187,6 +187,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 +250,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 +278,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 +305,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 +393,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 +443,23 @@ export class GajaePetWidget { } } + #releaseFixedSuffixScrollRegion(): void { + const token = this.#fixedSuffixScrollRegionToken; + this.#fixedSuffixScrollRegionToken = undefined; + 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; + this.#fixedSuffixScrollRegionToken = token; + if (this.#ui.armFixedSuffixScrollRegion(token, lease) === undefined) this.#releaseFixedSuffixScrollRegion(); + } + #mountEditor(framed: boolean): void { this.#editorContainer.clear(); this.#editorContainer.addChild(framed ? this.#framedEditor : this.#editor); @@ -476,8 +497,11 @@ export class GajaePetWidget { return "base"; } - #tickIterm(now: number): void { - if (!this.#isActiveOwner() || this.#ui.manualViewportActive) return; + #tickIterm(_now: number, working: boolean): void { + 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 +509,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 +524,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 +539,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,11 +564,20 @@ export class GajaePetWidget { height: pixelRows, }; const availability = getVerifiedItermPetAvailability(); - if (!availability?.available || getItermPetUnavailableReason() || !this.#ui.terminalAvailable) 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}`; - if (this.#itermSubmitPending || (semantic === this.#itermLastSemantic && this.#itermLease)) return; + if (!availability?.available || getItermPetUnavailableReason() || !this.#ui.terminalAvailable) { + this.#releaseFixedSuffixScrollRegion(); + return; + } + // 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); + return; + } this.#itermLastSemantic = semantic; this.#itermSubmitPending = true; const generation = this.#itermGeneration; @@ -551,8 +587,7 @@ export class GajaePetWidget { availability.epoch, availability.mode, semantic, - working, - flexing, + animationPhase, { columns: this.#ui.terminal.columns, rows: terminalRows, @@ -571,14 +606,12 @@ export class GajaePetWidget { epoch: number, mode: "direct" | "managed", semantic: string, - working: boolean, - flexing: boolean, + animationPhase: "idle" | "working", geometry: Readonly<{ columns: number; rows: number; cellWidthPx: number; cellHeightPx: number }>, composerBottomOffset: number, ): Promise { - const current = () => { + const currentGeometry = () => { const availability = getVerifiedItermPetAvailability(); - const flexingNow = this.#flexUntil > performance.now(); const terminal = this.#ui.terminal; const cell = getCellDimensions(); const liveComposerBottomOffset = this.#getComposerBottomOffset(); @@ -596,8 +629,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 && @@ -618,13 +649,14 @@ 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; } token = undefined; } - if (!current()) return; + if (!currentGeometry()) return; if (!token) { const acquired = await this.#ui.acquireRasterLease({ ownerId: this.#itermOwner, @@ -638,15 +670,17 @@ export class GajaePetWidget { ).join("")}`, ), }, + nativeScrollbackEligible: true, onInvalidated: notice => { if (this.#itermLease === notice.token) { + this.#releaseFixedSuffixScrollRegion(); this.#itermLease = undefined; - this.#itermLastSemantic = ""; } }, }); - if (!current() || acquired.status !== "acquired") { + if (!currentGeometry() || acquired.status !== "acquired") { + this.#releaseFixedSuffixScrollRegion(); if (acquired.status === "acquired") await this.#ui.invalidateRasterLease({ token: acquired.token, @@ -658,15 +692,15 @@ export class GajaePetWidget { this.#itermLease = token; } this.#itermLastSemantic = semantic; - const frames = flexing - ? burstTimeline(this.#mode === "off" ? "red" : this.#mode) - : working - ? workingTimeline() - : idleTimeline(); + 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, 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: @@ -698,23 +732,26 @@ 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) { this.#itermLease = undefined; } return; } + this.#releaseFixedSuffixScrollRegion(); + this.#armFixedSuffixScrollRegion(token); } #scheduleAutoFlex(now: number): void { if (!this.#autoFlexGapMs) return; @@ -755,7 +792,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 555a728ef1..2347cbd280 100644 --- a/packages/coding-agent/test/gajae-pet-widget.test.ts +++ b/packages/coding-agent/test/gajae-pet-widget.test.ts @@ -3,8 +3,11 @@ import { __animationSchedulerTestHooks, Container, getCellDimensions, + getGajaePetGifCached, + idleTimeline, setCellDimensions, type TUI, + workingTimeline, wrapITerm2RecordForTmux, } from "@gajae-code/tui"; import type { CustomEditor } from "../src/modes/components/custom-editor"; @@ -31,12 +34,15 @@ 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 }> = []; 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> = []; @@ -85,12 +91,32 @@ 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); + }, + 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; + }, acquireRasterLease: async (request: { 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 }, @@ -162,6 +188,8 @@ function makeStubs(columns = 80, rows = 30) { getInvalidatedRasterLeases: () => invalidatedRasterLeases, getRasterLeaseRequests: () => rasterLeaseRequests, getRasterCursorVisibilityRestores: () => rasterCursorVisibilityRestores, + getFixedSuffixScrollRegionOwnerCount: () => fixedSuffixScrollRegionOwners.size, + resetFixedSuffixScrollRegions: () => fixedSuffixScrollRegionOwners.clear(), getPendingRasterAcquireCount: () => rasterAcquireWaiters.length, setRasterAcquireDelayed: (value: boolean) => { delayRasterAcquire = value; @@ -1196,6 +1224,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. @@ -1220,7 +1249,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"); @@ -1302,6 +1331,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(); @@ -1397,7 +1427,39 @@ describe("GajaePetWidget", () => { stubs.widget.dispose(); } }); - it("runs scheduled auto-flex bursts on the iTerm raster path", async () => { + 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, { protocol: null, @@ -1415,19 +1477,32 @@ 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(2); - const sizes = headers.map(header => Number(/;size=(\d+);/u.exec(header)?.[1])); - expect(sizes[1]).toBeGreaterThan(sizes[0]); + 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 }], + 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) }, + 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(); } }); - 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 { @@ -1439,18 +1514,30 @@ 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(); } }); - 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, { @@ -1468,21 +1555,41 @@ 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); + 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(1); + 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(); @@ -1524,6 +1631,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 +1642,38 @@ 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(); + } + }); + 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(); @@ -1575,7 +1715,7 @@ describe("GajaePetWidget", () => { stubs.widget.dispose(); } }); - it("reuses one raster lease and applies cursor visibility for 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 }); @@ -1594,24 +1734,31 @@ 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 + .getRasterOutputs() + .map(record => new TextDecoder().decode(record)) + .filter(record => record.includes("MultipartFile=")), + ).toHaveLength(2); expect(stubs.getRasterCursorVisibilityRestores()).toEqual([true, true]); working = false; vi.advanceTimersByTime(80); await flushAsyncChain(); 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("replaces the managed iTerm GIF without blanking its footprint", 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 }); @@ -1638,7 +1785,7 @@ describe("GajaePetWidget", () => { stubs.widget.dispose(); } }); - it("settles after replacing the idle raster with the working raster", 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 }); 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. 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/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/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/src/tui.ts b/packages/tui/src/tui.ts index ba34b4f0b8..496eaf34ba 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,8 @@ export class TUI extends Container { token: RasterLeaseToken; erase: Uint8Array; callback?: (n: RasterLeaseInvalidatedNotification) => void; + nativeScrollbackEligible: boolean; + nativeScrollbackArmed: boolean; revoked: boolean; } >(); @@ -1023,7 +1026,11 @@ export class TUI extends Container { #fixedSuffixScrollRegionGeneration = 0; #fixedSuffixScrollRegionOwners = new Map(); #armedFixedSuffixScrollRegionToken: FixedSuffixScrollRegionToken | undefined; + #armedFixedSuffixScrollRegionRasterLease: RasterLeaseToken | undefined; #fixedSuffixScrollRegionResetPending = false; + #fixedSuffixScrollPlane: + | Readonly<{ token: FixedSuffixScrollRegionToken; upperBottom: number; transcriptTop: number }> + | undefined; #unsubscribeTabWidthChange?: () => void; static #renderCounters: TuiRenderCounterSnapshot = { @@ -1288,37 +1295,87 @@ 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; + } + if (this.#fixedSuffixScrollPlane?.token === token) { + this.#fixedSuffixScrollPlane = undefined; + this.#fixedSuffixScrollRegionResetPending = true; } } /** * 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; + 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) 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"); } #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; 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; @@ -1802,7 +1859,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" }; @@ -1824,6 +1882,8 @@ export class TUI extends Container { token, erase: new Uint8Array(request.erase.bytes), callback: request.onInvalidated, + nativeScrollbackEligible: request.nativeScrollbackEligible === true, + nativeScrollbackArmed: false, revoked: false, }); return { status: "acquired", token }; @@ -1899,6 +1959,10 @@ export class TUI extends Container { !op.shouldWrite() ) return { queueId: id, operation: op.type, status: "stale-token" }; + if ( + !this.#writeFixedSuffixResetBefore(bytes => this.#guardTerminalOperation(() => this.terminal.write(bytes))) + ) + return failed(); if (op.type === "raster-multipart-batch" && op.prefix !== undefined && op.afterPrefix !== undefined) { const prefixWritten = this.#guardTerminalOperation(() => this.terminal.write(new TextDecoder().decode(op.prefix)), @@ -1970,7 +2034,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, @@ -2129,7 +2193,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()]; @@ -2321,8 +2385,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 { @@ -2352,22 +2425,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 { @@ -2539,6 +2616,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; @@ -3847,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 = []; @@ -3990,6 +4072,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; @@ -4304,6 +4387,33 @@ 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; + if (preserveArmedFixedSuffixLease) { + if (this.#rasterCleanup.size > 0) { + void this.#rasterIngress.then(() => { + if (!this.#stopped && this.terminalAvailable) this.requestRender(); + }); + return; + } + if (newLines.some(line => TERMINAL.isImageLine(line))) return; + this.#fixedSuffixScrollPlane = undefined; + this.#fixedSuffixScrollRegionResetPending = true; + viewportRepaint(`armed fixed suffix lease blocked full render: ${reason}`); + return; + } if ( clear && !forceScrollbackClear && @@ -4717,14 +4827,246 @@ 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; } + 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) === + 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 || fixedSuffixTailRewrite) && + newLines + .slice(0, fixedSuffixTailRewrite ? previousTranscriptLineCount - 1 : previousTranscriptLineCount) + .every((line, index) => line === previousLogicalFrame[index]); + const fixedSuffixRasterLease = + fixedSuffixScrollRegionToken === undefined + ? undefined + : this.#rasterLeases.get(fixedSuffixScrollRegionToken.ownerId); + const fixedSuffixNativeAppendPreservesRasterLease = + fixedSuffixNativeAppend && + !newLines.some(line => TERMINAL.isImageLine(line)) && + this.#rasterCleanup.size === 0 && + !this.#fixedSuffixScrollRegionResetPending && + (this.#rasterLeases.size === 0 || + (this.#rasterLeases.size === 1 && + fixedSuffixScrollRegionToken !== undefined && + fixedSuffixScrollRegionRasterLease !== undefined && + fixedSuffixRasterLease?.token === fixedSuffixScrollRegionRasterLease && + fixedSuffixRasterLease.nativeScrollbackEligible && + fixedSuffixRasterLease.nativeScrollbackArmed && + fixedSuffixRasterLease.token.rect.row > 0)); + const fixedPlaneRequestedUpperBottom = + fixedSuffixRasterLease === undefined + ? 0 + : Math.min(height - nextSuffixLineCount, fixedSuffixRasterLease.token.rect.row); + 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) === + fixedSuffixScrollRegionToken && + fixedSuffixScrollRegionRasterLease !== undefined && + fixedSuffixRasterLease?.token === fixedSuffixScrollRegionRasterLease && + fixedSuffixRasterLease.nativeScrollbackEligible && + fixedSuffixRasterLease.nativeScrollbackArmed && + this.#rasterLeases.size === 1 && + this.#rasterCleanup.size === 0 && + this.#rasterPending === 0 && + fixedPlaneUpperBottom > 0 && + appendedLines && + 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 && + // 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) { + this.#fixedSuffixScrollPlane = undefined; + this.#fixedSuffixScrollRegionResetPending = true; + } + if (fixedPlaneEligible) { + const plane = previousFixedPlane; + const physicalTop = fixedPlanePhysicalTop; + const desiredTop = Math.max(0, nextTranscriptLineCount - fixedPlaneUpperBottom); + if (physicalTop > desiredTop) { + this.#fixedSuffixScrollPlane = undefined; + this.#fixedSuffixScrollRegionResetPending = true; + fullRender(true, "fixed scroll plane contraction"); + return; + } + + 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 + 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; + 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); + fixedPlaneBuffer += `\x1b8\x1b[r\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, physicalTop, newLines.length - 1); + this.#nativeScrollbackAdmissionPending = false; + this.#transcriptIdentityReplaced = false; + return; + } + 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; + 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 + // 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 && this.#rasterLeases.size > 0 && this.#rasterCleanup.size === 0 && !newLines.slice(Math.max(0, newLines.length - height)).some(line => TERMINAL.isImageLine(line)) @@ -4931,42 +5273,34 @@ 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) { - const regionBottom = height - nextSuffixLineCount; + 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 + // 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`; + 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 = + 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)) { + 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; @@ -4975,26 +5309,37 @@ 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); + }, + fixedSuffixNativeAppendPreservesRasterLease, + ) ) return; this.#fixedSuffixScrollRegionResetPending = false; 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; @@ -5030,6 +5375,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 && @@ -5325,7 +5671,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; + 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); @@ -5360,15 +5709,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 1cd453a1ba..1810f4e4c3 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 { @@ -19,15 +19,16 @@ class LinesComponent implements Component { function createPinnedTui( rows = 5, transcriptLines = ["line-1", `line-2${CURSOR_MARKER}`, "line-3"], -): { term: VirtualTerminal; transcript: LinesComponent; tui: TUI } { - const term = new VirtualTerminal(40, rows); + processTerminal = false, +): { term: VirtualTerminal; transcript: LinesComponent; suffix: LinesComponent; tui: TUI } { + const term = new VirtualTerminal(40, rows, { isProcessTerminal: processTerminal }); const tui = new TUI(term); const transcript = new LinesComponent(transcriptLines); const suffix = new LinesComponent(["status", "composer"]); tui.addChild(transcript); tui.addChild(suffix); tui.setBottomPinnedComponent(suffix); - return { term, transcript, tui }; + return { term, transcript, suffix, tui }; } describe("TUI fixed suffix scroll region", () => { @@ -79,7 +80,598 @@ 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("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("keeps an armed iTerm lease through a multipart barrier append", 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", "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", "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; + 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"]); + term.clearWriteLog(); + expect(tui.armFixedSuffixScrollRegion(token, lease.token)).toBeGreaterThan(0); + await term.waitForRender(); + + const output = term.getWriteLog().join(""); + expect(output).toContain("\x1b[1;2r"); + 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"); + 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"); + 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"]); + term.clearWriteLog(); + tui.requestRender(); + await term.waitForRender(); + const streamingOutput = term.getWriteLog().join(""); + 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); + 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"); + 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); + 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("\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"]); + term.clearWriteLog(); + tui.requestRender(); + await term.waitForRender(); + const postRewriteOutput = term.getWriteLog().join(""); + 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); + 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).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); + 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", + ]); + 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(); + 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"); + 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); + 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(); + await term.waitForRender(); + expect(term.getWriteLog().join("")).toContain("\x1b[r\x1b[?6l"); + } finally { + tui.stop(); + } + }); + it("keeps an ineligible raster lease on its generic renderer path", 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).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(); + } + }); + 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)).toBeUndefined(); + tui.requestRender(); + 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("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("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(); + 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(); + setTerminalImageProtocol(previousImageProtocol); + } + }); it("uses the existing renderer unless a current owner arms the fixed suffix region", async () => { const { term, transcript, tui } = createPinnedTui(); try { @@ -132,15 +724,134 @@ 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(); }); + 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); + 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(); + } + }); + 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(); + } + }); + 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(); + } + }); }); 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`); }); diff --git a/packages/tui/test/gajae-pet.test.ts b/packages/tui/test/gajae-pet.test.ts index 756224e3d9..eba7bb6e9f 100644 --- a/packages/tui/test/gajae-pet.test.ts +++ b/packages/tui/test/gajae-pet.test.ts @@ -199,12 +199,27 @@ 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); 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() }); 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 { diff --git a/packages/tui/test/raster-lease.test.ts b/packages/tui/test/raster-lease.test.ts index e02db6c74f..3c0baf94bf 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() {} }; @@ -445,6 +486,41 @@ describe("TUI raster lease public boundary", () => { expect(calls).toBe(0); tui.stop(); }); + 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; + const component: Component = { render: () => lines, invalidate() {} }; + 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++), + nativeScrollbackEligible: true, + }); + expect(lease.status).toBe("acquired"); + terminal.clearWriteLog(); + lines = [...lines, "five"]; + tui.requestRender(); + await terminal.waitForRender(); + + const output = terminal.getWriteLog().join(""); + expect(output).not.toContain("ERASE"); + expect(output).not.toContain("\r\n"); + expect(calls).toBe(0); + await terminal.flush(); + expect(terminal.getScrollBuffer()).toHaveLength(initialScrollbackLength); + + lines = [...lines, "six"]; + terminal.clearWriteLog(); + tui.requestRender(); + await terminal.waitForRender(); + expect(terminal.getWriteLog().join("")).not.toContain("ERASE"); + expect(calls).toBe(0); + tui.stop(); + }); it("repaints rewritten streaming output without scrolling an active raster", async () => { const { tui, terminal } = await setup(); let lines = ["one", "two", "three", "four"];