@gajae-code/coding-agent 0.5.4 → 0.6.1

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 (155) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/types/cli/web-search-cli.d.ts +12 -0
  3. package/dist/types/commands/rlm.d.ts +10 -0
  4. package/dist/types/commands/web-search.d.ts +54 -0
  5. package/dist/types/config/keybindings.d.ts +10 -0
  6. package/dist/types/config/model-profiles.d.ts +2 -1
  7. package/dist/types/config/model-registry.d.ts +3 -0
  8. package/dist/types/config/models-config-schema.d.ts +3 -0
  9. package/dist/types/config/settings-schema.d.ts +61 -3
  10. package/dist/types/edit/notebook.d.ts +3 -0
  11. package/dist/types/eval/py/executor.d.ts +3 -0
  12. package/dist/types/eval/py/kernel.d.ts +3 -1
  13. package/dist/types/eval/py/runtime.d.ts +9 -1
  14. package/dist/types/exec/bash-executor.d.ts +4 -0
  15. package/dist/types/extensibility/custom-tools/types.d.ts +2 -0
  16. package/dist/types/extensibility/custom-tools/wrapper.d.ts +1 -0
  17. package/dist/types/extensibility/extensions/types.d.ts +2 -0
  18. package/dist/types/extensibility/extensions/wrapper.d.ts +1 -0
  19. package/dist/types/gjc-runtime/launch-tmux.d.ts +6 -0
  20. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +14 -0
  21. package/dist/types/gjc-runtime/tmux-common.d.ts +6 -0
  22. package/dist/types/gjc-runtime/tmux-gc.d.ts +3 -3
  23. package/dist/types/gjc-runtime/tmux-sessions.d.ts +4 -0
  24. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +18 -0
  25. package/dist/types/goals/state.d.ts +1 -1
  26. package/dist/types/goals/tools/goal-tool.d.ts +2 -0
  27. package/dist/types/main.d.ts +11 -0
  28. package/dist/types/modes/components/custom-editor.d.ts +4 -2
  29. package/dist/types/modes/components/custom-model-preset-wizard.d.ts +12 -0
  30. package/dist/types/modes/components/model-selector.d.ts +5 -2
  31. package/dist/types/modes/components/status-line.d.ts +4 -1
  32. package/dist/types/modes/controllers/input-controller.d.ts +3 -0
  33. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  34. package/dist/types/modes/print-mode.d.ts +6 -0
  35. package/dist/types/modes/rpc/rpc-client.d.ts +21 -0
  36. package/dist/types/modes/rpc/rpc-socket-security.d.ts +7 -0
  37. package/dist/types/modes/rpc/rpc-types.d.ts +13 -0
  38. package/dist/types/modes/shared/agent-wire/command-dispatch.d.ts +2 -0
  39. package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +1 -0
  40. package/dist/types/rlm/artifacts.d.ts +9 -0
  41. package/dist/types/rlm/complete-research-tool.d.ts +35 -0
  42. package/dist/types/rlm/data-context.d.ts +6 -0
  43. package/dist/types/rlm/index.d.ts +35 -0
  44. package/dist/types/rlm/notebook.d.ts +12 -0
  45. package/dist/types/rlm/preset.d.ts +23 -0
  46. package/dist/types/rlm/python-tool.d.ts +16 -0
  47. package/dist/types/rlm/report.d.ts +14 -0
  48. package/dist/types/rlm/types.d.ts +37 -0
  49. package/dist/types/sdk.d.ts +7 -0
  50. package/dist/types/session/agent-session.d.ts +21 -0
  51. package/dist/types/tools/bash-allowed-prefixes.d.ts +6 -1
  52. package/dist/types/tools/browser/attach.d.ts +19 -3
  53. package/dist/types/tools/browser/registry.d.ts +15 -0
  54. package/dist/types/tools/browser/render.d.ts +3 -0
  55. package/dist/types/tools/browser.d.ts +18 -1
  56. package/dist/types/tools/computer/render.d.ts +17 -0
  57. package/dist/types/tools/computer.d.ts +465 -0
  58. package/dist/types/tools/index.d.ts +24 -1
  59. package/dist/types/tools/job.d.ts +13 -0
  60. package/dist/types/tools/tool-timeouts.d.ts +5 -0
  61. package/dist/types/web/search/index.d.ts +32 -2
  62. package/dist/types/web/search/providers/base.d.ts +22 -0
  63. package/dist/types/web/search/providers/xai.d.ts +64 -0
  64. package/dist/types/web/search/types.d.ts +11 -3
  65. package/package.json +7 -7
  66. package/src/cli/web-search-cli.ts +123 -8
  67. package/src/cli.ts +2 -0
  68. package/src/commands/rlm.ts +19 -0
  69. package/src/commands/web-search.ts +66 -0
  70. package/src/config/keybindings.ts +11 -0
  71. package/src/config/model-profiles.ts +11 -3
  72. package/src/config/model-registry.ts +55 -1
  73. package/src/config/models-config-schema.ts +1 -0
  74. package/src/config/settings-schema.ts +67 -1
  75. package/src/edit/notebook.ts +6 -2
  76. package/src/eval/py/executor.ts +8 -1
  77. package/src/eval/py/kernel.ts +9 -4
  78. package/src/eval/py/runtime.ts +153 -32
  79. package/src/exec/bash-executor.ts +10 -4
  80. package/src/extensibility/custom-tools/types.ts +2 -0
  81. package/src/extensibility/custom-tools/wrapper.ts +2 -0
  82. package/src/extensibility/extensions/types.ts +2 -0
  83. package/src/extensibility/extensions/wrapper.ts +1 -0
  84. package/src/gjc-runtime/launch-tmux.ts +129 -1
  85. package/src/gjc-runtime/session-state-sidecar.ts +61 -1
  86. package/src/gjc-runtime/tmux-common.ts +26 -2
  87. package/src/gjc-runtime/tmux-gc.ts +40 -27
  88. package/src/gjc-runtime/tmux-sessions.ts +13 -1
  89. package/src/gjc-runtime/ultragoal-runtime.ts +340 -18
  90. package/src/goals/runtime.ts +4 -3
  91. package/src/goals/state.ts +1 -1
  92. package/src/goals/tools/goal-tool.ts +16 -3
  93. package/src/internal-urls/docs-index.generated.ts +13 -9
  94. package/src/main.ts +28 -3
  95. package/src/modes/components/custom-editor.ts +13 -4
  96. package/src/modes/components/custom-model-preset-wizard.ts +293 -0
  97. package/src/modes/components/hook-selector.ts +1 -1
  98. package/src/modes/components/model-selector.ts +72 -29
  99. package/src/modes/components/skill-message.ts +62 -8
  100. package/src/modes/components/status-line.ts +13 -1
  101. package/src/modes/controllers/input-controller.ts +60 -11
  102. package/src/modes/controllers/selector-controller.ts +39 -0
  103. package/src/modes/interactive-mode.ts +1 -1
  104. package/src/modes/print-mode.ts +14 -4
  105. package/src/modes/rpc/rpc-client.ts +250 -80
  106. package/src/modes/rpc/rpc-mode.ts +6 -12
  107. package/src/modes/rpc/rpc-socket-security.ts +103 -0
  108. package/src/modes/rpc/rpc-types.ts +10 -0
  109. package/src/modes/shared/agent-wire/command-dispatch.ts +7 -0
  110. package/src/modes/shared/agent-wire/command-validation.ts +1 -0
  111. package/src/modes/shared/agent-wire/scopes.ts +1 -0
  112. package/src/modes/shared/agent-wire/unattended-session.ts +9 -0
  113. package/src/modes/utils/hotkeys-markdown.ts +4 -2
  114. package/src/modes/utils/ui-helpers.ts +2 -2
  115. package/src/prompts/goals/goal-continuation.md +1 -0
  116. package/src/prompts/goals/goal-mode-active.md +1 -0
  117. package/src/prompts/system/rlm-report-command.md +1 -0
  118. package/src/prompts/system/rlm-research.md +23 -0
  119. package/src/prompts/tools/bash.md +23 -2
  120. package/src/prompts/tools/browser.md +7 -3
  121. package/src/prompts/tools/computer.md +74 -0
  122. package/src/prompts/tools/goal.md +3 -0
  123. package/src/prompts/tools/job.md +9 -1
  124. package/src/prompts/tools/web-search.md +7 -0
  125. package/src/rlm/artifacts.ts +60 -0
  126. package/src/rlm/complete-research-tool.ts +163 -0
  127. package/src/rlm/data-context.ts +26 -0
  128. package/src/rlm/index.ts +339 -0
  129. package/src/rlm/notebook.ts +108 -0
  130. package/src/rlm/preset.ts +76 -0
  131. package/src/rlm/python-tool.ts +68 -0
  132. package/src/rlm/report.ts +70 -0
  133. package/src/rlm/types.ts +40 -0
  134. package/src/sdk.ts +12 -0
  135. package/src/session/agent-session.ts +48 -3
  136. package/src/slash-commands/builtin-registry.ts +17 -0
  137. package/src/tools/bash-allowed-prefixes.ts +84 -1
  138. package/src/tools/bash.ts +80 -13
  139. package/src/tools/browser/attach.ts +103 -3
  140. package/src/tools/browser/registry.ts +176 -2
  141. package/src/tools/browser/render.ts +9 -1
  142. package/src/tools/browser.ts +33 -0
  143. package/src/tools/computer/render.ts +78 -0
  144. package/src/tools/computer.ts +640 -0
  145. package/src/tools/index.ts +41 -1
  146. package/src/tools/job.ts +88 -5
  147. package/src/tools/json-tree.ts +42 -29
  148. package/src/tools/renderers.ts +2 -0
  149. package/src/tools/tool-timeouts.ts +1 -0
  150. package/src/web/search/index.ts +27 -2
  151. package/src/web/search/provider.ts +16 -1
  152. package/src/web/search/providers/base.ts +22 -0
  153. package/src/web/search/providers/xai.ts +511 -0
  154. package/src/web/search/render.ts +7 -0
  155. package/src/web/search/types.ts +11 -1
