@gajae-code/coding-agent 0.7.6 → 0.7.8

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 (111) hide show
  1. package/CHANGELOG.md +26 -1
  2. package/README.md +8 -0
  3. package/dist/types/cli/local-provider-smoke.d.ts +59 -0
  4. package/dist/types/cli/notify-cli.d.ts +1 -1
  5. package/dist/types/cli/update-cli.d.ts +24 -1
  6. package/dist/types/commands/local-provider.d.ts +34 -0
  7. package/dist/types/config/model-profiles.d.ts +2 -2
  8. package/dist/types/config/model-registry.d.ts +8 -3
  9. package/dist/types/config/models-config-schema.d.ts +5 -5
  10. package/dist/types/config/settings-schema.d.ts +123 -9
  11. package/dist/types/config/settings.d.ts +7 -0
  12. package/dist/types/coordinator-mcp/server.d.ts +8 -0
  13. package/dist/types/export/html/template.generated.d.ts +1 -1
  14. package/dist/types/gjc-runtime/launch-tmux.d.ts +2 -2
  15. package/dist/types/gjc-runtime/state-renderer.d.ts +5 -0
  16. package/dist/types/gjc-runtime/team-runtime.d.ts +1 -1
  17. package/dist/types/gjc-runtime/tmux-common.d.ts +12 -0
  18. package/dist/types/gjc-runtime/ultragoal-guard.d.ts +37 -1
  19. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +80 -0
  20. package/dist/types/modes/components/custom-model-preset-wizard.d.ts +1 -1
  21. package/dist/types/modes/components/model-selector.d.ts +2 -0
  22. package/dist/types/modes/components/settings-selector.d.ts +4 -1
  23. package/dist/types/modes/components/status-line/types.d.ts +5 -7
  24. package/dist/types/modes/components/status-line.d.ts +4 -3
  25. package/dist/types/modes/controllers/input-controller.d.ts +1 -0
  26. package/dist/types/modes/controllers/selector-controller.d.ts +2 -1
  27. package/dist/types/modes/prompt-action-autocomplete.d.ts +1 -0
  28. package/dist/types/modes/tmux-scroll.d.ts +18 -0
  29. package/dist/types/modes/utils/terminal-bell.d.ts +3 -0
  30. package/dist/types/notifications/telegram-daemon-cli.d.ts +13 -2
  31. package/dist/types/tools/browser/tab-supervisor.d.ts +31 -0
  32. package/dist/types/tools/computer-gc.d.ts +23 -0
  33. package/dist/types/tools/cron.d.ts +36 -61
  34. package/dist/types/tools/index.d.ts +0 -1
  35. package/dist/types/tools/resource-gc.d.ts +54 -0
  36. package/dist/types/web/search/index.d.ts +1 -0
  37. package/dist/types/web/search/providers/utils.d.ts +11 -4
  38. package/package.json +7 -7
  39. package/src/cli/args.ts +0 -1
  40. package/src/cli/fast-help.ts +0 -1
  41. package/src/cli/local-provider-smoke.ts +632 -0
  42. package/src/cli/notify-cli.ts +4 -2
  43. package/src/cli/update-cli.ts +155 -13
  44. package/src/cli/web-search-cli.ts +5 -0
  45. package/src/cli.ts +18 -0
  46. package/src/commands/launch.ts +46 -1
  47. package/src/commands/local-provider.ts +62 -0
  48. package/src/commands/notify.ts +1 -1
  49. package/src/config/model-profiles.ts +3 -4
  50. package/src/config/model-registry.ts +58 -7
  51. package/src/config/models-config-schema.ts +10 -1
  52. package/src/config/settings-schema.ts +127 -10
  53. package/src/config/settings.ts +11 -0
  54. package/src/coordinator-mcp/server.ts +276 -34
  55. package/src/export/html/template.generated.ts +1 -1
  56. package/src/export/html/template.js +0 -12
  57. package/src/gjc-runtime/launch-tmux.ts +34 -8
  58. package/src/gjc-runtime/launch-worktree.ts +16 -2
  59. package/src/gjc-runtime/state-renderer.ts +13 -0
  60. package/src/gjc-runtime/team-runtime.ts +21 -6
  61. package/src/gjc-runtime/tmux-common.ts +16 -0
  62. package/src/gjc-runtime/tmux-sessions.ts +13 -9
  63. package/src/gjc-runtime/ultragoal-guard.ts +167 -0
  64. package/src/gjc-runtime/ultragoal-runtime.ts +221 -1
  65. package/src/goals/tools/goal-tool.ts +14 -1
  66. package/src/internal-urls/docs-index.generated.ts +7 -7
  67. package/src/modes/components/agent-dashboard.ts +2 -1
  68. package/src/modes/components/custom-model-preset-wizard.ts +30 -189
  69. package/src/modes/components/model-selector.ts +182 -5
  70. package/src/modes/components/session-observer-overlay.ts +3 -3
  71. package/src/modes/components/settings-selector.ts +491 -5
  72. package/src/modes/components/status-line/segments.ts +7 -18
  73. package/src/modes/components/status-line/types.ts +6 -2
  74. package/src/modes/components/status-line.ts +69 -35
  75. package/src/modes/controllers/event-controller.ts +98 -4
  76. package/src/modes/controllers/extension-ui-controller.ts +3 -0
  77. package/src/modes/controllers/input-controller.ts +15 -0
  78. package/src/modes/controllers/selector-controller.ts +18 -4
  79. package/src/modes/prompt-action-autocomplete.ts +8 -0
  80. package/src/modes/tmux-scroll.ts +92 -0
  81. package/src/modes/utils/terminal-bell.ts +48 -0
  82. package/src/notifications/telegram-daemon-cli.ts +112 -9
  83. package/src/notifications/telegram-daemon.ts +4 -0
  84. package/src/prompts/system/system-prompt.md +2 -2
  85. package/src/prompts/tools/cron.md +5 -3
  86. package/src/prompts/tools/read.md +1 -1
  87. package/src/sdk.ts +5 -0
  88. package/src/session/agent-session.ts +118 -5
  89. package/src/slash-commands/builtin-registry.ts +106 -13
  90. package/src/tools/browser/tab-supervisor.ts +86 -2
  91. package/src/tools/computer-gc.ts +66 -0
  92. package/src/tools/computer.ts +2 -0
  93. package/src/tools/cron.ts +75 -112
  94. package/src/tools/index.ts +2 -8
  95. package/src/tools/read.ts +25 -55
  96. package/src/tools/renderers.ts +0 -2
  97. package/src/tools/resource-gc.ts +291 -0
  98. package/src/tools/ultragoal-ask-guard.ts +7 -1
  99. package/src/web/search/index.ts +1 -0
  100. package/src/web/search/providers/utils.ts +22 -5
  101. package/vendor/insane-search/MANIFEST.json +3 -1
  102. package/vendor/insane-search/engine/__init__.py +14 -0
  103. package/vendor/insane-search/engine/content_safety.py +151 -0
  104. package/vendor/insane-search/engine/fetch_chain.py +32 -0
  105. package/vendor/insane-search/engine/tests/test_u8.py +216 -0
  106. package/dist/types/tools/inspect-image-renderer.d.ts +0 -26
  107. package/dist/types/tools/inspect-image.d.ts +0 -31
  108. package/src/prompts/tools/inspect-image-system.md +0 -20
  109. package/src/prompts/tools/inspect-image.md +0 -32
  110. package/src/tools/inspect-image-renderer.ts +0 -103
  111. package/src/tools/inspect-image.ts +0 -172
