@gajae-code/coding-agent 0.10.0 → 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.
Files changed (65) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dist/types/cli.d.ts +1 -0
  3. package/dist/types/config/settings.d.ts +2 -0
  4. package/dist/types/coordinator/contract.d.ts +1 -1
  5. package/dist/types/coordinator-mcp/policy.d.ts +3 -0
  6. package/dist/types/coordinator-mcp/server.d.ts +17 -5
  7. package/dist/types/coordinator-mcp/session-reaper.d.ts +53 -0
  8. package/dist/types/extensibility/extensions/types.d.ts +3 -1
  9. package/dist/types/modes/components/gajae-pet-widget.d.ts +1 -0
  10. package/dist/types/modes/components/pet-capability.d.ts +37 -0
  11. package/dist/types/modes/components/pet-selector.d.ts +1 -1
  12. package/dist/types/modes/components/settings-selector.d.ts +9 -0
  13. package/dist/types/modes/components/status-line/types.d.ts +1 -1
  14. package/dist/types/modes/components/status-line.d.ts +0 -10
  15. package/dist/types/modes/interactive-mode.d.ts +2 -1
  16. package/dist/types/modes/print-mode.d.ts +3 -4
  17. package/dist/types/modes/rpc/rpc-client.d.ts +7 -1
  18. package/dist/types/modes/rpc/rpc-mode.d.ts +2 -1
  19. package/dist/types/modes/rpc/rpc-types.d.ts +17 -1
  20. package/dist/types/modes/types.d.ts +11 -1
  21. package/dist/types/modes/utils/context-usage.d.ts +4 -19
  22. package/dist/types/session/agent-session.d.ts +14 -1
  23. package/dist/types/session/context-estimation.d.ts +31 -0
  24. package/package.json +7 -7
  25. package/src/cli.ts +1 -0
  26. package/src/config/model-profiles.ts +19 -19
  27. package/src/config/settings.ts +96 -45
  28. package/src/coordinator/contract.ts +1 -0
  29. package/src/coordinator-mcp/policy.ts +30 -0
  30. package/src/coordinator-mcp/server.ts +294 -66
  31. package/src/coordinator-mcp/session-reaper.ts +129 -0
  32. package/src/extensibility/extensions/runner.ts +1 -1
  33. package/src/extensibility/extensions/types.ts +3 -1
  34. package/src/extensibility/slash-commands.ts +3 -3
  35. package/src/gjc-runtime/tmux-owner-isolation.ts +4 -1
  36. package/src/internal-urls/docs-index.generated.ts +7 -7
  37. package/src/main.ts +3 -5
  38. package/src/modes/acp/acp-agent.ts +2 -2
  39. package/src/modes/components/custom-editor.ts +1 -0
  40. package/src/modes/components/footer.ts +3 -3
  41. package/src/modes/components/gajae-pet-widget.ts +169 -20
  42. package/src/modes/components/pet-capability.ts +115 -0
  43. package/src/modes/components/pet-selector.ts +4 -6
  44. package/src/modes/components/settings-selector.ts +28 -2
  45. package/src/modes/components/status-line/segments.ts +13 -6
  46. package/src/modes/components/status-line/types.ts +1 -1
  47. package/src/modes/components/status-line.ts +4 -171
  48. package/src/modes/controllers/extension-ui-controller.ts +10 -9
  49. package/src/modes/controllers/input-controller.ts +43 -13
  50. package/src/modes/controllers/selector-controller.ts +7 -18
  51. package/src/modes/interactive-mode.ts +65 -4
  52. package/src/modes/print-mode.ts +228 -92
  53. package/src/modes/rpc/rpc-client.ts +58 -1
  54. package/src/modes/rpc/rpc-mode.ts +286 -85
  55. package/src/modes/rpc/rpc-types.ts +15 -1
  56. package/src/modes/shared/agent-wire/command-dispatch.ts +33 -1
  57. package/src/modes/shared/agent-wire/command-validation.ts +10 -0
  58. package/src/modes/shared/agent-wire/scopes.ts +1 -0
  59. package/src/modes/types.ts +11 -1
  60. package/src/modes/utils/context-usage.ts +56 -92
  61. package/src/session/agent-session.ts +337 -90
  62. package/src/session/context-estimation.ts +91 -0
  63. package/src/session/session-manager.ts +9 -2
  64. package/src/slash-commands/builtin-registry.ts +31 -19
  65. package/src/slash-commands/helpers/context-report.ts +26 -4
package/src/main.ts CHANGED
@@ -1398,12 +1398,10 @@ export async function runRootCommand(
1398
1398
  if ($env.PI_TIMING) {
1399
1399
  logger.printTimings();
1400
1400
  }
1401
+ stopThemeWatcher();
1401
1402
  if (!deps.suppressProcessExit) {
1402
- await session.dispose();
1403
- stopThemeWatcher();
1404
- await postmortem.quit(0);
1405
- } else {
1406
- stopThemeWatcher();
1403
+ const exitCode = typeof process.exitCode === "number" ? process.exitCode : 0;
1404
+ await postmortem.quit(exitCode);
1407
1405
  }
1408
1406
  }