package/src/main.ts CHANGED
@@ -705,11 +705,23 @@ async function buildSessionOptions(
705
705
  return { options };
706
706
  }
707
707
 
708
+ /**
709
+ * Research-mode (RLM) preset hook. Lets `gjc rlm` augment the session options
710
+ * (system prompt, restricted toolset, custom python tool) and assert the tool
711
+ * boundary once the session's tool registry is fully assembled.
712
+ */
713
+ export interface RlmPreset {
714
+ applyOptions: (options: CreateAgentSessionOptions, settings: Settings) => void;
715
+ onSessionCreated?: (session: AgentSession) => void | Promise<void>;
716
+ }
717
+
708
718
  interface RunRootCommandDependencies {
709
719
  createAgentSession?: typeof createAgentSession;
710
720
  discoverAuthStorage?: typeof discoverAuthStorage;
711
721
  runAcpMode?: (createSession: AcpSessionFactory) => Promise<void>;
712
722
  settings?: Settings;
723
+ rlmPreset?: RlmPreset;
724
+ suppressProcessExit?: boolean;
713
725
  }
714
726
 
715
727
  export async function runRootCommand(
@@ -897,6 +909,9 @@ export async function runRootCommand(
897
909
  sessionOptions.hasUI = isInteractive || mode === "rpc-ui";
898
910
  sessionOptions.settings = settingsInstance;
899
911
 
912
+ // Research-mode (RLM) preset: augment session options before session creation.
913
+ deps.rlmPreset?.applyOptions(sessionOptions, settingsInstance);
914
+
900
915
  // Handle CLI --api-key as runtime override (not persisted)
901
916
  if (parsedArgs.apiKey) {
902
917
  if (!sessionOptions.model && !sessionOptions.modelPattern) {
@@ -939,6 +954,11 @@ export async function runRootCommand(
939
954
  authStorage.setRuntimeApiKey(session.model.provider, parsedArgs.apiKey);
940
955
  }
941
956
 
957
+ // Research-mode (RLM) preset: hard tool-boundary assertion after the registry is assembled.
958
+ if (deps.rlmPreset?.onSessionCreated) {
959
+ await deps.rlmPreset.onSessionCreated(session);
960
+ }
961
+
942
962
  await applyStartupModelProfilesOrExit({
943
963
  session,
944
964
  settings: settingsInstance,
@@ -1038,13 +1058,18 @@ export async function runRootCommand(
1038
1058
  messages: parsedArgs.messages,
1039
1059
  initialMessage,
1040
1060
  initialImages,
1061
+ suppressProcessExit: deps.suppressProcessExit,
1041
1062
  });
1042
1063
  if ($env.PI_TIMING) {
1043
1064
  logger.printTimings();
1044
1065
  }
1045
- await session.dispose();
1046
- stopThemeWatcher();
1047
- await postmortem.quit(0);
1066
+ if (!deps.suppressProcessExit) {
1067
+ await session.dispose();
1068
+ stopThemeWatcher();
1069
+ await postmortem.quit(0);
1070
+ } else {
1071
+ stopThemeWatcher();
1072
+ }
1048
1073
  }
1049
1074
  }
1050
1075
  }
@@ -18,6 +18,7 @@ type ConfigurableEditorAction = Extract<
18
18
  | "app.editor.external"
19
19
  | "app.history.search"
20
20
  | "app.message.dequeue"
21
+ | "app.message.queue"
21
22
  | "app.clipboard.pasteImage"
22
23
  | "app.clipboard.copyPrompt"
23
24
  >;
@@ -36,6 +37,7 @@ const DEFAULT_ACTION_KEYS: Record<ConfigurableEditorAction, KeyId[]> = {
36
37
  "app.thinking.toggle": ["ctrl+t"],
37
38
  "app.editor.external": ["ctrl+g"],
38
39
  "app.history.search": ["ctrl+r"],
40
+ "app.message.queue": ["alt+enter"],
39
41
  "app.message.dequeue": ["alt+up"],
40
42
  "app.clipboard.pasteImage": ["ctrl+v"],
41
43
  "app.clipboard.copyPrompt": ["alt+shift+c"],
@@ -82,11 +84,13 @@ export class CustomEditor extends Editor {
82
84
  onPastePendingInputCleared?: (reason: PastePendingClearReason, droppedInputCount: number) => void;
83
85
  /** Called when the configured dequeue shortcut is pressed. */
84
86
  onDequeue?: () => void;
87
+ /** Called when the configured queue shortcut is pressed. */
88
+ onQueue?: () => void;
85
89
  /** Called when Caps Lock is pressed. */
86
90
  onCapsLock?: () => void;
87
91
 
88
92
  /** Custom key handlers from extensions and non-built-in app actions. */
89
- #customKeyHandlers = new Map<KeyId, () => void>();
93
+ #customKeyHandlers = new Map<KeyId, () => boolean | undefined>();
90
94
  #actionKeys = new Map<ConfigurableEditorAction, KeyId[]>(
91
95
  Object.entries(DEFAULT_ACTION_KEYS).map(([action, keys]) => [action as ConfigurableEditorAction, [...keys]]),
92
96
  );
@@ -112,7 +116,7 @@ export class CustomEditor extends Editor {
112
116
  /**
113
117
  * Register a custom key handler. Extensions use this for shortcuts.
114
118
  */
115
- setCustomKeyHandler(key: KeyId, handler: () => void): void {
119
+ setCustomKeyHandler(key: KeyId, handler: () => boolean | undefined): void {
116
120
  this.#customKeyHandlers.set(key, handler);
117
121
  }
118
122
 
@@ -328,6 +332,12 @@ export class CustomEditor extends Editor {
328
332
  return;
329
333
  }
330
334
 
335
+ // Intercept configured queue shortcut (send message after current turn)
336
+ if (this.#matchesAction(data, "app.message.queue") && this.onQueue) {
337
+ this.onQueue();
338
+ return;
339
+ }
340
+
331
341
  // Intercept configured copy-prompt shortcut
332
342
  if (this.#matchesAction(data, "app.clipboard.copyPrompt") && this.onCopyPrompt) {
333
343
  this.onCopyPrompt();
@@ -343,8 +353,7 @@ export class CustomEditor extends Editor {
343
353
  // Check custom key handlers (extensions)
344
354
  for (const [keyId, handler] of this.#customKeyHandlers) {
345
355
  if (matchesKey(data, keyId)) {
346
- handler();
347
- return;
356
+ if (handler() !== false) return;
348
357
  }
349
358
  }
350
359
 
@@ -0,0 +1,293 @@
1
+ import { Container, Input, matchesKey, Spacer, Text, TruncatedText } from "@gajae-code/tui";
2
+ import type { ModelProfileConfig } from "../../config/models-config-schema";
3
+ import { theme } from "../theme/theme";
4
+ import { matchesAppInterrupt } from "../utils/keybinding-matchers";
5
+ import { DynamicBorder } from "./dynamic-border";
6
+
7
+ const PROFILE_NAME_PATTERN = /^[a-z0-9][a-z0-9._-]*$/;
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
+ export interface CustomModelPresetWizardSubmit {
19
+ name: string;
20
+ profile: ModelProfileConfig;
21
+ }
22
+
23
+ export class CustomModelPresetWizardComponent extends Container {
24
+ #contentContainer: Container;
25
+ #input: Input | null = null;
26
+ #step: WizardStep = "name";
27
+ #selectedIndex = 0;
28
+ #lastError: string | null = null;
29
+ #state: WizardState = {
30
+ name: "",
31
+ displayName: "",
32
+ provider: "",
33
+ model: "",
34
+ };
35
+ #onSubmit: (input: CustomModelPresetWizardSubmit) => void;
36
+ #onCancel: () => void;
37
+ #onRender: () => void;
38
+
39
+ constructor(
40
+ onSubmit: (input: CustomModelPresetWizardSubmit) => void,
41
+ onCancel: () => void,
42
+ onRender: () => void = () => {},
43
+ ) {
44
+ super();
45
+ this.#onSubmit = onSubmit;
46
+ this.#onCancel = onCancel;
47
+ this.#onRender = onRender;
48
+
49
+ this.addChild(new DynamicBorder());
50
+ this.addChild(new Spacer(1));
51
+ this.addChild(new TruncatedText(theme.bold("Create custom model preset")));
52
+ this.addChild(
53
+ new TruncatedText(
54
+ theme.fg("muted", " Save provider/model as a selectable profile. Secrets are not requested."),
55
+ 0,
56
+ 0,
57
+ ),
58
+ );
59
+ this.addChild(new Spacer(1));
60
+ this.#contentContainer = new Container();
61
+ this.addChild(this.#contentContainer);
62
+ this.addChild(new Spacer(1));
63
+ this.addChild(new DynamicBorder());
64
+ this.#renderStep();
65
+ }
66
+
67
+ setSubmitError(error: string): void {
68
+ this.#lastError = error;
69
+ this.#renderStep();
70
+ this.#onRender();
71
+ }
72
+
73
+ handleInput(keyData: string): void {
74
+ if (matchesAppInterrupt(keyData)) {
75
+ if (this.#step === "name") {
76
+ this.#onCancel();
77
+ return;
78
+ }
79
+ this.#goBack();
80
+ return;
81
+ }
82
+
83
+ if (this.#input) {
84
+ if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
85
+ this.#saveInputAndProceed();
86
+ return;
87
+ }
88
+ 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
+ }
103
+ }
104
+
105
+ #renderStep(): void {
106
+ this.#contentContainer.clear();
107
+ 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)));
149
+ this.#contentContainer.addChild(new Spacer(1));
150
+ if (this.#lastError) {
151
+ this.#contentContainer.addChild(new Text(theme.fg("error", this.#lastError), 0, 0));
152
+ this.#contentContainer.addChild(new Spacer(1));
153
+ }
154
+ this.#contentContainer.addChild(new Text(prompt, 0, 0));
155
+ this.#contentContainer.addChild(new Spacer(1));
156
+ this.#input = new Input();
157
+ this.#input.setValue(value);
158
+ this.#contentContainer.addChild(this.#input);
159
+ this.#contentContainer.addChild(new Spacer(1));
160
+ this.#addHelp(hint);
161
+ this.#addHelp("[Enter to continue, Esc to go back]");
162
+ }
163
+
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));
170
+ }
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));
175
+ 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
+ }
186
+
187
+ #addHelp(text: string): void {
188
+ this.#contentContainer.addChild(new Text(theme.fg("muted", text), 0, 0));
189
+ }
190
+
191
+ #saveInputAndProceed(): void {
192
+ const value = this.#input?.getValue().trim() ?? "";
193
+ if (!value) {
194
+ this.#lastError = this.#emptyFieldMessage();
195
+ this.#renderStep();
196
+ this.#onRender();
197
+ return;
198
+ }
199
+ const validationError = this.#validateCurrentInput(value);
200
+ if (validationError) {
201
+ this.#lastError = validationError;
202
+ this.#renderStep();
203
+ this.#onRender();
204
+ return;
205
+ }
206
+ 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,
265
+ profile: {
266
+ required_providers: [this.#state.provider],
267
+ display_name: this.#state.displayName,
268
+ model_mapping: { default: this.#selector() },
269
+ },
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();
292
+ }
293
+ }
@@ -518,7 +518,7 @@ export class HookSelectorComponent extends Container {
518
518
  return;
519
519
  }