@@ -25,6 +25,7 @@ import {
25
25
  matchesKey,
26
26
  padding,
27
27
  replaceTabs,
28
+ resolveTerminalColumns,
28
29
  Spacer,
29
30
  Text,
30
31
  truncateToWidth,
@@ -870,7 +871,7 @@ export class AgentDashboard extends Container {
870
871
  }
871
872
 
872
873
  #uiWidth(): number {
873
- return Math.max(40, process.stdout.columns ?? 100);
874
+ return Math.max(40, resolveTerminalColumns());
874
875
  }
875
876
 
876
877
  /** Rebuild layout and request a TUI render pass (for use after async state changes). */
@@ -6,15 +6,6 @@ import { DynamicBorder } from "./dynamic-border";
6
6
 
7
7
  const PROFILE_NAME_PATTERN = /^[a-z0-9][a-z0-9._-]*$/;
8
8
 
9
- type WizardStep = "name" | "display-name" | "provider" | "model" | "confirm";
10
-
11
- interface WizardState {
12
- name: string;
13
- displayName: string;
14
- provider: string;
15
- model: string;
16
- }
17
-
18
9
  export interface CustomModelPresetWizardSubmit {
19
10
  name: string;
20
11
  profile: ModelProfileConfig;
@@ -23,25 +14,21 @@ export interface CustomModelPresetWizardSubmit {
23
14
  export class CustomModelPresetWizardComponent extends Container {
24
15
  #contentContainer: Container;
25
16
  #input: Input | null = null;
26
- #step: WizardStep = "name";
27
- #selectedIndex = 0;
28
17
  #lastError: string | null = null;
29
- #state: WizardState = {
30
- name: "",
31
- displayName: "",
32
- provider: "",
33
- model: "",
34
- };
18
+ #name = "";
19
+ #snapshot: ModelProfileConfig;
35
20
  #onSubmit: (input: CustomModelPresetWizardSubmit) => void;
36
21
  #onCancel: () => void;
37
22
  #onRender: () => void;
38
23
 
39
24
  constructor(
25
+ snapshot: ModelProfileConfig,
40
26
  onSubmit: (input: CustomModelPresetWizardSubmit) => void,
41
27
  onCancel: () => void,
42
28
  onRender: () => void = () => {},
43
29
  ) {
44
30
  super();
31
+ this.#snapshot = snapshot;
45
32
  this.#onSubmit = onSubmit;
46
33
  this.#onCancel = onCancel;
47
34
  this.#onRender = onRender;
@@ -51,7 +38,7 @@ export class CustomModelPresetWizardComponent extends Container {
51
38
  this.addChild(new TruncatedText(theme.bold("Create custom model preset")));
52
39
  this.addChild(
53
40
  new TruncatedText(
54
- theme.fg("muted", " Save provider/model as a selectable profile. Secrets are not requested."),
41
+ theme.fg("muted", " Save the current default and explicit role models as a selectable profile."),
55
42
  0,
56
43
  0,
57
44
  ),
@@ -72,222 +59,76 @@ export class CustomModelPresetWizardComponent extends Container {
72
59
 
73
60
  handleInput(keyData: string): void {
74
61
  if (matchesAppInterrupt(keyData)) {
75
- if (this.#step === "name") {
76
- this.#onCancel();
77
- return;
78
- }
79
- this.#goBack();
62
+ this.#onCancel();
80
63
  return;
81
64
  }
82
65
 
83
66
  if (this.#input) {
84
67
  if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
85
- this.#saveInputAndProceed();
68
+ this.#saveInputAndSubmit();
86
69
  return;
87
70
  }
88
71
  this.#input.handleInput(keyData);
89
- return;
90
- }
91
-
92
- if (matchesKey(keyData, "up")) {
93
- this.#moveSelection(-1);
94
- return;
95
- }
96
- if (matchesKey(keyData, "down")) {
97
- this.#moveSelection(1);
98
- return;
99
- }
100
- if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
101
- this.#selectCurrentOption();
102
72
  }
103
73
  }
104
74
 
105
75
  #renderStep(): void {
106
76
  this.#contentContainer.clear();
107
77
  this.#input = null;
108
- switch (this.#step) {
109
- case "name":
110
- this.#renderInputStep(
111
- "Step 1: Preset id",
112
- "Enter a unique preset id:",
113
- this.#state.name,
114
- "e.g. my-fast-coder",
115
- );
116
- break;
117
- case "display-name":
118
- this.#renderInputStep(
119
- "Step 2: Display name",
120
- "Enter a display name:",
121
- this.#state.displayName,
122
- "e.g. My Fast Coder",
123
- );
124
- break;
125
- case "provider":
126
- this.#renderInputStep(
127
- "Step 3: Provider",
128
- "Enter the provider id:",
129
- this.#state.provider,
130
- "e.g. openai-codex, anthropic, my-oai",
131
- );
132
- break;
133
- case "model":
134
- this.#renderInputStep(
135
- "Step 4: Model",
136
- "Enter the model id or provider/model selector:",
137
- this.#state.model,
138
- "e.g. gpt-5.5:medium or my-oai/gpt-example:low",
139
- );
140
- break;
141
- case "confirm":
142
- this.#renderConfirmStep();
143
- break;
144
- }
145
- }
146
-
147
- #renderInputStep(title: string, prompt: string, value: string, hint: string): void {
148
- this.#contentContainer.addChild(new Text(theme.fg("accent", title)));
78
+ this.#contentContainer.addChild(new Text(theme.fg("accent", "Preset id")));
149
79
  this.#contentContainer.addChild(new Spacer(1));
