@gajae-code/coding-agent 0.10.1 → 0.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -248,6 +248,7 @@ export class CustomEditor extends Editor {
248
248
  const paste = this.#pasteHandler.process(data);
249
249
  if (paste.handled) {
250
250
  if (paste.pasteContent !== undefined) {
251
+ this.onViewportFollowLive?.();
251
252
  this.#handleBracketedPaste(paste.pasteContent, paste.remaining);
252
253
  }
253
254
  return;
@@ -6,7 +6,6 @@ import {
6
6
  type GajaePixelFrameName,
7
7
  type GajaePixelFrames,
8
8
  getCellDimensions,
9
- ImageProtocol,
10
9
  PARA_PARA_STEPS,
11
10
  PET_SKINS,
12
11
  type PetMode,
@@ -14,10 +13,10 @@ import {
14
13
  petBurstDurationMs,
15
14
  petBurstFrame,
16
15
  registerAnimationCallback,
17
- TERMINAL,
18
16
  type TUI,
19
17
  } from "@gajae-code/tui";
20
18
  import type { CustomEditor } from "./custom-editor";
19
+ import { getPetPixelProtocol } from "./pet-capability";
21
20
 
22
21
  /** Re-exported from the tui skin registry so widget-relative imports stay valid. */
23
22
  export type { PetMode, PetSkinId };
@@ -38,6 +37,35 @@ const KITTY_DROP_FRACTION = 0.45;
38
37
  const petKittyDropPx = (cellHeightPx: number): number =>
39
38
  Math.min(Math.max(0, cellHeightPx - 1), Math.floor(cellHeightPx * KITTY_DROP_FRACTION));
40
39
  const PET_RAISE_ROWS = 1;
40
+ const allocatedPetKittyImageIds = new Set<number>();
41
+
42
+ function allocatePetKittyImageId(): number {
43
+ let id = 0;
44
+ while (id === 0 || allocatedPetKittyImageIds.has(id)) {
45
+ id = crypto.getRandomValues(new Uint32Array(1))[0] ?? 0;
46
+ }
47
+ allocatedPetKittyImageIds.add(id);
48
+ return id;
49
+ }
50
+
51
+ interface SixelFootprint {
52
+ x: number;
53
+ y: number;
54
+ columns: number;
55
+ rows: number;
56
+ }
57
+
58
+ function sameFootprint(left: SixelFootprint, right: SixelFootprint): boolean {
59
+ return left.x === right.x && left.y === right.y && left.columns === right.columns && left.rows === right.rows;
60
+ }
61
+
62
+ /**
63
+ * Which widget currently owns each TUI's single shared post-render emitter
64
+ * slot. A stale or repeated dispose (or off-switch) of a predecessor widget
65
+ * must never clear a successor's overlay authority.
66
+ */
67
+ const petOverlayEmitterOwners = new WeakMap<TUI, GajaePetWidget>();
68
+
41
69
  /** Working animation: the shared para-para beats looped end to end. */
42
70
  const WORK_LOOP_TOTAL = PARA_PARA_STEPS.reduce((sum, [, ms]) => sum + ms, 0);
43
71
  /** Random gap between automatic claw flexes (fires while idle AND working). */
@@ -127,6 +155,20 @@ export class GajaePetWidget {
127
155
  /** Cell metrics the current frames were built for; a change triggers a rebuild. */
128
156
  #builtCellW = 0;
129
157
  #builtCellH = 0;
158
+ #kittyImageId: number | undefined;
159
+ /** True while a kitty placement may exist on screen; cleared only after the delete escape is delivered. */
160
+ #kittyCleanupPending = false;
161
+ /** Last emitted Sixel raster position; retained until an erase is actually delivered. */
162
+ #lastSixelFootprint: SixelFootprint | undefined;
163
+ /** Terminal state: a disposed widget never touches the TUI or shared slots again. */
164
+ #disposed = false;
165
+ /**
166
+ * True while the previous overlay frame carried the cleanup payload. The
167
+ * TUI writes the frame after the emitter returns, so delivery is
168
+ * acknowledged only on the next emitter pass — and only while the terminal
169
+ * stayed available, since a failed render write drops availability.
170
+ */
171
+ #frameCleanupAwaitingAck = false;
130
172
 
131
173
  constructor(options: {
132
174
  ui: TUI;
@@ -154,9 +196,7 @@ export class GajaePetWidget {
154
196
 
155
197
  /** Protocol available for the real-pixel pet, if any. */
156
198
  static pixelProtocol(): "sixel" | "kitty" | null {
157
- if (TERMINAL.imageProtocol === ImageProtocol.Kitty) return "kitty";
158
- if (TERMINAL.imageProtocol === ImageProtocol.Sixel) return "sixel";
159
- return null;
199
+ return getPetPixelProtocol();
160
200
  }
161
201
 
162
202
  get mode(): PetMode {
@@ -182,14 +222,19 @@ export class GajaePetWidget {
182
222
  }
183
223
  }
184
224
 
225
+ commitPreviewMode(mode: PetMode): void {
226
+ this.#applyMode(mode, false);
227
+ }
228
+
185
229
  #applyMode(mode: PetMode, mountComposer: boolean): void {
186
- if (mode === this.#mode) return;
230
+ if (this.#disposed || mode === this.#mode) return;
187
231
 
188
232
  if (mode === "off") {
233
+ this.#writeImageCleanup();
189
234
  this.#mode = "off";
190
235
  this.#animation?.unregister();
191
236
  this.#animation = undefined;
192
- this.#ui.setPostRenderEmitter(undefined);
237
+ this.#releaseOverlayEmitter();
193
238
  this.#floorContainer.clear();
194
239
  this.#pixel = undefined;
195
240
  this.#framedEditor.setReserve(0);
@@ -200,6 +245,7 @@ export class GajaePetWidget {
200
245
 
201
246
  const protocol = this.#forcedProtocol ?? GajaePetWidget.pixelProtocol();
202
247
  if (!protocol) return;
248
+ if (this.#mode !== "off") this.#writeImageCleanup();
203
249
  this.#mode = mode;
204
250
  this.#frame = "base";
205
251
  this.#flexUntil = 0;
@@ -210,6 +256,7 @@ export class GajaePetWidget {
210
256
  // the composer stays pinned to the terminal bottom.
211
257
  this.#floorContainer.clear();
212
258
  this.#ui.setPostRenderEmitter(() => this.#overlayPayload());
259
+ petOverlayEmitterOwners.set(this.#ui, this);
213
260
  this.#animation ??= registerAnimationCallback(now => this.#tick(now), 80);
214
261
  this.#ui.requestRender(true);
215
262
  }
@@ -220,6 +267,10 @@ export class GajaePetWidget {
220
267
  this.#builtCellW = cell.widthPx;
221
268
  this.#builtCellH = cell.heightPx;
222
269
  const skin: PetSkinId = this.#mode === "off" ? "red" : this.#mode;
270
+ if (protocol === "kitty") {
271
+ this.#kittyImageId ??= allocatePetKittyImageId();
272
+ this.#kittyCleanupPending = true;
273
+ }
223
274
  this.#pixel = buildGajaePixelFrames({
224
275
  protocol,
225
276
  skin,
@@ -228,17 +279,49 @@ export class GajaePetWidget {
228
279
  targetRows: 2,
229
280
  sixelTopPaddingPx: protocol === "sixel" ? PET_SIXEL_DROP_PX : 0,
230
281
  kittyCellYOffsetPx: protocol === "kitty" ? petKittyDropPx(cell.heightPx) : 0,
282
+ kittyImageId: protocol === "kitty" ? this.#kittyImageId : undefined,
231
283
  });
232
284
  this.#framedEditor.setReserve(this.#pixel.columns + PET_SIDE_MARGIN);
233
285
  }
234
286
 
235
287
  dispose(): void {
236
- this.#animation?.unregister();
237
- this.#animation = undefined;
238
- this.#ui.setPostRenderEmitter(undefined);
239
- this.#floorContainer.clear();
240
- this.#framedEditor.setReserve(0);
241
- this.#mountEditor(false);
288
+ if (this.#disposed) return;
289
+ this.#disposed = true;
290
+ const kittyImageId = this.#kittyImageId;
291
+ const cleanupPayload = this.#imageCleanupPayload();
292
+ try {
293
+ if (cleanupPayload) {
294
+ this.#ui.queueTerminalCleanup(
295
+ `\x1b[?2026h\x1b7${cleanupPayload}\x1b8\x1b[?2026l`,
296
+ kittyImageId === undefined ? undefined : () => allocatedPetKittyImageIds.delete(kittyImageId),
297
+ );
298
+ } else if (kittyImageId !== undefined) {
299
+ allocatedPetKittyImageIds.delete(kittyImageId);
300
+ }
301
+ this.#consumeCleanupAuthority();
302
+ this.#kittyImageId = undefined;
303
+ } finally {
304
+ this.#animation?.unregister();
305
+ this.#animation = undefined;
306
+ this.#releaseOverlayEmitter();
307
+ this.#mode = "off";
308
+ this.#pixel = undefined;
309
+ this.#floorContainer.clear();
310
+ this.#framedEditor.setReserve(0);
311
+ // Restore the plain composer only while our framed wrapper is still
312
+ // mounted; a successor widget may already own the editor container.
313
+ if (this.#editorContainer.children.includes(this.#framedEditor)) {
314
+ this.#mountEditor(false);
315
+ }
316
+ }
317
+ }
318
+
319
+ /** Clear the shared post-render slot only while this widget still owns it. */
320
+ #releaseOverlayEmitter(): void {
321
+ if (petOverlayEmitterOwners.get(this.#ui) === this) {
322
+ this.#ui.setPostRenderEmitter(undefined);
323
+ petOverlayEmitterOwners.delete(this.#ui);
324
+ }
242
325
  }
243
326
 
244
327
  #mountEditor(framed: boolean): void {
@@ -335,25 +418,91 @@ export class GajaePetWidget {
335
418
  return { x, y };
336
419
  }
337
420
 
338
- #clearPetPayload(x: number, y: number): string {
339
- const pixel = this.#pixel;
340
- if (pixel?.protocol !== "sixel") return "";
421
+ #clearSixelFootprint(footprint: SixelFootprint): string {
341
422
  let out = "\x1b[0m";
342
- for (let row = 0; row < pixel.rasterRows; row++) {
343
- out += `\x1b[${y + row + 1};${x + 1}H\x1b[${pixel.columns}X`;
423
+ for (let row = 0; row < footprint.rows; row++) {
424
+ out += `\x1b[${footprint.y + row + 1};${footprint.x + 1}H\x1b[${footprint.columns}X`;
425
+ }
426
+ return out;
427
+ }
428
+
429
+ /** Pending on-screen image cleanup. Pure: authority is consumed separately, on delivery. */
430
+ #imageCleanupPayload(): string {
431
+ let out = "";
432
+ if (this.#kittyCleanupPending && this.#kittyImageId !== undefined) {
433
+ out += `\x1b_Ga=d,d=I,i=${this.#kittyImageId},q=2\x1b\\`;
434
+ }
435
+ if (this.#lastSixelFootprint) {
436
+ out += this.#clearSixelFootprint(this.#lastSixelFootprint);
344
437
  }
345
438
  return out;
346
439
  }
347
440
 
441
+ #consumeCleanupAuthority(): void {
442
+ this.#kittyCleanupPending = false;
443
+ this.#lastSixelFootprint = undefined;
444
+ }
445
+
446
+ /**
447
+ * Best-effort direct erase of the on-screen pet image. Cleanup authority is
448
+ * consumed only after the write is actually delivered: an unavailable
449
+ * terminal or a throwing write keeps the erase pending so a later mode
450
+ * switch or dispose can retry it.
451
+ */
452
+ #writeImageCleanup(): void {
453
+ if (!this.#ui.terminalAvailable) return;
454
+ const payload = this.#imageCleanupPayload();
455
+ if (!payload) return;
456
+ try {
457
+ this.#ui.terminal.write(`\x1b[?2026h\x1b7${payload}\x1b8\x1b[?2026l`);
458
+ } catch {
459
+ // Keep the footprint/placement authority; the terminal write layer
460
+ // reports availability separately and callers retry on the next
461
+ // lifecycle transition.
462
+ return;
463
+ }
464
+ this.#consumeCleanupAuthority();
465
+ }
466
+
348
467
  /** Draw escape payload at the pet's absolute position. */
349
468
  #overlayPayload(clearPet = false): string | null {
350
469
  const pixel = this.#pixel;
351
470
  if (!pixel) return null;
352
471
  const pos = this.#petPosition();
353
- if (!pos) return null;
472
+ if (!pos) {
473
+ // Deferred delivery acknowledgement: the TUI writes the frame after
474
+ // this emitter returns, and that write can fail. Consume the cleanup
475
+ // authority only once a later pass observes the terminal survived
476
+ // the frame that carried the payload; otherwise retain it so a later
477
+ // lifecycle cleanup retries the erase/delete.
478
+ if (this.#frameCleanupAwaitingAck && this.#ui.terminalAvailable) {
479
+ this.#consumeCleanupAuthority();
480
+ }
481
+ this.#frameCleanupAwaitingAck = false;
482
+ if (!this.#ui.terminalAvailable) return null;
483
+ const cleanup = this.#imageCleanupPayload();
484
+ if (!cleanup) return null;
485
+ this.#frameCleanupAwaitingAck = true;
486
+ return cleanup;
487
+ }
488
+ // A full frame supersedes any cleanup-only frame still awaiting ack.
489
+ this.#frameCleanupAwaitingAck = false;
354
490
  const { x, y } = pos;
491
+ let out = "";
492
+
493
+ if (pixel.protocol === "sixel") {
494
+ const footprint = { x, y, columns: pixel.columns, rows: pixel.rasterRows };
495
+ if (this.#lastSixelFootprint && !sameFootprint(this.#lastSixelFootprint, footprint)) {
496
+ out += this.#clearSixelFootprint(this.#lastSixelFootprint);
497
+ }
498
+ if (clearPet) out += this.#clearSixelFootprint(footprint);
499
+ this.#lastSixelFootprint = footprint;
500
+ } else {
501
+ // A kitty frame emitted below (re)places the image, so cleanup is
502
+ // pending again even if a narrow-terminal pass consumed it earlier.
503
+ this.#kittyCleanupPending = true;
504
+ }
355
505
 
356
- let out = clearPet ? this.#clearPetPayload(x, y) : "";
357
506
  out += `\x1b[${y + 1};${x + 1}H${pixel.frames[this.#frame]}`;
358
507
  return out;
359
508
  }
@@ -0,0 +1,115 @@
1
+ import {
2
+ ImageProtocol,
3
+ isUnderTerminalMultiplexer,
4
+ onImageProtocolChanged,
5
+ type SelectItem,
6
+ shouldProbeSixelCapability,
7
+ TERMINAL,
8
+ } from "@gajae-code/tui";
9
+
10
+ export type PetPixelProtocol = "sixel" | "kitty";
11
+
12
+ export const PET_UNAVAILABLE_DESCRIPTION = "Unavailable: requires compatible Kitty or Sixel overlay rendering";
13
+ export const PET_SAVED_UNAVAILABLE_DESCRIPTION =
14
+ "Saved, unavailable — requires compatible Kitty or Sixel overlay rendering";
15
+ export const PET_UNAVAILABLE_WARNING =
16
+ "⚠ Pets aren’t available in this terminal. Its image support isn’t compatible with Gajae Pet’s overlay rendering yet. Try Kitty, Ghostty, WezTerm, or a terminal with compatible Sixel support.";
17
+ const PET_MULTIPLEXER_UNAVAILABLE_WARNING =
18
+ "⚠ Gajae Pet graphics are unavailable inside tmux, screen, or zellij because image escapes are not forwarded end to end. Run gjc outside the multiplexer, or set PI_FORCE_IMAGE_PROTOCOL=sixel only when the full terminal chain supports Sixel.";
19
+
20
+ export function getPetUnavailableWarning(env: NodeJS.ProcessEnv = Bun.env): string {
21
+ return isUnderTerminalMultiplexer(env) ? PET_MULTIPLEXER_UNAVAILABLE_WARNING : PET_UNAVAILABLE_WARNING;
22
+ }
23
+
24
+ export function getPetPixelProtocol(): PetPixelProtocol | null {
25
+ if (TERMINAL.imageProtocol === ImageProtocol.Kitty) return "kitty";
26
+ if (TERMINAL.imageProtocol === ImageProtocol.Sixel) return "sixel";
27
+ return null;
28
+ }
29
+
30
+ export function isPetAvailable(): boolean {
31
+ return getPetPixelProtocol() !== null;
32
+ }
33
+
34
+ export function createPetSelectItems(
35
+ options: ReadonlyArray<SelectItem>,
36
+ currentValue: string,
37
+ available: boolean,
38
+ ): SelectItem[] {
39
+ return options.map(option => {
40
+ const disabled = !available && option.value !== "off";
41
+ const current = option.value === currentValue;
42
+ const savedUnavailable = disabled && current;
43
+ let description = `${option.description ?? ""}${current ? " (current)" : ""}`;
44
+ if (disabled) {
45
+ description = savedUnavailable ? PET_SAVED_UNAVAILABLE_DESCRIPTION : PET_UNAVAILABLE_DESCRIPTION;
46
+ }
47
+ return {
48
+ ...option,
49
+ label: savedUnavailable ? `${option.label} (saved)` : option.label,
50
+ description,
51
+ disabled,
52
+ };
53
+ });
54
+ }
55
+
56
+ /**
57
+ * Grace period before declaring the terminal pet-incapable at startup. The
58
+ * asynchronous Sixel capability probe starts inside `TUI.start()` and answers
59
+ * within its own 250 ms deadline; this margin covers probe scheduling so a
60
+ * supported terminal is never told it is incompatible while the probe is
61
+ * still in flight.
62
+ */
63
+ export const PET_CAPABILITY_SETTLE_MS = 1_000;
64
+
65
+ /**
66
+ * Whether the asynchronous startup Sixel capability probe may still enable
67
+ * graphics for this session, meaning current unavailability is not final.
68
+ */
69
+ export function isPetCapabilityProbePending(
70
+ env: NodeJS.ProcessEnv = Bun.env,
71
+ platform: NodeJS.Platform = process.platform,
72
+ ): boolean {
73
+ if (TERMINAL.imageProtocol !== null) return false;
74
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return false;
75
+ return shouldProbeSixelCapability(env, platform);
76
+ }
77
+
78
+ /**
79
+ * Deliver the pet-unavailable startup warning only once the capability
80
+ * question is settled. With no probe pending, unavailability is final and
81
+ * `onUnavailable` fires immediately. While a probe may still enable
82
+ * graphics, the warning is deferred: a protocol-change event cancels it (the
83
+ * saved pet re-applies through the existing subscription), and only the
84
+ * settle deadline passing with the terminal still unavailable emits it.
85
+ * Returns a disposer that cancels the pending decision.
86
+ */
87
+ export function warnWhenPetCapabilitySettled(options: {
88
+ probePending: boolean;
89
+ isAvailable?: () => boolean;
90
+ onUnavailable: () => void;
91
+ settleMs?: number;
92
+ }): () => void {
93
+ if (!options.probePending) {
94
+ options.onUnavailable();
95
+ return () => {};
96
+ }
97
+ const isAvailable = options.isAvailable ?? isPetAvailable;
98
+ let settled = false;
99
+ const finish = () => {
100
+ if (settled) return;
101
+ settled = true;
102
+ clearTimeout(timer);
103
+ unsubscribe();
104
+ };
105
+ const unsubscribe = onImageProtocolChanged(protocol => {
106
+ if (!protocol) return;
107
+ finish();
108
+ });
109
+ const timer = setTimeout(() => {
110
+ finish();
111
+ if (!isAvailable()) options.onUnavailable();
112
+ }, options.settleMs ?? PET_CAPABILITY_SETTLE_MS);
113
+ timer.unref?.();
114
+ return finish;
115
+ }
@@ -1,7 +1,8 @@
1
- import { Container, PET_SKIN_IDS, PET_SKINS, type SelectItem, SelectList } from "@gajae-code/tui";
1
+ import { Container, PET_SKIN_IDS, PET_SKINS, SelectList } from "@gajae-code/tui";
2
2
  import { getSelectListTheme } from "../theme/theme";
3
3
  import { DynamicBorder } from "./dynamic-border";
4
4
  import type { PetMode } from "./gajae-pet-widget";
5
+ import { createPetSelectItems } from "./pet-capability";
5
6
 
6
7
  const PET_OPTIONS: { value: PetMode; label: string; description: string }[] = [
7
8
  { value: "off", label: "Off", description: "No pet" },
@@ -24,14 +25,11 @@ export class PetSelectorComponent extends Container {
24
25
  onSelect: (mode: PetMode) => void,
25
26
  onCancel: () => void,
26
27
  onPreview: (mode: PetMode) => void,
28
+ available: boolean,
27
29
  ) {
28
30
  super();
29
31
 
30
- const items: SelectItem[] = PET_OPTIONS.map(option => ({
31
- value: option.value,
32
- label: option.label,
33
- description: option.value === current ? `${option.description} (current)` : option.description,
34
- }));
32
+ const items = createPetSelectItems(PET_OPTIONS, current, available);
35
33
 
36
34
  this.addChild(new DynamicBorder());
37
35
 
@@ -25,6 +25,7 @@ import { getCurrentThemeName, getSelectListTheme, getSettingsListTheme, theme }
25
25
  import { matchesAppInterrupt } from "../../modes/utils/keybinding-matchers";
26
26
  import { getTabBarTheme } from "../shared";
27
27
  import { DynamicBorder } from "./dynamic-border";
28
+ import { createPetSelectItems, getPetUnavailableWarning, isPetAvailable } from "./pet-capability";
28
29
  import { handleInputOrEscape, PluginSettingsComponent } from "./plugin-settings";
29
30
  import { getSettingsForTab, type SettingDef } from "./settings-defs";
30
31
  import type { StatusLineSegmentOptions } from "./status-line";
@@ -661,6 +662,8 @@ export interface SettingsRuntimeContext {
661
662
  availableModelProfiles: string[];
662
663
  /** Working directory for plugins tab */
663
664
  cwd: string;
665
+ /** Whether this terminal can render the pet overlay. */
666
+ petAvailable?: boolean;
664
667
  }
665
668
 
666
669
  /** Status line settings subset for preview */
@@ -684,6 +687,13 @@ export interface SettingsCallbacks {
684
687
  onThemePreviewCancel?: (theme: string) => void | Promise<void>;
685
688
  /** Called to live-preview the gajae pet skin while browsing the pet setting. */
686
689
  onPetPreview?: (mode: string) => void;
690
+ /**
691
+ * Commit a pet mode through the shared result-returning policy. The policy
692
+ * rechecks capability immediately before mutation and owns persistence, so
693
+ * the settings surface never persists `pet.mode` ahead of acceptance.
694
+ * Returns whether the commit was accepted.
695
+ */
696
+ onPetCommit?: (mode: string) => boolean;
687
697
  /** Called for status line preview while configuring */
688
698
  onStatusLinePreview?: (settings: StatusLinePreviewSettings) => void;
689
699
  /** Get current rendered status line for inline preview */
@@ -841,7 +851,7 @@ export class SettingsSelectorComponent extends Container {
841
851
  currentValue: string,
842
852
  done: (value?: string) => void,
843
853
  ): Container {
844
- let options = def.options;
854
+ let options: ReadonlyArray<SelectItem> = def.options;
845
855
 
846
856
  // Special case: inject runtime options for thinking level
847
857
  if (def.path === "defaultThinkingLevel") {
@@ -857,6 +867,14 @@ export class SettingsSelectorComponent extends Container {
857
867
  if (def.path === "statusLine.preset") {
858
868
  options = options.filter(option => option.value !== "custom");
859
869
  }
870
+ let description = def.description;
871
+ if (def.path === "pet.mode") {
872
+ const petAvailable = this.context.petAvailable ?? isPetAvailable();
873
+ options = createPetSelectItems(options, currentValue, petAvailable);
874
+ // Unsupported terminals must see the same actionable guidance the
875
+ // startup notice and /pet show, not only dimmed option descriptions.
876
+ if (!petAvailable) description = getPetUnavailableWarning();
877
+ }
860
878
  // Preview handlers
861
879
  let onPreview: ((value: string) => void | Promise<void>) | undefined;
862
880
  let onPreviewCancel: (() => void) | undefined;
@@ -943,10 +961,18 @@ export class SettingsSelectorComponent extends Container {
943
961
 
944
962
  return new SelectSubmenu(
945
963
  def.label,
946
- def.description,
964
+ description,
947
965
  options,
948
966
  currentValue,
949
967
  value => {
968
+ if (def.path === "pet.mode") {
969
+ // The shared pet commit policy rechecks capability immediately
970
+ // before mutation and persists only on acceptance; the settings
971
+ // surface must not persist ahead of that result.
972
+ const accepted = this.callbacks.onPetCommit?.(value) ?? false;
973
+ done(accepted ? value : undefined);
974
+ return;
975
+ }
950
976
  this.#setSettingValue(def.path, value);
951
977
  this.callbacks.onChange(def.path, value);
952
978
  done(value);
@@ -50,6 +50,11 @@ export class ExtensionUiController {
50
50
  overlay?.hide();
51
51
  }
52
52
 
53
+ /** Re-mount the pet-aware composer after a transient hook UI closes. */
54
+ #restoreComposerEditor(): void {
55
+ this.ctx.restoreComposer();
56
+ }
57
+
53
58
  /**
54
59
  * Initialize the hook system with TUI-based UI context.
55
60
  */
@@ -723,8 +728,7 @@ export class ExtensionUiController {
723
728
  */
724
729
  hideHookSelector(): void {
725
730
  this.ctx.hookSelector?.dispose();
726
- this.ctx.editorContainer.clear();
727
- this.ctx.editorContainer.addChild(this.ctx.editor);
731
+ this.#restoreComposerEditor();
728
732
  this.ctx.hookSelector = undefined;
729
733
  this.ctx.ui.setFocus(this.ctx.editor);
730
734
  this.ctx.ui.requestRender();
@@ -785,8 +789,7 @@ export class ExtensionUiController {
785
789
  */
786
790
  hideHookInput(): void {
787
791
  this.ctx.hookInput?.dispose();
788
- this.ctx.editorContainer.clear();
789
- this.ctx.editorContainer.addChild(this.ctx.editor);
792
+ this.#restoreComposerEditor();
790
793
  this.ctx.hookInput = undefined;
791
794
  this.ctx.ui.setFocus(this.ctx.editor);
792
795
  this.ctx.ui.requestRender();
@@ -835,8 +838,7 @@ export class ExtensionUiController {
835
838
  * Hide the hook editor.
836
839
  */
837
840
  hideHookEditor(): void {
838
- this.ctx.editorContainer.clear();
839
- this.ctx.editorContainer.addChild(this.ctx.editor);
841
+ this.#restoreComposerEditor();
840
842
  this.ctx.hookEditor = undefined;
841
843
  this.ctx.ui.setFocus(this.ctx.editor);
842
844
  this.ctx.ui.requestRender();
@@ -879,8 +881,7 @@ export class ExtensionUiController {
879
881
  closed = true;
880
882
  this.#clearActiveHookCustom();
881
883
  if (!options?.overlay) {
882
- this.ctx.editorContainer.clear();
883
- this.ctx.editorContainer.addChild(this.ctx.editor);
884
+ this.#restoreComposerEditor();
884
885
  this.ctx.editor.setText(savedText);
885
886
  }
886
887
  this.ctx.ui.setFocus(this.ctx.editor);
@@ -434,13 +434,14 @@ export class InputController {
434
434
  }
435
435
 
436
436
  const runner = this.ctx.session.extensionRunner;
437
+ const pendingImages = this.ctx.pendingImages;
437
438
  let inputImages = this.#visiblePendingImagesForText(text);
438
439
 
439
440
  if (runner?.hasHandlers("input")) {
440
441
  const result = await runner.emitInput(text, inputImages, "interactive");
441
442
  if (result?.handled) {
442
443
  this.ctx.editor.setText("");
443
- this.ctx.pendingImages = [];
444
+ this.#clearPendingImagesIfOwnedBy(pendingImages);
444
445
  return;
445
446
  }
446
447
  if (result?.text !== undefined) {
@@ -530,7 +531,7 @@ export class InputController {
530
531
  this.ctx.editor.addToHistory(text);
531
532
  this.ctx.editor.setText("");
532
533
  const images = inputImages && inputImages.length > 0 ? [...inputImages] : undefined;
533
- this.ctx.pendingImages = [];
534
+ this.#clearPendingImagesIfOwnedBy(pendingImages);
534
535
  // Record the signature so the queued message's eventual delivery
535
536
  // (a user-role `message_start` event) leaves any draft the user has
536
537
  // typed since queuing intact. Same protection as #783, applied to
@@ -582,7 +583,7 @@ export class InputController {
582
583
  if (this.ctx.onInputCallback) {
583
584
  // Include any pending images from clipboard paste
584
585
  const images = inputImages && inputImages.length > 0 ? [...inputImages] : undefined;
585
- this.ctx.pendingImages = [];
586
+ this.#clearPendingImagesIfOwnedBy(pendingImages);
586
587
 
587
588
  // Render user message immediately, then let session events catch up
588
589
  const submission = this.ctx.startPendingSubmission({ text, images });
@@ -1104,11 +1105,14 @@ export class InputController {
1104
1105
  return false;
1105
1106
  }
1106
1107
 
1107
- this.ctx.pendingImages.push({
1108
- type: "image",
1109
- data: image.data,
1110
- mimeType: image.mimeType,
1111
- });
1108
+ this.ctx.pendingImages = [
1109
+ ...this.ctx.pendingImages,
1110
+ {
1111
+ type: "image",
1112
+ data: image.data,
1113
+ mimeType: image.mimeType,
1114
+ },
1115
+ ];
1112
1116
  this.ctx.editor.insertText(`${formatPastedImageReference(this.#nextImagePlaceholder(), image.resolvedPath)} `);
1113
1117
  this.ctx.showStatus(`Attached image: ${path.basename(image.resolvedPath)}`, { dim: true });
1114
1118
  this.ctx.ui.requestRender();
@@ -1151,10 +1155,33 @@ export class InputController {
1151
1155
  return images.length > 0 ? images : undefined;
1152
1156
  }
1153
1157
 
1158
+ #clearPendingImagesIfOwnedBy(pendingImages: InteractiveModeContext["pendingImages"]): void {
1159
+ if (this.ctx.pendingImages === pendingImages) {
1160
+ this.ctx.pendingImages = [];
1161
+ }
1162
+ }
1163
+
1154
1164
  #clearPendingImagesIfPlaceholdersRemoved(text: string): void {
1155
1165
  if (this.ctx.pendingImages.length === 0 || IMAGE_PLACEHOLDER_PRESENT_PATTERN.test(text)) {
1156
1166
  return;
1157
1167
  }
1168
+ // Editor submit resets the composer and emits onChange("") before onSubmit(result).
1169
+ // Defer the empty-buffer clear so the submit handler can still resolve image
1170
+ // placeholders against pendingImages, while manual clears still drop stale images.
1171
+ if (text.length === 0) {
1172
+ const pendingImages = this.ctx.pendingImages;
1173
+ const pendingImageCount = pendingImages.length;
1174
+ queueMicrotask(() => {
1175
+ if (
1176
+ this.ctx.pendingImages === pendingImages &&
1177
+ pendingImages.length === pendingImageCount &&
1178
+ this.ctx.editor.getText().length === 0
1179
+ ) {
1180
+ this.ctx.pendingImages = [];
1181
+ }
1182
+ });
1183
+ return;
1184
+ }
1158
1185
  this.ctx.pendingImages = [];
1159
1186
  }
1160
1187
 
@@ -1185,11 +1212,14 @@ export class InputController {
1185
1212
  }
1186
1213
  }
1187
1214
 
1188
- this.ctx.pendingImages.push({
1189
- type: "image",
1190
- data: imageData.data,
1191
- mimeType: imageData.mimeType,
1192
- });
1215
+ this.ctx.pendingImages = [
1216
+ ...this.ctx.pendingImages,
1217
+ {
1218
+ type: "image",
1219
+ data: imageData.data,
1220
+ mimeType: imageData.mimeType,
1221
+ },
1222
+ ];
1193
1223
  this.ctx.editor.insertText(`${this.#nextImagePlaceholder()} `);
1194
1224
  this.ctx.ui.requestRender();
1195
1225
  return true;