From 9fa2bf900b2215195467799d9d062dab96856b0f Mon Sep 17 00:00:00 2001 From: snowykr Date: Sun, 2 Aug 2026 00:30:00 +0900 Subject: [PATCH 01/11] feat(tui): add stable iTerm2 pet rendering Add lease-based OSC 1337 GIF rendering for iTerm2 while preserving Sixel, Kitty, tmux, viewport, and ordinary renderer behavior. --- .gitignore | 7 + packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/scripts/qa-iterm-pet.ts | 1102 +++++++++++++++++ packages/coding-agent/src/modes/DESIGN.md | 120 ++ .../src/modes/components/gajae-pet-widget.ts | 626 ++++++++-- .../modes/components/iterm-pet-transport.ts | 595 +++++++++ .../src/modes/components/pet-capability.ts | 33 +- .../src/modes/controllers/input-controller.ts | 15 +- .../src/modes/interactive-mode.ts | 87 +- .../test/gajae-pet-widget.test.ts | 1084 +++++++++++++++- .../test/input-controller-keybindings.test.ts | 15 + .../components/iterm-pet-transport.test.ts | 867 +++++++++++++ .../modes/components/pet-capability.test.ts | 34 + .../coding-agent/test/qa-iterm-pet.test.ts | 335 +++++ packages/tui/CHANGELOG.md | 1 + packages/tui/artifacts/g015-qa-report.json | 8 +- packages/tui/src/components/gajae-pet.ts | 320 ++++- packages/tui/src/terminal-capabilities.ts | 137 ++ packages/tui/src/terminal.ts | 25 +- packages/tui/src/tui.ts | 900 ++++++++++++-- .../test/bench/gajae-pet-iterm-cache.bench.ts | 140 +++ packages/tui/test/cell-size-response.test.ts | 25 + packages/tui/test/g003-qa-report.test.ts | 7 +- .../test/g011-batched-natives-redteam.test.ts | 7 +- .../g014-editor-layout-cache-redteam.test.ts | 7 +- .../tui/test/g015-debug-width-redteam.test.ts | 5 +- packages/tui/test/gajae-pet.test.ts | 273 +++- packages/tui/test/iterm2-protocol.test.ts | 133 ++ packages/tui/test/raster-lease.test.ts | 729 +++++++++++ packages/tui/test/render-commit.test.ts | 22 +- packages/tui/test/virtual-terminal.ts | 55 +- 31 files changed, 7425 insertions(+), 290 deletions(-) create mode 100644 packages/coding-agent/scripts/qa-iterm-pet.ts create mode 100644 packages/coding-agent/src/modes/components/iterm-pet-transport.ts create mode 100644 packages/coding-agent/test/modes/components/iterm-pet-transport.test.ts create mode 100644 packages/coding-agent/test/qa-iterm-pet.test.ts create mode 100644 packages/tui/test/bench/gajae-pet-iterm-cache.bench.ts create mode 100644 packages/tui/test/cell-size-response.test.ts create mode 100644 packages/tui/test/iterm2-protocol.test.ts create mode 100644 packages/tui/test/raster-lease.test.ts diff --git a/.gitignore b/.gitignore index 4deb6a4e40..abb6ad3eb1 100644 --- a/.gitignore +++ b/.gitignore @@ -86,3 +86,10 @@ packages/coding-agent/binaries/ # Python SDK build output python/gjc-sdk/build/ +/artifacts/g003-qa-report.json +/artifacts/g011-qa-report.json +/artifacts/g014-qa-report.json +/artifacts/g015-qa-report.json +/artifacts/ultragoal-g003-iterm-size-test-report.json +/artifacts/ultragoal-g003-quality-gate.json +/artifacts/ultragoal-g003-review-receipts.json diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 5746982228..e18ff0e2ab 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -12,6 +12,7 @@ - Notification settings now expose first-class Telegram, Discord, and Slack configuration, desired-intent toggles, provider-local quarantine and repair guidance, explicit `keep | replace | remove` secret actions, provider-specific health/test diagnostics, and truthful saved-but-runtime-degraded outcomes. The global master preserves provider credentials and intent, `GJC_NOTIFICATIONS=0` suppresses only automatic generic-session admission, and blocked Telegram ownership uses an isolated chat-only endpoint so verified Discord or Slack siblings can continue without exposing the shared endpoint. +- Added capability-gated iTerm2 Pet GIF rendering with managed tmux transport, manual-history suspension, and lifecycle-safe raster cleanup. ### Fixed - Windows automatic tmux resolution now selects `psmux` then `pmux` by canonical command order without rejecting distinct lower-priority aliases; it probes `tmux` only when neither named provider is available (#3725). diff --git a/packages/coding-agent/scripts/qa-iterm-pet.ts b/packages/coding-agent/scripts/qa-iterm-pet.ts new file mode 100644 index 0000000000..48a4232422 --- /dev/null +++ b/packages/coding-agent/scripts/qa-iterm-pet.ts @@ -0,0 +1,1102 @@ +#!/usr/bin/env bun +/** + * Declared-schema/integrity-only iTerm Pet QA bundle validator. + * + * This tool validates declarations and bytes. It does not establish that a + * bundle was produced by a live PTY; that classification is an external + * review concern. + */ +import * as crypto from "node:crypto"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { logger } from "@gajae-code/utils"; + +type Json = Record; +type SourceKind = "live-pty" | "replay" | "fixture"; +type Member = { path: string; sha256: string; size: number; kind?: string; caseId?: string }; +type Artifact = { + path: string; + sha256: string; + format?: string; + role?: string; + provenance?: string; + classification?: "current" | "regression"; + observedResult?: string; +}; +type Bundle = { + [key: string]: unknown; + caseId: string; + viewport: string; + scroll: string; + members: Member[]; + metadata: Json; +}; +type RasterEvidence = { artifactSha256: string; width: number; height: number }; +type OwnedRectangle = { x: number; y: number; width: number; height: number }; +type EraseEvidence = { before: RasterEvidence; after: RasterEvidence }; +type StateScenario = { + expected: RasterEvidence; + actual: RasterEvidence; + owned: OwnedRectangle; + erase: EraseEvidence; + telemetryMs: number; +}; +const requestedVersions = ["3.5.0", "3.6.11"]; +const requestedModes = ["direct", "tmux"]; +const REQUIRED_PRODUCER = "gjc-iterm-live-capture-v1"; +const EXPECTED_CJK: Record = { + "cjk-ko-composer-idle": ["저장하지 않은 변경 사항이 있습니다.", "Enter로 저장하거나", "Esc로 취소하세요."], + "cjk-ja-stream-working": ["未保存の変更があります。", "Enter で保存し、", "Esc でキャンセルします。"], + "cjk-zh-error-recovery": ["存在未保存的更改。", "按 Enter 保存,", "按 Esc 取消。"], + "cjk-mixed-preview-scroll": [ + "작업 상태: 준비 중입니다.", + "iTerm2 환경에서", + "출력 상태를 확인하세요.", + "Enter로 계속하고 Esc로 취소하세요.", + ], +}; +const PET_IDS = [ + "red-idle", + "red-working", + "red-burst", + "red-preview", + "blue-idle", + "blue-working", + "blue-burst", + "blue-preview", + "missing-f", + "invalid-f", + "probe-timeout", + "erase", +]; +const cjkRangeFor = (scroll: string): [number, number] | undefined => + scroll === "top" ? [1, 21] : scroll === "middle" ? [50, 70] : scroll === "bottom" ? [100, 120] : undefined; +const deterministicCjkBody = (segments: string[], range: [number, number]): string => + Array.from({ length: range[1] - range[0] + 1 }, (_, index) => { + const line = range[0] + index; + return `${String(line).padStart(3, "0")}: ${segments[index % segments.length]}`; + }).join("\n"); +const petAnchorCases = new Set(PET_IDS); +petAnchorCases.add("topology-ineligible"); +const composerAnchorCases = new Set(Object.keys(EXPECTED_CJK)); +function die(message: string): never { + logger.error("iTerm Pet QA failed", { error: message }); + process.exit(2); + throw Error(message); +} +const args = process.argv.slice(2); +const values = (name: string): string[] => { + const result: string[] = []; + for (let i = 0; i < args.length; i++) if (args[i] === `--${name}`) result.push(args[i + 1] ?? ""); + return result; +}; +const one = (name: string): string | undefined => values(name)[0]; +const inputName = one("input"); +const outputName = one("output"); +const versionArg = one("versions"); +const modeArg = one("modes"); +const versions = (versionArg ?? "").split(",").filter(Boolean); +const modes = (modeArg ?? "").split(",").filter(Boolean); +const expectedValues = values("expected-sha"); +const expectedSha = expectedValues.length === 1 ? expectedValues[0] : ""; +if ( + !outputName || + versions.join() !== requestedVersions.join() || + modes.join() !== requestedModes.join() || + expectedValues.length !== 1 || + !/^[a-f0-9]{40}$/.test(expectedSha) || + /^0{40}$/.test(expectedSha) +) + die( + "usage: --versions 3.5.0,3.6.11 --modes direct,tmux --expected-sha <40 lowercase hex> [--input ] --output ", + ); +const output = outputName ?? die("output is required"); +const inputNameValue = inputName ?? path.join(output, "manifest.json"); +const pathExists = async (path: string): Promise => { + try { + await fs.access(path); + return true; + } catch { + return false; + } +}; +const asJson = (value: unknown): Json | undefined => + typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Json) : undefined; +const hasString = (value: Json, key: K): value is Json & Record => + typeof value[key] === "string"; +const hasNumber = (value: Json, key: K): value is Json & Record => + typeof value[key] === "number" && Number.isFinite(value[key]); +const hasStringArray = (value: Json, key: K): value is Json & Record => + Array.isArray(value[key]) && value[key].every(item => typeof item === "string"); +const isRasterEvidence = (value: unknown): value is RasterEvidence => { + const item = asJson(value); + return ( + !!item && + hasString(item, "artifactSha256") && + /^[a-f0-9]{64}$/.test(item.artifactSha256) && + hasNumber(item, "width") && + Number.isInteger(item.width) && + item.width > 0 && + hasNumber(item, "height") && + Number.isInteger(item.height) && + item.height > 0 + ); +}; +const isOwnedRectangle = (value: unknown): value is OwnedRectangle => { + const item = asJson(value); + return ( + !!item && + hasNumber(item, "x") && + Number.isInteger(item.x) && + item.x >= 0 && + hasNumber(item, "y") && + Number.isInteger(item.y) && + item.y >= 0 && + hasNumber(item, "width") && + Number.isInteger(item.width) && + item.width > 0 && + hasNumber(item, "height") && + Number.isInteger(item.height) && + item.height > 0 + ); +}; +const asStateScenario = (value: unknown): StateScenario | undefined => { + const item = asJson(value); + if (!item) return undefined; + const expected = isRasterEvidence(item.expected) ? item.expected : undefined; + const actual = isRasterEvidence(item.actual) ? item.actual : undefined; + const owned = isOwnedRectangle(item.owned) ? item.owned : undefined; + const erase = asJson(item.erase); + const before = erase && isRasterEvidence(erase.before) ? erase.before : undefined; + const after = erase && isRasterEvidence(erase.after) ? erase.after : undefined; + return expected && actual && owned && before && after && hasNumber(item, "telemetryMs") + ? { expected, actual, owned, erase: { before, after }, telemetryMs: item.telemetryMs } + : undefined; +}; +const sha256 = (bytes: Uint8Array): string => crypto.createHash("sha256").update(bytes).digest("hex"); +const canonicalPath = async (candidate: string): Promise => { + const absolute = path.resolve(candidate); + let existing = absolute; + const suffix: string[] = []; + while (!(await pathExists(existing))) { + const parent = path.dirname(existing); + if (parent === existing) die(`path has no existing ancestor: ${candidate}`); + suffix.unshift(existing.slice(parent.length + 1)); + existing = parent; + } + return suffix.reduce((current, part) => path.join(current, part), await fs.realpath(existing)); +}; +const safePath = async (root: string, declared: string): Promise => { + if (!declared || path.isAbsolute(declared)) die(`member path is unsafe: ${declared}`); + const candidate = path.resolve(root, declared); + const rel = path.relative(root, candidate); + if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) die(`member path escapes root: ${declared}`); + if (!(await pathExists(candidate))) die(`member unavailable: ${declared}`); + const stat = await fs.lstat(candidate); + if (stat.isSymbolicLink() || !stat.isFile()) die(`member is not a regular file: ${declared}`); + const canonical = await fs.realpath(candidate); + const canonicalRel = path.relative(await fs.realpath(root), canonical); + if (!canonicalRel || canonicalRel.startsWith("..") || path.isAbsolute(canonicalRel)) + die(`member path escapes root: ${declared}`); + return canonical; +}; +const parseJsonFile = async (path: string, message: string): Promise => { + try { + const value: unknown = JSON.parse(await Bun.file(path).text()); + return asJson(value) ?? die(message); + } catch (error) { + if (error instanceof Error && error.message.startsWith("iTerm Pet QA failed:")) throw error; + die(message); + } +}; +const validSha = (value: unknown): value is string => + typeof value === "string" && /^[a-f0-9]{40}$/.test(value) && !/^0{40}$/.test(value); +const sourceKind = (value: unknown): SourceKind | undefined => + value === "fixture" || value === "replay" || value === "live-pty" ? value : undefined; +const declaredSource = (value: Json, label: string): SourceKind => { + const classification = sourceKind(value.classification); + const source = asJson(value.source); + if (!classification || !source || source.kind !== classification) + die(`${label}: classification/source declaration is invalid`); + return classification; +}; +const requireRevision = (value: Json, label: string): void => { + if ( + value.expectedSha !== expectedSha || + value.gitRevision !== expectedSha || + !validSha(value.expectedSha) || + !validSha(value.gitRevision) + ) + die(`${label}: expected SHA declaration is invalid`); +}; +const digestMember = async (root: string, value: unknown, label: string, caseId?: string): Promise => { + const object = asJson(value); + if ( + !object || + !hasString(object, "path") || + !hasString(object, "sha256") || + !/^[a-f0-9]{64}$/.test(object.sha256) || + !hasNumber(object, "size") || + !Number.isInteger(object.size) || + object.size < 0 + ) + die(`${label}: member declaration is invalid`); + const path = await safePath(root, object.path); + const bytes = await Bun.file(path).bytes(); + if (bytes.length !== object.size || sha256(bytes) !== object.sha256) die(`${label}: member digest/size mismatch`); + return { + path: object.path, + sha256: object.sha256, + size: object.size, + ...(typeof object.kind === "string" ? { kind: object.kind } : {}), + ...(caseId ? { caseId } : {}), + }; +}; +const visibleWidth = (text: string): number => { + let width = 0; + for (const char of text.normalize("NFC")) { + const code = char.codePointAt(0) ?? 0; + width += + code >= 0x1100 && + (code <= 0x115f || + code === 0x2329 || + code === 0x232a || + (code >= 0x2e80 && code <= 0xa4cf) || + (code >= 0xac00 && code <= 0xd7a3) || + (code >= 0xf900 && code <= 0xfaff) || + (code >= 0xfe10 && code <= 0xfe19) || + (code >= 0xfe30 && code <= 0xfe6f) || + (code >= 0xff00 && code <= 0xff60) || + (code >= 0xffe0 && code <= 0xffe6)) + ? 2 + : 1; + } + return width; +}; +const stripAnsi = (value: string): string => + value.replace( + /[\u001b\u009b][[\]()#;?]*(?:(?:(?:[a-zA-Z\d]*(?:;[-a-zA-Z\d/#&.:=?%@~_]+)*)?\u0007)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))/g, + "", + ); +const declaredMode = (value: Json, label: string): string | undefined => { + const mode = typeof value.mode === "string" ? value.mode : undefined; + const transport = typeof value.transport === "string" ? value.transport : undefined; + if (mode && transport && mode !== transport) die(`${label}: mode/transport declarations disagree`); + return mode ?? transport; +}; +const validateTerminalContent = async ( + rootDir: string, + textPath: string, + ansiPath: string, + bundle: Bundle, + metadata: Json, + label: string, +): Promise => { + const text = (await Bun.file(await safePath(rootDir, textPath)).text()).normalize("NFC"); + const ansi = await Bun.file(await safePath(rootDir, ansiPath)).text(); + if (stripAnsi(ansi).normalize("NFC") !== text) die(`${label}: ANSI/text content differs`); + if (petAnchorCases.has(bundle.caseId) && !text.includes(bundle.caseId)) die(`${label}: pet anchor is missing`); + if (composerAnchorCases.has(bundle.caseId) && (!text.includes("Enter") || !text.includes("Esc"))) + die(`${label}: composer anchor is missing`); + const segments = EXPECTED_CJK[bundle.caseId]; + if (!segments) return; + let cursor = 0; + for (const segment of segments) { + const position = text.indexOf(segment, cursor); + if (position < cursor) die(`${label}: semantic content is missing`); + cursor = position + segment.length; + } + if (bundle.caseId !== "cjk-mixed-preview-scroll") return; + const expectedRange = cjkRangeFor(bundle.scroll); + const range: unknown[] = Array.isArray(metadata.scrollRange) ? metadata.scrollRange : []; + const rangeNumbers = + range.length === 2 && + typeof range[0] === "number" && + Number.isInteger(range[0]) && + typeof range[1] === "number" && + Number.isInteger(range[1]) + ? ([range[0], range[1]] as [number, number]) + : undefined; + if ( + !hasNumber(metadata, "lineCount") || + !Number.isInteger(metadata.lineCount) || + metadata.lineCount !== 120 || + !rangeNumbers || + !expectedRange || + JSON.stringify(range) !== JSON.stringify(expectedRange) || + rangeNumbers[0] < 1 || + rangeNumbers[1] > metadata.lineCount || + rangeNumbers[0] > rangeNumbers[1] || + text !== deterministicCjkBody(segments, expectedRange) || + stripAnsi(ansi).normalize("NFC") !== deterministicCjkBody(segments, expectedRange) + ) + die(`${label}: deterministic CJK body/range is invalid`); +}; +const validateMetadata = ( + metadata: Json, + version: string, + mode: string, + bundle: Bundle, + classification: SourceKind, + label: string, +): void => { + if ( + metadata.schemaVersion !== 2 || + metadata.caseId !== bundle.caseId || + metadata.iTermVersion !== version || + declaredMode(metadata, label) !== mode || + metadata.viewport !== bundle.viewport || + metadata.scroll !== bundle.scroll || + !validSha(metadata.expectedSha) || + metadata.expectedSha !== expectedSha || + !validSha(metadata.gitRevision) || + metadata.gitRevision !== expectedSha || + sourceKind(metadata.classification) !== classification || + asJson(metadata.source)?.kind !== classification || + typeof metadata.producer !== "string" || + !String(metadata.producer).trim() || + typeof metadata.toolVersion !== "string" || + !String(metadata.toolVersion).trim() || + typeof metadata.capturedAt !== "string" || + !String(metadata.capturedAt).trim() || + typeof metadata.commandOrReplay !== "string" || + !String(metadata.commandOrReplay).trim() || + typeof metadata.fontFamily !== "string" || + !String(metadata.fontFamily).trim() || + !["fontSize", "zoom", "cellWidthPx", "cellHeightPx"].every( + key => hasNumber(metadata, key) && metadata[key] > 0, + ) || + typeof metadata.wrappingPolicy !== "string" || + typeof metadata.truncationPolicy !== "string" || + !hasStringArray(metadata, "linkedRasterIdentifiers") + ) + die(`${label}: metadata declaration is invalid`); + const segments = EXPECTED_CJK[bundle.caseId]; + if (segments) { + if ( + !Array.isArray(metadata.semanticSegments) || + JSON.stringify(metadata.semanticSegments) !== JSON.stringify(segments) || + segments.some(segment => visibleWidth(segment) > 40) + ) + die(`${label}: semantic CJK segments are invalid`); + if ( + bundle.caseId === "cjk-mixed-preview-scroll" && + (!hasNumber(metadata, "lineCount") || + !Number.isInteger(metadata.lineCount) || + metadata.lineCount !== 120 || + JSON.stringify(metadata.scrollRange) !== JSON.stringify(cjkRangeFor(bundle.scroll))) + ) + die(`${label}: deterministic scroll range is invalid`); + if (bundle.caseId !== "cjk-mixed-preview-scroll" && bundle.scroll !== "top") + die(`${label}: non-scroll CJK case has an invalid range`); + if (bundle.viewport === "40x12" && metadata.resizeFrom !== "80x24") die(`${label}: resize transition is missing`); + } +}; +const memberNames = ["terminal.txt", "terminal-ansi.txt", "terminal.html", "metadata.json"]; +const normalizeBundle = async ( + rootDir: string, + value: unknown, + version: string, + mode: string, + classification: SourceKind, +): Promise => { + const item = asJson(value); + if (!item || typeof item.caseId !== "string" || typeof item.viewport !== "string" || typeof item.scroll !== "string") + die(`${version}/${mode}: bundle declaration is invalid`); + const caseId = item.caseId; + requireRevision(item, `${version}/${mode}/${item.caseId}`); + if (declaredSource(item, `${version}/${mode}/${item.caseId}`) !== classification) + die(`${version}/${mode}/${item.caseId}: source differs from capture`); + if (!["80x24", "40x12"].includes(item.viewport) || !["top", "middle", "bottom"].includes(item.scroll)) + die(`${version}/${mode}: bundle viewport/scroll is invalid`); + const raw = Array.isArray(item.members) ? item.members : Array.isArray(item.files) ? item.files : undefined; + const membersRaw = raw ?? die(`${version}/${mode}/${item.caseId}: required evidence members are missing`); + if (membersRaw.length !== 4) die(`${version}/${mode}/${item.caseId}: required evidence members are missing`); + const members: Member[] = []; + for (let index = 0; index < membersRaw.length; index++) + members.push(await digestMember(rootDir, membersRaw[index], `${version}/${mode}/${caseId}/${index}`, caseId)); + const names = new Set(members.map(member => path.basename(member.path))); + if (names.size !== 4 || memberNames.some(name => !names.has(name))) + die(`${version}/${mode}/${item.caseId}: required evidence members are missing`); + const metadataMember = members.find(member => path.basename(member.path) === "metadata.json"); + if (!metadataMember) die(`${version}/${mode}/${caseId}: required metadata member is missing`); + const metadata = await parseJsonFile( + await safePath(rootDir, metadataMember.path), + `${version}/${mode}/${caseId}: metadata is invalid`, + ); + const bundle: Bundle = { + ...item, + caseId, + viewport: item.viewport, + scroll: item.scroll, + members, + metadata, + }; + const textMember = members.find(member => path.basename(member.path) === "terminal.txt"); + const ansiMember = members.find(member => path.basename(member.path) === "terminal-ansi.txt"); + if (!textMember || !ansiMember) die(`${version}/${mode}/${caseId}: required terminal members are missing`); + await validateTerminalContent( + rootDir, + textMember.path, + ansiMember.path, + bundle, + metadata, + `${version}/${mode}/${caseId}`, + ); + validateMetadata(metadata, version, mode, bundle, classification, `${version}/${mode}/${item.caseId}`); + if ( + !hasStringArray(metadata, "linkedRasterIdentifiers") || + metadata.linkedRasterIdentifiers.some(value => !/^[a-f0-9]{64}$/.test(value)) + ) + die(`${version}/${mode}/${item.caseId}: raster linkage is invalid`); + return bundle; +}; +const journalPathFor = (outputPath: string): string => `${outputPath}.transaction-journal`; +const writeJournal = async (path: string, value: Json): Promise => { + await Bun.write(path, `${JSON.stringify(value)}\n`); + const handle = await fs.open(path, "r"); + try { + await handle.sync(); + } finally { + await handle.close(); + } +}; +const parseRoot = async (path: string): Promise => parseJsonFile(path, "capture input is not valid JSON"); +const resolveEvidencePointer = (document: Json, reference: unknown): Json | undefined => { + if (typeof reference !== "string" || !reference.startsWith("evidence.json#/")) return undefined; + let pointer: string; + try { + pointer = decodeURIComponent(reference.slice("evidence.json#".length)); + } catch { + return undefined; + } + if (!pointer.startsWith("/")) return undefined; + let current: unknown = document; + for (const rawToken of pointer.slice(1).split("/")) { + if (/~(?!0|1)/.test(rawToken)) return undefined; + const token = rawToken.replace(/~1/g, "/").replace(/~0/g, "~"); + if (Array.isArray(current)) { + if (!/^(0|[1-9]\d*)$/.test(token)) return undefined; + current = current[Number(token)]; + } else { + const object = asJson(current); + if (!object || !(token in object)) return undefined; + current = object[token]; + } + } + return asJson(current); +}; +const expectedPublishedCaseIds = (mode: string): Set => + new Set([...PET_IDS, ...(mode === "tmux" ? ["topology-ineligible"] : []), ...Object.keys(EXPECTED_CJK)]); +const expectedPublishedBundleKeys = (mode: string): Set => { + const keys = [...PET_IDS, ...(mode === "tmux" ? ["topology-ineligible"] : [])].map(caseId => `${caseId}/80x24/top`); + for (const caseId of Object.keys(EXPECTED_CJK)) { + keys.push(`${caseId}/80x24/top`, `${caseId}/40x12/top`); + if (caseId === "cjk-mixed-preview-scroll") keys.push(`${caseId}/80x24/middle`, `${caseId}/80x24/bottom`); + } + return new Set(keys); +}; +const validatePublished = async (outputPath: string): Promise => { + if (!(await pathExists(outputPath)) || (await fs.readdir(outputPath)).sort().join() !== requestedVersions.join()) + die("published output hierarchy is invalid"); + for (const version of requestedVersions) + for (const mode of requestedModes) { + const dir = path.join(outputPath, version, mode); + if ( + !(await pathExists(dir)) || + (await fs.readdir(dir)).sort().join() !== "captures,evidence.json,manifest.json,rasters" + ) + die(`published hierarchy is invalid: ${version}/${mode}`); + const manifest = await parseRoot(path.join(dir, "manifest.json")); + const evidence = await parseRoot(path.join(dir, "evidence.json")); + if ( + manifest.schemaVersion !== 2 || + manifest.expectedSha !== expectedSha || + manifest.gitRevision !== expectedSha || + manifest.iTermVersion !== version || + manifest.mode !== mode + ) + die(`published manifest is invalid: ${version}/${mode}`); + const classification = declaredSource(manifest, `${version}/${mode}/manifest`); + if ( + manifest.producer !== REQUIRED_PRODUCER || + typeof manifest.provenance !== "string" || + !manifest.provenance.trim() || + typeof manifest.capturedAt !== "string" || + !manifest.capturedAt.trim() + ) + die(`published manifest is invalid: ${version}/${mode}`); + if ( + evidence.schemaVersion !== 2 || + evidence.expectedSha !== expectedSha || + evidence.gitRevision !== expectedSha || + sourceKind(evidence.classification) !== classification || + asJson(evidence.source)?.kind !== classification + ) + die(`published evidence is invalid: ${version}/${mode}`); + const files = Array.isArray(manifest.files) ? manifest.files : []; + const listed = new Set(); + for (const entry of files) { + const member = asJson(entry); + if ( + !member || + typeof member.path !== "string" || + listed.has(member.path) || + member.path === "manifest.json" || + member.path === "evidence.json" + ) + die(`published file table is invalid: ${version}/${mode}`); + listed.add(member.path); + const path = await safePath(dir, member.path); + const bytes = await Bun.file(path).bytes(); + if (member.size !== bytes.length || member.sha256 !== sha256(bytes)) + die(`published file digest is invalid: ${version}/${mode}`); + } + const actual: string[] = []; + const walk = async (base: string, prefix: string): Promise => { + for (const name of await fs.readdir(base)) { + const entryPath = path.join(base, name), + rel = prefix ? `${prefix}/${name}` : name; + if ((await fs.lstat(entryPath)).isDirectory()) await walk(entryPath, rel); + else actual.push(rel); + } + }; + await walk(path.join(dir, "captures"), "captures"); + await walk(path.join(dir, "rasters"), "rasters"); + if (actual.sort().join() !== [...listed].sort().join()) + die(`published file table is incomplete: ${version}/${mode}`); + const bundles = Array.isArray(manifest.bundles) ? manifest.bundles : []; + const seen = new Set(); + const expectedBundleKeys = expectedPublishedBundleKeys(mode); + if (bundles.length !== expectedBundleKeys.size) die(`published bundle matrix is invalid: ${version}/${mode}`); + for (const value of bundles) { + const b = asJson(value); + if (!b || typeof b.caseId !== "string" || typeof b.viewport !== "string" || typeof b.scroll !== "string") + die(`published bundle is invalid: ${version}/${mode}`); + if ( + b.expectedSha !== expectedSha || + b.gitRevision !== expectedSha || + sourceKind(b.classification) !== classification || + asJson(b.source)?.kind !== classification + ) + die(`published bundle declaration is invalid: ${version}/${mode}`); + const key = `${b.caseId}/${b.viewport}/${b.scroll}`; + if (seen.has(key)) die(`published bundle is duplicated: ${version}/${mode}`); + seen.add(key); + if (!expectedBundleKeys.has(key)) die(`published bundle matrix is invalid: ${version}/${mode}`); + const metadataPath = typeof b.metadataPath === "string" ? b.metadataPath : ""; + if (!listed.has(metadataPath)) die(`published metadata is unbound: ${version}/${mode}`); + const metadata = await parseJsonFile( + path.join(dir, metadataPath), + `published metadata is invalid: ${version}/${mode}`, + ); + const bundle: Bundle = { + caseId: b.caseId, + viewport: b.viewport, + scroll: b.scroll, + members: [], + metadata, + }; + validateMetadata(metadata, version, mode, bundle, classification, `${version}/${mode}/${key}`); + if (!hasStringArray(metadata, "linkedRasterIdentifiers")) + die(`published metadata is invalid: ${version}/${mode}`); + for (const hash of metadata.linkedRasterIdentifiers) + if (!listed.has(`rasters/${hash}.rgba`) && !listed.has(`rasters/${hash}.png`)) + die(`published raster linkage is unbound: ${version}/${mode}`); + let textPath = ""; + let ansiPath = ""; + for (const name of memberNames) { + const memberPath = + typeof b[`${name.replace(".", "_")}Path`] === "string" + ? String(b[`${name.replace(".", "_")}Path`]) + : `${path.dirname(metadataPath)}/${name}`; + if (memberPath !== `${path.dirname(metadataPath)}/${name}` || !listed.has(memberPath)) + die(`published bundle member is unbound: ${version}/${mode}`); + if (name === "terminal.txt") textPath = memberPath; + if (name === "terminal-ansi.txt") ansiPath = memberPath; + } + await validateTerminalContent(dir, textPath, ansiPath, bundle, metadata, `${version}/${mode}/${key}`); + } + if (seen.size !== expectedBundleKeys.size || [...expectedBundleKeys].some(key => !seen.has(key))) + die(`published bundle matrix is incomplete: ${version}/${mode}`); + const records = Array.isArray(evidence.records) ? evidence.records : []; + const cases = Array.isArray(manifest.cases) ? manifest.cases : []; + const expectedCaseIds = expectedPublishedCaseIds(mode); + const bundleCaseIds = new Set([...seen].map(key => key.split("/")[0])); + if ( + bundleCaseIds.size !== expectedCaseIds.size || + [...expectedCaseIds].some(caseId => !bundleCaseIds.has(caseId)) + ) + die(`published case-to-bundle relationship is invalid: ${version}/${mode}`); + const recordIds = records.map(value => asJson(value)?.caseId); + const caseIds = cases.map(value => asJson(value)?.caseId); + if ( + records.length !== expectedCaseIds.size || + cases.length !== expectedCaseIds.size || + recordIds.some(value => typeof value !== "string") || + caseIds.some(value => typeof value !== "string") || + new Set(recordIds).size !== expectedCaseIds.size || + new Set(caseIds).size !== expectedCaseIds.size || + [...expectedCaseIds].some(id => !recordIds.includes(id) || !caseIds.includes(id)) + ) + die(`published case evidence is invalid: ${version}/${mode}`); + for (const value of [...records, ...cases]) { + const record = asJson(value); + if ( + !record || + record.expectedSha !== expectedSha || + record.gitRevision !== expectedSha || + sourceKind(record.classification) !== classification || + asJson(record.source)?.kind !== classification + ) + die(`published case declaration is invalid: ${version}/${mode}`); + } + for (const value of cases) { + const record = asJson(value); + const linked = record && resolveEvidencePointer(evidence, record.evidence); + if (!record || !linked || linked.caseId !== record.caseId) + die(`published evidence pointer is invalid: ${version}/${mode}`); + } + const successCases = cases.filter(value => typeof asJson(value)?.actualCapture === "string"); + if ( + successCases.length !== 8 || + new Set(successCases.map(value => String(asJson(value)?.actualCapture))).size !== 8 || + successCases.some(value => !listed.has(String(asJson(value)?.actualCapture))) + ) + die(`published raster cases are invalid: ${version}/${mode}`); + validateMatrix(seen, version, mode); + } +}; +const validateMatrix = (seen: Set, version: string, mode: string): void => { + for (const id of Object.keys(EXPECTED_CJK)) { + for (const key of [`${id}/80x24/top`, `${id}/40x12/top`]) + if (!seen.has(key)) die(`CJK matrix is incomplete: ${version}/${mode}`); + if (id === "cjk-mixed-preview-scroll") + for (const scroll of ["middle", "bottom"]) + if (!seen.has(`${id}/80x24/${scroll}`)) die(`CJK matrix is incomplete: ${version}/${mode}`); + } +}; +const recoverPublication = async (outputPath: string): Promise => { + const journal = journalPathFor(outputPath); + if (!(await pathExists(journal))) return; + const journalStat = await fs.lstat(journal); + if (journalStat.isSymbolicLink() || !journalStat.isFile()) die("publication journal is invalid"); + const declaration = await parseRoot(journal); + const backup = typeof declaration.backup === "string" ? declaration.backup : ""; + const validSibling = async (value: string): Promise => { + if (!path.isAbsolute(value) || path.resolve(value) !== value || path.dirname(value) !== path.dirname(outputPath)) + return false; + if (value === outputPath || value === journal) return false; + const name = path.basename(value); + if (!name.startsWith(`${path.basename(outputPath)}.backup-`) || !(await pathExists(value))) return false; + const stat = await fs.lstat(value); + return ( + stat.isDirectory() && + !stat.isSymbolicLink() && + (await canonicalPath(path.dirname(value))) === (await canonicalPath(path.dirname(outputPath))) + ); + }; + if (declaration.output !== outputPath || (backup && !(await validSibling(backup)))) + die("publication journal is invalid"); + if (!(await pathExists(outputPath)) && backup) { + await validatePublished(backup); + await fs.rename(backup, outputPath); + } + if (await pathExists(outputPath)) { + await validatePublished(outputPath); + await fs.rm(journal, { force: true }); + } +}; +const compareRaster = async (scenario: StateScenario, artifacts: Artifact[], rootDir: string): Promise => { + const refs = new Map(artifacts.map(ref => [ref.sha256, ref])); + const raster = async (value: RasterEvidence): Promise<{ width: number; height: number; bytes: Buffer }> => { + const ref = refs.get(value.artifactSha256); + if (ref?.format !== "rgba8") throw Error("raster must reference rgba8 sidecar"); + const bytes = Buffer.from(await Bun.file(await safePath(rootDir, ref.path)).bytes()); + if (bytes.length !== value.width * value.height * 4 || sha256(bytes) !== value.artifactSha256) + throw Error("raster geometry or digest mismatch"); + return { width: value.width, height: value.height, bytes }; + }; + const ex = await raster(scenario.expected); + const ac = await raster(scenario.actual); + const before = await raster(scenario.erase.before); + const after = await raster(scenario.erase.after); + if ([ac, before, after].some(item => item.width !== ex.width || item.height !== ex.height)) + throw Error("exact geometry mismatch"); + const { x, y, width, height } = scenario.owned; + if (x + width > ex.width || y + height > ex.height) throw Error("owned rectangle is invalid"); + let shown = 0; + const colors = new Set(); + for (let row = y; row < y + height; row++) + for (let col = x; col < x + width; col++) { + const pixel = ac.bytes.subarray((row * ex.width + col) * 4, (row * ex.width + col + 1) * 4); + if (pixel[3] !== 0) { + shown++; + colors.add(pixel.toString("hex")); + } + } + if (shown < 2 || colors.size < 2) throw Error("actual owned raster lacks meaningful variation"); + let changed = 0; + for (let i = 0; i < ex.width * ex.height; i++) + if (!ex.bytes.subarray(i * 4, i * 4 + 4).equals(ac.bytes.subarray(i * 4, i * 4 + 4))) changed++; + if (changed > ex.width * ex.height * 0.005) throw Error("pixel mismatch exceeds 0.5%"); + if (!before.bytes.equals(ac.bytes) || !after.bytes.equals(ex.bytes)) throw Error("erase evidence is invalid"); + for (let i = 0; i < ex.width * ex.height; i++) { + const col = i % ex.width, + row = Math.floor(i / ex.width); + if ( + (col < x || col >= x + width || row < y || row >= y + height) && + !before.bytes.subarray(i * 4, i * 4 + 4).equals(after.bytes.subarray(i * 4, i * 4 + 4)) + ) + throw Error("exterior pixels changed after erase"); + } + if (scenario.telemetryMs < 0 || scenario.telemetryMs > 250) throw Error("semantic telemetry delta is invalid"); +}; +const validatePng = (bytes: Buffer, label: string): void => { + const signature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); + if (bytes.length < 8 || !bytes.subarray(0, 8).equals(signature)) die(`artifact is not a PNG: ${label}`); + let offset = 8, + hasIHDR = false, + hasIDAT = false, + hasIEND = false; + while (offset < bytes.length) { + if (offset + 12 > bytes.length) die(`artifact PNG framing invalid: ${label}`); + const length = bytes.readUInt32BE(offset), + type = bytes.toString("ascii", offset + 4, offset + 8), + end = offset + 12 + length; + if (end > bytes.length) die(`artifact PNG framing invalid: ${label}`); + const crcOffset = offset + 8 + length; + let crc = 0xffffffff; + for (let i = offset + 4; i < crcOffset; i++) { + crc ^= bytes[i]; + for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0); + } + if ((crc ^ 0xffffffff) >>> 0 !== bytes.readUInt32BE(crcOffset)) die(`artifact PNG CRC invalid: ${label}`); + if (type === "IHDR") { + if ( + hasIHDR || + length !== 13 || + offset !== 8 || + !bytes.readUInt32BE(offset + 8) || + !bytes.readUInt32BE(offset + 12) + ) + die(`artifact PNG IHDR invalid: ${label}`); + hasIHDR = true; + } else if (type === "IDAT") hasIDAT = true; + else if (type === "IEND") { + if (length !== 0 || !hasIHDR || !hasIDAT || end !== bytes.length) die(`artifact PNG IEND invalid: ${label}`); + hasIEND = true; + } + offset = end; + } + if (!hasIHDR || !hasIDAT || !hasIEND) die(`artifact PNG chunks incomplete: ${label}`); +}; +const validateArtifacts = async (capture: Json, rootDir: string): Promise => { + const raw = Array.isArray(capture.artifacts) ? capture.artifacts : []; + const result: Artifact[] = []; + const seen = new Set(); + for (const value of raw) { + const item = asJson(value); + if ( + !item || + typeof item.path !== "string" || + typeof item.sha256 !== "string" || + !/^[a-f0-9]{64}$/.test(item.sha256) || + seen.has(item.path) + ) + die("artifact declaration is invalid"); + seen.add(item.path); + const path = await safePath(rootDir, item.path); + const bytes = Buffer.from(await Bun.file(path).bytes()); + if (sha256(bytes) !== item.sha256) die(`artifact digest mismatch: ${item.path}`); + if (item.format !== "rgba8") { + if (!item.role || !item.provenance || !item.classification || !item.observedResult) + die(`PNG artifact metadata is invalid: ${item.path}`); + validatePng(bytes, item.path); + } + result.push({ + path: item.path, + sha256: item.sha256, + ...(typeof item.format === "string" ? { format: item.format } : {}), + ...(typeof item.role === "string" ? { role: item.role } : {}), + ...(typeof item.provenance === "string" ? { provenance: item.provenance } : {}), + ...(item.classification === "current" || item.classification === "regression" + ? { classification: item.classification } + : {}), + ...(typeof item.observedResult === "string" ? { observedResult: item.observedResult } : {}), + }); + } + return result; +}; +async function main(): Promise { + const rootInput = path.resolve(inputNameValue); + const inputFile = + (await pathExists(rootInput)) && !rootInput.endsWith(".json") ? path.join(rootInput, "manifest.json") : rootInput; + if (!inputName && !(await pathExists(inputFile))) { + await recoverPublication(path.resolve(output)); + await validatePublished(path.resolve(output)); + process.exit(0); + } + if (!(await pathExists(inputFile))) die(`required capture input unavailable: ${inputFile}`); + const captureRoot = inputFile.endsWith(".json") ? path.dirname(inputFile) : inputFile; + const canonicalCaptureRoot = await canonicalPath(captureRoot); + const canonicalOutput = await canonicalPath(output); + const relativeOutput = path.relative(canonicalCaptureRoot, canonicalOutput); + const relativeCapture = path.relative(canonicalOutput, canonicalCaptureRoot); + if ( + inputName && + (!relativeOutput || + (!relativeOutput.startsWith("..") && !path.isAbsolute(relativeOutput)) || + !relativeCapture.startsWith("..")) + ) + die("input and output paths overlap"); + const root = await parseRoot(inputFile); + if ( + root.schemaVersion !== 2 || + !validSha(root.expectedSha) || + root.expectedSha !== expectedSha || + !validSha(root.gitRevision) || + root.gitRevision !== expectedSha + ) + die("input expected SHA declaration is invalid"); + const classification = declaredSource(root, "input"); + if ( + root.producer !== REQUIRED_PRODUCER || + typeof root.provenance !== "string" || + !root.provenance.trim() || + typeof root.capturedAt !== "string" || + !root.capturedAt.trim() + ) + die("input producer/provenance declaration is invalid"); + const captures = Array.isArray(root.captures) ? root.captures : []; + if (captures.length !== 4) die("input must declare four captures"); + const evidence: Array<{ version: string; mode: string; capture: Json; bundles: Bundle[]; artifacts: Artifact[] }> = + []; + for (const version of requestedVersions) + for (const mode of requestedModes) { + const capture = captures.map(asJson).find(item => { + if (!item || item.version !== version) return false; + return declaredMode(item, `${version}/${mode}/capture`) === mode; + }); + if (!capture) die(`missing capture for ${version}/${mode}`); + requireRevision(capture, `${version}/${mode}`); + if (declaredSource(capture, `${version}/${mode}`) !== classification) + die(`${version}/${mode}: source differs from root`); + const rawBundles = Array.isArray(capture.bundles) ? capture.bundles : []; + const bundles: Bundle[] = []; + for (const value of rawBundles) + bundles.push(await normalizeBundle(captureRoot, value, version, mode, classification)); + if (!bundles.length) die(`${version}/${mode}: bundles are missing`); + const bundleKeys = new Set(); + const bundlePaths = new Set(); + for (const bundle of bundles) { + const key = `${bundle.caseId}/${bundle.viewport}/${bundle.scroll}`; + if (bundleKeys.has(key)) die(`${version}/${mode}: duplicate bundle declaration`); + bundleKeys.add(key); + for (const member of bundle.members) { + if (bundlePaths.has(member.path)) die(`${version}/${mode}: overlapping bundle member`); + bundlePaths.add(member.path); + } + if ( + (EXPECTED_CJK[bundle.caseId] && + bundle.caseId !== "cjk-mixed-preview-scroll" && + (bundle.scroll !== "top" || !["80x24", "40x12"].includes(bundle.viewport))) || + (bundle.caseId === "cjk-mixed-preview-scroll" && + (bundle.scroll !== "top" + ? bundle.viewport !== "80x24" + : !["80x24", "40x12"].includes(bundle.viewport))) + ) + die(`${version}/${mode}: CJK viewport/scroll matrix is invalid`); + } + const requiredPetIds = [...PET_IDS, ...(mode === "tmux" ? ["topology-ineligible"] : [])]; + for (const id of requiredPetIds) + if (!bundles.some(bundle => bundle.caseId === id && bundle.viewport === "80x24" && bundle.scroll === "top")) + die(`${version}/${mode}: pet matrix is incomplete`); + const artifacts = await validateArtifacts(capture, captureRoot); + const artifactHashes = new Set(artifacts.map(artifact => artifact.sha256)); + for (const bundle of bundles) { + if (!hasStringArray(bundle.metadata, "linkedRasterIdentifiers")) + die(`${version}/${mode}/${bundle.caseId}: raster linkage is invalid`); + for (const hash of bundle.metadata.linkedRasterIdentifiers) + if (!artifactHashes.has(hash)) die(`${version}/${mode}/${bundle.caseId}: raster linkage is unbound`); + } + const cjkSeen = new Set( + bundles + .filter(bundle => EXPECTED_CJK[bundle.caseId]) + .map(bundle => `${bundle.caseId}/${bundle.viewport}/${bundle.scroll}`), + ); + validateMatrix(cjkSeen, version, mode); + const stateRoot = asJson(capture.states); + if (!stateRoot) die(`${version}/${mode}: pet states are missing`); + for (const skin of ["red", "blue"]) + for (const state of ["idle", "working", "burst", "preview"]) { + const scenario = asStateScenario(asJson(asJson(stateRoot[skin])?.[state])); + if (!scenario) die(`${version}/${mode}/${skin}-${state}: state evidence is missing`); + try { + await compareRaster(scenario, artifacts, captureRoot); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + die(`${version}/${mode}/${skin}-${state}: ${message}`); + } + } + evidence.push({ version, mode, capture, bundles, artifacts }); + } + const outputPath = path.resolve(output); + await recoverPublication(outputPath); + const stage = await fs.mkdtemp(`${outputPath}.staging-`); + try { + for (const item of evidence) { + const out = path.join(stage, item.version, item.mode); + await fs.mkdir(path.join(out, "captures"), { recursive: true }); + await fs.mkdir(path.join(out, "rasters"), { recursive: true }); + const files: Json[] = [], + manifestBundles: Json[] = []; + const addFile = async (source: string, target: string, kind: string, caseId?: string): Promise => { + const bytes = await Bun.file(source).bytes(); + await Bun.write(path.join(out, target), bytes); + files.push({ + path: target, + sha256: sha256(bytes), + size: bytes.length, + kind, + ...(caseId ? { caseId } : {}), + }); + }; + const copied = new Map(); + for (const ref of item.artifacts) { + const target = `rasters/${ref.sha256}.${ref.format === "rgba8" ? "rgba" : "png"}`; + if (!copied.has(target)) { + const bytes = await Bun.file(await safePath(captureRoot, ref.path)).bytes(); + await Bun.write(path.join(out, target), bytes); + files.push({ + path: target, + sha256: ref.sha256, + size: bytes.length, + kind: ref.format === "rgba8" ? "rgba8" : "png", + }); + copied.set(target, target); + } + } + for (const bundle of item.bundles) { + const base = `captures/${bundle.caseId}/${bundle.viewport}/${bundle.scroll}`; + await fs.mkdir(path.join(out, base), { recursive: true }); + const paths: Record = {}; + for (const member of bundle.members) { + const name = path.basename(member.path); + const target = `${base}/${name}`; + await addFile( + await safePath(captureRoot, member.path), + target, + name === "metadata.json" + ? "metadata" + : name === "terminal-ansi.txt" + ? "ansi" + : name === "terminal.html" + ? "html" + : "text", + bundle.caseId, + ); + paths[name] = target; + } + manifestBundles.push({ + caseId: bundle.caseId, + viewport: bundle.viewport, + scroll: bundle.scroll, + expectedSha, + gitRevision: expectedSha, + classification, + source: { kind: classification }, + metadataPath: paths["metadata.json"], + terminal_txtPath: paths["terminal.txt"], + terminal_ansi_txtPath: paths["terminal-ansi.txt"], + terminal_htmlPath: paths["terminal.html"], + }); + } + const capture = item.capture; + const records = [ + ...new Set([ + ...PET_IDS, + ...(item.mode === "tmux" ? ["topology-ineligible"] : []), + ...item.bundles.map(bundle => bundle.caseId), + ]), + ].map(caseId => ({ + caseId, + expectedSha, + gitRevision: expectedSha, + classification, + source: { kind: classification }, + status: "declared", + provenance: root.provenance, + capturedAt: root.capturedAt, + })); + const states = asJson(capture.states); + const cases = records.map((record, index) => { + const stateName = record.caseId.startsWith("red-") + ? record.caseId.slice("red-".length) + : record.caseId.startsWith("blue-") + ? record.caseId.slice("blue-".length) + : ""; + const skin = record.caseId.startsWith("red-") ? "red" : record.caseId.startsWith("blue-") ? "blue" : ""; + const scenario = skin && states ? asJson(asJson(states[skin])?.[stateName]) : undefined; + const actual = asJson(scenario?.actual)?.artifactSha256; + const expected = asJson(scenario?.expected)?.artifactSha256; + return { + ...record, + ...(typeof actual === "string" + ? { actualCapture: `rasters/${actual}.rgba`, expectedLogicalRaster: `rasters/${expected}.rgba` } + : {}), + evidence: `evidence.json#/records/${index}`, + }; + }); + const evidenceJson = { + schemaVersion: 2, + expectedSha, + gitRevision: expectedSha, + classification, + source: { kind: classification }, + records, + }; + await Bun.write(path.join(out, "evidence.json"), `${JSON.stringify(evidenceJson, null, 2)}\n`); + const manifest = { + schemaVersion: 2, + expectedSha, + gitRevision: expectedSha, + version: item.version, + iTermVersion: item.version, + mode: item.mode, + classification, + source: { kind: classification }, + producer: REQUIRED_PRODUCER, + provenance: root.provenance, + capturedAt: root.capturedAt, + cases, + bundles: manifestBundles, + files, + }; + await Bun.write(path.join(out, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`); + } + await validatePublished(stage); + } catch (error) { + await fs.rm(stage, { recursive: true, force: true }); + throw error; + } + const backup = `${outputPath}.backup-${process.pid}-${Date.now()}`; + const journalPath = journalPathFor(outputPath); + await writeJournal(journalPath, { output: outputPath, backup }); + let moved = false; + try { + if (await pathExists(outputPath)) { + await fs.rename(outputPath, backup); + moved = true; + } + await fs.rename(stage, outputPath); + if (moved) await fs.rm(backup, { recursive: true, force: true }); + await fs.rm(journalPath, { force: true }); + } catch (error) { + if (await pathExists(outputPath)) await fs.rm(outputPath, { recursive: true, force: true }); + if (moved && (await pathExists(backup))) await fs.rename(backup, outputPath); + if (await pathExists(stage)) await fs.rm(stage, { recursive: true, force: true }); + die(`unable to publish evidence: ${error instanceof Error ? error.message : String(error)}`); + } +} +await main(); diff --git a/packages/coding-agent/src/modes/DESIGN.md b/packages/coding-agent/src/modes/DESIGN.md index b945c56b18..b273c02580 100644 --- a/packages/coding-agent/src/modes/DESIGN.md +++ b/packages/coding-agent/src/modes/DESIGN.md @@ -16,6 +16,126 @@ Source material: - `../theme/theme.ts` and `../shared.ts` - `packages/tui/src/components/tab-bar.ts`, `settings-list.ts`, `select-list.ts`, and `input.ts` +- `components/gajae-pet-widget.ts`, `components/iterm-pet-transport.ts`, and + `components/pet-capability.ts` +- `packages/tui/src/components/gajae-pet.ts` and the terminal capability + transport APIs +- `test/gajae-pet-widget.test.ts` for focused placement and cleanup invariants + +## Corrective iTerm Pet guidance + +This section is **corrective first-party design documentation** for the selected +workflow branch. It records the contract that the iTerm Pet surface must satisfy; +it is not evidence that this guidance preceded implementation, and it is not a +live-terminal, independent-review, or capture-provenance claim. + +The Pet is a 16×16 real-pixel sprite presented in a two-terminal-row footprint +beside the composer. Its component anatomy is: + +1. `PetFramedEditor` wraps the existing composer and reserves only the Pet's + measured cell width plus a one-cell right inset. It narrows the editor; it + does not add a floor row or replace the editor's input behavior. +2. `GajaePetWidget` owns the mode, skin/frame timeline, cell-metric rebuild, + composer-bottom calculation, and overlay lifecycle. +3. The post-render overlay emitter re-applies the absolute-positioned raster + after ordinary TUI writes, while animation frame swaps go through the TUI + output queue. The overlay must not move the hardware/IME cursor. +4. A raster lease owns the iTerm placement rectangle. Disable, disposal, + capability loss, topology loss, and resize invalidate that lease before a + replacement can be submitted. Cleanup authority is retained until erase or + image deletion is actually delivered. + +### iTerm transport states and failure copy + +Direct transport is allowed only for a TTY identified as iTerm.app 3.5 or +newer. It drains input, sends the iTerm `Capabilities` query, and accepts the +Pet only after a complete `f` capability reply. Managed transport is a separate +state: all managed-session, pane, and owner-run identifiers must be present; +the single-client topology and expected pane/client must match; tmux +`allow-passthrough` is enabled from a saved value and restored during cleanup. +Managed records are wrapped for tmux and refresh the managed cursor before +submission. A tmux/screen/zellij context that is not an eligible managed +session must not silently use direct transport. + +Availability is explicit and recoverable rather than inferred from an ANSI +string. Pending probe, available direct, available managed, revoked, and +disposed/cleaned-up are distinct lifecycle states. User-facing status names a +safe next action and never exposes credentials. Capability errors include +`not-iterm2`, `tty-unavailable`, `missing-f`, `invalid-f`, and `probe-timeout`. +Topology errors include `topology-ineligible`, `topology-lost`, and +`zero-client-recovery`; managed-option restoration failure is +`cleanup-failed`. A failed probe or topology check suppresses raster submission, +invalidates the owned lease, and leaves the saved Pet choice understandable as +unavailable. Cleanup failure remains visible to the lifecycle owner and must +not strand a stale image or leave managed passthrough enabled. + +### Placement, composer, and responsive behavior + +The Pet occupies exactly two terminal rows, uses terminal-cell measurements, +and remains one cell inset from the right edge. The editor renders at +`terminalColumns - (petColumns + 1)` only when it has more than the reserved +area plus its minimum usable width; otherwise the normal full-width editor is +used and no raster is submitted. The composer remains pinned to the bottom. +Placement uses the composer bottom offset (including content below it), then +clamps the two-row image to retain one safety row above the terminal scrolling +edge. This one-row lift is an intentional iTerm safety trade-off: it prevents an +inline-image cursor advance from scrolling the viewport. Unlike Kitty/Sixel, +iTerm has no sub-cell placement or cursor-advance suppression; do not “fix” the +trade-off by adding a permanent floor row or allowing placement to reach the +unsafe last row. + +On a narrow transition, stop submitting new raster frames, erase the previous +Sixel footprint or delete the Kitty placement, and retain cleanup authority +until delivery is acknowledged. On resize or font/zoom cell-metric change, +invalidate the old lease, rebuild the two-row presentation and reserve, and +resume only after the new rectangle fits. Widening the terminal may place the +Pet again; it must not reuse stale coordinates or image ownership. + +### Pet state and motion + +`off` removes the frame, reserve, emitter authority, and image/lease state. +Active idle uses a discrete base/gaze-left/base/gaze-right/flicker loop. +Working uses the shared para-para loop. A skin burst is a finite flex/show-off +sequence; selector preview may schedule its deterministic introductory +eye-roll and signature burst, while live automatic bursts remain time-spaced. +Every state remains legible when animation is paused: mode, availability, and +composer controls are textual/structural, not conveyed by motion alone. + +### Pet visual-QA matrix + +The Pet showcase must exercise the full terminal surface, not just a raster +payload: direct and managed/tmux transport; RedGajae and BlueGajae in idle, +working, burst, and selector-preview states; capability/probe failure, +topology rejection/loss, cleanup failure, and unavailable/disabled states; and +normal, narrow, resize, composer-bottom, scroll, and mixed-script layouts. +The matrix must include the canonical wide terminal sizes and a deliberately +narrow case (including `80x24` and `40x12` where the surrounding harness uses +those sizes), plus normal-to-narrow and narrow-to-wide transitions. + +Each required capture is full-surface evidence with `terminal.txt`, +`terminal-ansi.txt`, `terminal.html`, and `metadata.json`. The text must remain +readable, ANSI/control semantics must be preserved for replay, and metadata must +describe the source, terminal size, font/render assumptions, timestamp, tool +version, and wrapping policy. +The matrix is a requirement for future capture/review, not a claim that any +capture, live origin, or independent review exists in this corrective change. + +### Evidence, provenance, and localized text + +ANSI output, terminal-cell rectangles, protocol records, and local validator +results are implementation artifacts. They can establish deterministic +payload/placement invariants, but they cannot establish that a live iTerm +terminal rendered the Pet, that the source was iTerm rather than a replay or +stub, or that an independent reviewer inspected a capture. Live capture, +terminal-origin metadata, and independent review must remain separately +identified evidence; never relabel ANSI evidence as any of those. + +The Pet reserve is measured with ANSI-aware terminal-cell width helpers and +must not change composer wrapping semantics. Mixed CJK/Latin text wraps at +semantic phrase or action boundaries, never through an action label, status +name, masked-secret marker, or short code/config identifier. Narrow CJK +fixtures must prove both cell alignment and semantic wrapping; a visually +aligned but semantically split line fails this contract. ## Existing visual grammar 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 86a4f212c5..30eff97efc 100644 --- a/packages/coding-agent/src/modes/components/gajae-pet-widget.ts +++ b/packages/coding-agent/src/modes/components/gajae-pet-widget.ts @@ -1,22 +1,29 @@ import { type AnimationRegistration, buildGajaePixelFrames, + burstTimeline, + type CellRect, type Component, type Container, type GajaePixelFrameName, type GajaePixelFrames, getCellDimensions, + getGajaePetGifCached, + idleTimeline, PARA_PARA_STEPS, PET_SKINS, type PetMode, type PetSkinId, petBurstDurationMs, petBurstFrame, + type RasterLeaseToken, registerAnimationCallback, type TUI, + workingTimeline, + 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 }; @@ -37,6 +44,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 { @@ -59,16 +68,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(); /** Working animation: the shared para-para beats looped end to end. */ const WORK_LOOP_TOTAL = PARA_PARA_STEPS.reduce((sum, [, ms]) => sum + ms, 0); -/** 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; // Deterministic idle loop: gaze around with a rare visor flicker. @@ -125,14 +140,15 @@ export class PetFramedEditor implements Component { /** * The gajae pet: a 16x16 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. + * by an empty one-line composer and placed across the composer's top, input, + * and bottom rows for the iTerm three-cell canvas. * * 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. + * - frame advances queue the payload through the TUI because a frame swap + * changes no component line. + * * * Requires a sixel- or kitty-graphics terminal (`pixelProtocol()`). */ @@ -150,25 +166,38 @@ export class GajaePetWidget { #animation: AnimationRegistration | undefined; #flexUntil = 0; #nextAutoFlexAt = 0; + /** Why the current burst is active; worker bursts must end with worker state. */ + #flexSource: "preview" | "working" | undefined; + /** Selector preview schedules one explicit burst; ordinary idle never does. */ + #previewFlexAt = 0; #autoFlexGapMs: [number, number] | null; - #forcedProtocol: "sixel" | "kitty" | undefined; + #forcedProtocol: "sixel" | "kitty" | "iterm" | undefined; /** Cell metrics the current frames were built for; a change triggers a rebuild. */ #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. */ + /** Monotonic generation prevents an earlier same-ID delete from clearing a newer pending delete. */ + #kittyCleanupGeneration = 0; + /** Last successfully delivered Sixel raster position. */ #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; + /** Shared-emitter epoch from the last time this widget owned its TUI. */ + #ownedOverlayEpoch = 0; + #itermLease: RasterLeaseToken | undefined; + #disposePromise: Promise | undefined; + /** Raster invalidation must settle before disposeAsync starts lifecycle recovery. */ + #disposeRasterBarrier: Promise = Promise.resolve(); + /** Snapshot from the first disposal; stale predecessors must not trigger lifecycle recovery. */ + #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; @@ -178,6 +207,7 @@ export class GajaePetWidget { isWorking: () => boolean; /** Rows rendered below the composer box (pet floor + hook widgets). */ getComposerBottomOffset: () => number; + syncManagedItermCursor: (row: number, column: number) => Promise; forcePixelProtocol?: "sixel" | "kitty"; /** Random [min, max] ms between auto-flexes; null disables. */ autoFlexGapMs?: [number, number] | null; @@ -189,13 +219,14 @@ 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" | null { + static pixelProtocol(): "sixel" | "kitty" | "iterm" | null { return getPetPixelProtocol(); } @@ -211,15 +242,29 @@ export class GajaePetWidget { this.#applyMode(mode, true); } + /** + * Suspend iTerm rendering after capability loss without changing the saved/user mode. + * A later verified availability emission can resume rendering in the same mode. + */ + 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. */ + * composer editor. A preview has its own explicit burst; ordinary idle does not + * schedule a work-like burst. */ 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 { @@ -230,7 +275,16 @@ export class GajaePetWidget { 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; @@ -244,33 +298,79 @@ 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 = "base"; this.#flexUntil = 0; + this.#flexSource = undefined; + 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); } + #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 { + // `dispose` retains a failed cleanup in TUI's lifecycle queue and releases the + // Kitty ID only after its delete is delivered. This runs before the successor + // claims the shared emitter, so an available terminal observes old cleanup first. + this.dispose(); + } /** (Re)build the encoded frames for the current terminal cell metrics. */ - #buildPixel(protocol: "sixel" | "kitty"): void { + #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(); + // A rebuilt placement supersedes any earlier delete acknowledgement for + // this reusable image ID. + 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, @@ -286,26 +386,25 @@ export class GajaePetWidget { 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.#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; + if (canMutateSharedUi) { this.#floorContainer.clear(); this.#framedEditor.setReserve(0); // Restore the plain composer only while our framed wrapper is still @@ -315,6 +414,22 @@ export class GajaePetWidget { } } } + 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 { @@ -331,14 +446,14 @@ export class GajaePetWidget { /** 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): GajaePixelFrameName { const mode = this.#mode; if (mode === "off") return "base"; - // Random idle burst → the skin's own animation, driven by its burst descriptor - // (RedGajae holds a flex; BlueGajae dances the para-para then sobs). + // Explicit selector preview or an active worker burst uses the skin's + // burst descriptor (RedGajae flexes; BlueGajae dances then sobs). if (now < this.#flexUntil) { const burst = PET_SKINS[mode].burst; const elapsed = now - (this.#flexUntil - petBurstDurationMs(burst)); @@ -361,6 +476,246 @@ export class GajaePetWidget { return "base"; } + #tickIterm(now: number): void { + if (!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); + } + // iTerm uses the same framed-editor invariant as the other pixel protocols. + 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; + } + // The cursor can advance after an inline image. Never draw a three-row + // iTerm canvas unless the terminal retains the one-row safety margin. + 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; + } + // Align to the composer's top, input, and bottom rows. On the smallest + // usable terminal retain a final-row margin; normal iTerm placement uses + // the complete composer footprint rather than shifting it into the row above. + 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; + const working = this.#isWorking(); + const flexing = this.#flexUntil > now; + const semantic = `${this.#mode}:${availability.mode}:${availability.epoch}:${working}:${flexing}:${rect.column},${rect.row}:${cell.widthPx},${cell.heightPx}:${this.#ui.terminal.columns},${this.#ui.terminal.rows}`; + if (this.#itermSubmitPending || (semantic === this.#itermLastSemantic && this.#itermLease)) return; + this.#itermLastSemantic = semantic; + this.#itermSubmitPending = true; + const generation = this.#itermGeneration; + void this.#submitIterm( + rect, + generation, + availability.epoch, + availability.mode, + semantic, + working, + flexing, + { + 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, + 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.#isActiveOwner() && + generation === this.#itermGeneration && + availability?.available === true && + availability.epoch === epoch && + availability.mode === mode && + !this.#ui.manualViewportActive && + this.#isWorking() === working && + flexingNow === flexing && + this.#framedEditor.canFit(terminal.columns) && + terminal.columns === geometry.columns && + terminal.rows === geometry.rows && + 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 frames = flexing + ? burstTimeline(this.#mode === "off" ? "red" : this.#mode) + : working + ? workingTimeline() + : idleTimeline(); + 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 }, + // Reserve a three-cell canvas but keep the two-cell sprite vertically centered: + // transparent half-cell insets move the visible pet down by half a cell. + contentInset: { + topPx: Math.floor(cell.heightPx / 2), + bottomPx: Math.ceil(cell.heightPx / 2), + }, + // Cell units match Kitty placement sizing and avoid Retina pixel-unit shrinkage. + 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", + // Semantic transitions re-submit a full GIF at the existing lease + // placement. Do not erase that placement first: iTerm visibly blanks + // transparent canvas cells between the erase and GIF upload. + 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; + } + } #scheduleAutoFlex(now: number): void { if (!this.#autoFlexGapMs) return; const [min, max] = this.#autoFlexGapMs; @@ -368,6 +723,41 @@ export class GajaePetWidget { } #tick(now: number): void { + if (!this.#isActiveOwner()) return; + const working = this.#isWorking(); + // Idle has one deterministic timeline. A worker burst cannot outlive work, + // while selector preview remains an explicit, separate state. + if (!working) { + this.#nextAutoFlexAt = 0; + if (this.#flexSource === "working") { + this.#flexUntil = 0; + this.#flexSource = undefined; + } + } + if (now >= this.#flexUntil) { + this.#flexUntil = 0; + this.#flexSource = undefined; + if (this.#previewFlexAt !== 0 && now >= this.#previewFlexAt) { + const skin = this.#mode === "off" ? "red" : this.#mode; + this.#flexUntil = now + petBurstDurationMs(PET_SKINS[skin].burst); + this.#flexSource = "preview"; + this.#previewFlexAt = 0; + } else if (this.#autoFlexGapMs && working) { + if (this.#nextAutoFlexAt === 0) { + this.#scheduleAutoFlex(now); + } else if (now >= this.#nextAutoFlexAt) { + const skin = this.#mode === "off" ? "red" : this.#mode; + const burstMs = petBurstDurationMs(PET_SKINS[skin].burst); + this.#flexUntil = now + burstMs; + this.#flexSource = "working"; + this.#scheduleAutoFlex(now + burstMs); + } + } + } + if (this.#itermProtocol) { + this.#tickIterm(now); + return; + } 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. @@ -380,25 +770,38 @@ export class GajaePetWidget { this.#ui.requestRender(true); } } - // 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). - if (this.#autoFlexGapMs && now >= this.#flexUntil) { - if (this.#nextAutoFlexAt === 0) { - this.#scheduleAutoFlex(now); - } else if (now >= this.#nextAutoFlexAt) { - const burstMs = petBurstDurationMs(PET_SKINS[this.#mode].burst); - this.#flexUntil = now + burstMs; - this.#scheduleAutoFlex(now + burstMs); - } - } 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`); + // Queue frame swaps through TUI so they share ordering with generic renders. + 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, + }); } } @@ -432,9 +835,7 @@ export class GajaePetWidget { 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; } @@ -444,66 +845,75 @@ export class GajaePetWidget { } /** - * 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. + * Queue image cleanup before subsequent raster output; failed delivery stays in + * TUI's lifecycle queue. `releaseKittyImage` reserves the image ID until its + * ID-scoped delete has actually reached the terminal. */ - #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 { + /** Build a physical overlay and defer state changes until its write succeeds. */ + #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 = ""; + 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; + // A Kitty image can exist only after its placement bytes reach the terminal. + 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..b0ab3ee39e --- /dev/null +++ b/packages/coding-agent/src/modes/components/iterm-pet-transport.ts @@ -0,0 +1,595 @@ +/** 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); + const candidate = combined.slice(combined.length - suffixLength); + const keep = candidate && marker.startsWith(candidate) ? candidate : ""; + 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 { + // Lifecycle notifications are observational and must not affect transport state. + } + } + + 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) { + const refreshed = result(await this.#tmux(["refresh-client", "-t", clientId])).status === 0; + return refreshed && 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 { + // Availability observers are isolated from transport completion. + } + } + } + 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); + if (epoch !== this.#epoch || this.#disposed) return this.availability; + 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 { + 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.revoke("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 cc503d5cd3..9cb3303195 100644 --- a/packages/coding-agent/src/modes/components/pet-capability.ts +++ b/packages/coding-agent/src/modes/components/pet-capability.ts @@ -6,8 +6,9 @@ import { shouldProbeSixelCapability, TERMINAL, } from "@gajae-code/tui"; +import type { PetTransportAvailability } from "./iterm-pet-transport"; -export type PetPixelProtocol = "sixel" | "kitty"; +export type PetPixelProtocol = "sixel" | "kitty" | "iterm"; export const PET_UNAVAILABLE_DESCRIPTION = "Unavailable: requires compatible Kitty or Sixel overlay rendering"; export const PET_SAVED_UNAVAILABLE_DESCRIPTION = @@ -21,9 +22,34 @@ export function getPetUnavailableWarning(env: NodeJS.ProcessEnv = Bun.env): stri return isUnderTerminalMultiplexer(env) ? PET_MULTIPLEXER_UNAVAILABLE_WARNING : PET_UNAVAILABLE_WARNING; } +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) return "kitty"; if (TERMINAL.imageProtocol === ImageProtocol.Sixel) return "sixel"; + if (verifiedItermAvailability?.available) return "iterm"; return null; } @@ -96,16 +122,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 267cdbbe0c..32c45a6f88 100644 --- a/packages/coding-agent/src/modes/controllers/input-controller.ts +++ b/packages/coding-agent/src/modes/controllers/input-controller.ts @@ -25,7 +25,11 @@ import { getEditorCommand, openInEditor } from "../../utils/external-editor"; import { ensureSupportedImageInput, ImageInputTooLargeError } from "../../utils/image-loading"; import { resizeImage } from "../../utils/image-resize"; import { loadPastedImageBatch, PastedImageBatchError } from "../../utils/pasted-image-loading"; -import { formatPastedImageReference, parsePastedImagePaths } from "../../utils/pasted-image-path"; +import { + decodePastedPathCandidate, + formatPastedImageReference, + parsePastedImagePaths, +} from "../../utils/pasted-image-path"; import { generateSessionTitle, setSessionTerminalTitle } from "../../utils/title-generator"; import { ActionRegistry, APP_ACTION_METADATA } from "../action-registry"; import { CommandPalette, type CommandPaletteAction, type CommandPaletteEntry } from "../components/command-palette"; @@ -51,6 +55,11 @@ 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\.gif$/; + +function isItermPetDragPaste(text: string): boolean { + return ITERM_PET_DRAG_PATH_PATTERN.test(decodePastedPathCandidate(text) ?? ""); +} const IMAGE_PLACEHOLDER_PRESENT_PATTERN = /\[image [1-9]\d*\]/; interface InputControllerDependencies { @@ -1611,6 +1620,10 @@ export class InputController { handleTextPaste(text: string, context: PasteTextContext): boolean | Promise { if (this.ctx.isBashMode || this.ctx.isPythonMode) return false; + if (isItermPetDragPaste(text)) { + this.ctx.showStatus("Ignored dragged Gajae Pet image.", { dim: true }); + return true; + } const parsed = parsePastedImagePaths(text, { cwd: this.ctx.sessionManager.getCwd() }); if (!parsed) return false; if (parsed.kind === "too-many") { diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index c294a6ce5b..5dd7bcaa58 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -67,10 +67,13 @@ import { IrcLeftLaneComponent, IrcSplitViewComponent, } from "./components/irc-sidebar"; +import { createNativePetTransport, type ItermPetTransport } from "./components/iterm-pet-transport"; import { + getItermPetUnavailableReason, getPetUnavailableWarning, isPetAvailable, isPetCapabilityProbePending, + setVerifiedItermPetAvailability, warnWhenPetCapabilitySettled, } from "./components/pet-capability"; import type { ToolExecutionHandle } from "./components/tool-execution"; @@ -400,6 +403,7 @@ export class InteractiveMode implements InteractiveModeContext { lastComposerClearEscapeTime = 0; shutdownRequested = false; #isShuttingDown = false; + #gracefulPetCleanupDone = false; hookSelector: HookSelectorComponent | undefined = undefined; hookInput: HookInputComponent | undefined = undefined; hookEditor: HookEditorComponent | undefined = undefined; @@ -414,6 +418,8 @@ export class InteractiveMode implements InteractiveModeContext { #resolvedSlashCommands: SlashCommand[] = []; #baseReservedSlashCommandNames: Set = new Set(); #cleanupUnsubscribe?: () => void; + #itermPetTransport?: ItermPetTransport; + #petTransportAvailabilityUnsubscribe?: () => void; #subprocessTeardownUnsubscribe?: () => void; #petProtocolUnsubscribe?: () => void; /** Cancels a startup pet-unavailable warning still awaiting probe settlement. */ @@ -600,6 +606,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); @@ -797,25 +815,17 @@ export class InteractiveMode implements InteractiveModeContext { // appears without the user re-running /pet. this.#petProtocolUnsubscribe?.(); this.#petProtocolUnsubscribe = onImageProtocolChanged(protocol => { - if (!protocol) return; + // A revocation is authoritative: suspend the overlay immediately so + // stale capability notifications cannot leave a pet on screen. + if (!protocol) { + this.petWidget?.setMode("off"); + 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(); @@ -865,6 +875,25 @@ 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()) { + // Start the deadline only after TUI.start() and the iTerm probe/poll + // have actually begun; startup I/O must not consume probe time. + this.#petUnavailableWarningDisposer?.(); + this.#petUnavailableWarningDisposer = warnWhenPetCapabilitySettled({ + probePending: this.#itermPetTransport !== undefined || isPetCapabilityProbePending(), + onUnavailable: () => { + this.showStatus( + theme.fg("warning", `${getPetUnavailableWarning()} (${getItermPetUnavailableReason() ?? "unknown"})`), + { dim: false }, + ); + this.ui.requestRender(); + }, + }); + } pushTerminalTitle(); setSessionTerminalTitle(this.sessionManager.getSessionName(), this.sessionManager.getCwd()); this.updateEditorChrome(); @@ -1260,7 +1289,11 @@ 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", `${getPetUnavailableWarning()} (${getItermPetUnavailableReason() ?? "unknown"})`), + { dim: false }, + ); this.ui.requestRender(); return false; } @@ -1317,6 +1350,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), }); } @@ -1468,8 +1503,17 @@ export class InteractiveMode implements InteractiveModeContext { this.#petProtocolUnsubscribe = undefined; this.#petUnavailableWarningDisposer?.(); this.#petUnavailableWarningDisposer = undefined; - this.petWidget?.dispose(); - this.petWidget = undefined; + // Emergency synchronous path: dispose the widget before transport teardown. + if (!this.#gracefulPetCleanupDone) { + this.petWidget?.dispose(); + this.petWidget = undefined; + this.#petTransportAvailabilityUnsubscribe?.(); + this.#petTransportAvailabilityUnsubscribe = undefined; + void this.#itermPetTransport?.dispose(); + this.#itermPetTransport = undefined; + } + this.#itermPetTransport = undefined; + setVerifiedItermPetAvailability(undefined); this.#welcomeComponent?.dispose(); this.#welcomeComponent = undefined; if (this.#sttController) { @@ -1540,6 +1584,15 @@ 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(); + // Complete widget cleanup and transport rollback before stopping the TUI. + await this.petWidget?.disposeAsync(); + this.petWidget = undefined; + this.#petTransportAvailabilityUnsubscribe?.(); + this.#petTransportAvailabilityUnsubscribe = undefined; + await this.#itermPetTransport?.dispose(); + this.#itermPetTransport = undefined; + setVerifiedItermPetAvailability(undefined); + this.#gracefulPetCleanupDone = true; 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 a679ce39d9..555a728ef1 100644 --- a/packages/coding-agent/test/gajae-pet-widget.test.ts +++ b/packages/coding-agent/test/gajae-pet-widget.test.ts @@ -5,9 +5,11 @@ import { getCellDimensions, setCellDimensions, 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 { setVerifiedItermPetAvailability } from "../src/modes/components/pet-capability"; function makeStubs(columns = 80, rows = 30) { const written: string[] = []; @@ -23,9 +25,21 @@ function makeStubs(columns = 80, rows = 30) { }, invalidate() {}, } as unknown as CustomEditor; + let renderRequests = 0; let emitter: (() => string | null) | undefined; let available = 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) { @@ -48,9 +62,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 }); @@ -59,7 +81,55 @@ function makeStubs(columns = 80, rows = 30) { get terminalAvailable() { return available; }, + 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(); @@ -72,6 +142,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,11 +153,27 @@ function makeStubs(columns = 80, rows = 30) { 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, @@ -105,6 +192,7 @@ 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, }); @@ -162,6 +250,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(); @@ -195,19 +340,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(); @@ -221,14 +362,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(); @@ -277,6 +415,7 @@ describe("GajaePetWidget", () => { floorContainer: stubs.floorContainer, isWorking: () => false, getComposerBottomOffset: () => 0, + syncManagedItermCursor: async () => true, forcePixelProtocol: "kitty", autoFlexGapMs: null, }); @@ -304,7 +443,7 @@ describe("GajaePetWidget", () => { third.dispose(); }); - it("completes logical teardown when the cleanup write throws", () => { + 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"); @@ -312,7 +451,7 @@ describe("GajaePetWidget", () => { setWriteFailure(true); widget.dispose(); - // The thrown write must not abort teardown: the shared emitter slot is + // 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"); @@ -322,6 +461,318 @@ describe("GajaePetWidget", () => { 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(); + }); + 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"); + + vi.advanceTimersByTime(160); + await flushAsyncChain(); + const queuedFrame = queued.find(output => output.shouldWrite !== undefined); + expect(queuedFrame?.shouldWrite?.()).toBe(true); + + 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 = () => @@ -332,6 +783,7 @@ describe("GajaePetWidget", () => { floorContainer: stubs.floorContainer, isWorking: () => false, getComposerBottomOffset: () => stubs.floorContainer.render(80).length, + syncManagedItermCursor: async () => true, forcePixelProtocol: "sixel", autoFlexGapMs: null, }); @@ -363,6 +815,7 @@ describe("GajaePetWidget", () => { floorContainer: stubs.floorContainer, isWorking: () => false, getComposerBottomOffset: () => stubs.floorContainer.render(80).length, + syncManagedItermCursor: async () => true, forcePixelProtocol: "sixel", autoFlexGapMs: null, }); @@ -407,6 +860,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(); @@ -424,7 +914,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"); @@ -504,7 +994,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 { @@ -512,6 +1002,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 @@ -519,6 +1010,7 @@ describe("GajaePetWidget", () => { expect(written.some(chunk => chunk.includes("\x1b[0m") && chunk.includes("\x1b[4X"))).toBe(true); } finally { widget.dispose(); + await flushAsyncChain(); } }); @@ -546,18 +1038,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); @@ -566,14 +1057,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); - it("runs a para-para-then-sob burst for BlueGajae", () => { + working = false; + vi.advanceTimersByTime(80); + expect(widget.isFlexing).toBe(false); + } finally { + widget.dispose(); + } + }); + + 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. @@ -589,8 +1096,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 { @@ -635,12 +1142,529 @@ describe("GajaePetWidget", () => { 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("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(); + } + }); + + 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 { + 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("guards direct and managed iTerm raster submission when the framed editor cannot fit", async () => { + vi.useFakeTimers(); + 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 { + 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(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("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 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(); + 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(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]); + + working = false; + vi.advanceTimersByTime(80); + await flushAsyncChain(); + expect(stubs.getRasterCursorVisibilityRestores()).toEqual([true, true, true]); + expect(stubs.getInvalidatedRasterLeases()).toHaveLength(0); + } finally { + 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(); + + 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(); + + 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(); + } + }); }); describe("PetFramedEditor", () => { diff --git a/packages/coding-agent/test/input-controller-keybindings.test.ts b/packages/coding-agent/test/input-controller-keybindings.test.ts index 49936408e0..12bcbb18d5 100644 --- a/packages/coding-agent/test/input-controller-keybindings.test.ts +++ b/packages/coding-agent/test/input-controller-keybindings.test.ts @@ -1039,6 +1039,21 @@ describe("InputController pasted image path transactions", () => { await fs.rm(imagePath, { force: true }); } }); + 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.sPsgeq.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("confirms and atomically attaches saved-image batches in source order", async () => { const directory = await fs.mkdtemp(path.join(os.tmpdir(), "gjc-controller-pasted-images-")); 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..cf567c15fa --- /dev/null +++ b/packages/coding-agent/test/modes/components/iterm-pet-transport.test.ts @@ -0,0 +1,867 @@ +import { afterEach, describe, expect, it, vi } from "bun:test"; +import type { PetTmuxResult, PetTmuxRunner } from "@gajae-code/coding-agent/modes/components/iterm-pet-transport"; +import { + capabilityProbe, + consumeCapabilityInput, + createNativePetTransport, + hasItermFileCapability, + ItermPetTransport, + isItermCandidate, +} from "@gajae-code/coding-agent/modes/components/iterm-pet-transport"; +import type { Subprocess } from "bun"; + +const ack = "\x1b]1337;Capabilities=F\x07"; +type SpawnCall = readonly string[]; +type SpawnOptions = Bun.SpawnOptions.SpawnOptions< + Bun.SpawnOptions.Writable, + Bun.SpawnOptions.Readable, + Bun.SpawnOptions.Readable +>; + +function createSpawnMock(calls: SpawnCall[]) { + function mockSpawn(options: SpawnOptions & { cmd: string[] }): Subprocess; + function mockSpawn(cmd: string[], options?: SpawnOptions): Subprocess; + function mockSpawn(first: string[] | (SpawnOptions & { cmd: string[] }), _second?: SpawnOptions): Subprocess { + calls.push(Array.isArray(first) ? first : first.cmd); + return { + pid: 1, + stdout: new Response("").body!, + stderr: new Response("").body!, + exited: Promise.resolve(1), + } as Subprocess; + } + return mockSpawn; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +class Clock { + nowMs = 0; + timers = new Map void }>(); + next = 0; + now = () => this.nowMs; + setTimeout = (cb: () => void, ms: number) => { + const id = ++this.next; + this.timers.set(id, { at: this.nowMs + ms, cb }); + return id; + }; + clearTimeout = (id: unknown) => { + this.timers.delete(id as number); + }; + advance(ms: number) { + this.nowMs += ms; + for (const [id, timer] of [...this.timers]) { + if (timer.at <= this.nowMs) { + this.timers.delete(id); + timer.cb(); + } + } + } +} + +const waitFor = async (predicate: () => boolean) => { + for (let turns = 0; turns < 50; turns++) { + if (predicate()) return; + await Promise.resolve(); + } + throw new Error("condition did not become true within 50 microtasks"); +}; + +class Input { + listeners = new Set<(data: string | Uint8Array) => unknown>(); + drains: number[][] = []; + drain = async (maxMs: number, quiescenceMs: number) => { + this.drains.push([maxMs, quiescenceMs]); + }; + onData = (callback: (data: string | Uint8Array) => unknown) => { + this.listeners.add(callback); + return () => this.listeners.delete(callback); + }; + send(data: string | Uint8Array) { + return [...this.listeners].map(callback => callback(data)); + } +} + +const make = (extra: Partial[0]> = {}) => { + const clock = new Clock(); + const input = new Input(); + const writes: Uint8Array[] = []; + const output = { + write: async (bytes: Uint8Array) => { + writes.push(bytes); + return { status: "written" as const }; + }, + }; + const transport = new ItermPetTransport({ clock, input, output, ...extra }); + return { clock, input, writes, transport }; +}; +const factoryUi = { + drainInput: async () => {}, + addInputListener: () => () => {}, + submitTerminalOutput: async () => ({ status: "written" as const }), + notifyTerminalLifecycle: async () => {}, + terminalGeneration: 1, +}; + +it("refreshes direct transport without invoking tmux", async () => { + let calls = 0; + const x = make({ + tmux: async () => { + calls++; + return { status: 0, stdout: "" }; + }, + }); + expect(await x.transport.refreshManagedClient(0, 0)).toBe(true); + expect(calls).toBe(0); +}); + +describe("iTerm Pet transport factory", () => { + it("creates managed transport for an eligible iTerm tmux session", () => { + const transport = createNativePetTransport({ + ui: factoryUi, + env: { + TERM_PROGRAM: "iTerm.app", + TERM_PROGRAM_VERSION: "3.7", + TMUX_PANE: "%13", + GJC_TMUX_ACTIVE_SESSION: "session", + GJC_MANAGED_OWNER_RUN_ID: "run", + }, + }); + expect(transport?.availability.mode).toBe("managed"); + }); + it("uses the shared tmux command override for managed sessions", async () => { + const calls: SpawnCall[] = []; + vi.spyOn(Bun, "spawn").mockImplementation(createSpawnMock(calls)); + const transport = createNativePetTransport({ + ui: factoryUi, + env: { + TERM_PROGRAM: "iTerm.app", + TERM_PROGRAM_VERSION: "3.7", + TMUX_PANE: "%13", + GJC_TMUX_ACTIVE_SESSION: "session", + GJC_MANAGED_OWNER_RUN_ID: "run", + GJC_TMUX_COMMAND: "psmux", + }, + }); + + await transport?.inspectManagedTopology(); + + expect(calls[0]?.[0]).toBe("psmux"); + }); + + it("rejects tmux when a managed marker is missing", () => { + const transport = createNativePetTransport({ + ui: factoryUi, + env: { + TERM_PROGRAM: "iTerm.app", + TERM_PROGRAM_VERSION: "3.7", + TMUX_PANE: "%13", + GJC_TMUX_ACTIVE_SESSION: "session", + }, + }); + expect(transport).toBeUndefined(); + }); + it.each([ + { TMUX: "/tmp/tmux-501/default,123,0" }, + { STY: "screen-session" }, + { ZELLIJ: "zellij-session" }, + ])("rejects unmanaged multiplexer contexts: %j", env => { + const transport = createNativePetTransport({ + ui: factoryUi, + env: { TERM_PROGRAM: "iTerm.app", TERM_PROGRAM_VERSION: "3.7", ...env }, + }); + expect(transport).toBeUndefined(); + }); + it("uses the non-destructive probe drain when the UI provides one", async () => { + const listeners = new Set<(data: string | Uint8Array) => unknown>(); + let probeDrains = 0; + let shutdownDrains = 0; + const transport = createNativePetTransport({ + ui: { + ...factoryUi, + drainPetProbeInput: async () => { + probeDrains++; + }, + drainInput: async () => { + shutdownDrains++; + }, + addInputListener: callback => { + listeners.add(callback); + return () => listeners.delete(callback); + }, + submitTerminalOutput: async () => { + setTimeout(() => { + for (const listener of listeners) listener(ack); + }, 0); + return { status: "written" }; + }, + }, + env: { TERM_PROGRAM: "iTerm.app", TERM_PROGRAM_VERSION: "3.7" }, + }); + expect((await transport?.probe())?.available).toBe(true); + expect(probeDrains).toBe(1); + expect(shutdownDrains).toBe(0); + }); + + it("rejects unsupported direct terminals", () => { + const transport = createNativePetTransport({ + ui: factoryUi, + env: { TERM_PROGRAM: "xterm", TERM_PROGRAM_VERSION: "3.7" }, + }); + expect(transport).toBeUndefined(); + }); + it("covers direct candidate version boundaries through the factory seam", () => { + expect(isItermCandidate({ TERM_PROGRAM: "iTerm.app", TERM_PROGRAM_VERSION: "3.4.9" }, true)).toBe(false); + expect(isItermCandidate({ TERM_PROGRAM: "iTerm.app", TERM_PROGRAM_VERSION: "3.5.0" }, true)).toBe(true); + expect(isItermCandidate({ TERM_PROGRAM: "iTerm.app", TERM_PROGRAM_VERSION: "4.0.0" }, true)).toBe(true); + expect(isItermCandidate({ TERM_PROGRAM: "iTerm.app", TERM_PROGRAM_VERSION: "3.5.0" }, false)).toBe(false); + }); +}); +describe("iTerm Pet transport", () => { + const managedReady = async ( + tmux: (argv: readonly string[]) => Promise<{ status: number; stdout: string }>, + topology: () => Promise<{ + clients: number; + paneId: string; + ownedPaneId: string; + clientId: string; + }> = async () => ({ + clients: 1, + paneId: "%1", + ownedPaneId: "%1", + clientId: "client-1", + }), + ) => { + const x = make({ + mode: "managed", + paneId: "%1", + sessionTarget: "s", + tmux, + topology, + expectedClientId: "client-1", + }); + const probe = x.transport.inspectManagedTopology(); + await waitFor(() => x.input.listeners.size === 1); + x.input.send(ack); + await probe; + return x; + }; + it("observes rejected lifecycle notifications without changing completed availability", async () => { + const notifications: string[] = []; + const x = make({ + output: { + write: async () => ({ status: "written" as const }), + notifyLifecycle: async event => { + notifications.push(event.kind); + throw new Error("notification failed"); + }, + }, + }); + const probe = x.transport.probe(); + await waitFor(() => x.input.listeners.size === 1); + x.input.send(ack); + expect((await probe).available).toBe(true); + await Promise.resolve(); + expect(x.transport.availability.available).toBe(true); + expect(notifications).toEqual(["availability-restored"]); + }); + it("contains synchronous lifecycle notification throws after availability completes", async () => { + const notifications: string[] = []; + const x = make({ + output: { + write: async () => ({ status: "written" as const }), + notifyLifecycle: event => { + notifications.push(event.kind); + throw new Error("notification failed"); + }, + }, + }); + const probe = x.transport.probe(); + await waitFor(() => x.input.listeners.size === 1); + x.input.send(ack); + expect((await probe).available).toBe(true); + expect(x.transport.availability.available).toBe(true); + expect(notifications).toEqual(["availability-restored"]); + }); + it("accepts official feature tokens after a written probe ack, including fragmented replies", async () => { + const live = "\x1b]1337;Capabilities=T3CwLrMSc7UUw9Ts3BFGsSyHNoSxFP\x07"; + expect(hasItermFileCapability(live)).toBe(true); + expect(hasItermFileCapability("\x1b]1337;Capabilities=T3CwLrMSc7UUw9Ts3B\x07")).toBe(false); + expect(hasItermFileCapability("\x1b]1337;Capabilities=Ffoo\x07")).toBe(false); + expect(hasItermFileCapability("\x1b]1337;Capabilities=Gx!\x07")).toBe(false); + expect(hasItermFileCapability("\x1b]1337;Capabilities=F\x1b\\")).toBe(true); + const x = make(); + const probe = x.transport.probe(); + await waitFor(() => x.input.listeners.size === 1); + expect(x.writes).toHaveLength(1); + x.input.send("\x1b]1337;Cap"); + expect(x.transport.availability.reason).toBeUndefined(); + x.input.send("abilities=T3CwLrMSc7UUw9Ts3BFGsSyHNoSxFP\x07"); + expect((await probe).available).toBe(true); + expect(capabilityProbe()).toEqual(new TextEncoder().encode("\x1b]1337;Capabilities\x07")); + expect(x.input.drains).toEqual([[100, 25]]); + }); + it("rejects a synchronous capability reply from output.write until the written ack, then accepts post-ack input", async () => { + const clock = new Clock(); + const input = new Input(); + const write = Promise.withResolvers<{ status: "written" }>(); + const output = { + write: async () => { + input.send(ack); + return write.promise; + }, + }; + const resolveWrite = write.resolve; + const transport = new ItermPetTransport({ clock, input, output }); + const probe = transport.probe(); + await Promise.resolve(); + expect(transport.availability.available).toBe(false); + resolveWrite({ status: "written" }); + await waitFor(() => clock.timers.size === 1); + await waitFor(() => input.listeners.size === 1); + input.send(ack); + expect((await probe).available).toBe(true); + expect(input.listeners.size).toBe(0); + expect(clock.timers.size).toBe(0); + }); + it("cleans up after a failed capability write", async () => { + const x = make({ + output: { write: async () => ({ status: "failed" as const }) }, + }); + expect((await x.transport.probe()).reason).toBe("probe-timeout"); + expect(x.input.listeners.size).toBe(0); + expect(x.clock.timers.size).toBe(0); + }); + it("preserves mixed user input while consuming only complete capability frames", () => { + const frames: string[] = []; + const split = consumeCapabilityInput(data => + frames.push(typeof data === "string" ? data : new TextDecoder().decode(data)), + ); + expect(split(`a${ack}b`)).toEqual({ data: "ab" }); + expect(frames).toEqual([ack]); + expect(split("left")).toEqual({ data: "left" }); + }); + + it("consumes pure complete and fragmented capability frames", () => { + const split = consumeCapabilityInput(() => {}); + expect(split(ack)).toEqual({ consume: true }); + expect(split("\x1b]1337;Cap")).toEqual({ consume: true }); + expect(split("abilities=F\x07")).toEqual({ consume: true }); + }); + + it("classifies completed replies as missing F only when syntax is valid", async () => { + for (const [reply, reason] of [ + ["\x1b]1337;Capabilities=T3CwLrMSc7UUw9Ts3B\x07", "missing-f"], + ["\x1b]1337;Capabilities=Gx!\x07", "invalid-f"], + ] as const) { + const x = make(); + const probe = x.transport.probe(); + await waitFor(() => x.input.listeners.size === 1); + x.input.send(reply); + expect((await probe).reason).toBe(reason); + expect(x.transport.availability.reason).toBe(reason); + } + }); + + it("times out at 1000ms without retry and allows manual retry as a new epoch", async () => { + const x = make(); + const probe = x.transport.probe(); + await waitFor(() => x.input.listeners.size === 1); + x.clock.advance(999); + expect(x.transport.availability.available).toBe(false); + x.clock.advance(1); + expect((await probe).reason).toBe("probe-timeout"); + const retry = x.transport.retry(); + await waitFor(() => x.input.listeners.size === 1); + expect(x.transport.availability.epoch).toBe(2); + x.input.send(ack); + expect((await retry).available).toBe(true); + }); + + it("keeps one outstanding query", async () => { + const x = make(); + const first = x.transport.probe(); + const second = x.transport.probe(); + await waitFor(() => x.input.listeners.size === 1); + expect(x.writes).toHaveLength(1); + x.input.send(ack); + await first; + expect((await second).available).toBe(false); + }); + + it("queries requested zero-based cursor coordinates and refreshes only on exact match", async () => { + const calls: string[][] = []; + const x = await managedReady(async argv => { + calls.push([...argv]); + if (argv[0] === "show-options" && argv[1] === "-q") return { status: 0, stdout: "" }; + if (argv[0] === "display-message") return { status: 0, stdout: "4\t7" }; + return { status: 0, stdout: "on" }; + }); + expect(await x.transport.refreshManagedClient(4, 7)).toBe(true); + expect(calls.slice(-2)).toEqual([ + ["display-message", "-p", "-t", "%1", "#{cursor_y}\t#{cursor_x}"], + ["refresh-client", "-t", "client-1"], + ]); + }); + + it("retries stale cursor after 10ms and refreshes once on exact result", async () => { + const calls: string[][] = []; + let count = 0; + const x = await managedReady(async argv => { + calls.push([...argv]); + if (argv[0] === "show-options" && argv[1] === "-q") return { status: 0, stdout: "" }; + if (argv[0] === "display-message") return { status: 0, stdout: count++ === 0 ? "1\t2" : "3\t4" }; + return { status: 0, stdout: "on" }; + }); + const pending = x.transport.refreshManagedClient(3, 4); + await waitFor(() => x.clock.timers.size === 1); + x.clock.advance(9); + expect(calls.filter(call => call[0] === "display-message")).toHaveLength(1); + x.clock.advance(1); + await waitFor(() => calls.filter(call => call[0] === "refresh-client").length === 1); + expect(await pending).toBe(true); + expect(calls.slice(-3)).toEqual([ + ["display-message", "-p", "-t", "%1", "#{cursor_y}\t#{cursor_x}"], + ["display-message", "-p", "-t", "%1", "#{cursor_y}\t#{cursor_x}"], + ["refresh-client", "-t", "client-1"], + ]); + }); + + it("returns false after 250ms for stale cursor and never refreshes", async () => { + const calls: string[][] = []; + const x = await managedReady(async argv => { + calls.push([...argv]); + if (argv[0] === "show-options" && argv[1] === "-q") return { status: 0, stdout: "" }; + if (argv[0] === "display-message") return { status: 0, stdout: "0\t1" }; + return { status: 0, stdout: "on" }; + }); + const pending = x.transport.refreshManagedClient(0, 0); + await waitFor(() => x.clock.timers.size === 1); + x.clock.advance(251); + expect(await pending).toBe(false); + expect(calls.some(call => call[0] === "refresh-client")).toBe(false); + }); + + it("returns false immediately for display errors and malformed cursor output", async () => { + for (const output of [ + { status: 1, stdout: "0\t0" }, + { status: 0, stdout: "malformed" }, + ]) { + const calls: string[][] = []; + const x = await managedReady(async argv => { + calls.push([...argv]); + if (argv[0] === "show-options" && argv[1] === "-q") return { status: 0, stdout: "" }; + if (argv[0] === "display-message") return output; + return { status: 0, stdout: "on" }; + }); + expect(await x.transport.refreshManagedClient(0, 0)).toBe(false); + expect(calls.some(call => call[0] === "refresh-client")).toBe(false); + } + }); + + it("fails when lifecycle or client validity changes during a cursor query", async () => { + for (const change of ["lifecycle", "client"] as const) { + const cursorQuery = Promise.withResolvers<{ status: number; stdout: string }>(); + const release = cursorQuery.resolve; + let clientId = "client-1"; + const calls: string[][] = []; + const x = await managedReady( + async argv => { + calls.push([...argv]); + if (argv[0] === "show-options" && argv[1] === "-q") return { status: 0, stdout: "" }; + if (argv[0] === "display-message") return cursorQuery.promise; + return { status: 0, stdout: "on" }; + }, + async () => ({ clients: 1, paneId: "%1", ownedPaneId: "%1", clientId }), + ); + const pending = x.transport.refreshManagedClient(0, 0); + await waitFor(() => release !== undefined); + if (change === "lifecycle") await x.transport.revoke(); + else { + clientId = "client-2"; + await x.transport.inspectManagedTopology(); + } + release({ status: 0, stdout: "0\t0" }); + expect(await pending).toBe(false); + expect(calls.some(call => call[0] === "refresh-client")).toBe(false); + } + }); + it("restores managed pane state with exact pane-only argv", async () => { + const calls: string[][] = []; + const tmux = async (argv: readonly string[]) => { + calls.push([...argv]); + if (argv[0] === "show-options" && argv[1] === "-q") return { status: 0, stdout: "" }; + if (argv[0] === "display-message") return { status: 0, stdout: "0\t0" }; + return { status: 0, stdout: "on" }; + }; + const topology = async () => ({ + clients: 1, + paneId: "%1", + ownedPaneId: "%1", + clientId: "client-1", + clientVersion: "3.5.0", + }); + const x = make({ + mode: "managed", + paneId: "%1", + sessionTarget: "s", + tmux, + topology, + expectedClientId: "client-1", + }); + const probe = x.transport.inspectManagedTopology(); + await waitFor(() => x.input.listeners.size === 1); + expect(new TextDecoder().decode(x.writes[0])).toBe("\x1bPtmux;\x1b\x1b]1337;Capabilities\x07\x1b\\"); + x.input.send(ack); + await probe; + const refresh = x.transport.refreshManagedClient(0, 0); + expect(calls).toHaveLength(4); + await refresh; + expect(calls).toHaveLength(5); + expect(calls[3]).toEqual(["display-message", "-p", "-t", "%1", "#{cursor_y}\t#{cursor_x}"]); + expect(calls[4]).toEqual(["refresh-client", "-t", "client-1"]); + await x.transport.revoke(); + expect(calls).toEqual([ + ["show-options", "-q", "-p", "-v", "-t", "%1", "allow-passthrough"], + ["set-option", "-p", "-t", "%1", "allow-passthrough", "on"], + ["show-options", "-A", "-p", "-v", "-t", "%1", "allow-passthrough"], + ["display-message", "-p", "-t", "%1", "#{cursor_y}\t#{cursor_x}"], + ["refresh-client", "-t", "client-1"], + ["set-option", "-u", "-p", "-t", "%1", "allow-passthrough"], + ]); + }); + + it("recovers after managed topology changes through zero clients", async () => { + let clients = 1; + const calls: string[][] = []; + const tmux = async (argv: readonly string[]) => { + calls.push([...argv]); + if (argv[0] === "show-options" && argv[1] === "-q") return { status: 0, stdout: "" }; + return { status: 0, stdout: "on" }; + }; + const x = make({ mode: "managed", paneId: "%1", sessionTarget: "s", tmux, topology: async () => ({ clients }) }); + const initial = x.transport.inspectManagedTopology(); + await waitFor(() => x.input.listeners.size === 1); + x.input.send(ack); + await initial; + await x.transport.inspectManagedTopology(); + expect(x.transport.availability.available).toBe(true); + expect(x.writes).toHaveLength(1); + clients = 2; + await x.transport.inspectManagedTopology(); + expect(x.transport.availability.reason).toBe("topology-ineligible"); + clients = 0; + await x.transport.inspectManagedTopology(); + expect(x.transport.availability.reason).toBe("zero-client-recovery"); + clients = 1; + const recovery = x.transport.inspectManagedTopology(); + await waitFor(() => x.input.listeners.size === 1); + x.input.send(ack); + const recovered = await recovery; + if (recovered === undefined) throw new Error("managed topology recovery returned no availability"); + expect(recovered.available).toBe(true); + expect(calls).toEqual([ + ["show-options", "-q", "-p", "-v", "-t", "%1", "allow-passthrough"], + ["set-option", "-p", "-t", "%1", "allow-passthrough", "on"], + ["show-options", "-A", "-p", "-v", "-t", "%1", "allow-passthrough"], + ["set-option", "-u", "-p", "-t", "%1", "allow-passthrough"], + ["show-options", "-q", "-p", "-v", "-t", "%1", "allow-passthrough"], + ["set-option", "-p", "-t", "%1", "allow-passthrough", "on"], + ["show-options", "-A", "-p", "-v", "-t", "%1", "allow-passthrough"], + ]); + }); + it("maps a live tmux client using client_name as the display target", async () => { + const calls: string[][] = []; + const tmux = async (argv: readonly string[]) => { + calls.push([...argv]); + if (argv[0] === "list-clients") return { status: 0, stdout: "/dev/ttys010\t/dev/ttys010\n" }; + if (argv[0] === "display-message") return { status: 0, stdout: "%1\n" }; + if (argv[0] === "show-options" && argv[1] === "-q") return { status: 0, stdout: "" }; + return { status: 0, stdout: "on" }; + }; + const x = make({ mode: "managed", paneId: "%1", sessionTarget: "s", tmux }); + const probe = x.transport.inspectManagedTopology(); + await waitFor(() => x.input.listeners.size === 1); + x.input.send(ack); + expect((await probe).available).toBe(true); + expect(calls).toEqual([ + ["list-clients", "-t", "s", "-F", "#{client_name}\t#{client_tty}"], + ["display-message", "-p", "-c", "/dev/ttys010", "#{pane_id}"], + ["show-options", "-q", "-p", "-v", "-t", "%1", "allow-passthrough"], + ["set-option", "-p", "-t", "%1", "allow-passthrough", "on"], + ["show-options", "-A", "-p", "-v", "-t", "%1", "allow-passthrough"], + ]); + }); + it("restores managed pane options when a later client query fails", async () => { + let listClientsAvailable = true; + const calls: string[][] = []; + const tmux: PetTmuxRunner = async argv => { + calls.push([...argv]); + if (argv[0] === "list-clients") + return listClientsAvailable ? { status: 0, stdout: "client-1\t/dev/ttys010\n" } : { status: 1, stdout: "" }; + if (argv[0] === "display-message") return { status: 0, stdout: "%1\n" }; + if (argv[0] === "show-options" && argv[1] === "-q") return { status: 0, stdout: "off" }; + if (argv[0] === "show-options" && argv[1] === "-A") return { status: 0, stdout: "on" }; + return { status: 0, stdout: "" }; + }; + const x = make({ mode: "managed", paneId: "%1", sessionTarget: "s", tmux }); + const probe = x.transport.inspectManagedTopology(); + await waitFor(() => x.input.listeners.size === 1); + x.input.send(ack); + expect((await probe).available).toBe(true); + + listClientsAvailable = false; + expect((await x.transport.inspectManagedTopology()).reason).toBe("topology-ineligible"); + expect(calls.at(-1)).toEqual(["set-option", "-p", "-t", "%1", "allow-passthrough", "off"]); + }); + + it("fails closed when tmux returns an empty client identity", async () => { + const calls: string[][] = []; + const tmux = async (argv: readonly string[]) => { + calls.push([...argv]); + return { status: 0, stdout: "\t/dev/ttys010\n" }; + }; + const x = make({ mode: "managed", paneId: "%1", sessionTarget: "s", tmux }); + const availability = await x.transport.inspectManagedTopology(); + expect(availability.reason).toBe("topology-ineligible"); + expect(calls).toEqual([["list-clients", "-t", "s", "-F", "#{client_name}\t#{client_tty}"]]); + }); + it("serializes revoke restore behind deferred managed preparation", async () => { + const calls: string[][] = []; + let releasePrepare!: (value: { status: number; stdout: string }) => void; + let releaseRestore!: (value: { status: number; stdout: string }) => void; + const tmux: PetTmuxRunner = async (argv: readonly string[]) => { + calls.push([...argv]); + if (argv[0] === "set-option" && argv[1] === "-p") { + const deferred = Promise.withResolvers(); + if (argv.at(-1) === "on") releasePrepare = deferred.resolve; + else releaseRestore = deferred.resolve; + return deferred.promise; + } + return { status: 0, stdout: "off" }; + }; + const x = make({ + mode: "managed", + paneId: "%1", + sessionTarget: "s", + tmux, + topology: async () => ({ clients: 1, paneId: "%1", ownedPaneId: "%1", clientId: "client-1" }), + expectedClientId: "client-1", + }); + const pending = x.transport.inspectManagedTopology(); + await waitFor(() => releasePrepare !== undefined); + const revoked = x.transport.revoke(); + expect(calls).toHaveLength(2); + releasePrepare({ status: 0, stdout: "" }); + await waitFor(() => calls.length === 3); + expect(calls[2]).toEqual(["set-option", "-p", "-t", "%1", "allow-passthrough", "off"]); + releaseRestore({ status: 0, stdout: "" }); + await pending; + await revoked; + expect(calls).toEqual([ + ["show-options", "-q", "-p", "-v", "-t", "%1", "allow-passthrough"], + ["set-option", "-p", "-t", "%1", "allow-passthrough", "on"], + ["set-option", "-p", "-t", "%1", "allow-passthrough", "off"], + ]); + }); + it("restores before retrying after managed preparation rejection", async () => { + const calls: string[][] = []; + let failed = true; + let releaseRestore!: (value: { status: number; stdout: string }) => void; + const tmux: PetTmuxRunner = async (argv: readonly string[]) => { + calls.push([...argv]); + if (argv[0] === "show-options" && argv[1] === "-q") return { status: 0, stdout: "off" }; + if (argv[0] === "set-option" && argv[1] === "-p" && failed) { + failed = false; + throw new Error("prepare failed"); + } + if (argv[0] === "set-option" && argv[1] === "-p" && argv[4] === "allow-passthrough" && argv[5] === "off") { + const deferred = Promise.withResolvers(); + releaseRestore = deferred.resolve; + return deferred.promise; + } + return { status: 0, stdout: "on" }; + }; + const { input, writes, transport } = make({ + mode: "managed", + paneId: "%1", + sessionTarget: "s", + tmux, + topology: async () => ({ clients: 1, paneId: "%1", ownedPaneId: "%1", clientId: "client-1" }), + expectedClientId: "client-1", + }); + const first = transport.inspectManagedTopology(); + await waitFor(() => releaseRestore !== undefined); + expect(calls).toEqual([ + ["show-options", "-q", "-p", "-v", "-t", "%1", "allow-passthrough"], + ["set-option", "-p", "-t", "%1", "allow-passthrough", "on"], + ["set-option", "-p", "-t", "%1", "allow-passthrough", "off"], + ]); + releaseRestore({ status: 0, stdout: "" }); + expect((await first).available).toBe(false); + const retry = transport.inspectManagedTopology(); + await waitFor(() => input.listeners.size === 1 && writes.length === 1); + input.send(ack); + expect((await retry).available).toBe(true); + const restore = calls.findIndex( + call => call[0] === "set-option" && call[1] === "-p" && call[4] === "allow-passthrough" && call[5] === "off", + ); + const retryPrepare = calls.findIndex( + (call, index) => index > restore && call[0] === "show-options" && call[1] === "-q", + ); + expect(restore).toBeGreaterThanOrEqual(0); + expect(retryPrepare).toBeGreaterThan(restore); + }); + it("ignores stale topology completion without probing or mutating identity", async () => { + const topology = Promise.withResolvers<{ + clients: number; + paneId: string; + ownedPaneId: string; + clientId: string; + }>(); + const release = topology.resolve; + const x = make({ + mode: "managed", + paneId: "%1", + sessionTarget: "s", + tmux: async () => ({ status: 0, stdout: "" }), + topology: () => topology.promise, + }); + const pending = x.transport.inspectManagedTopology(); + await waitFor(() => release !== undefined); + await x.transport.revoke(); + release({ clients: 1, paneId: "%2", ownedPaneId: "%2", clientId: "stale" }); + await pending; + expect(x.writes).toHaveLength(0); + expect(x.transport.availability.reason).toBe("topology-lost"); + }); +}); +it("retries cleanup after a thrown restore and retains the snapshot until success", async () => { + const calls: string[][] = []; + let attempts = 0; + const x = make({ + mode: "managed", + paneId: "%1", + sessionTarget: "s", + tmux: async argv => { + calls.push([...argv]); + if (argv[0] === "show-options" && argv[1] === "-q") return { status: 0, stdout: "off" }; + if (argv[0] === "show-options" && argv[1] === "-A") return { status: 0, stdout: "on" }; + if (argv[0] === "set-option" && argv.at(-1) === "on") return { status: 0, stdout: "" }; + if (argv.at(-1) === "off" && attempts++ === 0) throw new Error("restore failed"); + return { status: 0, stdout: "" }; + }, + topology: async () => ({ clients: 1, paneId: "%1", ownedPaneId: "%1", clientId: "client-1" }), + expectedClientId: "client-1", + }); + const probe = x.transport.inspectManagedTopology(); + await waitFor(() => x.input.listeners.size === 1); + x.input.send(ack); + await probe; + await x.transport.revoke(); + expect(x.transport.availability.reason).toBe("cleanup-failed"); + await x.transport.revoke("topology-ineligible"); + expect(x.transport.availability.reason).toBe("topology-ineligible"); + expect(calls.filter(argv => argv.at(-1) === "off")).toHaveLength(2); + expect(calls.at(-1)).toEqual(["set-option", "-p", "-t", "%1", "allow-passthrough", "off"]); +}); + +it("retries cleanup after a nonzero restore and retains the snapshot until success", async () => { + const calls: string[][] = []; + let attempts = 0; + const x = make({ + mode: "managed", + paneId: "%1", + sessionTarget: "s", + tmux: async argv => { + calls.push([...argv]); + if (argv[0] === "show-options" && argv[1] === "-q") return { status: 0, stdout: "off" }; + if (argv[0] === "show-options" && argv[1] === "-A") return { status: 0, stdout: "on" }; + if (argv[0] === "set-option" && argv.at(-1) === "on") return { status: 0, stdout: "" }; + if (argv.at(-1) === "off" && attempts++ === 0) return { status: 1, stdout: "" }; + return { status: 0, stdout: "" }; + }, + topology: async () => ({ clients: 1, paneId: "%1", ownedPaneId: "%1", clientId: "client-1" }), + expectedClientId: "client-1", + }); + const probe = x.transport.inspectManagedTopology(); + await waitFor(() => x.input.listeners.size === 1); + x.input.send(ack); + await probe; + await x.transport.revoke(); + expect(x.transport.availability.reason).toBe("cleanup-failed"); + await x.transport.revoke(); + expect(x.transport.availability.reason).toBe("topology-lost"); + expect(calls.filter(argv => argv.at(-1) === "off")).toHaveLength(2); + expect(calls.at(-1)).toEqual(["set-option", "-p", "-t", "%1", "allow-passthrough", "off"]); +}); + +it("coalesces concurrent restores while preserving the newer topology reason", async () => { + const calls: string[][] = []; + const restore = Promise.withResolvers(); + const release = restore.resolve; + const x = make({ + mode: "managed", + paneId: "%1", + sessionTarget: "s", + tmux: async argv => { + calls.push([...argv]); + if (argv[0] === "show-options" && argv[1] === "-q") return { status: 0, stdout: "off" }; + if (argv[0] === "show-options" && argv[1] === "-A") return { status: 0, stdout: "on" }; + if (argv.at(-1) === "on") return { status: 0, stdout: "" }; + return restore.promise; + }, + topology: async () => ({ clients: 1, paneId: "%1", ownedPaneId: "%1", clientId: "client-1" }), + expectedClientId: "client-1", + }); + const probe = x.transport.inspectManagedTopology(); + await waitFor(() => x.input.listeners.size === 1); + x.input.send(ack); + await probe; + const first = x.transport.revoke("topology-lost"); + await waitFor(() => calls.filter(argv => argv.at(-1) === "off").length === 1); + const second = x.transport.revoke("topology-ineligible"); + expect(calls.filter(argv => argv.at(-1) === "off")).toHaveLength(1); + release({ status: 0, stdout: "" }); + await first; + await second; + expect(calls.filter(argv => argv.at(-1) === "off")).toHaveLength(1); + expect(x.transport.availability.reason).toBe("topology-ineligible"); +}); + +it("disposes only after restore and cannot restart listeners, timers, polling, or observers", async () => { + const calls: string[][] = []; + const restore = Promise.withResolvers(); + const release = restore.resolve; + const x = make({ + mode: "managed", + paneId: "%1", + sessionTarget: "s", + tmux: async argv => { + calls.push([...argv]); + if (argv[0] === "show-options" && argv[1] === "-q") return { status: 0, stdout: "off" }; + if (argv[0] === "show-options" && argv[1] === "-A") return { status: 0, stdout: "on" }; + if (argv.at(-1) === "on") return { status: 0, stdout: "" }; + return restore.promise; + }, + topology: async () => ({ clients: 1, paneId: "%1", ownedPaneId: "%1", clientId: "client-1" }), + expectedClientId: "client-1", + }); + const probe = x.transport.inspectManagedTopology(); + await waitFor(() => x.input.listeners.size === 1); + x.input.send(ack); + await probe; + const disposing = x.transport.dispose(); + await waitFor(() => calls.filter(argv => argv.at(-1) === "off").length === 1); + expect(x.input.listeners.size).toBe(0); + release({ status: 0, stdout: "" }); + await disposing; + const count = calls.length; + x.input.send(ack); + x.clock.advance(10000); + expect(calls).toHaveLength(count); + expect(x.input.listeners.size).toBe(0); + expect(x.clock.timers.size).toBe(0); +}); 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..328f7a5003 100644 --- a/packages/coding-agent/test/modes/components/pet-capability.test.ts +++ b/packages/coding-agent/test/modes/components/pet-capability.test.ts @@ -1,14 +1,33 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; import { + getItermPetUnavailableReason, PET_CAPABILITY_SETTLE_MS, + setVerifiedItermPetAvailability, warnWhenPetCapabilitySettled, } from "@gajae-code/coding-agent/modes/components/pet-capability"; import { ImageProtocol, setTerminalImageProtocol, TERMINAL } from "@gajae-code/tui"; const originalProtocol = TERMINAL.imageProtocol; +describe("getItermPetUnavailableReason", () => { + it("does not report an iTerm reason before iTerm availability is published", () => { + expect(getItermPetUnavailableReason()).toBeUndefined(); + }); + + it("preserves the published iTerm probe failure reason", () => { + setVerifiedItermPetAvailability({ + available: false, + mode: "direct", + epoch: 1, + reason: "probe-timeout", + }); + + expect(getItermPetUnavailableReason()).toBe("probe-timeout"); + }); +}); afterEach(() => { setTerminalImageProtocol(originalProtocol); + setVerifiedItermPetAvailability(undefined); vi.useRealTimers(); vi.restoreAllMocks(); }); @@ -41,6 +60,21 @@ describe("warnWhenPetCapabilitySettled", () => { dispose(); } }); + it("cancels when verified iTerm availability arrives before the deadline", () => { + vi.useFakeTimers(); + setTerminalImageProtocol(null); + const onUnavailable = vi.fn(); + + const dispose = warnWhenPetCapabilitySettled({ probePending: true, onUnavailable }); + try { + setVerifiedItermPetAvailability({ available: true, mode: "direct", epoch: 1 }); + vi.advanceTimersByTime(PET_CAPABILITY_SETTLE_MS * 2); + + expect(onUnavailable).not.toHaveBeenCalled(); + } finally { + dispose(); + } + }); it("warns exactly once when the settle deadline passes with the terminal still unavailable", () => { vi.useFakeTimers(); diff --git a/packages/coding-agent/test/qa-iterm-pet.test.ts b/packages/coding-agent/test/qa-iterm-pet.test.ts new file mode 100644 index 0000000000..ca04fb92e6 --- /dev/null +++ b/packages/coding-agent/test/qa-iterm-pet.test.ts @@ -0,0 +1,335 @@ +import { describe, expect, it } from "bun:test"; +import { createHash } from "node:crypto"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +const runner = join(import.meta.dir, "../scripts/qa-iterm-pet.ts"); +const expectedSha = "a".repeat(40); +const versions = ["3.5.0", "3.6.11"]; +const modes = ["direct", "tmux"]; +const petIds = [ + "red-idle", + "red-working", + "red-burst", + "red-preview", + "blue-idle", + "blue-working", + "blue-burst", + "blue-preview", + "missing-f", + "invalid-f", + "probe-timeout", + "erase", +]; +const cjk: Record = { + "cjk-ko-composer-idle": ["저장하지 않은 변경 사항이 있습니다.", "Enter로 저장하거나", "Esc로 취소하세요."], + "cjk-ja-stream-working": ["未保存の変更があります。", "Enter で保存し、", "Esc でキャンセルします。"], + "cjk-zh-error-recovery": ["存在未保存的更改。", "按 Enter 保存,", "按 Esc 取消。"], + "cjk-mixed-preview-scroll": [ + "작업 상태: 준비 중입니다.", + "iTerm2 환경에서", + "출력 상태를 확인하세요.", + "Enter로 계속하고 Esc로 취소하세요.", + ], +}; +const deterministicCjkBody = (segments: string[], range: number[]): string => + Array.from({ length: range[1] - range[0] + 1 }, (_, index) => { + const line = range[0] + index; + return `${String(line).padStart(3, "0")}: ${segments[index % segments.length]}`; + }).join("\n"); +type Json = Record; +const asObject = (value: unknown): Json => { + if (typeof value !== "object" || value === null || Array.isArray(value)) throw Error("fixture object is missing"); + return value as Json; +}; +const firstObject = (value: unknown): Json => { + if (!Array.isArray(value) || value.length === 0) throw Error("fixture array is missing"); + return asObject(value[0]); +}; +const hash = (value: Uint8Array | string) => createHash("sha256").update(value).digest("hex"); +function fixture() { + const dir = mkdtempSync(join(process.env.TMPDIR ?? "/tmp", "pet-v2-")); + const refs: Json[] = []; + const put = (path: string, bytes: Buffer, format = "rgba8") => { + mkdirSync(join(dir, path, ".."), { recursive: true }); + writeFileSync(join(dir, path), bytes); + const sha256 = hash(bytes); + refs.push({ path, sha256, format }); + return sha256; + }; + const rasterByCapture = new Map(); + const captures: Json[] = []; + for (const version of versions) + for (const mode of modes) { + const key = `${version}/${mode}`; + const expected = put(`rasters/${version}-${mode}-expected.rgba`, Buffer.alloc(20 * 20 * 4)); + const after = put(`rasters/${version}-${mode}-after.rgba`, Buffer.alloc(20 * 20 * 4)); + const actual: string[] = []; + for (let n = 0; n < 8; n++) { + const bytes = Buffer.alloc(20 * 20 * 4); + bytes[2 * 20 * 4 + 2 * 4] = 220; + bytes[2 * 20 * 4 + 2 * 4 + 1] = n + 1; + bytes[2 * 20 * 4 + 2 * 4 + 3] = 255; + bytes[2 * 20 * 4 + 3 * 4] = 40; + bytes[2 * 20 * 4 + 3 * 4 + 1] = 220; + bytes[2 * 20 * 4 + 3 * 4 + 3] = 255; + actual.push(put(`rasters/${version}-${mode}-actual-${n}.rgba`, bytes)); + } + rasterByCapture.set(key, [expected, after, ...actual]); + const bundles: Json[] = []; + const bundle = (caseId: string, viewport: string, scroll: string, text: string, range?: number[]) => { + const base = `captures/${version}/${mode}/${caseId}/${viewport}/${scroll}`; + const metadata: Json = { + schemaVersion: 2, + caseId, + iTermVersion: version, + transport: mode, + viewport, + scroll, + expectedSha, + gitRevision: expectedSha, + classification: "fixture", + source: { kind: "fixture" }, + producer: "gjc-iterm-live-capture-v1", + toolVersion: "qa-fixture", + capturedAt: "2026-01-01T00:00:00Z", + commandOrReplay: "declared-fixture", + fontFamily: "Menlo", + fontSize: 12, + zoom: 1, + cellWidthPx: 8, + cellHeightPx: 16, + wrappingPolicy: "semantic-segment-boundaries", + truncationPolicy: "none", + linkedRasterIdentifiers: rasterByCapture.get(key), + ...(cjk[caseId] ? { semanticSegments: cjk[caseId] } : {}), + ...(caseId === "cjk-mixed-preview-scroll" ? { lineCount: 120, scrollRange: range } : {}), + ...(viewport === "40x12" ? { resizeFrom: "80x24" } : {}), + }; + const memberBytes: Array<[string, Buffer]> = [ + ["terminal.txt", Buffer.from(text)], + ["terminal-ansi.txt", Buffer.from(text)], + ["terminal.html", Buffer.from(`
${text}
`, "utf8")], + ["metadata.json", Buffer.from(`${JSON.stringify(metadata)}\n`)], + ]; + const members = memberBytes.map(([name, bytes]) => ({ + path: `${base}/${name}`, + sha256: put(`${base}/${name}`, bytes, name === "metadata.json" ? "metadata" : name), + size: bytes.length, + kind: name, + })); + bundles.push({ + caseId, + viewport, + scroll, + expectedSha, + gitRevision: expectedSha, + classification: "fixture", + source: { kind: "fixture" }, + members, + }); + }; + for (const id of [...petIds, ...(mode === "tmux" ? ["topology-ineligible"] : [])]) + bundle(id, "80x24", "top", `${id}: pet evidence`); + for (const id of Object.keys(cjk)) { + for (const viewport of ["80x24", "40x12"]) + bundle( + id, + viewport, + "top", + id === "cjk-mixed-preview-scroll" ? deterministicCjkBody(cjk[id], [1, 21]) : cjk[id].join(""), + id === "cjk-mixed-preview-scroll" ? [1, 21] : undefined, + ); + if (id === "cjk-mixed-preview-scroll") { + bundle(id, "80x24", "middle", deterministicCjkBody(cjk[id], [50, 70]), [50, 70]); + bundle(id, "80x24", "bottom", deterministicCjkBody(cjk[id], [100, 120]), [100, 120]); + } + } + const states = Object.fromEntries( + ["red", "blue"].map((skin, skinIndex) => [ + skin, + Object.fromEntries( + ["idle", "working", "burst", "preview"].map((state, stateIndex) => { + const hashValue = actual[skinIndex * 4 + stateIndex]; + const raster = { artifactSha256: hashValue, width: 20, height: 20 }; + return [ + state, + { + expected: { artifactSha256: expected, width: 20, height: 20 }, + actual: raster, + owned: { x: 2, y: 2, width: 2, height: 1 }, + erase: { before: raster, after: { artifactSha256: after, width: 20, height: 20 } }, + telemetryMs: 10, + }, + ]; + }), + ), + ]), + ); + captures.push({ + version, + mode, + expectedSha, + gitRevision: expectedSha, + classification: "fixture", + source: { kind: "fixture" }, + artifacts: refs.filter(ref => String(ref.path).startsWith(`rasters/${version}-${mode}-`)), + states, + bundles, + }); + } + return { + dir, + root: { + schemaVersion: 2, + expectedSha, + gitRevision: expectedSha, + classification: "fixture", + source: { kind: "fixture" }, + producer: "gjc-iterm-live-capture-v1", + provenance: "declared-fixture", + capturedAt: "2026-01-01T00:00:00Z", + captures, + }, + }; +} +function run(mutate?: (root: Json, dir: string) => void, sha = expectedSha) { + const fixtureValue = fixture(); + mutate?.(fixtureValue.root, fixtureValue.dir); + const input = join(fixtureValue.dir, "capture.json"); + writeFileSync(input, JSON.stringify(fixtureValue.root)); + const output = `${fixtureValue.dir}-published`; + const result = Bun.spawnSync([ + "bun", + runner, + "--versions", + versions.join(","), + "--modes", + modes.join(","), + "--expected-sha", + sha, + "--input", + input, + "--output", + output, + ]); + rmSync(fixtureValue.dir, { recursive: true, force: true }); + return result; +} +describe("iTerm Pet QA schema v2", () => { + it("publishes the complete declared fixture matrix", () => expect(run().exitCode).toBe(0)); + it("requires an explicit lowercase expected SHA", () => { + const result = fixture(); + try { + const input = join(result.dir, "capture.json"); + writeFileSync(input, JSON.stringify(result.root)); + expect( + Bun.spawnSync([ + "bun", + runner, + "--versions", + versions.join(","), + "--modes", + modes.join(","), + "--input", + input, + "--output", + join(result.dir, "out"), + ]).exitCode, + ).not.toBe(0); + } finally { + rmSync(result.dir, { recursive: true, force: true }); + } + }); + it("rejects an altered required member", () => + expect( + run((_root, dir) => + writeFileSync( + join(dir, "captures", "3.5.0", "direct", "red-idle", "80x24", "top", "terminal.txt"), + "altered", + ), + ).exitCode, + ).not.toBe(0)); + it("rejects a root/capture SHA mismatch", () => + expect(run(root => (firstObject(root.captures).expectedSha = "b".repeat(40))).exitCode).not.toBe(0)); + it("rejects a classification/source mismatch", () => + expect(run(root => (firstObject(root.captures).source = { kind: "live-pty" })).exitCode).not.toBe(0)); + it("rejects a CJK scroll range failure", () => + expect( + run((root, dir) => { + const capture = firstObject(root.captures); + const bundle = asObject( + (Array.isArray(capture.bundles) ? capture.bundles : []) + .map(asObject) + .find( + value => + typeof value.caseId === "string" && + value.caseId === "cjk-mixed-preview-scroll" && + value.scroll === "bottom", + ), + ); + const metadataMember = asObject( + (Array.isArray(bundle.members) ? bundle.members : []) + .map(asObject) + .find(value => typeof value.path === "string" && value.path.endsWith("metadata.json")), + ); + if (typeof metadataMember.path !== "string") throw Error("fixture metadata path is invalid"); + const metadataPath = join(dir, metadataMember.path); + const metadata = asObject(JSON.parse(readFileSync(metadataPath, "utf8"))); + metadata.scrollRange = [99, 120]; + const bytes = Buffer.from(`${JSON.stringify(metadata)}\n`); + writeFileSync(metadataPath, bytes); + metadataMember.sha256 = hash(bytes); + metadataMember.size = bytes.length; + }).exitCode, + ).not.toBe(0)); + it("rejects a recomputed digest for a short CJK scroll body", () => + expect( + run((_root, dir) => { + const capture = firstObject((_root as Json).captures); + const bundle = asObject( + (Array.isArray(capture.bundles) ? capture.bundles : []) + .map(asObject) + .find(value => value.caseId === "cjk-mixed-preview-scroll" && value.scroll === "bottom"), + ); + for (const name of ["terminal.txt", "terminal-ansi.txt"]) { + const member = asObject( + (Array.isArray(bundle.members) ? bundle.members : []) + .map(asObject) + .find(value => typeof value.path === "string" && value.path.endsWith(name)), + ); + if (typeof member.path !== "string") throw Error("fixture terminal path is invalid"); + const path = join(dir, member.path); + const short = readFileSync(path, "utf8").split("\n").slice(0, -1).join("\n"); + const bytes = Buffer.from(short); + writeFileSync(path, bytes); + member.sha256 = hash(bytes); + member.size = bytes.length; + } + }).exitCode, + ).not.toBe(0)); + it("rejects disagreeing mode and transport metadata", () => + expect( + run((_root, dir) => { + const capture = firstObject((_root as Json).captures); + const bundle = asObject( + (Array.isArray(capture.bundles) ? capture.bundles : []) + .map(asObject) + .find(value => value.caseId === "red-idle"), + ); + const member = asObject( + (Array.isArray(bundle.members) ? bundle.members : []) + .map(asObject) + .find(value => typeof value.path === "string" && value.path.endsWith("metadata.json")), + ); + if (typeof member.path !== "string") throw Error("fixture metadata path is invalid"); + const path = join(dir, member.path); + const metadata = asObject(JSON.parse(readFileSync(path, "utf8"))); + metadata.mode = "tmux"; + const bytes = Buffer.from(`${JSON.stringify(metadata)}\n`); + writeFileSync(path, bytes); + member.sha256 = hash(bytes); + member.size = bytes.length; + }).exitCode, + ).not.toBe(0)); +}); diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 164c4fe542..3fa8289675 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -10,6 +10,7 @@ - Restored the `isProcessTerminal`/`shouldUseViewportRepaintForHost` gate on the width-change viewport-repaint intercept so plain terminals (non-multiplexer, non-process-terminal) use `fullRender` for width changes instead of an unconditional viewport repaint. The #3684 chain removed this gate, causing lossless Korean/CJK prose wrapping to break at narrow widths because the viewport repaint only painted the visible rows without committing the full transcript to scrollback (#1979). - Restored the `fullRender` fallback for the `firstChanged < viewportTop` branch on non-viewport-repaint hosts, so above-viewport mutations replay the full frame instead of silently viewport-repainting. - Propagated IME cursor write failure from `#writeRenderBufferAndReanchorImeCursor` so callers detect terminal detach when the deferred cursor write fails after the shared frame commits. +- Added leased iTerm2 inline-GIF rendering support with protected renderer clipping, lifecycle cleanup, and cell-metric refresh on resize. ## [0.12.7] - 2026-07-31 diff --git a/packages/tui/artifacts/g015-qa-report.json b/packages/tui/artifacts/g015-qa-report.json index 74eb9f5d4e..a57bfa029f 100644 --- a/packages/tui/artifacts/g015-qa-report.json +++ b/packages/tui/artifacts/g015-qa-report.json @@ -32,8 +32,8 @@ "differentialGuardVisibleWidthCalls": 0 }, "writes": [ - "[2026-07-17T07:22:55.913Z] fullRender: terminal width changed (-1 -> 20) (prev=0, new=2, height=4)\n", - "[2026-07-17T07:22:55.941Z] fullRender: terminal width changed (-1 -> 20) (prev=0, new=2, height=4)\n" + "[2026-07-23T03:26:49.359Z] fullRender: terminal width changed (-1 -> 20) (prev=0, new=2, height=4)\n", + "[2026-07-23T03:26:49.388Z] fullRender: terminal width changed (-1 -> 20) (prev=0, new=2, height=4)\n" ] } }, @@ -246,8 +246,8 @@ "differentialGuardVisibleWidthCalls": 0 }, "writes": [ - "[2026-07-17T07:22:55.913Z] fullRender: terminal width changed (-1 -> 20) (prev=0, new=2, height=4)\n", - "[2026-07-17T07:22:55.941Z] fullRender: terminal width changed (-1 -> 20) (prev=0, new=2, height=4)\n" + "[2026-07-23T03:26:49.359Z] fullRender: terminal width changed (-1 -> 20) (prev=0, new=2, height=4)\n", + "[2026-07-23T03:26:49.388Z] fullRender: terminal width changed (-1 -> 20) (prev=0, new=2, height=4)\n" ] } }, diff --git a/packages/tui/src/components/gajae-pet.ts b/packages/tui/src/components/gajae-pet.ts index 5ae6fbdac7..b9d0433a01 100644 --- a/packages/tui/src/components/gajae-pet.ts +++ b/packages/tui/src/components/gajae-pet.ts @@ -1,3 +1,5 @@ +import { encodeITerm2Multipart, wrapITerm2RecordsForTmux } from "../terminal-capabilities"; + /** * ┌─ GAJAE PET SPRITE SPEC ────────────────────────────────────────────────┐ * The pet is a 16×16 pixel sprite drawn beside the composer. Everything here is @@ -203,7 +205,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], @@ -218,9 +221,13 @@ export const PARA_PARA_STEPS: ReadonlyArray; + intro: readonly GajaeGifFrameTuple[]; /** Frames cycled every `stepMs` for `ms` after the intro (a held or looping finish). */ - tail?: { frames: readonly GajaePixelFrameName[]; stepMs: number; ms: number }; + tail?: { + frames: readonly GajaePixelFrameName[]; + stepMs: number; + ms: number; + }; } /** Everything that defines a pet skin: identity, UI copy, colors and behavior. */ @@ -261,19 +268,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): GajaePixelFrameName { 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]; } @@ -284,6 +291,300 @@ export const __gajaePetTestHooks = { }, }; +export interface GajaeGifFrame { + readonly name: GajaePixelFrameName; + 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 = PIXEL_GRIDS[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[], @@ -447,7 +748,8 @@ export function buildGajaePixelFrames(options: { const topPaddingPx = allocatedHeightPx - visibleHeightPx + (options.protocol === "sixel" ? (options.sixelTopPaddingPx ?? 0) : 0); const heightPx = visibleHeightPx + topPaddingPx; - const rasterRows = Math.ceil(heightPx / options.cellHeightPx); + const kittyYOffsetPx = options.protocol === "kitty" ? Math.max(0, Math.round(options.kittyCellYOffsetPx ?? 0)) : 0; + const rasterRows = Math.ceil((heightPx + kittyYOffsetPx) / options.cellHeightPx); // 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); diff --git a/packages/tui/src/terminal-capabilities.ts b/packages/tui/src/terminal-capabilities.ts index d1e1535b60..b364165884 100644 --- a/packages/tui/src/terminal-capabilities.ts +++ b/packages/tui/src/terminal-capabilities.ts @@ -7,6 +7,8 @@ export enum ImageProtocol { Sixel = "\x1bPq", } +const ITERM2_MULTIPART_IMAGE_PREFIX = "\x1b]1337;MultipartFile="; + export enum NotifyProtocol { Bell = "\x07", Osc99 = "\x1b]99;;", @@ -31,6 +33,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); } @@ -854,3 +860,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 f5ab144494..9d9c99b06b 100644 --- a/packages/tui/src/terminal.ts +++ b/packages/tui/src/terminal.ts @@ -113,9 +113,15 @@ export interface Terminal { * @param idleMs - Exit early if no input arrives within this time (default: 50ms) */ drainInput(maxMs?: number, idleMs?: number): Promise; + /** + * Drain stale input without changing active keyboard-enhancement modes. + * Capability probes use this while the interactive terminal remains live. + */ + drainPendingInput?(maxMs?: number, idleMs?: number): Promise; // Write output to terminal write(data: string): void; + flush?(): Promise; // Whether terminal output is still writable get available(): boolean; @@ -768,6 +774,9 @@ export class ProcessTerminal implements Terminal { this.#modifyOtherKeysActive = false; } + await this.drainPendingInput(maxMs, idleMs); + } + async drainPendingInput(maxMs = 1000, idleMs = 50): Promise { const previousHandler = this.#inputHandler; this.#inputHandler = undefined; @@ -785,7 +794,7 @@ export class ProcessTerminal implements Terminal { const timeLeft = endTime - now; if (timeLeft <= 0) break; if (now - lastDataTime >= idleMs) break; - await new Promise(resolve => setTimeout(resolve, Math.min(idleMs, timeLeft))); + await Bun.sleep(Math.min(idleMs, timeLeft)); } } finally { process.stdin.removeListener("data", onData); @@ -900,6 +909,20 @@ export class ProcessTerminal implements Terminal { } } + async flush(): Promise { + if (this.#dead) return false; + const { promise, resolve } = Promise.withResolvers(); + try { + process.stdout.write("", () => { + resolve(!this.#dead); + }); + } catch (err) { + this.#markUnavailable(err, "flush"); + resolve(false); + } + return await promise; + } + #safeWrite(data: string): void { if (this.#dead) return; // Skip control sequences when stdout isn't a TTY (piped output, tests, log diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 5d466c241c..a3c2e794bb 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 @@ -888,6 +955,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; @@ -923,6 +991,32 @@ 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; #unsubscribeTabWidthChange?: () => void; static #renderCounters: TuiRenderCounterSnapshot = { @@ -1016,8 +1110,17 @@ 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, + }); + } get fullRedraws(): number { return this.#fullRedrawCount; @@ -1214,6 +1317,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; @@ -1238,6 +1363,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: @@ -1310,6 +1440,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 ?? @@ -1578,6 +1713,400 @@ export class TUI extends Container { for (const overlay of this.overlayStack) overlay.mouseBounds = undefined; } + acquireRasterLease(request: RasterLeaseRequest): Promise { + return this.#enqueueRaster(() => { + 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 () => { + const id = ++this.#rasterQueueId; + 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 && + !op.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 = () => { + 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.flush?.(); + if (flushed === false) { + abortBarrier(); + return failed(); + } + let afterPrefixSucceeded: boolean; + try { + afterPrefixSucceeded = await op.afterPrefix(); + } catch { + abortBarrier(); + return failed(); + } + if (afterPrefixSucceeded !== true) { + abortBarrier(); + return failed(); + } + const currentLease = this.#rasterLeases.get(request.token?.ownerId ?? ""); + if (!currentLease || currentLease.revoked || currentLease.token !== request.token) { + abortBarrier(); + return { queueId: id, operation: op.type, status: currentLease?.revoked ? "revoked" : "stale-token" }; + } + if (op.shouldWrite !== undefined && !op.shouldWrite()) { + abortBarrier(); + return { queueId: id, operation: op.type, status: "stale-token" }; + } + } + 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(() => { + const id = ++this.#rasterQueueId, + lease = this.#rasterLeases.get(request.token.ownerId); + 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(() => { + 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: () => T | Promise): Promise { + this.#rasterPending++; + const next: Promise = this.#rasterIngress.then(fn) 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; @@ -1588,11 +2117,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(); @@ -1654,7 +2198,31 @@ export class TUI extends Container { return !this.#terminalUnavailable && this.terminal.available; } + 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; @@ -1697,12 +2265,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) ?? this.terminal.drainInput(maxMs, quiescenceMs); + } removeInputListener(listener: InputListener): void { this.#inputListeners.delete(listener); @@ -1843,9 +2436,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 @@ -1854,7 +2452,7 @@ export class TUI extends Container { } stop(): void { - this.flushTerminalCleanup(); + this.#finalizeRasterLeases("terminal-loss"); const placementCleanup = this.#kittyPlacementDeletePlan(this.#kittyPlacementSpans, [], [], true).output; if (placementCleanup.length > 0 && this.#writeTerminal(placementCleanup)) this.#kittyPlacementSpans = []; this.#clearSixelProbeState(); @@ -2408,7 +3006,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; } @@ -3135,34 +3740,55 @@ export class TUI extends Container { { top: transcriptLineCount, bottom: transcriptLineCount + suffixLineCount }, ] : [{ top: nextViewportTop, bottom: nextViewportTop + height }]; - let buffer = `\x1b[?2026h${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"; @@ -3177,25 +3803,31 @@ export class TUI extends Container { buffer += cursorSeq; buffer += "\x1b[?2026l"; 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`; @@ -3993,6 +4625,14 @@ export class TUI extends Container { if (this.#writeCursorPosition(cursorPos, newLines.length)) this.#refreshPaintedLiveViewportObservation(height); return; } + 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; + } const nextLiveViewportTop = Math.max(0, newLines.length - height); if (newLines.length < this.#previousLines.length && nextLiveViewportTop !== prevViewportTop) { @@ -4195,6 +4835,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 }, @@ -4222,6 +4863,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 @@ -4257,12 +4908,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); @@ -4289,8 +4944,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 @@ -4350,25 +5015,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; @@ -4418,18 +5089,49 @@ 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(() => { + if (this.#writeTerminal(payload)) { + onDelivered?.(); + return; + } + this.#pendingTerminalCleanup.push({ payload, onDelivered }); + }); + } + + /** 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 queued terminal cleanup after terminal recovery or before shutdown. */ - flushTerminalCleanup(): void { + /** 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?.(); } @@ -4440,44 +5142,44 @@ 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 = `\x1b[?2026h\x1b7${overlay}\x1b8\x1b[?2026l`; - // 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 write = () => { + if (!writeIngress(buffer)) return false; + onBufferWritten?.(); + this.#lastRenderWriteSucceeded = true; + 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(write); + return true; } /** diff --git a/packages/tui/test/bench/gajae-pet-iterm-cache.bench.ts b/packages/tui/test/bench/gajae-pet-iterm-cache.bench.ts new file mode 100644 index 0000000000..17396ba917 --- /dev/null +++ b/packages/tui/test/bench/gajae-pet-iterm-cache.bench.ts @@ -0,0 +1,140 @@ +import type { GajaeGifTimeline, PetSkinId } from "@gajae-code/tui"; +import { + burstTimeline, + encodeGajaePetGif, + getGajaePetGifCached, + getGajaePetGifCacheStats, + idleTimeline, + previewTimeline, + resetGajaePetGifCache, + workingTimeline, +} from "@gajae-code/tui"; + +const MAX_ENTRIES = 32; +const MAX_RETAINED_BYTES = 8 * 1024 * 1024; +const iterations = Number(process.env.GAJAE_BENCH_ITERATIONS ?? 100); +if (!Number.isInteger(iterations) || iterations < 1) throw new Error("iterations must be a positive integer"); + +const modes: Array GajaeGifTimeline]> = [ + ["idle", idleTimeline], + ["working", workingTimeline], + ["burst", burstTimeline], + ["preview", previewTimeline], +]; +const metrics = [ + ["default", 9, 18, 2], + ["enlarged", 12, 24, 3], +] as const; +const matrix: Array> = []; +const cases: Array<{ options: Parameters[0] }> = []; +resetGajaePetGifCache(); + +for (const skin of ["red", "blue"] as const) { + for (const [mode, timeline] of modes) { + for (const [metric, cellWidthPx, cellHeightPx, targetRows] of metrics) { + const options = { + skin, + timeline: timeline(skin), + cellWidthPx, + cellHeightPx, + targetRows, + }; + const direct = encodeGajaePetGif(options); + const first = getGajaePetGifCached(options); + const second = getGajaePetGifCached(options); + if (first.base64 !== second.base64 || first.bytes.byteLength !== second.bytes.byteLength) { + throw new Error(`nondeterministic output: ${skin}/${mode}/${metric}`); + } + const directMultipartBytes = direct.multipart.reduce((sum, record) => sum + Buffer.byteLength(record), 0); + const directTmuxDcsBytes = direct.tmuxDcs.reduce((sum, record) => sum + Buffer.byteLength(record), 0); + matrix.push({ + skin, + mode, + metric, + direct: { + gifBytes: direct.bytes.byteLength, + multipartBytes: directMultipartBytes, + tmuxDcsBytes: directTmuxDcsBytes, + combinedBytes: direct.bytes.byteLength + directMultipartBytes + directTmuxDcsBytes, + }, + managed: { + gifBytes: first.bytes.byteLength, + multipartBytes: first.multipart.reduce((sum, record) => sum + Buffer.byteLength(record), 0), + tmuxDcsBytes: first.tmuxDcs.reduce((sum, record) => sum + Buffer.byteLength(record), 0), + combinedBytes: + first.bytes.byteLength + + first.multipart.reduce((sum, record) => sum + Buffer.byteLength(record), 0) + + first.tmuxDcs.reduce((sum, record) => sum + Buffer.byteLength(record), 0), + }, + }); + cases.push({ options }); + } + } +} + +const percentile = (samples: number[], rank: number): number => { + const sorted = [...samples].sort((a, b) => a - b); + const position = (sorted.length - 1) * rank; + const lower = Math.floor(position); + const upper = Math.ceil(position); + return lower === upper ? sorted[lower] : sorted[lower] + (sorted[upper] - sorted[lower]) * (position - lower); +}; +const coldSamples: number[] = []; +resetGajaePetGifCache(); +for (const { options } of cases) { + const started = performance.now(); + getGajaePetGifCached(options); + coldSamples.push(performance.now() - started); +} +const warmSamples: number[] = []; +for (let i = 0; i < iterations; i++) { + const started = performance.now(); + getGajaePetGifCached(cases[i % cases.length].options); + warmSamples.push(performance.now() - started); +} +const evictionCases = Array.from({ length: MAX_ENTRIES + 1 }, (_, index) => ({ + ...cases[index % cases.length].options, + cellWidthPx: 9 + index, +})); +resetGajaePetGifCache(); +for (const options of evictionCases) getGajaePetGifCached(options); +const stats = getGajaePetGifCacheStats(); +const evictedArtifact = getGajaePetGifCached(evictionCases[0]); +const reusedArtifact = getGajaePetGifCached(evictionCases[0]); +if (evictedArtifact !== reusedArtifact) throw new Error("cache reuse was not deterministic"); +const componentBytes = stats.gifBytes + stats.multipartBytes + stats.tmuxDcsBytes; +if ( + stats.size !== MAX_ENTRIES || + stats.bytes !== componentBytes || + stats.bytes > MAX_RETAINED_BYTES || + stats.evictions < 1 +) + throw new Error("cache capacity, combined retained bytes, or eviction was not exercised"); +const p50BuildMs = percentile(coldSamples, 0.5); +const p95BuildMs = percentile(coldSamples, 0.95); +const warmHitP50Ms = percentile(warmSamples, 0.5); +if (![p50BuildMs, p95BuildMs, warmHitP50Ms].every(value => Number.isFinite(value) && value > 0)) + throw new Error("invalid timing metrics"); +console.log( + JSON.stringify({ + matrix, + cache: { + combinedRetainedBytes: stats.bytes, + gifBytes: stats.gifBytes, + multipartBytes: stats.multipartBytes, + tmuxDcsBytes: stats.tmuxDcsBytes, + evictionCount: stats.evictions, + size: stats.size, + }, + p50BuildMs, + p95BuildMs, + coldBuildP50Ms: p50BuildMs, + coldBuildP95Ms: p95BuildMs, + warmHitP50Ms, + artifactBytes: matrix.reduce((n, m) => n + Number((m.direct as { combinedBytes: number }).combinedBytes), 0), + retainedBytes: stats.bytes, + evictionCount: stats.evictions, + maxEntries: MAX_ENTRIES, + maxRetainedBytes: MAX_RETAINED_BYTES, + }), +); diff --git a/packages/tui/test/cell-size-response.test.ts b/packages/tui/test/cell-size-response.test.ts new file mode 100644 index 0000000000..b0614a298c --- /dev/null +++ b/packages/tui/test/cell-size-response.test.ts @@ -0,0 +1,25 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { getCellDimensions, setCellDimensions } from "@gajae-code/tui/terminal-capabilities"; +import { TUI } from "@gajae-code/tui/tui"; +import { VirtualTerminal } from "./virtual-terminal"; + +describe("TUI terminal cell-size responses", () => { + const originalDimensions = { ...getCellDimensions() }; + + afterEach(() => { + setCellDimensions(originalDimensions); + }); + + it("consumes oversized CSI 6 metrics without allocating an unsafe raster", () => { + setCellDimensions({ widthPx: 8, heightPx: 16 }); + const terminal = new VirtualTerminal(); + const tui = new TUI(terminal); + tui.start(); + try { + terminal.sendInput("\x1b[6;65535;65535t"); + expect(getCellDimensions()).toEqual({ widthPx: 8, heightPx: 16 }); + } finally { + tui.stop(); + } + }); +}); diff --git a/packages/tui/test/g003-qa-report.test.ts b/packages/tui/test/g003-qa-report.test.ts index 242aaf2fa6..6054ae0ac5 100644 --- a/packages/tui/test/g003-qa-report.test.ts +++ b/packages/tui/test/g003-qa-report.test.ts @@ -1,10 +1,12 @@ import { afterAll, beforeEach, describe, expect, it, vi } from "bun:test"; -import { mkdirSync, writeFileSync } from "node:fs"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import { type Component, renderMetrics, TUI } from "@gajae-code/tui"; import { VirtualTerminal } from "./virtual-terminal"; const FLAG = "PI_TUI_VIRTUAL_VIEWPORT"; +const REPORT_PATH = join(mkdtempSync(join(tmpdir(), "g003-qa-")), "g003-qa-report.json"); const ROWS = 12; const OVERSCAN = 8; @@ -167,9 +169,8 @@ describe("G003 virtual viewport adversarial parity QA", () => { const passed = cases.filter(c => c.status === "passed").length; const failed = cases.filter(c => c.status === "failed").length; - mkdirSync("artifacts", { recursive: true }); writeFileSync( - join("artifacts", "g003-qa-report.json"), + REPORT_PATH, `${JSON.stringify({ schemaVersion: 1, kind: "tui-parity-test-report", cases, summary: { total: cases.length, passed, failed } }, null, 2)}\n`, ); }); diff --git a/packages/tui/test/g011-batched-natives-redteam.test.ts b/packages/tui/test/g011-batched-natives-redteam.test.ts index 7d6ac3ebd4..0850d72cae 100644 --- a/packages/tui/test/g011-batched-natives-redteam.test.ts +++ b/packages/tui/test/g011-batched-natives-redteam.test.ts @@ -1,4 +1,7 @@ import { afterEach, describe, expect, it } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { __textHelperPerfCounters, type Component, @@ -14,7 +17,7 @@ import { ImageProtocol, TERMINAL } from "@gajae-code/tui/terminal-capabilities"; import { getDefaultTabWidth, setDefaultTabWidth } from "@gajae-code/utils"; import { VirtualTerminal } from "./virtual-terminal"; -const REPORT_PATH = "artifacts/g011-qa-report.json"; +const REPORT_PATH = join(mkdtempSync(join(tmpdir(), "g011-qa-")), "g011-qa-report.json"); const SEGMENT_RESET = "\x1b[0m"; const LINE_TERMINATOR = "\x1b[0m\x1b]8;;\x1b\\"; @@ -315,7 +318,7 @@ describe("G011 batched text natives red-team", () => { }); }); - it("writes artifacts/g011-qa-report.json", async () => { + it("writes a temporary QA report", async () => { const blockers = cases .filter(entry => entry.verdict === "failed") .map(entry => ({ diff --git a/packages/tui/test/g014-editor-layout-cache-redteam.test.ts b/packages/tui/test/g014-editor-layout-cache-redteam.test.ts index 48c95b8860..517894d094 100644 --- a/packages/tui/test/g014-editor-layout-cache-redteam.test.ts +++ b/packages/tui/test/g014-editor-layout-cache-redteam.test.ts @@ -1,5 +1,7 @@ import { afterEach, describe, expect, it } from "bun:test"; -import { mkdirSync, writeFileSync } from "node:fs"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { stripVTControlCharacters } from "node:util"; import type { AutocompleteItem, AutocompleteProvider } from "@gajae-code/tui/autocomplete"; import { __editorPerfCounters, Editor } from "@gajae-code/tui/components/editor"; @@ -19,7 +21,7 @@ type CaseResult = { }; const WIDTH = 72; -const reportPath = "artifacts/g014-qa-report.json"; +const reportPath = join(mkdtempSync(join(tmpdir(), "g014-qa-")), "g014-qa-report.json"); const originalTabWidth = getDefaultTabWidth(); afterEach(() => { @@ -411,7 +413,6 @@ describe("G014 editor layout cache red-team", () => { artifactRefs: [{ id: "g014-qa-report", kind: "api-package-test-report", description: reportPath }], blockers, }; - mkdirSync("artifacts", { recursive: true }); writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`); expect(results.map(result => result.id)).toEqual([ "CURSOR-PARITY-FUZZ", diff --git a/packages/tui/test/g015-debug-width-redteam.test.ts b/packages/tui/test/g015-debug-width-redteam.test.ts index 822b74ab51..a4ab46182c 100644 --- a/packages/tui/test/g015-debug-width-redteam.test.ts +++ b/packages/tui/test/g015-debug-width-redteam.test.ts @@ -1,11 +1,13 @@ import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; import { type Component, TUI } from "@gajae-code/tui"; import { Ellipsis, truncateToWidth, visibleWidth } from "@gajae-code/tui/utils"; import { getDefaultTabWidth, setDefaultTabWidth } from "@gajae-code/utils"; import { VirtualTerminal } from "./virtual-terminal"; -const REPORT_PATH = "artifacts/g015-qa-report.json"; +const REPORT_PATH = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "g015-qa-")), "g015-qa-report.json"); const originalTabWidth = getDefaultTabWidth(); type CaseResult = { @@ -207,7 +209,6 @@ afterEach(() => { }); afterAll(async () => { - await fs.promises.mkdir("artifacts", { recursive: true }); await fs.promises.writeFile(REPORT_PATH, `${JSON.stringify(makeReport(), null, "\t")}\n`); }); diff --git a/packages/tui/test/gajae-pet.test.ts b/packages/tui/test/gajae-pet.test.ts index a14921690b..756224e3d9 100644 --- a/packages/tui/test/gajae-pet.test.ts +++ b/packages/tui/test/gajae-pet.test.ts @@ -1,5 +1,21 @@ import { describe, expect, it } from "bun:test"; -import { __gajaePetTestHooks, buildGajaePixelFrames, encodeGridSixel } from "@gajae-code/tui"; +import { + __gajaePetTestHooks, + buildGajaePixelFrames, + burstTimeline, + encodeGajaePetGif, + encodeGridSixel, + getGajaePetGifCached, + getGajaePetGifCacheStats, + idleTimeline, + PET_SKINS, + petBurstDurationMs, + petBurstFrame, + previewTimeline, + resetGajaePetGifCache, + workingTimeline, +} from "@gajae-code/tui"; +import { encodeITerm2Multipart, wrapITerm2RecordsForTmux } from "../src/terminal-capabilities"; describe("gajae pixel frames", () => { it("encodes bottom-aligned sixel frames with a transparent background", () => { @@ -69,3 +85,258 @@ describe("gajae pixel frames", () => { expect(sixel.startsWith('\x1bP0;1;0q"1;1;2;2')).toBe(true); }); }); +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/iterm2-protocol.test.ts b/packages/tui/test/iterm2-protocol.test.ts new file mode 100644 index 0000000000..0a00d8b2d2 --- /dev/null +++ b/packages/tui/test/iterm2-protocol.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from "bun:test"; +import { encodeGajaePetGif } from "@gajae-code/tui"; +import { + encodeITerm2Multipart, + ImageProtocol, + Iterm2CapabilitiesParser, + renderImage, + TERMINAL, + wrapITerm2RecordForTmux, +} from "@gajae-code/tui/terminal-capabilities"; + +type MutableTerminalInfo = { + imageProtocol: ImageProtocol | null; +}; + +const terminal = TERMINAL as unknown as MutableTerminalInfo; + +describe("iTerm2 multipart protocol", () => { + it("emits exact file, 200-character parts, and end records", () => { + const data = "ABCD".repeat(151); + const records = encodeITerm2Multipart(data, { width: 80, height: "auto" }); + expect(records[0]).toBe( + "\x1b]1337;MultipartFile=;name=Z2FqYWUtcGV0LmdpZg==;size=453;width=80;height=auto;inline=1;preserveAspectRatio=0:\x07", + ); + expect(records.at(-1)).toBe("\x1b]1337;FileEnd\x07"); + const parts = records.slice(1, -1).map(record => record.slice("\x1b]1337;FilePart=".length, -1)); + expect(parts.map(part => part.length)).toEqual([200, 200, 200, 4]); + expect(parts.join("")).toBe(data); + }); + it("uses explicit pixel units for generated Gajae GIF dimensions", () => { + const artifact = encodeGajaePetGif({ rectangle: { width: 7, height: 5 } }); + const header = artifact.multipart[0]; + expect(header).toContain(";width=7px;height=5px;"); + expect(header).not.toMatch(/;(?:width|height)=\d+(?:;|:)/u); + expect([artifact.width, artifact.height]).toEqual([7, 5]); + }); + it("can present a high-resolution GIF in terminal-cell units", () => { + const artifact = encodeGajaePetGif({ + rectangle: { width: 36, height: 36 }, + displaySize: { width: 4, height: 2 }, + }); + expect(artifact.multipart[0]).toContain(";width=4;height=2;"); + expect([artifact.width, artifact.height]).toEqual([36, 36]); + }); + it("recognizes ordinary renderImage sequences without text terminators", () => { + const previousProtocol = terminal.imageProtocol; + terminal.imageProtocol = ImageProtocol.Iterm2; + try { + const rendered = renderImage("AA==", { widthPx: 1, heightPx: 1 }); + expect(rendered).not.toBeNull(); + const sequence = rendered?.sequence ?? ""; + expect(sequence).toMatch(/^\x1b\]1337;File=/u); + expect(TERMINAL.isImageLine(sequence)).toBe(true); + expect(sequence.endsWith("\x07")).toBe(true); + expect(sequence.endsWith("\x1b[K")).toBe(false); + expect(TERMINAL.isImageLine("\x1b]1337;MultipartFile=;name=pet;size=1;width=1;height=1;inline=1:\x07")).toBe( + true, + ); + } finally { + terminal.imageProtocol = previousProtocol; + } + }); + it("wraps every complete multipart record independently under tmux byte limits", () => { + const records = encodeITerm2Multipart("A".repeat(400), { width: 1, height: 1 }); + const wrapped = records.map(wrapITerm2RecordForTmux); + expect(wrapped).toHaveLength(4); + expect(wrapped.every(record => Buffer.byteLength(record) <= 256)).toBe(true); + expect(wrapped.map(record => record.slice(0, 7))).toEqual([ + "\x1bPtmux;", + "\x1bPtmux;", + "\x1bPtmux;", + "\x1bPtmux;", + ]); + expect(wrapped.map(record => record.endsWith("\x1b\\"))).toEqual([true, true, true, true]); + expect(wrapped.map(record => record.includes("\x1b\x1b"))).toEqual([true, true, true, true]); + }); + + it("rejects malformed base64", () => { + expect(() => encodeITerm2Multipart("not base64!")).toThrow("Invalid RFC 4648 base64"); + expect(() => encodeITerm2Multipart("abc")).toThrow("Invalid RFC 4648 base64"); + }); + + it("doubles ESC for tmux and keeps wrapped records within the limit", () => { + const record = `\x1b]1337;FilePart=${"A".repeat(180)}\x07`; + const wrapped = wrapITerm2RecordForTmux(record); + expect(wrapped).toBe(`\x1bPtmux;${record.replaceAll("\x1b", "\x1b\x1b")}\x1b\\`); + expect(Buffer.byteLength(wrapped)).toBeLessThanOrEqual(256); + expect(() => wrapITerm2RecordForTmux("x".repeat(257))).toThrow("iTerm2 record exceeds tmux limit"); + expect(() => wrapITerm2RecordForTmux(`${"x".repeat(249)}\x1b`)).toThrow("iTerm2 record exceeds tmux limit"); + }); +}); + +describe("iTerm2 capability parser", () => { + it("coalesces incremental BEL and ST records, including Uint8Array fragments", () => { + const parser = new Iterm2CapabilitiesParser(); + expect(parser.push("noise\x1b]1337;Capabilities=foo=bar;baz=qux")).toEqual([]); + expect(parser.push("\x07\x1b]1337;Capabilities=one=1")).toEqual([ + { key: "foo", value: "bar" }, + { key: "baz", value: "qux" }, + ]); + expect(parser.push(new TextEncoder().encode("\x1b\\\x1b]1337;Capabilities=two=2\x07"))).toEqual([ + { key: "one", value: "1" }, + { key: "two", value: "2" }, + ]); + }); + + it("preserves standalone capability codes across fragmented records", () => { + const parser = new Iterm2CapabilitiesParser(); + expect(parser.push("\x1b]1337;Capabilities=fi")).toEqual([]); + expect(parser.push("le=1;F")).toEqual([]); + expect(parser.push("\x07")).toEqual([ + { key: "file", value: "1" }, + { key: "F", value: "" }, + ]); + }); + + it("ignores malformed pairs and recovers from oversize records", () => { + const parser = new Iterm2CapabilitiesParser(); + expect(parser.push("\x1b]1337;Capabilities=bad;=empty;ok=yes\x07")).toEqual([ + { key: "bad", value: "" }, + { key: "ok", value: "yes" }, + ]); + expect(parser.push(`\x1b]1337;Capabilities=${"x".repeat(4098)}\x07\x1b]1337;Capabilities=valid=yes\x07`)).toEqual( + [{ key: "valid", value: "yes" }], + ); + }); + + it("does not treat standalone ESC as an ST terminator", () => { + const parser = new Iterm2CapabilitiesParser(); + expect(parser.push("\x1b]1337;Capabilities=a=1\x1b")).toEqual([]); + expect(parser.push("\\")).toEqual([{ key: "a", value: "1" }]); + }); +}); diff --git a/packages/tui/test/raster-lease.test.ts b/packages/tui/test/raster-lease.test.ts new file mode 100644 index 0000000000..bd403027f6 --- /dev/null +++ b/packages/tui/test/raster-lease.test.ts @@ -0,0 +1,729 @@ +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); +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("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("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 VirtualTerminal & { 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 VirtualTerminal & { 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", "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[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"); + terminal.failNextWrites(); + 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; + terminal.failNextWrites(); + tui.queueTerminalCleanup("PET_CLEANUP", () => delivered++); + await Promise.resolve(); + await Promise.resolve(); + + expect(delivered).toBe(0); + expect(tui.terminalAvailable).toBe(false); + terminal.failNextWrites(); + 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"); + terminal.failNextWrites(); + 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"); + terminal.failNextWrites(); + 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; + terminal.failNextWrites(); + 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"); + } + terminal.failNextWrites(); + 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"); + terminal.failNextWrites(); + 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(); + }); +}); diff --git a/packages/tui/test/render-commit.test.ts b/packages/tui/test/render-commit.test.ts index 1134783b25..f9557b75a0 100644 --- a/packages/tui/test/render-commit.test.ts +++ b/packages/tui/test/render-commit.test.ts @@ -62,8 +62,12 @@ describe("generation-scoped render commits", () => { setTerminalImageProtocol(null); const terminal = new SecondWriteFailureTerminal(40, 8); const tui = new TUI(terminal, true); + let overlayWrites = 0; tui.addChild(new Text("overlay-frame", 1, 0)); - tui.setPostRenderEmitter(() => "\x1b[?25l"); + tui.setPostRenderEmitter(() => ({ + payload: "\x1b[?25l", + onWritten: () => overlayWrites++, + })); try { tui.start(); @@ -71,11 +75,27 @@ describe("generation-scoped render commits", () => { expect(await tui.waitForRenderCommit(generation)).toBe(true); expect(tui.terminalAvailable).toBe(false); + expect(overlayWrites).toBe(0); } finally { tui.stop(); setTerminalImageProtocol(previousImageProtocol); } }); + it("runs queued-output delivery callbacks at the terminal write boundary", async () => { + const terminal = new VirtualTerminal(40, 8); + const tui = new TUI(terminal); + const events: string[] = []; + tui.start(); + + await tui + .queueTerminalOutput("queued-overlay", { + onWritten: () => events.push("written"), + }) + .then(() => events.push("ack")); + + expect(events).toEqual(["written", "ack"]); + tui.stop(); + }); it("fails open immediately after the renderer is stopped", async () => { const terminal = new VirtualTerminal(40, 8); diff --git a/packages/tui/test/virtual-terminal.ts b/packages/tui/test/virtual-terminal.ts index 2a50540d11..efac25be35 100644 --- a/packages/tui/test/virtual-terminal.ts +++ b/packages/tui/test/virtual-terminal.ts @@ -13,6 +13,7 @@ export class VirtualTerminal implements Terminal { private inputHandler?: (data: string) => void; private resizeHandler?: () => void; #writeLog: string[] = []; + #failWrites = 0; private _columns: number; private _rows: number; @@ -57,8 +58,15 @@ export class VirtualTerminal implements Terminal { } write(data: string): void { + if (this.#failWrites > 0) { + this.#failWrites--; + throw new Error("injected terminal write failure"); + } this.#write(data); } + failNextWrites(count = 1): void { + this.#failWrites += count; + } getWriteLog(): string[] { return [...this.#writeLog]; @@ -138,11 +146,41 @@ export class VirtualTerminal implements Terminal { this.#write(active ? "\x1b]9;4;3\x07" : "\x1b]9;4;0;\x07"); } - /** Wait for TUI's throttled render pipeline to settle (matches the 16ms frame budget). */ + /** Wait until scheduled renders and terminal writes have become idle. */ async waitForRender(): Promise { - await new Promise(resolve => process.nextTick(resolve)); - await new Promise(resolve => setTimeout(resolve, 20)); - await this.flush(); + const baselineWrites = this.#writeLog.length; + let previousWrites = baselineWrites; + let stableTurns = 0; + let sawWrite = false; + const timeoutMs = 1000; + const renderIntervalMs = 16; + const quietWindowMs = renderIntervalMs * 2; + const startedAt = Date.now(); + const deadline = startedAt + timeoutMs; + + while (Date.now() < deadline) { + const nextTick = Promise.withResolvers(); + process.nextTick(nextTick.resolve); + await nextTick.promise; + const immediate = Promise.withResolvers(); + setImmediate(immediate.resolve); + await immediate.promise; + await this.flush(); + + const writes = this.#writeLog.length; + if (writes !== baselineWrites) sawWrite = true; + if (writes === previousWrites) stableTurns++; + else stableTurns = 0; + if (stableTurns >= 2 && (sawWrite || Date.now() - startedAt >= quietWindowMs)) { + return; + } + previousWrites = writes; + await Bun.sleep(renderIntervalMs); + } + + throw new Error( + `Timed out waiting for virtual terminal render: writes=${this.#writeLog.length}, baseline=${baselineWrites}, stableTurns=${stableTurns}`, + ); } // Test-specific methods not in Terminal interface @@ -171,11 +209,12 @@ export class VirtualTerminal implements Terminal { /** * Wait for all pending writes to complete. Viewport and scroll buffer will be updated. */ - async flush(): Promise { + async flush(): Promise { // Write an empty string to ensure all previous writes are flushed - return new Promise(resolve => { - this.xterm.write("", () => resolve()); - }); + const flushed = Promise.withResolvers(); + this.xterm.write("", flushed.resolve); + await flushed.promise; + return true; } /** From 41be53ef461d82316179f0b59d3dd897c52dc450 Mon Sep 17 00:00:00 2001 From: snowykr Date: Sun, 2 Aug 2026 01:38:30 +0900 Subject: [PATCH 02/11] fix(tui): preserve scrollback around raster leases A protected iTerm raster lease must yield before a transcript append scrolls. Queued render writes now settle their generation at the terminal write boundary. Tested: TUI raster lease, render commit, render regressions, overlay scroll, detach, Pet, and iTerm protocol suites Confidence: high Scope-risk: narrow --- packages/tui/src/tui.ts | 17 ++++++++-- packages/tui/test/raster-lease.test.ts | 14 ++++----- packages/tui/test/render-commit.test.ts | 41 +++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 10 deletions(-) diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index a3c2e794bb..810f77c830 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -911,6 +911,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; @@ -2682,7 +2684,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); }); @@ -2725,7 +2729,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) { @@ -2755,7 +2761,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); } @@ -4625,16 +4633,17 @@ export class TUI extends Container { if (this.#writeCursorPosition(cursorPos, newLines.length)) this.#refreshPaintedLiveViewportObservation(height); return; } + const nextLiveViewportTop = Math.max(0, newLines.length - height); + const nativeScrollbackAppend = appendedLines && nextLiveViewportTop > prevViewportTop; if ( this.#rasterLeases.size > 0 && this.#rasterCleanup.size === 0 && + !nativeScrollbackAppend && !newLines.slice(Math.max(0, newLines.length - height)).some(line => TERMINAL.isImageLine(line)) ) { viewportRepaint("changed frame with active raster lease"); return; } - - const nextLiveViewportTop = Math.max(0, newLines.length - height); if (newLines.length < this.#previousLines.length && nextLiveViewportTop !== prevViewportTop) { viewportRepaint(`content contraction changed viewport top (${prevViewportTop} -> ${nextLiveViewportTop})`); return; @@ -4868,6 +4877,7 @@ export class TUI extends Container { (moveTargetRow > prevViewportBottom || appendWillScroll || renderEnd > prevViewportBottom) && this.#rasterLeases.size > 0 && this.#rasterCleanup.size === 0 && + !nativeScrollbackAppend && !newLines.slice(Math.max(0, newLines.length - height)).some(line => TERMINAL.isImageLine(line)) ) { viewportRepaint("streaming append with active raster lease"); @@ -4913,6 +4923,7 @@ export class TUI extends Container { const preserveRasterLeases = this.#rasterLeases.size > 0 && this.#rasterCleanup.size === 0 && + !nativeScrollbackAppend && moveTargetRow <= prevViewportBottom && !newLines.slice(firstChanged, renderEnd + 1).some(line => TERMINAL.isImageLine(line)); for (let i = firstChanged; i <= renderEnd; i++) { @@ -5158,10 +5169,12 @@ export class TUI extends Container { 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; diff --git a/packages/tui/test/raster-lease.test.ts b/packages/tui/test/raster-lease.test.ts index bd403027f6..0ee386dab3 100644 --- a/packages/tui/test/raster-lease.test.ts +++ b/packages/tui/test/raster-lease.test.ts @@ -404,7 +404,7 @@ describe("TUI raster lease public boundary", () => { expect(calls).toBe(0); tui.stop(); }); - it("repaints streaming overflow around the lease without erasing or re-uploading it", async () => { + it("releases a raster lease before a streaming append enters native scrollback", async () => { const { tui, terminal } = await setup(); let lines = ["one", "two", "three", "four"]; let calls = 0; @@ -421,12 +421,10 @@ describe("TUI raster lease public boundary", () => { 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); + expect(output).toContain("ERASE"); + expect(output).toContain("\r\n"); + expect(terminal.getScrollBuffer().map(line => line.trim())).toContain("one"); + expect(calls).toBe(1); tui.stop(); }); it("repaints rewritten streaming output without scrolling an active raster", async () => { @@ -440,7 +438,7 @@ describe("TUI raster lease public boundary", () => { const lease = await tui.acquireRasterLease(request("pet", rect(8, 3, 2, 1))); expect(lease.status).toBe("acquired"); terminal.clearWriteLog(); - lines = ["ONE", "two", "three", "four", "five"]; + lines = ["ONE", "two", "three", "four"]; tui.requestRender(); await terminal.waitForRender(); diff --git a/packages/tui/test/render-commit.test.ts b/packages/tui/test/render-commit.test.ts index f9557b75a0..5c4a3e3aac 100644 --- a/packages/tui/test/render-commit.test.ts +++ b/packages/tui/test/render-commit.test.ts @@ -96,6 +96,47 @@ describe("generation-scoped render commits", () => { expect(events).toEqual(["written", "ack"]); tui.stop(); }); + it("commits a render generation after a queued raster write finishes", async () => { + const terminal = new VirtualTerminal(40, 8); + const tui = new TUI(terminal); + const releaseBarrier = Promise.withResolvers(); + const prefixEntered = Promise.withResolvers(); + const text = new Text("queued-raster-frame", 1, 0); + tui.addChild(text); + tui.start(); + await terminal.waitForRender(); + + const lease = await tui.acquireRasterLease({ + ownerId: "render-commit", + rect: { column: 38, row: 7, width: 2, height: 1 }, + erase: { type: "raster-erase", bytes: new TextEncoder().encode("ERASE") }, + }); + if (lease.status !== "acquired") throw new Error("expected lease"); + const raster = tui.submitTerminalOutput({ + token: lease.token, + operation: { + type: "raster-multipart-batch", + prefix: new TextEncoder().encode("PREFIX"), + afterPrefix: async () => { + prefixEntered.resolve(); + return releaseBarrier.promise; + }, + records: [new TextEncoder().encode("PAYLOAD")], + abortSuffix: new TextEncoder().encode("ABORT"), + }, + }); + await prefixEntered.promise; + text.setText("queued-raster-frame-updated"); + + const generation = tui.requestRenderWithGeneration(false, "test.queued-raster"); + const committed = tui.waitForRenderCommit(generation); + releaseBarrier.resolve(true); + + expect(await raster).toMatchObject({ status: "written" }); + expect(await committed).toBe(true); + expect(terminal.getWriteLog().join(" ")).toContain("queued-raster-frame"); + tui.stop(); + }); it("fails open immediately after the renderer is stopped", async () => { const terminal = new VirtualTerminal(40, 8); From 99259812c18e509ec40d725bbcd1c3953e27ea7a Mon Sep 17 00:00:00 2001 From: snowykr Date: Sun, 2 Aug 2026 01:44:34 +0900 Subject: [PATCH 03/11] fix(tui): erase raster leases before terminal stop A synchronous terminal stop could overtake queued lease cleanup. Flush active lease erases before terminal teardown. Tested: TUI raster lease and render commit suites Confidence: high Scope-risk: narrow --- packages/tui/src/tui.ts | 18 +++++++++++++++++- packages/tui/test/raster-lease.test.ts | 16 ++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 810f77c830..de42184b1c 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -1123,6 +1123,22 @@ export class TUI extends Container { 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; @@ -2454,7 +2470,7 @@ export class TUI extends Container { } stop(): void { - this.#finalizeRasterLeases("terminal-loss"); + this.#flushRasterLeasesBeforeStop("terminal-loss"); const placementCleanup = this.#kittyPlacementDeletePlan(this.#kittyPlacementSpans, [], [], true).output; if (placementCleanup.length > 0 && this.#writeTerminal(placementCleanup)) this.#kittyPlacementSpans = []; this.#clearSixelProbeState(); diff --git a/packages/tui/test/raster-lease.test.ts b/packages/tui/test/raster-lease.test.ts index 0ee386dab3..287e51d884 100644 --- a/packages/tui/test/raster-lease.test.ts +++ b/packages/tui/test/raster-lease.test.ts @@ -112,6 +112,22 @@ describe("TUI raster lease public boundary", () => { 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(); From e11b9a4a7328db84df68722ab4c818052c6e6184 Mon Sep 17 00:00:00 2001 From: snowykr Date: Sun, 2 Aug 2026 01:47:10 +0900 Subject: [PATCH 04/11] fix(tui): retain pet raster lease during streaming Releasing the lease on every scroll-producing append re-uploaded the Pet and caused visible animation flicker during active work. Keep the lease while the live viewport is repainted. Tested: raster lease, render commit, Pet, and iTerm protocol TUI suites Confidence: high Scope-risk: narrow --- packages/tui/src/tui.ts | 4 ---- packages/tui/test/raster-lease.test.ts | 12 +++++++----- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index de42184b1c..6c4f8b792c 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -4650,11 +4650,9 @@ export class TUI extends Container { return; } const nextLiveViewportTop = Math.max(0, newLines.length - height); - const nativeScrollbackAppend = appendedLines && nextLiveViewportTop > prevViewportTop; if ( this.#rasterLeases.size > 0 && this.#rasterCleanup.size === 0 && - !nativeScrollbackAppend && !newLines.slice(Math.max(0, newLines.length - height)).some(line => TERMINAL.isImageLine(line)) ) { viewportRepaint("changed frame with active raster lease"); @@ -4893,7 +4891,6 @@ export class TUI extends Container { (moveTargetRow > prevViewportBottom || appendWillScroll || renderEnd > prevViewportBottom) && this.#rasterLeases.size > 0 && this.#rasterCleanup.size === 0 && - !nativeScrollbackAppend && !newLines.slice(Math.max(0, newLines.length - height)).some(line => TERMINAL.isImageLine(line)) ) { viewportRepaint("streaming append with active raster lease"); @@ -4939,7 +4936,6 @@ export class TUI extends Container { const preserveRasterLeases = this.#rasterLeases.size > 0 && this.#rasterCleanup.size === 0 && - !nativeScrollbackAppend && moveTargetRow <= prevViewportBottom && !newLines.slice(firstChanged, renderEnd + 1).some(line => TERMINAL.isImageLine(line)); for (let i = firstChanged; i <= renderEnd; i++) { diff --git a/packages/tui/test/raster-lease.test.ts b/packages/tui/test/raster-lease.test.ts index 287e51d884..e02db6c74f 100644 --- a/packages/tui/test/raster-lease.test.ts +++ b/packages/tui/test/raster-lease.test.ts @@ -420,7 +420,7 @@ describe("TUI raster lease public boundary", () => { expect(calls).toBe(0); tui.stop(); }); - it("releases a raster lease before a streaming append enters native scrollback", async () => { + 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; @@ -437,10 +437,12 @@ describe("TUI raster lease public boundary", () => { await terminal.waitForRender(); const output = terminal.getWriteLog().join(""); - expect(output).toContain("ERASE"); - expect(output).toContain("\r\n"); - expect(terminal.getScrollBuffer().map(line => line.trim())).toContain("one"); - expect(calls).toBe(1); + 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 () => { From 0d21e25de5bb1e71c7938697ecc10c05e1257299 Mon Sep 17 00:00:00 2001 From: snowykr Date: Sun, 2 Aug 2026 01:49:39 +0900 Subject: [PATCH 05/11] test(coding-agent): use async Bun fixture I/O The Pet QA fixture test used synchronous named Node filesystem APIs. Use Bun content I/O and promised namespace directory APIs instead. Tested: qa-iterm-pet test and coding-agent check Confidence: high Scope-risk: narrow --- .../coding-agent/test/qa-iterm-pet.test.ts | 255 ++++++++++-------- 1 file changed, 139 insertions(+), 116 deletions(-) diff --git a/packages/coding-agent/test/qa-iterm-pet.test.ts b/packages/coding-agent/test/qa-iterm-pet.test.ts index ca04fb92e6..3fe7a8e092 100644 --- a/packages/coding-agent/test/qa-iterm-pet.test.ts +++ b/packages/coding-agent/test/qa-iterm-pet.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from "bun:test"; -import { createHash } from "node:crypto"; -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import * as crypto from "node:crypto"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; -const runner = join(import.meta.dir, "../scripts/qa-iterm-pet.ts"); +const runner = path.join(import.meta.dir, "../scripts/qa-iterm-pet.ts"); const expectedSha = "a".repeat(40); const versions = ["3.5.0", "3.6.11"]; const modes = ["direct", "tmux"]; @@ -46,15 +46,15 @@ const firstObject = (value: unknown): Json => { if (!Array.isArray(value) || value.length === 0) throw Error("fixture array is missing"); return asObject(value[0]); }; -const hash = (value: Uint8Array | string) => createHash("sha256").update(value).digest("hex"); -function fixture() { - const dir = mkdtempSync(join(process.env.TMPDIR ?? "/tmp", "pet-v2-")); +const hash = (value: Uint8Array | string) => crypto.createHash("sha256").update(value).digest("hex"); +async function fixture() { + const dir = await fs.mkdtemp(path.join(process.env.TMPDIR ?? "/tmp", "pet-v2-")); const refs: Json[] = []; - const put = (path: string, bytes: Buffer, format = "rgba8") => { - mkdirSync(join(dir, path, ".."), { recursive: true }); - writeFileSync(join(dir, path), bytes); + const put = async (relativePath: string, bytes: Buffer, format = "rgba8") => { + await fs.mkdir(path.join(dir, relativePath, ".."), { recursive: true }); + await Bun.write(path.join(dir, relativePath), bytes); const sha256 = hash(bytes); - refs.push({ path, sha256, format }); + refs.push({ path: relativePath, sha256, format }); return sha256; }; const rasterByCapture = new Map(); @@ -62,8 +62,8 @@ function fixture() { for (const version of versions) for (const mode of modes) { const key = `${version}/${mode}`; - const expected = put(`rasters/${version}-${mode}-expected.rgba`, Buffer.alloc(20 * 20 * 4)); - const after = put(`rasters/${version}-${mode}-after.rgba`, Buffer.alloc(20 * 20 * 4)); + const expected = await put(`rasters/${version}-${mode}-expected.rgba`, Buffer.alloc(20 * 20 * 4)); + const after = await put(`rasters/${version}-${mode}-after.rgba`, Buffer.alloc(20 * 20 * 4)); const actual: string[] = []; for (let n = 0; n < 8; n++) { const bytes = Buffer.alloc(20 * 20 * 4); @@ -73,11 +73,11 @@ function fixture() { bytes[2 * 20 * 4 + 3 * 4] = 40; bytes[2 * 20 * 4 + 3 * 4 + 1] = 220; bytes[2 * 20 * 4 + 3 * 4 + 3] = 255; - actual.push(put(`rasters/${version}-${mode}-actual-${n}.rgba`, bytes)); + actual.push(await put(`rasters/${version}-${mode}-actual-${n}.rgba`, bytes)); } rasterByCapture.set(key, [expected, after, ...actual]); const bundles: Json[] = []; - const bundle = (caseId: string, viewport: string, scroll: string, text: string, range?: number[]) => { + const bundle = async (caseId: string, viewport: string, scroll: string, text: string, range?: number[]) => { const base = `captures/${version}/${mode}/${caseId}/${viewport}/${scroll}`; const metadata: Json = { schemaVersion: 2, @@ -112,12 +112,15 @@ function fixture() { ["terminal.html", Buffer.from(`
${text}
`, "utf8")], ["metadata.json", Buffer.from(`${JSON.stringify(metadata)}\n`)], ]; - const members = memberBytes.map(([name, bytes]) => ({ - path: `${base}/${name}`, - sha256: put(`${base}/${name}`, bytes, name === "metadata.json" ? "metadata" : name), - size: bytes.length, - kind: name, - })); + const members: Json[] = []; + for (const [name, bytes] of memberBytes) { + members.push({ + path: `${base}/${name}`, + sha256: await put(`${base}/${name}`, bytes, name === "metadata.json" ? "metadata" : name), + size: bytes.length, + kind: name, + }); + } bundles.push({ caseId, viewport, @@ -130,10 +133,10 @@ function fixture() { }); }; for (const id of [...petIds, ...(mode === "tmux" ? ["topology-ineligible"] : [])]) - bundle(id, "80x24", "top", `${id}: pet evidence`); + await bundle(id, "80x24", "top", `${id}: pet evidence`); for (const id of Object.keys(cjk)) { for (const viewport of ["80x24", "40x12"]) - bundle( + await bundle( id, viewport, "top", @@ -141,8 +144,8 @@ function fixture() { id === "cjk-mixed-preview-scroll" ? [1, 21] : undefined, ); if (id === "cjk-mixed-preview-scroll") { - bundle(id, "80x24", "middle", deterministicCjkBody(cjk[id], [50, 70]), [50, 70]); - bundle(id, "80x24", "bottom", deterministicCjkBody(cjk[id], [100, 120]), [100, 120]); + await bundle(id, "80x24", "middle", deterministicCjkBody(cjk[id], [50, 70]), [50, 70]); + await bundle(id, "80x24", "bottom", deterministicCjkBody(cjk[id], [100, 120]), [100, 120]); } } const states = Object.fromEntries( @@ -193,11 +196,11 @@ function fixture() { }, }; } -function run(mutate?: (root: Json, dir: string) => void, sha = expectedSha) { - const fixtureValue = fixture(); - mutate?.(fixtureValue.root, fixtureValue.dir); - const input = join(fixtureValue.dir, "capture.json"); - writeFileSync(input, JSON.stringify(fixtureValue.root)); +async function run(mutate?: (root: Json, dir: string) => void | Promise, sha = expectedSha) { + const fixtureValue = await fixture(); + await mutate?.(fixtureValue.root, fixtureValue.dir); + const input = path.join(fixtureValue.dir, "capture.json"); + await Bun.write(input, JSON.stringify(fixtureValue.root)); const output = `${fixtureValue.dir}-published`; const result = Bun.spawnSync([ "bun", @@ -213,16 +216,16 @@ function run(mutate?: (root: Json, dir: string) => void, sha = expectedSha) { "--output", output, ]); - rmSync(fixtureValue.dir, { recursive: true, force: true }); + await fs.rm(fixtureValue.dir, { recursive: true, force: true }); return result; } describe("iTerm Pet QA schema v2", () => { - it("publishes the complete declared fixture matrix", () => expect(run().exitCode).toBe(0)); - it("requires an explicit lowercase expected SHA", () => { - const result = fixture(); + it("publishes the complete declared fixture matrix", async () => expect((await run()).exitCode).toBe(0)); + it("requires an explicit lowercase expected SHA", async () => { + const result = await fixture(); try { - const input = join(result.dir, "capture.json"); - writeFileSync(input, JSON.stringify(result.root)); + const input = path.join(result.dir, "capture.json"); + await Bun.write(input, JSON.stringify(result.root)); expect( Bun.spawnSync([ "bun", @@ -234,102 +237,122 @@ describe("iTerm Pet QA schema v2", () => { "--input", input, "--output", - join(result.dir, "out"), + path.join(result.dir, "out"), ]).exitCode, ).not.toBe(0); } finally { - rmSync(result.dir, { recursive: true, force: true }); + await fs.rm(result.dir, { recursive: true, force: true }); } }); - it("rejects an altered required member", () => + it("rejects an altered required member", async () => expect( - run((_root, dir) => - writeFileSync( - join(dir, "captures", "3.5.0", "direct", "red-idle", "80x24", "top", "terminal.txt"), - "altered", - ), + ( + await run(async (_root, dir) => { + await Bun.write( + path.join(dir, "captures", "3.5.0", "direct", "red-idle", "80x24", "top", "terminal.txt"), + "altered", + ); + }) ).exitCode, ).not.toBe(0)); - it("rejects a root/capture SHA mismatch", () => - expect(run(root => (firstObject(root.captures).expectedSha = "b".repeat(40))).exitCode).not.toBe(0)); - it("rejects a classification/source mismatch", () => - expect(run(root => (firstObject(root.captures).source = { kind: "live-pty" })).exitCode).not.toBe(0)); - it("rejects a CJK scroll range failure", () => + it("rejects a root/capture SHA mismatch", async () => expect( - run((root, dir) => { - const capture = firstObject(root.captures); - const bundle = asObject( - (Array.isArray(capture.bundles) ? capture.bundles : []) - .map(asObject) - .find( - value => - typeof value.caseId === "string" && - value.caseId === "cjk-mixed-preview-scroll" && - value.scroll === "bottom", - ), - ); - const metadataMember = asObject( - (Array.isArray(bundle.members) ? bundle.members : []) - .map(asObject) - .find(value => typeof value.path === "string" && value.path.endsWith("metadata.json")), - ); - if (typeof metadataMember.path !== "string") throw Error("fixture metadata path is invalid"); - const metadataPath = join(dir, metadataMember.path); - const metadata = asObject(JSON.parse(readFileSync(metadataPath, "utf8"))); - metadata.scrollRange = [99, 120]; - const bytes = Buffer.from(`${JSON.stringify(metadata)}\n`); - writeFileSync(metadataPath, bytes); - metadataMember.sha256 = hash(bytes); - metadataMember.size = bytes.length; - }).exitCode, + ( + await run(root => { + firstObject(root.captures).expectedSha = "b".repeat(40); + }) + ).exitCode, ).not.toBe(0)); - it("rejects a recomputed digest for a short CJK scroll body", () => + it("rejects a classification/source mismatch", async () => expect( - run((_root, dir) => { - const capture = firstObject((_root as Json).captures); - const bundle = asObject( - (Array.isArray(capture.bundles) ? capture.bundles : []) - .map(asObject) - .find(value => value.caseId === "cjk-mixed-preview-scroll" && value.scroll === "bottom"), - ); - for (const name of ["terminal.txt", "terminal-ansi.txt"]) { + ( + await run(root => { + firstObject(root.captures).source = { kind: "live-pty" }; + }) + ).exitCode, + ).not.toBe(0)); + it("rejects a CJK scroll range failure", async () => + expect( + ( + await run(async (root, dir) => { + const capture = firstObject(root.captures); + const bundle = asObject( + (Array.isArray(capture.bundles) ? capture.bundles : []) + .map(asObject) + .find( + value => + typeof value.caseId === "string" && + value.caseId === "cjk-mixed-preview-scroll" && + value.scroll === "bottom", + ), + ); + const metadataMember = asObject( + (Array.isArray(bundle.members) ? bundle.members : []) + .map(asObject) + .find(value => typeof value.path === "string" && value.path.endsWith("metadata.json")), + ); + if (typeof metadataMember.path !== "string") throw Error("fixture metadata path is invalid"); + const metadataPath = path.join(dir, metadataMember.path); + const metadata = asObject(JSON.parse(await Bun.file(metadataPath).text())); + metadata.scrollRange = [99, 120]; + const bytes = Buffer.from(`${JSON.stringify(metadata)}\n`); + await Bun.write(metadataPath, bytes); + metadataMember.sha256 = hash(bytes); + metadataMember.size = bytes.length; + }) + ).exitCode, + ).not.toBe(0)); + it("rejects a recomputed digest for a short CJK scroll body", async () => + expect( + ( + await run(async (_root, dir) => { + const capture = firstObject((_root as Json).captures); + const bundle = asObject( + (Array.isArray(capture.bundles) ? capture.bundles : []) + .map(asObject) + .find(value => value.caseId === "cjk-mixed-preview-scroll" && value.scroll === "bottom"), + ); + for (const name of ["terminal.txt", "terminal-ansi.txt"]) { + const member = asObject( + (Array.isArray(bundle.members) ? bundle.members : []) + .map(asObject) + .find(value => typeof value.path === "string" && value.path.endsWith(name)), + ); + if (typeof member.path !== "string") throw Error("fixture terminal path is invalid"); + const filePath = path.join(dir, member.path); + const short = (await Bun.file(filePath).text()).split("\n").slice(0, -1).join("\n"); + const bytes = Buffer.from(short); + await Bun.write(filePath, bytes); + member.sha256 = hash(bytes); + member.size = bytes.length; + } + }) + ).exitCode, + ).not.toBe(0)); + it("rejects disagreeing mode and transport metadata", async () => + expect( + ( + await run(async (_root, dir) => { + const capture = firstObject((_root as Json).captures); + const bundle = asObject( + (Array.isArray(capture.bundles) ? capture.bundles : []) + .map(asObject) + .find(value => value.caseId === "red-idle"), + ); const member = asObject( (Array.isArray(bundle.members) ? bundle.members : []) .map(asObject) - .find(value => typeof value.path === "string" && value.path.endsWith(name)), + .find(value => typeof value.path === "string" && value.path.endsWith("metadata.json")), ); - if (typeof member.path !== "string") throw Error("fixture terminal path is invalid"); - const path = join(dir, member.path); - const short = readFileSync(path, "utf8").split("\n").slice(0, -1).join("\n"); - const bytes = Buffer.from(short); - writeFileSync(path, bytes); + if (typeof member.path !== "string") throw Error("fixture metadata path is invalid"); + const filePath = path.join(dir, member.path); + const metadata = asObject(JSON.parse(await Bun.file(filePath).text())); + metadata.mode = "tmux"; + const bytes = Buffer.from(`${JSON.stringify(metadata)}\n`); + await Bun.write(filePath, bytes); member.sha256 = hash(bytes); member.size = bytes.length; - } - }).exitCode, - ).not.toBe(0)); - it("rejects disagreeing mode and transport metadata", () => - expect( - run((_root, dir) => { - const capture = firstObject((_root as Json).captures); - const bundle = asObject( - (Array.isArray(capture.bundles) ? capture.bundles : []) - .map(asObject) - .find(value => value.caseId === "red-idle"), - ); - const member = asObject( - (Array.isArray(bundle.members) ? bundle.members : []) - .map(asObject) - .find(value => typeof value.path === "string" && value.path.endsWith("metadata.json")), - ); - if (typeof member.path !== "string") throw Error("fixture metadata path is invalid"); - const path = join(dir, member.path); - const metadata = asObject(JSON.parse(readFileSync(path, "utf8"))); - metadata.mode = "tmux"; - const bytes = Buffer.from(`${JSON.stringify(metadata)}\n`); - writeFileSync(path, bytes); - member.sha256 = hash(bytes); - member.size = bytes.length; - }).exitCode, + }) + ).exitCode, ).not.toBe(0)); }); From 11b7a66332ce89291b509ccf337bdec0ae35ad61 Mon Sep 17 00:00:00 2001 From: snowykr Date: Sun, 2 Aug 2026 01:52:46 +0900 Subject: [PATCH 06/11] fix(coding-agent): preserve standalone Escape during probes An isolated Escape is user input, not a capability fragment. Keep multi-character OSC fragments buffered while forwarding standalone Escape. Tested: iTerm Pet transport test and coding-agent check Confidence: high Scope-risk: narrow --- .../coding-agent/src/modes/components/iterm-pet-transport.ts | 2 +- .../test/modes/components/iterm-pet-transport.test.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/src/modes/components/iterm-pet-transport.ts b/packages/coding-agent/src/modes/components/iterm-pet-transport.ts index b0ab3ee39e..339fb1238b 100644 --- a/packages/coding-agent/src/modes/components/iterm-pet-transport.ts +++ b/packages/coding-agent/src/modes/components/iterm-pet-transport.ts @@ -82,7 +82,7 @@ export function consumeCapabilityInput(callback: (data: Uint8Array | string) => if (start < 0) { const suffixLength = Math.min(marker.length - 1, combined.length - offset); const candidate = combined.slice(combined.length - suffixLength); - const keep = candidate && marker.startsWith(candidate) ? candidate : ""; + const keep = candidate.length > 1 && marker.startsWith(candidate) ? candidate : ""; passthrough += combined.slice(offset, combined.length - keep.length); fragment = keep; break; diff --git a/packages/coding-agent/test/modes/components/iterm-pet-transport.test.ts b/packages/coding-agent/test/modes/components/iterm-pet-transport.test.ts index cf567c15fa..52370f1e55 100644 --- a/packages/coding-agent/test/modes/components/iterm-pet-transport.test.ts +++ b/packages/coding-agent/test/modes/components/iterm-pet-transport.test.ts @@ -349,6 +349,7 @@ describe("iTerm Pet transport", () => { expect(split(ack)).toEqual({ consume: true }); expect(split("\x1b]1337;Cap")).toEqual({ consume: true }); expect(split("abilities=F\x07")).toEqual({ consume: true }); + expect(split("\x1b")).toEqual({ data: "\x1b" }); }); it("classifies completed replies as missing F only when syntax is valid", async () => { From 8ed5a9e40ecca1baa69f88f764d68f6b91b73010 Mon Sep 17 00:00:00 2001 From: snowykr Date: Sun, 2 Aug 2026 01:54:22 +0900 Subject: [PATCH 07/11] chore(tui): remove unrelated QA artifact changes The Pet PR does not need unrelated QA report path or timestamp changes. Restore the fork base versions to keep review scope bounded. Confidence: high Scope-risk: narrow --- .gitignore | 7 ------- packages/tui/artifacts/g015-qa-report.json | 8 ++++---- packages/tui/test/g003-qa-report.test.ts | 7 +++---- packages/tui/test/g011-batched-natives-redteam.test.ts | 7 ++----- .../tui/test/g014-editor-layout-cache-redteam.test.ts | 7 +++---- packages/tui/test/g015-debug-width-redteam.test.ts | 5 ++--- 6 files changed, 14 insertions(+), 27 deletions(-) diff --git a/.gitignore b/.gitignore index abb6ad3eb1..4deb6a4e40 100644 --- a/.gitignore +++ b/.gitignore @@ -86,10 +86,3 @@ packages/coding-agent/binaries/ # Python SDK build output python/gjc-sdk/build/ -/artifacts/g003-qa-report.json -/artifacts/g011-qa-report.json -/artifacts/g014-qa-report.json -/artifacts/g015-qa-report.json -/artifacts/ultragoal-g003-iterm-size-test-report.json -/artifacts/ultragoal-g003-quality-gate.json -/artifacts/ultragoal-g003-review-receipts.json diff --git a/packages/tui/artifacts/g015-qa-report.json b/packages/tui/artifacts/g015-qa-report.json index a57bfa029f..74eb9f5d4e 100644 --- a/packages/tui/artifacts/g015-qa-report.json +++ b/packages/tui/artifacts/g015-qa-report.json @@ -32,8 +32,8 @@ "differentialGuardVisibleWidthCalls": 0 }, "writes": [ - "[2026-07-23T03:26:49.359Z] fullRender: terminal width changed (-1 -> 20) (prev=0, new=2, height=4)\n", - "[2026-07-23T03:26:49.388Z] fullRender: terminal width changed (-1 -> 20) (prev=0, new=2, height=4)\n" + "[2026-07-17T07:22:55.913Z] fullRender: terminal width changed (-1 -> 20) (prev=0, new=2, height=4)\n", + "[2026-07-17T07:22:55.941Z] fullRender: terminal width changed (-1 -> 20) (prev=0, new=2, height=4)\n" ] } }, @@ -246,8 +246,8 @@ "differentialGuardVisibleWidthCalls": 0 }, "writes": [ - "[2026-07-23T03:26:49.359Z] fullRender: terminal width changed (-1 -> 20) (prev=0, new=2, height=4)\n", - "[2026-07-23T03:26:49.388Z] fullRender: terminal width changed (-1 -> 20) (prev=0, new=2, height=4)\n" + "[2026-07-17T07:22:55.913Z] fullRender: terminal width changed (-1 -> 20) (prev=0, new=2, height=4)\n", + "[2026-07-17T07:22:55.941Z] fullRender: terminal width changed (-1 -> 20) (prev=0, new=2, height=4)\n" ] } }, diff --git a/packages/tui/test/g003-qa-report.test.ts b/packages/tui/test/g003-qa-report.test.ts index 6054ae0ac5..242aaf2fa6 100644 --- a/packages/tui/test/g003-qa-report.test.ts +++ b/packages/tui/test/g003-qa-report.test.ts @@ -1,12 +1,10 @@ import { afterAll, beforeEach, describe, expect, it, vi } from "bun:test"; -import { mkdtempSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { type Component, renderMetrics, TUI } from "@gajae-code/tui"; import { VirtualTerminal } from "./virtual-terminal"; const FLAG = "PI_TUI_VIRTUAL_VIEWPORT"; -const REPORT_PATH = join(mkdtempSync(join(tmpdir(), "g003-qa-")), "g003-qa-report.json"); const ROWS = 12; const OVERSCAN = 8; @@ -169,8 +167,9 @@ describe("G003 virtual viewport adversarial parity QA", () => { const passed = cases.filter(c => c.status === "passed").length; const failed = cases.filter(c => c.status === "failed").length; + mkdirSync("artifacts", { recursive: true }); writeFileSync( - REPORT_PATH, + join("artifacts", "g003-qa-report.json"), `${JSON.stringify({ schemaVersion: 1, kind: "tui-parity-test-report", cases, summary: { total: cases.length, passed, failed } }, null, 2)}\n`, ); }); diff --git a/packages/tui/test/g011-batched-natives-redteam.test.ts b/packages/tui/test/g011-batched-natives-redteam.test.ts index 0850d72cae..7d6ac3ebd4 100644 --- a/packages/tui/test/g011-batched-natives-redteam.test.ts +++ b/packages/tui/test/g011-batched-natives-redteam.test.ts @@ -1,7 +1,4 @@ import { afterEach, describe, expect, it } from "bun:test"; -import { mkdtempSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; import { __textHelperPerfCounters, type Component, @@ -17,7 +14,7 @@ import { ImageProtocol, TERMINAL } from "@gajae-code/tui/terminal-capabilities"; import { getDefaultTabWidth, setDefaultTabWidth } from "@gajae-code/utils"; import { VirtualTerminal } from "./virtual-terminal"; -const REPORT_PATH = join(mkdtempSync(join(tmpdir(), "g011-qa-")), "g011-qa-report.json"); +const REPORT_PATH = "artifacts/g011-qa-report.json"; const SEGMENT_RESET = "\x1b[0m"; const LINE_TERMINATOR = "\x1b[0m\x1b]8;;\x1b\\"; @@ -318,7 +315,7 @@ describe("G011 batched text natives red-team", () => { }); }); - it("writes a temporary QA report", async () => { + it("writes artifacts/g011-qa-report.json", async () => { const blockers = cases .filter(entry => entry.verdict === "failed") .map(entry => ({ diff --git a/packages/tui/test/g014-editor-layout-cache-redteam.test.ts b/packages/tui/test/g014-editor-layout-cache-redteam.test.ts index 517894d094..48c95b8860 100644 --- a/packages/tui/test/g014-editor-layout-cache-redteam.test.ts +++ b/packages/tui/test/g014-editor-layout-cache-redteam.test.ts @@ -1,7 +1,5 @@ import { afterEach, describe, expect, it } from "bun:test"; -import { mkdtempSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { mkdirSync, writeFileSync } from "node:fs"; import { stripVTControlCharacters } from "node:util"; import type { AutocompleteItem, AutocompleteProvider } from "@gajae-code/tui/autocomplete"; import { __editorPerfCounters, Editor } from "@gajae-code/tui/components/editor"; @@ -21,7 +19,7 @@ type CaseResult = { }; const WIDTH = 72; -const reportPath = join(mkdtempSync(join(tmpdir(), "g014-qa-")), "g014-qa-report.json"); +const reportPath = "artifacts/g014-qa-report.json"; const originalTabWidth = getDefaultTabWidth(); afterEach(() => { @@ -413,6 +411,7 @@ describe("G014 editor layout cache red-team", () => { artifactRefs: [{ id: "g014-qa-report", kind: "api-package-test-report", description: reportPath }], blockers, }; + mkdirSync("artifacts", { recursive: true }); writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`); expect(results.map(result => result.id)).toEqual([ "CURSOR-PARITY-FUZZ", diff --git a/packages/tui/test/g015-debug-width-redteam.test.ts b/packages/tui/test/g015-debug-width-redteam.test.ts index a4ab46182c..822b74ab51 100644 --- a/packages/tui/test/g015-debug-width-redteam.test.ts +++ b/packages/tui/test/g015-debug-width-redteam.test.ts @@ -1,13 +1,11 @@ import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import * as fs from "node:fs"; -import * as os from "node:os"; -import * as path from "node:path"; import { type Component, TUI } from "@gajae-code/tui"; import { Ellipsis, truncateToWidth, visibleWidth } from "@gajae-code/tui/utils"; import { getDefaultTabWidth, setDefaultTabWidth } from "@gajae-code/utils"; import { VirtualTerminal } from "./virtual-terminal"; -const REPORT_PATH = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "g015-qa-")), "g015-qa-report.json"); +const REPORT_PATH = "artifacts/g015-qa-report.json"; const originalTabWidth = getDefaultTabWidth(); type CaseResult = { @@ -209,6 +207,7 @@ afterEach(() => { }); afterAll(async () => { + await fs.promises.mkdir("artifacts", { recursive: true }); await fs.promises.writeFile(REPORT_PATH, `${JSON.stringify(makeReport(), null, "\t")}\n`); }); From 02bd38b5d375b5891c96929d2bd2ec74033b3770 Mon Sep 17 00:00:00 2001 From: snowykr Date: Sun, 2 Aug 2026 02:00:44 +0900 Subject: [PATCH 08/11] test(tui): prove queued render commit ordering The raster barrier regression now proves the generation remains pending and the updated frame is written only after the barrier releases. Tested: render-commit test and TUI check Confidence: high Scope-risk: narrow --- packages/tui/test/render-commit.test.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/tui/test/render-commit.test.ts b/packages/tui/test/render-commit.test.ts index 5c4a3e3aac..a3d658e35c 100644 --- a/packages/tui/test/render-commit.test.ts +++ b/packages/tui/test/render-commit.test.ts @@ -105,6 +105,7 @@ describe("generation-scoped render commits", () => { tui.addChild(text); tui.start(); await terminal.waitForRender(); + terminal.clearWriteLog(); const lease = await tui.acquireRasterLease({ ownerId: "render-commit", @@ -129,12 +130,13 @@ describe("generation-scoped render commits", () => { text.setText("queued-raster-frame-updated"); const generation = tui.requestRenderWithGeneration(false, "test.queued-raster"); - const committed = tui.waitForRenderCommit(generation); - releaseBarrier.resolve(true); + expect(await tui.waitForRenderCommit(generation, 10)).toBe(false); + expect(terminal.getWriteLog().join(" ")).not.toContain("queued-raster-frame-updated"); + releaseBarrier.resolve(true); expect(await raster).toMatchObject({ status: "written" }); - expect(await committed).toBe(true); - expect(terminal.getWriteLog().join(" ")).toContain("queued-raster-frame"); + expect(await tui.waitForRenderCommit(generation)).toBe(true); + expect(terminal.getWriteLog().join(" ")).toContain("queued-raster-frame-updated"); tui.stop(); }); From 31fdce6d660e82a3ee34ea5f630dd0139c2f5738 Mon Sep 17 00:00:00 2001 From: snowykr Date: Sun, 2 Aug 2026 04:32:24 +0900 Subject: [PATCH 09/11] feat(tui): add fixed-suffix scroll region primitive Provide an opt-in, transport-neutral DECSTBM transaction for strict transcript appends with a bottom-pinned suffix.\n\nA current owner remains armed across eligible streaming appends; every transaction resets margins before returning control to the ordinary renderer.\n\nLore-id: 8da7f412\nConstraint: no alternate buffer or DECSCA\nConstraint: keep terminal state transaction-scoped\nTested: fixed-suffix and full TUI suite on primitive branch\nScope-risk: narrow\nReversibility: straightforward --- packages/tui/src/tui.ts | 143 ++++++++++++++++++ .../test/fixed-suffix-scroll-region.test.ts | 133 ++++++++++++++++ 2 files changed, 276 insertions(+) create mode 100644 packages/tui/test/fixed-suffix-scroll-region.test.ts diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 6c4f8b792c..7381e2978f 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -94,6 +94,7 @@ export type RasterLeaseAcquireResult = reason: "invalid-geometry" | "terminal-unavailable" | "owner-conflict" | "manual-viewport"; }>; +export type FixedSuffixScrollRegionToken = Readonly<{ ownerId: string; generation: number }>; const SEGMENT_RESET = "\x1b[0m"; /** * Per-line terminator written at the end of every non-image line. Closes both @@ -1019,6 +1020,10 @@ export class TUI extends Container { #rasterPending = 0; #pendingDependentGenericBytes: Array<{ bytes: Uint8Array; rect: CellRect; blockedBy: string[] }> = []; #terminalGeneration = 0; + #fixedSuffixScrollRegionGeneration = 0; + #fixedSuffixScrollRegionOwners = new Map(); + #armedFixedSuffixScrollRegionToken: FixedSuffixScrollRegionToken | undefined; + #fixedSuffixScrollRegionResetPending = false; #unsubscribeTabWidthChange?: () => void; static #renderCounters: TuiRenderCounterSnapshot = { @@ -1110,6 +1115,7 @@ export class TUI extends Container { } override dispose(): void { + this.#resetFixedSuffixScrollRegions(); this.#unsubscribeTabWidthChange?.(); this.#unsubscribeTabWidthChange = undefined; this.#finalizeRasterLeases("terminal-loss"); @@ -1256,9 +1262,60 @@ export class TUI extends Container { } setBottomPinnedComponent(component: Component | null): void { + if (this.#bottomPinnedComponent === component) return; + this.#resetFixedSuffixScrollRegions(); this.#bottomPinnedComponent = component; this.requestRender(); } + /** + * Acquire the exclusive owner token for a scoped DECSTBM append transaction. + * + * The region is established and reset in one terminal write; the token only + * authorizes a caller to submit that transaction while the layout is live. + */ + acquireFixedSuffixScrollRegion(ownerId: string): FixedSuffixScrollRegionToken | undefined { + if ( + typeof ownerId !== "string" || + ownerId.trim().length === 0 || + !this.terminalAvailable || + this.manualViewportActive || + this.#bottomPinnedComponent === null || + this.#fixedSuffixScrollRegionOwners.size > 0 + ) + return undefined; + const token = Object.freeze({ ownerId, generation: ++this.#fixedSuffixScrollRegionGeneration }); + this.#fixedSuffixScrollRegionOwners.set(ownerId, token); + return token; + } + + releaseFixedSuffixScrollRegion(token: FixedSuffixScrollRegionToken): void { + if (this.#fixedSuffixScrollRegionOwners.get(token.ownerId) !== token) return; + this.#fixedSuffixScrollRegionOwners.delete(token.ownerId); + if (this.#armedFixedSuffixScrollRegionToken === token) { + this.#armedFixedSuffixScrollRegionToken = undefined; + } + } + + /** + * Keep a current owner armed for eligible transcript appends. Every DECSTBM + * transaction still establishes and resets its own terminal state. + */ + armFixedSuffixScrollRegion(token: FixedSuffixScrollRegionToken): number | undefined { + if ( + this.#fixedSuffixScrollRegionOwners.get(token.ownerId) !== token || + !this.terminalAvailable || + this.manualViewportActive || + this.#bottomPinnedComponent === null + ) + return undefined; + this.#armedFixedSuffixScrollRegionToken = token; + return this.requestRenderWithGeneration(false, "fixed-suffix-scroll-region"); + } + + #resetFixedSuffixScrollRegions(): void { + this.#armedFixedSuffixScrollRegionToken = undefined; + this.#fixedSuffixScrollRegionOwners.clear(); + } /** Report the logical output producer revision without coupling TUI to message types. */ setViewportOutputSource(source: ViewportOutputSource | null): void { @@ -1302,6 +1359,7 @@ export class TUI extends Container { /** Clear manual viewport ownership and durable history before replacing the transcript identity. */ resetViewportAnchorIntent(): void { + this.#resetFixedSuffixScrollRegions(); this.#manualViewportTop = undefined; this.#manualViewportAnchor = null; this.#manualViewportFallbackAnchors = []; @@ -1414,6 +1472,7 @@ export class TUI extends Container { Math.abs(b.desiredScreenRow - this.#manualViewportAnchor!.desiredScreenRow), ); this.#manualViewportFallbackAnchors = fallbacks; + this.#resetFixedSuffixScrollRegions(); this.#manualViewportTop = this.#viewportTopRow; this.#reconcileMissingViewportAnchor = false; this.requestRender(); @@ -1531,6 +1590,7 @@ export class TUI extends Container { this.#manualViewportFallbackAnchors = fallbacks; } } + this.#resetFixedSuffixScrollRegions(); this.#manualViewportTop = targetViewportTop; let contentPainted = false; const painted = this.#repaintViewportFromLines( @@ -2135,6 +2195,7 @@ export class TUI extends Container { this.terminal.start( data => this.#handleInput(data), () => { + this.#resetFixedSuffixScrollRegions(); const hadRasterLease = this.#rasterLeases.size > 0; this.#revokeRasterLeases("resize"); if (TERMINAL.imageProtocol || hadRasterLease) this.#queryCellSize(true); @@ -2148,6 +2209,10 @@ export class TUI extends Container { }); }, ); + if (this.#fixedSuffixScrollRegionResetPending && this.#writeTerminal("\x1b[r\x1b[?6l")) { + this.#fixedSuffixScrollRegionResetPending = false; + } + this.flushTerminalCleanup(); if (this.#pendingTerminalCleanup.length > 0 || this.#rasterCleanup.size > 0) { void this.notifyTerminalLifecycle({ kind: "availability-restored", @@ -2238,6 +2303,7 @@ export class TUI extends Container { this.#rasterLeases.clear(); } #markTerminalUnavailable(settleRenderWaiters = true): void { + this.#resetFixedSuffixScrollRegions(); this.#terminalGeneration++; for (const record of this.#rasterCleanup.values()) record.terminalGeneration = this.#terminalGeneration; this.#revokeRasterLeases("terminal-loss"); @@ -2470,7 +2536,9 @@ export class TUI extends Container { } stop(): void { + this.#resetFixedSuffixScrollRegions(); this.#flushRasterLeasesBeforeStop("terminal-loss"); + this.flushTerminalCleanup(); const placementCleanup = this.#kittyPlacementDeletePlan(this.#kittyPlacementSpans, [], [], true).output; if (placementCleanup.length > 0 && this.#writeTerminal(placementCleanup)) this.#kittyPlacementSpans = []; this.#clearSixelProbeState(); @@ -2568,6 +2636,7 @@ export class TUI extends Container { * the last committed frame. */ requestResizeRender(): void { + this.#resetFixedSuffixScrollRegions(); // Width is tracked against the last OBSERVED terminal width, not against // #previousWidth (the last committed frame). Those diverge whenever resize // events coalesce inside one frame budget: a 100->90->100 burst would leave @@ -3918,6 +3987,7 @@ export class TUI extends Container { } #doRender(): void { + const fixedSuffixScrollRegionToken = this.#armedFixedSuffixScrollRegionToken; if (this.#stopped || !this.terminalAvailable) return; const transcriptIdentityReplaced = this.#transcriptIdentityReplaced; const restartViewportRepaintPending = this.#restartViewportRepaintPending; @@ -4070,6 +4140,8 @@ export class TUI extends Container { const previousLogicalFrame = this.#latestRenderedLines.slice(); const previousRawFrame = this.#latestRaw.slice(); const previousRenderedLength = previousLogicalFrame.length; + const previousTranscriptLineCount = this.#latestRenderedTranscriptLineCount; + const previousSuffixLineCount = this.#latestRenderedSuffixLineCount; this.#latestRenderedLines = newLines; this.#latestRenderedTranscriptLineCount = nextTranscriptLineCount; this.#latestRenderedSuffixLineCount = nextSuffixLineCount; @@ -4857,6 +4929,77 @@ export class TUI extends Container { } return; } + const fixedSuffixNativeAppend = + fixedSuffixScrollRegionToken !== undefined && + this.#fixedSuffixScrollRegionOwners.get(fixedSuffixScrollRegionToken.ownerId) === + fixedSuffixScrollRegionToken && + appendedLines && + hasStickySuffix && + !widthChanged && + !heightChanged && + !transcriptIdentityReplaced && + !restartViewportRepaintPending && + !resizeRenderMutationQueued && + !widthSettleRenderQueued && + !tabWidthRepairPending && + !forcedRenderQueued && + !anchorRenderFailed && + this.overlayStack.length === 0 && + previousKittyPlacementSpans.length === 0 && + nextKittyPlacementSpans.length === 0 && + this.#scrollbackResumeViewportTop === undefined && + previousSuffixLineCount > 0 && + previousSuffixLineCount === nextSuffixLineCount && + nextSuffixLineCount < height && + previousLogicalFrame.length === previousTranscriptLineCount + previousSuffixLineCount && + nextTranscriptLineCount > previousTranscriptLineCount && + firstChanged === previousTranscriptLineCount && + newLines.slice(0, previousTranscriptLineCount).every((line, index) => line === previousLogicalFrame[index]); + if (fixedSuffixNativeAppend) { + const regionBottom = height - nextSuffixLineCount; + let fixedSuffixBuffer = `\x1b[?2026h\x1b7\x1b[?6l\x1b[1;${regionBottom}r\x1b[${regionBottom};1H`; + for (let lineIndex = previousTranscriptLineCount; lineIndex < nextTranscriptLineCount; lineIndex += 1) { + fixedSuffixBuffer += `\x1bD\r\x1b[2K${this.#padLineToWidth(newLines[lineIndex]!, width)}`; + } + fixedSuffixBuffer += "\x1b[r\x1b[?6l"; + for (let suffixIndex = 0; suffixIndex < nextSuffixLineCount; suffixIndex += 1) { + const suffixRow = regionBottom + suffixIndex + 1; + const suffixLine = newLines[nextTranscriptLineCount + suffixIndex] ?? ""; + fixedSuffixBuffer += `\x1b[${suffixRow};1H\x1b[2K${this.#padLineToWidth(suffixLine, width)}`; + } + const transcriptDelta = nextTranscriptLineCount - previousTranscriptLineCount; + const restoredHardwareCursorRow = + this.#hardwareCursorRow >= previousTranscriptLineCount + ? this.#hardwareCursorRow + transcriptDelta + : this.#hardwareCursorRow; + const { seq, toRow } = this.#cursorControlSequence(cursorPos, newLines.length, restoredHardwareCursorRow); + fixedSuffixBuffer += `\x1b8${seq}\x1b[?2026l`; + this.#fixedSuffixScrollRegionResetPending = true; + if ( + !this.#writeRenderBufferAndReanchorImeCursor(fixedSuffixBuffer, cursorPos, newLines.length, () => { + this.#hardwareCursorRow = toRow; + this.#cursorRow = Math.max(0, newLines.length - 1); + this.#maxLinesRendered = newLines.length; + this.#viewportTopRow = Math.max(0, newLines.length - height); + this.#nativeScrollbackViewportTop = Math.max(this.#nativeScrollbackViewportTop, this.#viewportTopRow); + this.#previousLines = newLines; + this.#previousWidth = width; + this.#previousHeight = height; + this.#manualTranscriptLineCount = nextTranscriptLineCount; + this.#manualSuffixLineCount = nextSuffixLineCount; + this.#refreshPaintedLiveViewportObservation(height); + }) + ) + return; + this.#fixedSuffixScrollRegionResetPending = false; + this.#latestRenderedLines = newLines; + if (this.#virtualViewport) this.#latestRaw = rawLines; + this.#durableLineCount = Math.max(this.#durableLineCount, newLines.length); + this.#recordDurableLines(newLines, rawLines, previousTranscriptLineCount, newLines.length - 1); + this.#nativeScrollbackAdmissionPending = false; + this.#transcriptIdentityReplaced = false; + 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 diff --git a/packages/tui/test/fixed-suffix-scroll-region.test.ts b/packages/tui/test/fixed-suffix-scroll-region.test.ts new file mode 100644 index 0000000000..7b76be777d --- /dev/null +++ b/packages/tui/test/fixed-suffix-scroll-region.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from "bun:test"; +import { type Component, TUI } from "@gajae-code/tui"; +import { VirtualTerminal } from "./virtual-terminal"; + +class LinesComponent implements Component { + constructor(private lines: string[]) {} + + invalidate(): void {} + + render(_width: number): string[] { + return this.lines; + } + + setLines(lines: string[]): void { + this.lines = lines; + } +} + +function createPinnedTui( + rows = 5, + transcriptLines = ["line-1", "line-2", "line-3"], +): { term: VirtualTerminal; transcript: LinesComponent; tui: TUI } { + const term = new VirtualTerminal(40, rows); + const tui = new TUI(term); + const transcript = new LinesComponent(transcriptLines); + const suffix = new LinesComponent(["status", "composer"]); + tui.addChild(transcript); + tui.addChild(suffix); + tui.setBottomPinnedComponent(suffix); + return { term, transcript, tui }; +} + +describe("TUI fixed suffix scroll region", () => { + it("keeps a current owner armed across streaming transcript appends", async () => { + const { term, transcript, tui } = createPinnedTui(); + try { + tui.start(); + await term.waitForRender(); + const token = tui.acquireFixedSuffixScrollRegion("test-owner"); + expect(token).toBeDefined(); + if (token === undefined) throw new Error("Expected fixed suffix token"); + transcript.setLines(["line-1", "line-2", "line-3", "line-4"]); + term.clearWriteLog(); + expect(tui.armFixedSuffixScrollRegion(token)).toBeGreaterThan(0); + await term.waitForRender(); + + const output = term.getWriteLog().join(""); + expect(output).toContain("\x1b[?2026h\x1b7\x1b[?6l\x1b[1;3r\x1b[3;1H"); + expect(output).toContain("\x1bD\r\x1b[2Kline-4"); + expect(output).toContain("\x1b[r\x1b[?6l\x1b[4;1H\x1b[2Kstatus"); + expect(output).toContain("\x1b[5;1H\x1b[2Kcomposer"); + expect(output).toContain("\x1b8"); + expect(output).toContain("\x1b[?2026l"); + expect(output).not.toContain("\r\nline-4"); + await term.flush(); + expect(term.getViewport().map(line => line.trimEnd())).toEqual([ + "line-2", + "line-3", + "line-4", + "status", + "composer", + ]); + const scrollback = term.getScrollBuffer().map(line => line.trimEnd()); + expect(scrollback).toContain("line-1"); + expect(scrollback.filter(line => line === "line-1")).toHaveLength(1); + transcript.setLines(["line-1", "line-2", "line-3", "line-4", "line-5"]); + term.clearWriteLog(); + tui.requestRender(); + await term.waitForRender(); + const secondOutput = term.getWriteLog().join(""); + expect(secondOutput).toContain("\x1b[1;3r"); + expect(secondOutput).toContain("\x1bD\r\x1b[2Kline-5"); + await term.flush(); + const secondScrollback = term.getScrollBuffer().map(line => line.trimEnd()); + expect(secondScrollback.filter(line => line === "line-2")).toHaveLength(1); + } finally { + tui.stop(); + } + }); + + it("uses the existing renderer unless a current owner arms the fixed suffix region", async () => { + const { term, transcript, tui } = createPinnedTui(); + try { + tui.start(); + await term.waitForRender(); + transcript.setLines(["line-1", "line-2", "line-3", "line-4"]); + term.clearWriteLog(); + tui.requestRender(); + await term.waitForRender(); + expect(term.getWriteLog().join("")).not.toContain("\x1b[1;3r"); + } finally { + tui.stop(); + } + }); + + it("rejects resized and released owners without arming a transaction", async () => { + const { term, tui } = createPinnedTui(); + try { + tui.start(); + await term.waitForRender(); + const token = tui.acquireFixedSuffixScrollRegion("test-owner"); + expect(token).toBeDefined(); + if (token === undefined) throw new Error("Expected fixed suffix token"); + term.resize(40, 6); + expect(tui.armFixedSuffixScrollRegion(token)).toBeUndefined(); + + const current = tui.acquireFixedSuffixScrollRegion("test-owner"); + expect(current).toBeDefined(); + if (current === undefined) throw new Error("Expected refreshed fixed suffix token"); + tui.releaseFixedSuffixScrollRegion(current); + expect(tui.armFixedSuffixScrollRegion(current)).toBeUndefined(); + } finally { + tui.stop(); + } + }); + + it("does not acquire while manual history owns the viewport", async () => { + const { term, tui } = createPinnedTui(3, ["line-1", "line-2", "line-3"]); + try { + tui.start(); + await term.waitForRender(); + const token = tui.acquireFixedSuffixScrollRegion("test-owner"); + expect(token).toBeDefined(); + if (token === undefined) throw new Error("Expected fixed suffix token"); + expect(tui.scrollViewportBy(-1)).toBe(true); + expect(tui.manualViewportActive).toBe(true); + expect(tui.armFixedSuffixScrollRegion(token)).toBeUndefined(); + expect(tui.acquireFixedSuffixScrollRegion("test-owner")).toBeUndefined(); + } finally { + tui.stop(); + } + }); +}); From 1e1e010d03da52c440910d6bb50fad90da435404 Mon Sep 17 00:00:00 2001 From: snowykr Date: Sun, 2 Aug 2026 04:43:42 +0900 Subject: [PATCH 10/11] fix(tui): harden fixed suffix transactions Stopped TUI instances cannot retain suffix owners, cursor restoration follows native transcript scrolls, and the public primitive is released in the changelog. Lore-id: decstbm-fix-01 Constraint: preserve existing renderer when no owner is armed Tested: fixed-suffix scroll-region regression and TUI check Scope-risk: narrow --- packages/tui/CHANGELOG.md | 3 +++ packages/tui/src/tui.ts | 7 +++---- .../test/fixed-suffix-scroll-region.test.ts | 18 +++++++++++++++--- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 3fa8289675..a8ec9e672f 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -3,6 +3,9 @@ ## [Unreleased] ## [0.12.8] - 2026-08-02 +### Added + +- Added an opt-in fixed-suffix DECSTBM transaction API for terminal-native transcript scrollback while a bottom-pinned suffix remains stable. ### Fixed - Fixed a tool block being rendered two or three times in the transcript (a pending `⏳` copy stranded above its own completed `✓` copy, with the rows between duplicated). When a block above the live viewport top grows in place — the bash tool's compact call render becoming a partial box and then a final box, with the editor/status chrome keeping it off-screen — the "commit only the changed visible suffix" path emitted rows by index even though the growth had shifted committed content down across the native-scrollback frontier, so rows already in scrollback were appended a second time under their new content. The suffix commit is now taken only when the last committed row is unchanged (a same-length off-screen substitution, such as a streaming status line); growth that shifts the committed boundary repaints the live viewport instead. diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 7381e2978f..92b3843fd1 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -1278,6 +1278,7 @@ export class TUI extends Container { typeof ownerId !== "string" || ownerId.trim().length === 0 || !this.terminalAvailable || + this.#stopped || this.manualViewportActive || this.#bottomPinnedComponent === null || this.#fixedSuffixScrollRegionOwners.size > 0 @@ -1304,6 +1305,7 @@ export class TUI extends Container { if ( this.#fixedSuffixScrollRegionOwners.get(token.ownerId) !== token || !this.terminalAvailable || + this.#stopped || this.manualViewportActive || this.#bottomPinnedComponent === null ) @@ -4968,10 +4970,7 @@ export class TUI extends Container { fixedSuffixBuffer += `\x1b[${suffixRow};1H\x1b[2K${this.#padLineToWidth(suffixLine, width)}`; } const transcriptDelta = nextTranscriptLineCount - previousTranscriptLineCount; - const restoredHardwareCursorRow = - this.#hardwareCursorRow >= previousTranscriptLineCount - ? this.#hardwareCursorRow + transcriptDelta - : this.#hardwareCursorRow; + const restoredHardwareCursorRow = this.#hardwareCursorRow + transcriptDelta; const { seq, toRow } = this.#cursorControlSequence(cursorPos, newLines.length, restoredHardwareCursorRow); fixedSuffixBuffer += `\x1b8${seq}\x1b[?2026l`; this.#fixedSuffixScrollRegionResetPending = true; diff --git a/packages/tui/test/fixed-suffix-scroll-region.test.ts b/packages/tui/test/fixed-suffix-scroll-region.test.ts index 7b76be777d..62647da709 100644 --- a/packages/tui/test/fixed-suffix-scroll-region.test.ts +++ b/packages/tui/test/fixed-suffix-scroll-region.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { type Component, TUI } from "@gajae-code/tui"; +import { type Component, CURSOR_MARKER, TUI } from "@gajae-code/tui"; import { VirtualTerminal } from "./virtual-terminal"; class LinesComponent implements Component { @@ -18,7 +18,7 @@ class LinesComponent implements Component { function createPinnedTui( rows = 5, - transcriptLines = ["line-1", "line-2", "line-3"], + transcriptLines = ["line-1", `line-2${CURSOR_MARKER}`, "line-3"], ): { term: VirtualTerminal; transcript: LinesComponent; tui: TUI } { const term = new VirtualTerminal(40, rows); const tui = new TUI(term); @@ -39,7 +39,7 @@ describe("TUI fixed suffix scroll region", () => { const token = tui.acquireFixedSuffixScrollRegion("test-owner"); expect(token).toBeDefined(); if (token === undefined) throw new Error("Expected fixed suffix token"); - transcript.setLines(["line-1", "line-2", "line-3", "line-4"]); + transcript.setLines(["line-1", `line-2${CURSOR_MARKER}`, "line-3", "line-4"]); term.clearWriteLog(); expect(tui.armFixedSuffixScrollRegion(token)).toBeGreaterThan(0); await term.waitForRender(); @@ -51,6 +51,7 @@ describe("TUI fixed suffix scroll region", () => { expect(output).toContain("\x1b[5;1H\x1b[2Kcomposer"); expect(output).toContain("\x1b8"); expect(output).toContain("\x1b[?2026l"); + expect(output).toContain("\x1b[1A"); expect(output).not.toContain("\r\nline-4"); await term.flush(); expect(term.getViewport().map(line => line.trimEnd())).toEqual([ @@ -130,4 +131,15 @@ describe("TUI fixed suffix scroll region", () => { tui.stop(); } }); + it("does not acquire or arm a fixed suffix owner after stop", async () => { + const { term, tui } = createPinnedTui(); + tui.start(); + await term.waitForRender(); + const token = tui.acquireFixedSuffixScrollRegion("test-owner"); + expect(token).toBeDefined(); + if (token === undefined) throw new Error("Expected fixed suffix token"); + tui.stop(); + expect(tui.acquireFixedSuffixScrollRegion("new-owner")).toBeUndefined(); + expect(tui.armFixedSuffixScrollRegion(token)).toBeUndefined(); + }); }); From d3201e2bedfb91dc1b3a54b3276ec3fb37a4fe1d Mon Sep 17 00:00:00 2001 From: snowykr Date: Sun, 2 Aug 2026 15:27:42 +0900 Subject: [PATCH 11/11] fix(tui): reset terminal mode after fixed suffix DECRC can restore origin mode after the scroll-region transaction. Reset margins and DECOM before cursor reanchoring so the primitive leaves no terminal mode state behind.\n\nConstraint: keep fixed-suffix transaction transport-neutral\nTested: fixed-suffix scroll-region tests; TUI package check\nScope-risk: narrow\nReversibility: straightforward --- packages/tui/src/tui.ts | 2 +- packages/tui/test/fixed-suffix-scroll-region.test.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 92b3843fd1..ba34b4f0b8 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -4972,7 +4972,7 @@ export class TUI extends Container { const transcriptDelta = nextTranscriptLineCount - previousTranscriptLineCount; const restoredHardwareCursorRow = this.#hardwareCursorRow + transcriptDelta; const { seq, toRow } = this.#cursorControlSequence(cursorPos, newLines.length, restoredHardwareCursorRow); - fixedSuffixBuffer += `\x1b8${seq}\x1b[?2026l`; + fixedSuffixBuffer += `\x1b8\x1b[r\x1b[?6l${seq}\x1b[?2026l`; this.#fixedSuffixScrollRegionResetPending = true; if ( !this.#writeRenderBufferAndReanchorImeCursor(fixedSuffixBuffer, cursorPos, newLines.length, () => { diff --git a/packages/tui/test/fixed-suffix-scroll-region.test.ts b/packages/tui/test/fixed-suffix-scroll-region.test.ts index 62647da709..1cd453a1ba 100644 --- a/packages/tui/test/fixed-suffix-scroll-region.test.ts +++ b/packages/tui/test/fixed-suffix-scroll-region.test.ts @@ -50,6 +50,7 @@ describe("TUI fixed suffix scroll region", () => { expect(output).toContain("\x1b[r\x1b[?6l\x1b[4;1H\x1b[2Kstatus"); expect(output).toContain("\x1b[5;1H\x1b[2Kcomposer"); expect(output).toContain("\x1b8"); + expect(output).toContain("\x1b8\x1b[r\x1b[?6l"); expect(output).toContain("\x1b[?2026l"); expect(output).toContain("\x1b[1A"); expect(output).not.toContain("\r\nline-4");