@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.
- package/CHANGELOG.md +16 -1
- package/dist/types/config/model-registry.d.ts +8 -0
- package/dist/types/config/model-resolver.d.ts +4 -1
- package/dist/types/gjc-runtime/team-runtime.d.ts +5 -0
- package/dist/types/gjc-runtime/ultragoal-guard.d.ts +26 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +44 -0
- package/dist/types/goals/tools/goal-tool.d.ts +4 -4
- package/dist/types/hooks/skill-state.d.ts +3 -0
- package/dist/types/modes/components/model-selector.d.ts +5 -7
- package/dist/types/modes/interactive-mode.d.ts +1 -0
- package/dist/types/sdk.d.ts +2 -4
- package/dist/types/session/agent-session.d.ts +3 -9
- package/dist/types/skill-state/deep-interview-mutation-guard.d.ts +28 -0
- package/package.json +13 -9
- package/src/config/model-registry.ts +45 -0
- package/src/config/model-resolver.ts +5 -1
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +30 -30
- package/src/defaults/gjc/skills/team/SKILL.md +1 -0
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +51 -21
- package/src/gjc-runtime/team-runtime.ts +80 -1
- package/src/gjc-runtime/ultragoal-guard.ts +239 -0
- package/src/gjc-runtime/ultragoal-runtime.ts +318 -4
- package/src/goals/tools/goal-tool.ts +10 -4
- package/src/hooks/native-skill-hook.ts +26 -0
- package/src/hooks/skill-state.ts +59 -0
- package/src/main.ts +2 -17
- package/src/modes/components/model-selector.ts +225 -33
- package/src/modes/controllers/selector-controller.ts +16 -3
- package/src/modes/interactive-mode.ts +34 -22
- package/src/modes/prompt-action-autocomplete.ts +40 -15
- package/src/sdk.ts +3 -1
- package/src/session/agent-session.ts +40 -4
- package/src/setup/model-onboarding-guidance.ts +5 -3
- package/src/skill-state/deep-interview-mutation-guard.ts +303 -0
- package/src/slash-commands/builtin-registry.ts +130 -11
- package/src/tools/ask.ts +55 -17
- package/src/tools/ast-edit.ts +7 -0
- package/src/tools/bash.ts +2 -1
- package/src/tools/gh.ts +37 -9
- package/src/tools/image-gen.ts +19 -10
- 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
|
}
|
package/src/hooks/skill-state.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
17
|
-
import {
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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: (
|
|
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.#
|
|
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
|
-
|
|
223
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
392
|
-
kind: "canonical"
|
|
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
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
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
|
|
739
|
-
//
|
|
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.#
|
|
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
|
-
#
|
|
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,
|
|
930
|
+
this.#onSelectCallback(item.model, null, itemThinkingLevel, item.selector);
|
|
762
931
|
return;
|
|
763
932
|
}
|
|
764
933
|
|
|
765
|
-
const selectedThinkingLevel =
|
|
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,
|
|
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));
|