520
520
  if (matchesKey(keyData, "enter") || matchesKey(keyData, "return")) {
521
- this.#customInput?.onSubmit(editor.getText());
521
+ this.#customInput?.onSubmit(editor.getExpandedText());
522
522
  return;
523
523
  }
524
524
  editor.handleInput(keyData);
@@ -102,6 +102,9 @@ export type ModelSelectorSelection =
102
102
  kind: "profile";
103
103
  profileName: string;
104
104
  setDefault: boolean;
105
+ }
106
+ | {
107
+ kind: "createProfile";
105
108
  };
106
109
 
107
110
  interface PendingThinkingChoice {
@@ -148,11 +151,15 @@ interface PresetProfileRow {
148
151
  profile: ModelProfileDefinition;
149
152
  }
150
153
 
154
+ interface PresetCreateRow {
155
+ kind: "create";
156
+ }
157
+
151
158
  interface PresetBrowseRow {
152
159
  kind: "browse";
153
160
  }
154
161
 
155
- type PresetLandingRow = PresetGroupRow | PresetProfileRow | PresetBrowseRow;
162
+ type PresetLandingRow = PresetGroupRow | PresetProfileRow | PresetCreateRow | PresetBrowseRow;
156
163
 
157
164
  // Stable logical identity for a preset landing row, independent of its current
158
165
  // list position. Used to relocate the cursor after the expanded group changes so
@@ -165,6 +172,8 @@ function presetRowIdentity(row: PresetLandingRow): string {
165
172
  return `profile:${row.groupId}:${row.profile.name}`;
166
173
  case "browse":
167
174
  return "browse";
175
+ case "create":
176
+ return "create";
168
177
  }
169
178
  }