150
80
  if (this.#lastError) {
151
81
  this.#contentContainer.addChild(new Text(theme.fg("error", this.#lastError), 0, 0));
152
82
  this.#contentContainer.addChild(new Spacer(1));
153
83
  }
154
- this.#contentContainer.addChild(new Text(prompt, 0, 0));
84
+ this.#contentContainer.addChild(new Text("Enter a unique preset id:", 0, 0));
155
85
  this.#contentContainer.addChild(new Spacer(1));
156
86
  this.#input = new Input();
157
- this.#input.setValue(value);
87
+ this.#input.setValue(this.#name);
158
88
  this.#contentContainer.addChild(this.#input);
159
89
  this.#contentContainer.addChild(new Spacer(1));
160
- this.#addHelp(hint);
161
- this.#addHelp("[Enter to continue, Esc to go back]");
90
+ this.#addSnapshotPreview();
91
+ this.#addHelp("e.g. my-fast-coder");
92
+ this.#addHelp("[Enter to create, Esc to cancel]");
162
93
  }
163
94
 
164
- #renderConfirmStep(): void {
165
- this.#contentContainer.addChild(new Text(theme.fg("accent", "Confirm custom preset")));
166
- this.#contentContainer.addChild(new Spacer(1));
167
- if (this.#lastError) {
168
- this.#contentContainer.addChild(new Text(theme.fg("error", this.#lastError), 0, 0));
169
- this.#contentContainer.addChild(new Spacer(1));
95
+ #addSnapshotPreview(): void {
96
+ this.#contentContainer.addChild(new Text(theme.fg("muted", "Snapshot:"), 0, 0));
97
+ for (const [role, selector] of Object.entries(this.#snapshot.model_mapping)) {
98
+ this.#contentContainer.addChild(new Text(` ${role}: ${selector}`, 0, 0));
170
99
  }
171
- this.#contentContainer.addChild(new Text(`Preset id: ${this.#state.name}`, 0, 0));
172
- this.#contentContainer.addChild(new Text(`Display name: ${this.#state.displayName}`, 0, 0));
173
- this.#contentContainer.addChild(new Text(`Provider: ${this.#state.provider}`, 0, 0));
174
- this.#contentContainer.addChild(new Text(`Default model: ${this.#selector()}`, 0, 0));
100
+ this.#contentContainer.addChild(new Text(` providers: ${this.#snapshot.required_providers.join(", ")}`, 0, 0));
175
101
  this.#contentContainer.addChild(new Spacer(1));
176
- this.#addOption(0, "Create preset");
177
- this.#addOption(1, "Go back");
178
- this.#addHelp("[↑↓ to navigate, Enter to select, Esc to go back]");
179
- }
180
-
181
- #addOption(index: number, label: string): void {
182
- const selected = index === this.#selectedIndex;
183
- const prefix = selected ? theme.fg("accent", `${theme.nav.cursor} `) : " ";
184
- this.#contentContainer.addChild(new Text(`${prefix}${selected ? theme.fg("accent", label) : label}`, 0, 0));
185
102
  }