1409
1407
  }
@@ -2217,14 +2217,14 @@ export class AcpAgent implements Agent {
2217
2217
  const sessionId = record.session.sessionId;
2218
2218
 
2219
2219
  const contextUsage = record.session.getContextUsage();
2220
- if (contextUsage) {
2220
+ if (contextUsage?.tokens != null) {
2221
2221
  const usageStats = record.session.sessionManager.getUsageStatistics();
2222
2222
  await this.#connection.sessionUpdate({
2223
2223
  sessionId,
2224
2224
  update: {
2225
2225
  sessionUpdate: "usage_update",
2226
2226
  size: contextUsage.contextWindow,
2227
- used: contextUsage.tokens ?? 0,
2227
+ used: contextUsage.tokens,
2228
2228
  cost: usageStats.cost > 0 ? { amount: usageStats.cost, currency: "USD" } : undefined,
2229
2229
  },
2230
2230
  });
@@ -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;
@@ -139,8 +139,8 @@ export class FooterComponent implements Component {
139
139
  // After compaction, tokens are unknown until the next LLM response.
140
140
  const contextUsage = this.session.getContextUsage();
141
141
  const contextWindow = contextUsage?.contextWindow ?? state.model?.contextWindow ?? 0;
142
- const contextPercentValue = contextUsage?.percent ?? 0;
143
- const contextPercent = contextUsage?.percent !== null ? contextPercentValue.toFixed(1) : "?";
142
+ const contextPercentValue = contextUsage?.percent;
143
+ const contextPercent = typeof contextPercentValue === "number" ? contextPercentValue.toFixed(1) : "?";
144
144
 
145
145
  // Replace home directory with ~
146
146
  let pwd = shortenPath(getProjectDir());
@@ -188,7 +188,7 @@ export class FooterComponent implements Component {
188
188
  contextPercent === "?"
189
189
  ? `?/${formatNumber(contextWindow)}${autoIndicator}`
190
190
  : `${contextPercent}%/${formatNumber(contextWindow)}${autoIndicator}`;
191
- if (contextUsage?.percent !== null && contextUsage?.percent !== undefined) {
191
+ if (typeof contextPercentValue === "number") {
192
192
  const color = getContextUsageThemeColor(getContextUsageLevel(contextPercentValue, contextWindow));
193
193
  contextPercentStr =
194
194
  color === "statusLineContext" ? contextPercentDisplay : theme.fg(color, contextPercentDisplay);
@@ -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);
@@ -114,9 +114,13 @@ const modelSegment: StatusLineSegment = {
114
114
  if (opts.showContextPercent !== false && ctx.contextPctSegmentActive !== true) {
115
115
  const pct = ctx.contextPercent;
116
116
  const window = ctx.contextWindow;
117
- if (window > 0 && Number.isFinite(pct)) {
118
- const color = getContextUsageThemeColor(getContextUsageLevel(pct, window));
119
- content += `${theme.sep.dot}${theme.fg(color, `${pct.toFixed(1)}%`)}`;
117
+ if (window > 0) {
118
+ if (typeof pct === "number" && Number.isFinite(pct)) {
119
+ const color = getContextUsageThemeColor(getContextUsageLevel(pct, window));
120
+ content += `${theme.sep.dot}${theme.fg(color, `${pct.toFixed(1)}%`)}`;
121
+ } else {
122
+ content += `${theme.sep.dot}${theme.fg("statusLineContext", "?")}`;
123
+ }
120
124
  }
121
125
  }
122
126
 
@@ -382,11 +386,14 @@ const contextPctSegment: StatusLineSegment = {
382
386
  render(ctx) {
383
387
  const pct = ctx.contextPercent;
384
388
  const window = ctx.contextWindow;
389
+ const knownPct = typeof pct === "number" && Number.isFinite(pct) ? pct : undefined;
385
390
 
386
391
  const autoIcon = ctx.autoCompactEnabled && theme.icon.auto ? ` ${theme.icon.auto}` : "";
387
- const text = `${pct.toFixed(1)}%/${formatNumber(window)}${autoIcon}`;
388
-
389
- const color = getContextUsageThemeColor(getContextUsageLevel(pct, window));
392
+ const text = `${knownPct === undefined ? "?" : `${knownPct.toFixed(1)}%`}/${formatNumber(window)}${autoIcon}`;
393
+ const color =
394
+ knownPct === undefined
395
+ ? "statusLineContext"
396
+ : getContextUsageThemeColor(getContextUsageLevel(knownPct, window));
390
397
  const content = withIcon(theme.icon.context, theme.fg(color, text));
391
398
 
392
399
  return { content, visible: true };
@@ -39,7 +39,7 @@ export interface SegmentContext {
39
39
  cost: number;
40
40
  tokensPerSecond: number | null;
41
41
  };
42
- contextPercent: number;
42
+ contextPercent: number | null;
43
43
  contextWindow: number;
44
44
  /**
45
45
  * True when a standalone `context_pct` segment is also part of the active