170
179
 
@@ -186,8 +195,9 @@ function profileRequiredProviders(profile: ModelProfileDefinition): string[] {
186
195
  }
187
196
  /**
188
197
  * Component that renders a canonical model selector with provider tabs.
189
- * - Tab/Arrow Left/Right: Switch between provider tabs
190
- * - Arrow Up/Down: Navigate model list
198
+ * - Preset landing Left/Right: Collapse/expand selected provider
199
+ * - Model browser Tab/Arrow Left/Right: Switch between provider tabs
200
+ * - Arrow Up/Down: Navigate rows
191
201
  * - Enter: Open assignment actions for default plus GJC role-agent models
192
202
  * - Escape: Close selector
193
203
  */
@@ -314,7 +324,6 @@ export class ModelSelectorComponent extends Container {
314
324
  this.#viewMode = "models";
315
325
  }
316
326
  if (this.#viewMode === "presets") {
317
- this.#updatePresetExpansion();
318
327
  void this.#refreshProviderAuth();
319
328
  this.#renderPresetLanding();
320
329
  } else {
@@ -701,6 +710,7 @@ export class ModelSelectorComponent extends Container {
701
710
  for (const profile of profiles) rows.push({ kind: "profile", groupId, profile });
702
711
  }
703
712
  }
713
+ rows.push({ kind: "create" });
704
714
  rows.push({ kind: "browse" });