186
103
 
187
104
  #addHelp(text: string): void {
188
105
  this.#contentContainer.addChild(new Text(theme.fg("muted", text), 0, 0));
189
106
  }
190
107
 
191
- #saveInputAndProceed(): void {
108
+ #saveInputAndSubmit(): void {
192
109
  const value = this.#input?.getValue().trim() ?? "";
193
110
  if (!value) {
194
- this.#lastError = this.#emptyFieldMessage();
111
+ this.#lastError = "Preset id is required.";
195
112
  this.#renderStep();
196
113
  this.#onRender();
197
114
  return;
198
115
  }
199
- const validationError = this.#validateCurrentInput(value);
200
- if (validationError) {
201
- this.#lastError = validationError;
116
+ if (!PROFILE_NAME_PATTERN.test(value)) {
117
+ this.#lastError = "Preset id must use lowercase letters, numbers, dots, underscores, or hyphens.";
202
118
  this.#renderStep();
203
119
  this.#onRender();
204
120
  return;
205
121
  }
122
+ this.#name = value;
206
123
  this.#lastError = null;
207
- if (this.#step === "name") {
208
- this.#state.name = value;
209
- this.#step = "display-name";
210
- } else if (this.#step === "display-name") {
211
- this.#state.displayName = value;
212
- this.#step = "provider";
213
- } else if (this.#step === "provider") {
214
- this.#state.provider = value;
215
- this.#step = "model";
216
- } else if (this.#step === "model") {
217
- this.#state.model = value;
218
- this.#step = "confirm";
219
- this.#selectedIndex = 0;
220
- }
221
- this.#renderStep();
222
- this.#onRender();
223
- }
224
-
225
- #emptyFieldMessage(): string {
226
- switch (this.#step) {
227
- case "name":
228
- return "Preset id is required.";
229
- case "display-name":
230
- return "Display name is required.";
231
- case "provider":
232
- return "Provider is required.";
233
- case "model":
234
- return "Model is required.";
235
- case "confirm":
236
- return "Value is required.";
237
- }
238
- }
239
-
240
- #validateCurrentInput(value: string): string | undefined {
241
- if (this.#step === "name" && !PROFILE_NAME_PATTERN.test(value)) {
242
- return "Preset id must use lowercase letters, numbers, dots, underscores, or hyphens.";
243
- }
244
- if (this.#step === "provider" && !PROFILE_NAME_PATTERN.test(value)) {
245
- return "Provider id must use lowercase letters, numbers, dots, underscores, or hyphens.";
246
- }
247
- if (this.#step === "model" && value.includes(",")) {
248
- return "Model must be one selector, not a comma-separated list.";
249
- }
250
- return undefined;
251
- }
252
-
253
- #selectCurrentOption(): void {
254
- if (this.#step !== "confirm") return;
255
- if (this.#selectedIndex === 0) {
256
- this.#onSubmit(this.#buildInput());
257
- return;
258
- }
259
- this.#goBack();
260
- }
261
-
262
- #buildInput(): CustomModelPresetWizardSubmit {
263
- return {
264
- name: this.#state.name,
124
+ this.#onSubmit({
125
+ name: value,
265
126
  profile: {
266
- required_providers: [this.#state.provider],
267
- display_name: this.#state.displayName,
268
- model_mapping: { default: this.#selector() },
127
+ ...this.#snapshot,
128
+ display_name: value,
129
+ model_mapping: { ...this.#snapshot.model_mapping },
130
+ required_providers: [...this.#snapshot.required_providers],
269
131
  },
270
- };
271
- }
272
-
273
- #selector(): string {
274
- return this.#state.model.includes("/") ? this.#state.model : `${this.#state.provider}/${this.#state.model}`;
275
- }
276
-
277
- #moveSelection(delta: number): void {
278
- this.#selectedIndex = (this.#selectedIndex + delta + 2) % 2;
279
- this.#renderStep();
280
- this.#onRender();
281
- }
282
-
283
- #goBack(): void {
284
- if (this.#step === "display-name") this.#step = "name";
285
- else if (this.#step === "provider") this.#step = "display-name";
286
- else if (this.#step === "model") this.#step = "provider";
287
- else if (this.#step === "confirm") this.#step = "model";
288
- this.#selectedIndex = 0;
289
- this.#lastError = null;
290
- this.#renderStep();
291
- this.#onRender();
132
+ });
292
133
  }
293
134
  }
@@ -28,6 +28,7 @@ import {
28
28
  resolveModelRoleValue,
29
29
  type ScopedModelSelection,
30
30
  } from "../../config/model-resolver";
31
+ import type { ModelProfileConfig } from "../../config/models-config-schema";
31
32
  import type { Settings } from "../../config/settings";
32
33
  import { type ThemeColor, theme } from "../../modes/theme/theme";
33
34
  import { formatModelOnboardingInlineHint } from "../../setup/model-onboarding-guidance";
@@ -105,6 +106,7 @@ export type ModelSelectorSelection =
105
106
  }
