diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 70e001498c..934f04e136 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -21,7 +21,7 @@ ### Added -- Gajae Pet now renders in iTerm2 using bounded inline PNG frames, with the same reserved composer-side layout and lifecycle cleanup used by Kitty and Sixel. +- Gajae Pet now renders in iTerm2 through a bounded inline GIF protected from ordinary TUI redraws by a reserved raster lease, with the same composer-side layout and lifecycle cleanup guarantees used by Kitty and Sixel. - Added the bundled `ouroboros` dark theme, translating the official navy, teal, green, and gold identity into terminal-safe semantic colors while retaining the pet's vivid `#AEE80E` lime and `#7092BE` cool-scale accents. Live previews now recolor the open `/theme` selector and Settings theme submenu instead of leaving their construction-time theme visible, and confirmation consistently settles the preview into the active appearance mapping. - Added the 16×16 `Ouroboros` terminal pet: a vivid lime snake with a cool `#7092BE` underside that rests in a soft coil, blinks, flicks its tongue upward, and occasionally sobs with `><` eyes. Its signature flex rolls the same silhouette through a symmetric circle into an exact 180-degree heart pose, blinks a small pink heart twice, and reuses the authored frames in reverse; agent work enters and exits a stable six-frame infinity loop through explicit unwind transitions. Pet skins now own their frame registry, source resolution, idle loop, work transitions, work loop, and signature burst; a saved skin removed by a later installation falls back to RedGajae while an explicit `off` remains off. - `subagent await` now emits periodic liveness even when the awaited subagent's rendered-state signature is stable, so a healthy long wait is never indistinguishable from a hung session. A bounded liveness interval (floor 15s, derived from `heartbeat_ms`) force-re-emits `onUpdate` through the existing update channel, preserving immediate emission on genuine progress changes. Long silent `sleep N` (N ≥ 120s) in bash now appends a one-time advisory notice recommending `subagent await` or `job poll` instead — purely advisory, never blocking (#4465). @@ -31,7 +31,12 @@ - `browser` Chrome profile mode (`app.browser: "chrome"`) now defaults an omitted `path` to installed Chrome/Chromium and an omitted `profile_directory` to `"Default"`. `user_data_dir` remains explicit and must be non-default because Chrome 136+ disables remote debugging for default Chrome data directories; Stable/Beta/Dev/Canary/Chromium roots (including trusted Linux environment, Flatpak, and Snap defaults), symlink aliases to them, and non-Chrome browser executables are rejected with remediation instead of timing out or risking cross-brand profile access. Profile and Windows executable discovery use trusted environment sources that ignore repository `.env`, platform path semantics are preserved, executable/profile-root canonicalization races stalled filesystem work against cancellation, the canonical Snap Chromium launcher is admitted only when it resolves to `/usr/bin/snap`, and wrapper-launched Linux Chrome processes are reused through an abort-aware asynchronous `/proc` scan only after kernel executable identity plus exact profile/loopback-CDP guards pass. Cancellation propagates before, during, and after CDP probing, and omitted profile names render as `Default` in the TUI. - Defense-in-depth: when an Anthropic-origin assistant transcript message carrying directly adjacent `thinking`/`redacted_thinking` blocks is persisted, a single bounded warn is emitted per session manager instance — but only in development/test builds, never in production. The diagnostic names only the envelope shape (block count, adjacency presence, provider), never raw thinking text, signatures, redacted payloads, or transcript-path metadata. Storage is never mutated — the send-boundary collapse remains the wire source of truth; this is a read-only observation that helps surface upstream producers of the rejected shape (#4443). ### Fixed -- Gajae Pet now keeps its intended two-row footprint in iTerm2 across terminal font, Retina, and cell-geometry changes instead of rendering as a fixed undersized pixel box. +- Ouroboros now keeps its own authored base, idle, work-transition, work, and burst frames across Sixel, Kitty, and iTerm2 rendering. Marked iTerm pet drag paths are also discarded in Bash and Python composer modes instead of being inserted literally. +- iTerm2 pet animation now stays silent while the TUI is temporarily stopped for an external editor or todo edit, preventing raster writes from corrupting the child terminal session. Its unavailable-capability deadline now begins after iTerm probing starts rather than during initialization work. +- Forced Kitty Gajae Pet graphics remain unavailable inside tmux, screen, and zellij; forced Sixel and verified iTerm2 transport keep their existing behavior. +- iTerm2 Gajae Pet rendering now uploads an animated GIF only when its meaningful state changes and holds a raster lease across ordinary TUI redraws, preventing work-animation flicker and transient composer-background frames. +- Dragging an iTerm2 Gajae Pet image no longer pastes its temporary named image pathname into the composer. Pet frames identify their generated image by name, and the paste controller consumes only that marked pathname while preserving native iTerm dragging and all other image-path pastes. +- Gajae Pet now keeps its intended two-row art footprint in iTerm2 across terminal font, Retina, and cell-geometry changes instead of rendering as a fixed undersized pixel box. - Post-merge repair for #4542: `CHAT_DAEMON_GENERATIONS.discord` 64→65 and `.slack` 67→68 so the `SessionRouter` initial attachment replay change is generation-fenced for already-running Discord and Slack daemons. The semantic guard manifest is regenerated with the corrected generation and declaration digests. - Cursor split responses now preserve canonical provider order across pre-admission artifact spilling, so a server-side tool result cannot be persisted after the assistant continuation that follows it. The canonical admission reservation is owned by each emission's own handler, so a host bridge or replay emitting the same `message_end` event object twice can no longer overwrite the reservation and deadlock every later canonical admission (#4536). 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 3813a0fca2..527438358c 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,14 @@ import { type AnimationRegistration, buildGajaePixelFrames, + type CellRect, type Component, type Container, + type GajaeGifFrame, + type GajaeGifTimeline, type GajaePixelFrames, getCellDimensions, + getGajaePetGifCached, PET_SKINS, type PetBurst, type PetFrameName, @@ -12,12 +16,13 @@ import { type PetSkinId, petBurstDurationMs, petBurstFrame, + type RasterLeaseToken, registerAnimationCallback, - resolvePetMode, type TUI, + wrapITerm2RecordForTmux, } from "@gajae-code/tui"; import type { CustomEditor } from "./custom-editor"; -import { getPetPixelProtocol } from "./pet-capability"; +import { getItermPetUnavailableReason, getPetPixelProtocol, getVerifiedItermPetAvailability } from "./pet-capability"; /** Re-exported from the tui skin registry so widget-relative imports stay valid. */ export type { PetMode, PetSkinId }; @@ -38,6 +43,8 @@ const KITTY_DROP_FRACTION = 0.45; const petKittyDropPx = (cellHeightPx: number): number => Math.min(Math.max(0, cellHeightPx - 1), Math.floor(cellHeightPx * KITTY_DROP_FRACTION)); const PET_RAISE_ROWS = 1; +const PET_ART_ROWS = 2; +const ITERM_CANVAS_ROWS = PET_ART_ROWS + 1; const allocatedPetKittyImageIds = new Set(); function allocatePetKittyImageId(): number { @@ -60,17 +67,22 @@ function sameFootprint(left: SixelFootprint, right: SixelFootprint): boolean { return left.x === right.x && left.y === right.y && left.columns === right.columns && left.rows === right.rows; } +type PetOverlayEmission = { + payload: string; + onWritten?: () => void; +}; + /** * Which widget currently owns each TUI's single shared post-render emitter * slot. A stale or repeated dispose (or off-switch) of a predecessor widget * must never clear a successor's overlay authority. */ const petOverlayEmitterOwners = new WeakMap(); +const petOverlayOwnershipEpochs = new WeakMap(); -/** Random gap between automatic claw flexes (fires while idle AND working). */ +/** Random gap between automatic claw flexes while work is active. */ const AUTO_FLEX_MIN_GAP_MS = 12_000; const AUTO_FLEX_MAX_GAP_MS = 40_000; - function animationFrameAt( steps: ReadonlyArray, now: number, @@ -102,6 +114,27 @@ function animationDuration(steps: ReadonlyArray return steps.reduce((sum, [, ms]) => sum + ms, 0); } +function gifTimeline(steps: ReadonlyArray): GajaeGifFrame[] { + return steps.map(([name, delayMs]) => ({ name, delayMs })); +} + +function burstGifTimeline(burst: PetBurst): GajaeGifTimeline { + const frames = gifTimeline(burst.intro); + const tail = burst.tail; + if (!tail || tail.frames.length === 0) return frames; + for (let elapsed = 0; elapsed < tail.ms; elapsed += tail.stepMs) { + frames.push({ + name: tail.frames[Math.floor(elapsed / tail.stepMs) % tail.frames.length]!, + delayMs: Math.min(tail.stepMs, tail.ms - elapsed), + }); + } + return frames; +} + +function timelineSignature(frames: GajaeGifTimeline): string { + return frames.map(({ name, delayMs }) => `${name}:${delayMs}`).join(","); +} + interface WorkTransition { startedAt: number; steps: ReadonlyArray; @@ -110,18 +143,10 @@ interface WorkTransition { const OUROBOROS_WORK_BURST_MIN_GAP_MS = 14_000; const OUROBOROS_WORK_BURST_MAX_GAP_MS = 30_000; -/** - * Selector preview: fire the first burst this soon after a skin is previewed, so the - * pet shows one idle eye-roll (base -> gazeL -> base -> gazeR) and then its signature - * flex/cry. Live use keeps the random AUTO_FLEX gap; only preview forces this demo. - */ +/** Selector preview: fire the first burst this soon after a skin is previewed. */ const PREVIEW_INTRO_MS = 2300; -/** - * Wraps the composer editor, reserving a right-side area beside it where the - * real-pixel pet is drawn. The editor just renders narrower; the pet pixels - * are emitted separately as an absolute-positioned overlay. - */ +/** Reserve a right-side area beside the composer where the pixel pet is drawn. */ export class PetFramedEditor implements Component { #editor: CustomEditor; #reserve = 0; @@ -143,27 +168,12 @@ export class PetFramedEditor implements Component { } render(width: number): string[] { - if (!this.canFit(width)) { - return this.#editor.render(width); - } + if (!this.canFit(width)) return this.#editor.render(width); return this.#editor.render(width - this.#reserve); } } -/** - * The gajae pet: a real-pixel sprite living in a reserved area beside the - * composer. It is nearest-neighbor scaled to the two terminal rows occupied - * by an empty one-line composer and lifted one row so its feet meet the input - * box's bottom edge. - * - * Rendering has two paths that share one payload builder: - * - a post-render emitter re-draws the sprite after every TUI write (line - * renders clear the pet cells, so the overlay must be re-applied), and - * - frame advances write the payload directly to the terminal, because the - * TUI skips writes entirely when no component line changed. - * - * Requires a Sixel-, Kitty-, or iTerm2-graphics terminal (`pixelProtocol()`). - */ +/** The gajae pet pixel sprite and protocol-specific rendering lifecycle. */ export class GajaePetWidget { #ui: TUI; #editor: CustomEditor; @@ -183,25 +193,28 @@ export class GajaePetWidget { #workTransition: WorkTransition | undefined; #nextWorkBurstAt = 0; #workBurstIndex = 0; + #flexSource: "preview" | "working" | undefined; + #previewFlexAt = 0; #autoFlexGapMs: [number, number] | null; - #forcedProtocol: "sixel" | "kitty" | "iterm2" | undefined; - /** Cell metrics the current frames were built for; a change triggers a rebuild. */ + #forcedProtocol: "sixel" | "kitty" | "iterm" | undefined; #builtCellW = 0; #builtCellH = 0; #kittyImageId: number | undefined; - /** True while a kitty placement may exist on screen; cleared only after the delete escape is delivered. */ #kittyCleanupPending = false; - /** Last emitted Sixel raster position; retained until an erase is actually delivered. */ + #kittyCleanupGeneration = 0; #lastSixelFootprint: SixelFootprint | undefined; - /** Terminal state: a disposed widget never touches the TUI or shared slots again. */ #disposed = false; - /** - * True while the previous overlay frame carried the cleanup payload. The - * TUI writes the frame after the emitter returns, so delivery is - * acknowledged only on the next emitter pass — and only while the terminal - * stayed available, since a failed render write drops availability. - */ - #frameCleanupAwaitingAck = false; + #ownedOverlayEpoch = 0; + #itermLease: RasterLeaseToken | undefined; + #disposePromise: Promise | undefined; + #disposeRasterBarrier: Promise = Promise.resolve(); + #disposeNeedsLifecycle = false; + #itermProtocol = false; + #itermLastSemantic = ""; + #itermOwner = `gajae-pet-${Math.random().toString(36).slice(2)}`; + #itermGeneration = 0; + #itermSubmitPending = false; + #syncManagedItermCursor: (row: number, column: number) => Promise; constructor(options: { ui: TUI; @@ -209,10 +222,9 @@ export class GajaePetWidget { editorContainer: Container; floorContainer: Container; isWorking: () => boolean; - /** Rows rendered below the composer box (pet floor + hook widgets). */ getComposerBottomOffset: () => number; - forcePixelProtocol?: "sixel" | "kitty" | "iterm2"; - /** Random [min, max] ms between auto-flexes; null disables. */ + syncManagedItermCursor: (row: number, column: number) => Promise; + forcePixelProtocol?: "sixel" | "kitty"; autoFlexGapMs?: [number, number] | null; }) { this.#ui = options.ui; @@ -222,13 +234,13 @@ export class GajaePetWidget { this.#framedEditor = new PetFramedEditor(options.editor); this.#isWorking = options.isWorking; this.#getComposerBottomOffset = options.getComposerBottomOffset; + this.#syncManagedItermCursor = options.syncManagedItermCursor; this.#forcedProtocol = options.forcePixelProtocol; this.#autoFlexGapMs = options.autoFlexGapMs === undefined ? [AUTO_FLEX_MIN_GAP_MS, AUTO_FLEX_MAX_GAP_MS] : options.autoFlexGapMs; } - /** Protocol available for the real-pixel pet, if any. */ - static pixelProtocol(): "sixel" | "kitty" | "iterm2" | null { + static pixelProtocol(): "sixel" | "kitty" | "iterm" | null { return getPetPixelProtocol(); } @@ -240,19 +252,25 @@ export class GajaePetWidget { return performance.now() < this.#flexUntil; } - setMode(mode: string): void { - this.#applyMode(resolvePetMode(mode), true); + setMode(mode: PetMode): void { + this.#applyMode(mode, true); + } + + async suspendItermCapability(): Promise { + if (!this.#isActiveOwner()) return; + this.#itermGeneration++; + const lease = this.#itermLease; + this.#itermLease = undefined; + this.#itermLastSemantic = ""; + if (lease) await this.#ui.invalidateRasterLease({ token: lease, cause: "capability-loss" }); + this.#ui.requestRender(true); } - /** Live preview during a selector: change the sprite without re-mounting the - * composer editor (that would tear down the open overlay). After a short idle - * eye-roll it fires the signature burst once (RedGajae flex, BlueGajae para-para - * then sob) so the selector demos the animation instead of waiting the random gap. */ previewMode(mode: PetMode): void { + if (this.#disposed) return; this.#applyMode(mode, false); - if (mode !== "off" && this.#autoFlexGapMs) { - this.#nextAutoFlexAt = performance.now() + PREVIEW_INTRO_MS; - } + if (!this.#isActiveOwner() || mode === "off" || !this.#autoFlexGapMs) return; + this.#previewFlexAt = performance.now() + PREVIEW_INTRO_MS; } commitPreviewMode(mode: PetMode): void { @@ -261,18 +279,25 @@ export class GajaePetWidget { #applyMode(mode: PetMode, mountComposer: boolean): void { if (this.#disposed || mode === this.#mode) return; - if (mode === "off") { - this.#writeImageCleanup(); + if (!this.#canMutateSharedUi()) return; + this.#itermGeneration++; + if (this.#itermLease) { + void this.#ui.invalidateRasterLease({ token: this.#itermLease, cause: "mode-off" }); + this.#itermLease = undefined; + } + this.#itermLastSemantic = ""; + this.#itermProtocol = false; + this.#queueImageCleanup(true); this.#mode = "off"; this.#animation?.unregister(); this.#animation = undefined; this.#releaseOverlayEmitter(); this.#floorContainer.clear(); this.#pixel = undefined; + this.#activeBurst = undefined; this.#workTransition = undefined; this.#nextWorkBurstAt = 0; - this.#activeBurst = undefined; this.#framedEditor.setReserve(0); if (mountComposer) this.#mountEditor(false); this.#ui.requestRender(true); @@ -280,94 +305,136 @@ export class GajaePetWidget { } const protocol = this.#forcedProtocol ?? GajaePetWidget.pixelProtocol(); - if (!protocol) return; - if (this.#mode !== "off") this.#writeImageCleanup(); + const ownershipEpoch = petOverlayOwnershipEpochs.get(this.#ui) ?? 0; + if (!protocol || (this.#ownedOverlayEpoch !== 0 && this.#ownedOverlayEpoch < ownershipEpoch)) return; + const predecessor = petOverlayEmitterOwners.get(this.#ui); + if (predecessor && predecessor !== this) predecessor.#retireForSuccessor(); + this.#itermGeneration++; + if (this.#itermLease) { + void this.#ui.invalidateRasterLease({ token: this.#itermLease, cause: "explicit" }); + this.#itermLease = undefined; + } + this.#itermLastSemantic = ""; + if (this.#mode !== "off") { + const releasesKittyImage = this.#pixel?.protocol === "kitty" && protocol !== "kitty"; + this.#queueImageCleanup(releasesKittyImage); + } this.#mode = mode; this.#frame = PET_SKINS[mode].baseFrame; this.#flexUntil = 0; this.#activeBurst = undefined; - this.#nextAutoFlexAt = 0; + this.#flexSource = undefined; this.#working = this.#isWorking(); this.#workTransition = undefined; this.#nextWorkBurstAt = 0; this.#workBurstIndex = 0; + this.#previewFlexAt = 0; + this.#nextAutoFlexAt = 0; this.#buildPixel(protocol); if (mountComposer) this.#mountEditor(true); - // The pet overlays the composer's bottom rows; no floor row is reserved, so - // the composer stays pinned to the terminal bottom. this.#floorContainer.clear(); - this.#ui.setPostRenderEmitter(() => this.#overlayPayload()); + this.#ui.setPostRenderEmitter(() => this.#overlayEmission()); + this.#ownedOverlayEpoch = ownershipEpoch + 1; + petOverlayOwnershipEpochs.set(this.#ui, this.#ownedOverlayEpoch); petOverlayEmitterOwners.set(this.#ui, this); this.#animation ??= registerAnimationCallback(now => this.#tick(now), 80); this.#ui.requestRender(true); } - /** (Re)build the encoded frames for the current terminal cell metrics. */ - #buildPixel(protocol: "sixel" | "kitty" | "iterm2"): void { + #isActiveOwner(): boolean { + return !this.#disposed && petOverlayEmitterOwners.get(this.#ui) === this; + } + + #canMutateSharedUi(): boolean { + const owner = petOverlayEmitterOwners.get(this.#ui); + return ( + owner === this || + (owner === undefined && + this.#ownedOverlayEpoch !== 0 && + this.#ownedOverlayEpoch === (petOverlayOwnershipEpochs.get(this.#ui) ?? 0)) + ); + } + + #retireForSuccessor(): void { + this.dispose(); + } + + #buildPixel(protocol: "sixel" | "kitty" | "iterm"): void { const cell = getCellDimensions(); this.#builtCellW = cell.widthPx; this.#builtCellH = cell.heightPx; const skin: PetSkinId = this.#mode === "off" ? "red" : this.#mode; if (protocol === "kitty") { this.#kittyImageId ??= allocatePetKittyImageId(); + this.#kittyCleanupGeneration++; this.#kittyCleanupPending = true; } + if (protocol === "iterm") { + this.#itermProtocol = true; + this.#pixel = undefined; + this.#framedEditor.setReserve(Math.max(1, Math.ceil((2 * cell.heightPx) / cell.widthPx)) + PET_SIDE_MARGIN); + return; + } + this.#itermProtocol = false; this.#pixel = buildGajaePixelFrames({ protocol, skin, cellWidthPx: cell.widthPx, cellHeightPx: cell.heightPx, - // Every protocol uses the same two-row geometry contract. iTerm2 emits - // the derived cell block rather than fixed pixels so Retina/font changes - // cannot shrink the visible footprint. targetRows: 2, sixelTopPaddingPx: protocol === "sixel" ? PET_SIXEL_DROP_PX : 0, kittyCellYOffsetPx: protocol === "kitty" ? petKittyDropPx(cell.heightPx) : 0, kittyImageId: protocol === "kitty" ? this.#kittyImageId : undefined, - // Keep the iTerm2 raster inside the same two-row footprint reserved for - // Kitty and Sixel; horizontal cell rounding is padded by the builder. - iterm2TopPaddingPx: 0, - iterm2BottomPaddingPx: 0, }); this.#framedEditor.setReserve(this.#pixel.columns + PET_SIDE_MARGIN); } dispose(): void { if (this.#disposed) return; + const canMutateSharedUi = this.#canMutateSharedUi(); + this.#disposeNeedsLifecycle = canMutateSharedUi; this.#disposed = true; - const kittyImageId = this.#kittyImageId; - const cleanupPayload = this.#imageCleanupPayload(); - try { - if (cleanupPayload) { - this.#ui.queueTerminalCleanup( - `\x1b[?2026h\x1b7${cleanupPayload}\x1b8\x1b[?2026l`, - kittyImageId === undefined ? undefined : () => allocatedPetKittyImageIds.delete(kittyImageId), - ); - } else if (kittyImageId !== undefined) { - allocatedPetKittyImageIds.delete(kittyImageId); - } - this.#consumeCleanupAuthority(); - this.#kittyImageId = undefined; - } finally { - this.#animation?.unregister(); - this.#animation = undefined; - this.#releaseOverlayEmitter(); - this.#mode = "off"; - this.#pixel = undefined; - this.#workTransition = undefined; - this.#nextWorkBurstAt = 0; - this.#activeBurst = undefined; + this.#itermGeneration++; + const lease = this.#itermLease; + this.#itermLease = undefined; + if (lease) + this.#disposeRasterBarrier = this.#ui + .invalidateRasterLease({ token: lease, cause: "dispose" }) + .then(() => undefined); + const cleanupBarrier = canMutateSharedUi ? this.#queueImageCleanup(true) : this.#queueImageCleanup(true, false); + this.#disposeRasterBarrier = Promise.all([this.#disposeRasterBarrier, cleanupBarrier]).then(() => undefined); + this.#animation?.unregister(); + this.#animation = undefined; + this.#releaseOverlayEmitter(); + this.#mode = "off"; + this.#pixel = undefined; + this.#activeBurst = undefined; + this.#workTransition = undefined; + this.#nextWorkBurstAt = 0; + if (canMutateSharedUi) { this.#floorContainer.clear(); this.#framedEditor.setReserve(0); - // Restore the plain composer only while our framed wrapper is still - // mounted; a successor widget may already own the editor container. - if (this.#editorContainer.children.includes(this.#framedEditor)) { - this.#mountEditor(false); - } + if (this.#editorContainer.children.includes(this.#framedEditor)) this.#mountEditor(false); + } + } + + async disposeAsync(): Promise { + if (!this.#disposePromise) { + this.dispose(); + if (!this.#disposeNeedsLifecycle) return; + this.#disposePromise = this.#disposeRasterBarrier + .then(() => + this.#ui.notifyTerminalLifecycle({ + kind: "explicit-cleanup", + source: "interactive-mode", + terminalGeneration: this.#ui.terminalGeneration, + }), + ) + .then(() => undefined); } + await this.#disposePromise; } - /** Clear the shared post-render slot only while this widget still owns it. */ #releaseOverlayEmitter(): void { if (petOverlayEmitterOwners.get(this.#ui) === this) { this.#ui.setPostRenderEmitter(undefined); @@ -380,15 +447,13 @@ export class GajaePetWidget { this.#editorContainer.addChild(framed ? this.#framedEditor : this.#editor); } - /** Re-mount the composer editor (framed when a skin is active) after an overlay. */ remountComposer(): void { - this.#mountEditor(this.#mode !== "off"); + if (this.#canMutateSharedUi()) this.#mountEditor(this.#mode !== "off"); } - #pickFrame(now: number): PetFrameName { - const mode = this.#mode; - if (mode === "off") return "base"; - const skin = PET_SKINS[mode]; + #syncWorkingState(now: number): PetSkinId | undefined { + if (this.#mode === "off") return undefined; + const skin = PET_SKINS[this.#mode]; const working = this.#isWorking(); if (working !== this.#working) { this.#working = working; @@ -398,26 +463,258 @@ export class GajaePetWidget { const steps = working ? skin.workEnter : skin.workExit; this.#workTransition = steps?.length ? { startedAt: now, steps } : undefined; } + return this.#mode; + } + + #pickFrame(now: number): PetFrameName { + const mode = this.#mode; + if (mode === "off") return "base"; + const skin = PET_SKINS[mode]; + this.#syncWorkingState(now); if (this.#workTransition) { const elapsed = now - this.#workTransition.startedAt; const duration = animationDuration(this.#workTransition.steps); - if (elapsed < duration) { - return animationFrameAtElapsed(this.#workTransition.steps, elapsed, skin.baseFrame); - } + if (elapsed < duration) return animationFrameAtElapsed(this.#workTransition.steps, elapsed, skin.baseFrame); this.#workTransition = undefined; } - // Random idle burst → the skin's own animation, driven by its burst descriptor - // (RedGajae holds a flex; BlueGajae dances the para-para then sobs). if (now < this.#flexUntil) { const burst = this.#activeBurst ?? skin.burst; const elapsed = now - (this.#flexUntil - petBurstDurationMs(burst)); return petBurstFrame(burst, elapsed, now); } - // Working → loop the selected skin's activity animation. - if (this.#isWorking()) { - return animationFrameAt(skin.work, now, skin.baseFrame); + return this.#isWorking() + ? animationFrameAt(skin.work, now, skin.baseFrame) + : animationFrameAt(skin.idle, now, skin.baseFrame); + } + + #itermTimeline(now: number, working: boolean, flexing: boolean): GajaeGifTimeline { + if (this.#mode === "off") return gifTimeline([]); + this.#syncWorkingState(now); + const skin = PET_SKINS[this.#mode]; + if (flexing) return burstGifTimeline(this.#activeBurst ?? skin.burst); + if (this.#workTransition) { + const elapsed = now - this.#workTransition.startedAt; + if (elapsed < animationDuration(this.#workTransition.steps)) return gifTimeline(this.#workTransition.steps); + this.#workTransition = undefined; + } + return gifTimeline(working ? skin.work : skin.idle); + } + + #tickIterm(now: number): void { + if (!this.#ui.isRunning || !this.#isActiveOwner() || this.#ui.manualViewportActive) return; + const cell = getCellDimensions(); + const pixelColumns = Math.max(1, Math.ceil((PET_ART_ROWS * cell.heightPx) / cell.widthPx)); + const pixelRows = ITERM_CANVAS_ROWS; + let metricsChanged = false; + if (cell.widthPx !== this.#builtCellW || cell.heightPx !== this.#builtCellH) { + metricsChanged = true; + this.#itermGeneration++; + const lease = this.#itermLease; + this.#itermLease = undefined; + if (lease) void this.#ui.invalidateRasterLease({ token: lease, cause: "resize" }); + this.#itermLastSemantic = ""; + this.#builtCellW = cell.widthPx; + this.#builtCellH = cell.heightPx; + this.#framedEditor.setReserve(pixelColumns + PET_SIDE_MARGIN); + this.#ui.requestRender(true); + } + if (!this.#framedEditor.canFit(this.#ui.terminal.columns)) { + if (!metricsChanged) { + this.#itermGeneration++; + const lease = this.#itermLease; + this.#itermLease = undefined; + if (lease) void this.#ui.invalidateRasterLease({ token: lease, cause: "resize" }); + } + this.#itermLastSemantic = ""; + return; + } + const terminalRows = this.#ui.terminal.rows; + if (terminalRows < ITERM_CANVAS_ROWS + PET_RAISE_ROWS) { + if (!metricsChanged) { + this.#itermGeneration++; + const lease = this.#itermLease; + this.#itermLease = undefined; + if (lease) void this.#ui.invalidateRasterLease({ token: lease, cause: "resize" }); + } + this.#itermLastSemantic = ""; + return; + } + const composerBottom = terminalRows - this.#getComposerBottomOffset(); + const desiredRow = composerBottom - pixelRows; + const maxSafeRow = + terminalRows === ITERM_CANVAS_ROWS + PET_RAISE_ROWS + ? terminalRows - pixelRows - PET_RAISE_ROWS + : terminalRows - pixelRows; + const rect: CellRect = { + column: Math.max(0, this.#ui.terminal.columns - pixelColumns - PET_SIDE_MARGIN), + row: Math.max(0, Math.min(desiredRow, maxSafeRow)), + width: pixelColumns, + height: pixelRows, + }; + const availability = getVerifiedItermPetAvailability(); + if (!availability?.available || getItermPetUnavailableReason() || !this.#ui.terminalAvailable) return; + this.#syncWorkingState(now); + const working = this.#isWorking(); + const flexing = this.#flexUntil > now; + const frames = this.#itermTimeline(now, working, flexing); + const semantic = `${this.#mode}:${availability.mode}:${availability.epoch}:${working}:${flexing}:${timelineSignature(frames)}:${rect.column},${rect.row}:${cell.widthPx},${cell.heightPx}:${this.#ui.terminal.columns},${this.#ui.terminal.rows}`; + if (this.#itermSubmitPending || (semantic === this.#itermLastSemantic && this.#itermLease)) return; + this.#itermLastSemantic = semantic; + this.#itermSubmitPending = true; + const generation = this.#itermGeneration; + void this.#submitIterm( + rect, + generation, + availability.epoch, + availability.mode, + semantic, + working, + flexing, + frames, + { + columns: this.#ui.terminal.columns, + rows: terminalRows, + cellWidthPx: cell.widthPx, + cellHeightPx: cell.heightPx, + }, + this.#getComposerBottomOffset(), + ).finally(() => { + this.#itermSubmitPending = false; + if (!this.#itermLease) this.#itermLastSemantic = ""; + }); + } + + async #submitIterm( + rect: CellRect, + generation: number, + epoch: number, + mode: "direct" | "managed", + semantic: string, + working: boolean, + flexing: boolean, + frames: GajaeGifTimeline, + geometry: Readonly<{ columns: number; rows: number; cellWidthPx: number; cellHeightPx: number }>, + composerBottomOffset: number, + ): Promise { + const current = () => { + const availability = getVerifiedItermPetAvailability(); + const flexingNow = this.#flexUntil > performance.now(); + const terminal = this.#ui.terminal; + const cell = getCellDimensions(); + const liveComposerBottomOffset = this.#getComposerBottomOffset(); + const liveComposerBottom = terminal.rows - liveComposerBottomOffset; + const liveMaxSafeRow = + terminal.rows === ITERM_CANVAS_ROWS + PET_RAISE_ROWS + ? terminal.rows - rect.height - PET_RAISE_ROWS + : terminal.rows - rect.height; + const expectedColumn = Math.max(0, terminal.columns - rect.width - PET_SIDE_MARGIN); + const expectedRow = Math.max(0, Math.min(liveComposerBottom - rect.height, liveMaxSafeRow)); + return ( + this.#ui.isRunning && + this.#isActiveOwner() && + generation === this.#itermGeneration && + availability?.available === true && + availability.epoch === epoch && + availability.mode === mode && + !this.#ui.manualViewportActive && + this.#isWorking() === working && + flexingNow === flexing && + timelineSignature(this.#itermTimeline(performance.now(), this.#isWorking(), flexingNow)) === + timelineSignature(frames) && + this.#framedEditor.canFit(terminal.columns) && + terminal.columns === geometry.columns && + terminal.rows === geometry.rows && + cell.widthPx === geometry.cellWidthPx && + cell.heightPx === geometry.cellHeightPx && + liveComposerBottomOffset === composerBottomOffset && + rect.column === expectedColumn && + rect.row === expectedRow && + rect.column + rect.width <= terminal.columns && + rect.row + rect.height <= terminal.rows + ); + }; + let token = this.#itermLease; + if ( + token && + (token.rect.column !== rect.column || + token.rect.row !== rect.row || + token.rect.width !== rect.width || + token.rect.height !== rect.height) + ) { + await this.#ui.invalidateRasterLease({ token, cause: "resize" }); + if (this.#itermLease === token) this.#itermLease = undefined; + token = undefined; + } + if (!current()) return; + if (!token) { + const acquired = await this.#ui.acquireRasterLease({ + ownerId: this.#itermOwner, + rect, + erase: { + type: "raster-erase", + bytes: new TextEncoder().encode( + `\x1b[0m${Array.from({ length: rect.height }, (_, row) => `\x1b[${rect.row + row + 1};${rect.column + 1}H\x1b[${rect.width}X`).join("")}`, + ), + }, + onInvalidated: notice => { + if (this.#itermLease === notice.token) { + this.#itermLease = undefined; + this.#itermLastSemantic = ""; + } + }, + }); + if (!current() || acquired.status !== "acquired") { + if (acquired.status === "acquired") + await this.#ui.invalidateRasterLease({ + token: acquired.token, + cause: this.#ui.manualViewportActive ? "manual-viewport" : "capability-loss", + }); + return; + } + token = acquired.token; + this.#itermLease = token; + } + this.#itermLastSemantic = semantic; + const cell = getCellDimensions(); + const gif = getGajaePetGifCached({ + skin: this.#mode === "off" ? "red" : this.#mode, + timeline: frames, + targetRows: PET_ART_ROWS, + rectangle: { width: rect.width * cell.widthPx, height: rect.height * cell.heightPx }, + contentInset: { topPx: Math.floor(cell.heightPx / 2), bottomPx: Math.ceil(cell.heightPx / 2) }, + displaySize: { width: rect.width, height: rect.height }, + }); + const cursorPosition = `\x1b[${rect.row + 1};${rect.column + 1}H`; + const cursorRestore = + mode === "managed" ? `${wrapITerm2RecordForTmux("\x1b8\x1b[?2026l")}\x1b8` : "\x1b8\x1b[?2026l"; + const encodedRecords = (mode === "managed" ? gif.tmuxDcs : gif.multipart).map(record => + new TextEncoder().encode(record), + ); + const submit = await this.#ui.submitTerminalOutput({ + token, + operation: { + type: "raster-multipart-batch", + prefix: new TextEncoder().encode( + mode === "managed" + ? `${wrapITerm2RecordForTmux("\x1b[?2026h\x1b7\x1b[?25l")}\x1b7${cursorPosition}` + : `\x1b[?2026h\x1b7\x1b[?25l${cursorPosition}`, + ), + afterPrefix: + mode === "managed" + ? async () => (current() ? 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, + }, + }); + if (!current() || submit.status !== "written") { + await this.#ui.invalidateRasterLease({ token, cause: "capability-loss" }); + if (this.#itermLease === token) this.#itermLease = undefined; } - return animationFrameAt(skin.idle, now, skin.baseFrame); } #scheduleWorkBurst(now: number): void { @@ -434,51 +731,92 @@ export class GajaePetWidget { } #tick(now: number): void { - if (this.#mode === "off" || !this.#pixel) return; - // A font/zoom change resizes the terminal cells; rebuild the frames so the - // kitty image and its sub-cell drop match the new cell metrics. - const cell = getCellDimensions(); - if (cell.widthPx !== this.#builtCellW || cell.heightPx !== this.#builtCellH) { - const protocol = this.#forcedProtocol ?? GajaePetWidget.pixelProtocol(); - if (protocol) { - this.#buildPixel(protocol); - this.#mountEditor(true); - this.#ui.requestRender(true); + if (!this.#isActiveOwner()) return; + const skin = this.#mode === "off" ? undefined : PET_SKINS[this.#mode]; + this.#syncWorkingState(now); + const working = this.#isWorking(); + if (!working) { + this.#nextAutoFlexAt = 0; + if (this.#flexSource === "working") { + this.#flexUntil = 0; + this.#flexSource = undefined; } } - // Random show-off, both while idle and while working. Each skin's burst runs for - // its own length (RedGajae a brief flex; BlueGajae a para-para cycle plus sob). - const skin = PET_SKINS[this.#mode]; - const usesWorkBursts = this.#isWorking() && Boolean(skin.workBursts?.length); - if (this.#autoFlexGapMs && !usesWorkBursts && now >= this.#flexUntil) { - if (this.#nextAutoFlexAt === 0) { - this.#scheduleAutoFlex(now); - } else if (now >= this.#nextAutoFlexAt) { - const burstMs = petBurstDurationMs(PET_SKINS[this.#mode].burst); + if (now >= this.#flexUntil) { + this.#flexUntil = 0; + this.#flexSource = undefined; + if (this.#previewFlexAt !== 0 && now >= this.#previewFlexAt && skin) { this.#activeBurst = skin.burst; - this.#flexUntil = now + burstMs; - this.#scheduleAutoFlex(now + burstMs); + this.#flexUntil = now + petBurstDurationMs(skin.burst); + this.#flexSource = "preview"; + this.#previewFlexAt = 0; + } else if (this.#autoFlexGapMs && working && skin && !skin.workBursts?.length) { + if (this.#nextAutoFlexAt === 0) this.#scheduleAutoFlex(now); + else if (now >= this.#nextAutoFlexAt) { + this.#activeBurst = skin.burst; + const burstMs = petBurstDurationMs(skin.burst); + this.#flexUntil = now + burstMs; + this.#flexSource = "working"; + this.#scheduleAutoFlex(now + burstMs); + } } } - if (this.#isWorking() && !this.#workTransition && now >= this.#flexUntil && skin.workBursts?.length) { - if (this.#nextWorkBurstAt === 0) { - this.#scheduleWorkBurst(now); - } else if (now >= this.#nextWorkBurstAt) { + if (working && skin?.workBursts?.length && !this.#workTransition && now >= this.#flexUntil) { + if (this.#nextWorkBurstAt === 0) this.#scheduleWorkBurst(now); + else if (now >= this.#nextWorkBurstAt) { const burst = skin.workBursts[this.#workBurstIndex % skin.workBursts.length]; this.#workBurstIndex++; this.#activeBurst = burst; this.#flexUntil = now + petBurstDurationMs(burst); + this.#flexSource = "working"; this.#scheduleWorkBurst(this.#flexUntil); } } + if (this.#itermProtocol) { + this.#tickIterm(now); + return; + } + if (this.#mode === "off" || !this.#pixel) return; + const cell = getCellDimensions(); + if (cell.widthPx !== this.#builtCellW || cell.heightPx !== this.#builtCellH) { + const protocol = this.#forcedProtocol ?? GajaePetWidget.pixelProtocol(); + if (protocol) { + this.#buildPixel(protocol); + this.#mountEditor(true); + this.#ui.requestRender(true); + } + } const frame = this.#pickFrame(now); if (frame === this.#frame) return; this.#frame = frame; - // Write directly: a frame swap changes no component line, so the TUI - // would skip the render write (and with it the post-render emitter). - const payload = this.#overlayPayload(true) ?? ""; - if (payload && this.#ui.terminalAvailable) { - this.#ui.terminal.write(`\x1b[?2026h\x1b7${payload}\x1b8\x1b[?2026l`); + const pixel = this.#pixel; + const mode = this.#mode; + const position = this.#petPosition(); + const terminalColumns = this.#ui.terminal.columns; + const terminalRows = this.#ui.terminal.rows; + const queuedCell = getCellDimensions(); + const emission = this.#overlayEmission(true); + if (emission && pixel && this.#ui.terminalAvailable) { + void this.#ui.queueTerminalOutput(`\x1b[?2026h\x1b7${emission.payload}\x1b8\x1b[?2026l`, { + shouldWrite: () => { + const currentPosition = this.#petPosition(); + const currentCell = getCellDimensions(); + return ( + this.#isActiveOwner() && + this.#mode === mode && + this.#pixel === pixel && + this.#frame === frame && + this.#ui.terminal.columns === terminalColumns && + this.#ui.terminal.rows === terminalRows && + currentCell.widthPx === queuedCell.widthPx && + currentCell.heightPx === queuedCell.heightPx && + (position === null + ? currentPosition === null + : currentPosition?.x === position.x && currentPosition.y === position.y) + ); + }, + onWritten: emission.onWritten, + }); } } @@ -488,15 +826,8 @@ export class GajaePetWidget { const columns = this.#ui.terminal.columns; if (!this.#framedEditor.canFit(columns)) return null; const rows = this.#ui.terminal.rows; - // The sprite is lifted one safety row above the scrolling edge, then dropped - // back onto the composer's bottom border per protocol (sixel via transparent - // top padding, kitty via a sub-cell Y offset baked into the frames). const composerBottom = rows - this.#getComposerBottomOffset(); - // iTerm2 anchors an inline image at the current cursor row. The shared - // safety lift keeps its geometry-derived cell block aligned with the - // cursor-neutral Kitty/Sixel overlays and above the HUD boundary. - const safetyLift = PET_RAISE_ROWS; - const y = composerBottom - pixel.rows - safetyLift; + const y = composerBottom - pixel.rows - PET_RAISE_ROWS; const x = columns - pixel.columns - PET_SIDE_MARGIN; if (y < 0 || x < 0) return null; return { x, y }; @@ -504,21 +835,16 @@ export class GajaePetWidget { #clearSixelFootprint(footprint: SixelFootprint): string { let out = "\x1b[0m"; - for (let row = 0; row < footprint.rows; row++) { + for (let row = 0; row < footprint.rows; row++) out += `\x1b[${footprint.y + row + 1};${footprint.x + 1}H\x1b[${footprint.columns}X`; - } return out; } - /** Pending on-screen image cleanup. Pure: authority is consumed separately, on delivery. */ #imageCleanupPayload(): string { let out = ""; - if (this.#kittyCleanupPending && this.#kittyImageId !== undefined) { + if (this.#kittyCleanupPending && this.#kittyImageId !== undefined) out += `\x1b_Ga=d,d=I,i=${this.#kittyImageId},q=2\x1b\\`; - } - if (this.#lastSixelFootprint) { - out += this.#clearSixelFootprint(this.#lastSixelFootprint); - } + if (this.#lastSixelFootprint) out += this.#clearSixelFootprint(this.#lastSixelFootprint); return out; } @@ -527,67 +853,66 @@ export class GajaePetWidget { this.#lastSixelFootprint = undefined; } - /** - * Best-effort direct erase of the on-screen pet image. Cleanup authority is - * consumed only after the write is actually delivered: an unavailable - * terminal or a throwing write keeps the erase pending so a later mode - * switch or dispose can retry it. - */ - #writeImageCleanup(): void { - if (!this.#ui.terminalAvailable) return; - const payload = this.#imageCleanupPayload(); - if (!payload) return; - try { - this.#ui.terminal.write(`\x1b[?2026h\x1b7${payload}\x1b8\x1b[?2026l`); - } catch { - // Keep the footprint/placement authority; the terminal write layer - // reports availability separately and callers retry on the next - // lifecycle transition. - return; + #queueImageCleanup(releaseKittyImage = false, includeSixel = true): Promise { + const sixelFootprint = includeSixel ? this.#lastSixelFootprint : undefined; + const kittyImageId = this.#kittyCleanupPending ? this.#kittyImageId : undefined; + const deliveredKittyImageId = releaseKittyImage && kittyImageId === undefined ? this.#kittyImageId : undefined; + if (deliveredKittyImageId !== undefined) { + this.#kittyImageId = undefined; + allocatedPetKittyImageIds.delete(deliveredKittyImageId); } - this.#consumeCleanupAuthority(); + let payload = ""; + if (kittyImageId !== undefined) payload += `\x1b_Ga=d,d=I,i=${kittyImageId},q=2\x1b\\`; + if (sixelFootprint) payload += this.#clearSixelFootprint(sixelFootprint); + if (!payload) return Promise.resolve(); + const kittyCleanupGeneration = kittyImageId === undefined ? undefined : ++this.#kittyCleanupGeneration; + if (releaseKittyImage && kittyImageId !== undefined && this.#kittyImageId === kittyImageId) + this.#kittyImageId = undefined; + return this.#ui.queueTerminalCleanup(`\x1b[?2026h\x1b7${payload}\x1b8\x1b[?2026l`, () => { + if (sixelFootprint && this.#lastSixelFootprint && sameFootprint(this.#lastSixelFootprint, sixelFootprint)) + this.#lastSixelFootprint = undefined; + if ( + kittyImageId !== undefined && + kittyCleanupGeneration === this.#kittyCleanupGeneration && + (this.#kittyImageId === kittyImageId || this.#kittyImageId === undefined) + ) + this.#kittyCleanupPending = false; + if (kittyImageId !== undefined && releaseKittyImage) allocatedPetKittyImageIds.delete(kittyImageId); + }); } - /** Draw escape payload at the pet's absolute position. */ - #overlayPayload(clearPet = false): string | null { + #overlayEmission(clearPet = false): PetOverlayEmission | null { + if (!this.#isActiveOwner()) return null; const pixel = this.#pixel; if (!pixel) return null; const pos = this.#petPosition(); if (!pos) { - // Deferred delivery acknowledgement: the TUI writes the frame after - // this emitter returns, and that write can fail. Consume the cleanup - // authority only once a later pass observes the terminal survived - // the frame that carried the payload; otherwise retain it so a later - // lifecycle cleanup retries the erase/delete. - if (this.#frameCleanupAwaitingAck && this.#ui.terminalAvailable) { - this.#consumeCleanupAuthority(); - } - this.#frameCleanupAwaitingAck = false; - if (!this.#ui.terminalAvailable) return null; const cleanup = this.#imageCleanupPayload(); if (!cleanup) return null; - this.#frameCleanupAwaitingAck = true; - return cleanup; + return { + payload: cleanup, + onWritten: () => { + if (this.#isActiveOwner()) this.#consumeCleanupAuthority(); + }, + }; } - // A full frame supersedes any cleanup-only frame still awaiting ack. - this.#frameCleanupAwaitingAck = false; const { x, y } = pos; let out = ""; - - if (pixel.protocol === "sixel" || pixel.protocol === "iterm2") { + let onWritten: (() => void) | undefined; + if (pixel.protocol === "sixel") { const footprint = { x, y, columns: pixel.columns, rows: pixel.rasterRows }; - if (this.#lastSixelFootprint && !sameFootprint(this.#lastSixelFootprint, footprint)) { - out += this.#clearSixelFootprint(this.#lastSixelFootprint); - } + const previous = this.#lastSixelFootprint; + if (previous && !sameFootprint(previous, footprint)) out += this.#clearSixelFootprint(previous); if (clearPet) out += this.#clearSixelFootprint(footprint); - this.#lastSixelFootprint = footprint; + onWritten = () => { + if (this.#isActiveOwner()) this.#lastSixelFootprint = footprint; + }; } else { - // A kitty frame emitted below (re)places the image, so cleanup is - // pending again even if a narrow-terminal pass consumed it earlier. - this.#kittyCleanupPending = true; + onWritten = () => { + if (this.#isActiveOwner()) this.#kittyCleanupPending = true; + }; } - out += `\x1b[${y + 1};${x + 1}H${pixel.frames[this.#frame]}`; - return out; + return { payload: out, onWritten }; } } diff --git a/packages/coding-agent/src/modes/components/iterm-pet-transport.ts b/packages/coding-agent/src/modes/components/iterm-pet-transport.ts new file mode 100644 index 0000000000..7ed52e244d --- /dev/null +++ b/packages/coding-agent/src/modes/components/iterm-pet-transport.ts @@ -0,0 +1,585 @@ +/** Protocol-correct direct and managed iTerm2 Pet capability transport. */ +import { isUnderTerminalMultiplexer, parseITerm2CapabilityReply, wrapITerm2RecordForTmux } from "@gajae-code/tui"; +import { resolveGjcTmuxCommand } from "../../gjc-runtime/tmux-common"; +export const PET_CAPABILITY_DRAIN_MAX_MS = 100; +export const PET_CAPABILITY_QUIESCENCE_MS = 25; +export const PET_CAPABILITY_QUERY_TIMEOUT_MS = 1000; +export const PET_TOPOLOGY_POLL_MS = 250; +export type PetTransportMode = "direct" | "managed"; +export type PetUnavailableReason = + | "not-iterm2" + | "tty-unavailable" + | "missing-f" + | "invalid-f" + | "probe-timeout" + | "topology-ineligible" + | "topology-lost" + | "zero-client-recovery" + | "cleanup-failed"; +export type PetTransportAvailability = Readonly<{ + available: boolean; + mode: PetTransportMode; + reason?: PetUnavailableReason; + epoch: number; +}>; +export type PetTransportClock = Readonly<{ + now(): number; + setTimeout(callback: () => void, ms: number): unknown; + clearTimeout(handle: unknown): void; +}>; +export type PetTransportInput = Readonly<{ + drain(maxMs: number, quiescenceMs: number): Promise; + onData(callback: (data: Uint8Array | string) => PetInputResult | undefined): () => void; +}>; +export type PetInputResult = Readonly<{ consume?: true; data?: string }>; +export type NativePetUi = Readonly<{ + drainPetProbeInput?(maxMs: number, quiescenceMs: number): Promise; + drainInput(maxMs: number, quiescenceMs: number): Promise; + addInputListener(callback: (data: string | Uint8Array) => unknown): () => void; + submitTerminalOutput( + request: Readonly<{ operation: Readonly<{ type: "raster-probe"; bytes: Uint8Array }> }>, + ): Promise>; + notifyTerminalLifecycle( + event: Readonly<{ + kind: "availability-restored" | "explicit-cleanup"; + source: "transport"; + terminalGeneration: number; + }>, + ): Promise; + readonly terminalGeneration: number; +}>; +export type PetTransportOutput = Readonly<{ + write(bytes: Uint8Array): Promise>; + notifyLifecycle?( + event: Readonly<{ kind: "availability-restored" | "explicit-cleanup"; terminalGeneration: number }>, + ): Promise; +}>; +export type PetTmuxResult = Readonly<{ status: number; stdout: string; stderr?: string }>; +export type PetTmuxRunner = (argv: readonly string[]) => Promise; +export type PetTmuxTopology = Readonly<{ + clients: number; + paneId?: string; + ownedPaneId?: string; + clientId?: string; + clientVersion?: string; +}>; +const text = (v: Uint8Array | string) => (typeof v === "string" ? v : new TextDecoder().decode(v)); +export function hasItermFileCapability(v: Uint8Array | string): boolean { + return parseITerm2CapabilityReply(v) === "complete-f"; +} +export function consumeCapabilityInput(callback: (data: Uint8Array | string) => void) { + const marker = "\x1b]1337;Capabilities"; + const maxFragment = 8192; + let fragment = ""; + return (data: string | Uint8Array): PetInputResult | undefined => { + const combined = fragment + text(data); + fragment = ""; + let offset = 0; + let consumed = false; + let passthrough = ""; + while (offset < combined.length) { + const start = combined.indexOf(marker, offset); + if (start < 0) { + const suffixLength = Math.min(marker.length - 1, combined.length - offset); + let keep = ""; + for (let len = suffixLength; len >= 1; len--) { + const candidate = combined.slice(combined.length - len); + if (marker.startsWith(candidate)) { + keep = candidate; + break; + } + } + passthrough += combined.slice(offset, combined.length - keep.length); + fragment = keep; + break; + } + passthrough += combined.slice(offset, start); + const end = combined.slice(start + marker.length).search(/(?:\x07|\x1b\\)/); + if (end < 0) { + const pending = combined.slice(start); + if (pending.length <= maxFragment) fragment = pending; + else passthrough += pending; + consumed = true; + break; + } + const terminator = combined[start + marker.length + end]; + const length = marker.length + end + (terminator === "\x1b" ? 2 : 1); + callback(combined.slice(start, start + length)); + consumed = true; + offset = start + length; + } + if (passthrough) return { data: passthrough }; + return consumed || fragment ? { consume: true } : undefined; + }; +} +export function capabilityProbe() { + return new TextEncoder().encode("\x1b]1337;Capabilities\x07"); +} +const result = (r: PetTmuxResult | string): PetTmuxResult => (typeof r === "string" ? { status: 0, stdout: r } : r); +export function isItermCandidate( + env: NodeJS.ProcessEnv = Bun.env, + tty = Boolean(process.stdin.isTTY && process.stdout.isTTY), +): boolean { + const v = env.TERM_PROGRAM_VERSION?.split(".").map(Number); + return env.TERM_PROGRAM === "iTerm.app" && tty && !!v && v[0] >= 3 && (v[0] > 3 || (v[1] ?? 0) >= 5); +} +export function createNativePetTransport(o: { + ui: NativePetUi; + env?: NodeJS.ProcessEnv; + topology?: () => Promise; +}): ItermPetTransport | undefined { + const env = o.env ?? Bun.env; + const managed = Boolean( + env.GJC_TMUX_ACTIVE_SESSION?.trim() && env.TMUX_PANE?.trim() && env.GJC_MANAGED_OWNER_RUN_ID?.trim(), + ); + if (isUnderTerminalMultiplexer(env) && !managed) return undefined; + if (!isItermCandidate(env, true)) return undefined; + const clock: PetTransportClock = { now: Date.now, setTimeout, clearTimeout }; + const input: PetTransportInput = { + drain: (a, b) => o.ui.drainPetProbeInput?.(a, b) ?? o.ui.drainInput(a, b), + onData: cb => { + const consume = consumeCapabilityInput(cb); + return o.ui.addInputListener((d: string | Uint8Array) => consume(d)); + }, + }; + const output: PetTransportOutput = { + write: async bytes => { + const r = await o.ui.submitTerminalOutput({ operation: { type: "raster-probe", bytes } }); + return { status: r?.status === "written" || (r?.written ?? 0) > 0 ? "written" : "failed" }; + }, + notifyLifecycle: e => + o.ui.notifyTerminalLifecycle({ ...e, source: "transport", terminalGeneration: o.ui.terminalGeneration }), + }; + const tmuxCommand = managed ? resolveGjcTmuxCommand(env) : undefined; + const tmux: PetTmuxRunner | undefined = managed + ? async argv => { + const p = Bun.spawn([tmuxCommand!, ...argv], { stdout: "pipe", stderr: "pipe" }); + return { + status: await p.exited, + stdout: await new Response(p.stdout).text(), + stderr: await new Response(p.stderr).text(), + }; + } + : undefined; + return new ItermPetTransport({ + mode: managed ? "managed" : "direct", + ttyCandidate: true, + clock, + input, + output, + tmux, + paneId: env.TMUX_PANE, + sessionTarget: env.GJC_TMUX_ACTIVE_SESSION, + topology: o.topology, + }); +} + +export class ItermPetTransport { + #epoch = 0; + #available = false; + #reason?: PetUnavailableReason; + #pending = false; + #unsubscribe?: () => void; + #timeout?: unknown; + #disposed = false; + #poll?: unknown; + #listeners = new Set<(a: PetTransportAvailability) => void>(); + #snapshot?: "unset" | { value: string }; + #restored = false; + #restoreInFlight?: Promise; + #paneTransaction: Promise = Promise.resolve(); + readonly #clock; + readonly #input; + readonly #output; + readonly #tmux; + readonly #ttyCandidate; + readonly #paneId?: string; + readonly #sessionTarget?: string; + readonly #topology?: () => Promise; + readonly #mode: PetTransportMode; + readonly #expectedClientId?: string; + #observedClientId?: string; + #pendingResolve?: (availability: PetTransportAvailability) => void; + constructor( + o: Readonly<{ + mode?: PetTransportMode; + ttyCandidate?: boolean; + clock: PetTransportClock; + input: PetTransportInput; + output: PetTransportOutput; + tmux?: PetTmuxRunner; + ownedPaneId?: string; + paneId?: string; + sessionTarget?: string; + topology?: () => Promise; + expectedClientId?: string; + }>, + ) { + this.#mode = o.mode ?? "direct"; + this.#ttyCandidate = o.ttyCandidate ?? true; + this.#clock = o.clock; + this.#input = o.input; + this.#output = o.output; + this.#tmux = o.tmux; + this.#paneId = o.ownedPaneId ?? o.paneId; + this.#sessionTarget = o.sessionTarget; + this.#topology = o.topology; + this.#expectedClientId = o.expectedClientId; + } + #notifyLifecycle( + event: Readonly<{ kind: "availability-restored" | "explicit-cleanup"; terminalGeneration: number }>, + ) { + try { + const notification = this.#output.notifyLifecycle?.(event); + if (notification !== undefined) void notification.catch(() => undefined); + } catch {} + } + get availability() { + return { available: this.#available, mode: this.#mode, reason: this.#reason, epoch: this.#epoch }; + } + async refreshManagedClient(row: number, column: number): Promise { + if (this.#mode === "direct") return true; + if (!Number.isInteger(row) || row < 0 || !Number.isInteger(column) || column < 0) return false; + if (!this.#available || !this.#tmux || this.#observedClientId === undefined || this.#paneId === undefined) + return false; + if (this.#expectedClientId !== undefined && this.#expectedClientId !== this.#observedClientId) return false; + const clientId = this.#observedClientId; + const epoch = this.#epoch; + const deadline = this.#clock.now() + 250; + const isCurrent = () => + !this.#disposed && + this.#available && + this.#tmux !== undefined && + this.#epoch === epoch && + this.#observedClientId === clientId && + (this.#expectedClientId === undefined || this.#expectedClientId === clientId); + while (this.#clock.now() <= deadline) { + if (!isCurrent()) return false; + try { + const pane = result( + await this.#tmux(["display-message", "-p", "-t", this.#paneId, "#{cursor_y}\t#{cursor_x}"]), + ); + if (!isCurrent() || pane.status !== 0) return false; + const match = /^([0-9]+)\t([0-9]+)$/.exec(pane.stdout.trim()); + if (!match) return false; + const observedRow = Number(match[1]); + const observedColumn = Number(match[2]); + if (!Number.isSafeInteger(observedRow) || !Number.isSafeInteger(observedColumn)) return false; + if (observedRow === row && observedColumn === column) + return result(await this.#tmux(["refresh-client", "-t", clientId])).status === 0 && isCurrent(); + } catch { + return false; + } + const { promise, resolve } = Promise.withResolvers(); + this.#clock.setTimeout(resolve, 10); + await promise; + } + return false; + } + subscribe(cb: (a: PetTransportAvailability) => void) { + this.#listeners.add(cb); + return () => this.#listeners.delete(cb); + } + #emit() { + for (const cb of this.#listeners) { + try { + cb(this.availability); + } catch {} + } + } + async probe() { + if (this.#disposed || this.#pending) return this.availability; + if (this.#mode === "managed") return this.inspectManagedTopology(); + return this.#probeAfterEligibility(); + } + async #probeAfterEligibility() { + if (this.#disposed || this.#pending) return this.availability; + this.#pending = true; + const epoch = ++this.#epoch; + this.#available = false; + this.#emit(); + if (this.#mode === "managed") { + let prepared = false; + try { + prepared = await this.#queuePaneTransaction(() => this.#prepareManagedPane(epoch)); + } catch { + prepared = false; + } + if (!prepared) { + if (epoch !== this.#epoch || this.#disposed) return this.availability; + this.#finish("topology-ineligible"); + await this.#restore(epoch); + return this.availability; + } + } + if (epoch !== this.#epoch || this.#disposed) return this.availability; + try { + await this.#input.drain(PET_CAPABILITY_DRAIN_MAX_MS, PET_CAPABILITY_QUIESCENCE_MS); + } catch { + if (epoch !== this.#epoch || this.#disposed) return this.availability; + this.#finish("probe-timeout"); + await this.#restore(epoch); + return this.availability; + } + if (epoch !== this.#epoch || this.#disposed) return this.availability; + if (!this.#ttyCandidate) return this.#finish("tty-unavailable"); + const probe = capabilityProbe(); + const bytes = + this.#mode === "managed" + ? new TextEncoder().encode(wrapITerm2RecordForTmux(new TextDecoder().decode(probe))) + : probe; + const { promise, resolve } = Promise.withResolvers(); + this.#pendingResolve = resolve; + let buffer = ""; + let acknowledged = false; + const cleanup = () => { + this.#clock.clearTimeout(this.#timeout); + this.#timeout = undefined; + this.#unsubscribe?.(); + this.#unsubscribe = undefined; + }; + const finish = (reason?: PetUnavailableReason) => { + if (!this.#pending || epoch !== this.#epoch || this.#disposed) return; + cleanup(); + this.#pendingResolve = undefined; + this.#finish(reason); + if (reason) void this.#restore(epoch); + resolve(this.availability); + }; + this.#unsubscribe = this.#input.onData(d => { + if (!acknowledged) return; + buffer += text(d); + const reply = parseITerm2CapabilityReply(buffer); + if (reply === "complete-f") finish(); + else if (reply === "missing-f" || reply === "invalid-f") finish(reply); + if (buffer.length > 8192) buffer = buffer.slice(-4096); + }); + let writeResult: Promise>; + try { + writeResult = this.#output.write(bytes); + } catch { + finish("probe-timeout"); + return promise; + } + Promise.resolve(writeResult) + .then(written => { + if (epoch !== this.#epoch || this.#disposed) { + cleanup(); + return; + } + if (written.status !== "written") { + finish("probe-timeout"); + return; + } + acknowledged = true; + this.#timeout = this.#clock.setTimeout(() => finish("probe-timeout"), PET_CAPABILITY_QUERY_TIMEOUT_MS); + }) + .catch(() => finish("probe-timeout")); + return promise; + } + retry() { + return this.#mode === "managed" ? this.inspectManagedTopology() : this.probe(); + } + #finish(reason?: PetUnavailableReason) { + this.#pending = false; + this.#available = !reason; + this.#reason = reason; + this.#emit(); + if (this.#available && !reason) + this.#notifyLifecycle({ kind: "availability-restored", terminalGeneration: this.#epoch }); + return this.availability; + } + async revoke(reason: PetUnavailableReason = "topology-lost"): Promise { + if (!this.#available && this.#reason === reason) return this.availability; + this.#epoch++; + this.#clock.clearTimeout(this.#timeout); + this.#timeout = undefined; + this.#unsubscribe?.(); + this.#unsubscribe = undefined; + this.#pending = false; + this.#available = false; + this.#reason = reason; + this.#observedClientId = undefined; + const availability = this.availability; + const resolve = this.#pendingResolve; + this.#pendingResolve = undefined; + this.#emit(); + resolve?.(availability); + await this.#restore(this.#epoch); + return availability; + } + #queuePaneTransaction(transaction: () => Promise): Promise { + const queued = this.#paneTransaction.then(transaction, transaction); + this.#paneTransaction = queued.then( + () => undefined, + () => undefined, + ); + return queued; + } + async #restoreManagedPane(epoch?: number) { + if ( + this.#mode !== "managed" || + !this.#tmux || + !this.#paneId || + this.#snapshot === undefined || + this.#restored || + (epoch !== undefined && (epoch !== this.#epoch || this.#disposed)) + ) + return; + if (this.#restoreInFlight) return this.#restoreInFlight; + const snapshot = this.#snapshot; + const argv = + snapshot === "unset" + ? ["set-option", "-u", "-p", "-t", this.#paneId, "allow-passthrough"] + : ["set-option", "-p", "-t", this.#paneId, "allow-passthrough", snapshot.value]; + this.#restoreInFlight = (async () => { + let r: PetTmuxResult; + try { + r = result(await this.#tmux!(argv)); + } catch { + this.#reason = "cleanup-failed"; + this.#available = false; + this.#emit(); + return; + } + if (r.status !== 0) { + this.#reason = "cleanup-failed"; + this.#available = false; + this.#emit(); + return; + } + if (this.#snapshot === snapshot) { + this.#restored = true; + this.#snapshot = undefined; + if (this.#reason === "cleanup-failed") this.#reason = undefined; + this.#notifyLifecycle({ kind: "explicit-cleanup", terminalGeneration: this.#epoch }); + } + })(); + try { + await this.#restoreInFlight; + } finally { + this.#restoreInFlight = undefined; + } + } + async #restore(epoch?: number) { + return this.#queuePaneTransaction(() => this.#restoreManagedPane(epoch)); + } + async #prepareManagedPane(epoch: number): Promise { + if (!this.#tmux || !this.#paneId) return false; + const isCurrent = () => epoch === this.#epoch && !this.#disposed; + if (this.#snapshot !== undefined) { + const current = result( + await this.#tmux(["show-options", "-A", "-p", "-v", "-t", this.#paneId, "allow-passthrough"]), + ); + if (!isCurrent()) return false; + if (current.status === 0 && current.stdout.replace(/\r?\n$/, "") === "on") return true; + await this.#restoreManagedPane(); + if (this.#snapshot !== undefined) return false; + } + const q = result(await this.#tmux(["show-options", "-q", "-p", "-v", "-t", this.#paneId, "allow-passthrough"])); + if (!isCurrent() || q.status !== 0) return false; + const snapshot = q.stdout.replace(/\r?\n$/, "") === "" ? "unset" : { value: q.stdout.replace(/\r?\n$/, "") }; + this.#snapshot = snapshot; + this.#restored = false; + const set = result(await this.#tmux(["set-option", "-p", "-t", this.#paneId, "allow-passthrough", "on"])); + if (!isCurrent() || set.status !== 0) { + await this.#restoreManagedPane(epoch); + return false; + } + const verify = result( + await this.#tmux(["show-options", "-A", "-p", "-v", "-t", this.#paneId, "allow-passthrough"]), + ); + if ( + epoch !== this.#epoch || + this.#disposed || + verify.status !== 0 || + verify.stdout.replace(/\r?\n$/, "") !== "on" + ) { + await this.#restoreManagedPane(epoch); + return false; + } + return true; + } + async inspectManagedTopology() { + if (this.#disposed) return this.availability; + const epoch = this.#epoch; + const isCurrent = () => epoch === this.#epoch && !this.#disposed; + if (this.#topology) { + let t: PetTmuxTopology; + try { + t = await this.#topology(); + } catch { + if (!isCurrent()) return this.availability; + return this.#finish("topology-ineligible"); + } + if (!isCurrent()) return this.availability; + if ( + t.clients === 1 && + ((t.paneId !== undefined && t.paneId !== this.#paneId) || + (t.ownedPaneId !== undefined && t.ownedPaneId !== this.#paneId) || + (t.clientId !== undefined && t.clientId !== (this.#expectedClientId ?? this.#observedClientId))) + ) + return this.revoke("topology-ineligible"); + if (t.clients === 1 && t.clientId !== undefined && this.#observedClientId === undefined) + this.#observedClientId = t.clientId; + if (t.clients === 0) return this.revoke("zero-client-recovery"); + if (t.clients !== 1) return this.revoke("topology-ineligible"); + if (this.#available) return this.availability; + return this.#probeAfterEligibility(); + } + if (!this.#tmux || !this.#paneId) return this.#finish("topology-ineligible"); + let r: PetTmuxResult; + try { + r = result( + await this.#tmux(["list-clients", "-t", this.#sessionTarget ?? "=", "-F", "#{client_name}\t#{client_tty}"]), + ); + } catch { + if (!isCurrent()) return this.availability; + return this.#finish("topology-ineligible"); + } + if (!isCurrent()) return this.availability; + if (r.status !== 0) return this.revoke("topology-ineligible"); + const rows = r.stdout.split(/\r?\n/).filter(x => x.length > 0); + if (rows.length !== 1) return this.revoke(rows.length === 0 ? "zero-client-recovery" : "topology-ineligible"); + const [clientId] = rows[0].split("\t"); + if (!clientId) return this.revoke("topology-ineligible"); + let pane: PetTmuxResult; + try { + pane = result(await this.#tmux(["display-message", "-p", "-c", clientId, "#{pane_id}"])); + } catch { + if (!isCurrent()) return this.availability; + return this.#finish("topology-ineligible"); + } + if (!isCurrent()) return this.availability; + if ( + pane.status !== 0 || + pane.stdout.trim() !== this.#paneId || + ((this.#expectedClientId ?? this.#observedClientId) !== undefined && + (this.#expectedClientId ?? this.#observedClientId) !== clientId) + ) + return this.revoke("topology-ineligible"); + if (this.#observedClientId === undefined) this.#observedClientId = clientId; + if (this.#available) return this.availability; + return this.#probeAfterEligibility(); + } + startManagedPolling() { + if (this.#mode !== "managed" || this.#poll) return; + const tick = async () => { + if (this.#disposed) return; + await this.inspectManagedTopology(); + if (!this.#disposed) this.#poll = this.#clock.setTimeout(() => void tick(), PET_TOPOLOGY_POLL_MS); + }; + void tick(); + } + stopManagedPolling() { + if (this.#poll !== undefined) this.#clock.clearTimeout(this.#poll); + this.#poll = undefined; + } + async dispose() { + if (this.#disposed) return; + this.stopManagedPolling(); + await this.revoke("topology-lost"); + this.#disposed = true; + this.#listeners.clear(); + } +} diff --git a/packages/coding-agent/src/modes/components/pet-capability.ts b/packages/coding-agent/src/modes/components/pet-capability.ts index 9c88ee42d9..d58a5db741 100644 --- a/packages/coding-agent/src/modes/components/pet-capability.ts +++ b/packages/coding-agent/src/modes/components/pet-capability.ts @@ -6,14 +6,15 @@ import { shouldProbeSixelCapability, TERMINAL, } from "@gajae-code/tui"; +import type { PetTransportAvailability } from "./iterm-pet-transport"; -export type PetPixelProtocol = "sixel" | "kitty" | "iterm2"; +export type PetPixelProtocol = "sixel" | "kitty" | "iterm"; -export const PET_UNAVAILABLE_DESCRIPTION = "Unavailable: requires compatible Kitty, iTerm2, or Sixel overlay rendering"; +export const PET_UNAVAILABLE_DESCRIPTION = "Unavailable: requires compatible Kitty or Sixel overlay rendering"; export const PET_SAVED_UNAVAILABLE_DESCRIPTION = - "Saved, unavailable — requires compatible Kitty, iTerm2, or Sixel overlay rendering"; + "Saved, unavailable — requires compatible Kitty or Sixel overlay rendering"; export const PET_UNAVAILABLE_WARNING = - "⚠ Pets aren’t available in this terminal. Its image support isn’t compatible with Gajae Pet’s overlay rendering yet. Try Kitty, Ghostty, WezTerm, iTerm2, or a terminal with compatible Sixel support."; + "⚠ Pets aren’t available in this terminal. Its image support isn’t compatible with Gajae Pet’s overlay rendering yet. Try Kitty, Ghostty, WezTerm, or a terminal with compatible Sixel support."; const PET_MULTIPLEXER_UNAVAILABLE_WARNING = "⚠ Gajae Pet graphics are unavailable inside tmux, screen, or zellij because image escapes are not forwarded end to end. Run gjc outside the multiplexer, or set PI_FORCE_IMAGE_PROTOCOL=sixel only when the full terminal chain supports Sixel."; @@ -21,14 +22,34 @@ export function getPetUnavailableWarning(env: NodeJS.ProcessEnv = Bun.env): stri return isUnderTerminalMultiplexer(env) ? PET_MULTIPLEXER_UNAVAILABLE_WARNING : PET_UNAVAILABLE_WARNING; } -export function getPetPixelProtocol(env: NodeJS.ProcessEnv = Bun.env): PetPixelProtocol | null { - if (isUnderTerminalMultiplexer(env)) { - const forced = env.PI_FORCE_IMAGE_PROTOCOL?.trim().toLowerCase(); - return TERMINAL.imageProtocol === ImageProtocol.Sixel && forced === "sixel" ? "sixel" : null; - } - if (TERMINAL.imageProtocol === ImageProtocol.Kitty) return "kitty"; +let latestItermAvailability: PetTransportAvailability | undefined; +let verifiedItermAvailability: PetTransportAvailability | undefined; +const verifiedItermListeners = new Set<(availability: PetTransportAvailability | undefined) => void>(); +export function subscribeVerifiedItermPetAvailability( + callback: (availability: PetTransportAvailability | undefined) => void, +): () => void { + verifiedItermListeners.add(callback); + return () => verifiedItermListeners.delete(callback); +} +export function setVerifiedItermPetAvailability(availability: PetTransportAvailability | undefined): void { + latestItermAvailability = availability; + verifiedItermAvailability = availability?.available ? availability : undefined; + for (const listener of verifiedItermListeners) listener(verifiedItermAvailability); +} +export function getItermPetAvailability(): PetTransportAvailability | undefined { + return latestItermAvailability; +} +export function getVerifiedItermPetAvailability(): PetTransportAvailability | undefined { + return verifiedItermAvailability; +} +export function getItermPetUnavailableReason(): string | undefined { + return latestItermAvailability?.available ? undefined : latestItermAvailability?.reason; +} + +export function getPetPixelProtocol(): PetPixelProtocol | null { + if (TERMINAL.imageProtocol === ImageProtocol.Kitty && !isUnderTerminalMultiplexer()) return "kitty"; if (TERMINAL.imageProtocol === ImageProtocol.Sixel) return "sixel"; - if (TERMINAL.imageProtocol === ImageProtocol.Iterm2) return "iterm2"; + if (verifiedItermAvailability?.available) return "iterm"; return null; } @@ -46,31 +67,13 @@ export function createPetSelectItems( const current = option.value === currentValue; const savedUnavailable = disabled && current; let description = `${option.description ?? ""}${current ? " (current)" : ""}`; - if (disabled) { - description = savedUnavailable ? PET_SAVED_UNAVAILABLE_DESCRIPTION : PET_UNAVAILABLE_DESCRIPTION; - } - return { - ...option, - label: savedUnavailable ? `${option.label} (saved)` : option.label, - description, - disabled, - }; + if (disabled) description = savedUnavailable ? PET_SAVED_UNAVAILABLE_DESCRIPTION : PET_UNAVAILABLE_DESCRIPTION; + return { ...option, label: savedUnavailable ? `${option.label} (saved)` : option.label, description, disabled }; }); } -/** - * Grace period before declaring the terminal pet-incapable at startup. The - * asynchronous Sixel capability probe starts inside `TUI.start()` and answers - * within its own 250 ms deadline; this margin covers probe scheduling so a - * supported terminal is never told it is incompatible while the probe is - * still in flight. - */ export const PET_CAPABILITY_SETTLE_MS = 1_000; -/** - * Whether the asynchronous startup Sixel capability probe may still enable - * graphics for this session, meaning current unavailability is not final. - */ export function isPetCapabilityProbePending( env: NodeJS.ProcessEnv = Bun.env, platform: NodeJS.Platform = process.platform, @@ -80,15 +83,6 @@ export function isPetCapabilityProbePending( return shouldProbeSixelCapability(env, platform); } -/** - * Deliver the pet-unavailable startup warning only once the capability - * question is settled. With no probe pending, unavailability is final and - * `onUnavailable` fires immediately. While a probe may still enable - * graphics, the warning is deferred: a protocol-change event cancels it (the - * saved pet re-applies through the existing subscription), and only the - * settle deadline passing with the terminal still unavailable emits it. - * Returns a disposer that cancels the pending decision. - */ export function warnWhenPetCapabilitySettled(options: { probePending: boolean; isAvailable?: () => boolean; @@ -101,16 +95,21 @@ export function warnWhenPetCapabilitySettled(options: { } const isAvailable = options.isAvailable ?? isPetAvailable; let settled = false; + let unsubscribeIterm = () => {}; const finish = () => { if (settled) return; settled = true; clearTimeout(timer); unsubscribe(); + unsubscribeIterm(); }; const unsubscribe = onImageProtocolChanged(protocol => { if (!protocol) return; finish(); }); + unsubscribeIterm = subscribeVerifiedItermPetAvailability(availability => { + if (availability?.available) finish(); + }); const timer = setTimeout(() => { finish(); if (!isAvailable()) options.onUnavailable(); diff --git a/packages/coding-agent/src/modes/controllers/input-controller.ts b/packages/coding-agent/src/modes/controllers/input-controller.ts index d9081a8684..840576b46d 100644 --- a/packages/coding-agent/src/modes/controllers/input-controller.ts +++ b/packages/coding-agent/src/modes/controllers/input-controller.ts @@ -26,6 +26,7 @@ import { ensureSupportedImageInput, ImageInputTooLargeError } from "../../utils/ import { resizeImage } from "../../utils/image-resize"; import { loadPastedImageBatch, PastedImageBatchError } from "../../utils/pasted-image-loading"; import { + decodePastedPathCandidate, formatPastedImageReference, locatePastedImageReferenceAroundCursor, parsePastedImagePaths, @@ -55,8 +56,13 @@ export const BACKGROUND_FOLD_DOUBLE_PRESS_MS = 750; const DRAFT_CLEAR_DOUBLE_ESCAPE_WINDOW_MS = 800; const EMPTY_EDITOR_DOUBLE_ESCAPE_WINDOW_MS = 500; const IMAGE_PLACEHOLDER_PATTERN = /\[image ([1-9]\d*)\]/g; +const ITERM_PET_DRAG_PATH_PATTERN = /^\/var\/folders\/[^/]+\/[^/]+\/T\/iTerm2\.[A-Za-z0-9]+\.gajae-pet\.(?:png|gif)$/; const IMAGE_PLACEHOLDER_PRESENT_PATTERN = /\[image [1-9]\d*\]/; +function isItermPetDragPaste(text: string): boolean { + return ITERM_PET_DRAG_PATH_PATTERN.test(decodePastedPathCandidate(text) ?? ""); +} + interface InputControllerDependencies { loadPastedImageBatch?: typeof loadPastedImageBatch; } @@ -1675,6 +1681,10 @@ export class InputController { } handleTextPaste(text: string, context: PasteTextContext): boolean | Promise { + if (isItermPetDragPaste(text)) { + this.ctx.showStatus("Ignored dragged Gajae Pet image.", { dim: true }); + return true; + } if (this.ctx.isBashMode || this.ctx.isPythonMode) return false; const parsed = parsePastedImagePaths(text, { cwd: this.ctx.sessionManager.getCwd() }); if (!parsed) return false; diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index a2b690b94f..ea6b30b4d6 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -69,10 +69,14 @@ import { IrcLeftLaneComponent, IrcSplitViewComponent, } from "./components/irc-sidebar"; +import { createNativePetTransport, type ItermPetTransport } from "./components/iterm-pet-transport"; import { + getItermPetAvailability, + getItermPetUnavailableReason, getPetUnavailableWarning, isPetAvailable, isPetCapabilityProbePending, + setVerifiedItermPetAvailability, warnWhenPetCapabilitySettled, } from "./components/pet-capability"; import type { ToolExecutionHandle } from "./components/tool-execution"; @@ -434,6 +438,20 @@ export class InteractiveMode implements InteractiveModeContext { #resolvedSlashCommands: SlashCommand[] = []; #baseReservedSlashCommandNames: Set = new Set(); #cleanupUnsubscribe?: () => void; + #itermPetTransport?: ItermPetTransport; + + /** + * Pet-unavailable warning text. The iTerm transport reason is parenthesized + * only when a transport actually reported one; non-iTerm terminals and a + * probe still in flight keep the plain warning instead of "(unknown)". + */ + #petUnavailableStatusText(): string { + if (!this.#itermPetTransport || getItermPetAvailability() === undefined) return getPetUnavailableWarning(); + const reason = getItermPetUnavailableReason(); + return reason === undefined ? getPetUnavailableWarning() : `${getPetUnavailableWarning()} (${reason})`; + } + + #petTransportAvailabilityUnsubscribe?: () => void; #subprocessTeardownUnsubscribe?: () => void; #petProtocolUnsubscribe?: () => void; /** Cancels a startup pet-unavailable warning still awaiting probe settlement. */ @@ -620,6 +638,18 @@ export class InteractiveMode implements InteractiveModeContext { } }, }); + this.#itermPetTransport = createNativePetTransport({ ui: this.ui }); + if (this.#itermPetTransport) { + this.#petTransportAvailabilityUnsubscribe = this.#itermPetTransport.subscribe(availability => { + if (!availability.available) void this.petWidget?.suspendItermCapability(); + setVerifiedItermPetAvailability(availability); + if (availability.available) { + if (availability.mode === "managed") this.ui.refreshImageCellSize(); + const saved = settings.get("pet.mode"); + if (saved !== "off" && this.petWidget && this.petWidget.mode === "off") this.petWidget.setMode(saved); + } + }); + } this.ui.setClearOnShrink(settings.get("clearOnShrink")); this.chatContainer = new Container(); this.#ircSplitView = new IrcSplitViewComponent(this.chatContainer, this.ircLedger, () => theme); @@ -812,26 +842,15 @@ export class InteractiveMode implements InteractiveModeContext { // appears without the user re-running /pet. this.#petProtocolUnsubscribe?.(); this.#petProtocolUnsubscribe = onImageProtocolChanged(protocol => { - if (!protocol) return; + if (!protocol) { + void this.petWidget?.suspendItermCapability(); + return; + } const saved = settings.get("pet.mode"); if (saved !== "off" && this.petWidget && this.petWidget.mode === "off") { this.petWidget.setMode(saved); } }); - if (configuredPetMode !== "off" && !isPetAvailable()) { - // The async Sixel capability probe (started by TUI.start()) may still - // enable graphics; warn only once the capability question is settled - // so a supported terminal is never told it is incompatible. - this.#petUnavailableWarningDisposer?.(); - this.#petUnavailableWarningDisposer = warnWhenPetCapabilitySettled({ - probePending: isPetCapabilityProbePending(), - onUnavailable: () => { - this.showStatus(theme.fg("warning", getPetUnavailableWarning()), { dim: false }); - this.ui.requestRender(); - }, - }); - } - this.#inputController.setupKeyHandlers(); this.#inputController.setupEditorSubmitHandler(); this.editor.onViewportPageScroll = direction => { @@ -880,6 +899,23 @@ export class InteractiveMode implements InteractiveModeContext { // Start the UI this.ui.start(); + if (this.#itermPetTransport) { + if (this.#itermPetTransport.availability.mode === "direct") void this.#itermPetTransport.probe(); + else this.#itermPetTransport.startManagedPolling(); + } + if (configuredPetMode !== "off" && !isPetAvailable()) { + // An async terminal capability probe may still enable graphics; warn only once + // the capability question is settled so a supported terminal is never told it + // is incompatible. + this.#petUnavailableWarningDisposer?.(); + this.#petUnavailableWarningDisposer = warnWhenPetCapabilitySettled({ + probePending: this.#itermPetTransport !== undefined || isPetCapabilityProbePending(), + onUnavailable: () => { + this.showStatus(theme.fg("warning", this.#petUnavailableStatusText()), { dim: false }); + this.ui.requestRender(); + }, + }); + } pushTerminalTitle(); setSessionTerminalTitle(this.sessionManager.getSessionName(), this.sessionManager.getCwd()); this.updateEditorChrome(); @@ -1323,7 +1359,8 @@ export class InteractiveMode implements InteractiveModeContext { */ #commitPetMode(mode: PetMode, apply: (mode: PetMode) => void): boolean { if (mode !== "off" && !isPetAvailable()) { - this.showStatus(theme.fg("warning", getPetUnavailableWarning()), { dim: false }); + void this.#itermPetTransport?.retry(); + this.showStatus(theme.fg("warning", this.#petUnavailableStatusText()), { dim: false }); this.ui.requestRender(); return false; } @@ -1384,6 +1421,8 @@ export class InteractiveMode implements InteractiveModeContext { getComposerBottomOffset: () => this.petFloorContainer.render(this.ui.terminal.columns).length + this.hookWidgetContainerBelow.render(this.ui.terminal.columns).length, + syncManagedItermCursor: (row, column) => + this.#itermPetTransport?.refreshManagedClient(row, column) ?? Promise.resolve(false), }); } @@ -1537,6 +1576,11 @@ export class InteractiveMode implements InteractiveModeContext { this.#petUnavailableWarningDisposer = undefined; this.petWidget?.dispose(); this.petWidget = undefined; + this.#petTransportAvailabilityUnsubscribe?.(); + this.#petTransportAvailabilityUnsubscribe = undefined; + void this.#itermPetTransport?.dispose(); + this.#itermPetTransport = undefined; + setVerifiedItermPetAvailability(undefined); this.#welcomeComponent?.dispose(); this.#welcomeComponent = undefined; if (this.#sttController) { @@ -1607,6 +1651,8 @@ export class InteractiveMode implements InteractiveModeContext { // This prevents escape sequences from leaking to the parent shell over slow SSH. await this.ui.terminal.drainInput(1000); popTerminalTitle(); + await this.petWidget?.disposeAsync(); + await this.#itermPetTransport?.dispose(); this.stop(); // Print resumption hint if this is a persisted session diff --git a/packages/coding-agent/test/gajae-pet-widget.test.ts b/packages/coding-agent/test/gajae-pet-widget.test.ts index 24c29b3c44..631a686962 100644 --- a/packages/coding-agent/test/gajae-pet-widget.test.ts +++ b/packages/coding-agent/test/gajae-pet-widget.test.ts @@ -1,16 +1,18 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; +import * as tui from "@gajae-code/tui"; import { __animationSchedulerTestHooks, + buildGajaePixelFrames, Container, getCellDimensions, - ImageProtocol, + PET_SKINS, setCellDimensions, - TERMINAL, type TUI, + wrapITerm2RecordForTmux, } from "@gajae-code/tui"; import type { CustomEditor } from "../src/modes/components/custom-editor"; import { GajaePetWidget, PetFramedEditor } from "../src/modes/components/gajae-pet-widget"; -import { getPetPixelProtocol } from "../src/modes/components/pet-capability"; +import { setVerifiedItermPetAvailability } from "../src/modes/components/pet-capability"; function makeStubs(columns = 80, rows = 30) { const written: string[] = []; @@ -26,9 +28,22 @@ function makeStubs(columns = 80, rows = 30) { }, invalidate() {}, } as unknown as CustomEditor; + let renderRequests = 0; let emitter: (() => string | null) | undefined; let available = true; + let running = true; let failWrites = false; + let manualViewportActive = false; + let rasterToken = 0; + 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 }; + }> = []; + let delayRasterAcquire = false; + const rasterAcquireWaiters: Array<() => void> = []; const pendingTerminalCleanup: Array<{ payload: string; onDelivered?: () => void }> = []; const flushTerminalCleanup = () => { while (available && pendingTerminalCleanup.length > 0) { @@ -51,9 +66,17 @@ function makeStubs(columns = 80, rows = 30) { }, }; const ui = { - requestRender: () => {}, - setPostRenderEmitter: (fn?: () => string | null) => { - emitter = fn; + requestRender: () => renderRequests++, + setPostRenderEmitter: (fn?: () => string | { payload: string; onWritten?: () => void } | null) => { + emitter = fn + ? () => { + const emission = fn(); + if (!emission) return null; + if (typeof emission === "string") return emission; + if (available && !failWrites) emission.onWritten?.(); + return emission.payload; + } + : undefined; }, queueTerminalCleanup: (payload: string, onDelivered?: () => void) => { pendingTerminalCleanup.push({ payload, onDelivered }); @@ -62,7 +85,58 @@ function makeStubs(columns = 80, rows = 30) { get terminalAvailable() { return available; }, + get isRunning() { + return running; + }, + get manualViewportActive() { + return manualViewportActive; + }, terminal, + acquireRasterLease: async (request: { + ownerId: string; + rect: { column: number; row: number; width: number; height: number }; + erase: { type: string; bytes: Uint8Array }; + }) => { + rasterLeaseRequests.push({ rect: request.rect, erase: request.erase }); + const result = { + status: "acquired", + token: { ownerId: request.ownerId, generation: ++rasterToken, rect: request.rect }, + }; + if (!delayRasterAcquire) return result; + const deferred = Promise.withResolvers(); + rasterAcquireWaiters.push(() => deferred.resolve(result)); + return await deferred.promise; + }, + invalidateRasterLease: async (request: { token: unknown; cause?: string }) => { + invalidatedRasterLeases.push(request); + return { status: "invalidated" }; + }, + queueTerminalOutput: async ( + payload: string, + options?: { shouldWrite?: () => boolean; onWritten?: () => void }, + ) => { + if (options?.shouldWrite && !options.shouldWrite()) return { status: "stale-token" as const }; + if (failWrites || !available) return { status: "failed" as const }; + written.push(payload); + options?.onWritten?.(); + return { status: "written" as const }; + }, + submitTerminalOutput: async (request: { + operation: { + prefix?: Uint8Array; + replayPrefix?: Uint8Array; + records: Uint8Array[]; + suffix?: Uint8Array; + restoreCursorVisibility?: boolean; + }; + }) => { + rasterCursorVisibilityRestores.push(request.operation.restoreCursorVisibility); + if (request.operation.prefix) rasterOutputs.push(request.operation.prefix); + if (request.operation.replayPrefix) rasterOutputs.push(request.operation.replayPrefix); + rasterOutputs.push(...request.operation.records); + if (request.operation.suffix) rasterOutputs.push(request.operation.suffix); + return { status: "written" }; + }, } as unknown as TUI; const editorContainer = new Container(); const floorContainer = new Container(); @@ -75,6 +149,7 @@ function makeStubs(columns = 80, rows = 30) { written, getEmitter: () => emitter, getRenderedWidth: () => renderedWidth, + getRenderRequestCount: () => renderRequests, setTerminalSize: (nextColumns: number, nextRows: number) => { terminal.columns = nextColumns; terminal.rows = nextRows; @@ -82,14 +157,33 @@ function makeStubs(columns = 80, rows = 30) { setTerminalAvailable: (value: boolean) => { available = value; }, + setRunning: (value: boolean) => { + running = value; + }, setWriteFailure: (value: boolean) => { failWrites = value; }, + setManualViewportActive: (value: boolean) => { + manualViewportActive = value; + }, flushTerminalCleanup, getPendingTerminalCleanupCount: () => pendingTerminalCleanup.length, + getRasterOutputs: () => rasterOutputs, + getInvalidatedRasterLeases: () => invalidatedRasterLeases, + getRasterLeaseRequests: () => rasterLeaseRequests, + getRasterCursorVisibilityRestores: () => rasterCursorVisibilityRestores, + getPendingRasterAcquireCount: () => rasterAcquireWaiters.length, + setRasterAcquireDelayed: (value: boolean) => { + delayRasterAcquire = value; + if (!value) while (rasterAcquireWaiters.length) rasterAcquireWaiters.shift()?.(); + }, }; } +async function flushAsyncChain() { + for (let i = 0; i < 4; i++) await Promise.resolve(); +} + function makeWidget( columns = 80, rows = 30, @@ -97,7 +191,7 @@ function makeWidget( bottomOffset?: number; isWorking?: () => boolean; autoFlexGapMs?: [number, number] | null; - protocol?: "sixel" | "kitty" | "iterm2" | null; + protocol?: "sixel" | "kitty" | null; } = {}, ) { const stubs = makeStubs(columns, rows); @@ -108,27 +202,13 @@ function makeWidget( floorContainer: stubs.floorContainer, isWorking: options.isWorking ?? (() => false), getComposerBottomOffset: () => stubs.floorContainer.render(columns).length + (options.bottomOffset ?? 0), + syncManagedItermCursor: async () => true, forcePixelProtocol: options.protocol === null ? undefined : (options.protocol ?? "sixel"), autoFlexGapMs: options.autoFlexGapMs !== undefined ? options.autoFlexGapMs : null, }); return { ...stubs, widget }; } -const ITERM_WIDGET_GEOMETRY_FIXTURES = [ - { - name: "80x30 terminal with 9x18 cells", - terminal: { columns: 80, rows: 30 }, - cell: { widthPx: 9, heightPx: 18 }, - expected: { imageColumns: 4, imageRows: 2, renderedWidth: 75, cursor: "\x1b[28;76H" }, - }, - { - name: "120x40 terminal with 18x24 cells", - terminal: { columns: 120, rows: 40 }, - cell: { widthPx: 18, heightPx: 24 }, - expected: { imageColumns: 3, imageRows: 2, renderedWidth: 116, cursor: "\x1b[38;117H" }, - }, -] as const; - describe("GajaePetWidget", () => { afterEach(() => { __animationSchedulerTestHooks.reset(); @@ -136,24 +216,6 @@ describe("GajaePetWidget", () => { vi.restoreAllMocks(); }); - it("selects iTerm2 directly but blocks raw graphics under multiplexers", () => { - const terminal = TERMINAL as unknown as { imageProtocol: ImageProtocol | null }; - const originalProtocol = terminal.imageProtocol; - try { - terminal.imageProtocol = ImageProtocol.Iterm2; - expect(getPetPixelProtocol({})).toBe("iterm2"); - expect(getPetPixelProtocol({ TMUX: "/tmp/tmux-1/default,1,0" })).toBeNull(); - expect(getPetPixelProtocol({ STY: "screen.1" })).toBeNull(); - expect(getPetPixelProtocol({ ZELLIJ: "1" })).toBeNull(); - - terminal.imageProtocol = ImageProtocol.Sixel; - expect(getPetPixelProtocol({ TMUX: "1", PI_FORCE_IMAGE_PROTOCOL: "sixel" })).toBe("sixel"); - expect(getPetPixelProtocol({ TMUX: "1" })).toBeNull(); - } finally { - terminal.imageProtocol = originalProtocol; - } - }); - it("on: reserves a side area and registers the overlay emitter", () => { const { widget, editorContainer, getEmitter, getRenderedWidth } = makeWidget(); try { @@ -172,80 +234,6 @@ describe("GajaePetWidget", () => { } }); - it("falls back an unavailable saved skin to RedGajae", () => { - const { widget, getEmitter } = makeWidget(); - try { - widget.setMode("removed-skin"); - - expect(widget.mode).toBe("red"); - expect(getEmitter()).toBeDefined(); - } finally { - widget.dispose(); - } - }); - - for (const fixture of ITERM_WIDGET_GEOMETRY_FIXTURES) { - it(`keeps the adaptive iTerm2 footprint at the composer boundary: ${fixture.name}`, () => { - const original = getCellDimensions(); - setCellDimensions(fixture.cell); - const { widget, editorContainer, getRenderedWidth, getEmitter } = makeWidget( - fixture.terminal.columns, - fixture.terminal.rows, - { protocol: "iterm2" }, - ); - try { - widget.setMode("red"); - editorContainer.render(fixture.terminal.columns); - expect(getRenderedWidth()).toBe(fixture.expected.renderedWidth); - const payload = getEmitter()?.(); - expect(payload).toContain(fixture.expected.cursor); - expect(payload).toContain("\x1b]1337;File="); - expect(payload).toContain( - `width=${fixture.expected.imageColumns};height=${fixture.expected.imageRows};preserveAspectRatio=0`, - ); - expect(payload).toEndWith("\x1b\\"); - } finally { - setCellDimensions(original); - widget.dispose(); - } - }); - } - - it("clears iTerm2 frames on animation, resize, disable, and dispose", () => { - vi.useFakeTimers(); - const original = getCellDimensions(); - const { widget, written, getEmitter, setTerminalSize } = makeWidget(80, 30, { - protocol: "iterm2", - autoFlexGapMs: null, - }); - try { - widget.setMode("red"); - expect(getEmitter()?.()).toContain("\x1b]1337;File="); - written.length = 0; - - vi.advanceTimersByTime(1200); - expect(written.some(chunk => chunk.includes("\x1b[28;76H\x1b[4X") && chunk.includes("\x1b]1337;File="))).toBe( - true, - ); - - setCellDimensions({ widthPx: 12, heightPx: 24 }); - vi.advanceTimersByTime(100); - expect(getEmitter()?.()).toContain("width=4;height=2"); - - setTerminalSize(12, 30); - expect(getEmitter()?.()).toContain("\x1b[28;76H\x1b[4X"); - setTerminalSize(80, 30); - expect(getEmitter()?.()).toContain("\x1b]1337;File="); - - written.length = 0; - widget.setMode("off"); - expect(written.some(chunk => chunk.includes("\x1b[28;76H\x1b[4X"))).toBe(true); - } finally { - setCellDimensions(original); - widget.dispose(); - } - }); - it("owns and deletes a distinct Kitty image ID per widget", () => { const first = makeWidget(80, 30, { protocol: "kitty" }); const second = makeWidget(80, 30, { protocol: "kitty" }); @@ -272,6 +260,63 @@ describe("GajaePetWidget", () => { second.widget.dispose(); } }); + it("does not reuse a Kitty image ID while a protocol-switch delete is pending", () => { + const imageIds = [101, 101, 202, 101]; + vi.spyOn(crypto, "getRandomValues").mockImplementation(values => { + if (!values) throw new Error("expected a typed array"); + const ids = new Uint32Array( + values.buffer, + values.byteOffset, + values.byteLength / Uint32Array.BYTES_PER_ELEMENT, + ); + ids[0] = imageIds.shift() ?? 303; + return values; + }); + + let protocol: "kitty" | "sixel" = "kitty"; + vi.spyOn(GajaePetWidget, "pixelProtocol").mockImplementation(() => protocol); + const stubs = makeStubs(); + const widget = new GajaePetWidget({ + ui: stubs.ui, + editor: stubs.editor, + editorContainer: stubs.editorContainer, + floorContainer: stubs.floorContainer, + isWorking: () => false, + getComposerBottomOffset: () => 0, + syncManagedItermCursor: async () => true, + autoFlexGapMs: null, + }); + + widget.setMode("red"); + expect(stubs.getEmitter()?.()).toContain("i=101"); + + stubs.setWriteFailure(true); + protocol = "sixel"; + widget.setMode("blue"); + expect(stubs.getPendingTerminalCleanupCount()).toBe(1); + + stubs.setWriteFailure(false); + protocol = "kitty"; + widget.setMode("red"); + expect(stubs.getEmitter()?.()).toContain("i=202"); + + stubs.flushTerminalCleanup(); + widget.dispose(); + + const replacement = new GajaePetWidget({ + ui: stubs.ui, + editor: stubs.editor, + editorContainer: stubs.editorContainer, + floorContainer: stubs.floorContainer, + isWorking: () => false, + getComposerBottomOffset: () => 0, + syncManagedItermCursor: async () => true, + autoFlexGapMs: null, + }); + replacement.setMode("red"); + expect(stubs.getEmitter()?.()).toContain("i=101"); + replacement.dispose(); + }); it("clears the last Sixel footprint when disabled", () => { const { widget, written, getEmitter } = makeWidget(); @@ -305,19 +350,15 @@ describe("GajaePetWidget", () => { } }); - it("retains cleanup authority when the render write that carried it fails, and dispose retries", () => { + it("retains cleanup authority when the queued TUI output fails, and dispose retries", () => { const { widget, written, getEmitter, setTerminalSize, setTerminalAvailable } = makeWidget(); widget.setMode("red"); expect(getEmitter()?.()).toContain("\x1bP0;1;0q"); setTerminalSize(12, 30); - - // The emitter hands the cleanup payload to the TUI, but the enclosing - // render write fails (availability drops), so delivery is never - // acknowledged and the authority must survive. - expect(getEmitter()?.()).toContain("\x1b[28;76H\x1b[4X"); setTerminalAvailable(false); - expect(getEmitter()?.()).toBeNull(); + // The emitter supplies cleanup but its terminal write cannot acknowledge it. + expect(getEmitter()?.()).toContain("\x1b[28;76H\x1b[4X"); written.length = 0; setTerminalAvailable(true); widget.dispose(); @@ -331,14 +372,11 @@ describe("GajaePetWidget", () => { widget.setMode("red"); expect(getEmitter()?.()).toContain("\x1bP0;1;0q"); setTerminalSize(12, 30); - expect(getEmitter()?.()).toContain("\x1b[28;76H\x1b[4X"); - - // Frame write fails; authority is retained. setTerminalAvailable(false); - expect(getEmitter()?.()).toBeNull(); + expect(getEmitter()?.()).toContain("\x1b[28;76H\x1b[4X"); + expect(getEmitter()?.()).toContain("\x1b[28;76H\x1b[4X"); - // Recovered terminal: the emitter carries the erase again, and the - // following pass acknowledges the successful delivery. + // Recovery replays the retained erase once and then consumes it. setTerminalAvailable(true); expect(getEmitter()?.()).toContain("\x1b[28;76H\x1b[4X"); expect(getEmitter()?.()).toBeNull(); @@ -387,6 +425,7 @@ describe("GajaePetWidget", () => { floorContainer: stubs.floorContainer, isWorking: () => false, getComposerBottomOffset: () => 0, + syncManagedItermCursor: async () => true, forcePixelProtocol: "kitty", autoFlexGapMs: null, }); @@ -407,31 +446,343 @@ describe("GajaePetWidget", () => { expect(stubs.getPendingTerminalCleanupCount()).toBe(0); expect(stubs.written.some(chunk => chunk.includes("a=d,d=I,i=101"))).toBe(true); - const third = makeKitty(); - third.setMode("red"); - expect(stubs.getEmitter()?.()).toContain("i=101"); + const third = makeKitty(); + third.setMode("red"); + expect(stubs.getEmitter()?.()).toContain("i=101"); + second.dispose(); + third.dispose(); + }); + + it("completes logical teardown when the queued cleanup output fails", () => { + const { widget, editorContainer, getEmitter, getRenderedWidth, setWriteFailure } = makeWidget(); + widget.setMode("red"); + expect(getEmitter()?.()).toContain("\x1bP0;1;0q"); + + setWriteFailure(true); + widget.dispose(); + + // The failed queued output must not abort teardown: the shared emitter slot is + // released, the composer is unframed, and the widget is terminal. + expect(getEmitter()).toBeUndefined(); + expect(widget.mode).toBe("off"); + editorContainer.render(80); + expect(getRenderedWidth()).toBe(80); + widget.setMode("red"); + expect(getEmitter()).toBeUndefined(); + }); + + it("retires an emitted Sixel predecessor before successor takeover", async () => { + const stubs = makeStubs(); + const make = () => + new GajaePetWidget({ + ui: stubs.ui, + editor: stubs.editor, + editorContainer: stubs.editorContainer, + floorContainer: stubs.floorContainer, + isWorking: () => false, + getComposerBottomOffset: () => stubs.floorContainer.render(80).length, + syncManagedItermCursor: async () => true, + forcePixelProtocol: "sixel", + autoFlexGapMs: null, + }); + const first = make(); + first.setMode("red"); + expect(stubs.getEmitter()?.()).toContain("\x1bP0;1;0q"); + + const second = make(); + second.setMode("red"); + await flushAsyncChain(); + const successorEmitter = stubs.getEmitter(); + expect(successorEmitter).toBeDefined(); + + stubs.written.length = 0; + first.setMode("blue"); + first.dispose(); + vi.useFakeTimers(); + vi.advanceTimersByTime(2_000); + await flushAsyncChain(); + + expect(stubs.getEmitter()).toBe(successorEmitter); + expect(stubs.written).toHaveLength(0); + second.dispose(); + }); + it("does not let an emitted Kitty predecessor delete its successor", async () => { + const stubs = makeStubs(); + const make = () => + new GajaePetWidget({ + ui: stubs.ui, + editor: stubs.editor, + editorContainer: stubs.editorContainer, + floorContainer: stubs.floorContainer, + isWorking: () => false, + getComposerBottomOffset: () => stubs.floorContainer.render(80).length, + syncManagedItermCursor: async () => true, + forcePixelProtocol: "kitty", + autoFlexGapMs: null, + }); + const first = make(); + first.setMode("red"); + const firstPayload = stubs.getEmitter()?.(); + if (!firstPayload) throw new Error("expected first Kitty frame"); + const firstId = firstPayload.match(/i=(\d+)/)?.[1]; + if (!firstId) throw new Error("expected first Kitty image ID"); + + const second = make(); + second.setMode("red"); + const successorEmitter = stubs.getEmitter(); + stubs.written.length = 0; + first.setMode("blue"); + first.dispose(); + await flushAsyncChain(); + + expect(stubs.getEmitter()).toBe(successorEmitter); + expect(stubs.written.some(chunk => chunk.includes(`a=d,d=I,i=${firstId}`))).toBe(false); + second.dispose(); + }); + it("releases a retired Kitty image ID only after its delete is acknowledged", async () => { + const ids = [501, 502, 501]; + vi.spyOn(crypto, "getRandomValues").mockImplementation(values => { + if (!values) throw new Error("expected typed array"); + const output = new Uint32Array( + values.buffer, + values.byteOffset, + values.byteLength / Uint32Array.BYTES_PER_ELEMENT, + ); + output[0] = ids.shift() ?? 503; + return values; + }); + + const stubs = makeStubs(); + const make = () => + new GajaePetWidget({ + ui: stubs.ui, + editor: stubs.editor, + editorContainer: stubs.editorContainer, + floorContainer: stubs.floorContainer, + isWorking: () => false, + getComposerBottomOffset: () => stubs.floorContainer.render(80).length, + syncManagedItermCursor: async () => true, + forcePixelProtocol: "kitty", + autoFlexGapMs: null, + }); + const first = make(); + first.setMode("red"); + expect(stubs.getEmitter()?.()).toContain("i=501"); + + const second = make(); + second.setMode("red"); + await flushAsyncChain(); + + const third = make(); + third.setMode("red"); + expect(stubs.getEmitter()?.()).toContain("i=501"); + third.dispose(); + }); + it("prevents an off predecessor from reclaiming a live successor", () => { + const stubs = makeStubs(); + const make = () => + new GajaePetWidget({ + ui: stubs.ui, + editor: stubs.editor, + editorContainer: stubs.editorContainer, + floorContainer: stubs.floorContainer, + isWorking: () => false, + getComposerBottomOffset: () => stubs.floorContainer.render(80).length, + syncManagedItermCursor: async () => true, + forcePixelProtocol: "sixel", + autoFlexGapMs: null, + }); + const first = make(); + first.setMode("red"); + first.setMode("off"); + + const second = make(); + second.setMode("red"); + const successorEmitter = stubs.getEmitter(); + first.previewMode("blue"); + first.setMode("blue"); + + expect(first.mode).toBe("off"); + expect(stubs.getEmitter()).toBe(successorEmitter); + second.dispose(); + }); + it("allows the same widget to resume after an ordinary off transition", () => { + const { widget, getEmitter } = makeWidget(); + widget.setMode("red"); + widget.setMode("off"); + widget.setMode("blue"); + + expect(widget.mode).toBe("blue"); + expect(getEmitter()).toBeDefined(); + widget.dispose(); + }); + it("ignores stale off and remount calls after successor takeover", () => { + const stubs = makeStubs(); + const make = () => + new GajaePetWidget({ + ui: stubs.ui, + editor: stubs.editor, + editorContainer: stubs.editorContainer, + floorContainer: stubs.floorContainer, + isWorking: () => false, + getComposerBottomOffset: () => stubs.floorContainer.render(80).length, + syncManagedItermCursor: async () => true, + forcePixelProtocol: "sixel", + autoFlexGapMs: null, + }); + const first = make(); + first.setMode("red"); + const second = make(); + second.setMode("blue"); + const successorEmitter = stubs.getEmitter(); + const successorEditor = stubs.editorContainer.children[0]; + + stubs.written.length = 0; + first.setMode("off"); + first.remountComposer(); + + expect(stubs.getEmitter()).toBe(successorEmitter); + expect(stubs.editorContainer.children[0]).toBe(successorEditor); + expect(stubs.written).toHaveLength(0); + second.dispose(); + }); + it("retains emitted predecessor cleanup across an unavailable terminal takeover", () => { + const stubs = makeStubs(); + const make = () => + new GajaePetWidget({ + ui: stubs.ui, + editor: stubs.editor, + editorContainer: stubs.editorContainer, + floorContainer: stubs.floorContainer, + isWorking: () => false, + getComposerBottomOffset: () => stubs.floorContainer.render(80).length, + syncManagedItermCursor: async () => true, + forcePixelProtocol: "sixel", + autoFlexGapMs: null, + }); + const first = make(); + first.setMode("red"); + expect(stubs.getEmitter()?.()).toContain("\x1bP0;1;0q"); + + stubs.setTerminalAvailable(false); + const second = make(); + second.setMode("red"); + expect(stubs.getPendingTerminalCleanupCount()).toBe(1); + + stubs.setTerminalAvailable(true); + stubs.flushTerminalCleanup(); + expect(stubs.written.some(chunk => chunk.includes("\x1b[28;76H\x1b[4X"))).toBe(true); + expect(stubs.getEmitter()?.()).toContain("\x1bP0;1;0q"); + second.dispose(); + }); + it("retains every emitted Sixel footprint during a geometry-change takeover", () => { + const stubs = makeStubs(); + const make = () => + new GajaePetWidget({ + ui: stubs.ui, + editor: stubs.editor, + editorContainer: stubs.editorContainer, + floorContainer: stubs.floorContainer, + isWorking: () => false, + getComposerBottomOffset: () => stubs.floorContainer.render(80).length, + syncManagedItermCursor: async () => true, + forcePixelProtocol: "sixel", + autoFlexGapMs: null, + }); + const first = make(); + first.setMode("red"); + const firstEmitter = stubs.getEmitter(); + expect(firstEmitter?.()).toContain("\x1b[28;76H"); + + stubs.setTerminalSize(78, 30); + const resizedPayload = firstEmitter?.(); + expect(resizedPayload).toContain("\x1b[28;76H\x1b[4X"); + expect(resizedPayload).toContain("\x1b[28;74H"); + + const second = make(); + second.setMode("red"); + + expect(stubs.written.some(chunk => chunk.includes("\x1b[28;74H\x1b[4X"))).toBe(true); + second.dispose(); + }); + it("marks a queued predecessor frame stale after takeover", async () => { + vi.useFakeTimers(); + const stubs = makeStubs(); + const make = () => + new GajaePetWidget({ + ui: stubs.ui, + editor: stubs.editor, + editorContainer: stubs.editorContainer, + floorContainer: stubs.floorContainer, + isWorking: () => true, + getComposerBottomOffset: () => stubs.floorContainer.render(80).length, + syncManagedItermCursor: async () => true, + forcePixelProtocol: "sixel", + autoFlexGapMs: null, + }); + const queued: Array<{ payload: string; shouldWrite?: () => boolean }> = []; + ( + stubs.ui as unknown as { + queueTerminalOutput: ( + payload: string, + options?: { shouldWrite?: () => boolean }, + ) => Promise<{ status: "written" }>; + } + ).queueTerminalOutput = async (payload, options) => { + queued.push({ payload, shouldWrite: options?.shouldWrite }); + return { status: "written" }; + }; + + const first = make(); + first.setMode("red"); + vi.advanceTimersByTime(160); + await flushAsyncChain(); + const predecessorFrame = queued.find(output => output.shouldWrite !== undefined); + expect(predecessorFrame?.payload).toContain("\x1bP0;1;0q"); + expect(predecessorFrame?.shouldWrite?.()).toBe(true); + + const second = make(); + second.setMode("red"); + expect(predecessorFrame?.shouldWrite?.()).toBe(false); second.dispose(); - third.dispose(); }); - - it("completes logical teardown when the cleanup write throws", () => { - const { widget, editorContainer, getEmitter, getRenderedWidth, setWriteFailure } = makeWidget(); + it("marks a queued Sixel frame stale after terminal geometry changes", async () => { + vi.useFakeTimers(); + const stubs = makeStubs(); + const queued: Array<{ payload: string; shouldWrite?: () => boolean }> = []; + ( + stubs.ui as unknown as { + queueTerminalOutput: ( + payload: string, + options?: { shouldWrite?: () => boolean }, + ) => Promise<{ status: "written" }>; + } + ).queueTerminalOutput = async (payload, options) => { + queued.push({ payload, shouldWrite: options?.shouldWrite }); + return { status: "written" }; + }; + const widget = new GajaePetWidget({ + ui: stubs.ui, + editor: stubs.editor, + editorContainer: stubs.editorContainer, + floorContainer: stubs.floorContainer, + isWorking: () => true, + getComposerBottomOffset: () => stubs.floorContainer.render(80).length, + syncManagedItermCursor: async () => true, + forcePixelProtocol: "sixel", + autoFlexGapMs: null, + }); widget.setMode("red"); - expect(getEmitter()?.()).toContain("\x1bP0;1;0q"); - setWriteFailure(true); - widget.dispose(); + vi.advanceTimersByTime(160); + await flushAsyncChain(); + const queuedFrame = queued.find(output => output.shouldWrite !== undefined); + expect(queuedFrame?.shouldWrite?.()).toBe(true); - // The thrown write must not abort teardown: the shared emitter slot is - // released, the composer is unframed, and the widget is terminal. - expect(getEmitter()).toBeUndefined(); - expect(widget.mode).toBe("off"); - editorContainer.render(80); - expect(getRenderedWidth()).toBe(80); - widget.setMode("red"); - expect(getEmitter()).toBeUndefined(); + stubs.setTerminalSize(78, 30); + expect(queuedFrame?.shouldWrite?.()).toBe(false); + widget.dispose(); }); - it("keeps a disposed widget from clearing its successor's overlay emitter", () => { const stubs = makeStubs(); const make = () => @@ -442,6 +793,7 @@ describe("GajaePetWidget", () => { floorContainer: stubs.floorContainer, isWorking: () => false, getComposerBottomOffset: () => stubs.floorContainer.render(80).length, + syncManagedItermCursor: async () => true, forcePixelProtocol: "sixel", autoFlexGapMs: null, }); @@ -473,6 +825,7 @@ describe("GajaePetWidget", () => { floorContainer: stubs.floorContainer, isWorking: () => false, getComposerBottomOffset: () => stubs.floorContainer.render(80).length, + syncManagedItermCursor: async () => true, forcePixelProtocol: "sixel", autoFlexGapMs: null, }); @@ -517,6 +870,43 @@ describe("GajaePetWidget", () => { widget.dispose(); } }); + it("releases a delivered narrow-terminal Kitty image ID on final disposal", () => { + const imageIds = [101, 101, 202]; + vi.spyOn(crypto, "getRandomValues").mockImplementation(values => { + if (!values) throw new Error("expected a typed array"); + const ids = new Uint32Array( + values.buffer, + values.byteOffset, + values.byteLength / Uint32Array.BYTES_PER_ELEMENT, + ); + ids[0] = imageIds.shift() ?? 303; + return values; + }); + + const stubs = makeStubs(12, 30); + const makeKitty = () => + new GajaePetWidget({ + ui: stubs.ui, + editor: stubs.editor, + editorContainer: stubs.editorContainer, + floorContainer: stubs.floorContainer, + isWorking: () => false, + getComposerBottomOffset: () => 0, + syncManagedItermCursor: async () => true, + forcePixelProtocol: "kitty", + autoFlexGapMs: null, + }); + const first = makeKitty(); + first.setMode("red"); + expect(stubs.getEmitter()?.()).toContain("i=101"); + expect(stubs.getEmitter()?.()).toBeNull(); + first.dispose(); + + const replacement = makeKitty(); + replacement.setMode("red"); + expect(stubs.getEmitter()?.()).toContain("i=101"); + replacement.dispose(); + }); it("retains Sixel cleanup authority while the terminal is unavailable and erases once it returns", () => { const { widget, written, getEmitter, setTerminalAvailable } = makeWidget(); @@ -534,7 +924,7 @@ describe("GajaePetWidget", () => { expect(written.some(chunk => chunk.includes("\x1b[28;76H\x1b[4X"))).toBe(true); }); - it("retains Sixel cleanup authority when the erase write throws and retries on dispose", () => { + it("retains Sixel cleanup authority when queued erase output fails and retries on dispose", () => { const { widget, written, getEmitter, setWriteFailure } = makeWidget(); widget.setMode("red"); expect(getEmitter()?.()).toContain("\x1bP0;1;0q"); @@ -614,7 +1004,7 @@ describe("GajaePetWidget", () => { } }); - it("writes frames directly to the terminal when the UI is quiet", () => { + it("queues frames through the TUI when the UI is quiet", async () => { vi.useFakeTimers(); const { widget, written } = makeWidget(); try { @@ -622,6 +1012,7 @@ describe("GajaePetWidget", () => { written.length = 0; // Idle loop leaves "base" at 1100ms; advance into the gazeL window. vi.advanceTimersByTime(1200); + await flushAsyncChain(); expect(written.length).toBeGreaterThan(0); expect(written.some(chunk => chunk.includes("\x1b[?2026h\x1b7") && chunk.includes("\x1bP0;1;0q"))).toBe(true); // Transparent sixel frames clear only the reserved pet cells inside @@ -629,6 +1020,7 @@ describe("GajaePetWidget", () => { expect(written.some(chunk => chunk.includes("\x1b[0m") && chunk.includes("\x1b[4X"))).toBe(true); } finally { widget.dispose(); + await flushAsyncChain(); } }); @@ -656,18 +1048,17 @@ describe("GajaePetWidget", () => { } }); - it("auto-flexes randomly in both idle and working states", () => { + it("auto-flexes only while working and resets the idle schedule", () => { vi.useFakeTimers(); const idle = makeWidget(80, 30, { autoFlexGapMs: [500, 500] }); const busy = makeWidget(80, 30, { autoFlexGapMs: [500, 500], isWorking: () => true }); try { idle.widget.setMode("red"); busy.widget.setMode("red"); - // First tick schedules; the flex fires ~500ms later. vi.advanceTimersByTime(700); - expect(idle.widget.isFlexing).toBe(true); + expect(idle.widget.isFlexing).toBe(false); expect(busy.widget.isFlexing).toBe(true); - // The multi-beat burst (~2.6s) ends before the next scheduled flex (~3.7s). + // The multi-beat burst (~2.6s) ends before the next worker-only flex. vi.advanceTimersByTime(2800); expect(idle.widget.isFlexing).toBe(false); expect(busy.widget.isFlexing).toBe(false); @@ -676,14 +1067,30 @@ describe("GajaePetWidget", () => { busy.widget.dispose(); } }); + it("cancels an active worker burst as soon as work ends", () => { + vi.useFakeTimers(); + let working = true; + const { widget } = makeWidget(80, 30, { autoFlexGapMs: [500, 500], isWorking: () => working }); + try { + widget.setMode("blue"); + vi.advanceTimersByTime(700); + expect(widget.isFlexing).toBe(true); + + working = false; + vi.advanceTimersByTime(80); + expect(widget.isFlexing).toBe(false); + } finally { + widget.dispose(); + } + }); - it("runs a para-para-then-sob burst for BlueGajae", () => { + it("runs a para-para-then-sob burst for working BlueGajae", () => { vi.useFakeTimers(); - const { widget } = makeWidget(80, 30, { autoFlexGapMs: [500, 500] }); + const { widget } = makeWidget(80, 30, { autoFlexGapMs: [500, 500], isWorking: () => true }); try { widget.setMode("blue"); - // Burst fires ~500ms in; the para-para (~1.6s) plus sobbing tail (~1s) keeps - // it flexing at the 2s mark, well into the burst. + // A worker burst fires ~500ms in; the para-para (~1.6s) plus sobbing tail + // (~1s) keeps the explicit working burst active at the 2s mark. vi.advanceTimersByTime(2000); expect(widget.isFlexing).toBe(true); // The whole ~2.6s burst clears before the next scheduled burst. @@ -699,8 +1106,8 @@ describe("GajaePetWidget", () => { const { widget } = makeWidget(80, 30, { autoFlexGapMs: [12_000, 40_000] }); try { widget.previewMode("red"); - // Live auto-flex is 12-40s out; a preview forces the demo burst right after - // the idle eye-roll (~2.3s) so the selector shows the animation immediately. + // Preview explicitly schedules its burst after the idle eye-roll, independent + // of the worker-only automatic burst cadence. vi.advanceTimersByTime(2600); expect(widget.isFlexing).toBe(true); } finally { @@ -724,104 +1131,636 @@ describe("GajaePetWidget", () => { } }); - it("runs the Ouroboros infinity chase while working", () => { + it("off: unregisters emitter, floor, width and timers", () => { + const { widget, editorContainer, floorContainer, getEmitter, getRenderedWidth } = makeWidget(); + widget.setMode("red"); + widget.setMode("off"); + expect(getEmitter()).toBeUndefined(); + expect(floorContainer.render(80).length).toBe(0); + editorContainer.render(80); + expect(getRenderedWidth()).toBe(80); + expect(__animationSchedulerTestHooks.getRegistrantCount(80)).toBe(0); + }); + + it("stays off when no pixel protocol is available", () => { + vi.spyOn(GajaePetWidget, "pixelProtocol").mockReturnValue(null); + const stubs = makeStubs(); + const widget = new GajaePetWidget({ + ui: stubs.ui, + editor: stubs.editor, + editorContainer: stubs.editorContainer, + floorContainer: stubs.floorContainer, + isWorking: () => false, + getComposerBottomOffset: () => 0, + syncManagedItermCursor: async () => true, + }); + widget.setMode("red"); + expect(widget.mode).toBe("off"); + expect(stubs.getEmitter()).toBeUndefined(); + widget.dispose(); + }); + it("does not acquire or submit iTerm raster frames while the TUI is stopped, then resumes", async () => { vi.useFakeTimers(); - const { widget, written } = makeWidget(80, 30, { isWorking: () => true, autoFlexGapMs: null }); + const stubs = makeWidget(80, 30, { protocol: null }); try { - widget.setMode("ouroboros"); - written.length = 0; - - // One full retouched orbit is 8 × 220ms. - vi.advanceTimersByTime(2000); + setVerifiedItermPetAvailability({ available: true, mode: "direct", epoch: 1 }); + stubs.setRasterAcquireDelayed(true); + stubs.widget.setMode("red"); + vi.advanceTimersByTime(80); + await flushAsyncChain(); + expect(stubs.getPendingRasterAcquireCount()).toBe(1); + + stubs.setRunning(false); + stubs.setRasterAcquireDelayed(false); + await flushAsyncChain(); + vi.advanceTimersByTime(80); + await flushAsyncChain(); + expect(stubs.getRasterLeaseRequests()).toHaveLength(1); + expect(stubs.getRasterOutputs()).toHaveLength(0); + expect(stubs.getInvalidatedRasterLeases()).toHaveLength(1); + + stubs.setRunning(true); + vi.advanceTimersByTime(80); + await flushAsyncChain(); + expect(stubs.getRasterLeaseRequests()).toHaveLength(2); + expect( + stubs + .getRasterOutputs() + .map(output => new TextDecoder().decode(output)) + .some(output => output.includes("MultipartFile=")), + ).toBe(true); + } finally { + setVerifiedItermPetAvailability(undefined); + stubs.widget.dispose(); + } + }); + it("drops an in-flight predecessor iTerm lease before successor takeover", async () => { + vi.useFakeTimers(); + const stubs = makeStubs(); + const make = () => + new GajaePetWidget({ + ui: stubs.ui, + editor: stubs.editor, + editorContainer: stubs.editorContainer, + floorContainer: stubs.floorContainer, + isWorking: () => false, + getComposerBottomOffset: () => stubs.floorContainer.render(80).length, + syncManagedItermCursor: async () => true, + autoFlexGapMs: null, + }); + try { + setVerifiedItermPetAvailability({ available: true, mode: "direct", epoch: 1 }); + stubs.setRasterAcquireDelayed(true); + const first = make(); + first.setMode("red"); + vi.advanceTimersByTime(80); + await flushAsyncChain(); + expect(stubs.getPendingRasterAcquireCount()).toBe(1); + + const second = make(); + second.setMode("red"); + stubs.setRasterAcquireDelayed(false); + vi.advanceTimersByTime(80); + await flushAsyncChain(); + + const multipartHeaders = stubs + .getRasterOutputs() + .map(output => new TextDecoder().decode(output)) + .filter(output => output.includes("MultipartFile=")); + expect(multipartHeaders).toHaveLength(1); + second.dispose(); + } finally { + setVerifiedItermPetAvailability(undefined); + } + }); + it("anchors the iTerm pet bottom row to the composer's bottom edge", async () => { + vi.useFakeTimers(); + const stubs = makeWidget(80, 30, { bottomOffset: 2, protocol: null }); + try { + setVerifiedItermPetAvailability({ available: true, mode: "direct", epoch: 1 }); + stubs.widget.setMode("red"); + vi.advanceTimersByTime(80); + await flushAsyncChain(); + + // 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. + const records = stubs.getRasterOutputs().map(record => new TextDecoder().decode(record)); + expect(records[0]).toBe("\x1b[?2026h\x1b7\x1b[?25l\x1b[26;76H"); + } finally { + setVerifiedItermPetAvailability(undefined); + stubs.widget.dispose(); + } + }); - const spinFrames = new Set(written.filter(chunk => chunk.includes("\x1b[?2026h"))); - expect(spinFrames.size).toBeGreaterThanOrEqual(8); + it("keeps cursor and multipart ordering for direct and managed iTerm records", 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(); + const directRecords = stubs.getRasterOutputs().map(record => new TextDecoder().decode(record)); + expect(directRecords[0]).toBe("\x1b[?2026h\x1b7\x1b[?25l\x1b[28;76H"); + 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.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"); + expect(directRecords.at(-1)).toBe("\x1b8\x1b[?2026l"); + + stubs.widget.setMode("off"); + await flushAsyncChain(); + setVerifiedItermPetAvailability({ available: true, mode: "managed", epoch: 1 }); + stubs.widget.setMode("red"); + vi.advanceTimersByTime(80); + await flushAsyncChain(); + const managedRecords = stubs + .getRasterOutputs() + .slice(directRecords.length) + .map(record => new TextDecoder().decode(record)); + expect(managedRecords[0]).toBe(`${wrapITerm2RecordForTmux("\x1b[?2026h\x1b7\x1b[?25l")}\x1b7\x1b[28;76H`); + expect(managedRecords[1]).toBe("\x1b[28;76H"); + expect(managedRecords[2]).toContain("\x1bPtmux;\x1b\x1b]1337;MultipartFile="); + expect(managedRecords[2]).toContain("width=4;height=3;"); + expect(managedRecords[2]).not.toContain("\x1b[28;76H"); + expect(managedRecords.at(-2)).toBe("\x1bPtmux;\x1b\x1b]1337;FileEnd\x07\x1b\\"); + expect(managedRecords.at(-1)).toBe(`${wrapITerm2RecordForTmux("\x1b8\x1b[?2026l")}\x1b8`); + expect(managedRecords.slice(3, -2).every(record => record.startsWith("\x1bPtmux;"))).toBe(true); + expect(managedRecords.slice(3, -2).every(record => !record.includes("\x1b[28;76H"))).toBe(true); } finally { - widget.dispose(); + setVerifiedItermPetAvailability(undefined); + stubs.widget.dispose(); + } + }); + it("updates iTerm cell geometry atomically when font metrics change", async () => { + vi.useFakeTimers(); + const original = getCellDimensions(); + const stubs = makeWidget(80, 30, { protocol: null }); + try { + setVerifiedItermPetAvailability({ available: true, mode: "direct", epoch: 1 }); + stubs.widget.setMode("red"); + vi.advanceTimersByTime(80); + await flushAsyncChain(); + stubs.editorContainer.render(80); + const previousRecordCount = stubs.getRasterOutputs().length; + expect(stubs.getRenderedWidth()).toBe(75); + + const renderRequestsBeforeResize = stubs.getRenderRequestCount(); + setCellDimensions({ widthPx: 18, heightPx: 18 }); + vi.advanceTimersByTime(80); + await flushAsyncChain(); + expect(stubs.getRenderRequestCount()).toBeGreaterThan(renderRequestsBeforeResize); + stubs.editorContainer.render(80); + const resizedRecords = stubs + .getRasterOutputs() + .slice(previousRecordCount) + .map(record => new TextDecoder().decode(record)); + expect(stubs.getRenderedWidth()).toBe(77); + expect(resizedRecords[0]).toBe("\x1b[?2026h\x1b7\x1b[?25l\x1b[28;78H"); + expect(resizedRecords[1]).toContain("width=2;height=3;"); + } finally { + setCellDimensions(original); + setVerifiedItermPetAvailability(undefined); + stubs.widget.dispose(); + } + }); + it("skips an iTerm canvas only when no safety row can remain", async () => { + vi.useFakeTimers(); + const stubs = makeWidget(80, 3, { protocol: null }); + try { + setVerifiedItermPetAvailability({ available: true, mode: "direct", epoch: 1 }); + stubs.widget.setMode("red"); + vi.advanceTimersByTime(80); + await flushAsyncChain(); + + expect(stubs.getRasterOutputs()).toHaveLength(0); + expect(stubs.getRasterLeaseRequests()).toHaveLength(0); + + stubs.setTerminalSize(80, 4); + vi.advanceTimersByTime(80); + await flushAsyncChain(); + const lease = stubs.getRasterLeaseRequests()[0]; + expect(lease?.rect).toEqual({ column: 75, row: 0, width: 4, height: 3 }); + 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", + ); + } finally { + setVerifiedItermPetAvailability(undefined); + stubs.widget.dispose(); } }); - it("occasionally leaves the Ouroboros work loop for heart and three-drop cry bursts", () => { + it("guards direct and managed iTerm raster submission when the framed editor cannot fit", async () => { vi.useFakeTimers(); - vi.spyOn(Math, "random").mockReturnValue(0); - const { widget, written } = makeWidget(80, 30, { - isWorking: () => true, + for (const mode of ["direct", "managed"] as const) { + const stubs = makeWidget(12, 30, { protocol: null }); + try { + setVerifiedItermPetAvailability({ available: true, mode, epoch: 1 }); + stubs.widget.setMode("red"); + stubs.editorContainer.render(12); + vi.advanceTimersByTime(80); + await flushAsyncChain(); + expect(stubs.getRasterOutputs()).toHaveLength(0); + expect(stubs.getRenderedWidth()).toBe(12); + + stubs.setTerminalSize(80, 30); + stubs.editorContainer.render(80); + expect(stubs.getRenderedWidth()).toBe(75); + vi.advanceTimersByTime(80); + await flushAsyncChain(); + const normalRecords = stubs.getRasterOutputs().map(record => new TextDecoder().decode(record)); + expect(normalRecords[0]).toBe( + mode === "managed" + ? `${wrapITerm2RecordForTmux("\x1b[?2026h\x1b7\x1b[?25l")}\x1b7\x1b[28;76H` + : "\x1b[?2026h\x1b7\x1b[?25l\x1b[28;76H", + ); + expect(normalRecords.some(record => record.includes("MultipartFile="))).toBe(true); + expect(normalRecords.at(-2)).toContain("FileEnd"); + expect(normalRecords.at(-1)).toContain("\x1b[?2026l"); + + stubs.setTerminalSize(12, 30); + stubs.editorContainer.render(12); + expect(stubs.getRenderedWidth()).toBe(12); + vi.advanceTimersByTime(80); + await flushAsyncChain(); + const beforeNarrowing = stubs.getRasterOutputs().length; + const cleanup = stubs.getEmitter()?.(); + expect(cleanup ?? "").not.toContain("MultipartFile="); + await flushAsyncChain(); + expect(stubs.getRasterOutputs()).toHaveLength(beforeNarrowing); + expect(stubs.getInvalidatedRasterLeases().at(-1)?.cause).toBe("resize"); + } finally { + setVerifiedItermPetAvailability(undefined); + stubs.widget.dispose(); + } + } + }); + it("drops stale async completion after mode-off", async () => { + vi.useFakeTimers(); + const stubs = makeWidget(80, 30, { protocol: null }); + try { + setVerifiedItermPetAvailability({ available: true, mode: "direct", epoch: 1 }); + stubs.setRasterAcquireDelayed(true); + stubs.widget.setMode("red"); + vi.advanceTimersByTime(80); + await flushAsyncChain(); + stubs.widget.setMode("off"); + stubs.setRasterAcquireDelayed(false); + await flushAsyncChain(); + expect(stubs.getRasterOutputs()).toHaveLength(0); + } finally { + setVerifiedItermPetAvailability(undefined); + stubs.widget.dispose(); + } + }); + it("coalesces animation ticks while an iTerm raster submission is pending", async () => { + vi.useFakeTimers(); + const stubs = makeWidget(80, 30, { protocol: null }); + try { + setVerifiedItermPetAvailability({ available: true, mode: "direct", epoch: 1 }); + stubs.setRasterAcquireDelayed(true); + stubs.widget.setMode("red"); + vi.advanceTimersByTime(800); + await flushAsyncChain(); + + expect(stubs.getPendingRasterAcquireCount()).toBe(1); + expect(stubs.getRasterOutputs()).toHaveLength(0); + + stubs.setRasterAcquireDelayed(false); + await flushAsyncChain(); + const headers = stubs + .getRasterOutputs() + .map(record => new TextDecoder().decode(record)) + .filter(record => record.includes("MultipartFile=")); + expect(headers).toHaveLength(1); + expect(stubs.getInvalidatedRasterLeases()).toHaveLength(0); + } finally { + setVerifiedItermPetAvailability(undefined); + stubs.widget.dispose(); + } + }); + it("runs scheduled auto-flex bursts on the iTerm raster path", async () => { + vi.useFakeTimers(); + const stubs = makeWidget(80, 30, { + protocol: null, autoFlexGapMs: [500, 500], + isWorking: () => true, }); try { - widget.setMode("ouroboros"); - written.length = 0; + setVerifiedItermPetAvailability({ available: true, mode: "direct", epoch: 1 }); + stubs.widget.setMode("red"); + vi.advanceTimersByTime(80); + await flushAsyncChain(); + expect(stubs.widget.isFlexing).toBe(false); + + vi.advanceTimersByTime(560); + 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]); + } finally { + setVerifiedItermPetAvailability(undefined); + stubs.widget.dispose(); + } + }); + it("keeps the iTerm GIF on its idle timeline while inactive", async () => { + vi.useFakeTimers(); + const stubs = makeWidget(80, 30, { protocol: null, autoFlexGapMs: [500, 500] }); + try { + setVerifiedItermPetAvailability({ available: true, mode: "direct", epoch: 1 }); + stubs.widget.setMode("blue"); + vi.advanceTimersByTime(80); + await flushAsyncChain(); - vi.advanceTimersByTime(15_000); - expect(widget.isFlexing).toBe(true); - const heartWrites = written.length; - vi.advanceTimersByTime(5_000); - expect(widget.isFlexing).toBe(false); + vi.advanceTimersByTime(700); + await flushAsyncChain(); + expect(stubs.widget.isFlexing).toBe(false); + expect( + stubs + .getRasterOutputs() + .map(record => new TextDecoder().decode(record)) + .filter(record => record.includes("MultipartFile=")), + ).toHaveLength(1); + } finally { + setVerifiedItermPetAvailability(undefined); + stubs.widget.dispose(); + } + }); + it("uses only Ouroboros frames for raster overlays and iTerm GIFs", async () => { + vi.useFakeTimers(); + let working = false; + const sixel = makeWidget(80, 30, { protocol: "sixel", isWorking: () => working }); + const cell = getCellDimensions(); + const ouroboros = PET_SKINS.ouroboros; + const expectedSixel = buildGajaePixelFrames({ + protocol: "sixel", + skin: "ouroboros", + cellWidthPx: cell.widthPx, + cellHeightPx: cell.heightPx, + targetRows: 2, + sixelTopPaddingPx: 9, + }); + try { + sixel.widget.setMode("ouroboros"); + expect(sixel.getEmitter()?.()).toContain(expectedSixel.frames.idle); - vi.advanceTimersByTime(12_500); - expect(widget.isFlexing).toBe(true); - expect(written.length).toBeGreaterThan(heartWrites); - vi.advanceTimersByTime(3_500); - expect(widget.isFlexing).toBe(false); + working = true; + vi.advanceTimersByTime(80); + await flushAsyncChain(); + const workEnterFrame = ouroboros.workEnter?.[0]?.[0]; + expect(workEnterFrame).toBeDefined(); + expect(sixel.written.some(chunk => chunk.includes(expectedSixel.frames[workEnterFrame!]))).toBe(true); + } finally { + sixel.widget.dispose(); + } - written.length = 0; - vi.advanceTimersByTime(1000); - const resumedSpinFrames = new Set(written.filter(chunk => chunk.includes("\x1b[?2026h"))); - expect(resumedSpinFrames.size).toBeGreaterThanOrEqual(4); + const iterm = makeWidget(80, 30, { protocol: null, autoFlexGapMs: null }); + try { + setVerifiedItermPetAvailability({ available: true, mode: "direct", epoch: 1 }); + iterm.widget.setMode("ouroboros"); + vi.advanceTimersByTime(80); + await flushAsyncChain(); + + const columns = Math.ceil((2 * cell.heightPx) / cell.widthPx); + const expectedGif = tui.getGajaePetGifCached({ + skin: "ouroboros", + timeline: ouroboros.idle.map(([name, delayMs]) => ({ name, delayMs })), + targetRows: 2, + rectangle: { width: columns * cell.widthPx, height: 3 * cell.heightPx }, + contentInset: { topPx: Math.floor(cell.heightPx / 2), bottomPx: Math.ceil(cell.heightPx / 2) }, + displaySize: { width: columns, height: 3 }, + }); + const records = iterm.getRasterOutputs().map(record => new TextDecoder().decode(record)); + for (const record of expectedGif.multipart) expect(records).toContain(record); } finally { - widget.dispose(); + setVerifiedItermPetAvailability(undefined); + iterm.widget.dispose(); } }); - it("enters and exits the Ouroboros work loop through its authored transition", () => { + it("drops a stale iTerm worker GIF when activity ends during lease acquisition", async () => { + vi.useFakeTimers(); + let working = true; + const stubs = makeWidget(80, 30, { + protocol: null, + isWorking: () => working, + }); + try { + setVerifiedItermPetAvailability({ available: true, mode: "direct", epoch: 1 }); + stubs.setRasterAcquireDelayed(true); + stubs.widget.setMode("red"); + vi.advanceTimersByTime(80); + await flushAsyncChain(); + expect(stubs.getPendingRasterAcquireCount()).toBe(1); + + working = false; + stubs.setRasterAcquireDelayed(false); + await flushAsyncChain(); + expect( + stubs + .getRasterOutputs() + .map(record => new TextDecoder().decode(record)) + .some(record => record.includes("MultipartFile=")), + ).toBe(false); + + vi.advanceTimersByTime(80); + await flushAsyncChain(); + expect( + stubs + .getRasterOutputs() + .map(record => new TextDecoder().decode(record)) + .filter(record => record.includes("MultipartFile=")), + ).toHaveLength(1); + } finally { + setVerifiedItermPetAvailability(undefined); + stubs.widget.dispose(); + } + }); + it("drops an iTerm lease acquired after terminal geometry changes", async () => { + vi.useFakeTimers(); + const stubs = makeWidget(80, 30, { protocol: null }); + try { + setVerifiedItermPetAvailability({ available: true, mode: "direct", epoch: 1 }); + stubs.setRasterAcquireDelayed(true); + stubs.widget.setMode("red"); + vi.advanceTimersByTime(80); + await flushAsyncChain(); + expect(stubs.getPendingRasterAcquireCount()).toBe(1); + + stubs.setTerminalSize(79, 30); + stubs.setRasterAcquireDelayed(false); + await flushAsyncChain(); + + expect( + stubs + .getRasterOutputs() + .map(record => new TextDecoder().decode(record)) + .some(record => record.includes("MultipartFile=")), + ).toBe(false); + } finally { + setVerifiedItermPetAvailability(undefined); + stubs.widget.dispose(); + } + }); + it("suspends iTerm submissions while the manual viewport owns history", async () => { + vi.useFakeTimers(); + const stubs = makeWidget(80, 30, { protocol: null }); + try { + setVerifiedItermPetAvailability({ available: true, mode: "direct", epoch: 1 }); + stubs.setManualViewportActive(true); + stubs.widget.setMode("red"); + vi.advanceTimersByTime(160); + await flushAsyncChain(); + expect(stubs.getRasterOutputs()).toHaveLength(0); + + stubs.setManualViewportActive(false); + vi.advanceTimersByTime(80); + await flushAsyncChain(); + expect( + stubs + .getRasterOutputs() + .map(record => new TextDecoder().decode(record)) + .some(record => record.includes("MultipartFile=")), + ).toBe(true); + } finally { + setVerifiedItermPetAvailability(undefined); + stubs.widget.dispose(); + } + }); + it("drops an iTerm lease acquired after manual history begins", async () => { + vi.useFakeTimers(); + const stubs = makeWidget(80, 30, { protocol: null }); + try { + setVerifiedItermPetAvailability({ available: true, mode: "direct", epoch: 1 }); + stubs.setRasterAcquireDelayed(true); + stubs.widget.setMode("red"); + vi.advanceTimersByTime(80); + await flushAsyncChain(); + expect(stubs.getPendingRasterAcquireCount()).toBe(1); + + stubs.setManualViewportActive(true); + stubs.setRasterAcquireDelayed(false); + await flushAsyncChain(); + expect( + stubs + .getRasterOutputs() + .map(record => new TextDecoder().decode(record)) + .some(record => record.includes("MultipartFile=")), + ).toBe(false); + expect(stubs.getInvalidatedRasterLeases().at(-1)?.cause).toBe("manual-viewport"); + + stubs.setManualViewportActive(false); + vi.advanceTimersByTime(80); + await flushAsyncChain(); + expect( + stubs + .getRasterOutputs() + .map(record => new TextDecoder().decode(record)) + .some(record => record.includes("MultipartFile=")), + ).toBe(true); + } finally { + setVerifiedItermPetAvailability(undefined); + stubs.widget.dispose(); + } + }); + it("reuses one raster lease and applies cursor visibility for idle-working-idle transitions", async () => { vi.useFakeTimers(); let working = false; - const { widget, written } = makeWidget(80, 30, { isWorking: () => working, autoFlexGapMs: null }); + const stubs = makeWidget(80, 30, { protocol: null, isWorking: () => working }); try { - widget.setMode("ouroboros"); - written.length = 0; + setVerifiedItermPetAvailability({ available: true, mode: "direct", epoch: 1 }); + stubs.widget.setMode("red"); + vi.advanceTimersByTime(80); + await flushAsyncChain(); + expect(stubs.getRasterCursorVisibilityRestores()).toEqual([true]); + expect( + new TextDecoder().decode( + stubs.getRasterOutputs().find(record => new TextDecoder().decode(record).includes("MultipartFile="))!, + ), + ).toContain("MultipartFile="); working = true; - vi.advanceTimersByTime(400); - const enterFrames = new Set(written.filter(chunk => chunk.includes("\x1b[?2026h"))); - expect(enterFrames.size).toBeGreaterThanOrEqual(3); + vi.advanceTimersByTime(80); + await flushAsyncChain(); + const replacementPrefix = stubs + .getRasterOutputs() + .map(record => new TextDecoder().decode(record)) + .find(record => record === "\x1b[?2026h\x1b7\x1b[?25l\x1b[28;76H"); + expect(replacementPrefix).toBe("\x1b[?2026h\x1b7\x1b[?25l\x1b[28;76H"); + expect(stubs.getRasterCursorVisibilityRestores()).toEqual([true, true]); - written.length = 0; working = false; - vi.advanceTimersByTime(520); - const exitFrames = new Set(written.filter(chunk => chunk.includes("\x1b[?2026h"))); - expect(exitFrames.size).toBeGreaterThanOrEqual(3); + vi.advanceTimersByTime(80); + await flushAsyncChain(); + expect(stubs.getRasterCursorVisibilityRestores()).toEqual([true, true, true]); + expect(stubs.getInvalidatedRasterLeases()).toHaveLength(0); } finally { - widget.dispose(); + setVerifiedItermPetAvailability(undefined); + stubs.widget.dispose(); } }); + it("replaces the managed iTerm GIF without blanking its footprint", async () => { + vi.useFakeTimers(); + let working = false; + const stubs = makeWidget(80, 30, { protocol: null, isWorking: () => working }); + try { + setVerifiedItermPetAvailability({ available: true, mode: "managed", epoch: 1 }); + stubs.widget.setMode("red"); + vi.advanceTimersByTime(80); + await flushAsyncChain(); - it("off: unregisters emitter, floor, width and timers", () => { - const { widget, editorContainer, floorContainer, getEmitter, getRenderedWidth } = makeWidget(); - widget.setMode("red"); - widget.setMode("off"); - expect(getEmitter()).toBeUndefined(); - expect(floorContainer.render(80).length).toBe(0); - editorContainer.render(80); - expect(getRenderedWidth()).toBe(80); - expect(__animationSchedulerTestHooks.getRegistrantCount(80)).toBe(0); + working = true; + vi.advanceTimersByTime(80); + await flushAsyncChain(); + + const expectedPrefix = `${wrapITerm2RecordForTmux("\x1b[?2026h\x1b7\x1b[?25l")}\x1b7\x1b[28;76H`; + expect( + stubs + .getRasterOutputs() + .map(record => new TextDecoder().decode(record)) + .filter(record => record === expectedPrefix), + ).toHaveLength(2); + expect(stubs.getInvalidatedRasterLeases()).toHaveLength(0); + } finally { + setVerifiedItermPetAvailability(undefined); + stubs.widget.dispose(); + } }); + it("settles after replacing the idle raster with the working raster", async () => { + vi.useFakeTimers(); + let working = false; + const stubs = makeWidget(80, 30, { protocol: null, isWorking: () => working }); + try { + setVerifiedItermPetAvailability({ available: true, mode: "direct", epoch: 1 }); + stubs.widget.setMode("red"); + vi.advanceTimersByTime(80); + await flushAsyncChain(); - it("stays off when no pixel protocol is available", () => { - vi.spyOn(GajaePetWidget, "pixelProtocol").mockReturnValue(null); - const stubs = makeStubs(); - const widget = new GajaePetWidget({ - ui: stubs.ui, - editor: stubs.editor, - editorContainer: stubs.editorContainer, - floorContainer: stubs.floorContainer, - isWorking: () => false, - getComposerBottomOffset: () => 0, - }); - widget.setMode("red"); - expect(widget.mode).toBe("off"); - expect(stubs.getEmitter()).toBeUndefined(); - widget.dispose(); + working = true; + vi.advanceTimersByTime(80); + await flushAsyncChain(); + vi.advanceTimersByTime(800); + await flushAsyncChain(); + + const headers = stubs + .getRasterOutputs() + .map(record => new TextDecoder().decode(record)) + .filter(record => record.includes("MultipartFile=")); + expect(headers).toHaveLength(2); + expect(stubs.getInvalidatedRasterLeases()).toHaveLength(0); + } finally { + setVerifiedItermPetAvailability(undefined); + stubs.widget.dispose(); + } }); }); diff --git a/packages/coding-agent/test/input-controller-keybindings.test.ts b/packages/coding-agent/test/input-controller-keybindings.test.ts index c8b75cca7b..23d2438a54 100644 --- a/packages/coding-agent/test/input-controller-keybindings.test.ts +++ b/packages/coding-agent/test/input-controller-keybindings.test.ts @@ -1193,6 +1193,55 @@ describe("InputController pasted image path transactions", () => { } }); + it("consumes iTerm's generated Gajae Pet drag path without changing the composer", async () => { + const { InputController, ctx, editor, spies } = await createContext(); + const controller = new InputController(ctx); + controller.setupKeyHandlers(); + + const handled = await editor.onPasteText?.( + "/var/folders/cp/9506bhz103gc1rg1k4xq3vcw0000gn/T/iTerm2.d54w3v.gajae-pet.gif\n", + pasteTextContext(), + ); + + expect(handled).toBe(true); + expect(editor.getText()).toBe(""); + expect(ctx.pendingImages).toEqual([]); + expect(spies.showStatus).toHaveBeenCalledWith("Ignored dragged Gajae Pet image.", { dim: true }); + }); + + it("consumes marked iTerm Pet drag paths in Bash and Python modes but leaves ordinary image paths literal", async () => { + const dragPaths = [ + "/var/folders/cp/9506bhz103gc1rg1k4xq3vcw0000gn/T/iTerm2.d54w3v.gajae-pet.gif\n", + "/var/folders/cp/9506bhz103gc1rg1k4xq3vcw0000gn/T/iTerm2.d54w3v.gajae-pet.png\n", + ]; + for (const mode of ["bash", "python"] as const) { + for (const dragPath of dragPaths) { + const { InputController, ctx, editor, spies } = await createContext(); + ctx.isBashMode = mode === "bash"; + ctx.isPythonMode = mode === "python"; + const controller = new InputController(ctx); + controller.setupKeyHandlers(); + + const handled = await editor.onPasteText?.(dragPath, pasteTextContext()); + + expect(handled).toBe(true); + expect(editor.getText()).toBe(""); + expect(ctx.pendingImages).toEqual([]); + expect(spies.showStatus).toHaveBeenCalledWith("Ignored dragged Gajae Pet image.", { dim: true }); + } + + const { InputController, ctx, editor, spies } = await createContext(); + ctx.isBashMode = mode === "bash"; + ctx.isPythonMode = mode === "python"; + const controller = new InputController(ctx); + controller.setupKeyHandlers(); + const handled = await editor.onPasteText?.("/tmp/ordinary-image.png\n", pasteTextContext()); + + expect(handled).toBe(false); + expect(spies.showStatus).not.toHaveBeenCalled(); + } + }); + it("confirms and atomically attaches saved-image batches in source order", async () => { const directory = await fs.mkdtemp(path.join(os.tmpdir(), "gjc-controller-pasted-images-")); const first = path.join(directory, "first.png"); diff --git a/packages/coding-agent/test/interactive-mode-editor-component.test.ts b/packages/coding-agent/test/interactive-mode-editor-component.test.ts index 7237036433..4b9b179ec4 100644 --- a/packages/coding-agent/test/interactive-mode-editor-component.test.ts +++ b/packages/coding-agent/test/interactive-mode-editor-component.test.ts @@ -4,6 +4,10 @@ import { Agent } from "@gajae-code/agent-core"; import type { AssistantMessage } from "@gajae-code/ai"; import { formatKeyHint, formatKeyHints, type KeyDisplayContext } from "@gajae-code/coding-agent/config/keybindings"; import { resetSettingsForTest, Settings, settings } from "@gajae-code/coding-agent/config/settings"; +import { + PET_CAPABILITY_SETTLE_MS, + setVerifiedItermPetAvailability, +} from "@gajae-code/coding-agent/modes/components/pet-capability"; import { initTheme, theme } from "@gajae-code/coding-agent/modes/theme/theme"; import { CURSOR_MARKER, ImageProtocol, setTerminalImageProtocol, TERMINAL, Text, visibleWidth } from "@gajae-code/tui"; import { TempDir } from "@gajae-code/utils"; @@ -115,6 +119,177 @@ describe("InteractiveMode.setEditorComponent", () => { expect(reconcile).toHaveBeenCalledTimes(1); }); + it("does not spend the iTerm pet warning deadline during pre-start initialization", async () => { + const originalProtocol = TERMINAL.imageProtocol; + const envKeys = [ + "TERM_PROGRAM", + "TERM_PROGRAM_VERSION", + "TERM", + "TMUX", + "TMUX_PANE", + "STY", + "ZELLIJ", + "GJC_TMUX_LAUNCHED", + "GJC_TMUX_ACTIVE_SESSION", + "GJC_MANAGED_OWNER_RUN_ID", + ] as const; + const originalEnv = new Map(envKeys.map(key => [key, Bun.env[key]] as const)); + vi.useFakeTimers(); + try { + mode.stop(); + setVerifiedItermPetAvailability(undefined); + setTerminalImageProtocol(null); + for (const key of envKeys) delete Bun.env[key]; + Bun.env.TERM_PROGRAM = "iTerm.app"; + Bun.env.TERM_PROGRAM_VERSION = "3.6.11"; + Bun.env.TERM = "xterm-256color"; + settings.set("pet.mode", "red"); + settings.set("startup.quiet", true); + mode = new InteractiveMode(session, "test"); + const showStatus = vi.spyOn(mode, "showStatus").mockImplementation(() => {}); + const startupOrder: string[] = []; + vi.spyOn(session, "getTodoPhases").mockImplementation(() => { + startupOrder.push("todos"); + vi.advanceTimersByTime(PET_CAPABILITY_SETTLE_MS); + return []; + }); + vi.spyOn(mode.ui, "start").mockImplementation(() => { + startupOrder.push("ui.start"); + }); + + await mode.init(); + + expect(startupOrder).toEqual(["todos", "ui.start"]); + expect(showStatus).not.toHaveBeenCalled(); + vi.advanceTimersByTime(PET_CAPABILITY_SETTLE_MS - 1); + expect(showStatus).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(showStatus).toHaveBeenCalledTimes(1); + expect(showStatus.mock.calls[0]?.[0]).toContain("Pets aren’t available"); + expect(showStatus.mock.calls[0]?.[0]).not.toContain("(unknown)"); + } finally { + mode.stop(); + setVerifiedItermPetAvailability(undefined); + setTerminalImageProtocol(originalProtocol); + for (const key of envKeys) { + const value = originalEnv.get(key); + if (value === undefined) delete Bun.env[key]; + else Bun.env[key] = value; + } + vi.useRealTimers(); + } + }); + + it("shows the plain pet warning without a reason suffix on non-iTerm terminals", async () => { + const originalProtocol = TERMINAL.imageProtocol; + const envKeys = [ + "TERM_PROGRAM", + "TERM_PROGRAM_VERSION", + "TMUX", + "TMUX_PANE", + "STY", + "ZELLIJ", + "GJC_TMUX_LAUNCHED", + "GJC_TMUX_ACTIVE_SESSION", + "GJC_MANAGED_OWNER_RUN_ID", + ] as const; + const originalEnv = new Map(envKeys.map(key => [key, Bun.env[key]] as const)); + vi.useFakeTimers(); + try { + mode.stop(); + setVerifiedItermPetAvailability(undefined); + setTerminalImageProtocol(null); + for (const key of envKeys) delete Bun.env[key]; + Bun.env.TERM_PROGRAM = "vscode"; + Bun.env.TERM = "xterm-256color"; + settings.set("pet.mode", "red"); + settings.set("startup.quiet", true); + mode = new InteractiveMode(session, "test"); + const showStatus = vi.spyOn(mode, "showStatus").mockImplementation(() => {}); + vi.spyOn(mode.ui, "start").mockImplementation(() => {}); + + await mode.init(); + vi.advanceTimersByTime(PET_CAPABILITY_SETTLE_MS * 2); + + expect(showStatus).toHaveBeenCalledTimes(1); + const startupWarning = showStatus.mock.calls[0]?.[0] ?? ""; + expect(startupWarning).toContain("Pets aren’t available"); + expect(startupWarning).not.toContain("("); + + showStatus.mockClear(); + expect(mode.setPetMode("red")).toBe(false); + expect(showStatus).toHaveBeenCalledTimes(1); + const refusal = showStatus.mock.calls[0]?.[0] ?? ""; + expect(refusal).toContain("Pets aren’t available"); + expect(refusal).not.toContain("("); + } finally { + mode.stop(); + setVerifiedItermPetAvailability(undefined); + setTerminalImageProtocol(originalProtocol); + for (const key of envKeys) { + const value = originalEnv.get(key); + if (value === undefined) delete Bun.env[key]; + else Bun.env[key] = value; + } + vi.useRealTimers(); + } + }); + + it("appends the concrete iTerm transport reason to the pet warning", async () => { + const originalProtocol = TERMINAL.imageProtocol; + const envKeys = [ + "TERM_PROGRAM", + "TERM_PROGRAM_VERSION", + "TMUX", + "TMUX_PANE", + "STY", + "ZELLIJ", + "GJC_TMUX_LAUNCHED", + "GJC_TMUX_ACTIVE_SESSION", + "GJC_MANAGED_OWNER_RUN_ID", + ] as const; + const originalEnv = new Map(envKeys.map(key => [key, Bun.env[key]] as const)); + vi.useFakeTimers(); + try { + mode.stop(); + setVerifiedItermPetAvailability(undefined); + setTerminalImageProtocol(null); + for (const key of envKeys) delete Bun.env[key]; + Bun.env.TERM_PROGRAM = "iTerm.app"; + Bun.env.TERM_PROGRAM_VERSION = "3.6.11"; + Bun.env.TERM = "xterm-256color"; + settings.set("pet.mode", "red"); + settings.set("startup.quiet", true); + mode = new InteractiveMode(session, "test"); + const showStatus = vi.spyOn(mode, "showStatus").mockImplementation(() => {}); + vi.spyOn(mode.ui, "start").mockImplementation(() => {}); + + await mode.init(); + setVerifiedItermPetAvailability({ available: false, mode: "direct", reason: "probe-timeout", epoch: 1 }); + vi.advanceTimersByTime(PET_CAPABILITY_SETTLE_MS * 2); + + expect(showStatus).toHaveBeenCalledTimes(1); + const startupWarning = showStatus.mock.calls[0]?.[0] ?? ""; + expect(startupWarning).toContain("Pets aren’t available"); + expect(startupWarning).toContain("(probe-timeout)"); + + showStatus.mockClear(); + expect(mode.setPetMode("red")).toBe(false); + expect(showStatus).toHaveBeenCalledTimes(1); + expect(showStatus.mock.calls[0]?.[0] ?? "").toContain("(probe-timeout)"); + } finally { + mode.stop(); + setVerifiedItermPetAvailability(undefined); + setTerminalImageProtocol(originalProtocol); + for (const key of envKeys) { + const value = originalEnv.get(key); + if (value === undefined) delete Bun.env[key]; + else Bun.env[key] = value; + } + vi.useRealTimers(); + } + }); + it("reports output revision changes while chrome-only editor reflow reports no output mutation", async () => { await mode.init(); const report = vi.spyOn(mode.ui, "setViewportOutputSource"); @@ -824,6 +999,10 @@ describe("InteractiveMode.setEditorComponent", () => { expect(mode.commitPetPreviewMode("red")).toBe(false); expect(settings.get("pet.mode")).toBe("off"); expect(showStatus).toHaveBeenCalledTimes(2); + for (const call of showStatus.mock.calls) { + expect(String(call[0])).toContain("Gajae Pet"); + expect(String(call[0])).not.toContain("(unknown)"); + } // An accepted commit persists only after the widget mutation applies. setTerminalImageProtocol(ImageProtocol.Sixel); diff --git a/packages/coding-agent/test/modes/components/iterm-pet-transport.test.ts b/packages/coding-agent/test/modes/components/iterm-pet-transport.test.ts new file mode 100644 index 0000000000..50eaf6946e --- /dev/null +++ b/packages/coding-agent/test/modes/components/iterm-pet-transport.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from "bun:test"; +import { + consumeCapabilityInput, + ItermPetTransport, + type PetTransportClock, +} from "@gajae-code/coding-agent/modes/components/iterm-pet-transport"; + +const clock: PetTransportClock = { + now: () => 0, + setTimeout: () => 0, + clearTimeout: () => {}, +}; + +class Input { + readonly listeners = new Set<(data: string | Uint8Array) => unknown>(); + async drain() {} + onData = (callback: (data: string | Uint8Array) => unknown) => { + this.listeners.add(callback); + return () => this.listeners.delete(callback); + }; + send(data: string) { + for (const listener of this.listeners) listener(data); + } +} + +function make(topology: () => Promise<{ clients: number; paneId?: string; ownedPaneId?: string; clientId?: string }>) { + const input = new Input(); + const events: Array<{ available: boolean; reason?: string; epoch: number }> = []; + const transport = new ItermPetTransport({ + mode: "managed", + clock, + input, + output: { + write: async () => ({ status: "written" as const }), + }, + tmux: async argv => { + if (argv[0] === "show-options" && argv.includes("-q")) return { status: 0, stdout: "" }; + if (argv[0] === "show-options" && argv.includes("-A")) return { status: 0, stdout: "on" }; + return { status: 0, stdout: "" }; + }, + paneId: "%1", + topology, + }); + transport.subscribe(availability => { + events.push({ available: availability.available, reason: availability.reason, epoch: availability.epoch }); + }); + return { events, input, transport }; +} + +async function waitFor(predicate: () => boolean): Promise { + for (let turn = 0; turn < 200; turn++) { + if (predicate()) return; + await Promise.resolve(); + } + throw new Error("condition did not become true within 200 microtasks"); +} + +describe("managed iTerm Pet topology revocation", () => { + it.each([ + ["zero clients", 0, "zero-client-recovery"], + ["multiple clients", 2, "topology-ineligible"], + ] as const)("does not repeat the unavailable event for %s", async (_label, clients, reason) => { + const x = make(async () => ({ clients })); + + await x.transport.inspectManagedTopology(); + await x.transport.inspectManagedTopology(); + + expect(x.events).toEqual([{ available: false, reason, epoch: 1 }]); + expect(x.transport.availability).toMatchObject({ available: false, reason, epoch: 1 }); + }); + + it("emits changed-reason, recovery, and later revocation transitions", async () => { + let clients = 0; + const x = make(async () => (clients === 1 ? { clients: 1, paneId: "%1", ownedPaneId: "%1" } : { clients })); + + await x.transport.inspectManagedTopology(); + clients = 2; + await x.transport.inspectManagedTopology(); + clients = 1; + const recovery = x.transport.inspectManagedTopology(); + await waitFor(() => x.input.listeners.size === 1); + x.input.send("\x1b]1337;Capabilities=F\x07"); + expect((await recovery).available).toBe(true); + + expect(x.events).toEqual([ + { available: false, reason: "zero-client-recovery", epoch: 1 }, + { available: false, reason: "topology-ineligible", epoch: 2 }, + { available: false, reason: "topology-ineligible", epoch: 3 }, + { available: true, reason: undefined, epoch: 3 }, + ]); + + clients = 2; + await x.transport.inspectManagedTopology(); + await x.transport.inspectManagedTopology(); + expect(x.events.at(-1)).toEqual({ available: false, reason: "topology-ineligible", epoch: 4 }); + expect(x.events).toHaveLength(5); + }); +}); + +describe("capability input fragmentation", () => { + it.each([ + ["immediately after the ESC byte", "abcde\x1b", "]1337;Capabilities=F\x07", "abcde"], + ["after two marker bytes", "abc\x1b]", "1337;Capabilities=F\x07", "abc"], + ["after a long marker prefix", "xx\x1b]1337", ";Capabilities=F\x07", "xx"], + ["with the marker alone in the first chunk", "\x1b]1337", ";Capabilities=F\x07", ""], + ["before the ST terminator", "\x1b]1337;Capabilities=F\x1b", "\\", ""], + ] as const)("captures the reply when it is split %s", (_label, firstChunk, secondChunk, expectedPassthrough) => { + const captured: string[] = []; + const consume = consumeCapabilityInput(data => captured.push(String(data))); + + const first = consume(firstChunk); + if (expectedPassthrough) expect(first?.data).toBe(expectedPassthrough); + else expect(first?.consume ?? first === undefined).toBeTruthy(); + + const second = consume(secondChunk); + expect(second?.consume ?? second === undefined).toBeTruthy(); + expect(captured).toEqual([ + secondChunk === "\\" ? "\x1b]1337;Capabilities=F\x1b\\" : "\x1b]1337;Capabilities=F\x07", + ]); + }); + + it("forwards ordinary input unchanged and keeps unrelated escape replies", () => { + const captured: string[] = []; + const consume = consumeCapabilityInput(data => captured.push(String(data))); + expect(consume("hello world")?.data).toBe("hello world"); + expect(consume("\x1b[97;1u")?.data).toBe("\x1b[97;1u"); + expect(consume("\x1b]11;rgb:1234/1234/1234\x1b\\")?.data).toBe("\x1b]11;rgb:1234/1234/1234\x1b\\"); + expect(consume("\x1b[?62;22;52c")?.data ?? "pass").toBeTruthy(); + expect(captured).toEqual([]); + }); + + it("captures every complete frame and passes interleaved text through", () => { + const captured: string[] = []; + const consume = consumeCapabilityInput(data => captured.push(String(data))); + expect(consume("\x1b]1337;Capabilities=F\x07xy\x1b]1337;Capabilities=x\x07")?.data).toBe("xy"); + expect(captured).toEqual(["\x1b]1337;Capabilities=F\x07", "\x1b]1337;Capabilities=x\x07"]); + }); +}); diff --git a/packages/coding-agent/test/modes/components/pet-capability.test.ts b/packages/coding-agent/test/modes/components/pet-capability.test.ts index 68b4dd7cb6..a1fd222709 100644 --- a/packages/coding-agent/test/modes/components/pet-capability.test.ts +++ b/packages/coding-agent/test/modes/components/pet-capability.test.ts @@ -1,18 +1,64 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; import { + getPetPixelProtocol, PET_CAPABILITY_SETTLE_MS, warnWhenPetCapabilitySettled, } from "@gajae-code/coding-agent/modes/components/pet-capability"; import { ImageProtocol, setTerminalImageProtocol, TERMINAL } from "@gajae-code/tui"; const originalProtocol = TERMINAL.imageProtocol; +const multiplexerEnvKeys = [ + "TMUX", + "TMUX_PANE", + "STY", + "ZELLIJ", + "GJC_TMUX_LAUNCHED", + "TERM", + "PI_FORCE_IMAGE_PROTOCOL", + "GJC_FORCE_IMAGE_PROTOCOL", +] as const; +const originalMultiplexerEnv = new Map(multiplexerEnvKeys.map(key => [key, Bun.env[key]] as const)); + +const multiplexerCases = [ + ["tmux", { TMUX: "/tmp/tmux-1000/default,1,0" }], + ["screen", { STY: "1234.pts-0.host" }], + ["zellij", { ZELLIJ: "session" }], +] as const; + +function setForcedProtocol(protocol: "kitty" | "sixel", multiplexerEnv: Readonly>): void { + for (const key of multiplexerEnvKeys) delete Bun.env[key]; + Bun.env.PI_FORCE_IMAGE_PROTOCOL = protocol; + for (const [key, value] of Object.entries(multiplexerEnv)) Bun.env[key] = value; + setTerminalImageProtocol(protocol === "kitty" ? ImageProtocol.Kitty : ImageProtocol.Sixel); +} afterEach(() => { setTerminalImageProtocol(originalProtocol); + for (const key of multiplexerEnvKeys) { + const value = originalMultiplexerEnv.get(key); + if (value === undefined) delete Bun.env[key]; + else Bun.env[key] = value; + } vi.useRealTimers(); vi.restoreAllMocks(); }); +describe("getPetPixelProtocol", () => { + for (const [name, multiplexerEnv] of multiplexerCases) { + it(`keeps forced Kitty unavailable inside ${name}`, () => { + setForcedProtocol("kitty", multiplexerEnv); + + expect(getPetPixelProtocol()).toBeNull(); + }); + + it(`keeps forced Sixel available inside ${name}`, () => { + setForcedProtocol("sixel", multiplexerEnv); + + expect(getPetPixelProtocol()).toBe("sixel"); + }); + } +}); + describe("warnWhenPetCapabilitySettled", () => { it("warns immediately when no probe can change the answer", () => { const onUnavailable = vi.fn(); diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index c86120b3f5..011afaf370 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -3,7 +3,9 @@ ## [Unreleased] ### Fixed -- iTerm2 Gajae Pet frames now size their inline PNG to the geometry-derived reserved cell block instead of a fixed pixel box, preserving the authored sprite aspect ratio across terminal font, Retina, and cell-aspect geometries while leaving Kitty and Sixel encoding unchanged. +- iTerm2 pet capability probes now wait for pending input without disabling Kitty keyboard mode, disabling `modifyOtherKeys`, or detaching the live input handler. +- iTerm2 Gajae Pet frames now carry a dedicated filename, so dragging one retains iTerm's native drag behavior while the coding agent can discard only the temporary pet pathname pasted back into its composer. +- iTerm2 Gajae Pet now uses one animated GIF upload per meaningful state change and a raster lease that excludes its cell rectangle from generic TUI redraw erases, eliminating animation flicker while preserving geometry-derived art placement and leaving Kitty and Sixel encoding unchanged. - Non-finite overlay geometry can no longer turn frame padding into an infinite allocating loop on the main thread; margins, positions, offsets, and minimum widths now fall back to bounded terminal-relative values. diff --git a/packages/tui/src/components/gajae-pet.ts b/packages/tui/src/components/gajae-pet.ts index d295aaca4b..bc0ef829e9 100644 --- a/packages/tui/src/components/gajae-pet.ts +++ b/packages/tui/src/components/gajae-pet.ts @@ -1,5 +1,6 @@ import * as zlib from "node:zlib"; +import { encodeITerm2Multipart, wrapITerm2RecordsForTmux } from "../terminal-capabilities"; import { OUROBOROS_HEART_STEPS, OUROBOROS_IDLE_STEPS, @@ -231,7 +232,8 @@ const PIXEL_GRIDS: Record = { }; /** Para-para work dance beats: the working loop and each skin's burst "work-in" intro. */ -export const PARA_PARA_STEPS: ReadonlyArray = [ +export type GajaeGifFrameTuple = readonly [GajaePixelFrameName, number]; +export const PARA_PARA_STEPS: readonly GajaeGifFrameTuple[] = [ ["danceL", 300], ["danceR", 300], ["base", 260], @@ -329,19 +331,19 @@ export const PET_SKINS: Record = { /** Total burst duration (intro beats plus the looping tail). */ export function petBurstDurationMs(burst: PetBurst): number { - const introMs = burst.intro.reduce((sum, [, ms]) => sum + ms, 0); + const introMs = burst.intro.reduce((sum, [, delayMs]) => sum + delayMs, 0); return introMs + (burst.tail?.ms ?? 0); } /** The frame to show `elapsed` ms into a burst (`now` cycles the looping tail). */ export function petBurstFrame(burst: PetBurst, elapsed: number, now: number): PetFrameName { let t = elapsed; - for (const [frame, ms] of burst.intro) { - if (t < ms) return frame; - t -= ms; + for (const [name, delayMs] of burst.intro) { + if (t < delayMs) return name; + t -= delayMs; } const tail = burst.tail; - if (!tail) return burst.intro[burst.intro.length - 1][0]; + if (!tail || tail.frames.length === 0) return burst.intro[burst.intro.length - 1]?.[0] ?? "base"; return tail.frames[Math.floor(now / tail.stepMs) % tail.frames.length]; } @@ -354,6 +356,301 @@ export const __gajaePetTestHooks = { }, }; +export interface GajaeGifFrame { + readonly name: PetFrameName; + readonly delayMs: number; +} +export type GajaeGifTimeline = readonly GajaeGifFrame[]; +export interface GajaeGifRectangle { + readonly width?: number; + readonly height?: number; +} +export interface GajaeGifDisplaySize { + /** iTerm2 display width: bare numbers are terminal cells; strings may use px or auto. */ + readonly width: number | string; + /** iTerm2 display height: bare numbers are terminal cells; strings may use px or auto. */ + readonly height: number | string; +} +export interface GajaeGifContentInset { + /** Transparent top padding in source pixels. */ + readonly topPx?: number; + /** Transparent bottom padding in source pixels. */ + readonly bottomPx?: number; +} +export interface GajaePetGifArtifact { + readonly bytes: Uint8Array; + readonly base64: string; + readonly width: number; + readonly height: number; + readonly frames: readonly GajaeGifFrame[]; + readonly skin: PetSkinId; + readonly multipart: readonly string[]; + readonly tmuxDcs: readonly string[]; +} +export interface GajaePetGifOptions { + readonly skin?: PetSkinId; + readonly timeline?: GajaeGifTimeline; + readonly cellWidthPx?: number; + readonly cellHeightPx?: number; + readonly targetRows?: number; + readonly rectangle?: GajaeGifRectangle; + readonly displaySize?: GajaeGifDisplaySize; + readonly contentInset?: GajaeGifContentInset; +} +const GIF_CLEAR = 256, + GIF_END = 257; +function gifLzw(pixels: number[], minCodeSize = 8): Uint8Array { + const out: number[] = [], + codes = pixels.flatMap(p => [GIF_CLEAR, p]).concat(GIF_END); + let bits = 0, + value = 0; + for (const code of codes) { + value |= code << bits; + bits += minCodeSize + 1; + while (bits >= 8) { + out.push(value & 255); + value >>>= 8; + bits -= 8; + } + } + if (bits) out.push(value & 255); + const blocks: number[] = [minCodeSize]; + for (let i = 0; i < out.length; i += 255) { + const part = out.slice(i, i + 255); + blocks.push(part.length, ...part); + } + blocks.push(0); + return Uint8Array.from(blocks); +} +export const idleTimeline = (): GajaeGifTimeline => [ + { name: "base", delayMs: 700 }, + { name: "gazeL", delayMs: 180 }, + { name: "base", delayMs: 700 }, + { name: "gazeR", delayMs: 180 }, + { name: "flicker", delayMs: 120 }, +]; +export const workingTimeline = (): GajaeGifTimeline => PARA_PARA_STEPS.map(([name, delayMs]) => ({ name, delayMs })); +export const burstTimeline = (skin: PetSkinId = "red"): GajaeGifTimeline => { + const burst = PET_SKINS[skin].burst; + const frames: GajaeGifFrame[] = burst.intro.map(([name, delayMs]) => ({ name, delayMs })); + const tail = burst.tail; + if (!tail || tail.frames.length === 0) return frames; + for (let elapsed = 0; elapsed < tail.ms; elapsed += tail.stepMs) { + frames.push({ + name: tail.frames[Math.floor(elapsed / tail.stepMs) % tail.frames.length], + delayMs: Math.min(tail.stepMs, tail.ms - elapsed), + }); + } + return frames; +}; +export const previewTimeline = (skin: PetSkinId = "red"): GajaeGifTimeline => burstTimeline(skin); +function isGifTimeline(input: GajaePetGifOptions | GajaeGifTimeline): input is GajaeGifTimeline { + return Array.isArray(input); +} +function gifOptions(input: GajaePetGifOptions | GajaeGifTimeline): Required< + Pick +> & { + rectangle?: GajaeGifRectangle; + displaySize?: GajaeGifDisplaySize; + contentInset?: GajaeGifContentInset; +} { + if (isGifTimeline(input)) { + return { skin: "red", timeline: input, cellWidthPx: 1, cellHeightPx: 1, targetRows: 16 }; + } + return { + skin: input.skin ?? "red", + timeline: input.timeline ?? idleTimeline(), + cellWidthPx: input.cellWidthPx ?? 1, + cellHeightPx: input.cellHeightPx ?? 1, + targetRows: input.targetRows ?? 16, + rectangle: input.rectangle, + displaySize: input.displaySize, + contentInset: input.contentInset, + }; +} +export function encodeGajaePetGif(input: GajaePetGifOptions | GajaeGifTimeline = {}): GajaePetGifArtifact { + const o = gifOptions(input), + rect = o.rectangle ?? {}; + if (o.timeline.length === 0) throw new Error("GIF timeline must not be empty"); + for (const frame of o.timeline) { + if (!Number.isFinite(frame.delayMs) || frame.delayMs < 0) throw new Error("Invalid GIF frame delay"); + } + const width = rect.width ?? rect.height ?? Math.round(Math.max(1, o.targetRows * o.cellHeightPx)); + const height = rect.height ?? Math.round(Math.max(1, o.targetRows * o.cellHeightPx)); + const valid = (n: number, label: string): number => { + if (!Number.isFinite(n) || !Number.isInteger(n) || n <= 0 || n > 0xffff) throw new Error(`Invalid GIF ${label}`); + return n; + }; + valid(width, "width"); + valid(height, "height"); + const inset = o.contentInset ?? {}; + const topInset = inset.topPx ?? 0; + const bottomInset = inset.bottomPx ?? 0; + if ( + ![topInset, bottomInset].every(value => Number.isFinite(value) && Number.isInteger(value) && value >= 0) || + topInset + bottomInset >= height + ) + throw new Error("Invalid GIF content inset"); + const contentHeight = height - topInset - bottomInset; + if (width * height * o.timeline.length > 64 * 1024 * 1024) throw new Error("GIF allocation exceeds safety budget"); + const paletteKeys = Object.keys(PET_SKINS[o.skin].palette).filter(k => k !== "."); + const palette = [[0, 0, 0], ...paletteKeys.map(k => PET_SKINS[o.skin].palette[k]!)]; + const chunks: number[] = [ + ...Buffer.from("GIF89a"), + width & 255, + width >> 8, + height & 255, + height >> 8, + 0xf7, + 0, + 0, + ...palette.flat(), + ...Array((256 - palette.length) * 3).fill(0), + 33, + 255, + 11, + ...Buffer.from("NETSCAPE2.0"), + 3, + 1, + 0, + 0, + 0, + ]; + for (const frame of o.timeline) { + const pixels: number[] = [], + grid = PET_SKINS[o.skin].frames[frame.name]; + if (!grid) throw new Error(`Unknown ${o.skin} GIF frame: ${frame.name}`); + for (let y = 0; y < height; y++) + for (let x = 0; x < width; x++) { + const contentY = y - topInset; + if (contentY < 0 || contentY >= contentHeight) { + pixels.push(0); + continue; + } + const sx = Math.min(15, Math.floor((x * 16) / width)); + const sy = Math.min(15, Math.floor((contentY * 16) / contentHeight)); + const ch = grid[sy][sx]; + pixels.push(ch === "." ? 0 : Math.max(1, paletteKeys.indexOf(ch) + 1)); + } + const delay = Math.round(frame.delayMs / 10); + chunks.push( + 33, + 249, + 4, + 0x09, + delay & 255, + delay >> 8, + 0, + 0, + 44, + 0, + 0, + 0, + 0, + width & 255, + width >> 8, + height & 255, + height >> 8, + 0, + ...gifLzw(pixels), + ); + } + chunks.push(59); + const bytes = Uint8Array.from(chunks); + const base64 = Buffer.from(bytes).toString("base64"); + const multipart = encodeITerm2Multipart(base64, { + width: o.displaySize?.width ?? `${width}px`, + height: o.displaySize?.height ?? `${height}px`, + }); + const tmuxDcs = wrapITerm2RecordsForTmux(multipart); + return { + bytes, + base64, + width, + height, + frames: [...o.timeline], + skin: o.skin, + multipart, + tmuxDcs, + }; +} +const gifCache = new Map(); +let gifCacheBytes = 0; +let gifCacheBase64Bytes = 0; +let gifCacheMultipartBytes = 0; +let gifCacheTmuxDcsBytes = 0; +let gifCacheEvictions = 0; +const GIF_CACHE_MAX_ENTRIES = 32; +const GIF_CACHE_MAX_BYTES = 8 * 1024 * 1024; +const byteLength = (value: string): number => Buffer.byteLength(value, "utf8"); + +export function getGajaePetGifCached(input: GajaePetGifOptions | GajaeGifTimeline = {}): GajaePetGifArtifact { + const o = gifOptions(input), + key = JSON.stringify([ + o.skin, + o.timeline, + o.cellWidthPx, + o.cellHeightPx, + o.targetRows, + o.rectangle, + o.displaySize, + o.contentInset, + ]), + hit = gifCache.get(key); + if (hit) { + gifCache.delete(key); + gifCache.set(key, hit); + return hit; + } + const value = encodeGajaePetGif(o); + gifCache.set(key, value); + gifCacheBytes += value.bytes.byteLength; + gifCacheBase64Bytes += byteLength(value.base64); + gifCacheMultipartBytes += value.multipart.reduce((sum, record) => sum + byteLength(record), 0); + gifCacheTmuxDcsBytes += value.tmuxDcs.reduce((sum, record) => sum + byteLength(record), 0); + while ( + gifCache.size > GIF_CACHE_MAX_ENTRIES || + gifCacheBytes + gifCacheBase64Bytes + gifCacheMultipartBytes + gifCacheTmuxDcsBytes > GIF_CACHE_MAX_BYTES + ) { + const k = gifCache.keys().next().value as string, + old = gifCache.get(k)!; + gifCache.delete(k); + gifCacheBytes -= old.bytes.byteLength; + gifCacheBase64Bytes -= byteLength(old.base64); + gifCacheMultipartBytes -= old.multipart.reduce((sum, record) => sum + byteLength(record), 0); + gifCacheTmuxDcsBytes -= old.tmuxDcs.reduce((sum, record) => sum + byteLength(record), 0); + gifCacheEvictions++; + } + return value; +} +export function getGajaePetGifCacheStats(): { + size: number; + bytes: number; + gifBytes: number; + base64Bytes: number; + multipartBytes: number; + tmuxDcsBytes: number; + evictions: number; +} { + return { + size: gifCache.size, + bytes: gifCacheBytes + gifCacheBase64Bytes + gifCacheMultipartBytes + gifCacheTmuxDcsBytes, + gifBytes: gifCacheBytes, + base64Bytes: gifCacheBase64Bytes, + multipartBytes: gifCacheMultipartBytes, + tmuxDcsBytes: gifCacheTmuxDcsBytes, + evictions: gifCacheEvictions, + }; +} +export function resetGajaePetGifCache(): void { + gifCache.clear(); + gifCacheBytes = 0; + gifCacheBase64Bytes = 0; + gifCacheMultipartBytes = 0; + gifCacheTmuxDcsBytes = 0; + gifCacheEvictions = 0; +} +export const clearGajaePetGifCache = resetGajaePetGifCache; /** Encode a grid as a transparent SIXEL image, optionally bottom-aligned by top padding. */ export function encodeGridSixel( grid: string[], @@ -569,7 +866,9 @@ export function encodeGridIterm2( // Size in character cells, not pixels: iTerm2 applies its own cell metrics // (and divides px values by the Retina backing scale), so cells keep the // padded canvas 1:1 with the reserved block at every geometry. - const params = `width=${columns};height=${rows};preserveAspectRatio=0;inline=1`; + // iTerm uses the supplied filename when a user drags this inline image out. + // The unique name lets the composer discard only this pet's automatic path paste. + const params = `name=Z2FqYWUtcGV0LnBuZw==;width=${columns};height=${rows};preserveAspectRatio=0;inline=1`; return `\x1b]1337;File=${params}:${png.toString("base64")}\x1b\\`; } @@ -655,10 +954,10 @@ export interface GajaePixelFrames { * - `rows = ceil(scaledSpriteHeightPx / cellHeightPx)` * - the square sprite is centered in a `columns * cellWidthPx` PNG canvas * - * iTerm2 receives unitless `width=columns;height=rows`, so it resolves the - * reserved block with its live cell metrics. The padded PNG has that block's - * pixel aspect ratio, allowing `preserveAspectRatio=0` without stretching the - * authored square sprite. Kitty and Sixel retain their protocol-specific paths. + * iTerm2 receives unitless `width=columns;height=rasterRows`, so it resolves the + * padded block with its live cell metrics. The PNG has that block's pixel aspect + * ratio, allowing `preserveAspectRatio=0` without stretching the authored square + * sprite. Kitty and Sixel retain their protocol-specific paths. */ export function buildGajaePixelFrames(options: { protocol: "sixel" | "kitty" | "iterm2"; @@ -672,7 +971,7 @@ export function buildGajaePixelFrames(options: { kittyImageId?: number; /** Additional transparent iTerm2-only top padding for sub-cell vertical alignment. */ iterm2TopPaddingPx?: number; - /** Transparent iTerm2-only bottom padding inside the two-row canvas. */ + /** Transparent iTerm2-only bottom padding inside the canvas. */ iterm2BottomPaddingPx?: number; /** Color skin for the sprite palette (default "red"). */ skin?: PetSkinId; @@ -705,6 +1004,7 @@ export function buildGajaePixelFrames(options: { const topPaddingPx = allocatedHeightPx - visibleHeightPx + (options.protocol === "sixel" ? (options.sixelTopPaddingPx ?? 0) : 0); const heightPx = visibleHeightPx + topPaddingPx; + const kittyYOffsetPx = options.protocol === "kitty" ? Math.max(0, Math.round(options.kittyCellYOffsetPx ?? 0)) : 0; // Center the square sprite in its (cols * cellWidth) block, which the ceil() // column rounding can make wider than the sprite itself. const horizontalPaddingPx = Math.max(0, columns * options.cellWidthPx - widthPx); @@ -713,9 +1013,14 @@ export function buildGajaePixelFrames(options: { const canvasWidthPx = widthPx + leftPaddingPx + rightPaddingPx; // When minimum 1x art is taller than targetRows (only possible with tiny // cells), top-pad iTerm2 to the full reserved row block. Its unitless OSC - // height then maps the PNG 1:1 without vertically stretching the sprite. + // height then maps the PNG 1:1 without vertically stretching the authored square sprite. const iterm2TopPaddingPx = allocatedHeightPx - visibleHeightPx + (options.protocol === "iterm2" ? (options.iterm2TopPaddingPx ?? 0) : 0); + const protocolHeightPx = + options.protocol === "iterm2" + ? visibleHeightPx + iterm2TopPaddingPx + (options.iterm2BottomPaddingPx ?? 0) + : heightPx; + const rasterRows = Math.ceil((protocolHeightPx + kittyYOffsetPx) / options.cellHeightPx); if ( widthPx > MAX_PET_FRAME_DIMENSION || heightPx > MAX_PET_FRAME_DIMENSION || @@ -736,7 +1041,7 @@ export function buildGajaePixelFrames(options: { grid, scale, columns, - rows, + rasterRows, iterm2TopPaddingPx, options.iterm2BottomPaddingPx ?? 0, leftPaddingPx, @@ -757,10 +1062,6 @@ export function buildGajaePixelFrames(options: { ); } - const protocolHeightPx = - options.protocol === "iterm2" - ? visibleHeightPx + iterm2TopPaddingPx + (options.iterm2BottomPaddingPx ?? 0) - : heightPx; return { frames, protocol: options.protocol, @@ -768,6 +1069,6 @@ export function buildGajaePixelFrames(options: { heightPx: protocolHeightPx, columns, rows, - rasterRows: Math.ceil(protocolHeightPx / options.cellHeightPx), + rasterRows, }; } diff --git a/packages/tui/src/terminal-capabilities.ts b/packages/tui/src/terminal-capabilities.ts index 2dfafe07a6..8032cf8f5e 100644 --- a/packages/tui/src/terminal-capabilities.ts +++ b/packages/tui/src/terminal-capabilities.ts @@ -15,6 +15,8 @@ export enum ImageProtocol { Sixel = "\x1bPq", } +const ITERM2_MULTIPART_IMAGE_PREFIX = "\x1b]1337;MultipartFile="; + export enum NotifyProtocol { Bell = "\x07", Osc99 = "\x1b]99;;", @@ -39,6 +41,10 @@ export class TerminalInfo { if (this.imageProtocol === ImageProtocol.Sixel) { return SIXEL_DCS_START_REGEX.test(line.slice(0, 128)); } + if (this.imageProtocol === ImageProtocol.Iterm2) { + const prefix = line.slice(0, 64); + return prefix.includes(ImageProtocol.Iterm2) || prefix.includes(ITERM2_MULTIPART_IMAGE_PREFIX); + } return line.slice(0, 64).includes(this.imageProtocol); } @@ -867,3 +873,134 @@ export function imageFallback(mimeType: string, dimensions?: ImageDimensions, fi if (dimensions) parts.push(`${dimensions.widthPx}x${dimensions.heightPx}`); return `[Image: ${parts.join(" ")}]`; } +export type Iterm2Capability = { readonly key: string; readonly value: string }; +export type Iterm2CapabilityReply = "complete-f" | "missing-f" | "invalid-f" | undefined; + +const ITERM2_CAPABILITY_REPLY_REGEX = /\x1b\]1337;Capabilities(?:=|:)([^\x07\x1b]*)(?:\x07|\x1b\\)/gu; +const ITERM2_FEATURE_TOKEN_REGEX = /^[A-Z][a-z]*[0-9]*$/u; + +/** + * Classifies complete iTerm2 capability replies. An absent result means the + * input does not yet contain a complete capability frame. + */ +export function parseITerm2CapabilityReply(input: Uint8Array | string): Iterm2CapabilityReply { + const value = typeof input === "string" ? input : new TextDecoder().decode(input); + let complete = false; + for (const match of value.matchAll(ITERM2_CAPABILITY_REPLY_REGEX)) { + complete = true; + const featureString = match[1] ?? ""; + const tokens: string[] = featureString.match(/[A-Z][a-z]*[0-9]*/gu) ?? []; + if (tokens.join("") !== featureString || !tokens.every(token => ITERM2_FEATURE_TOKEN_REGEX.test(token))) { + return "invalid-f"; + } + if (tokens.includes("F")) return "complete-f"; + } + return complete ? "missing-f" : undefined; +} + +const ITERM2_BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u; +const ITERM2_MAX_CAPABILITY_BYTES = 4096; + +function assertIterm2Base64(value: string): void { + if (value.length % 4 !== 0 || !ITERM2_BASE64.test(value)) throw new Error("Invalid RFC 4648 base64"); +} + +export function encodeITerm2Multipart( + base64Data: string, + options: { width?: number | string; height?: number | string } = {}, +): string[] { + assertIterm2Base64(base64Data); + const validate = (value: number | string, label: string): number | string => { + if ( + typeof value === "number" && + (!Number.isFinite(value) || !Number.isInteger(value) || value <= 0 || value > 0xffff) + ) + throw new Error(`Invalid iTerm2 ${label}`); + if (typeof value === "string" && !/^[A-Za-z0-9]+$/u.test(value)) throw new Error(`Invalid iTerm2 ${label}`); + return value; + }; + const width = validate(options.width ?? "auto", "width"); + const height = validate(options.height ?? "auto", "height"); + const size = Buffer.from(base64Data, "base64").byteLength; + const name = Buffer.from("gajae-pet.gif").toString("base64"); + const records = [ + `\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`); + } + records.push("\x1b]1337;FileEnd\x07"); + for (const record of records) { + if (Buffer.byteLength(`\x1bPtmux;${record.replaceAll("\x1b", "\x1b\x1b")}\x1b\\`, "utf8") > 256) + throw new Error("iTerm2 record exceeds tmux limit"); + } + return records; +} + +export function wrapITerm2RecordForTmux(record: string): string { + const wrapped = `\x1bPtmux;${record.replaceAll("\x1b", "\x1b\x1b")}\x1b\\`; + if (Buffer.byteLength(wrapped, "utf8") > 256) throw new Error("iTerm2 record exceeds tmux limit"); + return wrapped; +} + +export function wrapITerm2RecordsForTmux(records: readonly string[]): string[] { + return records.map(wrapITerm2RecordForTmux); +} + +function parseIterm2CapabilityString(value: string): Iterm2Capability[] { + const out: Iterm2Capability[] = []; + for (const pair of value.split(";")) { + const i = pair.indexOf("="); + if (i > 0) out.push({ key: pair.slice(0, i), value: pair.slice(i + 1) }); + else if (i < 0 && pair.length > 0) out.push({ key: pair, value: "" }); + } + return out; +} + +export function parseITerm2Capabilities(input: string): Iterm2Capability[] { + const parser = new Iterm2CapabilitiesParser(); + return parser.push(input); +} + +export class Iterm2CapabilitiesParser { + #buffer = ""; + push(input: Uint8Array | string): Iterm2Capability[] { + this.#buffer += typeof input === "string" ? input : new TextDecoder().decode(input); + const out: Iterm2Capability[] = []; + while (true) { + const marker = this.#buffer.indexOf("\x1b]1337;Capabilities="); + if (marker < 0) { + this.#buffer = this.#buffer.slice(-32); + break; + } + const valueStart = marker + "\x1b]1337;Capabilities=".length; + let end = -1; + for (let i = valueStart; i < this.#buffer.length; i++) { + if (this.#buffer[i] === "\x07") { + end = i + 1; + break; + } + if (this.#buffer[i] === "\x1b" && this.#buffer[i + 1] === "\\") { + end = i + 2; + break; + } + if (i - valueStart > ITERM2_MAX_CAPABILITY_BYTES) { + end = -2; + break; + } + } + if (end === -1) break; + if (end === -2) { + this.#buffer = this.#buffer.slice(valueStart + 1); + continue; + } + const terminatorLength = this.#buffer[end - 2] === "\x1b" ? 2 : 1; + out.push(...parseIterm2CapabilityString(this.#buffer.slice(valueStart, end - terminatorLength))); + this.#buffer = this.#buffer.slice(end); + } + return out; + } + reset(): void { + this.#buffer = ""; + } +} diff --git a/packages/tui/src/terminal.ts b/packages/tui/src/terminal.ts index 85a902d6ec..cb318bb300 100644 --- a/packages/tui/src/terminal.ts +++ b/packages/tui/src/terminal.ts @@ -118,6 +118,15 @@ export interface Terminal { */ drainInput(maxMs?: number, idleMs?: number): Promise; + /** + * Wait for pending stdin to go quiet without changing terminal protocols or + * the active input handler. Capability probes use this non-destructive drain; + * shutdown paths must continue using drainInput(). + * @param maxMs - Maximum time to wait (default: 1000ms) + * @param idleMs - Exit early if no input arrives within this time (default: 50ms) + */ + drainPendingInput?(maxMs?: number, idleMs?: number): Promise; + // Write output to terminal write(data: string): void; @@ -822,6 +831,30 @@ export class ProcessTerminal implements Terminal { }, 150); } + async drainPendingInput(maxMs = 1000, idleMs = 50): Promise { + let lastDataTime = Date.now(); + const onData = () => { + lastDataTime = Date.now(); + }; + + process.stdin.on("data", onData); + const endTime = Date.now() + maxMs; + + try { + while (true) { + const now = Date.now(); + const timeLeft = endTime - now; + if (timeLeft <= 0) break; + if (now - lastDataTime >= idleMs) break; + const { promise, resolve } = Promise.withResolvers(); + setTimeout(resolve, Math.min(idleMs, timeLeft)); + await promise; + } + } finally { + process.stdin.removeListener("data", onData); + } + } + async drainInput(maxMs = 1000, idleMs = 50): Promise { if (this.#kittyProtocolActive) { // Disable Kitty keyboard protocol first so any late key releases diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index c6d4967c07..d7ed9f14be 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -32,6 +32,67 @@ import { visibleWidth, visibleWidths, } from "./utils"; +export type CellRect = Readonly<{ column: number; row: number; width: number; height: number }>; +export type RasterLeaseToken = Readonly<{ ownerId: string; generation: number; rect: CellRect }>; +export type RasterLeaseInvalidatedNotification = Readonly<{ + type: "raster-lease-invalidated"; + queueId: number; + token: RasterLeaseToken; + cause: + | "intersecting-generic-output" + | "full-redraw" + | "resize" + | "terminal-loss" + | "capability-loss" + | "mode-off" + | "dispose" + | "explicit" + | "manual-viewport"; + eraseAck: TerminalOutputAck; +}>; +export type RasterLeaseRequest = Readonly<{ + ownerId: string; + rect: CellRect; + erase: Readonly<{ type: "raster-erase"; bytes: Uint8Array }>; + onInvalidated?: (notice: RasterLeaseInvalidatedNotification) => void; +}>; +export type TerminalOutputOperation = + | Readonly<{ type: "generic-render"; rect: CellRect; bytes: Uint8Array }> + | Readonly<{ type: "generic-full-redraw"; rect: CellRect; bytes: Uint8Array }> + | Readonly<{ + type: "raster-multipart-batch"; + records: readonly Uint8Array[]; + prefix?: Uint8Array; + afterPrefix?: () => Promise; + /** Synchronous freshness gate evaluated immediately before terminal output. */ + shouldWrite?: () => boolean; + replayPrefix?: Uint8Array; + suffix?: Uint8Array; + abortSuffix?: Uint8Array; + restoreCursorVisibility?: boolean; + }> + | Readonly<{ type: "raster-erase"; bytes: Uint8Array }> + | Readonly<{ type: "raster-probe"; bytes: Uint8Array }> + | Readonly<{ + type: "queued-output"; + bytes: Uint8Array; + shouldWrite?: () => boolean; + /** Runs synchronously at the terminal write boundary after a successful write. */ + onWritten?: () => void; + }>; +export type TerminalOutputAck = Readonly<{ + queueId: number; + operation: TerminalOutputOperation["type"]; + status: "written" | "stale-token" | "revoked" | "failed"; + token?: RasterLeaseToken; +}>; +export type LifecycleCleanupAck = Readonly<{ attempted: number; written: number; stillPending: number }>; +export type RasterLeaseAcquireResult = + | Readonly<{ status: "acquired"; token: RasterLeaseToken }> + | Readonly<{ + status: "rejected"; + reason: "invalid-geometry" | "terminal-unavailable" | "owner-conflict" | "manual-viewport"; + }>; const SEGMENT_RESET = "\x1b[0m"; /** @@ -44,6 +105,8 @@ const LINE_TERMINATOR = "\x1b[0m\x1b]8;;\x07"; const MOUSE_SELECTION_SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme" }); /** Discrete mouse-wheel notch size in terminal rows (xterm/less-style). */ export const DEFAULT_WHEEL_LINES = 3; +/** CSI 6 terminal-cell dimensions beyond this cannot produce a safe pet raster. */ +const MAX_CELL_DIMENSION_PX = 512; /** DA1 (`CSI ? … c`) and XTSMGRAPHICS (`CSI ? … S`) replies to the sixel probe. */ const DEVICE_REPORT_PATTERN = /^\x1b\[\?[\d;]*[cS]$/u; @@ -110,6 +173,10 @@ function stripTerminalEraseControls(bytes: string): string { } type InputListenerResult = { consume?: boolean; data?: string } | undefined; type InputListener = (data: string) => InputListenerResult; +type PostRenderEmission = { + payload: string; + onWritten?: () => void; +}; /** * Component interface - all components must implement this @@ -933,6 +1000,8 @@ export class TUI extends Container { #committedRenderGeneration = 0; #renderCommitWaiters = new Map>(); #lastRenderWriteSucceeded = false; + /** Generation whose render path is currently capturing terminal output. */ + #renderGenerationInProgress = 0; #resizeRenderQueued = false; #resizeRenderMutationQueued = false; #renderMutationQueued = false; @@ -978,6 +1047,7 @@ export class TUI extends Container { #manualViewportAnchor: ManualViewportAnchor | null = null; #manualViewportFallbackAnchors: ManualViewportAnchor[] = []; #reconcileMissingViewportAnchor = false; + #manualViewportLeaseSuspendPending = false; #lastCursorPosition: { row: number; col: number } | null = null; #latestViewportObservation: TuiViewportObservation | null = null; #sixelProbePendingDa = false; @@ -1015,6 +1085,39 @@ export class TUI extends Container { #manualSuffixLineCount = 0; #committedTranscriptRows: Array = []; #paintedManualOutputNotice = false; + #rasterGeneration = 0; + #rasterQueueId = 0; + #rasterLeases = new Map< + string, + { + token: RasterLeaseToken; + erase: Uint8Array; + callback?: (n: RasterLeaseInvalidatedNotification) => void; + revoked: boolean; + } + >(); + #rasterCleanup = new Map< + string, + { + token: RasterLeaseToken; + erase: Uint8Array; + callback?: (n: RasterLeaseInvalidatedNotification) => void; + queueId: number; + cause: RasterLeaseInvalidatedNotification["cause"]; + terminalGeneration: number; + } + >(); + #rasterIngress: Promise = Promise.resolve(); + #rasterPending = 0; + #pendingDependentGenericBytes: Array<{ bytes: Uint8Array; rect: CellRect; blockedBy: string[] }> = []; + #terminalGeneration = 0; + /** + * Raster lifecycle epoch. `stop()` increments it before lease cleanup so a + * queue body captured under a prior running epoch can never write to the + * terminal after restoration; `start()` does not reset it, so work enqueued + * in the new lifecycle carries the new epoch. + */ + #rasterLifecycle = 0; #unsubscribeTabWidthChange?: () => void; static #renderCounters: TuiRenderCounterSnapshot = { @@ -1111,8 +1214,33 @@ export class TUI extends Container { override dispose(): void { this.#unsubscribeTabWidthChange?.(); this.#unsubscribeTabWidthChange = undefined; + this.#finalizeRasterLeases("terminal-loss"); super.dispose(); } + #finalizeRasterLeases(cause: RasterLeaseInvalidatedNotification["cause"]): void { + this.#revokeRasterLeases(cause); + void this.notifyTerminalLifecycle({ + kind: "explicit-cleanup", + source: "tui", + terminalGeneration: this.#terminalGeneration, + }); + } + #flushRasterLeasesBeforeStop(cause: RasterLeaseInvalidatedNotification["cause"]): void { + this.#revokeRasterLeases(cause); + for (const [owner, record] of this.#rasterCleanup) { + const erase = this.#cursorGuardedRasterSequence(new TextDecoder().decode(record.erase)); + if (!this.#writeTerminal(erase)) return; + this.#rasterCleanup.delete(owner); + record.callback?.({ + type: "raster-lease-invalidated", + queueId: record.queueId, + token: record.token, + cause: record.cause, + eraseAck: { queueId: record.queueId, operation: "raster-erase", status: "written", token: record.token }, + }); + } + this.flushTerminalCleanup(); + } get fullRedraws(): number { return this.#fullRedrawCount; @@ -1313,6 +1441,28 @@ export class TUI extends Container { if (this.#manualViewportAnchor !== null) this.#reconcileMissingViewportAnchor = true; } + #suspendRasterLeasesForManualViewport(resume: () => void): boolean { + if (this.#manualViewportTop !== undefined) return false; + if (this.#rasterLeases.size === 0 && this.#rasterCleanup.size === 0) return false; + if (this.#manualViewportLeaseSuspendPending) return true; + + this.#manualViewportLeaseSuspendPending = true; + this.#revokeRasterLeases("manual-viewport"); + void this.notifyTerminalLifecycle({ + kind: "explicit-cleanup", + source: "tui", + terminalGeneration: this.#terminalGeneration, + }) + .then(result => { + this.#manualViewportLeaseSuspendPending = false; + if (result.stillPending === 0) resume(); + }) + .catch(() => { + this.#manualViewportLeaseSuspendPending = false; + }); + return true; + } + /** Reveal a semantic viewport anchor without changing the rendered content width. */ revealViewportAnchor(id: ViewportAnchorId, alignment: "top" | "center" | "bottom"): boolean { const height = this.terminal.rows; @@ -1337,6 +1487,11 @@ export class TUI extends Container { const desiredScreenRow = alignment === "top" ? 0 : alignment === "center" ? Math.floor(transcriptCapacity / 2) : transcriptCapacity - 1; const targetViewportTop = Math.max(0, frame.startRow + selectedRow - desiredScreenRow); + if ( + this.#manualViewportTop === undefined && + this.#suspendRasterLeasesForManualViewport(() => this.revealViewportAnchor(id, alignment)) + ) + return true; this.#manualViewportAnchor = { id: selected.id, graphemeIndex: @@ -1409,6 +1564,11 @@ export class TUI extends Container { if (this.#manualViewportTop === undefined && currentViewportTop === maxViewportTop) return true; if (this.#manualViewportTop !== undefined) return this.followLiveViewport(); } + if ( + this.#manualViewportTop === undefined && + this.#suspendRasterLeasesForManualViewport(() => this.scrollViewportBy(delta, options)) + ) + return true; if (frame !== null) { const desiredScreenRow = this.#manualViewportAnchor?.desiredScreenRow ?? @@ -1678,6 +1838,442 @@ export class TUI extends Container { for (const overlay of this.overlayStack) overlay.mouseBounds = undefined; } + acquireRasterLease(request: RasterLeaseRequest): Promise { + return this.#enqueueRaster(isCurrentLifecycle => { + if (!isCurrentLifecycle()) return { status: "rejected", reason: "terminal-unavailable" } as const; + if ( + !request || + typeof request.ownerId !== "string" || + !request.rect || + typeof request.erase !== "object" || + request.erase.type !== "raster-erase" || + !(request.erase.bytes instanceof Uint8Array) || + (request.onInvalidated !== undefined && typeof request.onInvalidated !== "function") + ) + return { status: "rejected", reason: "invalid-geometry" }; + if (!this.#validRect(request.rect)) return { status: "rejected", reason: "invalid-geometry" }; + if (!this.terminalAvailable) return { status: "rejected", reason: "terminal-unavailable" }; + if (this.manualViewportActive) return { status: "rejected", reason: "manual-viewport" }; + if ( + this.#rasterLeases.has(request.ownerId) || + this.#rasterCleanup.has(request.ownerId) || + [...this.#rasterLeases.values()].some(l => this.#intersects(l.token.rect, request.rect)) || + [...this.#rasterCleanup.values()].some(l => this.#intersects(l.token.rect, request.rect)) + ) + return { status: "rejected", reason: "owner-conflict" }; + const token = Object.freeze({ + ownerId: request.ownerId, + generation: ++this.#rasterGeneration, + rect: Object.freeze({ ...request.rect }), + }); + this.#rasterLeases.set(request.ownerId, { + token, + erase: new Uint8Array(request.erase.bytes), + callback: request.onInvalidated, + revoked: false, + }); + return { status: "acquired", token }; + }); + } + submitTerminalOutput( + request: Readonly<{ operation: TerminalOutputOperation; token?: RasterLeaseToken }>, + ): Promise { + return this.#enqueueRaster(async isCurrentLifecycle => { + const id = ++this.#rasterQueueId; + const rawOperation0 = + request && typeof request === "object" ? (request as { operation?: unknown }).operation : undefined; + const operation0 = + rawOperation0 && + typeof rawOperation0 === "object" && + typeof (rawOperation0 as { type?: unknown }).type === "string" + ? (rawOperation0 as { type: string }).type + : "queued-output"; + if (!isCurrentLifecycle()) + return { queueId: id, operation: operation0 as TerminalOutputOperation["type"], status: "failed" as const }; + const rawOperation = + request && typeof request === "object" ? (request as { operation?: unknown }).operation : undefined; + const operation = + rawOperation && + typeof rawOperation === "object" && + typeof (rawOperation as { type?: unknown }).type === "string" + ? (rawOperation as { type: string }).type + : "queued-output"; + const failed = () => ({ + queueId: id, + operation: operation as TerminalOutputOperation["type"], + status: "failed" as const, + }); + if (!rawOperation || typeof rawOperation !== "object") return failed(); + const op = rawOperation as unknown as TerminalOutputOperation; + if ( + ![ + "generic-render", + "generic-full-redraw", + "raster-multipart-batch", + "raster-erase", + "raster-probe", + "queued-output", + ].includes(op.type as string) + ) + return failed(); + if ( + (op.type === "generic-render" || op.type === "generic-full-redraw") && + (!this.#validRect(op.rect as CellRect) || !(op.bytes instanceof Uint8Array)) + ) + return failed(); + if ( + op.type === "raster-multipart-batch" && + (!Array.isArray(op.records) || + !op.records.every((b: unknown) => b instanceof Uint8Array) || + (op.prefix !== undefined && !(op.prefix instanceof Uint8Array)) || + (op.afterPrefix !== undefined && typeof op.afterPrefix !== "function") || + (op.shouldWrite !== undefined && typeof op.shouldWrite !== "function") || + (op.replayPrefix !== undefined && !(op.replayPrefix instanceof Uint8Array)) || + (op.suffix !== undefined && !(op.suffix instanceof Uint8Array)) || + (op.abortSuffix !== undefined && !(op.abortSuffix instanceof Uint8Array)) || + (op.restoreCursorVisibility !== undefined && typeof op.restoreCursorVisibility !== "boolean") || + ((op.replayPrefix !== undefined || op.abortSuffix !== undefined) && + (op.prefix === undefined || op.afterPrefix === undefined))) + ) + return failed(); + if ( + (op.type === "raster-erase" || op.type === "raster-probe" || op.type === "queued-output") && + (!(op.bytes instanceof Uint8Array) || + (op.type === "queued-output" && + ((op.shouldWrite !== undefined && typeof op.shouldWrite !== "function") || + (op.onWritten !== undefined && typeof op.onWritten !== "function")))) + ) + return failed(); + if (op.type.startsWith("raster-") && op.type !== "raster-probe") { + const lease = request?.token && this.#rasterLeases.get(request.token.ownerId); + if (!lease || lease.revoked || lease.token !== request?.token) + return { queueId: id, operation: op.type, status: lease?.revoked ? "revoked" : "stale-token" }; + } + if ((op.type === "raster-multipart-batch" || op.type === "queued-output") && op.shouldWrite !== undefined) { + let shouldWrite: boolean; + try { + shouldWrite = op.shouldWrite(); + } catch { + return failed(); + } + if (!shouldWrite) return { queueId: id, operation: op.type, status: "stale-token" }; + } + if (op.type === "raster-multipart-batch" && op.prefix !== undefined && op.afterPrefix !== undefined) { + const prefixWritten = this.#guardTerminalOperation(() => + this.terminal.write(new TextDecoder().decode(op.prefix)), + ); + if (!prefixWritten) return failed(); + const abortBarrier = () => { + // Abort/cursor-restoration bytes are terminal writes: never emit + // them once the running epoch ended (e.g. a user predicate that + // itself stops the terminal before throwing or returning false). + if (!isCurrentLifecycle()) return; + const abortSuffix = op.abortSuffix === undefined ? "" : new TextDecoder().decode(op.abortSuffix); + const cursorVisibility = op.restoreCursorVisibility ? this.#cursorVisibilitySequence() : ""; + if (abortSuffix || cursorVisibility) + this.#guardTerminalOperation(() => this.terminal.write(abortSuffix + cursorVisibility)); + }; + const flushed = await (this.terminal as Terminal & { flush?: () => Promise }).flush?.(); + // Async boundary: the terminal may have stopped while we awaited. + if (!isCurrentLifecycle()) return failed(); + if (flushed === false) { + if (isCurrentLifecycle()) abortBarrier(); + return failed(); + } + let afterPrefixSucceeded: boolean; + try { + afterPrefixSucceeded = await op.afterPrefix(); + } catch { + if (isCurrentLifecycle()) abortBarrier(); + return failed(); + } + if (afterPrefixSucceeded !== true) { + if (isCurrentLifecycle()) abortBarrier(); + return failed(); + } + // Async boundary: afterPrefix awaited external work; re-check epoch. + if (!isCurrentLifecycle()) return failed(); + const currentLease = this.#rasterLeases.get(request.token?.ownerId ?? ""); + if (!currentLease || currentLease.revoked || currentLease.token !== request.token) { + if (isCurrentLifecycle()) abortBarrier(); + return { queueId: id, operation: op.type, status: currentLease?.revoked ? "revoked" : "stale-token" }; + } + if (op.shouldWrite !== undefined) { + let shouldWrite: boolean; + try { + shouldWrite = op.shouldWrite(); + } catch { + if (isCurrentLifecycle()) abortBarrier(); + return failed(); + } + if (!shouldWrite) { + if (isCurrentLifecycle()) abortBarrier(); + return { queueId: id, operation: op.type, status: "stale-token" }; + } + } + } + // Final pre-write gate: an async body may have resumed after stop(). + if (!isCurrentLifecycle()) return failed(); + const bytes = + op.type === "raster-multipart-batch" + ? op.records.map((b: Uint8Array) => new TextDecoder().decode(b)).join("") + : new TextDecoder().decode(op.bytes); + const finalBytes = + op.type === "raster-multipart-batch" + ? `${op.afterPrefix === undefined && op.prefix !== undefined ? new TextDecoder().decode(op.prefix) : ""}${op.replayPrefix !== undefined ? new TextDecoder().decode(op.replayPrefix) : ""}${bytes}${op.suffix !== undefined ? new TextDecoder().decode(op.suffix) : ""}${op.restoreCursorVisibility ? this.#cursorVisibilitySequence() : ""}` + : bytes; + const dependent = op.type === "generic-render" || op.type === "generic-full-redraw"; + const ok = dependent + ? this.#writeProtectedRenderIngress(finalBytes) + : this.#guardTerminalOperation(() => this.terminal.write(finalBytes)); + if (!ok && dependent) { + const rect = (op as { rect: CellRect }).rect; + const blockedBy = [...this.#rasterCleanup.entries()] + .filter(([, record]) => this.#intersects(record.token.rect, rect)) + .map(([owner]) => owner); + this.#pendingDependentGenericBytes.push({ bytes: new Uint8Array(op.bytes), rect, blockedBy }); + } + if (ok && op.type === "queued-output") op.onWritten?.(); + return { queueId: id, operation: op.type, status: ok ? "written" : "failed", token: request.token }; + }); + } + invalidateRasterLease( + request: Readonly<{ token: RasterLeaseToken; cause: RasterLeaseInvalidatedNotification["cause"] }>, + ): Promise { + return this.#enqueueRaster(isCurrentLifecycle => { + const id = ++this.#rasterQueueId, + lease = this.#rasterLeases.get(request.token.ownerId); + if (!isCurrentLifecycle()) + return { queueId: id, operation: "raster-erase" as const, status: "stale-token" as const }; + if (!lease || lease.token !== request.token) + return { queueId: id, operation: "raster-erase", status: "stale-token" as const }; + 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)); + if (!ok) + this.#rasterCleanup.set(request.token.ownerId, { + token: lease.token, + erase: lease.erase, + callback: lease.callback, + queueId: id, + cause: request.cause, + terminalGeneration: this.#terminalGeneration, + }); + else + lease.callback?.({ + type: "raster-lease-invalidated", + queueId: id, + token: lease.token, + cause: request.cause, + eraseAck: { queueId: id, operation: "raster-erase", status: "written", token: lease.token }, + }); + return { + queueId: id, + operation: "raster-erase" as const, + status: ok ? ("written" as const) : ("failed" as const), + token: lease.token, + }; + }); + } + notifyTerminalLifecycle(event: { + kind: "availability-restored" | "explicit-cleanup"; + source: "tui" | "interactive-mode" | "transport"; + terminalGeneration: number; + }): Promise { + if ( + !event || + (event.kind !== "availability-restored" && event.kind !== "explicit-cleanup") || + (event.source !== "tui" && event.source !== "interactive-mode" && event.source !== "transport") || + !Number.isSafeInteger(event.terminalGeneration) || + event.terminalGeneration < 0 + ) { + return Promise.reject(new TypeError("invalid terminal lifecycle event")); + } + return this.#enqueueRaster(isCurrentLifecycle => { + if (!isCurrentLifecycle()) return { attempted: 0, written: 0, stillPending: 0 }; + if (event.terminalGeneration !== this.#terminalGeneration) + return { attempted: 0, written: 0, stillPending: 0 }; + this.flushTerminalCleanup(true); + if (this.#pendingTerminalCleanup.length > 0) { + return { + attempted: 0, + written: 0, + stillPending: this.#rasterCleanup.size + this.#pendingTerminalCleanup.length, + }; + } + let attempted = 0, + written = 0; + for (const [owner, r] of this.#rasterCleanup) { + r.terminalGeneration = this.#terminalGeneration; + attempted++; + if (this.#writeLifecycleCleanup(this.#cursorGuardedRasterSequence(new TextDecoder().decode(r.erase)))) { + written++; + this.#rasterCleanup.delete(owner); + const ack: TerminalOutputAck = { + queueId: r.queueId, + operation: "raster-erase", + status: "written", + token: r.token, + }; + r.callback?.({ + type: "raster-lease-invalidated", + queueId: r.queueId, + token: r.token, + cause: r.cause, + eraseAck: ack, + }); + const index = this.#pendingDependentGenericBytes.findIndex(item => item.blockedBy.includes(owner)); + if (index >= 0) { + const item = this.#pendingDependentGenericBytes[index]; + const blocked = [...this.#rasterLeases.values(), ...this.#rasterCleanup.values()].some(record => + this.#intersects(record.token.rect, item.rect), + ); + if (!blocked && this.#writeDisjointDependentIngress(new TextDecoder().decode(item.bytes), item.rect)) + this.#pendingDependentGenericBytes.splice(index, 1); + } + } else { + r.terminalGeneration = this.#terminalGeneration; + } + } + if (written > 0) this.requestRender(true); + return { + attempted, + written, + stillPending: this.#rasterCleanup.size + this.#pendingTerminalCleanup.length, + }; + }); + } + #enqueueRaster(fn: (isCurrentLifecycle: () => boolean) => T | Promise): Promise { + const lifecycle = this.#rasterLifecycle; + this.#rasterPending++; + // A queue body captured under a prior running epoch must not write to the + // terminal after stop() restored it — captured-epoch equality only, + // evaluated lazily at entry AND after every await inside async bodies. + // Synchronous stop cleanup writes directly, never through a stale body. + const isCurrentLifecycle = () => lifecycle === this.#rasterLifecycle; + const next: Promise = this.#rasterIngress.then(() => fn(isCurrentLifecycle)) as Promise; + this.#rasterIngress = next.catch(() => undefined); + void next.then( + () => { + this.#rasterPending--; + }, + () => { + this.#rasterPending--; + }, + ); + return next; + } + #validRect(r: CellRect): boolean { + return ( + Object.values(r).every(Number.isSafeInteger) && + r.column >= 0 && + r.row >= 0 && + r.width > 0 && + r.height > 0 && + r.column + r.width <= this.terminal.columns && + r.row + r.height <= this.terminal.rows + ); + } + #unleasedRowSegments(row: number, width: number): Array<{ column: number; width: number }> { + let segments = [{ column: 0, width }]; + for (const lease of this.#rasterLeases.values()) { + const rect = lease.token.rect; + if (row < rect.row || row >= rect.row + rect.height) continue; + const protectedStart = rect.column; + const protectedEnd = rect.column + rect.width; + const next: Array<{ column: number; width: number }> = []; + for (const segment of segments) { + const segmentEnd = segment.column + segment.width; + if (protectedEnd <= segment.column || protectedStart >= segmentEnd) { + next.push(segment); + continue; + } + if (protectedStart > segment.column) + next.push({ column: segment.column, width: protectedStart - segment.column }); + if (protectedEnd < segmentEnd) next.push({ column: protectedEnd, width: segmentEnd - protectedEnd }); + } + segments = next; + } + return segments; + } + #intersects(a: CellRect, b: CellRect): boolean { + return ( + a.column < b.column + b.width && + b.column < a.column + a.width && + a.row < b.row + b.height && + b.row < a.row + a.height + ); + } + #writeRasterPreservingRenderIngress(buffer: string): boolean { + if (this.#rasterCleanup.size > 0) return this.#writeProtectedRenderIngress(buffer); + return this.#writeTerminal(buffer); + } + #writeDisjointDependentIngress(buffer: string, rect: CellRect): boolean { + if ( + [...this.#rasterLeases.values(), ...this.#rasterCleanup.values()].some(record => + this.#intersects(record.token.rect, rect), + ) + ) + return false; + return this.#guardTerminalOperation(() => this.terminal.write(buffer)); + } + #writeProtectedRenderIngress(buffer: string): boolean { + const affected = [...this.#rasterLeases.values()]; + const pending = [...this.#rasterCleanup.values()]; + if (pending.length > 0) return false; + if (affected.length === 0 && pending.length === 0) return this.#writeTerminal(buffer); + const queueId = ++this.#rasterQueueId; + const cleanup = this.#cursorGuardedRasterSequence( + [ + ...pending.map(r => new TextDecoder().decode(r.erase)), + ...affected.map(lease => new TextDecoder().decode(lease.erase)), + ].join(""), + ); + const ok = this.#guardTerminalOperation(() => this.terminal.write(cleanup + buffer)); + if (!ok) { + this.#terminalUnavailable = true; + this.#previousLines = []; + this.#renderRequested = true; + for (const lease of affected) { + this.#rasterLeases.delete(lease.token.ownerId); + this.#rasterCleanup.set(lease.token.ownerId, { + token: lease.token, + erase: lease.erase, + callback: lease.callback, + queueId, + cause: "intersecting-generic-output", + terminalGeneration: this.#terminalGeneration, + }); + } + return false; + } + this.#rasterCleanup.clear(); + for (const lease of affected) { + this.#rasterLeases.delete(lease.token.ownerId); + } + for (const record of pending) { + const ack: TerminalOutputAck = { queueId, operation: "raster-erase", status: "written", token: record.token }; + record.callback?.({ + type: "raster-lease-invalidated", + queueId, + token: record.token, + cause: record.cause, + eraseAck: ack, + }); + } + for (const lease of affected) { + const ack: TerminalOutputAck = { queueId, operation: "raster-erase", status: "written", token: lease.token }; + lease.callback?.({ + type: "raster-lease-invalidated", + queueId, + token: lease.token, + cause: "intersecting-generic-output", + eraseAck: ack, + }); + } + return true; + } start(): void { this.#stopped = false; this.#terminalUnavailable = false; @@ -1688,11 +2284,26 @@ export class TUI extends Container { this.terminal.start( data => this.#handleInput(data), () => { + const hadRasterLease = this.#rasterLeases.size > 0; + this.#revokeRasterLeases("resize"); + if (TERMINAL.imageProtocol || hadRasterLease) this.#queryCellSize(true); this.invalidate(); - this.requestResizeRender(); + this.notifyTerminalLifecycle({ + kind: "explicit-cleanup", + source: "tui", + terminalGeneration: this.#terminalGeneration, + }).then(result => { + if (result.stillPending === 0) this.requestResizeRender(); + }); }, ); - this.flushTerminalCleanup(); + if (this.#pendingTerminalCleanup.length > 0 || this.#rasterCleanup.size > 0) { + void this.notifyTerminalLifecycle({ + kind: "availability-restored", + source: "tui", + terminalGeneration: this.#terminalGeneration, + }); + } this.#hideCursor(); this.#querySixelSupport(); this.#queryCellSize(); @@ -1754,7 +2365,35 @@ export class TUI extends Container { return !this.#terminalUnavailable && this.terminal.available; } + get isRunning(): boolean { + return !this.#stopped; + } + + get terminalGeneration(): number { + return this.#terminalGeneration; + } + get manualViewportActive(): boolean { + return this.#manualViewportTop !== undefined || this.#manualViewportLeaseSuspendPending; + } + #revokeRasterLeases(cause: RasterLeaseInvalidatedNotification["cause"]): void { + const queueId = ++this.#rasterQueueId; + for (const lease of this.#rasterLeases.values()) { + lease.revoked = true; + this.#rasterCleanup.set(lease.token.ownerId, { + token: lease.token, + erase: lease.erase, + callback: lease.callback, + queueId, + cause, + terminalGeneration: this.#terminalGeneration, + }); + } + this.#rasterLeases.clear(); + } #markTerminalUnavailable(settleRenderWaiters = true): void { + this.#terminalGeneration++; + for (const record of this.#rasterCleanup.values()) record.terminalGeneration = this.#terminalGeneration; + this.#revokeRasterLeases("terminal-loss"); this.#terminalUnavailable = true; this.#stopped = true; this.#renderRequested = false; @@ -1801,12 +2440,37 @@ export class TUI extends Container { return true; } + #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; + } + addInputListener(listener: InputListener): () => void { this.#inputListeners.add(listener); return () => { this.#inputListeners.delete(listener); }; } + drainInput(maxMs: number, quiescenceMs: number): Promise { + return this.terminal.drainInput(maxMs, quiescenceMs); + } + drainPetProbeInput(maxMs: number, quiescenceMs: number): Promise { + return this.terminal.drainPendingInput?.(maxMs, quiescenceMs) ?? Promise.resolve(); + } removeInputListener(listener: InputListener): void { this.#inputListeners.delete(listener); @@ -1947,9 +2611,14 @@ export class TUI extends Container { this.invalidate(); this.requestRender(true); } - #queryCellSize(): void { - // Only query if terminal supports images (cell size is only used for image rendering) - if (!TERMINAL.imageProtocol) { + /** Refresh terminal cell metrics for a verified external image transport. */ + refreshImageCellSize(): void { + this.#queryCellSize(true); + } + #queryCellSize(force = false): void { + // Cell dimensions are also needed by the explicitly verified iTerm transport, + // which intentionally does not claim a general TUI image protocol. + if (!force && !TERMINAL.imageProtocol) { return; } // Query terminal for cell size in pixels: CSI 16 t @@ -1958,7 +2627,11 @@ export class TUI extends Container { } stop(): void { - this.flushTerminalCleanup(); + // Invalidate every raster-queue body captured under the running epoch + // before any teardown: nothing queued before stop may write after + // restoration. Synchronous stop cleanup below writes directly. + this.#rasterLifecycle++; + this.#flushRasterLeasesBeforeStop("terminal-loss"); const placementCleanup = this.#kittyPlacementDeletePlan(this.#kittyPlacementSpans, [], [], true).output; if (placementCleanup.length > 0 && this.#writeTerminal(placementCleanup)) this.#kittyPlacementSpans = []; this.#clearSixelProbeState(); @@ -2198,7 +2871,9 @@ export class TUI extends Container { this.#lastRenderAt = performance.now(); this.#lastRenderWriteSucceeded = false; const t0 = renderMetrics.now(); + this.#renderGenerationInProgress = requestedGeneration; this.#doRender(); + this.#renderGenerationInProgress = 0; this.#commitRenderGeneration(requestedGeneration); if (renderMetrics.enabled) renderMetrics.recordRender(renderMetrics.now() - t0); }); @@ -2241,7 +2916,9 @@ export class TUI extends Container { this.#lastRenderAt = performance.now(); this.#lastRenderWriteSucceeded = false; const t0 = renderMetrics.now(); + this.#renderGenerationInProgress = requestedGeneration; this.#doRender(); + this.#renderGenerationInProgress = 0; this.#commitRenderGeneration(requestedGeneration); if (renderMetrics.enabled) renderMetrics.recordRender(renderMetrics.now() - t0); if (this.#renderRequested) { @@ -2271,7 +2948,9 @@ export class TUI extends Container { this.#lastRenderAt = performance.now(); this.#lastRenderWriteSucceeded = false; const t0 = renderMetrics.now(); + this.#renderGenerationInProgress = requestedGeneration; this.#doRender(); + this.#renderGenerationInProgress = 0; this.#commitRenderGeneration(requestedGeneration); if (renderMetrics.enabled) renderMetrics.recordRender(renderMetrics.now() - t0); } @@ -2522,7 +3201,14 @@ export class TUI extends Container { const heightPx = parseInt(match[1], 10); const widthPx = parseInt(match[2], 10); - if (heightPx <= 0 || widthPx <= 0) { + if ( + !Number.isSafeInteger(heightPx) || + !Number.isSafeInteger(widthPx) || + heightPx <= 0 || + widthPx <= 0 || + heightPx > MAX_CELL_DIMENSION_PX || + widthPx > MAX_CELL_DIMENSION_PX + ) { return true; } @@ -3261,34 +3947,55 @@ export class TUI extends Container { { top: transcriptLineCount, bottom: transcriptLineCount + suffixLineCount }, ] : [{ top: nextViewportTop, bottom: nextViewportTop + height }]; - let buffer = deletePlan.output; - buffer += "\x1b[H"; + const lineForScreenRow = (screenRow: number): string => { + const lineIndex = nextViewportTop + screenRow; + const suffixRow = screenRow - transcriptCapacity - noticeRows; + return paintManual && screenRow === transcriptCapacity && noticeRows > 0 + ? "New output — type to follow" + : paintManual && suffixRow >= 0 + ? (lines[transcriptLineCount + suffixRow] ?? "") + : paintManual && lineIndex >= transcriptLineCount + ? "" + : (lines[lineIndex] ?? ""); + }; + const visibleLines = Array.from({ length: height }, (_, screenRow) => lineForScreenRow(screenRow)); + const preserveRasterLeases = + this.#rasterLeases.size > 0 && + this.#rasterCleanup.size === 0 && + !visibleLines.some(line => TERMINAL.isImageLine(line)); + let buffer = `\x1b[?2026h${deletePlan.output}${preserveRasterLeases ? "\x1b[?25l" : ""}`; + if (!preserveRasterLeases) buffer += "\x1b[H"; const committedTranscriptRows: Array = []; for (let screenRow = 0; screenRow < height; screenRow++) { - if (screenRow > 0) buffer += avoidScrollback ? "\r\x1b[1B" : "\r\n"; + if (preserveRasterLeases) buffer += `\x1b[${screenRow + 1};1H`; + else if (screenRow > 0) buffer += avoidScrollback ? "\r\x1b[1B" : "\r\n"; const lineIndex = nextViewportTop + screenRow; - const suffixRow = screenRow - transcriptCapacity - noticeRows; - const line = - paintManual && screenRow === transcriptCapacity && noticeRows > 0 - ? "New output — type to follow" - : paintManual && suffixRow >= 0 - ? (lines[transcriptLineCount + suffixRow] ?? "") - : paintManual && lineIndex >= transcriptLineCount - ? "" - : (lines[lineIndex] ?? ""); + const line = visibleLines[screenRow]!; committedTranscriptRows.push( screenRow < transcriptCapacity && lineIndex < transcriptLineCount ? lineIndex : null, ); const isImage = TERMINAL.isImageLine(line); - if (avoidScrollback && isImage) buffer += "\x1b7\x1b[2K"; + if (!preserveRasterLeases && avoidScrollback && isImage) buffer += "\x1b7\x1b[2K"; if (!isImage && this.#visibleWidthForDifferentialGuard(line) > width) { let truncatedLine = truncateToWidth(line, width, Ellipsis.Omit); truncatedLine += truncatedLine.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET; - buffer += this.#padLineToWidth(truncatedLine, width); + if (preserveRasterLeases) { + for (const segment of this.#unleasedRowSegments(screenRow, width)) { + buffer += `\x1b[${segment.column + 1}G\x1b[${segment.width}X`; + buffer += `${sliceByColumn(truncatedLine, segment.column, segment.width, true)}${SEGMENT_RESET}`; + } + } else { + buffer += this.#padLineToWidth(truncatedLine, width); + } + } else if (preserveRasterLeases) { + for (const segment of this.#unleasedRowSegments(screenRow, width)) { + buffer += `\x1b[${segment.column + 1}G\x1b[${segment.width}X`; + buffer += `${sliceByColumn(line, segment.column, segment.width, true)}${SEGMENT_RESET}`; + } } else { buffer += this.#padLineToWidth(line, width); } - if (avoidScrollback && isImage) buffer += "\x1b8"; + if (!preserveRasterLeases && avoidScrollback && isImage) buffer += "\x1b8"; } if (avoidScrollback) buffer += "\r"; @@ -3303,25 +4010,31 @@ export class TUI extends Container { buffer += cursorSeq; buffer = this.#frameSynchronizedOutput(buffer); let contentWritten = false; - const writeSucceeded = this.#writeRenderBufferAndReanchorImeCursor(buffer, cursorPos, lines.length, () => { - contentWritten = true; - this.#hardwareCursorRow = cursorToRow; - this.#committedTranscriptRows = committedTranscriptRows; - this.#cursorRow = Math.max(0, lines.length - 1); - this.#maxLinesRendered = lines.length; - this.#viewportTopRow = nextViewportTop; - if (paintManual) this.#manualViewportTop = nextViewportTop; - this.#kittyPlacementSpans = this.#kittyCommittedPlacementsAfterPaint( - placementsToClear, - placementsToPaint, - deletePlan, - emittedRegions, - ); - onPainted?.(); - this.#paintedManualOutputNotice = paintManual && this.#manualOutputNotice; - this.#recordPaintedViewportObservation(nextViewportTop, height, paintManual); - }); - if (!contentWritten) return false; + const writeSucceeded = this.#writeRenderBufferAndReanchorImeCursor( + buffer, + cursorPos, + lines.length, + () => { + contentWritten = true; + this.#hardwareCursorRow = cursorToRow; + this.#committedTranscriptRows = committedTranscriptRows; + this.#cursorRow = Math.max(0, lines.length - 1); + this.#maxLinesRendered = lines.length; + this.#viewportTopRow = nextViewportTop; + if (paintManual) this.#manualViewportTop = nextViewportTop; + this.#kittyPlacementSpans = this.#kittyCommittedPlacementsAfterPaint( + placementsToClear, + placementsToPaint, + deletePlan, + emittedRegions, + ); + onPainted?.(); + this.#paintedManualOutputNotice = paintManual && this.#manualOutputNotice; + this.#recordPaintedViewportObservation(nextViewportTop, height, paintManual); + }, + preserveRasterLeases, + ); + if (!writeSucceeded || !contentWritten) return false; if (this.#debugRedraw) { const msg = `[${new Date().toISOString()}] viewportRepaint: ${reason} (lines=${lines.length}, height=${height}, viewportTop=${nextViewportTop})\n`; @@ -4146,8 +4859,15 @@ export class TUI extends Container { if (this.#writeCursorPosition(cursorPos, newLines.length)) this.#refreshPaintedLiveViewportObservation(height); return; } - const nextLiveViewportTop = Math.max(0, newLines.length - height); + if ( + this.#rasterLeases.size > 0 && + this.#rasterCleanup.size === 0 && + !newLines.slice(Math.max(0, newLines.length - height)).some(line => TERMINAL.isImageLine(line)) + ) { + viewportRepaint("changed frame with active raster lease"); + return; + } if (newLines.length < this.#previousLines.length && nextLiveViewportTop !== prevViewportTop) { viewportRepaint(`content contraction changed viewport top (${prevViewportTop} -> ${nextLiveViewportTop})`); return; @@ -4360,6 +5080,7 @@ export class TUI extends Container { return; } // Render from first changed line to end + const renderEnd = Math.min(lastChanged, newLines.length - 1); // Build buffer with all updates wrapped in synchronized output const deletePlan = this.#kittyPlacementDeletePlan(previousKittyPlacementSpans, nextKittyPlacementSpans, [ { top: firstChanged, bottom: lastChanged + 1 }, @@ -4387,6 +5108,16 @@ export class TUI extends Container { : appendStart ? firstChanged - 1 : firstChanged; + const appendWillScroll = appendStart && moveTargetRow >= prevViewportBottom; + if ( + (moveTargetRow > prevViewportBottom || appendWillScroll || renderEnd > prevViewportBottom) && + this.#rasterLeases.size > 0 && + this.#rasterCleanup.size === 0 && + !newLines.slice(Math.max(0, newLines.length - height)).some(line => TERMINAL.isImageLine(line)) + ) { + viewportRepaint("streaming append with active raster lease"); + return; + } if (moveTargetRow > prevViewportBottom) { if (nativeScrollbackAdmission) { // The logical cursor row can be one row ahead of the physical xterm @@ -4422,12 +5153,16 @@ export class TUI extends Container { buffer += appendStart ? "\r\n" : "\r"; // Move to column 0 - // Only render changed lines (firstChanged to lastChanged), not all lines to end - // This reduces flicker when only a single line changes (e.g., spinner animation) - const renderEnd = Math.min(lastChanged, newLines.length - 1); + // Only render changed lines (firstChanged to lastChanged), not all lines to end. + // This reduces flicker when only a single line changes (e.g., spinner animation). + const preserveRasterLeases = + this.#rasterLeases.size > 0 && + this.#rasterCleanup.size === 0 && + moveTargetRow <= prevViewportBottom && + !newLines.slice(firstChanged, renderEnd + 1).some(line => TERMINAL.isImageLine(line)); for (let i = firstChanged; i <= renderEnd; i++) { if (i > firstChanged) buffer += "\r\n"; - buffer += "\x1b[2K"; + if (!preserveRasterLeases) buffer += "\x1b[2K"; const line = newLines[i]; let truncatedLine = line; const isImage = TERMINAL.isImageLine(line); @@ -4454,8 +5189,18 @@ export class TUI extends Container { truncatedLine += truncatedLine.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET; } // Non-image lines are pre-terminated/normalized by #applyLineResets; - // truncated lines re-append LINE_TERMINATOR above. - buffer += this.#padLineToWidth(truncatedLine, width); + // truncated lines re-append LINE_TERMINATOR above. While a raster lease + // occupies this screen row, clear and redraw only the complementary cell + // spans so ordinary input cannot erase the inline image. + if (preserveRasterLeases) { + const screenRow = i - viewportTop; + for (const segment of this.#unleasedRowSegments(screenRow, width)) { + buffer += `\x1b[${segment.column + 1}G\x1b[${segment.width}X`; + buffer += `${sliceByColumn(truncatedLine, segment.column, segment.width, true)}${SEGMENT_RESET}`; + } + } else { + buffer += this.#padLineToWidth(truncatedLine, width); + } } // Track where cursor ended up after rendering @@ -4515,25 +5260,31 @@ export class TUI extends Container { // frame and geometry are authoritative even when the optional IME cursor // write subsequently detaches the terminal. if ( - !this.#writeRenderBufferAndReanchorImeCursor(buffer, 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.#kittyPlacementSpans = this.#kittyCommittedPlacementsAfterPaint( - previousKittyPlacementSpans, - nextKittyPlacementSpans, - deletePlan, - [{ top: firstChanged, bottom: renderEnd + 1 }], - ); - this.#manualTranscriptLineCount = nextTranscriptLineCount; - this.#manualSuffixLineCount = nextSuffixLineCount; - this.#refreshPaintedLiveViewportObservation(height); - }) + !this.#writeRenderBufferAndReanchorImeCursor( + buffer, + 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.#kittyPlacementSpans = this.#kittyCommittedPlacementsAfterPaint( + previousKittyPlacementSpans, + nextKittyPlacementSpans, + deletePlan, + [{ top: firstChanged, bottom: renderEnd + 1 }], + ); + this.#manualTranscriptLineCount = nextTranscriptLineCount; + this.#manualSuffixLineCount = nextSuffixLineCount; + this.#refreshPaintedLiveViewportObservation(height); + }, + preserveRasterLeases, + ) ) return; this.#latestRenderedLines = newLines; @@ -4583,18 +5334,51 @@ export class TUI extends Container { return { seq, toRow: targetRow }; } + #cursorVisibilitySequence(): string { + if (this.#showHardwareCursor || this.#imeCursorActive) + return this.#useImeBlockCursor ? "\x1b[2 q\x1b[?25h" : "\x1b[?25h"; + return this.#useImeBlockCursor ? "\x1b[0 q\x1b[?25l" : "\x1b[?25l"; + } + #cursorGuardedRasterSequence(payload: string): string { + return `\x1b[?2026h\x1b7\x1b[?25l${payload}\x1b8${this.#cursorVisibilitySequence()}\x1b[?2026l`; + } /** Retain terminal cleanup until a write succeeds, even after its component is disposed. */ - queueTerminalCleanup(payload: string, onDelivered?: () => void): void { - this.#pendingTerminalCleanup.push({ payload, onDelivered }); - this.flushTerminalCleanup(); + queueTerminalCleanup(payload: string, onDelivered?: () => void): Promise { + return this.#enqueueRaster(isCurrentLifecycle => { + if (isCurrentLifecycle() && this.#writeTerminal(payload)) { + onDelivered?.(); + return; + } + // Stale epoch or failed write: retain the payload for a future start() + // instead of writing it after terminal restoration. + this.#pendingTerminalCleanup.push({ payload, onDelivered }); + }); } - /** Retry queued terminal cleanup after terminal recovery or before shutdown. */ - flushTerminalCleanup(): void { + /** Queue protocol-neutral output behind the same terminal ordering as renders. */ + queueTerminalOutput( + payload: string, + options?: { shouldWrite?: () => boolean; onWritten?: () => void }, + ): Promise { + return this.submitTerminalOutput({ + operation: { + type: "queued-output", + bytes: new TextEncoder().encode(payload), + ...(options?.shouldWrite ? { shouldWrite: options.shouldWrite } : {}), + ...(options?.onWritten ? { onWritten: options.onWritten } : {}), + }, + }); + } + + /** Retry retained cleanup after recovery or before shutdown. */ + flushTerminalCleanup(restoreTerminalAvailability = false): void { while (this.#pendingTerminalCleanup.length > 0) { const pending = this.#pendingTerminalCleanup[0]; - if (!this.#writeTerminal(pending.payload)) return; + const written = restoreTerminalAvailability + ? this.#writeLifecycleCleanup(pending.payload) + : this.#writeTerminal(pending.payload); + if (!written) return; this.#pendingTerminalCleanup.shift(); pending.onDelivered?.(); } @@ -4605,44 +5389,46 @@ export class TUI extends Container { * transaction. The emitter is an exempt physical overlay: its bytes are * deliberately kept out of the shared transcript write. */ - setPostRenderEmitter(emitter: (() => string | null) | undefined): void { + setPostRenderEmitter(emitter: (() => string | PostRenderEmission | null) | undefined): void { this.#postRenderEmitter = emitter; } - #postRenderEmitter: (() => string | null) | undefined; + #postRenderEmitter: (() => string | PostRenderEmission | null) | undefined; #writeRenderBufferAndReanchorImeCursor( buffer: string, cursorPos: { row: number; col: number } | null, totalLines: number, onBufferWritten?: () => void, + preserveRasterLeases = false, ): boolean { - if (!this.#writeTerminal(buffer)) { - return false; - } - onBufferWritten?.(); - this.#lastRenderWriteSucceeded = true; - - const overlay = this.#postRenderEmitter?.(); - if (overlay) { - // DECSC/DECRC keep the hardware cursor stable; the dedicated - // synchronized block prevents visible tearing while the overlay - // area is cleared and redrawn. - const overlayBuffer = this.#frameSynchronizedOutput(`\x1b7${overlay}\x1b8`); - // Overlay delivery is outside shared transcript ownership. The - // shared write has already committed even when this exempt write - // fails, so do not make callers retry the shared bytes. - if (!this.#writeTerminal(overlayBuffer, true)) { - return true; + const writeIngress = preserveRasterLeases + ? (bytes: string) => this.#writeRasterPreservingRenderIngress(bytes) + : (bytes: string) => this.#writeProtectedRenderIngress(bytes); + const renderGeneration = this.#renderGenerationInProgress; + const write = () => { + if (!writeIngress(buffer)) return false; + onBufferWritten?.(); + this.#lastRenderWriteSucceeded = true; + if (renderGeneration > 0) this.#settleRenderCommitWaiters(true, renderGeneration); + const emission = this.#postRenderEmitter?.(); + if (emission) { + const overlay = typeof emission === "string" ? emission : emission.payload; + const overlayBuffer = `\x1b[?2026h\x1b7${overlay}\x1b8\x1b[?2026l`; + if (this.#writeTerminal(overlayBuffer, true) && typeof emission !== "string") emission.onWritten?.(); } - } - if (!this.#imeCursorActive) return true; - // Cursor positioning is outside shared transcript ownership. A failure still - // makes the terminal unavailable, but cannot uncommit the shared frame. The - // onBufferWritten callback has already run; the return value propagates - // terminal availability so callers can detect the detach. - const cursorWritten = this.#writeCursorPosition(cursorPos, totalLines, true); - return cursorWritten; + if (!this.#imeCursorActive) return true; + const cursorWritten = this.#writeCursorPosition(cursorPos, totalLines, true); + return cursorWritten; + }; + if ( + this.#rasterPending === 0 && + this.#rasterCleanup.size === 0 && + (preserveRasterLeases || this.#rasterLeases.size === 0) + ) + return write(); + this.#enqueueRaster(isCurrentLifecycle => isCurrentLifecycle() && write()); + return true; } /** diff --git a/packages/tui/test/gajae-pet.test.ts b/packages/tui/test/gajae-pet.test.ts index 80b07c533d..146685c921 100644 --- a/packages/tui/test/gajae-pet.test.ts +++ b/packages/tui/test/gajae-pet.test.ts @@ -3,12 +3,23 @@ import * as zlib from "node:zlib"; import { __gajaePetTestHooks, buildGajaePixelFrames, + burstTimeline, + encodeGajaePetGif, encodeGridIterm2, encodeGridSixel, + getGajaePetGifCached, + getGajaePetGifCacheStats, + idleTimeline, PET_SKIN_IDS, PET_SKINS, + petBurstDurationMs, + petBurstFrame, + previewTimeline, + resetGajaePetGifCache, resolvePetMode, + workingTimeline, } from "@gajae-code/tui"; +import { encodeITerm2Multipart, wrapITerm2RecordsForTmux } from "../src/terminal-capabilities"; function decodeIterm2Png(sequence: string): { width: number; @@ -135,6 +146,31 @@ describe("gajae pixel frames", () => { expect(built.frames.base.startsWith('\x1bP0;1;0q"1;1;36;45')).toBe(true); }); + it("centers iTerm2 art in a three-row raster and advertises the padded canvas", () => { + const built = buildGajaePixelFrames({ + protocol: "iterm2", + cellWidthPx: 9, + cellHeightPx: 18, + targetRows: 2, + iterm2TopPaddingPx: 9, + iterm2BottomPaddingPx: 9, + }); + const decoded = decodeIterm2Png(built.frames.base); + expect(built.rows).toBe(2); + expect(built.rasterRows).toBe(3); + expect(built.heightPx).toBe(54); + expect(built.frames.base).toContain("width=4;height=3;preserveAspectRatio=0"); + expect(decoded.width).toBe(36); + expect(decoded.height).toBe(54); + const alpha = (x: number, y: number) => decoded.rgba[(y * decoded.width + x) * 4 + 3]; + for (const y of [0, 8, 45, 53]) { + expect(Array.from({ length: decoded.width }, (_, x) => alpha(x, y))).toEqual(Array(decoded.width).fill(0)); + } + expect( + decoded.rgba.some((value, index) => index % 4 === 3 && value === 255 && index / 4 / decoded.width >= 9), + ).toBe(true); + }); + it("carries the >< effort face on danceL and the ^^ victory face on flex", () => { const effort = __gajaePetTestHooks .getPixelGrid("danceL") @@ -175,7 +211,7 @@ describe("gajae pixel frames", () => { it("encodes deterministic iTerm2 PNG dimensions, CRCs, colors, and transparent padding", () => { const sequence = encodeGridIterm2(["R."], 2, 2, 2, 1, 1, 0, 0, { R: [12, 34, 56] }); - expect(sequence).toContain("width=2;height=2;preserveAspectRatio=0;inline=1"); + expect(sequence).toContain("name=Z2FqYWUtcGV0LnBuZw==;width=2;height=2;preserveAspectRatio=0;inline=1"); const decoded = decodeIterm2Png(sequence); expect(decoded.width).toBe(4); expect(decoded.height).toBe(4); @@ -354,3 +390,258 @@ describe("gajae pixel frames", () => { expect(built.frames["spin-1"]).not.toBe(built.frames["spin-2"]); }); }); +function gifFrameDelays(bytes: Uint8Array): number[] { + let offset = 6; + const read16 = () => { + const value = bytes[offset] | (bytes[offset + 1] << 8); + offset += 2; + return value; + }; + read16(); + read16(); + const packed = bytes[offset++]; + if (packed & 0x80) offset += 3 * (1 << ((packed & 7) + 1)); + offset += 2; + const delays: number[] = []; + const skipSubBlocks = () => { + while (bytes[offset] !== 0) offset += 1 + bytes[offset]; + offset++; + }; + while (bytes[offset] !== 0x3b) { + if (bytes[offset] === 0x21 && bytes[offset + 1] === 0xf9) { + if (bytes[offset + 2] !== 4) throw new Error("invalid GIF graphics control extension"); + offset += 3; + offset++; + delays.push((bytes[offset] | (bytes[offset + 1] << 8)) * 10); + offset += 2; + offset++; + if (bytes[offset++] !== 0) throw new Error("invalid GIF graphics control extension"); + } else if (bytes[offset] === 0x2c) { + offset += 10; + const imagePacked = bytes[offset - 1]; + if (imagePacked & 0x80) offset += 3 * (1 << ((imagePacked & 7) + 1)); + offset++; + skipSubBlocks(); + } else if (bytes[offset] === 0x21) { + offset += 2; + offset += 1 + bytes[offset]; + skipSubBlocks(); + } else throw new Error(`invalid GIF block at offset ${offset}`); + } + return delays; +} +function gifFirstFramePixels(bytes: Uint8Array): number[] { + let offset = 6; + const read16 = () => { + const value = bytes[offset] | (bytes[offset + 1] << 8); + offset += 2; + return value; + }; + read16(); + read16(); + const packed = bytes[offset++]; + if (packed & 0x80) offset += 3 * (1 << ((packed & 7) + 1)); + offset += 2; + const skipSubBlocks = () => { + while (bytes[offset] !== 0) offset += 1 + bytes[offset]; + offset++; + }; + while (bytes[offset] === 0x21) { + offset += 2; + offset += 1 + bytes[offset]; + skipSubBlocks(); + } + if (bytes[offset] !== 0x2c) throw new Error("missing GIF image block"); + offset += 10; + const minCodeSize = bytes[offset++]; + if (minCodeSize !== 8) throw new Error("unexpected GIF LZW code size"); + const compressed: number[] = []; + while (bytes[offset] !== 0) { + const length = bytes[offset++]; + compressed.push(...bytes.slice(offset, offset + length)); + offset += length; + } + let bitOffset = 0; + const readCode = () => { + let code = 0; + for (let bit = 0; bit < 9; bit++) { + code |= ((compressed[(bitOffset + bit) >> 3] >> ((bitOffset + bit) & 7)) & 1) << bit; + } + bitOffset += 9; + return code; + }; + const pixels: number[] = []; + while (bitOffset + 9 <= compressed.length * 8) { + const code = readCode(); + if (code === 257) break; + if (code !== 256) pixels.push(code); + } + return pixels; +} +describe("GIF artifacts and helpers", () => { + it("encodes deterministic GIF89a geometry, metadata, delays, and distinct skin palettes", () => { + const timeline = [ + { name: "base" as const, delayMs: 25 }, + { name: "flex" as const, delayMs: 100 }, + ]; + const red = encodeGajaePetGif({ skin: "red", timeline, cellWidthPx: 9, cellHeightPx: 18, targetRows: 2 }); + const blue = encodeGajaePetGif({ skin: "blue", timeline, cellWidthPx: 9, cellHeightPx: 18, targetRows: 2 }); + expect(Buffer.from(red.bytes.slice(0, 6)).toString()).toBe("GIF89a"); + expect([red.width, red.height]).toEqual([36, 36]); + expect(red.bytes).not.toEqual(blue.bytes); + expect(red.bytes).toEqual( + encodeGajaePetGif({ skin: "red", timeline, cellWidthPx: 9, cellHeightPx: 18, targetRows: 2 }).bytes, + ); + expect(red.frames).toEqual(timeline); + expect(Buffer.from(red.bytes).toString("latin1")).toContain("NETSCAPE2.0"); + const graphicsControlExtension = red.bytes.findIndex( + (value, index) => value === 0x21 && red.bytes[index + 1] === 0xf9 && red.bytes[index + 2] === 0x04, + ); + expect(graphicsControlExtension).toBeGreaterThanOrEqual(0); + expect(red.bytes[graphicsControlExtension + 3]).toBe(0x09); + expect(red.bytes[graphicsControlExtension + 6]).toBe(0); + expect(gifFrameDelays(red.bytes)).toEqual([30, 100]); + 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`, + ); + 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("supports rectangle geometry and all public timeline helpers", () => { + const rectangle = encodeGajaePetGif({ rectangle: { width: 7, height: 5 }, timeline: idleTimeline() }); + expect([rectangle.width, rectangle.height]).toEqual([7, 5]); + expect(workingTimeline()).toEqual([ + { name: "danceL", delayMs: 300 }, + { name: "danceR", delayMs: 300 }, + { name: "base", delayMs: 260 }, + { name: "flex", delayMs: 480 }, + { name: "base", delayMs: 260 }, + ]); + const blueBurst = burstTimeline("blue"); + expect(blueBurst.slice(0, workingTimeline().length)).toEqual([...workingTimeline()]); + expect(blueBurst.slice(workingTimeline().length).map(frame => frame.name)).toEqual([ + "cry1", + "cry2", + "cry3", + "cry1", + "cry2", + "cry3", + "cry1", + "cry2", + "cry3", + ]); + expect(blueBurst.reduce((sum, frame) => sum + frame.delayMs, 0)).toBe(petBurstDurationMs(PET_SKINS.blue.burst)); + expect(previewTimeline("red")).toEqual(burstTimeline("red")); + expect(petBurstDurationMs(PET_SKINS.red.burst)).toBe(2600); + expect(petBurstFrame(PET_SKINS.red.burst, 0, 0)).toBe("danceL"); + expect(petBurstFrame(PET_SKINS.red.burst, 2000, 220)).toBe("base"); + }); + it("centers an unchanged two-row sprite inside a transparent three-cell iTerm canvas", () => { + const timeline = [{ name: "base" as const, delayMs: 100 }]; + const centered = encodeGajaePetGif({ + timeline, + rectangle: { width: 36, height: 57 }, + displaySize: { width: 4, height: 3 }, + contentInset: { topPx: 9, bottomPx: 10 }, + }); + const unpadded = encodeGajaePetGif({ + timeline, + rectangle: { width: 36, height: 57 }, + displaySize: { width: 4, height: 3 }, + }); + + expect([centered.width, centered.height]).toEqual([36, 57]); + expect(centered.multipart[0]).toContain("width=4;height=3;"); + // Index zero remains transparent. Verify the odd-height half-cell split + // leaves exactly 9 transparent top rows and 10 transparent bottom rows. + const pixels = gifFirstFramePixels(centered.bytes); + expect(pixels).toHaveLength(36 * 57); + expect(pixels.slice(0, 36 * 9)).toEqual(Array(36 * 9).fill(0)); + expect(pixels.slice(-36 * 10)).toEqual(Array(36 * 10).fill(0)); + expect(centered.bytes).not.toEqual(unpadded.bytes); + }); + + it("keeps GIF cache bounded and resettable", () => { + resetGajaePetGifCache(); + expect(getGajaePetGifCacheStats()).toMatchObject({ + size: 0, + bytes: 0, + evictions: 0, + gifBytes: 0, + multipartBytes: 0, + tmuxDcsBytes: 0, + base64Bytes: 0, + }); + const first = getGajaePetGifCached({ rectangle: { width: 1, height: 1 } }); + const second = getGajaePetGifCached({ rectangle: { width: 2, height: 1 } }); + for (let i = 3; i <= 32; i++) getGajaePetGifCached({ rectangle: { width: i, height: 1 } }); + expect(getGajaePetGifCacheStats().size).toBe(32); + expect(getGajaePetGifCacheStats().evictions).toBe(0); + expect(getGajaePetGifCached({ rectangle: { width: 1, height: 1 } })).toBe(first); + getGajaePetGifCached({ rectangle: { width: 33, height: 1 } }); + expect(getGajaePetGifCacheStats().size).toBe(32); + expect(getGajaePetGifCacheStats().evictions).toBe(1); + expect(getGajaePetGifCached({ rectangle: { width: 1, height: 1 } })).toBe(first); + expect(getGajaePetGifCached({ rectangle: { width: 2, height: 1 } })).not.toBe(second); + resetGajaePetGifCache(); + expect(getGajaePetGifCacheStats()).toMatchObject({ + size: 0, + bytes: 0, + evictions: 0, + gifBytes: 0, + multipartBytes: 0, + tmuxDcsBytes: 0, + }); + }); + it("keys cached GIF artifacts by terminal display size", () => { + resetGajaePetGifCache(); + const pixels = { width: 36, height: 36 }; + const pixelSized = getGajaePetGifCached({ rectangle: pixels }); + const cellSized = getGajaePetGifCached({ + rectangle: pixels, + displaySize: { width: 4, height: 2 }, + }); + expect(cellSized).not.toBe(pixelSized); + expect(pixelSized.multipart[0]).toContain("width=36px;height=36px;"); + expect(cellSized.multipart[0]).toContain("width=4;height=2;"); + resetGajaePetGifCache(); + }); + + it("evicts deterministically at the retained-byte ceiling independently of the entry cap", () => { + const maxRetainedBytes = 8 * 1024 * 1024; + resetGajaePetGifCache(); + const first = getGajaePetGifCached({ + rectangle: { width: 512, height: 512 }, + timeline: [{ name: "base", delayMs: 100 }], + }); + for (let width = 513; width <= 527; width++) { + getGajaePetGifCached({ + rectangle: { width, height: 512 }, + timeline: [{ name: "base", delayMs: 100 }], + }); + } + const stats = getGajaePetGifCacheStats(); + expect(stats.bytes).toBeLessThanOrEqual(maxRetainedBytes); + expect(stats.base64Bytes).toBeGreaterThan(0); + expect(stats.bytes).toBe(stats.gifBytes + stats.base64Bytes + stats.multipartBytes + stats.tmuxDcsBytes); + expect(stats.size).toBeLessThan(32); + expect(stats.evictions).toBeGreaterThan(0); + expect( + getGajaePetGifCached({ + rectangle: { width: 512, height: 512 }, + timeline: [{ name: "base", delayMs: 100 }], + }), + ).not.toBe(first); + const repeated = getGajaePetGifCacheStats(); + expect(repeated.bytes).toBeLessThanOrEqual(maxRetainedBytes); + expect(repeated.bytes).toBe( + repeated.gifBytes + repeated.base64Bytes + repeated.multipartBytes + repeated.tmuxDcsBytes, + ); + expect(repeated.size).toBeLessThanOrEqual(32); + resetGajaePetGifCache(); + }); +}); diff --git a/packages/tui/test/raster-lease.test.ts b/packages/tui/test/raster-lease.test.ts new file mode 100644 index 0000000000..a955617364 --- /dev/null +++ b/packages/tui/test/raster-lease.test.ts @@ -0,0 +1,908 @@ +import { describe, expect, it, spyOn } from "bun:test"; +import { type Component, Container, CURSOR_MARKER, setTerminalImageProtocol, Text, TUI } from "@gajae-code/tui"; +import { VirtualTerminal } from "./virtual-terminal"; + +const rect = (column = 0, row = 0, width = 2, height = 1) => ({ column, row, width, height }); +const bytes = (value: string) => new TextEncoder().encode(value); +function failNextWrites(terminal: VirtualTerminal): void { + const originalWrite = terminal.write.bind(terminal); + let failed = false; + terminal.write = data => { + if (!failed) { + failed = true; + throw new Error("injected terminal write failure"); + } + originalWrite(data); + }; +} +const request = ( + ownerId: string, + r = rect(), + erase = "ERASE", + onInvalidated?: NonNullable[0]["onInvalidated"]>, +): Parameters[0] => ({ + ownerId, + rect: r, + erase: { type: "raster-erase" as const, bytes: bytes(erase) }, + onInvalidated, +}); + +async function setup(showHardwareCursor?: boolean) { + const terminal = new VirtualTerminal(10, 4); + const tui = new TUI(terminal, showHardwareCursor); + return { terminal, tui }; +} + +describe("TUI raster lease public boundary", () => { + it("rejects invalid geometry and allows one overlapping lease only", async () => { + const { tui } = await setup(); + const invalid = await tui.acquireRasterLease(request("bad", rect(9, 0, 2, 1))); + expect(invalid.status).toBe("rejected"); + if (invalid.status !== "rejected") throw new Error("expected invalid geometry rejection"); + expect(invalid.reason).toBe("invalid-geometry"); + const first = await tui.acquireRasterLease(request("one")); + expect(first.status).toBe("acquired"); + const conflict = await tui.acquireRasterLease(request("two", rect(1, 0, 2, 1))); + expect(conflict.status).toBe("rejected"); + if (conflict.status !== "rejected") throw new Error("expected owner conflict rejection"); + expect(conflict.reason).toBe("owner-conflict"); + if (first.status === "acquired") + expect( + ( + await tui.submitTerminalOutput({ + operation: { type: "raster-probe", bytes: bytes("P") }, + token: first.token, + }) + ).status, + ).toBe("written"); + }); + + it("rejects stale identity tokens without writing", async () => { + const { tui, terminal } = await setup(); + const acquired = await tui.acquireRasterLease(request("owner")); + if (acquired.status !== "acquired") throw new Error("lease not acquired"); + const stale = { ...acquired.token, rect: { ...acquired.token.rect } }; + terminal.clearWriteLog(); + const ack = await tui.submitTerminalOutput({ + operation: { type: "raster-erase", bytes: bytes("X") }, + token: stale, + }); + expect(ack.status).toBe("stale-token"); + expect(terminal.getWriteLog()).toEqual([]); + }); + + it("writes multipart records as one terminal write", async () => { + const { tui, terminal } = await setup(); + const lease = await tui.acquireRasterLease(request("multipart")); + if (lease.status !== "acquired") throw new Error("lease not acquired"); + terminal.clearWriteLog(); + const ack = await tui.submitTerminalOutput({ + operation: { type: "raster-multipart-batch", records: [bytes("A"), bytes("B"), bytes("C")] }, + token: lease.token, + }); + expect(ack.status).toBe("written"); + expect(terminal.getWriteLog()).toEqual(["ABC"]); + }); + + it("classifies a throwing shouldWrite predicate as a failed operation instead of rejecting", async () => { + const { tui, terminal } = await setup(); + const lease = await tui.acquireRasterLease(request("throwing-predicate")); + if (lease.status !== "acquired") throw new Error("lease not acquired"); + terminal.clearWriteLog(); + + const queued = await tui.queueTerminalOutput("THROW", { + shouldWrite: () => { + throw new Error("predicate boom"); + }, + }); + expect(queued.status).toBe("failed"); + + const ack = await tui.submitTerminalOutput({ + token: lease.token, + operation: { + type: "raster-multipart-batch", + records: [bytes("A")], + shouldWrite: () => { + throw new Error("predicate boom"); + }, + }, + }); + expect(ack.status).toBe("failed"); + expect(terminal.getWriteLog()).toEqual([]); + + const stale = await tui.queueTerminalOutput("NOPE", { shouldWrite: () => false }); + expect(stale.status).toBe("stale-token"); + terminal.clearWriteLog(); + const written = await tui.queueTerminalOutput("OK", { shouldWrite: () => true }); + expect(written.status).toBe("written"); + expect(terminal.getWriteLog()).toEqual(["OK"]); + tui.stop(); + }); + it("drops a stale multipart batch at the terminal-write boundary", async () => { + const { tui, terminal } = await setup(); + const lease = await tui.acquireRasterLease(request("freshness")); + if (lease.status !== "acquired") throw new Error("lease not acquired"); + terminal.clearWriteLog(); + + const ack = await tui.submitTerminalOutput({ + token: lease.token, + operation: { + type: "raster-multipart-batch", + prefix: bytes("STALE_PREFIX"), + afterPrefix: async () => true, + records: [bytes("STALE_GIF")], + abortSuffix: bytes("STALE_ABORT"), + shouldWrite: () => false, + }, + }); + expect(ack.status).toBe("stale-token"); + expect(terminal.getWriteLog()).toEqual([]); + }); + it("drops queued output whose owner becomes stale before terminal write", async () => { + const { tui, terminal } = await setup(); + terminal.clearWriteLog(); + + const ack = await tui.queueTerminalOutput("STALE_FRAME", { shouldWrite: () => false }); + + expect(ack.status).toBe("stale-token"); + expect(terminal.getWriteLog()).toEqual([]); + }); + it("serializes retained cleanup behind prior output and before successor output", async () => { + const { tui, terminal } = await setup(); + terminal.clearWriteLog(); + + const prior = tui.queueTerminalOutput("PRIOR"); + tui.queueTerminalCleanup("PET_CLEANUP"); + const successor = tui.queueTerminalOutput("SUCCESSOR"); + + await Promise.all([prior, successor]); + expect(terminal.getWriteLog()).toEqual(["PRIOR", "PET_CLEANUP", "SUCCESSOR"]); + }); + it("erases an active raster lease before terminal teardown", async () => { + const { tui, terminal } = await setup(); + let invalidated = 0; + tui.start(); + await terminal.waitForRender(); + const lease = await tui.acquireRasterLease(request("stop", rect(8, 3, 2, 1), "ERASE", () => invalidated++)); + expect(lease.status).toBe("acquired"); + terminal.clearWriteLog(); + + tui.stop(); + + const output = terminal.getWriteLog().join(""); + expect(output).toContain("ERASE"); + expect(output.indexOf("ERASE")).toBeLessThan(output.indexOf("\x1b[?2004l")); + expect(invalidated).toBe(1); + }); + it("refreshes cell metrics when a raster lease is revoked by resize", async () => { + setTerminalImageProtocol(null); + const { tui, terminal } = await setup(); + tui.start(); + const lease = await tui.acquireRasterLease(request("iterm-resize")); + if (lease.status !== "acquired") throw new Error("lease not acquired"); + terminal.clearWriteLog(); + + terminal.resize(11, 4); + await terminal.waitForRender(); + + expect(terminal.getWriteLog()).toContain("\x1b[16t"); + tui.stop(); + }); + it("aborts a multipart barrier when it becomes stale after its prefix", async () => { + const { tui, terminal } = await setup(); + const lease = await tui.acquireRasterLease(request("barrier-freshness")); + if (lease.status !== "acquired") throw new Error("lease not acquired"); + let current = true; + terminal.clearWriteLog(); + + const ack = await tui.submitTerminalOutput({ + token: lease.token, + operation: { + type: "raster-multipart-batch", + prefix: bytes("SAVE"), + afterPrefix: async () => { + current = false; + return true; + }, + records: [bytes("STALE_GIF")], + abortSuffix: bytes("RESTORE"), + shouldWrite: () => current, + }, + }); + expect(ack.status).toBe("stale-token"); + expect(terminal.getWriteLog()).toEqual(["SAVE", "RESTORE"]); + }); + it("writes multipart cursor guards atomically when no barrier is required", async () => { + const { tui, terminal } = await setup(); + const lease = await tui.acquireRasterLease(request("cursor-guard")); + if (lease.status !== "acquired") throw new Error("lease not acquired"); + terminal.clearWriteLog(); + const ack = await tui.submitTerminalOutput({ + operation: { + type: "raster-multipart-batch", + prefix: bytes("SAVE"), + records: [bytes("IMAGE")], + suffix: bytes("RESTORE"), + }, + token: lease.token, + }); + expect(ack.status).toBe("written"); + expect(terminal.getWriteLog()).toEqual(["SAVEIMAGERESTORE"]); + }); + it("restores tracked cursor visibility without moving it after multipart output", async () => { + const { tui, terminal } = await setup(true); + const lease = await tui.acquireRasterLease(request("cursor-reanchor")); + if (lease.status !== "acquired") throw new Error("lease not acquired"); + terminal.clearWriteLog(); + const ack = await tui.submitTerminalOutput({ + operation: { + type: "raster-multipart-batch", + records: [bytes("IMAGE")], + restoreCursorVisibility: true, + }, + token: lease.token, + }); + expect(ack.status).toBe("written"); + const output = terminal.getWriteLog().join(""); + expect(output).toStartWith("IMAGE"); + expect(output).not.toContain("\x1b[1;1H"); + expect(output).toEndWith("\x1b[?25h"); + }); + 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() {} }; + tui.addChild(component); + tui.start(); + await terminal.waitForRender(); + const lease = await tui.acquireRasterLease(request("active-cursor-erase")); + if (lease.status !== "acquired") throw new Error("lease not acquired"); + terminal.clearWriteLog(); + + const ack = await tui.invalidateRasterLease({ token: lease.token, cause: "explicit" }); + expect(ack.status).toBe("written"); + const output = terminal.getWriteLog().join(""); + expect(output).toContain("\x1b[?2026h\x1b7\x1b[?25lERASE\x1b8"); + expect(output).not.toContain("\x1b[1;6H"); + expect(output).toEndWith("\x1b[?25h\x1b[?2026l"); + tui.stop(); + }); + it("restores saved rendition and cursor state around an iTerm-style lease erase", async () => { + const { tui, terminal } = await setup(true); + const lease = await tui.acquireRasterLease(request("iterm-erase", rect(6, 1, 2, 3), "\x1b[0m\x1b[2;7H\x1b[2X")); + if (lease.status !== "acquired") throw new Error("lease not acquired"); + terminal.clearWriteLog(); + + expect((await tui.invalidateRasterLease({ token: lease.token, cause: "resize" })).status).toBe("written"); + const output = terminal.getWriteLog().join(""); + expect(output).toContain("\x1b[?2026h\x1b7\x1b[?25l\x1b[0m\x1b[2;7H\x1b[2X\x1b8"); + expect(output).toEndWith("\x1b[?25h\x1b[?2026l"); + }); + it("writes prefix, awaits callback, then writes records within one queued operation", async () => { + const { tui, terminal } = await setup(); + const lease = await tui.acquireRasterLease(request("prefix-order")); + if (lease.status !== "acquired") throw new Error("lease not acquired"); + const order: string[] = []; + (terminal as unknown as { flush: () => Promise }).flush = async () => { + order.push("flush"); + return true; + }; + const originalWrite = terminal.write.bind(terminal); + terminal.write = (data: string) => { + originalWrite(data); + order.push(data === "P" ? "prefix" : "records"); + }; + const ack = await tui.submitTerminalOutput({ + operation: { + type: "raster-multipart-batch", + prefix: bytes("P"), + afterPrefix: async () => { + order.push("afterPrefix"); + return true; + }, + records: [bytes("R")], + }, + token: lease.token, + }); + expect(ack.status).toBe("written"); + expect(order).toEqual(["prefix", "flush", "afterPrefix", "records"]); + expect(terminal.getWriteLog()).toEqual(["P", "R"]); + }); + it("replays only the post-barrier prefix and restores the cursor on success", async () => { + const { tui, terminal } = await setup(); + const lease = await tui.acquireRasterLease(request("barrier-cursor")); + if (lease.status !== "acquired") throw new Error("lease not acquired"); + terminal.clearWriteLog(); + const ack = await tui.submitTerminalOutput({ + operation: { + type: "raster-multipart-batch", + prefix: bytes("SAVE+PLACE"), + afterPrefix: async () => true, + replayPrefix: bytes("PLACE"), + records: [bytes("IMAGE")], + suffix: bytes("RESTORE"), + abortSuffix: bytes("RESTORE"), + }, + token: lease.token, + }); + expect(ack.status).toBe("written"); + expect(terminal.getWriteLog()).toEqual(["SAVE+PLACE", "PLACEIMAGERESTORE"]); + }); + it("restores the cursor when a multipart barrier aborts", async () => { + const { tui, terminal } = await setup(); + const lease = await tui.acquireRasterLease(request("barrier-abort")); + if (lease.status !== "acquired") throw new Error("lease not acquired"); + terminal.clearWriteLog(); + const ack = await tui.submitTerminalOutput({ + operation: { + type: "raster-multipart-batch", + prefix: bytes("SAVE+PLACE"), + afterPrefix: async () => false, + records: [bytes("IMAGE")], + abortSuffix: bytes("RESTORE"), + }, + token: lease.token, + }); + expect(ack.status).toBe("failed"); + expect(terminal.getWriteLog()).toEqual(["SAVE+PLACE", "RESTORE"]); + }); + it("does not write records when the prefix callback returns false or throws", async () => { + const { tui, terminal } = await setup(); + const lease = await tui.acquireRasterLease(request("prefix-failure")); + if (lease.status !== "acquired") throw new Error("lease not acquired"); + for (const afterPrefix of [ + async () => false, + async () => { + throw new Error("boom"); + }, + ]) { + terminal.clearWriteLog(); + const ack = await tui.submitTerminalOutput({ + operation: { type: "raster-multipart-batch", prefix: bytes("P"), afterPrefix, records: [bytes("R")] }, + token: lease.token, + }); + expect(ack.status).toBe("failed"); + expect(terminal.getWriteLog()).toEqual(["P"]); + } + }); + it("flush failure does not invoke callback or write records", async () => { + const { tui, terminal } = await setup(); + const lease = await tui.acquireRasterLease(request("prefix-flush-failure")); + if (lease.status !== "acquired") throw new Error("lease not acquired"); + let callbackCalled = false; + (terminal as unknown as { flush: () => Promise }).flush = async () => false; + const ack = await tui.submitTerminalOutput({ + operation: { + type: "raster-multipart-batch", + prefix: bytes("P"), + afterPrefix: async () => { + callbackCalled = true; + return true; + }, + records: [bytes("R")], + }, + token: lease.token, + }); + expect(ack.status).toBe("failed"); + expect(callbackCalled).toBe(false); + expect(terminal.getWriteLog()).toEqual(["P"]); + }); + it("queues multipart records normally", async () => { + const { tui, terminal } = await setup(); + const lease = await tui.acquireRasterLease(request("multipart-queue")); + if (lease.status !== "acquired") throw new Error("lease not acquired"); + terminal.clearWriteLog(); + const pending = tui.submitTerminalOutput({ + operation: { type: "raster-multipart-batch", records: [bytes("P"), bytes("R")] }, + token: lease.token, + }); + const interleaved = tui.queueTerminalOutput("I"); + expect(await pending).toMatchObject({ status: "written" }); + expect(await interleaved).toMatchObject({ status: "written" }); + expect(terminal.getWriteLog()).toEqual(["PR", "I"]); + }); + + it("invalidates on generic render with erase first and callback once", async () => { + const { tui, terminal } = await setup(); + let calls = 0; + const lease = await tui.acquireRasterLease(request("pet", rect(), "\x1b[1;1H\x1b[2K", () => calls++)); + expect(lease.status).toBe("acquired"); + const component: Component = { render: () => ["PAYLOAD"], invalidate() {} }; + tui.addChild(component); + terminal.clearWriteLog(); + tui.start(); + await terminal.waitForRender(); + const output = terminal.getWriteLog().join(""); + expect(output).toContain("\x1b[?2026h\x1b7\x1b[?25l\x1b[1;1H\x1b[2K\x1b8"); + expect(output.indexOf("PAYLOAD")).toBeGreaterThan(output.indexOf("\x1b[?2026l")); + expect(calls).toBe(1); + tui.stop(); + }); + it("clips a differential render around an active raster lease", async () => { + const { tui, terminal } = await setup(); + let line = "abcdefghij"; + let calls = 0; + const component: Component = { render: () => [line], invalidate() {} }; + tui.addChild(component); + tui.start(); + await terminal.waitForRender(); + + const lease = await tui.acquireRasterLease(request("pet", rect(8, 0, 2, 1), "ERASE", () => calls++)); + expect(lease.status).toBe("acquired"); + terminal.clearWriteLog(); + line = "ABCDEFGHIJ"; + tui.requestRender(); + await terminal.waitForRender(); + + const output = terminal.getWriteLog().join(""); + expect(output).not.toContain("ERASE"); + expect(output).not.toContain("\x1b[2K"); + expect(output).toContain("\x1b[1G\x1b[8X"); + expect(output).not.toContain("\r\n"); + expect(output).toContain("\x1b[?2026h\x1b[?25l"); + expect(output).toContain("\x1b[1;1H"); + expect(output).toContain("ABCDEFGH"); + expect(output).not.toContain("IJ"); + expect(calls).toBe(0); + tui.stop(); + }); + it("keeps a raster lease while streaming appends content inside the viewport", async () => { + const { tui, terminal } = await setup(); + let lines = ["first"]; + let calls = 0; + const component: Component = { render: () => lines, invalidate() {} }; + tui.addChild(component); + tui.start(); + await terminal.waitForRender(); + + const lease = await tui.acquireRasterLease(request("pet", rect(8, 3, 2, 1), "ERASE", () => calls++)); + expect(lease.status).toBe("acquired"); + terminal.clearWriteLog(); + lines = ["first", "second"]; + tui.requestRender(); + await terminal.waitForRender(); + + const output = terminal.getWriteLog().join(""); + expect(output).not.toContain("ERASE"); + expect(calls).toBe(0); + tui.stop(); + }); + it("repaints streaming overflow around the lease without erasing or re-uploading it", async () => { + const { tui, terminal } = await setup(); + let lines = ["one", "two", "three", "four"]; + let calls = 0; + const component: Component = { render: () => lines, invalidate() {} }; + tui.addChild(component); + tui.start(); + await terminal.waitForRender(); + + const lease = await tui.acquireRasterLease(request("pet", rect(8, 3, 2, 1), "ERASE", () => calls++)); + 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(output).toContain("\x1b[1G\x1b[8X"); + expect(output).toContain("\x1b[1;1H"); + expect(output).toContain("\x1b[4;1H"); + 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"]; + const component: Component = { render: () => lines, invalidate() {} }; + tui.addChild(component); + tui.start(); + await terminal.waitForRender(); + + const lease = await tui.acquireRasterLease(request("pet", rect(8, 3, 2, 1))); + expect(lease.status).toBe("acquired"); + terminal.clearWriteLog(); + lines = ["ONE", "two", "three", "four"]; + tui.requestRender(); + await terminal.waitForRender(); + + const output = terminal.getWriteLog().join(""); + expect(output).not.toContain("ERASE"); + expect(output).not.toContain("\r\n"); + expect(output).toContain("\x1b[1;1H"); + expect(output).toContain("\x1b[4;1H"); + tui.stop(); + }); + + it("rejects malformed and stale lifecycle notifications without touching pending cleanup", async () => { + const { tui, terminal } = await setup(); + const lease = await tui.acquireRasterLease(request("owner")); + if (lease.status !== "acquired") throw new Error("lease not acquired"); + failNextWrites(terminal); + await tui.invalidateRasterLease({ token: lease.token, cause: "explicit" }); + const generation = tui.terminalGeneration; + terminal.clearWriteLog(); + for (const event of [ + { kind: "bad", source: "tui", terminalGeneration: generation }, + { kind: "availability-restored", source: "bad", terminalGeneration: generation }, + { kind: "availability-restored", source: "tui", terminalGeneration: null }, + { kind: "availability-restored", source: "tui", terminalGeneration: -1 }, + { kind: "availability-restored", source: "tui", terminalGeneration: 1.5 }, + ] as unknown[]) { + await expect( + tui.notifyTerminalLifecycle(event as Parameters[0]), + ).rejects.toThrow(TypeError); + } + expect( + await tui.notifyTerminalLifecycle({ + kind: "availability-restored", + source: "tui", + terminalGeneration: generation, + }), + ).toEqual({ attempted: 1, written: 1, stillPending: 0 }); + expect(terminal.getWriteLog()).toHaveLength(1); + expect(terminal.getWriteLog()[0]).toContain("\x1b[?25lERASE\x1b8"); + }); + it("retries retained protocol-neutral cleanup after availability is restored", async () => { + const { tui, terminal } = await setup(); + tui.start(); + await terminal.waitForRender(); + terminal.clearWriteLog(); + + let delivered = 0; + failNextWrites(terminal); + tui.queueTerminalCleanup("PET_CLEANUP", () => delivered++); + await Promise.resolve(); + await Promise.resolve(); + + expect(delivered).toBe(0); + expect(tui.terminalAvailable).toBe(false); + failNextWrites(terminal); + const firstRetry = await tui.notifyTerminalLifecycle({ + kind: "availability-restored", + source: "tui", + terminalGeneration: tui.terminalGeneration, + }); + + expect(firstRetry).toEqual({ attempted: 0, written: 0, stillPending: 1 }); + expect(delivered).toBe(0); + const secondRetry = await tui.notifyTerminalLifecycle({ + kind: "availability-restored", + source: "tui", + terminalGeneration: tui.terminalGeneration, + }); + expect(secondRetry).toEqual({ attempted: 0, written: 0, stillPending: 0 }); + expect(delivered).toBe(1); + expect(terminal.getWriteLog()).toContain("PET_CLEANUP"); + }); + + it("rejects same-owner active and cleanup-pending conflicts", async () => { + const { tui, terminal } = await setup(); + tui.start(); + const first = await tui.acquireRasterLease(request("owner", rect(0, 0, 2, 1))); + if (first.status !== "acquired") throw new Error("lease not acquired"); + failNextWrites(terminal); + await tui.invalidateRasterLease({ token: first.token, cause: "explicit" }); + const pending = await tui.acquireRasterLease(request("owner", rect(5, 0, 2, 1))); + expect(pending.status).toBe("rejected"); + }); + + it("retains failed cleanup, blocks reacquire, then reports retry counts", async () => { + const { tui, terminal } = await setup(); + let calls = 0; + const lease = await tui.acquireRasterLease(request("owner", rect(), "ERASE", () => calls++)); + if (lease.status !== "acquired") throw new Error("lease not acquired"); + failNextWrites(terminal); + expect((await tui.invalidateRasterLease({ token: lease.token, cause: "explicit" })).status).toBe("failed"); + const unavailable = await tui.acquireRasterLease(request("new")); + expect(unavailable.status).toBe("rejected"); + if (unavailable.status !== "rejected") throw new Error("expected terminal unavailable rejection"); + expect(unavailable.reason).toBe("terminal-unavailable"); + tui.start(); + const firstGeneration = tui.terminalGeneration; + failNextWrites(terminal); + await Promise.resolve(); + expect( + await tui.notifyTerminalLifecycle({ + kind: "availability-restored", + source: "tui", + terminalGeneration: firstGeneration, + }), + ).toEqual({ attempted: 0, written: 0, stillPending: 0 }); + const unavailableAfterRetry = await tui.acquireRasterLease(request("new")); + expect(unavailableAfterRetry.status).toBe("rejected"); + if (unavailableAfterRetry.status !== "rejected") throw new Error("expected terminal unavailable rejection"); + expect(unavailableAfterRetry.reason).toBe("terminal-unavailable"); + const component: Component = { render: () => ["PAYLOAD"], invalidate() {} }; + tui.addChild(component); + terminal.clearWriteLog(); + tui.start(); + await Promise.resolve(); + tui.requestRender(true); + await terminal.waitForRender(); + expect( + await tui.notifyTerminalLifecycle({ + kind: "availability-restored", + source: "tui", + terminalGeneration: tui.terminalGeneration, + }), + ).toEqual({ attempted: 0, written: 0, stillPending: 0 }); + expect(calls).toBe(1); + expect((await tui.acquireRasterLease(request("new"))).status).toBe("acquired"); + await terminal.waitForRender(); + const output = terminal.getWriteLog().join(""); + expect(output.indexOf("ERASE")).toBeGreaterThanOrEqual(0); + expect(output.indexOf("PAYLOAD")).toBeGreaterThan(output.indexOf("ERASE")); + }); + + it("retries two cleanup records independently and releases only recovered dependent FIFO work", async () => { + const { tui, terminal } = await setup(); + const firstLease = await tui.acquireRasterLease(request("a", rect(0, 0, 2, 1), "A")); + const secondLease = await tui.acquireRasterLease(request("b", rect(3, 0, 2, 1), "B")); + if (firstLease.status !== "acquired" || secondLease.status !== "acquired") { + throw new Error("leases not acquired"); + } + failNextWrites(terminal); + await tui.invalidateRasterLease({ token: firstLease.token, cause: "explicit" }); + await tui.invalidateRasterLease({ token: secondLease.token, cause: "explicit" }); + const first = tui.submitTerminalOutput({ + operation: { type: "generic-render", bytes: bytes("a-dependent"), rect: rect(0, 0, 2, 1) }, + }); + const second = tui.submitTerminalOutput({ + operation: { type: "generic-render", bytes: bytes("b-dependent"), rect: rect(3, 0, 2, 1) }, + }); + expect((await first).status).toBe("failed"); + expect((await second).status).toBe("failed"); + terminal.clearWriteLog(); + const originalWrite = terminal.write.bind(terminal); + const writeSpy = spyOn(terminal, "write").mockImplementation(data => { + if (data.includes("\x1b[?25lB\x1b8")) throw new Error("injected terminal write failure"); + originalWrite(data); + }); + expect( + await tui.notifyTerminalLifecycle({ + kind: "availability-restored", + source: "tui", + terminalGeneration: tui.terminalGeneration, + }), + ).toEqual({ attempted: 2, written: 1, stillPending: 1 }); + expect(terminal.getWriteLog().filter(value => value === "a-dependent" || value === "b-dependent")).toEqual([ + "a-dependent", + ]); + expect(terminal.getWriteLog()).toContain("a-dependent"); + expect(terminal.getWriteLog()).not.toContain("b-dependent"); + writeSpy.mockRestore(); + }); + it("automatically recovers FIFO disjoint cleanup before stale explicit lifecycle calls", async () => { + const { tui, terminal } = await setup(); + const seen: string[] = []; + for (const owner of ["a", "b"]) { + const got = await tui.acquireRasterLease( + request(owner, owner === "a" ? rect(0, 0, 2, 1) : rect(3, 0, 2, 1), owner.toUpperCase(), () => + seen.push(owner), + ), + ); + if (got.status !== "acquired") throw new Error("lease not acquired"); + failNextWrites(terminal); + await tui.invalidateRasterLease({ token: got.token, cause: "explicit" }); + tui.start(); + await tui.notifyTerminalLifecycle({ + kind: "availability-restored", + source: "tui", + terminalGeneration: tui.terminalGeneration, + }); + } + expect(seen).toEqual(["a", "b"]); + expect( + terminal + .getWriteLog() + .filter(value => value.includes("A") || value.includes("B")) + .map(value => (value.includes("A") ? "A" : "B")), + ).toEqual(["A", "B"]); + expect( + await tui.notifyTerminalLifecycle({ + kind: "availability-restored", + source: "tui", + terminalGeneration: tui.terminalGeneration, + }), + ).toEqual({ attempted: 0, written: 0, stillPending: 0 }); + }); + it("erases raster leases before entering the manual history viewport", async () => { + const { tui, terminal } = await setup(); + const component: Component = { + render: () => Array.from({ length: 8 }, (_value, index) => `history-${index}`), + invalidate() {}, + }; + let invalidatedCause: string | undefined; + tui.addChild(component); + tui.start(); + await terminal.waitForRender(); + const lease = await tui.acquireRasterLease( + request("pet", rect(8, 2, 2, 2), "PET_ERASE", notice => { + invalidatedCause = notice.cause; + }), + ); + if (lease.status !== "acquired") throw new Error("lease not acquired"); + terminal.clearWriteLog(); + + expect(tui.scrollViewportPages(-1)).toBe(true); + await Promise.resolve(); + await Promise.resolve(); + await terminal.waitForRender(); + + const output = terminal.getWriteLog().join(""); + expect(output).toContain("PET_ERASE"); + expect(invalidatedCause).toBe("manual-viewport"); + const blocked = await tui.acquireRasterLease(request("while-reading", rect(5, 0, 2, 1))); + expect(blocked).toEqual({ status: "rejected", reason: "manual-viewport" }); + expect( + ( + await tui.submitTerminalOutput({ + token: lease.token, + operation: { type: "raster-erase", bytes: bytes("STALE_PET") }, + }) + ).status, + ).toBe("stale-token"); + + expect(tui.followLiveViewport()).toBe(true); + await terminal.waitForRender(); + const reacquired = await tui.acquireRasterLease(request("live-again", rect(5, 0, 2, 1))); + expect(reacquired.status).toBe("acquired"); + tui.stop(); + }); + it("erases raster leases before revealing a manual transcript anchor", async () => { + const { tui, terminal } = await setup(); + const transcript = new Container(); + for (let index = 0; index < 8; index++) { + const row = new Text(`history-${index}`, 0, 0); + transcript.addChild(row); + transcript.setViewportAnchorSource(row, { id: `history-${index}` }); + } + let invalidatedCause: string | undefined; + tui.addChild(transcript); + tui.setViewportAnchorComponent(transcript); + tui.start(); + await terminal.waitForRender(); + const lease = await tui.acquireRasterLease( + request("pet-anchor", rect(8, 2, 2, 2), "ANCHOR_PET_ERASE", notice => { + invalidatedCause = notice.cause; + }), + ); + if (lease.status !== "acquired") throw new Error("lease not acquired"); + terminal.clearWriteLog(); + + expect(tui.revealViewportAnchor("history-0", "top")).toBe(true); + await Promise.resolve(); + await Promise.resolve(); + await terminal.waitForRender(); + + expect(terminal.getWriteLog().join("")).toContain("ANCHOR_PET_ERASE"); + expect(invalidatedCause).toBe("manual-viewport"); + expect(tui.manualViewportActive).toBe(true); + expect( + await tui.acquireRasterLease(request("anchor-reading", rect(5, 0, 2, 1))).then(result => result.status), + ).toBe("rejected"); + expect(tui.followLiveViewport()).toBe(true); + await terminal.waitForRender(); + const reacquired = await tui.acquireRasterLease(request("anchor-live-again", rect(5, 0, 2, 1))); + expect(reacquired.status).toBe("acquired"); + tui.stop(); + }); + + it("never writes queued raster work after stop and resumes cleanly after start", async () => { + const { tui, terminal } = await setup(); + + // (a) A multipart body parks inside afterPrefix (blocking the raster + // ingress); a cleanup body queues behind it. stop() must complete without + // either writing after restoration. + const gateLease = await tui.acquireRasterLease(request("gate")); + if (gateLease.status !== "acquired") throw new Error("lease not acquired"); + let releaseIngress: () => void = () => {}; + const ingressGate = new Promise(resolve => { + releaseIngress = resolve; + }); + const blocker = tui.submitTerminalOutput({ + token: gateLease.token, + operation: { + type: "raster-multipart-batch", + prefix: bytes("GATE_PREFIX"), + afterPrefix: async () => { + await ingressGate; + return true; + }, + records: [bytes("GATE_GIF")], + }, + }); + const cleanup = tui.queueTerminalCleanup("PET_ERASE"); + await Bun.sleep(5); + expect(terminal.getWriteLog().join("")).toContain("GATE_PREFIX"); + terminal.clearWriteLog(); + tui.stop(); + // Snapshot immediately after stop: synchronous stop cleanup (lease erase) + // is legitimate and happens before terminal restoration. + const atStop = terminal.getWriteLog().join(""); + releaseIngress(); + const settled = await Promise.allSettled([blocker, cleanup]); + expect(settled.every(result => result.status === "fulfilled")).toBe(true); + if (settled[0].status === "fulfilled") expect(settled[0].value.status).toBe("failed"); + await Bun.sleep(10); + // Nothing further may be appended while the terminal stays stopped — no + // GIF bytes, no retained-cleanup delivery. + const whileStopped = terminal.getWriteLog().join(""); + expect(whileStopped).toBe(atStop); + expect(whileStopped).not.toContain("GATE_GIF"); + expect(whileStopped).not.toContain("PET_ERASE"); + terminal.clearWriteLog(); + tui.start(); + await Bun.sleep(10); + // The retained payload is delivered by the first start() after stop. + expect(terminal.getWriteLog().join("")).toContain("PET_ERASE"); + + // (b) Started body paused at an await when stop occurs: no suffix/abort/GIF + // bytes and no cursor restoration may follow stop. + const lease = await tui.acquireRasterLease(request("paused")); + if (lease.status !== "acquired") throw new Error("lease not acquired"); + terminal.clearWriteLog(); + let releaseAfterPrefix: () => void = () => {}; + const gate = new Promise(resolve => { + releaseAfterPrefix = resolve; + }); + const submit = tui.submitTerminalOutput({ + token: lease.token, + operation: { + type: "raster-multipart-batch", + prefix: bytes("PREFIX"), + afterPrefix: async () => { + await gate; + return true; + }, + records: [bytes("GIF")], + suffix: bytes("SUFFIX"), + abortSuffix: bytes("ABORT_BYTES"), + restoreCursorVisibility: true, + }, + }); + await Bun.sleep(5); + expect(terminal.getWriteLog().join("")).toContain("PREFIX"); + tui.stop(); + releaseAfterPrefix(); + const ack = await submit; + expect(ack.status).toBe("failed"); + const after = terminal.getWriteLog().join(""); + expect(after).not.toContain("SUFFIX"); + expect(after).not.toContain("ABORT_BYTES"); + expect(after).not.toContain("GIF"); + + // (c) A shouldWrite predicate that stops the terminal mid-operation must + // not emit abort or cursor-restoration bytes either. + tui.start(); + const lease2 = await tui.acquireRasterLease(request("predicate-stop")); + if (lease2.status !== "acquired") throw new Error("lease not acquired"); + terminal.clearWriteLog(); + const ack2 = await tui.submitTerminalOutput({ + token: lease2.token, + operation: { + type: "raster-multipart-batch", + prefix: bytes("P2"), + afterPrefix: async () => true, + records: [bytes("R2")], + abortSuffix: bytes("ABORT2"), + restoreCursorVisibility: true, + shouldWrite: () => { + tui.stop(); + throw new Error("predicate boom"); + }, + }, + }); + expect(ack2.status).toBe("failed"); + expect(terminal.getWriteLog().join("")).not.toContain("ABORT2"); + + // (d) New-lifecycle work after restart writes normally. + tui.start(); + terminal.clearWriteLog(); + const ack3 = await tui.queueTerminalOutput("POST_RESTART", { shouldWrite: () => true }); + expect(ack3.status).toBe("written"); + expect(terminal.getWriteLog()).toContain("POST_RESTART"); + tui.stop(); + }); +}); diff --git a/packages/tui/test/terminal-probe-reply-leak.test.ts b/packages/tui/test/terminal-probe-reply-leak.test.ts index 8b411ce040..d31cd597af 100644 --- a/packages/tui/test/terminal-probe-reply-leak.test.ts +++ b/packages/tui/test/terminal-probe-reply-leak.test.ts @@ -23,6 +23,7 @@ const DA1_REPLY = "\x1b[?62;22;52c"; const REPLY_PAIR = `${OSC11_REPLY}${DA1_REPLY}`; /** DA1 is forwarded by ProcessTerminal on purpose: `Tui` owns that reply. */ const DA1_PATTERN = /^\x1b\[\?[\d;]*c$/u; +const terminalWrites: string[] = []; function restoreProperty(target: object, key: string, descriptor: PropertyDescriptor | undefined): void { if (descriptor) { @@ -39,7 +40,10 @@ function mockTty(): void { vi.spyOn(process, "kill").mockReturnValue(true); vi.spyOn(process.stdin, "resume").mockImplementation(() => process.stdin); vi.spyOn(process.stdin, "pause").mockImplementation(() => process.stdin); - vi.spyOn(process.stdout, "write").mockImplementation(() => true); + vi.spyOn(process.stdout, "write").mockImplementation(chunk => { + terminalWrites.push(typeof chunk === "string" ? chunk : chunk.toString()); + return true; + }); } function restoreTty(): void { @@ -149,6 +153,7 @@ describe("Tui probe-reply leaks", () => { beforeEach(() => { mockTty(); + terminalWrites.length = 0; received = []; tui = new TUI(new ProcessTerminal()); tui.start(); @@ -172,6 +177,24 @@ describe("Tui probe-reply leaks", () => { received = []; } + it("drains probe input without disabling keyboard protocols or detaching input", async () => { + // Complete ProcessTerminal's Kitty query so the safe drain runs while the + // enhanced keyboard mode is active. A destructive drain would emit both + // protocol-disable sequences and drop the TUI input handler. + process.stdin.emit("data", Buffer.from("\x1b[?1u")); + terminalWrites.length = 0; + + await tui!.drainPetProbeInput(25, 5); + + const output = terminalWrites.join(""); + expect(output).not.toContain("\x1b[4;0m"); + + process.stdin.emit("data", Buffer.from("x")); + await sleep(20); + expect(received).toContain("x"); + }); + it("still hands the sixel probe its own DA1 reply", async () => { const seen: string[] = []; tui!.addInputListener(data => {