@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
@@ -1,6 +1,5 @@
1
1
  import * as fs from "node:fs";
2
- import type { AgentMessage } from "@gajae-code/agent-core";
3
- import { estimateMessageTokensHeuristic } from "@gajae-code/agent-core/compaction";
2
+
4
3
  import { type Component, truncateToWidth, visibleWidth } from "@gajae-code/tui";
5
4
  import { formatCount, getProjectDir } from "@gajae-code/utils";
6
5
  import { $ } from "bun";
@@ -13,7 +12,6 @@ import * as git from "../../utils/git";
13
12
  import { getSessionAccentAnsi, getSessionAccentHex } from "../../utils/session-color";
14
13
  import { EMPTY_JOBS_SNAPSHOT, type JobsSnapshot } from "../jobs-observer";
15
14
  import { sanitizeStatusText } from "../shared";
16
- import { computeNonMessageTokens } from "../utils/context-usage";
17
15
  import { renderSkillHudBar } from "./skill-hud/render";
18
16
  import {
19
17
  canReuseCachedPr,
@@ -70,104 +68,6 @@ interface CollectedStatusSegments {
70
68
  rightCapWidth: number;
71
69
  }
72
70
 
73
- // ═══════════════════════════════════════════════════════════════════════════
74
- // Per-message token cache
75
- // ═══════════════════════════════════════════════════════════════════════════
76
-
77
- /**
78
- * Symbol-keyed sidecar tagged onto each `AgentMessage` to memoize its
79
- * `estimateMessageTokensHeuristic` result. Keyed by message identity (the object itself);
80
- * a cheap content fingerprint detects in-place mutations (post-hoc error
81
- * attachment, retry-truncated branch rebuild, etc.) and forces recompute.
82
- *
83
- * Cache lives on the message — multiple `StatusLineComponent` instances
84
- * share it for free, and entries collect with the message itself when the
85
- * conversation is replaced or compacted.
86
- */
87
- const kTokenCache = Symbol("statusLine.tokenCache");
88
- interface TaggedMessage {
89
- [kTokenCache]?: { fingerprint: string; tokens: number };
90
- }
91
-
92
- /**
93
- * Cheap structural fingerprint mirroring `estimateMessageTokensHeuristic`'s content walk.
94
- * O(blocks) — only reads string `.length` and primitives, never copies or
95
- * serializes content. Any in-place mutation that alters total tokenized
96
- * content also alters one of the byte-length sums or block counts captured
97
- * here, forcing the cached heuristic token value to be recomputed.
98
- */
99
- function messageFingerprint(msg: AgentMessage): string {
100
- const role = (msg as { role?: string }).role ?? "";
101
- const ts = (msg as { timestamp?: number }).timestamp ?? 0;
102
- let textLen = 0;
103
- let blocks = 0;
104
- let images = 0;
105
- if (role === "bashExecution") {
106
- const b = msg as { command?: unknown; output?: unknown };
107
- if (typeof b.command === "string") textLen += b.command.length;
108
- if (typeof b.output === "string") textLen += b.output.length;
109
- } else if (role === "user") {
110
- const content = (msg as { content?: unknown }).content;
111
- if (typeof content === "string") {
112
- textLen += content.length;
113
- } else if (Array.isArray(content)) {
114
- blocks = content.length;
115
- for (const block of content) {
116
- if (block?.type === "text" && typeof block.text === "string") textLen += block.text.length;
117
- }
118
- }
119
- } else if (role === "assistant") {
120
- const content = (msg as { content?: unknown }).content;
121
- if (Array.isArray(content)) {
122
- blocks = content.length;
123
- for (const block of content) {
124
- if (!block || typeof block !== "object") continue;
125
- const b = block as { type?: string; text?: string; thinking?: string; name?: string; arguments?: unknown };
126
- if (b.type === "text" && typeof b.text === "string") textLen += b.text.length;
127
- else if (b.type === "thinking" && typeof b.thinking === "string") textLen += b.thinking.length;
128
- else if (b.type === "toolCall") {
129
- if (typeof b.name === "string") textLen += b.name.length;
130
- // Argument bytes vary; a length proxy is enough to detect in-place edits.
131
- textLen += b.arguments === undefined ? 0 : JSON.stringify(b.arguments).length;
132
- }
133
- }
134
- }
135
- } else if (role === "toolResult" || role === "hookMessage") {
136
- const content = (msg as { content?: unknown }).content;
137
- if (typeof content === "string") {
138
- textLen += content.length;
139
- } else if (Array.isArray(content)) {
140
- blocks = content.length;
141
- for (const block of content) {
142
- if (!block || typeof block !== "object") continue;
143
- const b = block as { type?: string; text?: string };
144
- if (b.type === "text" && typeof b.text === "string") textLen += b.text.length;
145
- else if (b.type === "image") images++;
146
- }
147
- }
148
- } else if (role === "branchSummary" || role === "compactionSummary") {
149
- const s = (msg as { summary?: unknown }).summary;
150
- if (typeof s === "string") textLen += s.length;
151
- }
152
- return `${role}:${ts}:${textLen}:${blocks}:${images}`;
153
- }
154
-
155
- /**
156
- * Token count for a single message, using the per-message sidecar cache.
157
- * The caller MUST skip caching for the last message during streaming —
158
- * it may still be growing and its tokens belong recomputed each refresh.
159
- */
160
- function tokensForMessage(msg: AgentMessage): number {
161
- const fp = messageFingerprint(msg);
162
- const tagged = msg as TaggedMessage;
163
- const cached = tagged[kTokenCache];
164
- if (cached && cached.fingerprint === fp) return cached.tokens;
165
- const tokens = estimateMessageTokensHeuristic(msg);
166
- tagged[kTokenCache] = { fingerprint: fp, tokens };
167
- return tokens;
168
- }
169
-
170
- // ═══════════════════════════════════════════════════════════════════════════
171
71
  // StatusLineComponent
172
72
  // ═══════════════════════════════════════════════════════════════════════════
173
73
 
@@ -217,20 +117,6 @@ export class StatusLineComponent implements Component {
217
117
  #cachedUsage: SegmentContext["usage"] = null;
218
118
  #usageFetchedAt = 0;
219
119
  #usageInFlight = false;
220
- // Context breakdown — incremental cache. Replaces the previous 2-second
221
- // TTL design (which re-walked every message on each refresh and produced
222
- // ~1.1 s sync freezes on 2,000+ message sessions because `updateEditorTopBorder`
223
- // is called on every agent event in event-controller). The new scheme
224
- // caches by message-object identity (a Symbol-keyed sidecar on each
225
- // message) plus a cheap content fingerprint, so in-place mutations of
226
- // an existing message (post-hoc error attachment, retry-truncated
227
- // branch rebuild, replaceMessages with the same length) are detected
228
- // and recomputed.
229
- // Cached non-message total (system prompt + tools + skills). Invalidated
230
- // when the inputs-identity fingerprint changes (model swap, skill toggle,
231
- // tool registration).
232
- #nonMessageTokensCache: number | undefined;
233
- #nonMessageInputsKey: string | undefined;
234
120
 
235
121
  constructor(
236
122
  private readonly session: AgentSession,
@@ -618,57 +504,6 @@ export class StatusLineComponent implements Component {
618
504
  return windows.length > 0 ? { windows } : null;
619
505
  }
620
506
 
621
- /**
622
- * Compute the (cached) used-tokens / context-window totals for the
623
- * status-line context% segment. Exposed (non-private) so unit tests can
624
- * verify the incremental-cache invariants; not part of any external
625
- * API.
626
- */
627
- getCachedContextBreakdown(): { usedTokens: number; contextWindow: number } {
628
- const messages = this.session.messages ?? [];
629
- const contextWindow = this.session.model?.contextWindow ?? 0;
630
-
631
- // 1) Non-message tokens (system prompt + tools + skills). Refresh only
632
- // when the inputs identity fingerprint changes — usually never
633
- // during a streaming turn. ~10-30 ms when it does refresh.
634
- const inputsKey = this.#computeNonMessageInputsKey();
635
- if (this.#nonMessageTokensCache === undefined || this.#nonMessageInputsKey !== inputsKey) {
636
- this.#nonMessageTokensCache = computeNonMessageTokens(this.session);
637
- this.#nonMessageInputsKey = inputsKey;
638
- }
639
-
640
- // 2) Message tokens — incremental. The sidecar cache lives on the
641
- // message object itself (Symbol-keyed), keyed by identity and
642
- // validated by a cheap content fingerprint. Mutations that
643
- // replace messages (replaceMessages, branch rebuild, compaction)
644
- // yield fresh objects → cache miss → recompute. In-place
645
- // mutations on the same object are caught by fingerprint
646
- // mismatch. The LAST message is always recomputed because it
647
- // may still be growing during streaming.
648
- let messagesTokens = 0;
649
- const lastIdx = messages.length - 1;
650
- for (let i = 0; i < messages.length; i++) {
651
- messagesTokens += i === lastIdx ? estimateMessageTokensHeuristic(messages[i]) : tokensForMessage(messages[i]);
652
- }
653
-
654
- const usedTokens = this.#nonMessageTokensCache + messagesTokens;
655
- return { usedTokens, contextWindow };
656
- }
657
-
658
- /**
659
- * Build an identity fingerprint for the non-message inputs (system prompt,
660
- * tools, skills). When this changes, the non-message token cache must be
661
- * recomputed. Cheap: just lengths + first-string-length. Doesn't need to
662
- * be cryptographically unique — only stable for the same inputs.
663
- */
664
- #computeNonMessageInputsKey(): string {
665
- const sp = this.session.systemPrompt ?? [];
666
- const tools = this.session.agent?.state?.tools ?? [];
667
- const skills = this.session.skills ?? [];
668
- const modelId = this.session.model?.id ?? "";
669
- return `${modelId}|${sp.length}:${sp[0]?.length ?? 0}|${tools.length}|${skills.length}`;
670
- }
671
-
672
507
  #buildSegmentContext(
673
508
  width: number,
674
509
  effectiveSettings: Required<
@@ -695,11 +530,9 @@ export class StatusLineComponent implements Component {
695
530
  tokensPerSecond: this.#getTokensPerSecond(),
696
531
  };
697
532
 
698
- // Context usage — aligned with /context command so both surfaces report the same value
699
- const breakdown = this.getCachedContextBreakdown();
700
- const contextTokens = breakdown.usedTokens;
701
- const contextWindow = breakdown.contextWindow || state.model?.contextWindow || 0;
702
- const contextPercent = contextWindow > 0 ? (contextTokens / contextWindow) * 100 : 0;
533
+ const contextUsage = this.session.getContextUsage?.();
534
+ const contextWindow = contextUsage?.contextWindow ?? state.model?.contextWindow ?? 0;
535
+ const contextPercent = contextUsage?.percent ?? null;
703
536
  // Suppress the inline model percentage when a standalone context_pct
704
537
  // segment is also rendered, so the value is not shown twice.
705
538
  const contextPctSegmentActive =
@@ -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
  */
@@ -578,7 +583,7 @@ export class ExtensionUiController {
578
583
  shutdown: () => {
579
584
  // Signal shutdown request
580
585
  },
581
- getSystemPrompt: () => this.ctx.session.systemPrompt,
586
+ getSystemPrompt: () => [...this.ctx.session.systemPrompt],
582
587
  });
583
588
  } catch (err) {
584
589
  this.showToolError(registeredTool.definition.name, err instanceof Error ? err.message : String(err));
@@ -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;
@@ -79,6 +79,7 @@ import { HistorySearchComponent } from "../components/history-search";
79
79
  import { JobsOverlayComponent } from "../components/jobs-overlay";
80
80
  import { ModelSelectorComponent } from "../components/model-selector";
81
81
  import { OAuthSelectorComponent } from "../components/oauth-selector";
82
+ import { isPetAvailable } from "../components/pet-capability";
82
83
  import { PetSelectorComponent } from "../components/pet-selector";
83
84
  import { PluginSelectorComponent } from "../components/plugin-selector";
84
85
  import {
@@ -492,6 +493,7 @@ export class SelectorController {
492
493
  availableThemes,
493
494
  availableModelProfiles: [...this.ctx.session.modelRegistry.getModelProfiles().keys()],
494
495
  cwd: getProjectDir(),
496
+ petAvailable: isPetAvailable(),
495
497
  },
496
498
  {
497
499
  onChange: (id, value) => this.handleSettingChange(id, value),
@@ -514,6 +516,7 @@ export class SelectorController {
514
516
  onPetPreview: mode => {
515
517
  this.ctx.previewPetMode(mode as PetMode);
516
518
  },
519
+ onPetCommit: mode => this.ctx.commitPetPreviewMode(mode as PetMode),
517
520
  onStatusLinePreview: previewSettings => {
518
521
  // Update status line with preview settings
519
522
  this.ctx.statusLine.updateSettings({
@@ -591,6 +594,7 @@ export class SelectorController {
591
594
  showPetSelector(): void {
592
595
  const stored = settings.get("pet.mode");
593
596
  const initial: PetMode = isPetMode(stored) ? stored : "off";
597
+ const available = isPetAvailable();
594
598
  this.showSelector(done => {
595
599
  // Live-preview via previewMode (no editor re-mount, so the overlay stays);
596
600
  // Enter commits + persists, Esc restores the initial skin.
@@ -607,6 +611,7 @@ export class SelectorController {
607
611
  mode => {
608
612
  this.ctx.previewPetMode(mode);
609
613
  },
614
+ available,
610
615
  );
611
616
  return { component: selector, focus: selector.getSelectList() };
612
617
  });
@@ -762,12 +767,6 @@ export class SelectorController {
762
767
  });
763
768
  break;
764
769
  }
765
- case "pet.mode":
766
- // The settings submenu already persisted the value; apply it to the live
767
- // widget via previewMode (the settings overlay is still open, so a full
768
- // re-mount would tear it down — restoreComposer re-mounts on close).
769
- this.ctx.previewPetMode(value as PetMode);
770
- break;
771
770
  case "symbolPreset": {
772
771
  setSymbolPreset(value as "unicode" | "nerd" | "ascii").then(() => {
773
772
  this.ctx.statusLine.invalidate();
@@ -985,21 +984,14 @@ export class SelectorController {
985
984
  assignments,
986
985
  });
987
986
  if (!materializedProfile) {
988
- const overrides = this.ctx.settings.get("task.agentModelOverrides");
989
- const nextOverrides = { ...overrides };
990
- let writesOverrides = false;
991
987
  for (const targetRole of targetRoles) {
992
988
  const target = GJC_MODEL_ASSIGNMENT_TARGETS[targetRole];
993
989
  if (target.settingsPath === "modelRoles") {
994
990
  this.ctx.settings.setModelRole(targetRole, value);
995
991
  } else {
996
- nextOverrides[targetRole] = value;
997
- writesOverrides = true;
992
+ this.ctx.settings.setAgentModelOverride(targetRole, value);
998
993
  }
999
994
  }
1000
- if (writesOverrides) {
1001
- this.ctx.settings.set("task.agentModelOverrides", nextOverrides);
1002
- }
1003
995
  }
1004
996
  modelSelector.refreshRoleAssignments({
1005
997
  currentModel: this.ctx.session.model,
@@ -1069,10 +1061,7 @@ export class SelectorController {
1069
1061
  if (target.settingsPath === "modelRoles") {
1070
1062
  this.ctx.settings.setModelRole(role, value);
1071
1063
  } else {
1072
- this.ctx.settings.set("task.agentModelOverrides", {
1073
- ...this.ctx.settings.get("task.agentModelOverrides"),
1074
- [role]: value,
1075
- });
1064
+ this.ctx.settings.setAgentModelOverride(role, value);
1076
1065
  }
1077
1066
  }
1078
1067
  modelSelector.refreshRoleAssignments({
@@ -21,6 +21,7 @@ import {
21
21
  getRenderCacheRetainedBytes,
22
22
  Loader,
23
23
  Markdown,
24
+ onImageProtocolChanged,
24
25
  ProcessTerminal,
25
26
  Spacer,
26
27
  Text,
@@ -86,6 +87,12 @@ import type { HookEditorComponent } from "./components/hook-editor";
86
87
  import type { HookInputComponent } from "./components/hook-input";
87
88
  import type { HookSelectorComponent } from "./components/hook-selector";
88
89
  import { IrcSplitViewComponent } from "./components/irc-sidebar";
90
+ import {
91
+ getPetUnavailableWarning,
92
+ isPetAvailable,
93
+ isPetCapabilityProbePending,
94
+ warnWhenPetCapabilitySettled,
95
+ } from "./components/pet-capability";
89
96
  import { StatusLineComponent } from "./components/status-line";
90
97
  import type { ToolExecutionHandle } from "./components/tool-execution";
91
98
  import {
@@ -387,6 +394,9 @@ export class InteractiveMode implements InteractiveModeContext {
387
394
  #baseReservedSlashCommandNames: Set<string> = new Set();
388
395
  #cleanupUnsubscribe?: () => void;
389
396
  #subprocessTeardownUnsubscribe?: () => void;
397
+ #petProtocolUnsubscribe?: () => void;
398
+ /** Cancels a startup pet-unavailable warning still awaiting probe settlement. */
399
+ #petUnavailableWarningDisposer?: () => void;
390
400
  readonly #version: string;
391
401
  readonly #changelogMarkdown: string | undefined;
392
402
  #planModePreviousTools: string[] | undefined;
@@ -615,8 +625,35 @@ export class InteractiveMode implements InteractiveModeContext {
615
625
  this.ui.addChild(this.hookWidgetContainerBelow);
616
626
  this.ui.setBottomPinnedComponent(this.statusLine);
617
627
  this.ui.setFocus(this.editor);
628
+ this.petWidget?.dispose();
618
629
  this.petWidget = this.#createPetWidget(this.editor);
619
- this.petWidget.setMode(settings.get("pet.mode"));
630
+ const configuredPetMode = settings.get("pet.mode");
631
+ this.petWidget.setMode(configuredPetMode);
632
+ // The async sixel capability probe can enable graphics after the saved
633
+ // pet mode was applied and dropped (no protocol yet at startup).
634
+ // Re-apply the configured mode when capability arrives so the pet
635
+ // appears without the user re-running /pet.
636
+ this.#petProtocolUnsubscribe?.();
637
+ this.#petProtocolUnsubscribe = onImageProtocolChanged(protocol => {
638
+ if (!protocol) return;
639
+ const saved = settings.get("pet.mode");
640
+ if (saved !== "off" && this.petWidget && this.petWidget.mode === "off") {
641
+ this.petWidget.setMode(saved);
642
+ }
643
+ });
644
+ if (configuredPetMode !== "off" && !isPetAvailable()) {
645
+ // The async Sixel capability probe (started by TUI.start()) may still
646
+ // enable graphics; warn only once the capability question is settled
647
+ // so a supported terminal is never told it is incompatible.
648
+ this.#petUnavailableWarningDisposer?.();
649
+ this.#petUnavailableWarningDisposer = warnWhenPetCapabilitySettled({
650
+ probePending: isPetCapabilityProbePending(),
651
+ onUnavailable: () => {
652
+ this.showStatus(theme.fg("warning", getPetUnavailableWarning()), { dim: false });
653
+ this.ui.requestRender();
654
+ },
655
+ });
656
+ }
620
657
 
621
658
  this.#inputController.setupKeyHandlers();
622
659
  this.#inputController.setupEditorSubmitHandler();
@@ -1064,10 +1101,26 @@ export class InteractiveMode implements InteractiveModeContext {
1064
1101
  this.editor.setTopBorder(undefined);
1065
1102
  }
1066
1103
 
1067
- setPetMode(mode: PetMode): void {
1068
- this.petWidget?.setMode(mode);
1104
+ /**
1105
+ * Single result-returning pet commit policy shared by every entry path
1106
+ * (`/pet`, the pet selector, and the Settings submenu). Capability is
1107
+ * rechecked immediately before mutation, and the preference persists only
1108
+ * after the commit is accepted.
1109
+ */
1110
+ #commitPetMode(mode: PetMode, apply: (mode: PetMode) => void): boolean {
1111
+ if (mode !== "off" && !isPetAvailable()) {
1112
+ this.showStatus(theme.fg("warning", getPetUnavailableWarning()), { dim: false });
1113
+ this.ui.requestRender();
1114
+ return false;
1115
+ }
1116
+ apply(mode);
1069
1117
  settings.set("pet.mode", mode);
1070
1118
  this.ui.requestRender();
1119
+ return true;
1120
+ }
1121
+
1122
+ setPetMode(mode: PetMode): boolean {
1123
+ return this.#commitPetMode(mode, next => this.petWidget?.setMode(next));
1071
1124
  }
1072
1125
 
1073
1126
  previewPetMode(mode: PetMode): void {
@@ -1075,6 +1128,10 @@ export class InteractiveMode implements InteractiveModeContext {
1075
1128
  this.ui.requestRender();
1076
1129
  }
1077
1130
 
1131
+ commitPetPreviewMode(mode: PetMode): boolean {
1132
+ return this.#commitPetMode(mode, next => this.petWidget?.commitPreviewMode(next));
1133
+ }
1134
+
1078
1135
  restoreComposer(): void {
1079
1136
  if (this.petWidget) {
1080
1137
  this.petWidget.remountComposer();
@@ -2074,6 +2131,10 @@ export class InteractiveMode implements InteractiveModeContext {
2074
2131
  }
2075
2132
 
2076
2133
  stop(): void {
2134
+ this.#petProtocolUnsubscribe?.();
2135
+ this.#petProtocolUnsubscribe = undefined;
2136
+ this.#petUnavailableWarningDisposer?.();
2137
+ this.#petUnavailableWarningDisposer = undefined;
2077
2138
  this.petWidget?.dispose();
2078
2139
  this.petWidget = undefined;
2079
2140
  if (this.loadingAnimation) {
@@ -2207,7 +2268,7 @@ export class InteractiveMode implements InteractiveModeContext {
2207
2268
  nextEditor.setText(previousText);
2208
2269
  previousEditor.dispose();
2209
2270
 
2210
- const petMode = this.petWidget?.mode ?? settings.get("pet.mode");
2271
+ const petMode = settings.get("pet.mode");
2211
2272
  this.petWidget?.dispose();
2212
2273
 
2213
2274
  this.editorContainer.clear();