@gajae-code/coding-agent 0.13.1 → 0.13.3

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 (45) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/dist/types/config/model-registry.d.ts +1 -0
  3. package/dist/types/config/settings-schema.d.ts +3 -3
  4. package/dist/types/main.d.ts +1 -0
  5. package/dist/types/modes/components/dynamic-theme-text.d.ts +9 -0
  6. package/dist/types/modes/components/gajae-pet-widget.d.ts +5 -5
  7. package/dist/types/modes/components/pet-capability.d.ts +5 -5
  8. package/dist/types/modes/components/settings-selector.d.ts +5 -0
  9. package/dist/types/modes/theme/defaults/index.d.ts +118 -0
  10. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +3 -1
  11. package/dist/types/session/internal/managed-session-storage.d.ts +13 -0
  12. package/dist/types/session/session-manager.d.ts +7 -0
  13. package/dist/types/tools/image-gen.d.ts +1 -0
  14. package/package.json +8 -8
  15. package/src/config/model-profile-activation.ts +3 -1
  16. package/src/config/model-profiles.ts +59 -2
  17. package/src/config/model-registry.ts +16 -1
  18. package/src/config/settings-schema.ts +1 -1
  19. package/src/coordinator-mcp/server.ts +3 -0
  20. package/src/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.ts +31 -6
  21. package/src/internal-urls/docs-index.generated.ts +7 -6
  22. package/src/main.ts +4 -0
  23. package/src/modes/components/chrome.ts +4 -3
  24. package/src/modes/components/dynamic-theme-text.ts +22 -0
  25. package/src/modes/components/gajae-pet-widget.ts +130 -42
  26. package/src/modes/components/model-selector.ts +1 -6
  27. package/src/modes/components/pet-capability.ts +10 -5
  28. package/src/modes/components/settings-selector.ts +20 -5
  29. package/src/modes/components/status-line/segments.ts +3 -3
  30. package/src/modes/controllers/selector-controller.ts +61 -5
  31. package/src/modes/interactive-mode.ts +4 -0
  32. package/src/modes/theme/defaults/index.ts +2 -0
  33. package/src/modes/theme/defaults/ouroboros.json +118 -0
  34. package/src/sdk/broker/process-incarnation.ts +6 -1
  35. package/src/sdk/bus/telegram-daemon-contract.ts +3 -1
  36. package/src/sdk/bus/telegram-daemon.ts +51 -43
  37. package/src/session/agent-session.ts +39 -0
  38. package/src/session/internal/managed-session-storage.ts +162 -40
  39. package/src/session/session-manager.ts +28 -0
  40. package/src/slash-commands/builtin-registry.ts +62 -8
  41. package/src/task/index.ts +5 -2
  42. package/src/tools/bash.ts +11 -9
  43. package/src/tools/image-gen.ts +322 -126
  44. package/src/tools/todo-contract.ts +55 -3
  45. package/src/tools/todo-write.ts +4 -3
package/src/main.ts CHANGED
@@ -1255,6 +1255,7 @@ export interface RunRootCommandDependencies {
1255
1255
  getChangelogForDisplay?: typeof getChangelogForDisplay;
1256
1256
  createInteractiveMode?: CreateInteractiveMode;
1257
1257
  runPrintMode?: RunPrintMode;
1258
+ quit?: (code: number) => Promise<void>;
1258
1259
  isResumePickerTerminal?: ResumePickerTerminalCheck;
1259
1260
  listForResumePickerReadOnly?: ListForResumePickerReadOnly;
1260
1261
  listManagedForResumePickerReadOnly?: ListManagedForResumePickerReadOnly;
@@ -1961,6 +1962,9 @@ export async function runRootCommand(
1961
1962
  authStorage.close();
1962
1963
  if (!deps.suppressProcessExit) await postmortem.cleanup();
1963
1964
  }
1965
+ if (!deps.suppressProcessExit) {
1966
+ await (deps.quit ?? postmortem.quit)(typeof process.exitCode === "number" ? process.exitCode : 0);
1967
+ }
1964
1968
  }
1965
1969
  }
1966
1970
  }
@@ -1,6 +1,7 @@
1
- import { Container, type SelectItem, SelectList, Text } from "@gajae-code/tui";
1
+ import { Container, type SelectItem, SelectList } from "@gajae-code/tui";
2
2
  import { getSelectListTheme, theme } from "../../modes/theme/theme";
3
3
  import { DynamicBorder } from "./dynamic-border";
4
+ import { DynamicThemeText } from "./dynamic-theme-text";
4
5
 
