@gajae-code/coding-agent 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +16 -1
  2. package/dist/types/config/model-registry.d.ts +8 -0
  3. package/dist/types/config/model-resolver.d.ts +4 -1
  4. package/dist/types/gjc-runtime/team-runtime.d.ts +5 -0
  5. package/dist/types/gjc-runtime/ultragoal-guard.d.ts +26 -0
  6. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +44 -0
  7. package/dist/types/goals/tools/goal-tool.d.ts +4 -4
  8. package/dist/types/hooks/skill-state.d.ts +3 -0
  9. package/dist/types/modes/components/model-selector.d.ts +5 -7
  10. package/dist/types/modes/interactive-mode.d.ts +1 -0
  11. package/dist/types/sdk.d.ts +2 -4
  12. package/dist/types/session/agent-session.d.ts +3 -9
  13. package/dist/types/skill-state/deep-interview-mutation-guard.d.ts +28 -0
  14. package/package.json +13 -9
  15. package/src/config/model-registry.ts +45 -0
  16. package/src/config/model-resolver.ts +5 -1
  17. package/src/defaults/gjc/skills/deep-interview/SKILL.md +30 -30
  18. package/src/defaults/gjc/skills/team/SKILL.md +1 -0
  19. package/src/defaults/gjc/skills/ultragoal/SKILL.md +51 -21
  20. package/src/gjc-runtime/team-runtime.ts +80 -1
  21. package/src/gjc-runtime/ultragoal-guard.ts +239 -0
  22. package/src/gjc-runtime/ultragoal-runtime.ts +318 -4
  23. package/src/goals/tools/goal-tool.ts +10 -4
  24. package/src/hooks/native-skill-hook.ts +26 -0
  25. package/src/hooks/skill-state.ts +59 -0
  26. package/src/main.ts +2 -17
  27. package/src/modes/components/model-selector.ts +225 -33
  28. package/src/modes/controllers/selector-controller.ts +16 -3
  29. package/src/modes/interactive-mode.ts +34 -22
  30. package/src/modes/prompt-action-autocomplete.ts +40 -15
  31. package/src/sdk.ts +3 -1
  32. package/src/session/agent-session.ts +40 -4
  33. package/src/setup/model-onboarding-guidance.ts +5 -3
  34. package/src/skill-state/deep-interview-mutation-guard.ts +303 -0
  35. package/src/slash-commands/builtin-registry.ts +130 -11
  36. package/src/tools/ask.ts +55 -17
  37. package/src/tools/ast-edit.ts +7 -0
  38. package/src/tools/bash.ts +2 -1
  39. package/src/tools/gh.ts +37 -9
  40. package/src/tools/image-gen.ts +19 -10
  41. package/src/tools/path-utils.ts +1 -0
@@ -152,6 +152,16 @@ function readTurnId(payload: HookPayload): string | undefined {
152
152
  return safeString(payload.turn_id ?? payload.turnId).trim() || undefined;
153
153
  }
154
154
 
155
+ function readSessionFile(payload: HookPayload): string | undefined {
156
+ return (
157
+ safeString(
158
+ payload.session_file ?? payload.sessionFile ?? payload.transcript_path ?? payload.transcriptPath,
159
+ ).trim() ||
160
+ process.env.GJC_SESSION_FILE?.trim() ||
161
+ undefined
162
+ );
163
+ }
164
+
155
165
  export async function dispatchGjcNativeSkillHook(
156
166
  payload: HookPayload,
157
167
  options: GjcNativeHookDispatchOptions = {},
@@ -180,7 +190,22 @@ export async function dispatchGjcNativeSkillHook(
180
190
  sessionId: readSessionId(payload),
181
191
  threadId: readThreadId(payload),
182
192
  stateDir: options.stateDir,
193
+ prompt,
194
+ sessionFile: readSessionFile(payload),
183
195
  });
196
+ if (activeUltragoalContext?.startsWith("BLOCK_ULTRAGOAL_COMPLETION:")) {
197
+ return {
198
+ hookEventName,
199
+ outputJson: {
200
+ decision: "block",
201
+ reason: activeUltragoalContext,
202
+ hookSpecificOutput: {
203
+ hookEventName,
204
+ additionalContext: activeUltragoalContext,
205
+ },
206
+ },
207
+ };
208
+ }
184
209
  return {
185
210
  hookEventName,
186
211
  outputJson:
@@ -205,6 +230,7 @@ export async function dispatchGjcNativeSkillHook(
205
230
  sessionId: readSessionId(payload),
206
231
  threadId: readThreadId(payload),
207
232
  stateDir: options.stateDir,
233
+ sessionFile: readSessionFile(payload),
208
234
  }),