705
715
  return rows;
706
716
  }
@@ -760,26 +770,36 @@ export class ModelSelectorComponent extends Container {
760
770
  this.#tui.requestRender();
761
771
  }
762
772
 
763
- #updatePresetExpansion(): void {
764
- const selected = this.#getSelectedPresetRow();
765
- if (selected?.kind === "group") this.#expandedPresetProviderId = selected.groupId;
766
- if (selected?.kind === "profile") this.#expandedPresetProviderId = selected.groupId;
773
+ #clampPresetCursor(): void {
767
774
  const rows = this.#getPresetRows();
768
- // Expanding/collapsing a group shifts row positions. Relocate the cursor by
769
- // the selected row's logical identity so crossing a provider group boundary
770
- // keeps it on the same logical row instead of overshooting into the
771
- // destination group's profiles (or off the end of the list).
772
- if (selected) {
773
- const targetIdentity = presetRowIdentity(selected);
774
- const relocated = rows.findIndex(row => presetRowIdentity(row) === targetIdentity);
775
- if (relocated >= 0) {
776
- this.#presetCursor = relocated;
777
- return;
778
- }
779
- }
780
775
  this.#presetCursor = Math.min(this.#presetCursor, Math.max(0, rows.length - 1));
781
776
  }
782
777
 