5
6
  export interface FramedSelectOptions {
6
7
  maxVisible?: number;
@@ -24,10 +25,10 @@ export function FramedSelect(
24
25
  const container = new Container();
25
26
  container.addChild(new DynamicBorder());
26
27
  if (title) {
27
- container.addChild(new Text(theme.bold(theme.fg("accent", title)), 0, 0));
28
+ container.addChild(new DynamicThemeText(() => theme.bold(theme.fg("accent", title))));
28
29
  }
29
30
 
30
- const selectList = new SelectList(items, opts.maxVisible ?? Math.min(items.length, 10), getSelectListTheme());
31
+ const selectList = new SelectList(items, opts.maxVisible ?? Math.min(items.length, 10), () => getSelectListTheme());
31
32
  const selectedIndex =
32
33
  opts.selectedValue === undefined ? -1 : items.findIndex(item => item.value === opts.selectedValue);
33
34
  if (selectedIndex !== -1) selectList.setSelectedIndex(selectedIndex);
@@ -0,0 +1,22 @@
1
+ import { type Component, Text } from "@gajae-code/tui";
2
+
3
+ /** Text whose styling is resolved at render time so live theme previews recolor open overlays. */
4
+ export class DynamicThemeText implements Component {
5
+ #text = new Text("", 0, 0);
6
+ #rendered = "";
7
+
8
+ constructor(private readonly getText: () => string) {}
9
+
10
+ invalidate(): void {
11
+ this.#text.invalidate();
12
+ }
13
+
14
+ render(width: number): string[] {
15
+ const rendered = this.getText();
16
+ if (rendered !== this.#rendered) {
17
+ this.#rendered = rendered;
18
+ this.#text.setText(rendered);
19
+ }
20
+ return this.#text.render(width);
21
+ }
22
+ }
@@ -3,16 +3,17 @@ import {
3
3
  buildGajaePixelFrames,
4
4
  type Component,
5
5
  type Container,
6
- type GajaePixelFrameName,
7
6
  type GajaePixelFrames,
8
7
  getCellDimensions,
9
- PARA_PARA_STEPS,
10
8
  PET_SKINS,
9
+ type PetBurst,
10
+ type PetFrameName,
11
11
  type PetMode,
12
12
  type PetSkinId,
13
13
  petBurstDurationMs,
14
14
  petBurstFrame,
15
15
  registerAnimationCallback,
16
+ resolvePetMode,
16
17
  type TUI,
17
18
  } from "@gajae-code/tui";
18
19
  import type { CustomEditor } from "./custom-editor";
@@ -66,21 +67,48 @@ function sameFootprint(left: SixelFootprint, right: SixelFootprint): boolean {
66
67
  */
67
68
  const petOverlayEmitterOwners = new WeakMap<TUI, GajaePetWidget>();
68
69
 
69
- /** Working animation: the shared para-para beats looped end to end. */
70
- const WORK_LOOP_TOTAL = PARA_PARA_STEPS.reduce((sum, [, ms]) => sum + ms, 0);
71
70
  /** Random gap between automatic claw flexes (fires while idle AND working). */
72
71
  const AUTO_FLEX_MIN_GAP_MS = 12_000;
73
72
  const AUTO_FLEX_MAX_GAP_MS = 40_000;
74
- // Deterministic idle loop: gaze around with a rare visor flicker.
75
- const IDLE_LOOP: Array<[GajaePixelFrameName, number]> = [
76
- ["base", 1100],
77
- ["gazeL", 350],
78
- ["base", 500],
79
- ["gazeR", 350],
80
- ["base", 800],
81
- ["flicker", 150],
82
- ];
83
- const IDLE_LOOP_TOTAL = IDLE_LOOP.reduce((sum, [, ms]) => sum + ms, 0);
73
+
74
+ function animationFrameAt(
75
+ steps: ReadonlyArray<readonly [PetFrameName, number]>,
76
+ now: number,
77
+ fallback: PetFrameName,
78
+ ): PetFrameName {
79
+ const total = steps.reduce((sum, [, ms]) => sum + ms, 0);
80
+ if (total <= 0) return fallback;
81
+ let elapsed = now % total;
82
+ for (const [frame, ms] of steps) {
83
+ if (elapsed < ms) return frame;
84
+ elapsed -= ms;
85
+ }
86
+ return fallback;
87
+ }
88
+
89
+ function animationFrameAtElapsed(
90
+ steps: ReadonlyArray<readonly [PetFrameName, number]>,
91
+ elapsed: number,
92
+ fallback: PetFrameName,
93
+ ): PetFrameName {
94
+ for (const [frame, ms] of steps) {
95
+ if (elapsed < ms) return frame;
96
+ elapsed -= ms;
97
+ }
98
+ return fallback;
99
+ }
100
+
101
+ function animationDuration(steps: ReadonlyArray<readonly [PetFrameName, number]>): number {
102
+ return steps.reduce((sum, [, ms]) => sum + ms, 0);
103
+ }
104
+
105
+ interface WorkTransition {
106
+ startedAt: number;
107
+ steps: ReadonlyArray<readonly [PetFrameName, number]>;
108
+ }
109
+
110
+ const OUROBOROS_WORK_BURST_MIN_GAP_MS = 14_000;
111
+ const OUROBOROS_WORK_BURST_MAX_GAP_MS = 30_000;
84
112
 
85
113
  /**
86
114
  * Selector preview: fire the first burst this soon after a skin is previewed, so the
@@ -123,8 +151,8 @@ export class PetFramedEditor implements Component {
123
151
  }
124
152
 
125
153
  /**
126
- * The gajae pet: a 16x16 real-pixel sprite living in a reserved area beside
127
- * the composer. It is nearest-neighbor scaled to the two terminal rows occupied
154
+ * The gajae pet: a real-pixel sprite living in a reserved area beside the
155
+ * composer. It is nearest-neighbor scaled to the two terminal rows occupied
128
156
  * by an empty one-line composer and lifted one row so its feet meet the input
129
157
  * box's bottom edge.
130
158
  *
@@ -146,12 +174,17 @@ export class GajaePetWidget {
146
174
  #getComposerBottomOffset: () => number;
147
175
  #mode: PetMode = "off";
148
176
  #pixel: GajaePixelFrames | undefined;
149
- #frame: GajaePixelFrameName = "base";
177
+ #frame: PetFrameName = "base";
150
178
  #animation: AnimationRegistration | undefined;
151
179
  #flexUntil = 0;
180
+ #activeBurst: PetBurst | undefined;
152
181
  #nextAutoFlexAt = 0;
182
+ #working = false;
183
+ #workTransition: WorkTransition | undefined;
184
+ #nextWorkBurstAt = 0;
185
+ #workBurstIndex = 0;
153
186
  #autoFlexGapMs: [number, number] | null;
154
- #forcedProtocol: "sixel" | "kitty" | undefined;
187
+ #forcedProtocol: "sixel" | "kitty" | "iterm2" | undefined;
155
188
  /** Cell metrics the current frames were built for; a change triggers a rebuild. */
156
189
  #builtCellW = 0;
157
190
  #builtCellH = 0;
@@ -178,7 +211,7 @@ export class GajaePetWidget {
178
211
  isWorking: () => boolean;
179
212
  /** Rows rendered below the composer box (pet floor + hook widgets). */
180
213
  getComposerBottomOffset: () => number;
181
- forcePixelProtocol?: "sixel" | "kitty";
214
+ forcePixelProtocol?: "sixel" | "kitty" | "iterm2";
182
215
  /** Random [min, max] ms between auto-flexes; null disables. */
183
216
  autoFlexGapMs?: [number, number] | null;
184
217
  }) {
@@ -195,7 +228,7 @@ export class GajaePetWidget {
195
228
  }
196
229
 
197
230
  /** Protocol available for the real-pixel pet, if any. */
198
- static pixelProtocol(): "sixel" | "kitty" | null {
231
+ static pixelProtocol(): "sixel" | "kitty" | "iterm2" | null {
199
232
  return getPetPixelProtocol();
200
233
  }
201
234
 
@@ -207,8 +240,8 @@ export class GajaePetWidget {
207
240
  return performance.now() < this.#flexUntil;
208
241
  }
209
242
 
210
- setMode(mode: PetMode): void {
211
- this.#applyMode(mode, true);
243
+ setMode(mode: string): void {
244
+ this.#applyMode(resolvePetMode(mode), true);
212
245
  }
213
246
 
214
247
  /** Live preview during a selector: change the sprite without re-mounting the
@@ -237,6 +270,9 @@ export class GajaePetWidget {
237
270
  this.#releaseOverlayEmitter();
238
271
  this.#floorContainer.clear();
239
272
  this.#pixel = undefined;
273
+ this.#workTransition = undefined;
274
+ this.#nextWorkBurstAt = 0;
275
+ this.#activeBurst = undefined;
240
276
  this.#framedEditor.setReserve(0);
241
277
  if (mountComposer) this.#mountEditor(false);
242
278
  this.#ui.requestRender(true);
@@ -247,9 +283,14 @@ export class GajaePetWidget {
247
283
  if (!protocol) return;
248
284
  if (this.#mode !== "off") this.#writeImageCleanup();
249
285
  this.#mode = mode;
250
- this.#frame = "base";
286
+ this.#frame = PET_SKINS[mode].baseFrame;
251
287
  this.#flexUntil = 0;
288
+ this.#activeBurst = undefined;
252
289
  this.#nextAutoFlexAt = 0;
290
+ this.#working = this.#isWorking();
291
+ this.#workTransition = undefined;
292
+ this.#nextWorkBurstAt = 0;
293
+ this.#workBurstIndex = 0;
253
294
  this.#buildPixel(protocol);
254
295
  if (mountComposer) this.#mountEditor(true);
255
296
  // The pet overlays the composer's bottom rows; no floor row is reserved, so
@@ -262,7 +303,7 @@ export class GajaePetWidget {
262
303
  }
263
304
 
264
305
  /** (Re)build the encoded frames for the current terminal cell metrics. */
265
- #buildPixel(protocol: "sixel" | "kitty"): void {
306
+ #buildPixel(protocol: "sixel" | "kitty" | "iterm2"): void {
266
307
  const cell = getCellDimensions();
267
308
  this.#builtCellW = cell.widthPx;
268
309
  this.#builtCellH = cell.heightPx;
@@ -276,10 +317,17 @@ export class GajaePetWidget {
276
317
  skin,
277
318
  cellWidthPx: cell.widthPx,
278
319
  cellHeightPx: cell.heightPx,
320
+ // Encode iTerm2 at the same two-row pixel size as Kitty/Sixel. The
321
+ // iTerm2 protocol receives explicit pixel dimensions below, so it must
322
+ // not be reduced to a fractional cell canvas or it becomes undersized.
279
323
  targetRows: 2,
280
324
  sixelTopPaddingPx: protocol === "sixel" ? PET_SIXEL_DROP_PX : 0,
281
325
  kittyCellYOffsetPx: protocol === "kitty" ? petKittyDropPx(cell.heightPx) : 0,
282
326
  kittyImageId: protocol === "kitty" ? this.#kittyImageId : undefined,
327
+ // iTerm2 receives an explicit 1:1 pixel size; keep its raster inside the
328
+ // same two-row footprint reserved for Kitty and Sixel.
329
+ iterm2TopPaddingPx: 0,
330
+ iterm2BottomPaddingPx: 0,
283
331
  });
284
332
  this.#framedEditor.setReserve(this.#pixel.columns + PET_SIDE_MARGIN);
285
333
  }
@@ -306,6 +354,9 @@ export class GajaePetWidget {
306
354
  this.#releaseOverlayEmitter();
307
355
  this.#mode = "off";
308
356
  this.#pixel = undefined;
357
+ this.#workTransition = undefined;
358
+ this.#nextWorkBurstAt = 0;
359
+ this.#activeBurst = undefined;
309
360
  this.#floorContainer.clear();
310
361
  this.#framedEditor.setReserve(0);
311
362
  // Restore the plain composer only while our framed wrapper is still
@@ -334,31 +385,46 @@ export class GajaePetWidget {
334
385
  this.#mountEditor(this.#mode !== "off");
335
386
  }
336
387
 
337
- #pickFrame(now: number): GajaePixelFrameName {
388
+ #pickFrame(now: number): PetFrameName {
338
389
  const mode = this.#mode;
339
390
  if (mode === "off") return "base";
391
+ const skin = PET_SKINS[mode];
392
+ const working = this.#isWorking();
393
+ if (working !== this.#working) {
394
+ this.#working = working;
395
+ this.#flexUntil = 0;
396
+ this.#activeBurst = undefined;
397
+ this.#nextWorkBurstAt = 0;
398
+ const steps = working ? skin.workEnter : skin.workExit;
399
+ this.#workTransition = steps?.length ? { startedAt: now, steps } : undefined;
400
+ }
401
+ if (this.#workTransition) {
402
+ const elapsed = now - this.#workTransition.startedAt;
403
+ const duration = animationDuration(this.#workTransition.steps);
404
+ if (elapsed < duration) {
405
+ return animationFrameAtElapsed(this.#workTransition.steps, elapsed, skin.baseFrame);
406
+ }
407
+ this.#workTransition = undefined;
408
+ }
340
409
  // Random idle burst → the skin's own animation, driven by its burst descriptor
341
410
  // (RedGajae holds a flex; BlueGajae dances the para-para then sobs).
342
411
  if (now < this.#flexUntil) {
343
- const burst = PET_SKINS[mode].burst;
412
+ const burst = this.#activeBurst ?? skin.burst;
344
413
  const elapsed = now - (this.#flexUntil - petBurstDurationMs(burst));
345
414
  return petBurstFrame(burst, elapsed, now);
346
415
  }
347
- // Working → loop the shared para-para beats.
416
+ // Working → loop the selected skin's activity animation.
348
417
  if (this.#isWorking()) {
349
- let d = now % WORK_LOOP_TOTAL;
350
- for (const [frame, ms] of PARA_PARA_STEPS) {
351
- if (d < ms) return frame;
352
- d -= ms;
353
- }
354
- return "base";
418
+ return animationFrameAt(skin.work, now, skin.baseFrame);
355
419
  }
356
- let t = now % IDLE_LOOP_TOTAL;
357
- for (const [frame, ms] of IDLE_LOOP) {
358
- if (t < ms) return frame;
359
- t -= ms;
360
- }
361
- return "base";
420
+ return animationFrameAt(skin.idle, now, skin.baseFrame);
421
+ }
422
+
423
+ #scheduleWorkBurst(now: number): void {
424
+ this.#nextWorkBurstAt =
425
+ now +
426
+ OUROBOROS_WORK_BURST_MIN_GAP_MS +
427
+ Math.random() * (OUROBOROS_WORK_BURST_MAX_GAP_MS - OUROBOROS_WORK_BURST_MIN_GAP_MS);
362
428
  }
363
429
 
364
430
  #scheduleAutoFlex(now: number): void {
@@ -382,15 +448,29 @@ export class GajaePetWidget {
382
448
  }
383
449
  // Random show-off, both while idle and while working. Each skin's burst runs for
384
450
  // its own length (RedGajae a brief flex; BlueGajae a para-para cycle plus sob).
385
- if (this.#autoFlexGapMs && now >= this.#flexUntil) {
451
+ const skin = PET_SKINS[this.#mode];
452
+ const usesWorkBursts = this.#isWorking() && Boolean(skin.workBursts?.length);
453
+ if (this.#autoFlexGapMs && !usesWorkBursts && now >= this.#flexUntil) {
386
454
  if (this.#nextAutoFlexAt === 0) {
387
455
  this.#scheduleAutoFlex(now);
388
456
  } else if (now >= this.#nextAutoFlexAt) {
389
457
  const burstMs = petBurstDurationMs(PET_SKINS[this.#mode].burst);
458
+ this.#activeBurst = skin.burst;
390
459
  this.#flexUntil = now + burstMs;
391
460
  this.#scheduleAutoFlex(now + burstMs);
392
461
  }
393
462
  }
463
+ if (this.#isWorking() && !this.#workTransition && now >= this.#flexUntil && skin.workBursts?.length) {
464
+ if (this.#nextWorkBurstAt === 0) {
465
+ this.#scheduleWorkBurst(now);
466
+ } else if (now >= this.#nextWorkBurstAt) {
467
+ const burst = skin.workBursts[this.#workBurstIndex % skin.workBursts.length];
468
+ this.#workBurstIndex++;
469
+ this.#activeBurst = burst;
470
+ this.#flexUntil = now + petBurstDurationMs(burst);
471
+ this.#scheduleWorkBurst(this.#flexUntil);
472
+ }
473
+ }
394
474
  const frame = this.#pickFrame(now);
395
475
  if (frame === this.#frame) return;
396
476
  this.#frame = frame;
@@ -412,7 +492,15 @@ export class GajaePetWidget {
412
492
  // back onto the composer's bottom border per protocol (sixel via transparent
413
493
  // top padding, kitty via a sub-cell Y offset baked into the frames).
414
494
  const composerBottom = rows - this.#getComposerBottomOffset();
415
- const y = composerBottom - pixel.rows - PET_RAISE_ROWS;
495
+ // iTerm2 anchors an inline image at the current cursor row. Keep the full
496
+ // sprite inside the composer rows and leave the HUD boundary below it.
497
+ // Keep the full-size iTerm2 sprite aligned with the composer like the
498
+ // cursor-neutral Kitty/Sixel overlays; only the protocol encoding differs.
499
+ // Nudge the iTerm2 image one terminal row upward while preserving its
500
+ // explicit 36px size and aspect ratio. Kitty/Sixel keep their established
501
+ // safety lift.
502
+ const safetyLift = PET_RAISE_ROWS;
503
+ const y = composerBottom - pixel.rows - safetyLift;
416
504
  const x = columns - pixel.columns - PET_SIDE_MARGIN;
417
505
  if (y < 0 || x < 0) return null;
418
506
  return { x, y };
@@ -490,7 +578,7 @@ export class GajaePetWidget {
490
578
  const { x, y } = pos;
491
579
  let out = "";
492
580
 
493
- if (pixel.protocol === "sixel") {
581
+ if (pixel.protocol === "sixel" || pixel.protocol === "iterm2") {
494
582
  const footprint = { x, y, columns: pixel.columns, rows: pixel.rasterRows };
495
583
  if (this.#lastSixelFootprint && !sameFootprint(this.#lastSixelFootprint, footprint)) {
496
584
  out += this.#clearSixelFootprint(this.#lastSixelFootprint);
@@ -27,6 +27,7 @@ import {
27
27
  GJC_MODEL_ASSIGNMENT_TARGETS,
28
28
  isAuthenticated,
29
29
  kNoAuth,
30
+ requiresExplicitThinkingChoice,
30
31
  } from "../../config/model-registry";
31
32
  import {
32
33
  formatModelSelectorValue,
@@ -2432,12 +2433,6 @@ export class ModelSelectorComponent extends Container {
2432
2433
  }
2433
2434
  }
2434
2435
 
2435
- function requiresExplicitThinkingChoice(model: Model, role: GjcModelAssignmentTargetId | null): boolean {
2436
- if (model.reasoning !== true) return false;
2437
- if (model.provider === "openai" || model.provider === "openai-codex") return true;
2438
- return role !== null && GJC_MODEL_ASSIGNMENT_TARGETS[role].settingsPath === "task.agentModelOverrides";
2439
- }
2440
-
2441
2436
  function getSelectableThinkingLevels(model: Model): ThinkingLevel[] {
2442
2437
  const levels: ThinkingLevel[] = [ThinkingLevel.Off];
2443
2438
  let efforts: readonly string[];
@@ -7,13 +7,13 @@ import {
7
7
  TERMINAL,
8
8
  } from "@gajae-code/tui";
9
9
 
10
- export type PetPixelProtocol = "sixel" | "kitty";
10
+ export type PetPixelProtocol = "sixel" | "kitty" | "iterm2";
11
11
 
12
- export const PET_UNAVAILABLE_DESCRIPTION = "Unavailable: requires compatible Kitty or Sixel overlay rendering";
12
+ export const PET_UNAVAILABLE_DESCRIPTION = "Unavailable: requires compatible Kitty, iTerm2, or Sixel overlay rendering";
13
13
  export const PET_SAVED_UNAVAILABLE_DESCRIPTION =
14
- "Saved, unavailable — requires compatible Kitty or Sixel overlay rendering";
14
+ "Saved, unavailable — requires compatible Kitty, iTerm2, or Sixel overlay rendering";
15
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.";
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, iTerm2, or a terminal with compatible Sixel support.";
17
17
  const PET_MULTIPLEXER_UNAVAILABLE_WARNING =
18
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
19
 
@@ -21,9 +21,14 @@ export function getPetUnavailableWarning(env: NodeJS.ProcessEnv = Bun.env): stri
21
21
  return isUnderTerminalMultiplexer(env) ? PET_MULTIPLEXER_UNAVAILABLE_WARNING : PET_UNAVAILABLE_WARNING;
22
22
  }
23
23
 
24
- export function getPetPixelProtocol(): PetPixelProtocol | null {
24
+ export function getPetPixelProtocol(env: NodeJS.ProcessEnv = Bun.env): PetPixelProtocol | null {
25
+ if (isUnderTerminalMultiplexer(env)) {
26
+ const forced = env.PI_FORCE_IMAGE_PROTOCOL?.trim().toLowerCase();
27
+ return TERMINAL.imageProtocol === ImageProtocol.Sixel && forced === "sixel" ? "sixel" : null;
28
+ }
25
29
  if (TERMINAL.imageProtocol === ImageProtocol.Kitty) return "kitty";
26
30
  if (TERMINAL.imageProtocol === ImageProtocol.Sixel) return "sixel";
31
+ if (TERMINAL.imageProtocol === ImageProtocol.Iterm2) return "iterm2";
27
32
  return null;
28
33
  }
29
34
 
@@ -5,6 +5,7 @@ import {
5
5
  Container,
6
6
  Input,
7
7
  matchesKey,
8
+ resolvePetMode,
8
9
  type SelectItem,
9
10
  SelectList,
10
11
  type SettingItem,
@@ -27,6 +28,7 @@ import { getCurrentThemeName, getSelectListTheme, getSettingsListTheme, theme }
27
28
  import { matchesAppInterrupt } from "../../modes/utils/keybinding-matchers";
28
29
  import { getTabBarTheme } from "../shared";
29
30
  import { DynamicBorder } from "./dynamic-border";
31
+ import { DynamicThemeText } from "./dynamic-theme-text";
30
32
  import { GjcBundleSettingsComponent } from "./gjc-bundle-settings";
31
33
  import {
32
34
  type NotificationsEditorOperations,
@@ -101,18 +103,18 @@ class SelectSubmenu extends Container {
101
103
  super();
102
104
 
103
105
  // Title
104
- this.addChild(new Text(theme.bold(theme.fg("accent", title)), 0, 0));
106
+ this.addChild(new DynamicThemeText(() => theme.bold(theme.fg("accent", title))));
105
107
 
106
108
  // Description
107
109
  if (description) {
108
110
  this.addChild(new Spacer(1));
109
- this.addChild(new Text(theme.fg("muted", description), 0, 0));
111
+ this.addChild(new DynamicThemeText(() => theme.fg("muted", description)));
110
112
  }
111
113
 
112
114
  // Preview (if provided)
113
115
  if (getPreview) {
114
116
  this.addChild(new Spacer(1));
115
- this.addChild(new Text(theme.fg("muted", "Preview:"), 0, 0));
117
+ this.addChild(new DynamicThemeText(() => theme.fg("muted", "Preview:")));
116
118
  this.#previewText = new Text(getPreview(), 0, 0);
117
119
  this.addChild(this.#previewText);
118
120
  }
@@ -121,7 +123,7 @@ class SelectSubmenu extends Container {
121
123
  this.addChild(new Spacer(1));
122
124
 
123
125
  // Select list
124
- this.#selectList = new SelectList(options, Math.min(options.length, 10), getSelectListTheme());
126
+ this.#selectList = new SelectList(options, Math.min(options.length, 10), () => getSelectListTheme());
125
127
 
126
128
  // Pre-select current value
127
129
  const currentIndex = options.findIndex(o => o.value === currentValue);
@@ -157,7 +159,7 @@ class SelectSubmenu extends Container {
157
159
 
158
160
  // Hint
159
161
  this.addChild(new Spacer(1));
160
- this.addChild(new Text(theme.fg("dim", " Enter to select · Esc to go back"), 0, 0));
162
+ this.addChild(new DynamicThemeText(() => theme.fg("dim", " Enter to select · Esc to go back")));
161
163
  }
162
164
 
163
165
  #updatePreview(): void {
@@ -708,6 +710,11 @@ export interface SettingsCallbacks {
708
710
  onThemePreview?: (theme: string) => void | Promise<void>;
709
711
  /** Called to restore the rendered theme when theme settings preview is cancelled */
710
712
  onThemePreviewCancel?: (theme: string) => void | Promise<void>;
713
+ /**
714
+ * Atomically apply and persist a theme selection. Returns false when the
715
+ * candidate could not be loaded, leaving the submenu open.
716
+ */
717
+ onThemeCommit?: (path: "theme.dark" | "theme.light", theme: string, previousTheme: string) => Promise<boolean>;
711
718
  /** Called to live-preview the gajae pet skin while browsing the pet setting. */
712
719
  onPetPreview?: (mode: string) => void;
713
720
  /**
@@ -971,6 +978,7 @@ export class SettingsSelectorComponent extends Container {
971
978
  }
972
979
  let description = def.description;
973
980
  if (def.path === "pet.mode") {
981
+ currentValue = resolvePetMode(currentValue);
974
982
  const petAvailable = this.context.petAvailable ?? isPetAvailable();
975
983
  options = createPetSelectItems(options, currentValue, petAvailable);
976
984
  // Unsupported terminals must see the same actionable guidance the
@@ -1072,6 +1080,13 @@ export class SettingsSelectorComponent extends Container {
1072
1080
  done(value);
1073
1081
  return;
1074
1082
  }
1083
+ if (def.path === "theme.dark" || def.path === "theme.light") {
1084
+ if (!this.callbacks.onThemeCommit) return;
1085
+ void this.callbacks.onThemeCommit(def.path, value, currentValue).then(accepted => {
1086
+ if (accepted) done(value);
1087
+ });
1088
+ return;
1089
+ }
1075
1090
  if (def.path === "pet.mode") {
1076
1091
  // The shared pet commit policy rechecks capability immediately
1077
1092
  // before mutation and persists only on acceptance; the settings
@@ -32,7 +32,7 @@ function normalizePremiumRequests(value: number): number {
32
32
  return Math.round((value + Number.EPSILON) * 100) / 100;
33
33
  }
34
34
 
35
- const SCRATCH_ROOTS: readonly string[] = (() => {
35
+ function scratchRoots(): readonly string[] {
36
36
  const roots = new Set<string>([os.tmpdir(), path.join(os.homedir(), "tmp")]);
37
37
  if (process.platform === "win32") {
38
38
  const { TEMP, TMP, SystemRoot } = process.env;
@@ -48,10 +48,10 @@ const SCRATCH_ROOTS: readonly string[] = (() => {
48
48
  }
49
49
  }
50
50
  return [...roots];
51
- })();
51
+ }
52
52
 
53
53
  function classifyProjectDir(pwd: string): { scratch: boolean; relative: string | null } {
54
- for (const root of SCRATCH_ROOTS) {
54
+ for (const root of scratchRoots()) {
55
55
  if (pathIsWithin(root, pwd)) {
56
56
  return { scratch: true, relative: relativePathWithinRoot(root, pwd) };
57
57
  }
@@ -4,7 +4,7 @@ import type { Api, Model } from "@gajae-code/ai/core";
4
4
  import { getOAuthProviders } from "@gajae-code/ai/utils/oauth";
5
5
  import type { OAuthProvider } from "@gajae-code/ai/utils/oauth/types";
6
6
  import type { Component, OverlayHandle, SlashCommand } from "@gajae-code/tui";
7
- import { Input, isPetMode, Loader, Spacer, Text } from "@gajae-code/tui";
7
+ import { Input, Loader, resolvePetMode, Spacer, Text } from "@gajae-code/tui";
8
8
  import { getAgentDbPath, getProjectDir, logger, VERSION } from "@gajae-code/utils";
9
9
  import type { AppKeybinding } from "../../config/keybindings";
10
10
  import {
@@ -1772,6 +1772,36 @@ export class SelectorController {
1772
1772
  this.#refreshThemeUi();
1773
1773
  });
1774
1774
  },
1775
+ onThemeCommit: async (path, themeName, previousTheme) => {
1776
+ if (!settings.canWriteDurableConfig()) {
1777
+ this.ctx.showError(
1778
+ "Cannot change settings while config.yml has invalid YAML syntax. Repair config.yml and reload settings.",
1779
+ );
1780
+ return false;
1781
+ }
1782
+ const applied = await restoreThemePreview(themeName);
1783
+ if (!applied.success) {
1784
+ if (applied.error && !isThemePreviewSuperseded(applied)) {
1785
+ this.ctx.showError(`Failed to apply theme "${themeName}": ${applied.error}`);
1786
+ }
1787
+ const restored = await restoreThemePreview(previousTheme);
1788
+ if (!restored.success && restored.error && !isThemePreviewSuperseded(restored)) {
1789
+ this.ctx.showError(`Failed to restore theme preview: ${restored.error}`);
1790
+ }
1791
+ this.#refreshThemeUi();
1792
+ return false;
1793
+ }
1794
+ try {
1795
+ settings.set(path, themeName);
1796
+ } catch (error) {
1797
+ await restoreThemePreview(previousTheme);
1798
+ this.ctx.showError(error instanceof Error ? error.message : String(error));
1799
+ this.#refreshThemeUi();
1800
+ return false;
1801
+ }
1802
+ this.#refreshThemeUi();
1803
+ return true;
1804
+ },
1775
1805
  onPetPreview: mode => {
1776
1806
  this.ctx.previewPetMode(mode as PetMode);
1777
1807
  },
@@ -1853,6 +1883,8 @@ export class SelectorController {
1853
1883
  showThemeSelector(): void {
1854
1884
  getAvailableThemes().then(availableThemes => {
1855
1885
  const initialTheme = getCurrentThemeName() ?? "red-claw";
1886
+ const settingsPath = getDetectedThemeSettingsPath();
1887
+ const savedTheme = settings.get(settingsPath);
1856
1888
  this.showSelector(done => {
1857
1889
  const restoreAndClose = () => {
1858
1890
  void restoreThemePreview(initialTheme).then(result => {
@@ -1875,7 +1907,7 @@ export class SelectorController {
1875
1907
  return;
1876
1908
  }
1877
1909
  try {
1878
- settings.set(getDetectedThemeSettingsPath(), themeName);
1910
+ settings.set(settingsPath, themeName);
1879
1911
  } catch (error) {
1880
1912
  if (!settings.canWriteDurableConfig()) {
1881
1913
  this.ctx.showError(error instanceof Error ? error.message : String(error));
@@ -1884,8 +1916,21 @@ export class SelectorController {
1884
1916
  }
1885
1917
  throw error;
1886
1918
  }
1887
- this.#refreshThemeUi();
1888
- done();
1919
+ void restoreThemePreview(themeName).then(result => {
1920
+ if (!result.success && result.error && !isThemePreviewSuperseded(result)) {
1921
+ this.ctx.showError(`Failed to apply theme: ${result.error}`);
1922
+ settings.set(settingsPath, savedTheme);
1923
+ void restoreThemePreview(initialTheme).then(recovery => {
1924
+ if (!recovery.success && recovery.error && !isThemePreviewSuperseded(recovery)) {
1925
+ this.ctx.showError(`Failed to restore theme preview: ${recovery.error}`);
1926
+ }
1927
+ this.#refreshThemeUi();
1928
+ });
1929
+ return;
1930
+ }
1931
+ this.#refreshThemeUi();
1932
+ done();
1933
+ });
1889
1934
  },
1890
1935
  restoreAndClose,
1891
1936
  themeName => {
@@ -1904,7 +1949,7 @@ export class SelectorController {
1904
1949
 
1905
1950
  showPetSelector(): void {
1906
1951
  const stored = settings.get("pet.mode");
1907
- const initial: PetMode = isPetMode(stored) ? stored : "off";
1952
+ const initial: PetMode = resolvePetMode(stored);
1908
1953
  this.showSelector(done => {
1909
1954
  // Live-preview via previewMode (no editor re-mount, so the overlay stays);
1910
1955
  // Enter commits + persists, Esc restores the initial skin.
@@ -2091,6 +2136,17 @@ export class SelectorController {
2091
2136
  });
2092
2137
  break;
2093
2138
  }
2139
+ case "theme.dark":
2140
+ case "theme.light": {
2141
+ restoreThemePreview(value as string).then(result => {
2142
+ if (this.ctx.isStopped?.()) return;
2143
+ this.#refreshThemeUi();
2144
+ if (!result.success && result.error && !isThemePreviewSuperseded(result)) {
2145
+ this.ctx.showError(`Failed to apply theme "${value}": ${result.error}`);
2146
+ }
2147
+ });
2148
+ break;
2149
+ }
2094
2150
  case "pet.mode":
2095
2151
  // The settings submenu already persisted the value; apply it to the live
2096
2152
  // widget via previewMode (the settings overlay is still open, so a full
@@ -1345,7 +1345,11 @@ export class InteractiveMode implements InteractiveModeContext {
1345
1345
  this.editorContainer.clear();
1346
1346
  this.editorContainer.addChild(this.editor);
1347
1347
  }
1348
+ // Re-mounting after a cancelled action must invalidate the editor so the
1349
+ // inline prompt suggestion is rendered again on the restored composer.
1350
+ this.editor.invalidate();
1348
1351
  this.ui.setFocus(this.editor);
1352
+ this.ui.requestRender();
1349
1353
  }
1350
1354
 
1351
1355
  #createPetWidget(editor: CustomEditor): GajaePetWidget {