@gajae-code/coding-agent 0.16.3 → 0.16.4

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 (133) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/dist/types/cli/args.d.ts +2 -2
  3. package/dist/types/cli/root-flags.d.ts +4 -2
  4. package/dist/types/cli.d.ts +1 -1
  5. package/dist/types/commands/launch.d.ts +1 -1
  6. package/dist/types/config/autorouting-tier-map.d.ts +3 -0
  7. package/dist/types/config/model-registry.d.ts +4 -0
  8. package/dist/types/config/model-resolver.d.ts +9 -0
  9. package/dist/types/config/settings-schema.d.ts +29 -1
  10. package/dist/types/discovery/claude-plugins.d.ts +3 -1
  11. package/dist/types/discovery/helpers.d.ts +2 -0
  12. package/dist/types/extensibility/gjc-plugins/mcp-policy.d.ts +16 -0
  13. package/dist/types/extensibility/gjc-plugins/paths.d.ts +2 -0
  14. package/dist/types/extensibility/runtime-skill-discovery.d.ts +1 -1
  15. package/dist/types/extensibility/shared-events.d.ts +1 -1
  16. package/dist/types/gjc-runtime/session-state-lock.d.ts +2 -0
  17. package/dist/types/modes/components/assistant-message.d.ts +1 -2
  18. package/dist/types/modes/components/settings-selector.d.ts +6 -1
  19. package/dist/types/modes/components/status-line/command.d.ts +35 -0
  20. package/dist/types/modes/components/status-line/index.d.ts +1 -0
  21. package/dist/types/modes/components/status-line/types.d.ts +5 -0
  22. package/dist/types/modes/components/tool-status-header.d.ts +14 -0
  23. package/dist/types/modes/controllers/event-controller.d.ts +6 -0
  24. package/dist/types/modes/interactive-mode.d.ts +1 -0
  25. package/dist/types/modes/types.d.ts +1 -0
  26. package/dist/types/runtime-mcp/client.d.ts +9 -1
  27. package/dist/types/runtime-mcp/manager.d.ts +7 -1
  28. package/dist/types/runtime-mcp/pool.d.ts +22 -3
  29. package/dist/types/runtime-mcp/transports/stdio.d.ts +0 -4
  30. package/dist/types/runtime-mcp/types.d.ts +22 -0
  31. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +12 -0
  32. package/dist/types/sdk/bus/slack-live-provider.d.ts +6 -0
  33. package/dist/types/sdk/bus/slack-provider.d.ts +12 -0
  34. package/dist/types/sdk/host/session-runtime.d.ts +23 -0
  35. package/dist/types/sdk/session-api-key.d.ts +26 -0
  36. package/dist/types/sdk/transport/serve-cli.d.ts +1 -1
  37. package/dist/types/session/agent-session.d.ts +9 -0
  38. package/dist/types/setup/paseo/announce.d.ts +111 -0
  39. package/dist/types/setup/paseo/setup-deps.d.ts +2 -1
  40. package/dist/types/system-prompt.d.ts +2 -0
  41. package/dist/types/task/executor.d.ts +2 -1
  42. package/dist/types/tools/fetch.d.ts +7 -0
  43. package/dist/types/tools/index.d.ts +8 -0
  44. package/dist/types/tools/path-utils.d.ts +2 -0
  45. package/dist/types/tools/search.d.ts +6 -0
  46. package/package.json +7 -7
  47. package/scripts/capture-status-line-custom-editor-showcase.ts +151 -0
  48. package/scripts/generate-sdk-operation-inventory.ts +9 -0
  49. package/scripts/verify-status-line-custom-editor-showcase.ts +309 -0
  50. package/src/cli/args.ts +20 -9
  51. package/src/cli/root-flags.ts +5 -3
  52. package/src/config/autorouting-tier-map.ts +1 -0
  53. package/src/config/file-lock.ts +181 -17
  54. package/src/config/model-preset-registry.ts +5 -3
  55. package/src/config/model-profiles.ts +40 -0
  56. package/src/config/model-registry.ts +57 -11
  57. package/src/config/model-resolver.ts +48 -0
  58. package/src/config/settings-schema.ts +33 -1
  59. package/src/config/settings.ts +1 -0
  60. package/src/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.ts +11 -11
  61. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.ts +13 -2
  62. package/src/discovery/builtin.ts +13 -4
  63. package/src/discovery/claude-plugins.ts +12 -4
  64. package/src/discovery/helpers.ts +10 -2
  65. package/src/extensibility/gjc-plugins/compiler.ts +208 -58
  66. package/src/extensibility/gjc-plugins/installer.ts +13 -12
  67. package/src/extensibility/gjc-plugins/mcp-policy.ts +81 -52
  68. package/src/extensibility/gjc-plugins/paths.ts +13 -0
  69. package/src/extensibility/gjc-plugins/runtime-adapters.ts +758 -16
  70. package/src/extensibility/runtime-skill-discovery.ts +77 -151
  71. package/src/extensibility/shared-events.ts +1 -1
  72. package/src/extensibility/skills.ts +44 -16
  73. package/src/gjc-runtime/repository-binding.ts +1 -1
  74. package/src/gjc-runtime/session-state-lock.ts +92 -2
  75. package/src/internal-urls/docs-index.generated.ts +7 -7
  76. package/src/main.ts +72 -34
  77. package/src/modes/DESIGN.md +54 -0
  78. package/src/modes/components/assistant-message.ts +22 -9
  79. package/src/modes/components/model-selector.ts +166 -33
  80. package/src/modes/components/settings-selector.ts +1075 -235
  81. package/src/modes/components/status-line/command.ts +190 -0
  82. package/src/modes/components/status-line/index.ts +1 -0
  83. package/src/modes/components/status-line/segments.ts +32 -5
  84. package/src/modes/components/status-line/types.ts +5 -0
  85. package/src/modes/components/tool-status-header.ts +393 -21
  86. package/src/modes/controllers/command-controller.ts +2 -0
  87. package/src/modes/controllers/event-controller.ts +148 -5
  88. package/src/modes/controllers/selector-controller.ts +21 -0
  89. package/src/modes/interactive-mode.ts +47 -1
  90. package/src/modes/types.ts +1 -0
  91. package/src/modes/utils/ui-helpers.ts +1 -0
  92. package/src/prompts/system/system-prompt.md +8 -0
  93. package/src/prompts/tools/search.md +2 -0
  94. package/src/runtime-mcp/client.ts +60 -17
  95. package/src/runtime-mcp/manager.ts +171 -14
  96. package/src/runtime-mcp/pool.test.ts +209 -0
  97. package/src/runtime-mcp/pool.ts +224 -23
  98. package/src/runtime-mcp/transports/stdio.ts +158 -24
  99. package/src/runtime-mcp/types.ts +24 -0
  100. package/src/sdk/broker/broker.ts +5 -4
  101. package/src/sdk/bus/chat-daemon-control.ts +1 -1
  102. package/src/sdk/bus/chat-daemon-runtime.ts +37 -1
  103. package/src/sdk/bus/slack-daemon.ts +92 -2
  104. package/src/sdk/bus/slack-live-provider.ts +12 -0
  105. package/src/sdk/bus/slack-provider.ts +5 -0
  106. package/src/sdk/cli/session-cli.ts +9 -6
  107. package/src/sdk/host/session-runtime.test.ts +2 -2
  108. package/src/sdk/host/session-runtime.ts +275 -43
  109. package/src/sdk/protocol/operation-inventory.generated.json +66 -0
  110. package/src/sdk/session-api-key.ts +94 -0
  111. package/src/sdk/session.ts +73 -29
  112. package/src/sdk/transport/serve-cli.ts +3 -3
  113. package/src/session/agent-session.ts +123 -38
  114. package/src/session/contribution-prep.ts +214 -21
  115. package/src/setup/paseo/announce.ts +450 -0
  116. package/src/setup/paseo/setup-deps.ts +15 -3
  117. package/src/slash-commands/builtin-registry.ts +1 -0
  118. package/src/system-prompt.ts +4 -0
  119. package/src/task/executor.ts +2 -1
  120. package/src/task/index.ts +17 -4
  121. package/src/tools/bash.ts +35 -8
  122. package/src/tools/cron.ts +1 -1
  123. package/src/tools/fetch.ts +29 -1
  124. package/src/tools/index.ts +8 -0
  125. package/src/tools/job.ts +8 -2
  126. package/src/tools/monitor.ts +8 -2
  127. package/src/tools/path-utils.ts +7 -0
  128. package/src/tools/search.ts +116 -58
  129. package/src/tools/skill.ts +22 -2
  130. package/src/tools/subagent.ts +3 -1
  131. package/src/tools/tool-catalog.generated.ts +8 -1
  132. package/src/web/insane/bridge.ts +113 -23
  133. package/src/web/search/providers/perplexity.ts +102 -41