209
235
  };
210
236
  }
@@ -1,6 +1,8 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import * as path from "node:path";
3
3
  import type { SkillDiscoverySettings } from "../config/skill-settings-defaults";
4
+ import { isUltragoalBypassPrompt, readUltragoalVerificationState } from "../gjc-runtime/ultragoal-guard";
5
+ import { buildSessionContext, loadEntriesFromFile, type SessionEntry } from "../session/session-manager";
4
6
  import {
5
7
  compareSkillKeywordMatches,
6
8
  GJC_SKILL_KEYWORD_DEFINITIONS,
@@ -124,6 +126,7 @@ export interface StopHookInput {
124
126
  sessionId?: string;
125
127
  threadId?: string;
126
128
  stateDir?: string;
129
+ sessionFile?: string;
127
130
  }
128
131
 
129
132
  export interface UserPromptSubmitStateInput {
@@ -131,6 +134,8 @@ export interface UserPromptSubmitStateInput {
131
134
  sessionId?: string;
132
135
  threadId?: string;
133
136
  stateDir?: string;
137
+ prompt?: string;
138
+ sessionFile?: string;
134
139
  }
135
140
 
136
141
  function escapeRegex(value: string): string {
@@ -361,6 +366,19 @@ function stateMatchesContext(state: ModeState, sessionId?: string, threadId?: st
361
366
  return true;
362
367
  }
363
368
 
369
+ async function readCurrentGoalObjectiveFromSessionFile(sessionFile: string | undefined): Promise<string | null> {
370
+ const trimmed = sessionFile?.trim();
371
+ if (!trimmed) return null;
372
+ const entries = (await loadEntriesFromFile(trimmed)).filter(
373
+ (entry): entry is SessionEntry => entry.type !== "session",
374
+ );
375
+ const context = buildSessionContext(entries);
376
+ const goal = context.modeData?.goal;
377
+ if (typeof goal !== "object" || goal === null) return null;
378
+ const objective = (goal as { objective?: unknown }).objective;
379
+ return typeof objective === "string" && objective.trim().length > 0 ? objective.trim() : null;
380
+ }
381
+
364
382
  export async function buildActiveUltragoalPromptContext(input: UserPromptSubmitStateInput): Promise<string | null> {
365
383
  const visibleModeState = await readVisibleModeState(input.cwd, "ultragoal", input.sessionId, input.stateDir);
366
384
  if (!visibleModeState) return null;
@@ -368,6 +386,22 @@ export async function buildActiveUltragoalPromptContext(input: UserPromptSubmitS
368
386
  if (!stateMatchesContext(visibleModeState.state, input.sessionId, input.threadId)) return null;
369
387
 
370
388
  const phase = String(visibleModeState.state.current_phase ?? "active");
389
+ const objective =
390
+ (await readCurrentGoalObjectiveFromSessionFile(input.sessionFile)) ??
391
+ (typeof visibleModeState.state.objective === "string"
392
+ ? visibleModeState.state.objective
393
+ : typeof visibleModeState.state.gjcObjective === "string"
394
+ ? visibleModeState.state.gjcObjective
395
+ : "");
396
+ if (input.prompt && isUltragoalBypassPrompt(input.prompt) && objective) {
397
+ const diagnostic = await readUltragoalVerificationState({
398
+ cwd: input.cwd,
399
+ currentGoal: { objective },
400
+ });
401
+ if (!["inactive", "unrelated_goal", "active_verified_complete"].includes(diagnostic.state)) {
402
+ return `BLOCK_ULTRAGOAL_COMPLETION: ${diagnostic.message} Use durable blocker work or run strict \`gjc ultragoal checkpoint --status complete --quality-gate-json <file> --gjc-goal-json <file>\` before completion.`;
403
+ }
404
+ }
371
405
  return `Ultragoal is active (phase: ${phase}; state: ${visibleModeState.statePath}). If the user prompt is a steering request, use \`gjc ultragoal steer\` to add or steer subgoals. Normal prose should not mutate Ultragoal state.`;
372
406
  }
373
407
 
@@ -384,6 +418,31 @@ export async function buildSkillStopOutput(input: StopHookInput): Promise<Record
384
418
  if (isTerminalModeState(modeState)) continue;
385
419
  const phase = String(modeState?.current_phase ?? entry.phase ?? skillState.phase ?? "active");
386
420
  const statePath = modeStatePath(resolvedStateDir, entry.skill, input.sessionId);
421
+ if (entry.skill === "ultragoal") {
422
+ const objective =
423
+ (await readCurrentGoalObjectiveFromSessionFile(input.sessionFile)) ??
424
+ (typeof modeState?.objective === "string"
425
+ ? modeState.objective
426
+ : typeof modeState?.gjcObjective === "string"
427
+ ? modeState.gjcObjective
428
+ : "");
429
+ if (objective) {
430
+ const diagnostic = await readUltragoalVerificationState({
431
+ cwd: input.cwd,
432
+ currentGoal: { objective },
433
+ });
434
+ if (diagnostic.state === "active_verified_complete") continue;
435
+ if (!["inactive", "unrelated_goal"].includes(diagnostic.state)) {
436
+ const ultragoalMessage = `GJC ultragoal verification is blocking stop: ${diagnostic.message} Run strict checkpoint verification or record review blockers before stopping.`;
437
+ return {
438
+ decision: "block",
439
+ reason: ultragoalMessage,
440
+ stopReason: `gjc_ultragoal_verification_${diagnostic.state}`,
441
+ systemMessage: ultragoalMessage,
442
+ };
443
+ }
444
+ }
445
+ }
387
446
  const systemMessage = `GJC skill "${entry.skill}" is still active (phase: ${phase}; state: ${statePath}). Continue or explicitly finish/cancel the skill before stopping.`;
388
447
  return {
389
448
  decision: "block",
package/src/main.ts CHANGED
@@ -480,21 +480,6 @@ async function maybeAutoChdir(parsed: Args): Promise<void> {
480
480
  }
481
481
  }
482
482
 
483
- /** Discover SYSTEM.md file if no CLI system prompt was provided */
484
- function discoverSystemPromptFile(): string | undefined {
485
- // Check project-local first (.gjc/SYSTEM.md, .pi/SYSTEM.md legacy)
486
- const projectPath = findConfigFile("SYSTEM.md", { user: false });
487
- if (projectPath) {
488
- return projectPath;
489
- }
490
- // If not found, check SYSTEM.md file in the global directory.
491
- const globalPath = findConfigFile("SYSTEM.md", { user: true });
492
- if (globalPath) {
493
- return globalPath;
494
- }
495
- return undefined;
496
- }
497
-
498
483
  /** Discover APPEND_SYSTEM.md file if no CLI append system prompt was provided */
499
484
  function discoverAppendSystemPromptFile(): string | undefined {
500
485
  const projectPath = findConfigFile("APPEND_SYSTEM.md", { user: false });
@@ -519,8 +504,7 @@ async function buildSessionOptions(
519
504
  cwd: parsed.cwd ?? getProjectDir(),
520
505
  };
521
506
 
522
- // Auto-discover SYSTEM.md if no CLI system prompt provided
523
- const systemPromptSource = parsed.systemPrompt ?? discoverSystemPromptFile();
507
+ const systemPromptSource = parsed.systemPrompt;
524
508
  const resolvedSystemPrompt = await resolvePromptInput(systemPromptSource, "system prompt");
525
509
  const appendPromptSource = parsed.appendSystemPrompt ?? discoverAppendSystemPromptFile();
526
510
  const resolvedAppendPrompt = await resolvePromptInput(appendPromptSource, "append system prompt");
@@ -617,6 +601,7 @@ async function buildSessionOptions(
617
601
  thinkingLevel: scopedModel.explicitThinkingLevel
618
602
  ? (scopedModel.thinkingLevel ?? defaultThinkingLevel)
619
603
  : defaultThinkingLevel,
604
+ explicitThinkingLevel: scopedModel.explicitThinkingLevel,
620
605
  }));
621
606
  }
622
607
 
@@ -1,5 +1,5 @@
1
1
  import { ThinkingLevel } from "@gajae-code/agent-core";
2
- import { type Model, modelsAreEqual } from "@gajae-code/ai";
2
+ import { getSupportedEfforts, type Model, modelsAreEqual } from "@gajae-code/ai";
3
3
  import {
4
4
  Container,
5
5
  fuzzyFilter,
@@ -12,13 +12,17 @@ import {
12
12
  Text,
13
13
  type TUI,
14
14
  } from "@gajae-code/tui";
15
- import type { ModelRegistry } from "../../config/model-registry";
16
- import { getRoleInfo } from "../../config/model-registry";
17
- import { resolveModelRoleValue } from "../../config/model-resolver";
15
+ import type { GjcModelAssignmentTargetId, ModelRegistry } from "../../config/model-registry";
16
+ import { GJC_MODEL_ASSIGNMENT_TARGET_IDS, GJC_MODEL_ASSIGNMENT_TARGETS } from "../../config/model-registry";
17
+ import {
18
+ formatModelSelectorValue,
19
+ resolveModelRoleValue,
20
+ type ScopedModelSelection,
21
+ } from "../../config/model-resolver";
18
22
  import type { Settings } from "../../config/settings";
19
23
  import { type ThemeColor, theme } from "../../modes/theme/theme";
20
24
  import { formatModelOnboardingInlineHint } from "../../setup/model-onboarding-guidance";
21
- import { getThinkingLevelMetadata } from "../../thinking";
25
+ import { getThinkingLevelMetadata, parseThinkingLevel } from "../../thinking";
22
26
  import { getTabBarTheme } from "../shared";
23
27
  import { DynamicBorder } from "./dynamic-border";
24
28
 
@@ -53,6 +57,8 @@ interface ModelItem {
53
57
  id: string;
54
58
  model: Model;
55
59
  selector: string;
60
+ thinkingLevel?: ThinkingLevel;
61
+ explicitThinkingLevel?: boolean;
56
62
  }
57
63
 
58
64
  interface CanonicalModelItem {
@@ -64,21 +70,26 @@ interface CanonicalModelItem {
64
70
  searchText: string;
65
71
  normalizedSearchText: string;
66
72
  compactSearchText: string;
73
+ thinkingLevel?: ThinkingLevel;
74
+ explicitThinkingLevel?: boolean;
67
75
  }
68
76
 
69
- interface ScopedModelItem {
70
- model: Model;
71
- thinkingLevel?: string;
72
- }
77
+ type ScopedModelItem = ScopedModelSelection;
73
78
 
74
79
  interface RoleAssignment {
75
80
  model: Model;
76
81
  thinkingLevel: ThinkingLevel;
77
82
  }
78
83
 
84
+ interface PendingThinkingChoice {
85
+ item: ModelItem | CanonicalModelItem;
86
+ role: GjcModelAssignmentTargetId | null;
87
+ levels: ThinkingLevel[];
88
+ }
89
+
79
90
  type RoleSelectCallback = (
80
91
  model: Model,
81
- role: "default" | null,
92
+ role: GjcModelAssignmentTargetId | null,
82
93
  thinkingLevel?: ThinkingLevel,
83
94
  selector?: string,
84
95
  ) => void;
@@ -108,7 +119,7 @@ function createProviderTab(providerId: string): ProviderTabState {
108
119
  * Component that renders a canonical model selector with provider tabs.
109
120
  * - Tab/Arrow Left/Right: Switch between provider tabs
110
121
  * - Arrow Up/Down: Navigate model list
111
- * - Enter: Select the highlighted model as the canonical/default model
122
+ * - Enter: Open assignment actions for default plus GJC role-agent models
112
123
  * - Escape: Close selector
113
124
  */
114
125
  export class ModelSelectorComponent extends Container {
@@ -130,6 +141,10 @@ export class ModelSelectorComponent extends Container {
130
141
  #tui: TUI;
131
142
  #scopedModels: ReadonlyArray<ScopedModelItem>;
132
143
  #temporaryOnly: boolean;
144
+ #pendingActionItem?: ModelItem | CanonicalModelItem;
145
+ #selectedActionIndex: number = 0;
146
+ #pendingThinkingChoice?: PendingThinkingChoice;
147
+ #selectedThinkingIndex: number = 0;
133
148
 
134
149
  // Tab state
135
150
  #providers: ProviderTabState[] = STATIC_PROVIDER_TABS;
@@ -141,7 +156,12 @@ export class ModelSelectorComponent extends Container {
141
156
  settings: Settings,
142
157
  modelRegistry: ModelRegistry,
143
158
  scopedModels: ReadonlyArray<ScopedModelItem>,
144
- onSelect: (model: Model, role: "default" | null, thinkingLevel?: ThinkingLevel, selector?: string) => void,
159
+ onSelect: (
160
+ model: Model,
161
+ role: GjcModelAssignmentTargetId | null,
162
+ thinkingLevel?: ThinkingLevel,
163
+ selector?: string,
164
+ ) => void,
145
165
  onCancel: () => void,
146
166
  options?: { temporaryOnly?: boolean; initialSearchInput?: string },
147
167
  ) {
@@ -183,7 +203,7 @@ export class ModelSelectorComponent extends Container {
183
203
  this.#searchInput.onSubmit = () => {
184
204
  const selectedItem = this.#getSelectedItem();
185
205
  if (selectedItem) {
186
- this.#handleSelect(selectedItem, this.#temporaryOnly ? null : "default");
206
+ this.#beginActionMenuOrSelect(selectedItem);
187
207
  }
188
208
  };
189
209
  this.addChild(this.#searchInput);
@@ -219,8 +239,11 @@ export class ModelSelectorComponent extends Container {
219
239
  #loadRoleModels(): void {
220
240
  const allModels = this.#modelRegistry.getAll();
221
241
  const matchPreferences = { usageOrder: this.#settings.getStorage()?.getModelUsageOrder() };
222
- for (const role of ["default"]) {
223
- const roleValue = this.#settings.getModelRole(role);
242
+ const agentModelOverrides = this.#settings.get("task.agentModelOverrides");
243
+ for (const role of GJC_MODEL_ASSIGNMENT_TARGET_IDS) {
244
+ const target = GJC_MODEL_ASSIGNMENT_TARGETS[role];
245
+ const roleValue =
246
+ target.settingsPath === "modelRoles" ? this.#settings.getModelRole(role) : agentModelOverrides[role];
224
247
  if (!roleValue) continue;
225
248
 
226
249
  const resolved = resolveModelRoleValue(roleValue, allModels, {
@@ -336,6 +359,8 @@ export class ModelSelectorComponent extends Container {
336
359
  id: scoped.model.id,
337
360
  model: scoped.model,
338
361
  selector: `${scoped.model.provider}/${scoped.model.id}`,
362
+ thinkingLevel: scoped.thinkingLevel,
363
+ explicitThinkingLevel: scoped.explicitThinkingLevel,
339
364
  }));
340
365
  } else {
341
366
  // Reload config and cached discovery state without blocking on live provider refresh
@@ -369,17 +394,20 @@ export class ModelSelectorComponent extends Container {
369
394
  }
370
395
  }
371
396
 
397
+ const candidateModels = models.map(item => item.model);
372
398
  const canonicalRecords = this.#modelRegistry.getCanonicalModels({
373
399
  availableOnly: this.#scopedModels.length === 0,
374
- candidates: models.map(item => item.model),
400
+ candidates: candidateModels,
375
401
  });
402
+ const scopedThinkingBySelector = new Map(models.map(item => [item.selector, item.thinkingLevel]));
376
403
  const canonicalModels = canonicalRecords
377
- .map(record => {
404
+ .map((record): CanonicalModelItem | undefined => {
378
405
  const selectedModel = this.#modelRegistry.resolveCanonicalModel(record.id, {
379
406
  availableOnly: this.#scopedModels.length === 0,
380
- candidates: models.map(item => item.model),
407
+ candidates: candidateModels,
381
408
  });
382
409
  if (!selectedModel) return undefined;
410
+ const selectedSelector = `${selectedModel.provider}/${selectedModel.id}`;
383
411
  const searchText = [
384
412
  record.id,
385
413
  record.name,
@@ -388,8 +416,8 @@ export class ModelSelectorComponent extends Container {
388
416
  selectedModel.name,
389
417
  ...record.variants.flatMap(variant => [variant.selector, variant.model.name]),
390
418
  ].join(" ");
391
- return {
392
- kind: "canonical" as const,
419
+ const item: CanonicalModelItem = {
420
+ kind: "canonical",
393
421
  id: record.id,
394
422
  model: selectedModel,
395
423
  selector: record.id,
@@ -398,6 +426,15 @@ export class ModelSelectorComponent extends Container {
398
426
  normalizedSearchText: normalizeSearchText(searchText),
399
427
  compactSearchText: compactSearchText(searchText),
400
428
  };
429
+ const scopedThinkingLevel = scopedThinkingBySelector.get(selectedSelector);
430
+ if (scopedThinkingLevel !== undefined) {
431
+ item.thinkingLevel = scopedThinkingLevel;
432
+ }
433
+ const scopedModel = models.find(model => `${model.model.provider}/${model.model.id}` === selectedSelector);
434
+ if (scopedModel?.explicitThinkingLevel !== undefined) {
435
+ item.explicitThinkingLevel = scopedModel.explicitThinkingLevel;
436
+ }
437
+ return item;
401
438
  })
402
439
  .filter((item): item is CanonicalModelItem => item !== undefined);
403
440
 
@@ -627,12 +664,14 @@ export class ModelSelectorComponent extends Container {
627
664
 
628
665
  // Build role badges (inverted: color as background, black text)
629
666
  const roleBadgeTokens: string[] = [];
630
- const defaultRoleInfo = getRoleInfo("default", this.#settings);
631
- const defaultAssigned = this.#roles.default;
632
- if (defaultRoleInfo.tag && defaultAssigned && modelsAreEqual(defaultAssigned.model, item.model)) {
633
- const badge = makeInvertedBadge(defaultRoleInfo.tag, defaultRoleInfo.color ?? "success");
634
- const thinkingLabel = getThinkingLevelMetadata(defaultAssigned.thinkingLevel).label;
635
- roleBadgeTokens.push(`${badge} ${theme.fg("dim", `(${thinkingLabel})`)}`);
667
+ for (const role of GJC_MODEL_ASSIGNMENT_TARGET_IDS) {
668
+ const roleInfo = GJC_MODEL_ASSIGNMENT_TARGETS[role];
669
+ const assigned = this.#roles[role];
670
+ if (roleInfo.tag && assigned && modelsAreEqual(assigned.model, item.model)) {
671
+ const badge = makeInvertedBadge(roleInfo.tag, roleInfo.color ?? "muted");
672
+ const thinkingLabel = getThinkingLevelMetadata(assigned.thinkingLevel).label;
673
+ roleBadgeTokens.push(`${badge} ${theme.fg("dim", `(${thinkingLabel})`)}`);
674
+ }
636
675
  }
637
676
  const badgeText = roleBadgeTokens.length > 0 ? ` ${roleBadgeTokens.join(" ")}` : "";
638
677
 
@@ -699,8 +738,47 @@ export class ModelSelectorComponent extends Container {
699
738
  this.#listContainer.addChild(
700
739
  new Text(theme.fg("muted", ` Model Name: ${selected.model.name}${suffix}`), 0, 0),
701
740
  );
741
+ if (this.#pendingThinkingChoice) {
742
+ this.#renderThinkingMenu(this.#pendingThinkingChoice);
743
+ } else if (this.#pendingActionItem) {
744
+ this.#renderActionMenu(this.#pendingActionItem);
745
+ }
746
+ }
747
+ }
748
+
749
+ #renderActionMenu(item: ModelItem | CanonicalModelItem): void {
750
+ this.#listContainer.addChild(new Spacer(1));
751
+ this.#listContainer.addChild(new Text(theme.fg("muted", ` Action for: ${item.model.id}`), 0, 0));
752
+ this.#listContainer.addChild(new Spacer(1));
753
+ for (let i = 0; i < GJC_MODEL_ASSIGNMENT_TARGET_IDS.length; i++) {
754
+ const role = GJC_MODEL_ASSIGNMENT_TARGET_IDS[i];
755
+ const target = GJC_MODEL_ASSIGNMENT_TARGETS[role];
756
+ const prefix = i === this.#selectedActionIndex ? theme.fg("accent", `${theme.nav.cursor} `) : " ";
757
+ const label = `Set as ${target.tag ?? role.toUpperCase()} (${target.name})`;
758
+ this.#listContainer.addChild(
759
+ new Text(`${prefix}${i === this.#selectedActionIndex ? theme.fg("accent", label) : label}`, 0, 0),
760
+ );
761
+ }
762
+ }
763
+
764
+ #renderThinkingMenu(choice: PendingThinkingChoice): void {
765
+ const targetLabel = choice.role === null ? "temporary model" : GJC_MODEL_ASSIGNMENT_TARGETS[choice.role].name;
766
+ this.#listContainer.addChild(new Spacer(1));
767
+ this.#listContainer.addChild(
768
+ new Text(theme.fg("muted", ` Reasoning for ${targetLabel}: ${choice.item.model.id}`), 0, 0),
769
+ );
770
+ this.#listContainer.addChild(new Spacer(1));
771
+ for (let i = 0; i < choice.levels.length; i++) {
772
+ const level = choice.levels[i];
773
+ const metadata = getThinkingLevelMetadata(level);
774
+ const prefix = i === this.#selectedThinkingIndex ? theme.fg("accent", `${theme.nav.cursor} `) : " ";
775
+ const label = `${metadata.label} — ${metadata.description}`;
776
+ this.#listContainer.addChild(
777
+ new Text(`${prefix}${i === this.#selectedThinkingIndex ? theme.fg("accent", label) : label}`, 0, 0),
778
+ );
702
779
  }
703
780
  }
781
+
704
782
  #getCurrentRoleThinkingLevel(role: string): ThinkingLevel {
705
783
  return this.#roles[role]?.thinkingLevel ?? ThinkingLevel.Inherit;
706
784
  }
@@ -712,6 +790,15 @@ export class ModelSelectorComponent extends Container {
712
790
  }
713
791
 
714
792
  handleInput(keyData: string): void {
793
+ if (this.#pendingThinkingChoice) {
794
+ this.#handleThinkingMenuInput(keyData);
795
+ return;
796
+ }
797
+ if (this.#pendingActionItem) {
798
+ this.#handleActionMenuInput(keyData);
799
+ return;
800
+ }
801
+
715
802
  // Tab bar navigation
716
803
  if (this.#tabBar?.handleInput(keyData)) {
717
804
  return;
@@ -735,12 +822,12 @@ export class ModelSelectorComponent extends Container {
735
822
  return;
736
823
  }
737
824
 
738
- // Enter - select highlighted model directly. Canonical setup exposes one default model,
739
- // while temporary-only mode keeps the existing non-persistent quick-switch behavior.
825
+ // Enter opens the persistent assignment menu. Temporary-only mode keeps the
826
+ // existing non-persistent quick-switch behavior.
740
827
  if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
741
828
  const selectedItem = this.#getSelectedItem();
742
829
  if (selectedItem) {
743
- this.#handleSelect(selectedItem, this.#temporaryOnly ? null : "default");
830
+ this.#beginActionMenuOrSelect(selectedItem);
744
831
  }
745
832
  return;
746
833
  }
@@ -755,20 +842,104 @@ export class ModelSelectorComponent extends Container {
755
842
  this.#searchInput.handleInput(keyData);
756
843
  this.#filterModels(this.#searchInput.getValue());
757
844
  }
758
- #handleSelect(item: ModelItem | CanonicalModelItem, role: "default" | null, thinkingLevel?: ThinkingLevel): void {
845
+ #beginActionMenuOrSelect(item: ModelItem | CanonicalModelItem): void {
846
+ if (this.#temporaryOnly) {
847
+ this.#handleSelect(item, null);
848
+ return;
849
+ }
850
+ this.#pendingActionItem = item;
851
+ this.#selectedActionIndex = 0;
852
+ this.#updateList();
853
+ }
854
+
855
+ #handleActionMenuInput(keyData: string): void {
856
+ if (matchesKey(keyData, "up")) {
857
+ this.#selectedActionIndex =
858
+ this.#selectedActionIndex === 0
859
+ ? GJC_MODEL_ASSIGNMENT_TARGET_IDS.length - 1
860
+ : this.#selectedActionIndex - 1;
861
+ this.#updateList();
862
+ return;
863
+ }
864
+ if (matchesKey(keyData, "down")) {
865
+ this.#selectedActionIndex = (this.#selectedActionIndex + 1) % GJC_MODEL_ASSIGNMENT_TARGET_IDS.length;
866
+ this.#updateList();
867
+ return;
868
+ }
869
+ if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
870
+ const item = this.#pendingActionItem;
871
+ if (!item) return;
872
+ const role = GJC_MODEL_ASSIGNMENT_TARGET_IDS[this.#selectedActionIndex];
873
+ this.#pendingActionItem = undefined;
874
+ this.#handleSelect(item, role);
875
+ return;
876
+ }
877
+ if (getKeybindings().matches(keyData, "tui.select.cancel")) {
878
+ this.#pendingActionItem = undefined;
879
+ this.#updateList();
880
+ }
881
+ }
882
+
883
+ #handleThinkingMenuInput(keyData: string): void {
884
+ const choice = this.#pendingThinkingChoice;
885
+ if (!choice) return;
886
+ if (matchesKey(keyData, "up")) {
887
+ this.#selectedThinkingIndex =
888
+ this.#selectedThinkingIndex === 0 ? choice.levels.length - 1 : this.#selectedThinkingIndex - 1;
889
+ this.#updateList();
890
+ return;
891
+ }
892
+ if (matchesKey(keyData, "down")) {
893
+ this.#selectedThinkingIndex = (this.#selectedThinkingIndex + 1) % choice.levels.length;
894
+ this.#updateList();
895
+ return;
896
+ }
897
+ if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
898
+ const level = choice.levels[this.#selectedThinkingIndex];
899
+ if (!level) return;
900
+ this.#pendingThinkingChoice = undefined;
901
+ this.#handleSelect(choice.item, choice.role, level);
902
+ return;
903
+ }
904
+ if (getKeybindings().matches(keyData, "tui.select.cancel")) {
905
+ this.#pendingThinkingChoice = undefined;
906
+ if (choice.role !== null) {
907
+ this.#pendingActionItem = choice.item;
908
+ this.#selectedActionIndex = Math.max(0, GJC_MODEL_ASSIGNMENT_TARGET_IDS.indexOf(choice.role));
909
+ }
910
+ this.#updateList();
911
+ }
912
+ }
913
+
914
+ #handleSelect(
915
+ item: ModelItem | CanonicalModelItem,
916
+ role: GjcModelAssignmentTargetId | null,
917
+ thinkingLevel?: ThinkingLevel,
918
+ ): void {
919
+ const itemThinkingLevel = thinkingLevel ?? item.thinkingLevel;
920
+ const hasExplicitThinkingChoice = thinkingLevel !== undefined || item.explicitThinkingLevel === true;
921
+ if (!hasExplicitThinkingChoice && requiresExplicitThinkingChoice(item.model)) {
922
+ this.#pendingThinkingChoice = { item, role, levels: getSelectableThinkingLevels(item.model) };
923
+ this.#selectedThinkingIndex = 0;
924
+ this.#updateList();
925
+ return;
926
+ }
927
+
759
928
  // For temporary role, don't save to settings - just notify caller
760
929
  if (role === null) {
761
- this.#onSelectCallback(item.model, null, undefined, item.selector);
930
+ this.#onSelectCallback(item.model, null, itemThinkingLevel, item.selector);
762
931
  return;
763
932
  }
764
933
 
765
- const selectedThinkingLevel = thinkingLevel ?? this.#getCurrentRoleThinkingLevel(role);
934
+ const selectedThinkingLevel = itemThinkingLevel ?? this.#getCurrentRoleThinkingLevel(role);
935
+ const selectorValue =
936
+ role === "default" ? item.selector : formatModelSelectorValue(item.selector, selectedThinkingLevel);
766
937
 
767
938
  // Update local state for UI
768
939
  this.#roles[role] = { model: item.model, thinkingLevel: selectedThinkingLevel };
769
940
 
770
941
  // Notify caller (for updating agent state if needed)
771
- this.#onSelectCallback(item.model, role, selectedThinkingLevel, item.selector);
942
+ this.#onSelectCallback(item.model, role, selectedThinkingLevel, selectorValue);
772
943
 
773
944
  // Update list to show new badges
774
945
  this.#updateList();
@@ -779,6 +950,27 @@ export class ModelSelectorComponent extends Container {
779
950
  }
780
951
  }
781
952
 
953
+ function requiresExplicitThinkingChoice(model: Model): boolean {
954
+ return model.reasoning === true && (model.provider === "openai" || model.provider === "openai-codex");
955
+ }
956
+
957
+ function getSelectableThinkingLevels(model: Model): ThinkingLevel[] {
958
+ const levels: ThinkingLevel[] = [ThinkingLevel.Off];
959
+ let efforts: readonly string[];
960
+ try {
961
+ efforts = getSupportedEfforts(model);
962
+ } catch {
963
+ return levels;
964
+ }
965
+ for (const effort of efforts) {
966
+ const level = parseThinkingLevel(effort);
967
+ if (level && !levels.includes(level)) {
968
+ levels.push(level);
969
+ }
970
+ }
971
+ return levels;
972
+ }
973
+
782
974
  /** Extract the first version number from a model ID (e.g. "gemini-2.5-pro" → 2.5, "Anthropic model-sonnet-4-6" → 4.6). */
783
975
  function extractVersionNumber(id: string): number {
784
976
  // Dot-separated version: "gemini-2.5-pro" → 2.5
@@ -435,14 +435,14 @@ export class SelectorController {
435
435
  try {
436
436
  if (role === null) {
437
437
  // Temporary: update agent state but don't persist to settings
438
- await this.ctx.session.setModelTemporary(model);
438
+ await this.ctx.session.setModelTemporary(model, thinkingLevel);
439
439
  this.ctx.statusLine.invalidate();
440
440
  this.ctx.updateEditorBorderColor();
441
441
  this.ctx.showStatus(`Temporary model: ${selector ?? model.id}`);
442
442
  done();
443
443
  this.ctx.ui.requestRender();
444
- } else {
445
- // Default: update agent state and persist
444
+ } else if (role === "default") {
445
+ // Default: update agent state and persist as the active default model.
446
446
  await this.ctx.session.setModel(model, role, {
447
447
  selector,
448
448
  thinkingLevel,
@@ -455,6 +455,19 @@ export class SelectorController {
455
455
  this.ctx.showStatus(`Default model: ${selector ?? model.id}`);
456
456
  done();
457
457
  this.ctx.ui.requestRender();
458
+ } else {
459
+ // Role-agent assignments configure Task dispatch and must not switch the active chat model.
460
+ const apiKey = await this.ctx.session.modelRegistry.getApiKey(model, this.ctx.session.sessionId);
461
+ if (!apiKey) {
462
+ throw new Error(`No API key for ${model.provider}/${model.id}`);
463
+ }
464
+ const overrides = this.ctx.settings.get("task.agentModelOverrides");
465
+ const value = selector ?? `${model.provider}/${model.id}`;
466
+ this.ctx.settings.set("task.agentModelOverrides", { ...overrides, [role]: value });
467
+ this.ctx.settings.getStorage()?.recordModelUsage(`${model.provider}/${model.id}`);
468
+ this.ctx.showStatus(`${role} agent model: ${value}`);
469
+ done();
470
+ this.ctx.ui.requestRender();
458
471
  }
459
472
  } catch (error) {
460
473
  this.ctx.showError(error instanceof Error ? error.message : String(error));