Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
597eb76
feat(tui): add stable iTerm2 pet rendering
snowykr Aug 1, 2026
0251f42
feat(iterm): preserve Pet through native scrollback
snowykr Aug 1, 2026
4c54525
fix(iterm): preserve Pet cells during suffix repaint
snowykr Aug 1, 2026
edecaa0
docs(iterm): record native scrollback behavior
snowykr Aug 1, 2026
7541354
fix(iterm): retain native admission on lease fallback
snowykr Aug 1, 2026
87003ee
fix(iterm): preserve native admission before Pet arm
snowykr Aug 1, 2026
16a6f30
fix(tui): preserve raster cells in fixed suffix
snowykr Aug 2, 2026
c8739dd
fix(iterm): exclude Pet rows from scroll region
snowykr Aug 2, 2026
b6fae7c
fix(iterm): preserve lease across wrapped stream output
snowykr Aug 2, 2026
ad4f1fd
fix(tui): record rewritten fixed-suffix tail
snowykr Aug 2, 2026
0f8d9d9
fix(tui): serialize cursor moves behind raster uploads
snowykr Aug 2, 2026
e4a34b8
fix(iterm): retain GIF across work transitions
snowykr Aug 2, 2026
0d1b4c0
fix(iterm): keep pending GIF uploads across activity changes
snowykr Aug 2, 2026
d180529
fix(tui): retain raster lease through streamed reflow
snowykr Aug 2, 2026
0ecce8e
fix(iterm): rearm scroll ownership after history
snowykr Aug 2, 2026
f0d99f7
Revert "fix(tui): retain raster lease through streamed reflow"
snowykr Aug 2, 2026
5fcb12b
fix(tui): keep an armed iTerm scroll plane persistent
snowykr Aug 2, 2026
5272d17
fix(tui): reset fixed margins before generic output
snowykr Aug 2, 2026
d2d9560
fix(tui): stage manual scrollback before fixed admission
snowykr Aug 2, 2026
52c9573
fix(tui): limit fixed plane to eligible raster leases
snowykr Aug 2, 2026
e44d3de
fix(tui): retain lower cursor position in fixed plane
snowykr Aug 2, 2026
5d86025
fix(tui): retire fixed planes before generic rendering
snowykr Aug 2, 2026
a399e5c
fix(tui): reset retired fixed scroll margins
snowykr Aug 2, 2026
1ecbd22
fix(tui): admit rewritten fixed-plane rows
snowykr Aug 2, 2026
28b55fe
fix(tui): reset retired planes before queued output
snowykr Aug 2, 2026
0e39ec6
fix(tui): reset planes before every queued ingress
snowykr Aug 2, 2026
59a6cdb
fix(tui): reset margins before deferred renders
snowykr Aug 2, 2026
b7127d6
fix(pet): keep iTerm GIF on idle timeline
snowykr Aug 2, 2026
f1a2af1
fix(tui): preserve raster while iTerm upload arms
snowykr Aug 2, 2026
c6195fa
fix(pet): keep iTerm working loop visible
snowykr Aug 2, 2026
63703c6
fix(iterm): scope fixed-plane scroll margins
snowykr Aug 2, 2026
f9df35c
test(tui): cover shrinking fixed scroll planes
snowykr Aug 2, 2026
f8f6fa4
test(tui): cover fixed-plane multipart ordering
snowykr Aug 2, 2026
d68cd43
fix(iterm): retain lease across stream reflow
snowykr Aug 2, 2026
e6d0454
fix(iterm): keep resident Pet through renderer fallbacks
snowykr Aug 2, 2026
b8d4fa2
fix(tui): defer unsafe iTerm lease renders
snowykr Aug 2, 2026
2e671ac
fix(tui): preserve iTerm lease around image frames
snowykr Aug 2, 2026
f8c1737
test(tui): cover fixed plane rearm
snowykr Aug 2, 2026
4cdfc10
fix(tui): defer image viewport repaints under iTerm lease
snowykr Aug 2, 2026
c0991bb
test(tui): assert image frames bypass fixed plane
snowykr Aug 2, 2026
3a6fa63
fix(iterm): emit canonical multipart file records
snowykr Aug 2, 2026
795528d
test(iterm): verify multipart metadata terminator
snowykr Aug 2, 2026
f62dd89
fix(iterm): switch pet timeline at work boundaries
snowykr Aug 2, 2026
1883e1b
test(iterm): retain terminal escape parser coverage
snowykr Aug 2, 2026
ede469c
fix(iterm): restore prior plane between working GIF frames
snowykr Aug 2, 2026
127be49
test(iterm): prove delayed phase convergence
snowykr Aug 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
- Notification settings now expose first-class Telegram, Discord, and Slack configuration, desired-intent toggles, provider-local quarantine and repair guidance, explicit `keep | replace | remove` secret actions, provider-specific health/test diagnostics, and truthful saved-but-runtime-degraded outcomes. The global master preserves provider credentials and intent, `GJC_NOTIFICATIONS=0` suppresses only automatic generic-session admission, and blocked Telegram ownership uses an isolated chat-only endpoint so verified Discord or Slack siblings can continue without exposing the shared endpoint.