package/src/main.ts CHANGED
@@ -31,7 +31,15 @@ import { selectSession } from "./cli/session-picker";
31
31
  import { findConfigFile } from "./config";
32
32
  import { activateModelProfile, ModelProfileCredentialError } from "./config/model-profile-activation";
33
33
  import { ModelRegistry, ModelsConfigFile } from "./config/model-registry";
34
- import { resolveCliModel, resolveModelRoleValue, resolveModelScope, type ScopedModel } from "./config/model-resolver";
34
+ import {
35
+ parseModelString,
36
+ refreshMissingQualifiedModelProviders,
37
+ resolveCliModel,
38
+ resolveModelRoleValue,
39
+ resolveModelScope,
40
+ resolveStartupModelRefreshSelectors,
41
+ type ScopedModel,
42
+ } from "./config/model-resolver";
35
43
  import { selectorHead } from "./config/model-selector-value";
36
44
  import { getDefault, type SettingPath, Settings, settings } from "./config/settings";
37
45
  import { resolveMachineLocalUpdateChannel, type UpdateChannel } from "./config/update-channel";
@@ -49,7 +57,7 @@ import type { PrintModeOptions } from "./modes/print-mode";
49
57
  import { initTheme, stopThemeWatcher } from "./modes/theme/theme";
50
58
  import type { SubmittedUserInput } from "./modes/types";
51
59
  import { applyCliRuntimeApiKeyOverride } from "./runtime-api-key";
52
- import { parseCliCredentialSelector } from "./runtime-credential-selector";
60
+ import { type CliCredentialSelector, parseCliCredentialSelector } from "./runtime-credential-selector";
53
61
  import type { MCPManager } from "./runtime-mcp";