106
107
  | {
107
108
  kind: "createProfile";
109
+ profile: ModelProfileConfig;
108
110
  };
109
111
 
110
112
  interface PendingThinkingChoice {
@@ -155,11 +157,27 @@ interface PresetCreateRow {
155
157
  kind: "create";
156
158
  }
157
159
 
160
+ interface PresetCreateUnavailableRow {
161
+ kind: "createUnavailable";
162
+ label: string;
163
+ }
164
+
165
+ interface PresetAlreadySavedRow {
166
+ kind: "alreadySaved";
167
+ profileName: string;
168
+ }
169
+
158
170
  interface PresetBrowseRow {
159
171
  kind: "browse";
160
172
  }
161
173
 
162
- type PresetLandingRow = PresetGroupRow | PresetProfileRow | PresetCreateRow | PresetBrowseRow;
174
+ type PresetLandingRow =
175
+ | PresetGroupRow
176
+ | PresetProfileRow
177
+ | PresetCreateRow
178
+ | PresetCreateUnavailableRow
179
+ | PresetAlreadySavedRow
180
+ | PresetBrowseRow;
163
181
 
164
182
  // Stable logical identity for a preset landing row, independent of its current
165
183
  // list position. Used to relocate the cursor after the expanded group changes so
@@ -174,6 +192,10 @@ function presetRowIdentity(row: PresetLandingRow): string {
174
192
  return "browse";
175
193
  case "create":
176
194
  return "create";
195
+ case "createUnavailable":
196
+ return "createUnavailable";
197
+ case "alreadySaved":
198
+ return `alreadySaved:${row.profileName}`;
177
199
  }
178
200
  }