778
+ #relocatePresetCursor(targetIdentity: string): boolean {
779
+ const relocated = this.#getPresetRows().findIndex(row => presetRowIdentity(row) === targetIdentity);
780
+ if (relocated < 0) return false;
781
+ this.#presetCursor = relocated;
782
+ return true;
783
+ }
784
+
785
+ #expandSelectedPresetProvider(): void {
786
+ const selected = this.#getSelectedPresetRow();
787
+ if (!selected || selected.kind === "browse" || selected.kind === "create") return;
788
+ if (this.#expandedPresetProviderId === selected.groupId) return;
789
+ const targetIdentity = presetRowIdentity(selected);
790
+ this.#expandedPresetProviderId = selected.groupId;
791
+ if (!this.#relocatePresetCursor(targetIdentity)) this.#clampPresetCursor();
792
+ }
793
+
794
+ #collapseSelectedPresetProvider(): void {
795
+ const selected = this.#getSelectedPresetRow();
796
+ if (!selected || selected.kind === "browse" || selected.kind === "create") return;
797
+ if (this.#expandedPresetProviderId !== selected.groupId) return;
798
+ const targetIdentity = selected.kind === "profile" ? `group:${selected.groupId}` : presetRowIdentity(selected);
799
+ this.#expandedPresetProviderId = undefined;
800
+ if (!this.#relocatePresetCursor(targetIdentity)) this.#clampPresetCursor();
801
+ }
802
+
783
803
  #switchToModelMode(seed?: string): void {
784
804
  this.#viewMode = "models";
785
805
  this.#expandedPresetProviderId = undefined;
@@ -804,6 +824,11 @@ export class ModelSelectorComponent extends Container {
804
824
  const row = rows[i];
805
825
  const selected = i === this.#presetCursor;
806
826
  const prefix = selected ? theme.fg("accent", `${theme.nav.cursor} `) : " ";
827
+ if (row.kind === "create") {
828
+ const label = "Create custom preset";
829
+ this.#listContainer.addChild(new Text(`${prefix}${selected ? theme.fg("accent", label) : label}`, 0, 0));
830
+ continue;
831
+ }
807
832
  if (row.kind === "browse") {
808
833
  const label = "Browse all models";
809
834
  this.#listContainer.addChild(new Text(`${prefix}${selected ? theme.fg("accent", label) : label}`, 0, 0));
@@ -817,7 +842,7 @@ export class ModelSelectorComponent extends Container {
817
842
  this.#listContainer.addChild(new Text(`${prefix}${renderedLabel}`, 0, 0));
818
843
  continue;
819
844
  }
820
- const presentation = getModelProfilePresentation(row.profile.name);
845
+ const presentation = getModelProfilePresentation(row.profile);
821
846
  const authenticated = this.#isPresetAuthenticated(row.profile);
822
847
  const mark = this.#providerAuthPending ? "…" : authenticated ? "✓" : "✗";
823
848
  const label = ` ${mark} ${presentation.displayName}`;
@@ -835,11 +860,7 @@ export class ModelSelectorComponent extends Container {
835
860
  #renderPresetPreview(profile: ModelProfileDefinition): void {
836
861
  this.#listContainer.addChild(new Spacer(1));
837
862
  this.#listContainer.addChild(
838
- new Text(
839
- theme.fg("muted", ` Preset preview: ${getModelProfilePresentation(profile.name).displayName}`),
840
- 0,
841
- 0,
842
- ),
863
+ new Text(theme.fg("muted", ` Preset preview: ${getModelProfilePresentation(profile).displayName}`), 0, 0),
843
864
  );
844
865
  for (const role of PROFILE_ROLE_PREVIEW_ORDER) {
845
866
  const selector = profile.modelMapping[role];
@@ -1160,7 +1181,7 @@ export class ModelSelectorComponent extends Container {
1160
1181
  this.#presetCursor = this.#presetCursor === 0 ? rows.length - 1 : this.#presetCursor - 1;
1161
1182
  this.#previewProfileName = undefined;
1162
1183
  this.#presetLoginHint = undefined;
1163
- this.#updatePresetExpansion();
1184
+ this.#clampPresetCursor();
1164
1185
  }
1165
1186
  this.#renderPresetLanding();
1166
1187
  return;
@@ -1174,11 +1195,29 @@ export class ModelSelectorComponent extends Container {
1174
1195
  this.#presetCursor = (this.#presetCursor + 1) % rows.length;
1175
1196
  this.#previewProfileName = undefined;
1176
1197
  this.#presetLoginHint = undefined;
1177
- this.#updatePresetExpansion();
1198
+ this.#clampPresetCursor();
1178
1199
  }
1179
1200
  this.#renderPresetLanding();
1180
1201
  return;
1181
1202
  }
1203
+ if (matchesKey(keyData, "right")) {
1204
+ if (!this.#presetScopeMenuOpen) {
1205
+ this.#expandSelectedPresetProvider();
1206
+ this.#previewProfileName = undefined;
1207
+ this.#presetLoginHint = undefined;
1208
+ this.#renderPresetLanding();
1209
+ }
1210
+ return;
1211
+ }
1212
+ if (matchesKey(keyData, "left")) {
1213
+ if (!this.#presetScopeMenuOpen) {
1214
+ this.#collapseSelectedPresetProvider();
1215
+ this.#previewProfileName = undefined;
1216
+ this.#presetLoginHint = undefined;
1217
+ this.#renderPresetLanding();
1218
+ }
1219
+ return;
1220
+ }
1182
1221
  if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
1183
1222
  this.#handlePresetEnter();
1184
1223
  return;
@@ -1196,7 +1235,7 @@ export class ModelSelectorComponent extends Container {
1196
1235
  }
1197
1236
  if (this.#expandedPresetProviderId) {
1198
1237
  this.#expandedPresetProviderId = undefined;
1199
- this.#presetCursor = Math.min(this.#presetCursor, Math.max(0, this.#getPresetRows().length - 1));
1238
+ this.#clampPresetCursor();
1200
1239
  this.#renderPresetLanding();
1201
1240
  return;
1202
1241
  }
@@ -1221,6 +1260,10 @@ export class ModelSelectorComponent extends Container {
1221
1260
  }
1222
1261
  const row = this.#getSelectedPresetRow();
1223
1262
  if (!row) return;
1263
+ if (row.kind === "create") {
1264
+ this.#onSelectCallback({ kind: "createProfile" });
1265
+ return;
1266
+ }
1224
1267
  if (row.kind === "browse") {
1225
1268
  this.#switchToModelMode();
1226
1269
  return;