54
62
  import {
55
63
  type CreateAgentSessionOptions,
@@ -1645,6 +1653,66 @@ export async function runRootCommand(
1645
1653
  )
1646
1654
  : undefined;
1647
1655
 
1656
+ if (parsedArgs.apiKey && parsedArgs.credential) {
1657
+ process.stderr.write(`${chalk.red("--api-key and --credential cannot be used together")}\n`);
1658
+ process.exit(1);
1659
+ }
1660
+ if (parsedArgs.credential && parsedArgs.preferCredential) {
1661
+ process.stderr.write(`${chalk.red("--credential and --prefer-credential cannot be used together")}\n`);
1662
+ process.exit(1);
1663
+ }
1664
+ if (parsedArgs.apiKey && parsedArgs.preferCredential) {
1665
+ process.stderr.write(`${chalk.red("--api-key and --prefer-credential cannot be used together")}\n`);
1666
+ process.exit(1);
1667
+ }
1668
+
1669
+ let credentialSelector: CliCredentialSelector | undefined;
1670
+ let preferredCredentialSelector: CliCredentialSelector | undefined;
1671
+ try {
1672
+ credentialSelector = parsedArgs.credential ? parseCliCredentialSelector(parsedArgs.credential) : undefined;
1673
+ preferredCredentialSelector = parsedArgs.preferCredential
1674
+ ? parseCliCredentialSelector(parsedArgs.preferCredential)
1675
+ : undefined;
1676
+ if (preferredCredentialSelector) {
1677
+ const provider =
1678
+ preferredCredentialSelector.provider ??
1679
+ authStorage.resolveRuntimePreferredCredentialSelectorProvider(preferredCredentialSelector.selector);
1680
+ authStorage.setRuntimePreferredCredentialSelector(provider, preferredCredentialSelector.selector);
1681
+ }
1682
+ } catch (error) {
1683
+ const message = error instanceof Error ? error.message : String(error);
1684
+ process.stderr.write(`${chalk.red(message)}\n`);
1685
+ process.exit(1);
1686
+ }
1687
+
1688
+ const hasRootStartupProfile = Boolean(settingsInstance.get("modelProfile.default") || parsedArgs.mpreset);
1689
+ const startupModelSelectors = resolveStartupModelRefreshSelectors(
1690
+ {
1691
+ model: parsedArgs.model,
1692
+ provider: parsedArgs.provider,
1693
+ credential: parsedArgs.credential,
1694
+ continue: parsedArgs.continue,
1695
+ resume: parsedArgs.resume,
1696
+ hasStartupProfile: hasRootStartupProfile,
1697
+ },
1698
+ settingsInstance,
1699
+ );
1700
+ const explicitStartupModelSelector = parsedArgs.model
1701
+ ? typeof startupModelSelectors === "string"
1702
+ ? startupModelSelectors
1703
+ : parsedArgs.model
1704
+ : undefined;
1705
+ if (parsedArgs.apiKey && explicitStartupModelSelector) {
1706
+ const parsedModel = parseModelString(explicitStartupModelSelector);
1707
+ if (parsedModel) authStorage.setRuntimeApiKey(parsedModel.provider, parsedArgs.apiKey);
1708
+ }
1709
+ await logger.time(
1710
+ "refreshStartupModelProviders",
1711
+ refreshMissingQualifiedModelProviders,
1712
+ startupModelSelectors,
1713
+ modelRegistry,
1714
+ );
1715
+
1648
1716
  let scopedModels: ScopedModel[] = [];
1649
1717
  const modelPatterns = parsedArgs.models ?? settingsInstance.get("enabledModels");
1650
1718
  const modelMatchPreferences = {
@@ -1779,7 +1847,6 @@ export async function runRootCommand(
1779
1847
  if (isInteractive && sessionOptions.mcpConfigPath) {
1780
1848
  sessionOptions.deferMcpConfigStartup = true;
1781
1849
  }
1782
- const hasRootStartupProfile = Boolean(settingsInstance.get("modelProfile.default") || parsedArgs.mpreset);
1783
1850
  // ACP is not carved out: `gjc acp` is broker-backed and never builds a local
1784
1851
  // session here, and the broker-launched lifecycle child defers memory startup
1785
1852
  // unconditionally (createLifecycleAgentSession) so readiness never waits on
@@ -1790,37 +1857,8 @@ export async function runRootCommand(
1790
1857
  const acpStartupOptions = mode === "acp" ? resolveAcpStartupOptions(parsedArgs, sessionOptions) : undefined;
1791
1858
 
1792
1859
  // Handle CLI credential selection and --api-key as runtime overrides (not persisted).
1793
- if (parsedArgs.apiKey && parsedArgs.credential) {
1794
- process.stderr.write(`${chalk.red("--api-key and --credential cannot be used together")}\n`);
1795
- process.exit(1);
1796
- }
1797
- if (parsedArgs.credential && parsedArgs.preferCredential) {
1798
- process.stderr.write(`${chalk.red("--credential and --prefer-credential cannot be used together")}\n`);
1799
- process.exit(1);
1800
- }
1801
- if (parsedArgs.apiKey && parsedArgs.preferCredential) {
1802
- process.stderr.write(`${chalk.red("--api-key and --prefer-credential cannot be used together")}\n`);
1803
- process.exit(1);
1804
- }
1805
-
1806
- if (parsedArgs.credential) {
1807
- try {
1808
- sessionOptions.credentialSelector = parseCliCredentialSelector(parsedArgs.credential);
1809
- } catch (error) {
1810
- const message = error instanceof Error ? error.message : String(error);
1811
- process.stderr.write(`${chalk.red(message)}\n`);
1812
- process.exit(1);
1813
- }
1814
- }
1815
- if (parsedArgs.preferCredential) {
1816
- try {
1817
- sessionOptions.preferredCredentialSelector = parseCliCredentialSelector(parsedArgs.preferCredential);
1818
- } catch (error) {
1819
- const message = error instanceof Error ? error.message : String(error);
1820
- process.stderr.write(`${chalk.red(message)}\n`);
1821
- process.exit(1);
1822
- }
1823
- }
1860
+ if (credentialSelector) sessionOptions.credentialSelector = credentialSelector;
1861
+ if (preferredCredentialSelector) sessionOptions.preferredCredentialSelector = preferredCredentialSelector;
1824
1862
  if (parsedArgs.apiKey) {
1825
1863
  if (!sessionOptions.model && !sessionOptions.modelPattern) {
1826
1864
  process.stderr.write(
@@ -347,6 +347,60 @@ CJK/Latin todo, IRC, status, and BTW text break at semantic phrase boundaries,
347
347
  not inside a phase/action/status label, short identifier, or grapheme cluster.
348
348
  Those defects, width overflow, overlap, hidden focus/cursor, and lost anchors
349
349
  are blocking visual-QA failures.
350
+
351
+ ### Status Line Custom Editor simulation
352
+
353
+ The Appearance tab's Status Line Custom Editor is a spatial editor, not a
354
+ generic setting row list. It renders a simulated statusbar with left/right
355
+ segment areas, a hidden-segment palette, visible Separator and segment-option
356
+ controls, and Confirm/Exit actions. It must not render legacy `Segment`,
357
+ `Move left`, or `Move right` rows.
358
+
359
+ The editor owns transient state locally: focused statusbar slot, focused palette
360
+ item, focused root control, open visible-choice panel, picked segment, floating
361
+ ghost, empty origin slot, and overflow warning. Durable settings remain only the
362
+ existing `statusLine.leftSegments`, `statusLine.rightSegments`,
363
+ `statusLine.separator`, and `statusLine.segmentOptions` keys; Confirm persists
364
+ through the existing settings guard, while Exit or root-state Escape restores
365
+ the previous preview and closes.
366
+
367
+ Focus variants are `statusbar`, `palette`, `separator-control`,
368
+ `option-control(path)`, `choice(target, focusedValue, returnFocus)`, `confirm`,
369
+ and `exit`. Left/Right is owned by the editor while active and never switches
370
+ settings tabs. Enter selects/deselects, picks/drops, applies a focused choice,
371
+ or activates Confirm/Exit according to focus. Up/Down traverses palette,
372
+ visible root controls, actions, and choice candidates. Escape inside `choice`
373
+ returns to the opener without mutation; Escape everywhere else cancels and
374
+ restores. Delete only hides a focused visible statusbar segment.
375
+
376
+ Separator and every segment option are visible-choice controls, never cycle-only
377
+ rows. Descriptor data owns labels, allowed values, visible current-value
378
+ formatting, and typed draft application. Separator choices are Powerline, Thin
379
+ chevron, Slash, Pipe, Block, None, and ASCII. Boolean options show `true` and
380
+ `false`; enum options show their union values; `path.maxLength` shows `16`,
381
+ `24`, `32`, `40`, `50`, `60`, and `80`.
382
+
383
+ At constrained width the simulated statusbar renders as two rows and emits an
384
+ explicit overflow/wrap warning. ANSI-aware cell measurement remains mandatory;
385
+ ASCII/no-color output must retain textual focus, ghost/origin, selected choice,
386
+ and warning affordances.
387
+
388
+ Deterministic evidence belongs to
389
+ `test/fixtures/tui/status-line-custom-editor-showcase.ts`, captured by
390
+ `scripts/capture-status-line-custom-editor-showcase.ts --out
391
+ .gjc/qa/status-line-custom-editor-<run>` and verified by
392
+ `scripts/verify-status-line-custom-editor-showcase.ts --root
393
+ .gjc/qa/status-line-custom-editor-<run> --require-independent-review`. The
394
+ canonical states are `root-statusbar`, `picked-origin-slot`,
395
+ `palette-exact-insert`, `separator-control`, `separator-choice-focused`,
396
+ `separator-choice-applied`, `option-boolean-choice-focused`,
397
+ `option-enum-choice-focused`, `option-numeric-choice-focused`,
398
+ `option-choice-applied`, `exit-restored`, `confirm-persisted`,
399
+ `overflow-two-row-warning`, and `narrow-cjk`. Each state records
400
+ `terminal.txt`, `terminal-ansi.txt`, `terminal.html`, and `metadata.json`, plus
401
+ the root manifest. Missing state witnesses, ANSI/plain mismatch, invalid
402
+ metadata, and absent independent review are blocking visual-QA failures.
403
+
350
404
  ### Sticky viewport deterministic visual QA
351
405
 
352
406
  `test/fixtures/tui/sticky-viewport-showcase.ts` is a fixed-clock, no-network,
@@ -106,16 +106,24 @@ export class AssistantMessageComponent extends Container {
106
106
  #toolImagesRevision = 0;
107
107
  #convertedToolImagesRevision = 0;
108
108
  #disposed = false;
109
+ // Cached Markdown children can outlive their attachment to this assistant.
110
+ // Session/live-parent validity belongs to the owner, not the paint lifetime.
111
+ readonly #requestRepaint?: () => void;
109
112
  constructor(
110
113
  message?: AssistantMessage,
111
114
  private hideThinkingBlock = false,
112
- private readonly onImageUpdate?: () => void,
115
+ onImageUpdate?: () => void,
113
116
  viewportAnchorId?: string,
114
117
  private readonly onVisibleMutation?: () => void,
115
118
  private readonly hintSession?: ProviderSafetyStopHintSession,
116
119
  ) {
117
120
  super();
118
121
  this.#viewportAnchorId = viewportAnchorId;
122
+ if (onImageUpdate) {
123
+ this.#requestRepaint = () => {
124
+ if (!this.#disposed) onImageUpdate();
125
+ };
126
+ }
119
127
 
120
128
  this.#contentContainer = new Container();
121
129
  this.addChild(this.#contentContainer);
@@ -172,6 +180,7 @@ export class AssistantMessageComponent extends Container {
172
180
  }
173
181
 
174
182
  #notifyVisibleMutation(): void {
183
+ if (this.#disposed) return;
175
184
  const after = this.#visibleContentSignature();
176
185
  if (after === this.#visibleSignature) return;
177
186
  this.#visibleSignature = after;
@@ -186,6 +195,7 @@ export class AssistantMessageComponent extends Container {
186
195
  }
187
196
 
188
197
  override invalidate(): void {
198
+ if (this.#disposed) return;
189
199
  super.invalidate();
190
200
  if (this.#lastMessage) {
191
201
  this.updateContent(this.#lastMessage, { streaming: this.#lastStreaming });
@@ -193,13 +203,14 @@ export class AssistantMessageComponent extends Container {
193
203
  }
194
204
 
195
205
  setHideThinkingBlock(hide: boolean): void {
206
+ if (this.#disposed) return;
196
207
  if (this.hideThinkingBlock === hide) return;
197
208
  this.hideThinkingBlock = hide;
198
209
  this.#notifyVisibleMutation();
199
210
  }
200
211
 
201
212
  setToolResultImages(toolCallId: string, images: ImageContent[]): void {
202
- if (!toolCallId) return;
213
+ if (this.#disposed || !toolCallId) return;
203
214
  const validImages = images.filter(img => img.type === "image" && img.data && img.mimeType);
204
215
  const previousImages = this.#toolImagesByCallId.get(toolCallId);
205
216
  if (
@@ -268,7 +279,7 @@ export class AssistantMessageComponent extends Container {
268
279
  this.#convertedKittyImages.set(key, { type: "image", data, mimeType: "image/png", source });
269
280
  this.#convertedToolImagesRevision += 1;
270
281
  if (this.#lastMessage) this.updateContent(this.#lastMessage, { streaming: this.#lastStreaming });
271
- this.onImageUpdate?.();
282
+ this.#requestRepaint?.();
272
283
  })
273
284
  .catch(() => {
274
285
  if (
@@ -283,6 +294,7 @@ export class AssistantMessageComponent extends Container {
283
294
  }
284
295
 
285
296
  setUsageInfo(usage: Usage): void {
297
+ if (this.#disposed) return;
286
298
  this.#usageInfo = usage;
287
299
  if (this.#lastMessage) {
288
300
  this.updateContent(this.#lastMessage, { streaming: this.#lastStreaming });
@@ -311,7 +323,7 @@ export class AssistantMessageComponent extends Container {
311
323
  const cached = this.#contentBlocksCache.get(content);
312
324
  if (cached?.source === content.text) {
313
325
  if (cached.component instanceof Markdown) {
314
- cached.component.setOnStaleThrottle(this.onImageUpdate);
326
+ cached.component.setOnStaleThrottle(this.#requestRepaint);
315
327
  cached.component.setStreaming(streaming);
316
328
  }
317
329
  return cached.component;
@@ -322,14 +334,14 @@ export class AssistantMessageComponent extends Container {
322
334
  // instead of constructing a new one each chunk; combined with the markdown
323
335
  // per-code-block highlight cache, appends no longer re-highlight the whole prefix.
324
336
  if (!deepInterview && cached && cached.component instanceof Markdown) {
325
- cached.component.setOnStaleThrottle(this.onImageUpdate);
337
+ cached.component.setOnStaleThrottle(this.#requestRepaint);
326
338
  cached.component.setText(trimmed, { streaming });
327
339
  cached.source = content.text;
328
340
  return cached.component;
329
341
  }
330
342
  const component = deepInterview ?? new Markdown(trimmed, 1, 0, getMarkdownTheme());
331
343
  if (component instanceof Markdown) {
332
- component.setOnStaleThrottle(this.onImageUpdate);
344
+ component.setOnStaleThrottle(this.#requestRepaint);
333
345
  component.setStreaming(streaming);
334
346
  }
335
347
  this.#contentBlocksCache.set(content, { source: content.text, component });
@@ -341,14 +353,14 @@ export class AssistantMessageComponent extends Container {
341
353
  const cached = this.#contentBlocksCache.get(content);
342
354
  if (cached?.source === content.thinking) {
343
355
  if (cached.component instanceof Markdown) {
344
- cached.component.setOnStaleThrottle(this.onImageUpdate);
356
+ cached.component.setOnStaleThrottle(this.#requestRepaint);
345
357
  cached.component.setStreaming(streaming);
346
358
  }
347
359
  return cached.component as Markdown;
348
360
  }
349
361
  const trimmed = elideRunawayThinkingRepetition(content.thinking.trim());
350
362
  if (cached?.component instanceof Markdown) {
351
- cached.component.setOnStaleThrottle(this.onImageUpdate);
363
+ cached.component.setOnStaleThrottle(this.#requestRepaint);
352
364
  cached.component.setText(trimmed, { streaming });
353
365
  cached.source = content.thinking;
354
366
  return cached.component;
@@ -357,7 +369,7 @@ export class AssistantMessageComponent extends Container {
357
369
  color: (text: string) => theme.fg("thinkingText", text),
358
370
  italic: true,
359
371
  });
360
- component.setOnStaleThrottle(this.onImageUpdate);
372
+ component.setOnStaleThrottle(this.#requestRepaint);
361
373
  component.setStreaming(streaming);
362
374
  this.#contentBlocksCache.set(content, { source: content.thinking, component });
363
375
  this.#reusableChildren.add(component);
@@ -437,6 +449,7 @@ export class AssistantMessageComponent extends Container {
437
449
  }
438
450
 
439
451
  updateContent(message: AssistantMessage, options?: AssistantMessageUpdateOptions): void {
452
+ if (this.#disposed) return;
440
453
  this.#lastMessage = message;
441
454
  this.#lastStreaming = options?.streaming ?? false;
442
455
 
@@ -410,6 +410,10 @@ export class ModelSelectorComponent extends Container {
410
410
  #unsubscribeProviderOrderChanged: () => void = () => {};
411
411
  #unsubscribeAuthGeneration: () => void = () => {};
412
412
  #disposed = false;
413
+ #catalogLoaded = false;
414
+ #catalogLoadPromise?: Promise<void>;
415
+ #presetCatalogRefreshPending = false;
416
+ #openSmartRoutingAfterPresetRefresh = false;
413
417
  /** Standalone smart-routing entry: cancel closes the selector instead of falling back to the preset landing. */
414
418
  #smartRoutingOnly = false;
415
419
 
@@ -422,6 +426,8 @@ export class ModelSelectorComponent extends Container {
422
426
  #presetScopeIndex: number = 0;
423
427
  #providerAuthById = new Map<string, boolean>();
424
428
  #bareProfileAuthByName = new Map<string, boolean>();
429
+ // Rebuilt with the provider and bare-selector snapshots on every catalog or credential refresh.
430
+ #profileAuthByName = new Map<string, boolean>();
425
431
  #providerAuthPending: boolean = false;
426
432
  #providerAuthRefreshGeneration = 0;
427
433
  #presetLoginHint?: string;
@@ -545,21 +551,39 @@ export class ModelSelectorComponent extends Container {
545
551
  this.#unsubscribeCatalogChanged = this.#modelRegistry.onCatalogChanged(() => {
546
552
  if (this.#disposed) return;
547
553
  if (this.#viewMode === "presets") {
548
- this.#refreshCatalogView();
554
+ if (this.#presetCatalogRefreshPending) return;
555
+ this.#rebuildRoleModels();
549
556
  this.#clampPresetCursor();
550
557
  void this.#refreshProviderAuth();
551
558
  this.#renderPresetLanding();
552
559
  this.#tui.requestRender();
553
560
  return;
554
561
  }
562
+ if (this.#viewMode === "smart-routing" && this.#smartRoutingPanel) return;
563
+ // The in-flight load already consumes the latest registry snapshot. Avoid
564
+ // materializing the same catalog again from its own change notification.
565
+ if (this.#catalogLoadPromise) return;
566
+ if (!this.#catalogLoaded) {
567
+ void this.#initializeCatalogView();
568
+ return;
569
+ }
555
570
  if (this.#refreshCatalogView()) this.#tui.requestRender();
556
571
  });
557
572
  }
558
573
 
559
- // Advisory drift only, and only while the smart-routing panel is mounted. This
560
- // must not call refreshState: that would discard the user's unsaved draft.
574
+ // Proxy and provider-policy changes invalidate preset availability. Provider-order changes are
575
+ // advisory only while smart routing is mounted and must not discard its draft.
561
576
  this.#unsubscribeProviderOrderChanged = this.#settings.onChanged?.(path => {
562
- if (this.#disposed || path !== "modelProviderOrder") return;
577
+ if (this.#disposed) return;
578
+ if (
579
+ path === "modelProfile.proxyProvider" ||
580
+ path === "modelProfile.proxyMode" ||
581
+ path === "disabledProviders"
582
+ ) {
583
+ if (this.#viewMode === "presets") void this.#refreshProviderAuth();
584
+ return;
585
+ }
586
+ if (path !== "modelProviderOrder") return;
563
587
  if (this.#viewMode !== "smart-routing") return;
564
588
  const panel = this.#smartRoutingPanel;
565
589
  if (!panel) return;
@@ -572,34 +596,21 @@ export class ModelSelectorComponent extends Container {
572
596
  void this.#refreshProviderAuth();
573
597
  }) ?? (() => {});
574
598
 
575
- // Load models and do initial render
576
- this.#loadModels().then(() => {
577
- this.#buildProviderTabs();
578
- if (this.#smartRoutingOnly) {
579
- this.#enterSmartRoutingMode();
580
- this.#tui.requestRender();
581
- return;
582
- }
583
- if (this.#viewMode === "presets" && (this.#modelRegistry.getModelProfiles?.().size ?? 0) === 0) {
584
- this.#viewMode = "models";
585
- }
586
- if (this.#viewMode === "presets") {
587
- void this.#refreshProviderAuth();
599
+ if (this.#viewMode === "presets") {
600
+ // The landing only needs profile definitions and provider authentication.
601
+ // Refresh static configuration without enumerating and canonicalizing the
602
+ // full browser catalog, then resolve preset authentication once.
603
+ this.#presetCatalogRefreshPending = true;
604
+ if ((this.#modelRegistry.getModelProfiles?.().size ?? 0) > 0) {
588
605
  this.#renderPresetLanding();
589
606
  } else {
590
- this.#updateTabBar();
591
- // Always apply the current search query — the user may have typed
592
- // while models were loading asynchronously.
593
- const currentQuery = this.#searchInput.getValue();
594
- if (currentQuery) {
595
- this.#filterModels(currentQuery);
596
- } else {
597
- this.#updateList();
598
- }
607
+ this.#listContainer.addChild(new Text(theme.fg("muted", "Loading model presets..."), 0, 0));
599
608
  }
600
- // Request re-render after models are loaded
601
609
  this.#tui.requestRender();
602
- });
610
+ void this.#initializePresetLanding();
611
+ } else {
612
+ void this.#initializeCatalogView();
613
+ }
603
614
  }
604
615
 
605
616
  override dispose(): void {
@@ -1332,6 +1343,10 @@ export class ModelSelectorComponent extends Container {
1332
1343
  return this.#modelRegistry.getModelProfile?.(name) ?? this.#modelRegistry.getModelProfiles?.().get(name);
1333
1344
  }
1334
1345
 
1346
+ #isPresetAvailabilityPending(): boolean {
1347
+ return this.#presetCatalogRefreshPending || this.#providerAuthPending;
1348
+ }
1349
+
1335
1350
  #isProviderAuthenticated(providerId: string): boolean | undefined {
1336
1351
  return this.#providerAuthById.get(providerId);
1337
1352
  }
@@ -1405,9 +1420,13 @@ export class ModelSelectorComponent extends Container {
1405
1420
  return [...missing].sort((a, b) => a.localeCompare(b));
1406
1421
  }
1407
1422
 
1408
- #isPresetAuthenticated(profileOrProfiles: ModelProfileDefinition | ModelProfileDefinition[]): boolean {
1423
+ #isPresetAuthenticated(
1424
+ profileOrProfiles: ModelProfileDefinition | ModelProfileDefinition[],
1425
+ computeCurrent = false,
1426
+ ): boolean {
1409
1427
  const profiles = Array.isArray(profileOrProfiles) ? profileOrProfiles : [profileOrProfiles];
1410
1428
  return profiles.every(profile => {
1429
+ if (!computeCurrent) return this.#profileAuthByName.get(profile.name) === true;
1411
1430
  if (profile.source !== "user") {
1412
1431
  if (inspectProxyProviderId(this.#settings).status === "invalid") return false;
1413
1432
  const proxyProvider = tryResolveProxyProviderId(this.#settings);
@@ -1503,6 +1522,7 @@ export class ModelSelectorComponent extends Container {
1503
1522
  const refreshGeneration = ++this.#providerAuthRefreshGeneration;
1504
1523
  this.#providerAuthById = new Map();
1505
1524
  this.#bareProfileAuthByName = new Map();
1525
+ this.#profileAuthByName = new Map();
1506
1526
  const availableModels = this.#getProfileAvailableModels();
1507
1527
  const resolutionRegistry = this.#createProfileResolutionRegistry(availableModels);
1508
1528
  const providers = new Set<string>();
@@ -1612,6 +1632,11 @@ export class ModelSelectorComponent extends Container {
1612
1632
  )
1613
1633
  return;
1614
1634
  this.#bareProfileAuthByName = new Map(profileAuthEntries);
1635
+ this.#profileAuthByName = new Map(
1636
+ [...this.#getPresetGroups().values()]
1637
+ .flat()
1638
+ .map(profile => [profile.name, this.#isPresetAuthenticated(profile, true)]),
1639
+ );
1615
1640
  } finally {
1616
1641
  if (
1617
1642
  !this.#disposed &&
@@ -1684,6 +1709,39 @@ export class ModelSelectorComponent extends Container {
1684
1709
  this.#searchInput.setValue(value);
1685
1710
  }
1686
1711
 
1712
+ async #initializePresetLanding(): Promise<void> {
1713
+ this.#presetCatalogRefreshPending = true;
1714
+ let refreshFailed = false;
1715
+ try {
1716
+ const refreshStatic = this.#modelRegistry.refreshStatic;
1717
+ if (typeof refreshStatic === "function") await refreshStatic.call(this.#modelRegistry);
1718
+ this.#errorMessage = this.#modelRegistry.getError();
1719
+ } catch (error) {
1720
+ refreshFailed = true;
1721
+ this.#errorMessage = error instanceof Error ? error.message : String(error);
1722
+ } finally {
1723
+ this.#presetCatalogRefreshPending = false;
1724
+ }
1725
+ if (this.#disposed || this.#viewMode !== "presets") return;
1726
+ if ((this.#modelRegistry.getModelProfiles?.().size ?? 0) === 0) {
1727
+ this.#viewMode = "models";
1728
+ void this.#initializeCatalogView();
1729
+ return;
1730
+ }
1731
+ this.#rebuildRoleModels();
1732
+ this.#clampPresetCursor();
1733
+ if (this.#openSmartRoutingAfterPresetRefresh) {
1734
+ this.#openSmartRoutingAfterPresetRefresh = false;
1735
+ if (!refreshFailed) {
1736
+ this.#enterSmartRoutingMode();
1737
+ return;
1738
+ }
1739
+ }
1740
+ void this.#refreshProviderAuth();
1741
+ this.#renderPresetLanding();
1742
+ this.#tui.requestRender();
1743
+ }
1744
+
1687
1745
  #switchToModelMode(seed?: string, options?: { imageRoleFilter?: boolean }): void {
1688
1746
  this.#viewMode = "models";
1689
1747
  this.#expandedPresetProviderId = undefined;
@@ -1695,8 +1753,52 @@ export class ModelSelectorComponent extends Container {
1695
1753
  this.#selectedIndex = 0;
1696
1754
  this.#imageRoleFilter = options?.imageRoleFilter ?? false;
1697
1755
  this.#setSearchInputValue(seed ?? this.#searchInput.getValue());
1756
+ void this.#initializeCatalogView();
1757
+ }
1758
+
1759
+ async #initializeCatalogView(): Promise<void> {
1760
+ if (this.#catalogLoaded) {
1761
+ this.#presentCatalogView();
1762
+ return;
1763
+ }
1764
+ if (this.#catalogLoadPromise) return this.#catalogLoadPromise;
1765
+
1766
+ this.#headerContainer.clear();
1767
+ this.#listContainer.clear();
1768
+ this.#listContainer.addChild(new Text(theme.fg("muted", "Loading models..."), 0, 0));
1769
+ this.#tui.requestRender();
1770
+
1771
+ const load = this.#loadModels().then(() => {
1772
+ this.#catalogLoaded = true;
1773
+ if (this.#disposed) return;
1774
+ if (this.#smartRoutingOnly) {
1775
+ this.#enterSmartRoutingMode();
1776
+ return;
1777
+ }
1778
+ if (this.#viewMode === "models") this.#presentCatalogView();
1779
+ });
1780
+ this.#catalogLoadPromise = load;
1781
+ try {
1782
+ await load;
1783
+ } catch (error) {
1784
+ if (!this.#disposed && (this.#viewMode === "models" || this.#viewMode === "smart-routing")) {
1785
+ this.#errorMessage = error instanceof Error ? error.message : String(error);
1786
+ this.#buildProviderTabs();
1787
+ this.#updateTabBar();
1788
+ this.#updateList();
1789
+ this.#tui.requestRender();
1790
+ }
1791
+ } finally {
1792
+ if (this.#catalogLoadPromise === load) this.#catalogLoadPromise = undefined;
1793
+ }
1794
+ }
1795
+
1796
+ #presentCatalogView(): void {
1797
+ this.#buildProviderTabs();
1698
1798
  this.#updateTabBar();
1799
+ // Apply the latest query: input remains responsive while the catalog loads.
1699
1800
  this.#filterModels(this.#searchInput.getValue());
1801
+ this.#tui.requestRender();
1700
1802
  }
1701
1803
 
1702
1804
  /**
@@ -1880,6 +1982,12 @@ export class ModelSelectorComponent extends Container {
1880
1982
  for (const line of this.#formatCurrentSessionLines()) {
1881
1983
  this.#headerContainer.addChild(new Text(line, 0, 0));
1882
1984
  }
1985
+ if (this.#errorMessage) {
1986
+ for (const line of String(this.#errorMessage).split("\n")) {
1987
+ this.#listContainer.addChild(new Text(theme.fg("error", line), 0, 0));
1988
+ }
1989
+ this.#listContainer.addChild(new Spacer(1));
1990
+ }
1883
1991
  const rows = this.#getPresetRows();
1884
1992
  for (let i = 0; i < rows.length; i++) {
1885
1993
  const row = rows[i];
@@ -1926,7 +2034,7 @@ export class ModelSelectorComponent extends Container {
1926
2034
  }
1927
2035
  if (row.kind === "group") {
1928
2036
  const authenticated = this.#isPresetGroupUsable(row.profiles);
1929
- const mark = this.#providerAuthPending ? "…" : authenticated ? "✓" : "✗";
2037
+ const mark = this.#isPresetAvailabilityPending() ? "…" : authenticated ? "✓" : "✗";
1930
2038
  const containsActive =
1931
2039
  this.#activeModelProfile !== undefined &&
1932
2040
  row.profiles.some(profile => profile.name === this.#activeModelProfile);
@@ -1938,7 +2046,7 @@ export class ModelSelectorComponent extends Container {
1938
2046
  }
1939
2047
  const presentation = getModelProfilePresentation(row.profile);
1940
2048
  const authenticated = this.#isPresetAuthenticated(row.profile);
1941
- const mark = this.#providerAuthPending ? "…" : authenticated ? "✓" : "✗";
2049
+ const mark = this.#isPresetAvailabilityPending() ? "…" : authenticated ? "✓" : "✗";
1942
2050
  const isActive = row.profile.name === this.#activeModelProfile;
1943
2051
  const label = ` ${mark} ${presentation.displayName}`;
1944
2052
  const renderedLabel = selected ? theme.fg("accent", label) : authenticated ? label : theme.fg("dim", label);
@@ -2305,7 +2413,11 @@ export class ModelSelectorComponent extends Container {
2305
2413
 
2306
2414
  handleInput(keyData: string): void {
2307
2415
  if (this.#viewMode === "smart-routing") {
2308
- this.#smartRoutingPanel?.handleInput(keyData);
2416
+ if (this.#smartRoutingPanel) {
2417
+ this.#smartRoutingPanel.handleInput(keyData);
2418
+ } else if (getKeybindings().matches(keyData, "tui.select.cancel")) {
2419
+ this.#onCancelCallback();
2420
+ }
2309
2421
  return;
2310
2422
  }
2311
2423
  if (this.#assignmentState === "assigning") {
@@ -2477,6 +2589,11 @@ export class ModelSelectorComponent extends Container {
2477
2589
  this.#onSelectCallback({ kind: "deleteProfile", profileName: this.#previewProfileName });
2478
2590
  return;
2479
2591
  }
2592
+ if (this.#isPresetAvailabilityPending()) {
2593
+ this.#presetLoginHint = "Refreshing preset availability...";
2594
+ this.#renderPresetLanding();
2595
+ return;
2596
+ }
2480
2597
  const missing = this.#getMissingProviders(profile);
2481
2598
  if (missing.length > 0) {
2482
2599
  this.#presetLoginHint = `Run ${missing.map(provider => `/login ${provider}`).join(", ")}`;
@@ -2504,6 +2621,12 @@ export class ModelSelectorComponent extends Container {
2504
2621
  const row = this.#getSelectedPresetRow();
2505
2622
  if (!row) return;
2506
2623
  if (row.kind === "smartRouting") {
2624
+ if (this.#presetCatalogRefreshPending) {
2625
+ this.#openSmartRoutingAfterPresetRefresh = true;
2626
+ this.#presetLoginHint = "Refreshing model configuration...";
2627
+ this.#renderPresetLanding();
2628
+ return;
2629
+ }
2507
2630
  this.#enterSmartRoutingMode();
2508
2631
  return;
2509
2632
  }
@@ -2523,6 +2646,11 @@ export class ModelSelectorComponent extends Container {
2523
2646
  return;
2524
2647
  }
2525
2648
  if (row.kind === "group") {
2649
+ if (this.#isPresetAvailabilityPending()) {
2650
+ this.#presetLoginHint = "Refreshing preset availability...";
2651
+ this.#renderPresetLanding();
2652
+ return;
2653
+ }
2526
2654
  // A group is a list of alternative presets; only surface a login hint
2527
2655
  // when none of its members are usable. A partially-usable group stays
2528
2656
  // navigable so the user can drill in and pick a usable member.
@@ -2546,6 +2674,11 @@ export class ModelSelectorComponent extends Container {
2546
2674
  this.#renderPresetLanding();
2547
2675
  return;
2548
2676
  }
2677
+ if (this.#isPresetAvailabilityPending() && !isCustomUserProfile(row.profile)) {
2678
+ this.#presetLoginHint = "Refreshing preset availability...";
2679
+ this.#renderPresetLanding();
2680
+ return;
2681
+ }
2549
2682
  const missing = this.#getMissingProviders(row.profile);
2550
2683
  if (missing.length > 0 && !isCustomUserProfile(row.profile)) {
2551
2684
  this.#presetLoginHint = `Run ${missing.map(provider => `/login ${provider}`).join(", ")}`;
@@ -2583,7 +2716,7 @@ export class ModelSelectorComponent extends Container {
2583
2716
  this.#presetLoginHint = undefined;
2584
2717
  this.#clampPresetCursor();
2585
2718
  }
2586
- this.#renderPresetLanding();
2719
+ void this.#refreshProviderAuth();
2587
2720
  this.#tui.requestRender();
2588
2721
  }
2589
2722