179
201
 
@@ -193,6 +215,64 @@ function isPrintableCharacter(keyData: string): boolean {
193
215
  function profileRequiredProviders(profile: ModelProfileDefinition): string[] {
194
216
  return [...new Set(profile.requiredProviders)].sort((a, b) => a.localeCompare(b));
195
217
  }
218
+
219
+ function isInheritedRoleSelector(value: string | undefined): boolean {
220
+ const normalized = value?.trim();
221
+ return !normalized || normalized === "default" || normalized === "pi/default";
222
+ }
223
+
224
+ function getDefaultAliasThinkingLevel(value: string | undefined): ThinkingLevel | undefined {
225
+ const normalized = value?.trim();
226
+ if (!normalized?.startsWith("pi/default:")) return undefined;
227
+ return parseThinkingLevel(normalized.slice("pi/default:".length));
228
+ }
229
+
230
+ function getSelectorProvider(selector: string): string | undefined {
231
+ const slashIndex = selector.indexOf("/");
232
+ return slashIndex > 0 ? selector.slice(0, slashIndex) : undefined;
233
+ }
234
+
235
+ function deriveRequiredProviders(modelMapping: ModelProfileConfig["model_mapping"]): string[] {
236
+ const providers = new Set<string>();
237
+ for (const selector of Object.values(modelMapping)) {
238
+ const provider = getSelectorProvider(selector);
239
+ if (provider) providers.add(provider);
240
+ }
241
+ return [...providers].sort((a, b) => a.localeCompare(b));
242
+ }
243
+
244
+ function sameStringRecord(
245
+ left: Readonly<Record<string, string | undefined>>,
246
+ right: Readonly<Record<string, string | undefined>>,
247
+ ): boolean {
248
+ const leftEntries = Object.entries(left)
249
+ .filter((entry): entry is [string, string] => entry[1] !== undefined)
250
+ .sort(([a], [b]) => a.localeCompare(b));
251
+ const rightEntries = Object.entries(right)
252
+ .filter((entry): entry is [string, string] => entry[1] !== undefined)
253
+ .sort(([a], [b]) => a.localeCompare(b));
254
+ if (leftEntries.length !== rightEntries.length) return false;
255
+ for (let i = 0; i < leftEntries.length; i++) {
256
+ const leftEntry = leftEntries[i];
257
+ const rightEntry = rightEntries[i];
258
+ if (!leftEntry || !rightEntry || leftEntry[0] !== rightEntry[0] || leftEntry[1] !== rightEntry[1]) {
259
+ return false;
260
+ }
261
+ }
262
+ return true;
263
+ }
264
+
265
+ function sameStringArray(left: readonly string[], right: readonly string[]): boolean {
266
+ if (left.length !== right.length) return false;
267
+ for (let i = 0; i < left.length; i++) {
268
+ if (left[i] !== right[i]) return false;
269
+ }
270
+ return true;
271
+ }
272
+
273
+ function hasPersistableProfileSnapshot(snapshot: ModelProfileConfig): boolean {
274
+ return Object.keys(snapshot.model_mapping).length > 0 && snapshot.required_providers.length > 0;
275
+ }
196
276
  /**
197
277
  * Component that renders a canonical model selector with provider tabs.
198
278
  * - Preset landing Left/Right: Collapse/expand selected provider
@@ -733,6 +813,67 @@ export class ModelSelectorComponent extends Container {
733
813
  return groupModelProfilesForPresetLanding(this.#modelRegistry.getModelProfiles?.() ?? new Map());
734
814
  }
735
815
 
816
+ #formatCurrentModelSelector(
817
+ thinkingLevel: ThinkingLevel | undefined = this.#currentThinkingLevel,
818
+ ): string | undefined {
819
+ if (!this.#currentModel) return undefined;
820
+ return formatModelSelectorValue(`${this.#currentModel.provider}/${this.#currentModel.id}`, thinkingLevel);
821
+ }
822
+
823
+ #resolveProfileModelSelector(value: string | undefined): string | undefined {
824
+ const normalized = value?.trim();
825
+ if (isInheritedRoleSelector(normalized)) return undefined;
826
+
827
+ const resolved = resolveModelRoleValue(normalized, this.#modelRegistry.getAll(), {
828
+ settings: this.#settings,
829
+ matchPreferences: { usageOrder: this.#settings.getStorage()?.getModelUsageOrder() },
830
+ modelRegistry: this.#modelRegistry,
831
+ });
832
+ if (resolved.model) {
833
+ return formatModelSelectorValue(`${resolved.model.provider}/${resolved.model.id}`, resolved.thinkingLevel);
834
+ }
835
+
836
+ const inheritedDefaultThinkingLevel = getDefaultAliasThinkingLevel(normalized);
837
+ if (inheritedDefaultThinkingLevel) return this.#formatCurrentModelSelector(inheritedDefaultThinkingLevel);
838
+ return undefined;
839
+ }
840
+
841
+ #buildCustomModelProfileSnapshot(): ModelProfileConfig {
842
+ const modelMapping: ModelProfileConfig["model_mapping"] = {};
843
+ const currentModelSelector = this.#formatCurrentModelSelector();
844
+ if (currentModelSelector) {
845
+ modelMapping.default = currentModelSelector;
846
+ } else {
847
+ const defaultRole = this.#settings.getModelRole("default");
848
+ const defaultSelector = this.#resolveProfileModelSelector(defaultRole);
849
+ if (defaultSelector) modelMapping.default = defaultSelector;
850
+ }
851
+
852
+ const agentOverrides = this.#settings.get("task.agentModelOverrides");
853
+ for (const role of GJC_MODEL_ASSIGNMENT_TARGET_IDS) {
854
+ if (role === "default") continue;
855
+ const selector = this.#resolveProfileModelSelector(agentOverrides[role]);
856
+ if (selector) modelMapping[role] = selector;
857
+ }
858
+
859
+ return {
860
+ required_providers: deriveRequiredProviders(modelMapping),
861
+ model_mapping: modelMapping,
862
+ };
863
+ }
864
+
865
+ #findDuplicateGeneratedProfile(snapshot: ModelProfileConfig): ModelProfileDefinition | undefined {
866
+ for (const profile of this.#modelRegistry.getModelProfiles?.().values() ?? []) {
867
+ if (
868
+ sameStringRecord(profile.modelMapping, snapshot.model_mapping) &&
869
+ sameStringArray(profile.requiredProviders, snapshot.required_providers)
870
+ ) {
871
+ return profile;
872
+ }
873
+ }
874
+ return undefined;
875
+ }
876
+
736
877
  #getPresetRows(): PresetLandingRow[] {
737
878
  const rows: PresetLandingRow[] = [];
738
879
  for (const [groupId, profiles] of this.#getPresetGroups()) {
@@ -741,7 +882,15 @@ export class ModelSelectorComponent extends Container {
741
882
  for (const profile of profiles) rows.push({ kind: "profile", groupId, profile });
742
883
  }
743
884
  }
744
- rows.push({ kind: "create" });
885
+ const snapshot = this.#buildCustomModelProfileSnapshot();
886
+ if (hasPersistableProfileSnapshot(snapshot)) {
887
+ const duplicateProfile = this.#findDuplicateGeneratedProfile(snapshot);
888
+ rows.push(
889
+ duplicateProfile ? { kind: "alreadySaved", profileName: duplicateProfile.name } : { kind: "create" },
890
+ );
891
+ } else {
892
+ rows.push({ kind: "createUnavailable", label: "Select a model before creating a custom preset" });
893
+ }
745
894
  rows.push({ kind: "browse" });
746
895
  return rows;
747
896
  }
@@ -815,7 +964,14 @@ export class ModelSelectorComponent extends Container {
815
964
 
816
965
  #expandSelectedPresetProvider(): void {
817
966
  const selected = this.#getSelectedPresetRow();
818
- if (!selected || selected.kind === "browse" || selected.kind === "create") return;
967
+ if (
968
+ !selected ||
969
+ selected.kind === "browse" ||
970
+ selected.kind === "create" ||
971
+ selected.kind === "createUnavailable" ||
972
+ selected.kind === "alreadySaved"
973
+ )
974
+ return;
819
975
  if (this.#expandedPresetProviderId === selected.groupId) return;
820
976
  const targetIdentity = presetRowIdentity(selected);
821
977
  this.#expandedPresetProviderId = selected.groupId;
@@ -824,7 +980,14 @@ export class ModelSelectorComponent extends Container {
824
980
 
825
981
  #collapseSelectedPresetProvider(): void {
826
982
  const selected = this.#getSelectedPresetRow();
827
- if (!selected || selected.kind === "browse" || selected.kind === "create") return;
983
+ if (
984
+ !selected ||
985
+ selected.kind === "browse" ||
986
+ selected.kind === "create" ||
987
+ selected.kind === "createUnavailable" ||
988
+ selected.kind === "alreadySaved"
989
+ )
990
+ return;
828
991
  if (this.#expandedPresetProviderId !== selected.groupId) return;
829
992
  const targetIdentity = selected.kind === "profile" ? `group:${selected.groupId}` : presetRowIdentity(selected);
830
993
  this.#expandedPresetProviderId = undefined;
@@ -860,6 +1023,17 @@ export class ModelSelectorComponent extends Container {
860
1023
  this.#listContainer.addChild(new Text(`${prefix}${selected ? theme.fg("accent", label) : label}`, 0, 0));
861
1024
  continue;
862
1025
  }
1026
+ if (row.kind === "createUnavailable") {
1027
+ const renderedLabel = selected ? theme.fg("accent", row.label) : theme.fg("dim", row.label);
1028
+ this.#listContainer.addChild(new Text(`${prefix}${renderedLabel}`, 0, 0));
1029
+ continue;
1030
+ }
1031
+ if (row.kind === "alreadySaved") {
1032
+ const label = `Already saved as ${row.profileName}`;
1033
+ const renderedLabel = selected ? theme.fg("accent", label) : theme.fg("dim", label);
1034
+ this.#listContainer.addChild(new Text(`${prefix}${renderedLabel}`, 0, 0));
1035
+ continue;
1036
+ }
863
1037
  if (row.kind === "browse") {
864
1038
  const label = "Browse all models";
865
1039
  this.#listContainer.addChild(new Text(`${prefix}${selected ? theme.fg("accent", label) : label}`, 0, 0));
@@ -1306,7 +1480,10 @@ export class ModelSelectorComponent extends Container {
1306
1480
  const row = this.#getSelectedPresetRow();
1307
1481
  if (!row) return;
1308
1482
  if (row.kind === "create") {
1309
- this.#onSelectCallback({ kind: "createProfile" });
1483
+ this.#onSelectCallback({ kind: "createProfile", profile: this.#buildCustomModelProfileSnapshot() });
1484
+ return;
1485
+ }
1486
+ if (row.kind === "alreadySaved" || row.kind === "createUnavailable") {
1310
1487
  return;
1311
1488
  }
1312
1489
  if (row.kind === "browse") {
@@ -15,7 +15,7 @@
15
15
  * - Enter on main session -> close overlay (jump back)
16
16
  */
17
17
  import type { ToolResultMessage } from "@gajae-code/ai";
18
- import { Container, Markdown, type MarkdownTheme, matchesKey } from "@gajae-code/tui";
18
+ import { Container, Markdown, type MarkdownTheme, matchesKey, resolveTerminalColumns } from "@gajae-code/tui";
19
19
  import { formatDuration, formatNumber, logger } from "@gajae-code/utils";
20
20
  import type { KeyId } from "../../config/keybindings";
21
21
  import { isSilentAbort } from "../../session/messages";
@@ -39,7 +39,7 @@ const INDENT = " ";
39
39
 
40
40
  /** Compute the max content width for the current terminal, accounting for indent and chrome. */
41
41
  function contentWidth(indent = INDENT): number {
42
- return Math.max(TRUNCATE_LENGTHS.SHORT, (process.stdout.columns || 80) - indent.length - 2);
42
+ return Math.max(TRUNCATE_LENGTHS.SHORT, resolveTerminalColumns() - indent.length - 2);
43
43
  }
44
44
 
45
45
  /** Sanitize a line for TUI display: replace tabs, then truncate to viewport width. */
@@ -390,7 +390,7 @@ export class SessionObserverOverlayComponent extends Container {
390
390
 
391
391
  /** Render markdown text into indented lines using the theme's markdown renderer */
392
392
  #renderMarkdownToLines(text: string, indent: string = INDENT): string[] {
393
- const width = Math.max(40, (process.stdout.columns || 80) - indent.length - 4);
393
+ const width = Math.max(40, resolveTerminalColumns() - indent.length - 4);
394
394
  const md = new Markdown(text, 0, 0, this.#mdTheme);
395
395
  const rendered = md.render(width);
396
396
  return rendered.map(line => `${indent}${line.trimEnd()}`);