- Added capability-gated iTerm2 Pet GIF rendering with managed tmux transport, manual-history suspension, and lifecycle-safe raster cleanup.
- iTerm2 Pet rendering now keeps the composer and inline GIF stable while streaming transcript rows enter native terminal scrollback.
### Fixed

- Windows automatic tmux resolution now selects `psmux` then `pmux` by canonical command order without rejecting distinct lower-priority aliases; it probes `tmux` only when neither named provider is available (#3725).
Expand Down
95 changes: 66 additions & 29 deletions packages/coding-agent/src/modes/components/gajae-pet-widget.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import {
type AnimationRegistration,
buildGajaePixelFrames,
burstTimeline,
type CellRect,
type Component,
type Container,
type FixedSuffixScrollRegionToken,
type GajaePixelFrameName,
type GajaePixelFrames,
getCellDimensions,
Expand Down Expand Up @@ -187,6 +187,7 @@ export class GajaePetWidget {
/** Shared-emitter epoch from the last time this widget owned its TUI. */
#ownedOverlayEpoch = 0;
#itermLease: RasterLeaseToken | undefined;
#fixedSuffixScrollRegionToken: FixedSuffixScrollRegionToken | undefined;
#disposePromise: Promise<void> | undefined;
/** Raster invalidation must settle before disposeAsync starts lifecycle recovery. */
#disposeRasterBarrier: Promise<void> = Promise.resolve();
Expand Down Expand Up @@ -249,9 +250,9 @@ export class GajaePetWidget {
async suspendItermCapability(): Promise<void> {
if (!this.#isActiveOwner()) return;
this.#itermGeneration++;
this.#releaseFixedSuffixScrollRegion();
const lease = this.#itermLease;
this.#itermLease = undefined;

this.#itermLastSemantic = "";
if (lease) await this.#ui.invalidateRasterLease({ token: lease, cause: "capability-loss" });
this.#ui.requestRender(true);
Expand All @@ -277,6 +278,7 @@ export class GajaePetWidget {
if (mode === "off") {
if (!this.#canMutateSharedUi()) return;
this.#itermGeneration++;
this.#releaseFixedSuffixScrollRegion();
if (this.#itermLease) {
void this.#ui.invalidateRasterLease({ token: this.#itermLease, cause: "mode-off" });
this.#itermLease = undefined;
Expand All @@ -303,6 +305,7 @@ export class GajaePetWidget {
const predecessor = petOverlayEmitterOwners.get(this.#ui);
if (predecessor && predecessor !== this) predecessor.#retireForSuccessor();
this.#itermGeneration++;
this.#releaseFixedSuffixScrollRegion();
if (this.#itermLease) {
void this.#ui.invalidateRasterLease({ token: this.#itermLease, cause: "explicit" });
this.#itermLease = undefined;
Expand Down Expand Up @@ -390,6 +393,7 @@ export class GajaePetWidget {
this.#disposeNeedsLifecycle = canMutateSharedUi;
this.#disposed = true;
this.#itermGeneration++;
this.#releaseFixedSuffixScrollRegion();
const lease = this.#itermLease;
this.#itermLease = undefined;
if (lease)
Expand Down Expand Up @@ -439,6 +443,23 @@ export class GajaePetWidget {
}
}

#releaseFixedSuffixScrollRegion(): void {
const token = this.#fixedSuffixScrollRegionToken;
this.#fixedSuffixScrollRegionToken = undefined;
if (token) this.#ui.releaseFixedSuffixScrollRegion(token);
}
#armFixedSuffixScrollRegion(lease: RasterLeaseToken): void {
const existing = this.#fixedSuffixScrollRegionToken;
if (existing && !this.#ui.isFixedSuffixScrollRegionCurrent(existing)) {
this.#fixedSuffixScrollRegionToken = undefined;
}
if (this.#fixedSuffixScrollRegionToken || this.#itermLease !== lease) return;
const token = this.#ui.acquireFixedSuffixScrollRegion(this.#itermOwner);
if (!token) return;
this.#fixedSuffixScrollRegionToken = token;
if (this.#ui.armFixedSuffixScrollRegion(token, lease) === undefined) this.#releaseFixedSuffixScrollRegion();
}

#mountEditor(framed: boolean): void {
this.#editorContainer.clear();
this.#editorContainer.addChild(framed ? this.#framedEditor : this.#editor);
Expand Down Expand Up @@ -476,15 +497,19 @@ export class GajaePetWidget {
return "base";
}

#tickIterm(now: number): void {
if (!this.#isActiveOwner() || this.#ui.manualViewportActive) return;
#tickIterm(_now: number, working: boolean): void {
if (!this.#isActiveOwner() || this.#ui.manualViewportActive) {
this.#releaseFixedSuffixScrollRegion();
return;
}
const cell = getCellDimensions();
const pixelColumns = Math.max(1, Math.ceil((PET_ART_ROWS * cell.heightPx) / cell.widthPx));
const pixelRows = ITERM_CANVAS_ROWS;
let metricsChanged = false;
if (cell.widthPx !== this.#builtCellW || cell.heightPx !== this.#builtCellH) {
metricsChanged = true;
this.#itermGeneration++;
this.#releaseFixedSuffixScrollRegion();
const lease = this.#itermLease;
this.#itermLease = undefined;

Expand All @@ -499,6 +524,7 @@ export class GajaePetWidget {
if (!this.#framedEditor.canFit(this.#ui.terminal.columns)) {
if (!metricsChanged) {
this.#itermGeneration++;
this.#releaseFixedSuffixScrollRegion();
const lease = this.#itermLease;
this.#itermLease = undefined;

Expand All @@ -513,6 +539,7 @@ export class GajaePetWidget {
if (terminalRows < ITERM_CANVAS_ROWS + PET_RAISE_ROWS) {
if (!metricsChanged) {
this.#itermGeneration++;
this.#releaseFixedSuffixScrollRegion();
const lease = this.#itermLease;
this.#itermLease = undefined;

Expand All @@ -537,11 +564,20 @@ export class GajaePetWidget {
height: pixelRows,
};
const availability = getVerifiedItermPetAvailability();
if (!availability?.available || getItermPetUnavailableReason() || !this.#ui.terminalAvailable) return;
const working = this.#isWorking();
const flexing = this.#flexUntil > now;
const semantic = `${this.#mode}:${availability.mode}:${availability.epoch}:${working}:${flexing}:${rect.column},${rect.row}:${cell.widthPx},${cell.heightPx}:${this.#ui.terminal.columns},${this.#ui.terminal.rows}`;
if (this.#itermSubmitPending || (semantic === this.#itermLastSemantic && this.#itermLease)) return;
if (!availability?.available || getItermPetUnavailableReason() || !this.#ui.terminalAvailable) {
this.#releaseFixedSuffixScrollRegion();
return;
}
// iTerm replaces a resident GIF only through another MultipartFile transfer.
// Include the activity phase so it changes exactly once per work boundary;
// steady ticks retain the existing lease and image.
const animationPhase = working ? "working" : "idle";
const semantic = `${this.#mode}:${animationPhase}:${availability.mode}:${availability.epoch}:${rect.column},${rect.row}:${cell.widthPx},${cell.heightPx}:${this.#ui.terminal.columns},${this.#ui.terminal.rows}`;
if (this.#itermSubmitPending) return;
if (semantic === this.#itermLastSemantic && this.#itermLease) {
this.#armFixedSuffixScrollRegion(this.#itermLease);
return;
}
this.#itermLastSemantic = semantic;
this.#itermSubmitPending = true;
const generation = this.#itermGeneration;
Expand All @@ -551,8 +587,7 @@ export class GajaePetWidget {
availability.epoch,
availability.mode,
semantic,
working,
flexing,
animationPhase,
{
columns: this.#ui.terminal.columns,
rows: terminalRows,
Expand All @@ -571,14 +606,12 @@ export class GajaePetWidget {
epoch: number,
mode: "direct" | "managed",
semantic: string,
working: boolean,
flexing: boolean,
animationPhase: "idle" | "working",
geometry: Readonly<{ columns: number; rows: number; cellWidthPx: number; cellHeightPx: number }>,
composerBottomOffset: number,
): Promise<void> {
const current = () => {
const currentGeometry = () => {
const availability = getVerifiedItermPetAvailability();
const flexingNow = this.#flexUntil > performance.now();
const terminal = this.#ui.terminal;
const cell = getCellDimensions();
const liveComposerBottomOffset = this.#getComposerBottomOffset();
Expand All @@ -596,8 +629,6 @@ export class GajaePetWidget {
availability.epoch === epoch &&
availability.mode === mode &&
!this.#ui.manualViewportActive &&
this.#isWorking() === working &&
flexingNow === flexing &&
this.#framedEditor.canFit(terminal.columns) &&
terminal.columns === geometry.columns &&
terminal.rows === geometry.rows &&
Expand All @@ -618,13 +649,14 @@ export class GajaePetWidget {
token.rect.width !== rect.width ||
token.rect.height !== rect.height)
) {
this.#releaseFixedSuffixScrollRegion();
await this.#ui.invalidateRasterLease({ token, cause: "resize" });
if (this.#itermLease === token) {
this.#itermLease = undefined;
}
token = undefined;
}
if (!current()) return;
if (!currentGeometry()) return;
if (!token) {
const acquired = await this.#ui.acquireRasterLease({
ownerId: this.#itermOwner,
Expand All @@ -638,15 +670,17 @@ export class GajaePetWidget {
).join("")}`,
),
},
nativeScrollbackEligible: true,
onInvalidated: notice => {
if (this.#itermLease === notice.token) {
this.#releaseFixedSuffixScrollRegion();
this.#itermLease = undefined;

this.#itermLastSemantic = "";
}
},
});
if (!current() || acquired.status !== "acquired") {
if (!currentGeometry() || acquired.status !== "acquired") {
this.#releaseFixedSuffixScrollRegion();
if (acquired.status === "acquired")
await this.#ui.invalidateRasterLease({
token: acquired.token,
Expand All @@ -658,15 +692,15 @@ export class GajaePetWidget {
this.#itermLease = token;
}
this.#itermLastSemantic = semantic;
const frames = flexing
? burstTimeline(this.#mode === "off" ? "red" : this.#mode)
: working
? workingTimeline()
: idleTimeline();
const frames =
animationPhase === "working"
? [...workingTimeline(), { name: "base" as const, delayMs: 700 }]
: [...idleTimeline(), { name: "base" as const, delayMs: 700 }];
const cell = getCellDimensions();
const gif = getGajaePetGifCached({
skin: this.#mode === "off" ? "red" : this.#mode,
timeline: frames,
disposal: animationPhase === "working" ? "restore-previous" : "restore-background",
targetRows: PET_ART_ROWS,
rectangle: { width: rect.width * cell.widthPx, height: rect.height * cell.heightPx },
// Reserve a three-cell canvas but keep the two-cell sprite vertically centered:
Expand Down Expand Up @@ -698,23 +732,26 @@ export class GajaePetWidget {
),
afterPrefix:
mode === "managed"
? async () => (current() ? await this.#syncManagedItermCursor(rect.row, rect.column) : false)
? async () => (currentGeometry() ? await this.#syncManagedItermCursor(rect.row, rect.column) : false)
: undefined,
replayPrefix: mode === "managed" ? new TextEncoder().encode(cursorPosition) : undefined,
records: encodedRecords,
suffix: new TextEncoder().encode(cursorRestore),
abortSuffix: mode === "managed" ? new TextEncoder().encode(cursorRestore) : undefined,
restoreCursorVisibility: true,
shouldWrite: current,
shouldWrite: currentGeometry,
},
});
if (!current() || submit.status !== "written") {
if (!currentGeometry() || submit.status !== "written") {
this.#releaseFixedSuffixScrollRegion();
await this.#ui.invalidateRasterLease({ token, cause: "capability-loss" });
if (this.#itermLease === token) {
this.#itermLease = undefined;
}
return;
}
this.#releaseFixedSuffixScrollRegion();
this.#armFixedSuffixScrollRegion(token);
}
#scheduleAutoFlex(now: number): void {
if (!this.#autoFlexGapMs) return;
Expand Down Expand Up @@ -755,7 +792,7 @@ export class GajaePetWidget {
}
}
if (this.#itermProtocol) {
this.#tickIterm(now);
this.#tickIterm(now, working);
return;
}
if (this.#mode === "off" || !this.#pixel) return;
Expand Down
Loading