gentle-pi 4.4.0 → 4.5.0
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/README.md +2 -2
- package/bin/gentle-shell.mjs +37 -4
- package/docs/gentle-music.md +24 -3
- package/docs/gentle-shell.md +5 -5
- package/docs/readme-reference.md +58 -45
- package/extensions/gentle-agents.ts +14 -2
- package/extensions/gentle-ai.ts +1672 -1237
- package/extensions/gentle-music.ts +73 -8
- package/extensions/gentle-shell.ts +88 -8
- package/lib/command-palette-catalog.ts +1 -2
- package/lib/gentle-shell-bundled.ts +18 -8
- package/lib/gentle-shell-launcher.ts +2 -0
- package/lib/jobs-sidebar-state.ts +22 -3
- package/lib/jobs-widget.ts +112 -0
- package/lib/session-profile-freeze.ts +2 -2
- package/lib/shell-bar.ts +2 -4
- package/lib/shell-sidebar-layout.ts +1 -1
- package/lib/vim-editor-adapter.ts +3 -2
- package/package.json +1 -1
- package/runtime/gentle-shell-bundled.mjs +18 -8
- package/runtime/gentle-shell-launcher.mjs +2 -0
- package/scripts/bundled-install.mjs +209 -13
- package/scripts/installer-downloads.mjs +24 -0
- package/scripts/installer-preflight.mjs +1 -1
- package/scripts/pi-update.mjs +3 -1
- package/tests/bundled-install.test.ts +273 -2
- package/tests/command-palette.test.ts +3 -3
- package/tests/gentle-agents.test.ts +4 -4
- package/tests/gentle-ai.test.ts +810 -609
- package/tests/gentle-music.test.ts +117 -0
- package/tests/gentle-shell-bundled.test.ts +20 -0
- package/tests/gentle-shell-launcher.test.ts +1 -0
- package/tests/gentle-shell.test.ts +44 -19
- package/tests/installer-downloads.test.ts +32 -0
- package/tests/installer-server.test.ts +2 -2
- package/tests/jobs-widget.test.ts +138 -0
- package/tests/model-profiles-preservation.test.ts +2 -2
- package/tests/package-manifest.test.ts +2 -2
- package/tests/pi-update.test.ts +5 -0
- package/tests/runtime-harness.mjs +188 -211
- package/tests/session-profile-freeze.test.ts +6 -4
- package/tests/shell-bar.test.ts +4 -4
- package/tests/shell-sidebar-layout.test.ts +17 -0
- package/tests/vim-editor-adapter.test.ts +2 -2
package/extensions/gentle-ai.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { getSupportedThinkingLevels } from "@earendil-works/pi-ai";
|
|
1
2
|
import { appendSystemPromptOnce } from "../lib/append-system-prompt.ts";
|
|
2
3
|
import { dataArgumentRanges, recognizeDestructiveCommands } from "../lib/destructive-command-guard.ts";
|
|
3
4
|
import { defaultTempScratchFs, isTempScratchRemoval, type TempScratchFs } from "../lib/yolo-temp-scratch.ts";
|
|
@@ -48,7 +49,7 @@ import type {
|
|
|
48
49
|
ToolResultEvent,
|
|
49
50
|
ToolResultEventResult,
|
|
50
51
|
} from "@earendil-works/pi-coding-agent";
|
|
51
|
-
import {
|
|
52
|
+
import { Text, isKeyRelease, matchesKey, truncateToWidth, visibleWidth, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
|
|
52
53
|
import { resolveGentlePiAgentHome, gentlePiConfigHome } from "../lib/agent-home.ts";
|
|
53
54
|
import {
|
|
54
55
|
BACKGROUND_SUBAGENTS_FILE,
|
|
@@ -121,8 +122,6 @@ import {
|
|
|
121
122
|
type OrchestratorSettingsReadResult,
|
|
122
123
|
parseOrchestratorModelRef,
|
|
123
124
|
} from "../lib/profiles-orchestrator.ts";
|
|
124
|
-
import { measureAgentsViewLayout, type AgentsViewLayout } from "../lib/agents-view-layout.ts";
|
|
125
|
-
import { NativeChoiceList } from "../lib/native-choice-list.ts";
|
|
126
125
|
import { createNativeFullscreenInteraction } from "../lib/native-fullscreen-interaction.ts";
|
|
127
126
|
import {
|
|
128
127
|
REVIEW_HOST_RELAY_FAILURE,
|
|
@@ -1816,26 +1815,12 @@ const KEEP_CURRENT = "Keep current";
|
|
|
1816
1815
|
const INHERIT_MODEL = "Inherit active/default model";
|
|
1817
1816
|
const CUSTOM_MODEL = "Custom model id";
|
|
1818
1817
|
const INHERIT_THINKING = "Inherit effort";
|
|
1819
|
-
const THINKING_OPTIONS: (ThinkingLevel | typeof INHERIT_THINKING)[] = [
|
|
1820
|
-
INHERIT_THINKING,
|
|
1821
|
-
...THINKING_LEVELS,
|
|
1822
|
-
];
|
|
1823
1818
|
|
|
1824
1819
|
const MODEL_CONTROL_OPTIONS = [
|
|
1825
1820
|
KEEP_CURRENT,
|
|
1826
1821
|
INHERIT_MODEL,
|
|
1827
1822
|
CUSTOM_MODEL,
|
|
1828
1823
|
] as const;
|
|
1829
|
-
const MODEL_PANEL_MAX_RENDER_ROWS = 20;
|
|
1830
|
-
// Rows the agent list does not own: two borders, title, current-profile line,
|
|
1831
|
-
// blank, "Current assignments:", blank, both scroll indicators, blank, Continue,
|
|
1832
|
-
// Back, blank, and the two footer rows.
|
|
1833
|
-
const AGENT_LIST_CHROME_ROWS = 15;
|
|
1834
|
-
const AGENT_LIST_MAX_VISIBLE_ROWS = MODEL_PANEL_MAX_RENDER_ROWS - AGENT_LIST_CHROME_ROWS;
|
|
1835
|
-
// Rows the model list does not own: two borders, title, blank, search, blank,
|
|
1836
|
-
// blank, and the footer row.
|
|
1837
|
-
const MODEL_LIST_CHROME_ROWS = 8;
|
|
1838
|
-
const MODEL_LIST_MAX_VISIBLE_ROWS = 12;
|
|
1839
1824
|
|
|
1840
1825
|
function readStringPath(value: unknown, path: string[]): string | undefined {
|
|
1841
1826
|
let current = value;
|
|
@@ -2544,7 +2529,7 @@ async function readGlobalEffectiveModelConfigFromAsync(
|
|
|
2544
2529
|
* discoverable agent it omits on inherit, not on whatever was materialized
|
|
2545
2530
|
* before. Padding the omitted agents with clear entries makes
|
|
2546
2531
|
* `applyModelConfig` remove their model profiles and frontmatter routing, the
|
|
2547
|
-
* same way
|
|
2532
|
+
* same way applying a profile with inherit entries clears an agent set.
|
|
2548
2533
|
*/
|
|
2549
2534
|
async function withOmittedAgentsClearedAsync(
|
|
2550
2535
|
cwd: string,
|
|
@@ -3114,32 +3099,80 @@ async function getPiModelOptions(ctx: ExtensionContext): Promise<string[]> {
|
|
|
3114
3099
|
return [...MODEL_CONTROL_OPTIONS, ...modelIds];
|
|
3115
3100
|
}
|
|
3116
3101
|
|
|
3102
|
+
/**
|
|
3103
|
+
* The effort levels each catalog model supports, keyed by its provider/model id.
|
|
3104
|
+
* Pi clamps an unsupported level at launch, so the models screen offers only
|
|
3105
|
+
* these and shows where a saved level would land. Models outside the catalog
|
|
3106
|
+
* are absent: their levels are unknown.
|
|
3107
|
+
*/
|
|
3108
|
+
async function getPiThinkingSupport(ctx: ExtensionContext): Promise<Record<string, ThinkingLevel[]>> {
|
|
3109
|
+
const support: Record<string, ThinkingLevel[]> = {};
|
|
3110
|
+
let raw: unknown;
|
|
3111
|
+
try {
|
|
3112
|
+
raw = await ctx.modelRegistry?.getAvailable();
|
|
3113
|
+
} catch {
|
|
3114
|
+
return support;
|
|
3115
|
+
}
|
|
3116
|
+
if (!Array.isArray(raw)) return support;
|
|
3117
|
+
for (const model of raw as Parameters<typeof getSupportedThinkingLevels>[0][]) {
|
|
3118
|
+
const id = normalizeModelId(`${model?.provider}/${model?.id}`);
|
|
3119
|
+
// Without the reasoning flag the catalog says nothing about effort: the
|
|
3120
|
+
// model stays unknown (every level offered) rather than "off only".
|
|
3121
|
+
if (!id || typeof model?.reasoning !== "boolean") continue;
|
|
3122
|
+
try {
|
|
3123
|
+
support[id] = getSupportedThinkingLevels(model).filter((level): level is ThinkingLevel =>
|
|
3124
|
+
(THINKING_LEVELS as readonly string[]).includes(level),
|
|
3125
|
+
);
|
|
3126
|
+
} catch {
|
|
3127
|
+
// A model whose metadata cannot be read stays unknown.
|
|
3128
|
+
}
|
|
3129
|
+
}
|
|
3130
|
+
return support;
|
|
3131
|
+
}
|
|
3132
|
+
|
|
3133
|
+
/**
|
|
3134
|
+
* Where Pi lands a requested level on a model, mirroring pi-ai's
|
|
3135
|
+
* clampThinkingLevel: the level itself when supported, else the nearest
|
|
3136
|
+
* supported level above it, else below it.
|
|
3137
|
+
*/
|
|
3138
|
+
function clampToSupportedLevel(level: ThinkingLevel, supported: readonly ThinkingLevel[]): ThinkingLevel {
|
|
3139
|
+
if (supported.includes(level)) return level;
|
|
3140
|
+
const start = THINKING_LEVELS.indexOf(level);
|
|
3141
|
+
for (let index = start + 1; index < THINKING_LEVELS.length; index += 1) {
|
|
3142
|
+
if (supported.includes(THINKING_LEVELS[index])) return THINKING_LEVELS[index];
|
|
3143
|
+
}
|
|
3144
|
+
for (let index = start - 1; index >= 0; index -= 1) {
|
|
3145
|
+
if (supported.includes(THINKING_LEVELS[index])) return THINKING_LEVELS[index];
|
|
3146
|
+
}
|
|
3147
|
+
return supported[0] ?? "off";
|
|
3148
|
+
}
|
|
3149
|
+
|
|
3117
3150
|
interface OverlayComponent {
|
|
3118
3151
|
render(width: number): string[];
|
|
3119
3152
|
handleInput(data: string): void;
|
|
3120
3153
|
invalidate(): void;
|
|
3121
3154
|
}
|
|
3122
3155
|
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
|
|
3129
|
-
| { type: "export"; config: AgentModelConfig }
|
|
3130
|
-
| { type: "restore"; config: AgentModelConfig }
|
|
3131
|
-
| { type: "cancel" };
|
|
3156
|
+
interface RoutingHitRegion {
|
|
3157
|
+
x0: number;
|
|
3158
|
+
x1: number;
|
|
3159
|
+
y: number;
|
|
3160
|
+
action: RoutingTableMouseAction;
|
|
3161
|
+
}
|
|
3132
3162
|
|
|
3133
|
-
const SET_ALL_AGENTS = "Set all agents";
|
|
3134
3163
|
const LEAVE_ORCHESTRATOR_UNTOUCHED = "leave untouched";
|
|
3135
3164
|
|
|
3136
3165
|
const PANEL_TONE = {
|
|
3137
3166
|
BORDER: "border",
|
|
3138
3167
|
MUTED: "muted",
|
|
3168
|
+
DIM: "dim",
|
|
3139
3169
|
TEXT: "text",
|
|
3140
3170
|
TITLE: "title",
|
|
3141
3171
|
ACCENT: "accent",
|
|
3142
3172
|
STATUS: "status",
|
|
3173
|
+
EDITED: "edited",
|
|
3174
|
+
GOOD: "good",
|
|
3175
|
+
DANGER: "danger",
|
|
3143
3176
|
} as const;
|
|
3144
3177
|
|
|
3145
3178
|
type PanelTone = (typeof PANEL_TONE)[keyof typeof PANEL_TONE];
|
|
@@ -3147,548 +3180,1400 @@ type PanelTone = (typeof PANEL_TONE)[keyof typeof PANEL_TONE];
|
|
|
3147
3180
|
const PANEL_TONE_COLOR: Record<PanelTone, ThemeColor> = {
|
|
3148
3181
|
border: "border",
|
|
3149
3182
|
muted: "muted",
|
|
3183
|
+
dim: "dim",
|
|
3150
3184
|
text: "text",
|
|
3151
3185
|
title: "accent",
|
|
3152
3186
|
accent: "accent",
|
|
3153
3187
|
status: "thinkingHigh",
|
|
3188
|
+
edited: "warning",
|
|
3189
|
+
good: "success",
|
|
3190
|
+
danger: "error",
|
|
3154
3191
|
};
|
|
3155
3192
|
|
|
3156
|
-
|
|
3193
|
+
/** The pseudo row behind the Model ▾ / Effort ▾ header: it sets every agent at once. */
|
|
3194
|
+
const ROUTING_ALL_ROW = "__all";
|
|
3195
|
+
const ROUTING_CUSTOM_MODEL = "Custom model id…";
|
|
3196
|
+
/** The table's short form of a provider review role's inherited model and effort. */
|
|
3197
|
+
const ROUTING_PI_DEFAULT = "Pi default";
|
|
3198
|
+
/** Body lines around the agent rows: tabs, rail, description, blank, orchestrator, rule, header, blank, footer, hints, two feedback lines. */
|
|
3199
|
+
const ROUTING_TABLE_FIXED_ROWS = 12;
|
|
3200
|
+
/** Compact button labels for terminals too narrow for the full ones. */
|
|
3201
|
+
const ROUTING_TABLE_SHORT_BUTTONS: Partial<Record<RoutingTableButton, string>> = {
|
|
3202
|
+
use: "Use here",
|
|
3203
|
+
default: "Default",
|
|
3204
|
+
};
|
|
3205
|
+
/**
|
|
3206
|
+
* The key hints in display order, each with a keep priority (lower stays
|
|
3207
|
+
* longer): narrow terminals drop whole hints instead of cutting one in half.
|
|
3208
|
+
*/
|
|
3209
|
+
const ROUTING_TABLE_HINTS: Array<{ text: string; keep: number }> = [
|
|
3210
|
+
{ text: "←→ profile", keep: 5 },
|
|
3211
|
+
{ text: "↑↓ agent", keep: 6 },
|
|
3212
|
+
{ text: "enter model", keep: 1 },
|
|
3213
|
+
{ text: "e effort", keep: 2 },
|
|
3214
|
+
{ text: "s save", keep: 3 },
|
|
3215
|
+
{ text: "u use", keep: 7 },
|
|
3216
|
+
{ text: "d default", keep: 8 },
|
|
3217
|
+
{ text: "n new", keep: 10 },
|
|
3218
|
+
{ text: "m menu", keep: 9 },
|
|
3219
|
+
{ text: "esc close", keep: 4 },
|
|
3220
|
+
];
|
|
3221
|
+
/** Model card chrome (title, search, rule, blank, hint), its borders, and a margin so the card never touches the frame. */
|
|
3222
|
+
const ROUTING_MODEL_CARD_CHROME_ROWS = 11;
|
|
3223
|
+
|
|
3224
|
+
type RoutingTableAction =
|
|
3225
|
+
| { type: "load"; name: string }
|
|
3226
|
+
| { type: "custom"; target: string; config: AgentModelConfig }
|
|
3227
|
+
| { type: "use-session"; name: string; config?: AgentModelConfig }
|
|
3228
|
+
| { type: "apply-global"; name: string; config: AgentModelConfig }
|
|
3229
|
+
| { type: "save"; name: string; config: AgentModelConfig }
|
|
3230
|
+
| { type: "create" }
|
|
3231
|
+
| { type: "create-live" }
|
|
3232
|
+
| { type: "duplicate"; name: string }
|
|
3233
|
+
| { type: "rename"; name: string }
|
|
3234
|
+
| { type: "delete"; name: string }
|
|
3235
|
+
| { type: "export-profile"; name: string }
|
|
3236
|
+
| { type: "import-profile" }
|
|
3237
|
+
| { type: "pin"; source: ProfilePinSource; name: string }
|
|
3238
|
+
| { type: "close" };
|
|
3239
|
+
|
|
3240
|
+
/**
|
|
3241
|
+
* `saveFirst` asks the loop to write the draft into the shown profile before
|
|
3242
|
+
* running the action (the "Save and switch" answer). `draft` carries unsaved
|
|
3243
|
+
* edits across actions that keep the same profile on screen (rename, pin, a
|
|
3244
|
+
* declined delete), so managing a profile never throws away what you typed.
|
|
3245
|
+
*/
|
|
3246
|
+
type RoutingTableResult = RoutingTableAction & { saveFirst?: AgentModelConfig; draft?: AgentModelConfig };
|
|
3247
|
+
|
|
3248
|
+
type RoutingTableButton = "use" | "default" | "save" | "switch-save" | "switch-discard" | "switch-stay";
|
|
3249
|
+
|
|
3250
|
+
/**
|
|
3251
|
+
* What the screen needs to show another profile without closing: the store
|
|
3252
|
+
* after an optional save, fresh scope state, and the footer line. `error`
|
|
3253
|
+
* means the save failed and the shown profile stays, edits intact.
|
|
3254
|
+
*/
|
|
3255
|
+
type RoutingProfileSwitch =
|
|
3256
|
+
| { file: AgentProfilesFile; sessionBoundName?: string; pinWinner?: string; notices: string[]; feedback: string }
|
|
3257
|
+
| { error: string };
|
|
3258
|
+
|
|
3259
|
+
/**
|
|
3260
|
+
* Switches the shown profile in place. Closing the overlay and reopening it on
|
|
3261
|
+
* the next tab let the terminal paint the screen beneath for a frame (and wait
|
|
3262
|
+
* on the async model catalog before the new frame), which is the tab flicker.
|
|
3263
|
+
*/
|
|
3264
|
+
type RoutingProfileSwitcher = (name: string, saveFirst?: AgentModelConfig) => RoutingProfileSwitch;
|
|
3265
|
+
|
|
3266
|
+
type RoutingTableMouseAction =
|
|
3267
|
+
| { kind: "tab"; name: string }
|
|
3268
|
+
| { kind: "tab-menu" }
|
|
3269
|
+
| { kind: "new" }
|
|
3270
|
+
| { kind: "row"; row: string }
|
|
3271
|
+
| { kind: "effort"; row: string }
|
|
3272
|
+
| { kind: "model-option"; index: number }
|
|
3273
|
+
| { kind: "effort-option"; index: number }
|
|
3274
|
+
| { kind: "menu-item"; id: string }
|
|
3275
|
+
| { kind: "button"; button: RoutingTableButton };
|
|
3276
|
+
|
|
3277
|
+
interface RoutingMenuItem {
|
|
3278
|
+
id: string;
|
|
3279
|
+
label: string;
|
|
3280
|
+
tone?: PanelTone;
|
|
3281
|
+
}
|
|
3282
|
+
|
|
3283
|
+
const ROUTING_TABLE_BUTTONS: Record<RoutingTableButton, string> = {
|
|
3284
|
+
use: "Use in this session",
|
|
3285
|
+
default: "Make default",
|
|
3286
|
+
save: "Save",
|
|
3287
|
+
"switch-save": "Save and switch",
|
|
3288
|
+
"switch-discard": "Discard",
|
|
3289
|
+
"switch-stay": "Stay",
|
|
3290
|
+
};
|
|
3291
|
+
|
|
3292
|
+
/**
|
|
3293
|
+
* `/gentle:profiles` — one screen, one table, profiles as
|
|
3294
|
+
* tabs. Each tab is a profile and carries where it is in effect (◉ this
|
|
3295
|
+
* session, ★ default for new sessions, ⌂ this repo); the table below is that
|
|
3296
|
+
* profile's routing, orchestrator first; three buttons decide where it applies.
|
|
3297
|
+
* Model and effort changes happen in small cards over the still-visible table.
|
|
3298
|
+
* Keyboard and pointer reach the same actions, and each key means one thing.
|
|
3299
|
+
*/
|
|
3300
|
+
class RoutingTablePanel implements OverlayComponent {
|
|
3301
|
+
private overlay: "model" | "effort" | "menu" | "new" | "switch" | undefined;
|
|
3302
|
+
private target: string = PROFILE_ORCHESTRATOR_KEY;
|
|
3303
|
+
private pending: RoutingTableResult | undefined;
|
|
3157
3304
|
private cursor = 0;
|
|
3158
|
-
private
|
|
3159
|
-
private selectedRow = SET_ALL_AGENTS;
|
|
3305
|
+
private query = "";
|
|
3160
3306
|
private modelCursor = 0;
|
|
3161
3307
|
private effortCursor = 0;
|
|
3162
|
-
private
|
|
3163
|
-
private
|
|
3308
|
+
private menuCursor = 0;
|
|
3309
|
+
private hovered: string | undefined;
|
|
3310
|
+
private escArmed = false;
|
|
3311
|
+
private feedback: string | undefined;
|
|
3312
|
+
private completed = false;
|
|
3313
|
+
private hitRegions: RoutingHitRegion[] = [];
|
|
3314
|
+
private activeTabColumn = 0;
|
|
3315
|
+
private newColumn = 0;
|
|
3316
|
+
private draft: AgentModelConfig;
|
|
3317
|
+
private stored: AgentModelConfig;
|
|
3164
3318
|
private readonly rows: string[];
|
|
3165
3319
|
private readonly agents: string[];
|
|
3166
|
-
private readonly
|
|
3167
|
-
private readonly done: (result:
|
|
3320
|
+
private readonly catalog: string[];
|
|
3321
|
+
private readonly done: (result: RoutingTableResult) => void;
|
|
3168
3322
|
private readonly theme: Theme | undefined;
|
|
3169
|
-
// The profile `u` writes to, named up front so the key never targets a surprise.
|
|
3170
|
-
private readonly profileLabel: string;
|
|
3171
|
-
// Terminal rows, so the card fills the fullscreen overlay like `/gentle:profiles`.
|
|
3172
3323
|
private readonly terminalRows: (() => number) | undefined;
|
|
3324
|
+
private profileName: string;
|
|
3325
|
+
private file: AgentProfilesFile;
|
|
3326
|
+
private sessionBoundName: string | undefined;
|
|
3327
|
+
private pinWinner: string | undefined;
|
|
3328
|
+
private readonly thinkingSupport: Record<string, readonly ThinkingLevel[]>;
|
|
3329
|
+
/** Configuration problems the screen must not hide: pins that do not resolve, keys no agent matches. */
|
|
3330
|
+
private notices: string[];
|
|
3331
|
+
private readonly switchProfile: RoutingProfileSwitcher | undefined;
|
|
3173
3332
|
|
|
3174
3333
|
constructor(
|
|
3175
|
-
|
|
3334
|
+
draft: AgentModelConfig,
|
|
3176
3335
|
modelOptions: string[],
|
|
3177
3336
|
agents: string[],
|
|
3178
|
-
done: (result:
|
|
3337
|
+
done: (result: RoutingTableResult) => void,
|
|
3179
3338
|
theme?: Theme,
|
|
3180
|
-
|
|
3339
|
+
profileName = "current",
|
|
3181
3340
|
terminalRows?: () => number,
|
|
3341
|
+
options: {
|
|
3342
|
+
file?: AgentProfilesFile;
|
|
3343
|
+
sessionBoundName?: string;
|
|
3344
|
+
pinWinner?: string;
|
|
3345
|
+
feedback?: string;
|
|
3346
|
+
thinkingSupport?: Record<string, readonly ThinkingLevel[]>;
|
|
3347
|
+
notices?: string[];
|
|
3348
|
+
switchProfile?: RoutingProfileSwitcher;
|
|
3349
|
+
} = {},
|
|
3182
3350
|
) {
|
|
3183
|
-
this.
|
|
3184
|
-
this.
|
|
3351
|
+
this.switchProfile = options.switchProfile;
|
|
3352
|
+
this.thinkingSupport = options.thinkingSupport ?? {};
|
|
3353
|
+
this.notices = options.notices ?? [];
|
|
3354
|
+
this.file = options.file ?? bootstrapProfilesFile(cloneModelConfig(draft));
|
|
3355
|
+
const names = Object.keys(this.file.profiles);
|
|
3356
|
+
this.profileName = hasOwnProfile(this.file.profiles, profileName) ? profileName : (names[0] ?? profileName);
|
|
3357
|
+
this.stored = cloneModelConfig(normalizeModelConfig(this.file.profiles[this.profileName]) ?? {});
|
|
3358
|
+
this.draft = cloneModelConfig(draft);
|
|
3185
3359
|
this.agents = [...agents];
|
|
3186
|
-
this.
|
|
3360
|
+
this.rows = [PROFILE_ORCHESTRATOR_KEY, ROUTING_ALL_ROW, ...agents];
|
|
3361
|
+
// The control entries of the old panel (keep current, custom id) are not
|
|
3362
|
+
// models; this screen offers inherit and custom as its own options.
|
|
3363
|
+
this.catalog = modelOptions.filter((option) => !(MODEL_CONTROL_OPTIONS as readonly string[]).includes(option));
|
|
3187
3364
|
this.done = done;
|
|
3188
3365
|
this.theme = theme;
|
|
3189
|
-
this.profileLabel = profileLabel;
|
|
3190
3366
|
this.terminalRows = terminalRows;
|
|
3367
|
+
this.sessionBoundName = options.sessionBoundName;
|
|
3368
|
+
this.pinWinner = options.pinWinner;
|
|
3369
|
+
this.feedback = options.feedback;
|
|
3191
3370
|
}
|
|
3192
3371
|
|
|
3193
|
-
invalidate(): void {
|
|
3372
|
+
invalidate(): void {
|
|
3373
|
+
this.hovered = undefined;
|
|
3374
|
+
}
|
|
3194
3375
|
|
|
3195
|
-
|
|
3196
|
-
|
|
3197
|
-
|
|
3198
|
-
|
|
3199
|
-
}
|
|
3200
|
-
if (this.mode === "effort") {
|
|
3201
|
-
this.handleEffortInput(data);
|
|
3202
|
-
return;
|
|
3203
|
-
}
|
|
3204
|
-
this.handleAgentInput(data);
|
|
3376
|
+
// -- state ----------------------------------------------------------------
|
|
3377
|
+
|
|
3378
|
+
private profileNames(): string[] {
|
|
3379
|
+
return Object.keys(this.file.profiles).filter((name) => hasOwnProfile(this.file.profiles, name));
|
|
3205
3380
|
}
|
|
3206
3381
|
|
|
3207
|
-
|
|
3208
|
-
|
|
3209
|
-
const
|
|
3210
|
-
this.
|
|
3211
|
-
|
|
3212
|
-
: this.mode === "effort"
|
|
3213
|
-
? this.renderEffortPicker(innerWidth)
|
|
3214
|
-
: this.renderAgentList(innerWidth);
|
|
3215
|
-
return this.renderCard(lines, width);
|
|
3216
|
-
}
|
|
3217
|
-
|
|
3218
|
-
// In the fullscreen overlay a list owns every row its chrome leaves free;
|
|
3219
|
-
// without a terminal height it keeps the fixed window.
|
|
3220
|
-
private visibleListRows(fixedRows: number, chromeRows: number): number {
|
|
3221
|
-
if (!this.terminalRows) return fixedRows;
|
|
3222
|
-
return Math.max(1, Math.floor(this.terminalRows()) - chromeRows);
|
|
3223
|
-
}
|
|
3224
|
-
|
|
3225
|
-
private renderCard(body: string[], width: number): string[] {
|
|
3226
|
-
let lines = body;
|
|
3227
|
-
const innerWidth = Math.max(1, width - 4);
|
|
3228
|
-
const horizontal = "─".repeat(innerWidth + 2);
|
|
3229
|
-
const border = (text: string) => this.renderText(text, "border");
|
|
3230
|
-
const bodyRows = this.terminalRows ? Math.floor(this.terminalRows()) - 2 : 0;
|
|
3231
|
-
if (lines.length < bodyRows) {
|
|
3232
|
-
lines = [...lines, ...Array<string>(bodyRows - lines.length).fill("")];
|
|
3382
|
+
private changeCount(): number {
|
|
3383
|
+
let count = 0;
|
|
3384
|
+
for (const key of [PROFILE_ORCHESTRATOR_KEY, ...this.agents]) {
|
|
3385
|
+
if (this.isEdited(key, "model")) count += 1;
|
|
3386
|
+
if (this.isEdited(key, "thinking")) count += 1;
|
|
3233
3387
|
}
|
|
3234
|
-
return
|
|
3235
|
-
border(`╭${horizontal}╮`),
|
|
3236
|
-
...lines.map(
|
|
3237
|
-
(line) =>
|
|
3238
|
-
`${border("│")} ${this.fitStyledLine(line, innerWidth)} ${border("│")}`,
|
|
3239
|
-
),
|
|
3240
|
-
border(`╰${horizontal}╯`),
|
|
3241
|
-
];
|
|
3388
|
+
return count;
|
|
3242
3389
|
}
|
|
3243
3390
|
|
|
3244
|
-
private
|
|
3245
|
-
|
|
3246
|
-
if (visible.length > width) {
|
|
3247
|
-
return truncateToWidth(visible, Math.max(1, width), "…", true);
|
|
3248
|
-
}
|
|
3249
|
-
return `${line}${" ".repeat(Math.max(0, width - visible.length))}`;
|
|
3391
|
+
private isEdited(key: string, field: "model" | "thinking"): boolean {
|
|
3392
|
+
return (this.draft[key]?.[field] ?? undefined) !== (this.stored[key]?.[field] ?? undefined);
|
|
3250
3393
|
}
|
|
3251
3394
|
|
|
3252
|
-
private
|
|
3253
|
-
const
|
|
3254
|
-
|
|
3255
|
-
|
|
3256
|
-
|
|
3257
|
-
|
|
3258
|
-
);
|
|
3259
|
-
return tone ? this.renderText(safe, tone) : safe;
|
|
3395
|
+
private scopeGlyphs(name: string): Array<{ text: string; tone: PanelTone }> {
|
|
3396
|
+
const glyphs: Array<{ text: string; tone: PanelTone }> = [];
|
|
3397
|
+
if (this.sessionBoundName === name) glyphs.push({ text: "◉", tone: "good" });
|
|
3398
|
+
if (this.file.active === name) glyphs.push({ text: "★", tone: "accent" });
|
|
3399
|
+
if (this.pinWinner === name) glyphs.push({ text: "⌂", tone: "edited" });
|
|
3400
|
+
return glyphs;
|
|
3260
3401
|
}
|
|
3261
3402
|
|
|
3262
|
-
private
|
|
3263
|
-
const
|
|
3264
|
-
if (
|
|
3265
|
-
|
|
3403
|
+
private scopeSentence(name: string): string {
|
|
3404
|
+
const parts: string[] = [];
|
|
3405
|
+
if (this.sessionBoundName === name) parts.push("Used in this session.");
|
|
3406
|
+
if (this.file.active === name) parts.push("Default for new sessions.");
|
|
3407
|
+
if (this.pinWinner === name) parts.push("Used in this repo.");
|
|
3408
|
+
return parts.length > 0 ? parts.join(" ") : "Not in use. Pick a button below to use it.";
|
|
3266
3409
|
}
|
|
3267
3410
|
|
|
3268
|
-
private
|
|
3269
|
-
|
|
3411
|
+
private rowLabel(key: string): string {
|
|
3412
|
+
if (key === PROFILE_ORCHESTRATOR_KEY) return "the orchestrator";
|
|
3413
|
+
if (key === ROUTING_ALL_ROW) return "every agent";
|
|
3414
|
+
return key;
|
|
3270
3415
|
}
|
|
3271
3416
|
|
|
3272
|
-
private
|
|
3273
|
-
|
|
3274
|
-
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
3281
|
-
|
|
3282
|
-
|
|
3283
|
-
|
|
3284
|
-
|
|
3285
|
-
|
|
3286
|
-
|
|
3287
|
-
|
|
3288
|
-
|
|
3417
|
+
private modelValue(key: string): string {
|
|
3418
|
+
if (key === PROFILE_ORCHESTRATOR_KEY) return this.draft[key]?.model ?? LEAVE_ORCHESTRATOR_UNTOUCHED;
|
|
3419
|
+
// Provider review roles inherit Pi's persisted default; the table cell says
|
|
3420
|
+
// so in short, the cards spell it out.
|
|
3421
|
+
if (isProviderReviewRole(key)) return this.draft[key]?.model ?? ROUTING_PI_DEFAULT;
|
|
3422
|
+
return this.draft[key]?.model ?? routingDefaultLabel(key, "model");
|
|
3423
|
+
}
|
|
3424
|
+
|
|
3425
|
+
private effortValue(key: string): string {
|
|
3426
|
+
if (key === PROFILE_ORCHESTRATOR_KEY && !this.draft[key]?.model) return "";
|
|
3427
|
+
const thinking = this.draft[key]?.thinking;
|
|
3428
|
+
if (thinking === undefined) return isProviderReviewRole(key) ? ROUTING_PI_DEFAULT : routingDefaultLabel(key, "effort");
|
|
3429
|
+
const supported = this.supportedLevels(key);
|
|
3430
|
+
const landed = supported ? clampToSupportedLevel(thinking, supported) : thinking;
|
|
3431
|
+
return landed === thinking ? thinking : `${thinking} → ${landed}`;
|
|
3432
|
+
}
|
|
3433
|
+
|
|
3434
|
+
/**
|
|
3435
|
+
* The levels the row's model supports, from the catalog. Every agent at once
|
|
3436
|
+
* offers what all their known models share. Undefined means unknown: the row
|
|
3437
|
+
* inherits its model, or the model is not in the catalog.
|
|
3438
|
+
*/
|
|
3439
|
+
private supportedLevels(key: string): readonly ThinkingLevel[] | undefined {
|
|
3440
|
+
if (key !== ROUTING_ALL_ROW) {
|
|
3441
|
+
const model = this.draft[key]?.model;
|
|
3442
|
+
return model === undefined ? undefined : this.thinkingSupport[model];
|
|
3289
3443
|
}
|
|
3290
|
-
|
|
3291
|
-
|
|
3292
|
-
|
|
3444
|
+
const known = this.agents
|
|
3445
|
+
.map((agent) => this.draft[agent]?.model)
|
|
3446
|
+
.map((model) => (model === undefined ? undefined : this.thinkingSupport[model]))
|
|
3447
|
+
.filter((levels): levels is readonly ThinkingLevel[] => levels !== undefined);
|
|
3448
|
+
if (known.length === 0) return undefined;
|
|
3449
|
+
return THINKING_LEVELS.filter((level) => known.every((levels) => levels.includes(level)));
|
|
3450
|
+
}
|
|
3451
|
+
|
|
3452
|
+
/** The effort card's choices: inherit, then the supported levels, or every level when unknown. */
|
|
3453
|
+
private effortOptions(): Array<ThinkingLevel | typeof INHERIT_THINKING> {
|
|
3454
|
+
const supported = this.supportedLevels(this.target);
|
|
3455
|
+
return [INHERIT_THINKING, ...THINKING_LEVELS.filter((level) => !supported || supported.includes(level))];
|
|
3456
|
+
}
|
|
3457
|
+
|
|
3458
|
+
/** The value the picker marks as current: one agent's, or every agent's when they agree. */
|
|
3459
|
+
private currentValue(key: string, field: "model" | "thinking"): string | undefined {
|
|
3460
|
+
if (key === ROUTING_ALL_ROW) {
|
|
3461
|
+
const values = this.agents.map((agent) => this.draft[agent]?.[field]);
|
|
3462
|
+
return values.length > 0 && values.every((value) => value === values[0]) ? values[0] : undefined;
|
|
3293
3463
|
}
|
|
3294
|
-
|
|
3295
|
-
|
|
3296
|
-
|
|
3464
|
+
return this.draft[key]?.[field];
|
|
3465
|
+
}
|
|
3466
|
+
|
|
3467
|
+
private inheritLabel(key: string): string {
|
|
3468
|
+
if (key === PROFILE_ORCHESTRATOR_KEY) return LEAVE_ORCHESTRATOR_UNTOUCHED;
|
|
3469
|
+
if (key !== ROUTING_ALL_ROW && isProviderReviewRole(key)) return routingDefaultLabel(key, "model");
|
|
3470
|
+
return "inherit";
|
|
3471
|
+
}
|
|
3472
|
+
|
|
3473
|
+
private modelOptions(): string[] {
|
|
3474
|
+
const query = this.query.trim().toLowerCase();
|
|
3475
|
+
const all = [this.inheritLabel(this.target), ...this.catalog];
|
|
3476
|
+
const matches = query ? all.filter((option) => option.toLowerCase().includes(query)) : all;
|
|
3477
|
+
return [...matches, ROUTING_CUSTOM_MODEL];
|
|
3478
|
+
}
|
|
3479
|
+
|
|
3480
|
+
private menuItems(): RoutingMenuItem[] {
|
|
3481
|
+
if (this.overlay === "new") {
|
|
3482
|
+
return [
|
|
3483
|
+
{ id: "new-copy", label: `Copy of ${this.profileName}` },
|
|
3484
|
+
{ id: "new-live", label: "From what's running now" },
|
|
3485
|
+
{ id: "new-empty", label: "Empty", tone: "muted" },
|
|
3486
|
+
];
|
|
3297
3487
|
}
|
|
3298
|
-
|
|
3299
|
-
|
|
3488
|
+
return [
|
|
3489
|
+
{ id: "rename", label: "Rename" },
|
|
3490
|
+
{ id: "duplicate", label: "Duplicate" },
|
|
3491
|
+
{ id: "repo", label: this.pinWinner === this.profileName ? "Stop using in this repo" : "Use in this repo" },
|
|
3492
|
+
{ id: "delete", label: "Delete", tone: "danger" },
|
|
3493
|
+
];
|
|
3494
|
+
}
|
|
3495
|
+
|
|
3496
|
+
// -- actions ----------------------------------------------------------------
|
|
3497
|
+
|
|
3498
|
+
private finish(result: RoutingTableResult): void {
|
|
3499
|
+
if (this.completed) return;
|
|
3500
|
+
if (result.type === "load" && this.switchProfile) {
|
|
3501
|
+
this.showProfile(result.name, result.saveFirst);
|
|
3300
3502
|
return;
|
|
3301
3503
|
}
|
|
3302
|
-
|
|
3303
|
-
|
|
3504
|
+
this.completed = true;
|
|
3505
|
+
this.done(result);
|
|
3506
|
+
}
|
|
3507
|
+
|
|
3508
|
+
/** Replaces the shown profile on this same screen, as a fresh open on that tab would. */
|
|
3509
|
+
private showProfile(name: string, saveFirst?: AgentModelConfig): void {
|
|
3510
|
+
const next = this.switchProfile!(name, saveFirst);
|
|
3511
|
+
this.closeOverlay();
|
|
3512
|
+
if ("error" in next) {
|
|
3513
|
+
this.say(next.error);
|
|
3304
3514
|
return;
|
|
3305
3515
|
}
|
|
3306
|
-
|
|
3307
|
-
|
|
3308
|
-
|
|
3309
|
-
|
|
3310
|
-
|
|
3516
|
+
this.file = next.file;
|
|
3517
|
+
this.profileName = hasOwnProfile(this.file.profiles, name) ? name : (this.profileNames()[0] ?? name);
|
|
3518
|
+
this.stored = cloneModelConfig(normalizeModelConfig(this.file.profiles[this.profileName]) ?? {});
|
|
3519
|
+
this.draft = cloneModelConfig(this.stored);
|
|
3520
|
+
this.sessionBoundName = next.sessionBoundName;
|
|
3521
|
+
this.pinWinner = next.pinWinner;
|
|
3522
|
+
this.notices = next.notices;
|
|
3523
|
+
this.target = PROFILE_ORCHESTRATOR_KEY;
|
|
3524
|
+
this.cursor = 0;
|
|
3525
|
+
this.hovered = undefined;
|
|
3526
|
+
this.feedback = next.feedback;
|
|
3527
|
+
this.escArmed = false;
|
|
3528
|
+
}
|
|
3529
|
+
|
|
3530
|
+
private say(message: string): void {
|
|
3531
|
+
this.feedback = message;
|
|
3532
|
+
this.escArmed = false;
|
|
3533
|
+
}
|
|
3534
|
+
|
|
3535
|
+
private closeOverlay(): void {
|
|
3536
|
+
this.overlay = undefined;
|
|
3537
|
+
this.pending = undefined;
|
|
3538
|
+
this.query = "";
|
|
3539
|
+
}
|
|
3540
|
+
|
|
3541
|
+
/** Leaving this profile with unsaved edits asks first; nothing is lost in silence. */
|
|
3542
|
+
private leave(result: RoutingTableResult): void {
|
|
3543
|
+
if (this.changeCount() === 0) {
|
|
3544
|
+
this.finish(result);
|
|
3311
3545
|
return;
|
|
3312
3546
|
}
|
|
3313
|
-
|
|
3314
|
-
|
|
3547
|
+
this.pending = result;
|
|
3548
|
+
this.overlay = "switch";
|
|
3549
|
+
}
|
|
3550
|
+
|
|
3551
|
+
private switchTab(name: string): void {
|
|
3552
|
+
if (name === this.profileName) return;
|
|
3553
|
+
this.leave({ type: "load", name });
|
|
3554
|
+
}
|
|
3555
|
+
|
|
3556
|
+
private switchBy(delta: number): void {
|
|
3557
|
+
const names = this.profileNames();
|
|
3558
|
+
const index = names.indexOf(this.profileName);
|
|
3559
|
+
const next = names[Math.max(0, Math.min(names.length - 1, index + delta))];
|
|
3560
|
+
if (next) this.switchTab(next);
|
|
3561
|
+
}
|
|
3562
|
+
|
|
3563
|
+
private openModel(key: string): void {
|
|
3564
|
+
this.target = key;
|
|
3565
|
+
this.overlay = "model";
|
|
3566
|
+
this.query = "";
|
|
3567
|
+
const options = this.modelOptions();
|
|
3568
|
+
const current = this.currentValue(key, "model") ?? (key === ROUTING_ALL_ROW ? undefined : this.inheritLabel(key));
|
|
3569
|
+
this.modelCursor = Math.max(0, current === undefined ? 0 : options.indexOf(current));
|
|
3570
|
+
}
|
|
3571
|
+
|
|
3572
|
+
private openEffort(key: string): void {
|
|
3573
|
+
if (key === PROFILE_ORCHESTRATOR_KEY && !this.draft[key]?.model) {
|
|
3574
|
+
this.say("Pick a model for the orchestrator first; its effort follows the model.");
|
|
3315
3575
|
return;
|
|
3316
3576
|
}
|
|
3317
|
-
|
|
3318
|
-
|
|
3577
|
+
this.target = key;
|
|
3578
|
+
this.overlay = "effort";
|
|
3579
|
+
const current = this.currentValue(key, "thinking") as ThinkingLevel | undefined;
|
|
3580
|
+
const supported = this.supportedLevels(key);
|
|
3581
|
+
const landed = current !== undefined && supported ? clampToSupportedLevel(current, supported) : current;
|
|
3582
|
+
this.effortCursor = Math.max(0, landed === undefined ? 0 : this.effortOptions().indexOf(landed));
|
|
3583
|
+
}
|
|
3584
|
+
|
|
3585
|
+
private openMenu(kind: "menu" | "new"): void {
|
|
3586
|
+
this.overlay = kind;
|
|
3587
|
+
this.menuCursor = 0;
|
|
3588
|
+
}
|
|
3589
|
+
|
|
3590
|
+
private pickModel(option: string | undefined): void {
|
|
3591
|
+
if (option === undefined) return;
|
|
3592
|
+
if (option === ROUTING_CUSTOM_MODEL) {
|
|
3593
|
+
this.finish({ type: "custom", target: this.target, config: cloneModelConfig(this.draft) });
|
|
3319
3594
|
return;
|
|
3320
3595
|
}
|
|
3321
|
-
|
|
3322
|
-
|
|
3323
|
-
|
|
3324
|
-
|
|
3325
|
-
|
|
3326
|
-
|
|
3596
|
+
const model = option === this.inheritLabel(this.target) ? undefined : option;
|
|
3597
|
+
for (const key of this.targetKeys()) this.setModel(key, model);
|
|
3598
|
+
this.say(`${this.rowLabel(this.target)}: model ${model ?? this.inheritLabel(this.target)}. Save keeps it in ${this.profileName}.`);
|
|
3599
|
+
this.closeOverlay();
|
|
3600
|
+
}
|
|
3601
|
+
|
|
3602
|
+
private pickEffort(index: number): void {
|
|
3603
|
+
const option = this.effortOptions()[index];
|
|
3604
|
+
if (option === undefined) return;
|
|
3605
|
+
const thinking = option === INHERIT_THINKING ? undefined : (option as ThinkingLevel);
|
|
3606
|
+
for (const key of this.targetKeys()) this.setThinking(key, thinking);
|
|
3607
|
+
this.say(`${this.rowLabel(this.target)}: effort ${thinking ?? "inherit"}. Save keeps it in ${this.profileName}.`);
|
|
3608
|
+
this.closeOverlay();
|
|
3609
|
+
}
|
|
3610
|
+
|
|
3611
|
+
private targetKeys(): string[] {
|
|
3612
|
+
return this.target === ROUTING_ALL_ROW ? this.agents : [this.target];
|
|
3613
|
+
}
|
|
3614
|
+
|
|
3615
|
+
private setModel(key: string, model: string | undefined): void {
|
|
3616
|
+
if (key === PROFILE_ORCHESTRATOR_KEY && model === undefined) {
|
|
3617
|
+
delete this.draft[key];
|
|
3327
3618
|
return;
|
|
3328
3619
|
}
|
|
3329
|
-
|
|
3330
|
-
if (
|
|
3331
|
-
|
|
3332
|
-
|
|
3620
|
+
const entry = { ...(this.draft[key] ?? {}) };
|
|
3621
|
+
if (model === undefined) delete entry.model;
|
|
3622
|
+
else entry.model = model;
|
|
3623
|
+
this.draft[key] = entry.model || entry.thinking ? entry : {};
|
|
3624
|
+
}
|
|
3625
|
+
|
|
3626
|
+
private setThinking(key: string, thinking: ThinkingLevel | undefined): void {
|
|
3627
|
+
if (key === PROFILE_ORCHESTRATOR_KEY && !this.draft[key]?.model) return;
|
|
3628
|
+
const entry = { ...(this.draft[key] ?? {}) };
|
|
3629
|
+
if (thinking === undefined) delete entry.thinking;
|
|
3630
|
+
else entry.thinking = thinking;
|
|
3631
|
+
this.draft[key] = entry.model || entry.thinking ? entry : {};
|
|
3632
|
+
}
|
|
3633
|
+
|
|
3634
|
+
private runMenuItem(id: string): void {
|
|
3635
|
+
const name = this.profileName;
|
|
3636
|
+
const draft = cloneModelConfig(this.draft);
|
|
3637
|
+
switch (id) {
|
|
3638
|
+
case "rename":
|
|
3639
|
+
this.finish({ type: "rename", name, draft });
|
|
3640
|
+
return;
|
|
3641
|
+
case "duplicate":
|
|
3642
|
+
case "new-copy":
|
|
3643
|
+
this.closeOverlay();
|
|
3644
|
+
this.leave({ type: "duplicate", name });
|
|
3645
|
+
return;
|
|
3646
|
+
case "repo":
|
|
3647
|
+
this.finish({ type: "pin", source: "local", name, draft });
|
|
3648
|
+
return;
|
|
3649
|
+
case "delete":
|
|
3650
|
+
this.finish({ type: "delete", name, draft });
|
|
3651
|
+
return;
|
|
3652
|
+
case "new-live":
|
|
3653
|
+
this.closeOverlay();
|
|
3654
|
+
this.leave({ type: "create-live" });
|
|
3655
|
+
return;
|
|
3656
|
+
case "new-empty":
|
|
3657
|
+
this.closeOverlay();
|
|
3658
|
+
this.leave({ type: "create" });
|
|
3659
|
+
return;
|
|
3333
3660
|
}
|
|
3334
|
-
|
|
3335
|
-
|
|
3336
|
-
|
|
3661
|
+
}
|
|
3662
|
+
|
|
3663
|
+
private pressButton(button: RoutingTableButton): void {
|
|
3664
|
+
const config = cloneModelConfig(this.draft);
|
|
3665
|
+
switch (button) {
|
|
3666
|
+
case "use":
|
|
3667
|
+
this.finish({ type: "use-session", name: this.profileName, config });
|
|
3668
|
+
return;
|
|
3669
|
+
case "default":
|
|
3670
|
+
this.finish({ type: "apply-global", name: this.profileName, config });
|
|
3671
|
+
return;
|
|
3672
|
+
case "save":
|
|
3673
|
+
if (this.changeCount() === 0) {
|
|
3674
|
+
this.say("Nothing to save.");
|
|
3675
|
+
return;
|
|
3676
|
+
}
|
|
3677
|
+
this.finish({ type: "save", name: this.profileName, config });
|
|
3678
|
+
return;
|
|
3679
|
+
case "switch-save":
|
|
3680
|
+
if (this.pending) this.finish({ ...this.pending, saveFirst: config });
|
|
3681
|
+
return;
|
|
3682
|
+
case "switch-discard":
|
|
3683
|
+
if (this.pending) this.finish(this.pending);
|
|
3684
|
+
return;
|
|
3685
|
+
case "switch-stay":
|
|
3686
|
+
this.closeOverlay();
|
|
3687
|
+
return;
|
|
3337
3688
|
}
|
|
3338
|
-
this.selectedRow = this.rows[this.cursor] ?? SET_ALL_AGENTS;
|
|
3339
|
-
this.mode = "models";
|
|
3340
|
-
this.modelCursor = 0;
|
|
3341
|
-
this.query = "";
|
|
3342
3689
|
}
|
|
3343
3690
|
|
|
3344
|
-
|
|
3345
|
-
|
|
3691
|
+
// -- keyboard ----------------------------------------------------------------
|
|
3692
|
+
|
|
3693
|
+
handleInput(data: string): void {
|
|
3694
|
+
if (isKeyRelease(data)) return;
|
|
3346
3695
|
if (matchesKey(data, "ctrl+c")) {
|
|
3347
|
-
this.
|
|
3696
|
+
this.finish({ type: "close" });
|
|
3348
3697
|
return;
|
|
3349
3698
|
}
|
|
3699
|
+
switch (this.overlay) {
|
|
3700
|
+
case "model":
|
|
3701
|
+
this.handleModelKey(data);
|
|
3702
|
+
return;
|
|
3703
|
+
case "effort":
|
|
3704
|
+
this.handleEffortKey(data);
|
|
3705
|
+
return;
|
|
3706
|
+
case "menu":
|
|
3707
|
+
case "new":
|
|
3708
|
+
this.handleMenuKey(data);
|
|
3709
|
+
return;
|
|
3710
|
+
case "switch":
|
|
3711
|
+
this.handleSwitchKey(data);
|
|
3712
|
+
return;
|
|
3713
|
+
default:
|
|
3714
|
+
this.handleTableKey(data);
|
|
3715
|
+
}
|
|
3716
|
+
}
|
|
3717
|
+
|
|
3718
|
+
private handleTableKey(data: string): void {
|
|
3350
3719
|
if (matchesKey(data, "escape")) {
|
|
3351
|
-
this.
|
|
3352
|
-
|
|
3720
|
+
if (this.changeCount() > 0 && !this.escArmed) {
|
|
3721
|
+
this.feedback = "Unsaved changes. Press esc again to close without saving.";
|
|
3722
|
+
this.escArmed = true;
|
|
3723
|
+
return;
|
|
3724
|
+
}
|
|
3725
|
+
this.finish({ type: "close" });
|
|
3353
3726
|
return;
|
|
3354
3727
|
}
|
|
3355
|
-
|
|
3356
|
-
|
|
3357
|
-
|
|
3358
|
-
|
|
3359
|
-
|
|
3360
|
-
);
|
|
3728
|
+
this.escArmed = false;
|
|
3729
|
+
if (matchesKey(data, "left")) return this.switchBy(-1);
|
|
3730
|
+
if (matchesKey(data, "right")) return this.switchBy(1);
|
|
3731
|
+
if (matchesKey(data, "up") || matchesKey(data, "k")) {
|
|
3732
|
+
this.cursor = Math.max(0, this.cursor - 1);
|
|
3361
3733
|
return;
|
|
3362
3734
|
}
|
|
3363
3735
|
if (matchesKey(data, "down") || matchesKey(data, "j")) {
|
|
3364
|
-
this.
|
|
3365
|
-
Math.max(0, options.length - 1),
|
|
3366
|
-
this.modelCursor + 1,
|
|
3367
|
-
);
|
|
3736
|
+
this.cursor = Math.min(this.rows.length - 1, this.cursor + 1);
|
|
3368
3737
|
return;
|
|
3369
3738
|
}
|
|
3370
|
-
if (matchesKey(data, "
|
|
3739
|
+
if (matchesKey(data, "return")) return this.openModel(this.rows[this.cursor] ?? PROFILE_ORCHESTRATOR_KEY);
|
|
3740
|
+
if (matchesKey(data, "e")) return this.openEffort(this.rows[this.cursor] ?? PROFILE_ORCHESTRATOR_KEY);
|
|
3741
|
+
if (matchesKey(data, "s") || matchesKey(data, "ctrl+s")) return this.pressButton("save");
|
|
3742
|
+
if (matchesKey(data, "u")) return this.pressButton("use");
|
|
3743
|
+
if (matchesKey(data, "d")) return this.pressButton("default");
|
|
3744
|
+
if (matchesKey(data, "n")) return this.openMenu("new");
|
|
3745
|
+
if (matchesKey(data, "m")) return this.openMenu("menu");
|
|
3746
|
+
}
|
|
3747
|
+
|
|
3748
|
+
private handleModelKey(data: string): void {
|
|
3749
|
+
const options = this.modelOptions();
|
|
3750
|
+
if (matchesKey(data, "escape")) return this.closeOverlay();
|
|
3751
|
+
if (matchesKey(data, "return")) return this.pickModel(options[this.modelCursor]);
|
|
3752
|
+
if (matchesKey(data, "up")) {
|
|
3371
3753
|
this.modelCursor = Math.max(0, this.modelCursor - 1);
|
|
3372
3754
|
return;
|
|
3373
3755
|
}
|
|
3374
|
-
if (matchesKey(data, "
|
|
3375
|
-
|
|
3376
|
-
if (!selected) return;
|
|
3377
|
-
if (selected === CUSTOM_MODEL) {
|
|
3378
|
-
this.done({
|
|
3379
|
-
type: "custom",
|
|
3380
|
-
agent: this.selectedRow === SET_ALL_AGENTS ? "all" : this.selectedRow,
|
|
3381
|
-
config: this.draft,
|
|
3382
|
-
});
|
|
3383
|
-
return;
|
|
3384
|
-
}
|
|
3385
|
-
if (selected === KEEP_CURRENT) {
|
|
3386
|
-
this.mode = "agents";
|
|
3387
|
-
return;
|
|
3388
|
-
}
|
|
3389
|
-
this.applyModelSelection(
|
|
3390
|
-
selected === INHERIT_MODEL || selected === LEAVE_ORCHESTRATOR_UNTOUCHED ? undefined : selected,
|
|
3391
|
-
);
|
|
3392
|
-
this.mode = "agents";
|
|
3756
|
+
if (matchesKey(data, "down")) {
|
|
3757
|
+
this.modelCursor = Math.min(options.length - 1, this.modelCursor + 1);
|
|
3393
3758
|
return;
|
|
3394
3759
|
}
|
|
3760
|
+
if (matchesKey(data, "backspace")) {
|
|
3761
|
+
this.query = this.query.slice(0, -1);
|
|
3762
|
+
this.modelCursor = 0;
|
|
3763
|
+
return;
|
|
3764
|
+
}
|
|
3765
|
+
// Every printable key types into the search, so letters never act here.
|
|
3395
3766
|
if (data.length === 1 && data.charCodeAt(0) >= 32) {
|
|
3396
3767
|
this.query += data;
|
|
3397
3768
|
this.modelCursor = 0;
|
|
3398
3769
|
}
|
|
3399
3770
|
}
|
|
3400
3771
|
|
|
3401
|
-
private
|
|
3402
|
-
|
|
3403
|
-
if (
|
|
3404
|
-
|
|
3772
|
+
private handleEffortKey(data: string): void {
|
|
3773
|
+
if (matchesKey(data, "escape")) return this.closeOverlay();
|
|
3774
|
+
if (matchesKey(data, "return")) return this.pickEffort(this.effortCursor);
|
|
3775
|
+
if (matchesKey(data, "left") || matchesKey(data, "up")) {
|
|
3776
|
+
this.effortCursor = Math.max(0, this.effortCursor - 1);
|
|
3405
3777
|
return;
|
|
3406
3778
|
}
|
|
3407
|
-
if (
|
|
3408
|
-
|
|
3779
|
+
if (matchesKey(data, "right") || matchesKey(data, "down")) {
|
|
3780
|
+
this.effortCursor = Math.min(this.effortOptions().length - 1, this.effortCursor + 1);
|
|
3781
|
+
}
|
|
3409
3782
|
}
|
|
3410
3783
|
|
|
3411
|
-
private
|
|
3412
|
-
const
|
|
3413
|
-
if (
|
|
3414
|
-
|
|
3784
|
+
private handleMenuKey(data: string): void {
|
|
3785
|
+
const items = this.menuItems();
|
|
3786
|
+
if (matchesKey(data, "escape")) return this.closeOverlay();
|
|
3787
|
+
if (matchesKey(data, "up") || matchesKey(data, "k")) {
|
|
3788
|
+
this.menuCursor = Math.max(0, this.menuCursor - 1);
|
|
3415
3789
|
return;
|
|
3416
3790
|
}
|
|
3417
|
-
|
|
3418
|
-
|
|
3419
|
-
|
|
3420
|
-
private applyInherit(): void {
|
|
3421
|
-
const row = this.rows[this.cursor];
|
|
3422
|
-
if (row === SET_ALL_AGENTS) {
|
|
3423
|
-
for (const name of this.agents) this.clearEntry(name);
|
|
3791
|
+
if (matchesKey(data, "down") || matchesKey(data, "j")) {
|
|
3792
|
+
this.menuCursor = Math.min(items.length - 1, this.menuCursor + 1);
|
|
3424
3793
|
return;
|
|
3425
3794
|
}
|
|
3426
|
-
if (
|
|
3795
|
+
if (matchesKey(data, "return")) {
|
|
3796
|
+
const item = items[this.menuCursor];
|
|
3797
|
+
if (item) this.runMenuItem(item.id);
|
|
3798
|
+
}
|
|
3427
3799
|
}
|
|
3428
3800
|
|
|
3429
|
-
private
|
|
3430
|
-
if (
|
|
3431
|
-
|
|
3432
|
-
|
|
3801
|
+
private handleSwitchKey(data: string): void {
|
|
3802
|
+
if (matchesKey(data, "escape")) return this.pressButton("switch-stay");
|
|
3803
|
+
if (matchesKey(data, "s") || matchesKey(data, "return")) return this.pressButton("switch-save");
|
|
3804
|
+
if (matchesKey(data, "d")) return this.pressButton("switch-discard");
|
|
3805
|
+
}
|
|
3806
|
+
|
|
3807
|
+
// -- pointer ----------------------------------------------------------------
|
|
3808
|
+
|
|
3809
|
+
handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
|
|
3810
|
+
if (event.type === "wheel" && event.wheelDelta) {
|
|
3811
|
+
const delta = event.wheelDelta < 0 ? -1 : 1;
|
|
3812
|
+
if (this.overlay === "model") {
|
|
3813
|
+
this.modelCursor = Math.max(0, Math.min(this.modelOptions().length - 1, this.modelCursor + delta));
|
|
3814
|
+
} else if (this.overlay === "effort") {
|
|
3815
|
+
this.effortCursor = Math.max(0, Math.min(this.effortOptions().length - 1, this.effortCursor + delta));
|
|
3816
|
+
} else if (this.overlay === "menu" || this.overlay === "new") {
|
|
3817
|
+
this.menuCursor = Math.max(0, Math.min(this.menuItems().length - 1, this.menuCursor + delta));
|
|
3818
|
+
} else if (this.overlay === undefined) {
|
|
3819
|
+
this.cursor = Math.max(0, Math.min(this.rows.length - 1, this.cursor + delta));
|
|
3820
|
+
}
|
|
3821
|
+
return { handled: true, render: true };
|
|
3822
|
+
}
|
|
3823
|
+
const exact = this.hitRegions.find(
|
|
3824
|
+
(region) => region.y === event.y && event.x >= region.x0 && event.x < region.x1,
|
|
3825
|
+
);
|
|
3826
|
+
if (event.type === "move") {
|
|
3827
|
+
if (event.button !== "none") return undefined;
|
|
3828
|
+
// Whole-row targets light up anywhere on their line; buttons and tabs
|
|
3829
|
+
// only under the pointer.
|
|
3830
|
+
const hit = exact ?? this.hitRegions.find((region) => region.y === event.y && this.isRowAction(region.action));
|
|
3831
|
+
const next = hit ? this.hoverKey(hit.action) : undefined;
|
|
3832
|
+
const changed = next !== this.hovered;
|
|
3833
|
+
this.hovered = next;
|
|
3834
|
+
return { handled: hit !== undefined, render: changed };
|
|
3835
|
+
}
|
|
3836
|
+
// A click arrives as press then click: the press only claims the pointer,
|
|
3837
|
+
// so one click never runs two actions.
|
|
3838
|
+
if (event.type === "press") return exact ? { handled: true } : undefined;
|
|
3839
|
+
if (event.type !== "click" || event.button !== "left") return undefined;
|
|
3840
|
+
if (!exact) {
|
|
3841
|
+
if (this.overlay !== undefined && this.overlay !== "switch") {
|
|
3842
|
+
this.closeOverlay();
|
|
3843
|
+
return { handled: true, render: true };
|
|
3844
|
+
}
|
|
3845
|
+
return undefined;
|
|
3433
3846
|
}
|
|
3434
|
-
|
|
3435
|
-
|
|
3436
|
-
else current.model = model;
|
|
3437
|
-
if (!current.model && !current.thinking) this.draft[name] = {};
|
|
3438
|
-
else this.draft[name] = current;
|
|
3847
|
+
this.dispatch(exact.action);
|
|
3848
|
+
return { handled: true, render: true };
|
|
3439
3849
|
}
|
|
3440
3850
|
|
|
3441
|
-
private
|
|
3442
|
-
|
|
3443
|
-
const current = this.draft[name] ?? {};
|
|
3444
|
-
if (thinking === undefined) delete current.thinking;
|
|
3445
|
-
else current.thinking = thinking;
|
|
3446
|
-
if (!current.model && !current.thinking) this.draft[name] = {};
|
|
3447
|
-
else this.draft[name] = current;
|
|
3851
|
+
private isRowAction(action: RoutingTableMouseAction): boolean {
|
|
3852
|
+
return action.kind === "row" || action.kind === "model-option" || action.kind === "menu-item";
|
|
3448
3853
|
}
|
|
3449
3854
|
|
|
3450
|
-
private
|
|
3451
|
-
|
|
3452
|
-
|
|
3855
|
+
private hoverKey(action: RoutingTableMouseAction): string {
|
|
3856
|
+
switch (action.kind) {
|
|
3857
|
+
case "row":
|
|
3858
|
+
case "effort":
|
|
3859
|
+
return `row:${action.row}`;
|
|
3860
|
+
case "tab":
|
|
3861
|
+
return `tab:${action.name}`;
|
|
3862
|
+
case "model-option":
|
|
3863
|
+
case "effort-option":
|
|
3864
|
+
return `${action.kind}:${action.index}`;
|
|
3865
|
+
case "menu-item":
|
|
3866
|
+
return `menu:${action.id}`;
|
|
3867
|
+
case "button":
|
|
3868
|
+
return `button:${action.button}`;
|
|
3869
|
+
default:
|
|
3870
|
+
return action.kind;
|
|
3871
|
+
}
|
|
3453
3872
|
}
|
|
3454
3873
|
|
|
3455
|
-
private
|
|
3456
|
-
|
|
3457
|
-
|
|
3458
|
-
|
|
3459
|
-
|
|
3460
|
-
|
|
3461
|
-
|
|
3462
|
-
|
|
3463
|
-
|
|
3874
|
+
private dispatch(action: RoutingTableMouseAction): void {
|
|
3875
|
+
switch (action.kind) {
|
|
3876
|
+
case "tab":
|
|
3877
|
+
this.switchTab(action.name);
|
|
3878
|
+
return;
|
|
3879
|
+
case "tab-menu":
|
|
3880
|
+
this.openMenu("menu");
|
|
3881
|
+
return;
|
|
3882
|
+
case "new":
|
|
3883
|
+
this.openMenu("new");
|
|
3884
|
+
return;
|
|
3885
|
+
case "row":
|
|
3886
|
+
this.cursor = Math.max(0, this.rows.indexOf(action.row));
|
|
3887
|
+
this.openModel(action.row);
|
|
3888
|
+
return;
|
|
3889
|
+
case "effort":
|
|
3890
|
+
this.cursor = Math.max(0, this.rows.indexOf(action.row));
|
|
3891
|
+
this.openEffort(action.row);
|
|
3892
|
+
return;
|
|
3893
|
+
case "model-option":
|
|
3894
|
+
this.pickModel(this.modelOptions()[action.index]);
|
|
3895
|
+
return;
|
|
3896
|
+
case "effort-option":
|
|
3897
|
+
this.pickEffort(action.index);
|
|
3898
|
+
return;
|
|
3899
|
+
case "menu-item":
|
|
3900
|
+
this.runMenuItem(action.id);
|
|
3901
|
+
return;
|
|
3902
|
+
case "button":
|
|
3903
|
+
this.pressButton(action.button);
|
|
3904
|
+
return;
|
|
3905
|
+
}
|
|
3906
|
+
}
|
|
3907
|
+
|
|
3908
|
+
// -- render ----------------------------------------------------------------
|
|
3909
|
+
|
|
3910
|
+
render(width: number): string[] {
|
|
3911
|
+
this.hitRegions = [];
|
|
3912
|
+
const inner = Math.max(20, width - 4);
|
|
3913
|
+
if (this.overlay === undefined) return this.renderFrame(this.tableLines(inner), width);
|
|
3914
|
+
// The table stays on screen, dimmed, under the card; its regions are
|
|
3915
|
+
// dropped because the card is modal.
|
|
3916
|
+
const table = this.tableLines(inner).map((line) => this.renderText(stripAnsi(line), "dim"));
|
|
3917
|
+
this.hitRegions = [];
|
|
3918
|
+
const card = this.cardFor(inner);
|
|
3919
|
+
return this.renderFrame(this.placeCard(table, card, inner), width);
|
|
3920
|
+
}
|
|
3921
|
+
|
|
3922
|
+
private renderFrame(body: string[], width: number): string[] {
|
|
3923
|
+
const inner = Math.max(20, width - 4);
|
|
3924
|
+
const rows = this.terminalRows ? Math.floor(this.terminalRows()) - 2 : body.length;
|
|
3925
|
+
const padded = body.length < rows ? [...body, ...Array<string>(rows - body.length).fill("")] : body.slice(0, Math.max(rows, 1));
|
|
3926
|
+
const rule = "─".repeat(inner + 2);
|
|
3927
|
+
const border = (text: string) => this.renderText(text, "border");
|
|
3928
|
+
return [
|
|
3929
|
+
border(`╭${rule}╮`),
|
|
3930
|
+
...padded.map((line) => `${border("│")} ${this.fit(line, inner)} ${border("│")}`),
|
|
3931
|
+
border(`╰${rule}╯`),
|
|
3932
|
+
];
|
|
3464
3933
|
}
|
|
3465
3934
|
|
|
3466
|
-
private
|
|
3935
|
+
private tableLines(inner: number): string[] {
|
|
3467
3936
|
const lines: string[] = [];
|
|
3468
|
-
|
|
3469
|
-
|
|
3470
|
-
|
|
3471
|
-
|
|
3472
|
-
|
|
3473
|
-
|
|
3474
|
-
|
|
3475
|
-
|
|
3476
|
-
|
|
3477
|
-
|
|
3478
|
-
|
|
3479
|
-
const
|
|
3480
|
-
|
|
3481
|
-
|
|
3482
|
-
|
|
3483
|
-
|
|
3484
|
-
|
|
3485
|
-
|
|
3486
|
-
);
|
|
3487
|
-
const
|
|
3488
|
-
|
|
3489
|
-
|
|
3490
|
-
|
|
3491
|
-
const
|
|
3492
|
-
|
|
3493
|
-
|
|
3494
|
-
|
|
3495
|
-
|
|
3496
|
-
|
|
3497
|
-
|
|
3498
|
-
|
|
3499
|
-
|
|
3500
|
-
|
|
3501
|
-
|
|
3502
|
-
|
|
3503
|
-
"Continue",
|
|
3504
|
-
this.cursor === this.rows.length ? "accent" : "text",
|
|
3505
|
-
)}`,
|
|
3506
|
-
);
|
|
3937
|
+
// Regions are recorded in frame coordinates: the top border is row 0 and
|
|
3938
|
+
// content starts two columns in ("│ ").
|
|
3939
|
+
const region = (action: RoutingTableMouseAction, x0: number, x1: number) =>
|
|
3940
|
+
this.hitRegions.push({ y: lines.length + 1, x0: 2 + x0, x1: 2 + x1, action });
|
|
3941
|
+
|
|
3942
|
+
// Tabs: every profile, its scopes, the menu on the active one, and New.
|
|
3943
|
+
const tabs = this.tabWindow(inner);
|
|
3944
|
+
let column = 0;
|
|
3945
|
+
let tabLine = "";
|
|
3946
|
+
let activeStart = 0;
|
|
3947
|
+
let activeEnd = 0;
|
|
3948
|
+
const put = (text: string, tone: PanelTone | undefined, action?: RoutingTableMouseAction) => {
|
|
3949
|
+
if (action) region(action, column, column + visibleWidth(text));
|
|
3950
|
+
const styled = tone ? this.renderText(text, tone) : sanitizeTerminalText(text);
|
|
3951
|
+
tabLine += action ? this.hoverWrap(this.hoverKey(action), styled, text) : styled;
|
|
3952
|
+
column += visibleWidth(text);
|
|
3953
|
+
};
|
|
3954
|
+
put(" ", undefined);
|
|
3955
|
+
if (tabs.before) put("‹ ", "dim");
|
|
3956
|
+
for (const name of tabs.names) {
|
|
3957
|
+
const active = name === this.profileName;
|
|
3958
|
+
if (active) activeStart = column;
|
|
3959
|
+
put(` ${truncateToWidth(sanitizeTerminalText(name), 18, "…")} `, active ? "title" : "muted", { kind: "tab", name });
|
|
3960
|
+
for (const glyph of this.scopeGlyphs(name)) put(glyph.text, glyph.tone);
|
|
3961
|
+
if (active) {
|
|
3962
|
+
put(" ▾ ", "accent", { kind: "tab-menu" });
|
|
3963
|
+
activeEnd = column;
|
|
3964
|
+
this.activeTabColumn = activeStart;
|
|
3965
|
+
}
|
|
3966
|
+
put(" ", undefined);
|
|
3967
|
+
}
|
|
3968
|
+
if (tabs.after) put("› ", "dim");
|
|
3969
|
+
this.newColumn = column;
|
|
3970
|
+
put("+ New", "accent", { kind: "new" });
|
|
3971
|
+
lines.push(tabLine);
|
|
3507
3972
|
lines.push(
|
|
3508
|
-
|
|
3509
|
-
"
|
|
3510
|
-
|
|
3511
|
-
)}`,
|
|
3973
|
+
this.renderText("─".repeat(activeStart), "border")
|
|
3974
|
+
+ this.renderText("━".repeat(Math.max(0, activeEnd - activeStart)), "accent")
|
|
3975
|
+
+ this.renderText("─".repeat(Math.max(0, inner - activeEnd)), "border"),
|
|
3512
3976
|
);
|
|
3977
|
+
const title = truncateToWidth(sanitizeTerminalText(this.profileName), Math.max(8, inner - 4), "…");
|
|
3978
|
+
const room = inner - 1 - visibleWidth(title) - 3;
|
|
3979
|
+
const sentence = [this.scopeSentence(this.profileName), this.shortScope(this.profileName)]
|
|
3980
|
+
.find((candidate) => visibleWidth(candidate) <= room) ?? "";
|
|
3981
|
+
lines.push(` ${this.renderText(title, "title")}${sentence ? ` ${this.renderText(sentence, "muted")}` : ""}`);
|
|
3982
|
+
const notices = this.noticeLines(inner);
|
|
3983
|
+
for (const notice of notices) lines.push(this.renderText(notice, "edited"));
|
|
3513
3984
|
lines.push("");
|
|
3514
|
-
|
|
3515
|
-
//
|
|
3516
|
-
|
|
3517
|
-
|
|
3518
|
-
|
|
3519
|
-
|
|
3520
|
-
)
|
|
3521
|
-
|
|
3985
|
+
|
|
3986
|
+
// Labels are measured after sanitizing: control sequences in an agent name
|
|
3987
|
+
// would otherwise pad the column and then vanish, gluing name and model.
|
|
3988
|
+
const agentCol = Math.min(26, Math.max("Orchestrator".length, ...this.agents.map((agent) => visibleWidth(sanitizeTerminalText(agent)))) + 2);
|
|
3989
|
+
const padLabel = (text: string) => {
|
|
3990
|
+
const safe = truncateToWidth(sanitizeTerminalText(text), agentCol - 1, "…");
|
|
3991
|
+
return safe + " ".repeat(Math.max(0, agentCol - visibleWidth(safe)));
|
|
3992
|
+
};
|
|
3993
|
+
const modelCol = Math.max(20, Math.min(40, inner - agentCol - 12));
|
|
3994
|
+
const effortStart = 2 + agentCol + modelCol;
|
|
3995
|
+
const row = (key: string, label: string, labelTone: PanelTone) => {
|
|
3996
|
+
const focused = this.rows[this.cursor] === key;
|
|
3997
|
+
const model = this.modelValue(key);
|
|
3998
|
+
const effort = this.effortValue(key);
|
|
3999
|
+
const modelTone: PanelTone = this.isEdited(key, "model")
|
|
4000
|
+
? "edited"
|
|
4001
|
+
: model === LEAVE_ORCHESTRATOR_UNTOUCHED || model === "inherit" || model === ROUTING_PI_DEFAULT ? "dim" : "text";
|
|
4002
|
+
const effortTone: PanelTone = this.isEdited(key, "thinking") ? "edited" : effort === "inherit" || effort === ROUTING_PI_DEFAULT ? "dim" : "status";
|
|
4003
|
+
const plain = `${focused ? "▸ " : " "}${padLabel(label)}${truncateToWidth(model, modelCol - 1, "…").padEnd(modelCol)}${effort}`;
|
|
4004
|
+
const styled =
|
|
4005
|
+
this.renderText(focused ? "▸ " : " ", "accent")
|
|
4006
|
+
+ this.renderText(padLabel(label), labelTone)
|
|
4007
|
+
+ this.renderText(truncateToWidth(model, modelCol - 1, "…").padEnd(modelCol), modelTone)
|
|
4008
|
+
+ this.renderText(effort, effortTone);
|
|
4009
|
+
if (effort) region({ kind: "effort", row: key }, effortStart, inner);
|
|
4010
|
+
region({ kind: "row", row: key }, 0, inner);
|
|
4011
|
+
lines.push(this.hoverWrap(`row:${key}`, styled, plain, inner));
|
|
4012
|
+
};
|
|
4013
|
+
|
|
4014
|
+
row(PROFILE_ORCHESTRATOR_KEY, "Orchestrator", "title");
|
|
4015
|
+
lines.push(` ${this.renderText("─".repeat(Math.max(0, effortStart + 8 - 1)), "border")}`);
|
|
4016
|
+
|
|
4017
|
+
// The header doubles as the "every agent" row: Model ▾ and Effort ▾.
|
|
4018
|
+
const headerFocused = this.rows[this.cursor] === ROUTING_ALL_ROW;
|
|
4019
|
+
const headerBase = `${headerFocused ? "▸ " : " "}${"Agent".padEnd(agentCol)}${"Model ▾".padEnd(modelCol)}Effort ▾`;
|
|
4020
|
+
const headerNote = visibleWidth(`${headerBase} set every agent`) <= inner ? " set every agent" : "";
|
|
4021
|
+
const headerPlain = `${headerBase}${headerNote}`;
|
|
4022
|
+
region({ kind: "effort", row: ROUTING_ALL_ROW }, effortStart, effortStart + "Effort ▾".length);
|
|
4023
|
+
region({ kind: "row", row: ROUTING_ALL_ROW }, 0, inner);
|
|
3522
4024
|
lines.push(
|
|
3523
|
-
|
|
3524
|
-
`
|
|
3525
|
-
"
|
|
4025
|
+
this.hoverWrap(
|
|
4026
|
+
`row:${ROUTING_ALL_ROW}`,
|
|
4027
|
+
this.renderText(headerFocused ? "▸ " : " ", "accent")
|
|
4028
|
+
+ this.renderText("Agent".padEnd(agentCol), "muted")
|
|
4029
|
+
+ this.renderText("Model ▾".padEnd(modelCol), "muted")
|
|
4030
|
+
+ this.renderText("Effort ▾", "muted")
|
|
4031
|
+
+ this.renderText(headerNote, "dim"),
|
|
4032
|
+
headerPlain,
|
|
4033
|
+
inner,
|
|
3526
4034
|
),
|
|
3527
4035
|
);
|
|
4036
|
+
|
|
4037
|
+
const window = this.agentWindow(notices.length);
|
|
4038
|
+
if (window.start > 0) lines.push(this.renderText(` ↑ ${window.start} more`, "dim"));
|
|
4039
|
+
for (const agent of this.agents.slice(window.start, window.end)) row(agent, agent, "text");
|
|
4040
|
+
if (window.end < this.agents.length) lines.push(this.renderText(` ↓ ${this.agents.length - window.end} more`, "dim"));
|
|
4041
|
+
lines.push("");
|
|
4042
|
+
|
|
4043
|
+
// Footer: three buttons, the unsaved count on the right.
|
|
4044
|
+
const changes = this.changeCount();
|
|
4045
|
+
const status = changes > 0 ? `● ${changes} unsaved` : "Saved";
|
|
4046
|
+
const buttonsWidth = (labels: (id: RoutingTableButton) => string) =>
|
|
4047
|
+
1 + (["use", "default", "save"] as const).reduce((sum, id) => sum + labels(id).length + 6, 0);
|
|
4048
|
+
const fullLabel = (id: RoutingTableButton) => ROUTING_TABLE_BUTTONS[id];
|
|
4049
|
+
const shortLabel = (id: RoutingTableButton) => ROUTING_TABLE_SHORT_BUTTONS[id] ?? ROUTING_TABLE_BUTTONS[id];
|
|
4050
|
+
const label = buttonsWidth(fullLabel) + status.length + 1 <= inner ? fullLabel : shortLabel;
|
|
4051
|
+
let footer = "";
|
|
4052
|
+
let footerColumn = 0;
|
|
4053
|
+
const button = (id: RoutingTableButton, tone: PanelTone) => {
|
|
4054
|
+
const text = `[ ${label(id)} ]`;
|
|
4055
|
+
region({ kind: "button", button: id }, footerColumn, footerColumn + text.length);
|
|
4056
|
+
footer += this.hoverWrap(`button:${id}`, this.renderText(text, tone), text);
|
|
4057
|
+
footer += " ";
|
|
4058
|
+
footerColumn += text.length + 2;
|
|
4059
|
+
};
|
|
4060
|
+
footer += " ";
|
|
4061
|
+
footerColumn += 1;
|
|
4062
|
+
button("use", "accent");
|
|
4063
|
+
button("default", "accent");
|
|
4064
|
+
button("save", changes > 0 ? "title" : "dim");
|
|
4065
|
+
const gap = Math.max(1, inner - footerColumn - status.length - 1);
|
|
4066
|
+
lines.push(footer + " ".repeat(gap) + this.renderText(status, changes > 0 ? "edited" : "good"));
|
|
4067
|
+
lines.push(this.renderText(this.fitHints(inner), "dim"));
|
|
4068
|
+
const [first = "", second = ""] = this.wrapFeedback(inner);
|
|
4069
|
+
const feedbackTone: PanelTone = this.escArmed ? "edited" : "muted";
|
|
4070
|
+
lines.push(first ? this.renderText(first, feedbackTone) : "");
|
|
4071
|
+
lines.push(second ? this.renderText(second, feedbackTone) : "");
|
|
3528
4072
|
return lines;
|
|
3529
4073
|
}
|
|
3530
4074
|
|
|
3531
|
-
|
|
3532
|
-
|
|
3533
|
-
const
|
|
3534
|
-
|
|
3535
|
-
this.renderLine(text, width, tone);
|
|
3536
|
-
lines.push(
|
|
3537
|
-
line(`Select model for ${sanitizeTerminalText(this.selectedRow)}`, "title"),
|
|
3538
|
-
);
|
|
3539
|
-
lines.push("");
|
|
3540
|
-
lines.push(
|
|
3541
|
-
`${this.renderText("◎", "accent")} ${this.renderText(this.query || "search...", "muted")}`,
|
|
4075
|
+
/** The scope in a few characters, for terminals too narrow for the sentence. */
|
|
4076
|
+
private shortScope(name: string): string {
|
|
4077
|
+
const parts = this.scopeGlyphs(name).map((glyph) =>
|
|
4078
|
+
glyph.text === "◉" ? "◉ session" : glyph.text === "★" ? "★ default" : "⌂ repo",
|
|
3542
4079
|
);
|
|
3543
|
-
|
|
3544
|
-
|
|
3545
|
-
|
|
3546
|
-
|
|
3547
|
-
|
|
3548
|
-
|
|
3549
|
-
|
|
3550
|
-
|
|
3551
|
-
);
|
|
3552
|
-
|
|
3553
|
-
|
|
3554
|
-
|
|
3555
|
-
|
|
3556
|
-
|
|
3557
|
-
|
|
3558
|
-
|
|
3559
|
-
|
|
3560
|
-
|
|
3561
|
-
|
|
3562
|
-
|
|
4080
|
+
return parts.length > 0 ? parts.join(" ") : "not in use";
|
|
4081
|
+
}
|
|
4082
|
+
|
|
4083
|
+
/** As many key hints as fit, chosen by priority and shown in their usual order. */
|
|
4084
|
+
private fitHints(inner: number): string {
|
|
4085
|
+
const budget = inner - 1;
|
|
4086
|
+
const chosen = new Set<number>();
|
|
4087
|
+
let used = 0;
|
|
4088
|
+
const byPriority = ROUTING_TABLE_HINTS.map((hint, index) => ({ ...hint, index })).sort((a, b) => a.keep - b.keep);
|
|
4089
|
+
for (const hint of byPriority) {
|
|
4090
|
+
const cost = visibleWidth(hint.text) + (chosen.size > 0 ? 2 : 0);
|
|
4091
|
+
if (used + cost > budget) continue;
|
|
4092
|
+
chosen.add(hint.index);
|
|
4093
|
+
used += cost;
|
|
4094
|
+
}
|
|
4095
|
+
return ` ${ROUTING_TABLE_HINTS.filter((_, index) => chosen.has(index)).map((hint) => hint.text).join(" ")}`;
|
|
4096
|
+
}
|
|
4097
|
+
|
|
4098
|
+
/**
|
|
4099
|
+
* Each notice wrapped between words under a ⚠, at most four lines in all so
|
|
4100
|
+
* the table keeps its room; anything beyond is counted, never cut mid-word.
|
|
4101
|
+
*/
|
|
4102
|
+
private noticeLines(inner: number): string[] {
|
|
4103
|
+
const width = Math.max(10, inner - 3);
|
|
4104
|
+
const lines: string[] = [];
|
|
4105
|
+
let shown = 0;
|
|
4106
|
+
for (const notice of this.notices) {
|
|
4107
|
+
const wrapped: string[] = [];
|
|
4108
|
+
let current = "";
|
|
4109
|
+
for (const word of sanitizeTerminalText(notice).split(/\s+/).filter(Boolean)) {
|
|
4110
|
+
const next = current ? `${current} ${word}` : word;
|
|
4111
|
+
if (visibleWidth(next) <= width || current === "") {
|
|
4112
|
+
current = next;
|
|
4113
|
+
continue;
|
|
4114
|
+
}
|
|
4115
|
+
wrapped.push(current);
|
|
4116
|
+
current = word;
|
|
4117
|
+
}
|
|
4118
|
+
if (current) wrapped.push(current);
|
|
4119
|
+
if (lines.length + wrapped.length > 4) break;
|
|
4120
|
+
wrapped.forEach((line, index) => lines.push(`${index === 0 ? " ⚠ " : " "}${line}`));
|
|
4121
|
+
shown += 1;
|
|
3563
4122
|
}
|
|
3564
|
-
|
|
3565
|
-
lines.push("");
|
|
3566
|
-
lines.push(
|
|
3567
|
-
line("j/k: navigate • type: search • enter: select • esc: back", "muted"),
|
|
3568
|
-
);
|
|
4123
|
+
const hidden = this.notices.length - shown;
|
|
4124
|
+
if (hidden > 0) lines.push(` ${hidden} more problem${hidden === 1 ? "" : "s"} not shown.`);
|
|
3569
4125
|
return lines;
|
|
3570
4126
|
}
|
|
3571
4127
|
|
|
3572
|
-
|
|
3573
|
-
|
|
3574
|
-
|
|
3575
|
-
|
|
4128
|
+
/** The feedback message on up to two lines, broken between words. */
|
|
4129
|
+
private wrapFeedback(inner: number): string[] {
|
|
4130
|
+
if (!this.feedback) return [];
|
|
4131
|
+
const width = Math.max(10, inner - 1);
|
|
4132
|
+
const lines: string[] = [];
|
|
4133
|
+
let current = "";
|
|
4134
|
+
for (const word of sanitizeTerminalText(this.feedback).split(/\s+/).filter(Boolean)) {
|
|
4135
|
+
const next = current ? `${current} ${word}` : word;
|
|
4136
|
+
if (visibleWidth(next) <= width) {
|
|
4137
|
+
current = next;
|
|
4138
|
+
continue;
|
|
4139
|
+
}
|
|
4140
|
+
if (current) lines.push(current);
|
|
4141
|
+
current = word;
|
|
4142
|
+
}
|
|
4143
|
+
if (current) lines.push(current);
|
|
4144
|
+
if (lines.length > 2) {
|
|
4145
|
+
lines[1] = truncateToWidth(lines.slice(1).join(" "), width, "…");
|
|
4146
|
+
lines.length = 2;
|
|
4147
|
+
}
|
|
4148
|
+
return lines.map((line) => ` ${line}`);
|
|
4149
|
+
}
|
|
4150
|
+
|
|
4151
|
+
/** Which tabs fit around the active one, and whether more hide on either side. */
|
|
4152
|
+
private tabWindow(inner: number): { names: string[]; before: boolean; after: boolean } {
|
|
4153
|
+
const names = this.profileNames();
|
|
4154
|
+
const widthOf = (name: string) =>
|
|
4155
|
+
Math.min(18, visibleWidth(name)) + 4 + this.scopeGlyphs(name).length + (name === this.profileName ? 3 : 0);
|
|
4156
|
+
const budget = inner - 1 - "+ New".length - 4;
|
|
4157
|
+
const active = Math.max(0, names.indexOf(this.profileName));
|
|
4158
|
+
let start = active;
|
|
4159
|
+
let end = active + 1;
|
|
4160
|
+
let used = widthOf(names[active] ?? "");
|
|
4161
|
+
for (let grew = true; grew;) {
|
|
4162
|
+
grew = false;
|
|
4163
|
+
if (end < names.length && used + widthOf(names[end]) <= budget) {
|
|
4164
|
+
used += widthOf(names[end]);
|
|
4165
|
+
end += 1;
|
|
4166
|
+
grew = true;
|
|
4167
|
+
}
|
|
4168
|
+
if (start > 0 && used + widthOf(names[start - 1]) <= budget) {
|
|
4169
|
+
start -= 1;
|
|
4170
|
+
used += widthOf(names[start]);
|
|
4171
|
+
grew = true;
|
|
4172
|
+
}
|
|
3576
4173
|
}
|
|
3577
|
-
|
|
3578
|
-
|
|
3579
|
-
|
|
4174
|
+
return { names: names.slice(start, end), before: start > 0, after: end < names.length };
|
|
4175
|
+
}
|
|
4176
|
+
|
|
4177
|
+
private agentWindow(noticeRows = 0): { start: number; end: number } {
|
|
4178
|
+
const total = this.agents.length;
|
|
4179
|
+
if (!this.terminalRows) return { start: 0, end: total };
|
|
4180
|
+
const room = Math.max(3, Math.floor(this.terminalRows()) - 2 - ROUTING_TABLE_FIXED_ROWS - noticeRows);
|
|
4181
|
+
if (total <= room) return { start: 0, end: total };
|
|
4182
|
+
const visible = Math.max(1, room - 2);
|
|
4183
|
+
const focus = Math.max(0, this.cursor - 2);
|
|
4184
|
+
const start = Math.max(0, Math.min(focus - Math.floor(visible / 2), total - visible));
|
|
4185
|
+
return { start, end: start + visible };
|
|
4186
|
+
}
|
|
4187
|
+
|
|
4188
|
+
// -- cards -------------------------------------------------------------------
|
|
4189
|
+
|
|
4190
|
+
private cardFor(inner: number): { lines: string[]; width: number; top: number; left: number } {
|
|
4191
|
+
switch (this.overlay) {
|
|
4192
|
+
case "model":
|
|
4193
|
+
return this.modelCard(inner);
|
|
4194
|
+
case "effort":
|
|
4195
|
+
return this.effortCard(inner);
|
|
4196
|
+
case "menu":
|
|
4197
|
+
return this.menuCard(inner, this.activeTabColumn);
|
|
4198
|
+
case "new":
|
|
4199
|
+
return this.menuCard(inner, this.newColumn);
|
|
4200
|
+
default:
|
|
4201
|
+
return this.switchCard(inner);
|
|
3580
4202
|
}
|
|
3581
|
-
|
|
3582
|
-
|
|
3583
|
-
|
|
3584
|
-
|
|
4203
|
+
}
|
|
4204
|
+
|
|
4205
|
+
private modelCard(inner: number): { lines: string[]; width: number; top: number; left: number } {
|
|
4206
|
+
const width = Math.min(56, inner - 4);
|
|
4207
|
+
const content = width - 4;
|
|
4208
|
+
const lines: string[] = [];
|
|
4209
|
+
const region = (action: RoutingTableMouseAction) =>
|
|
4210
|
+
this.hitRegions.push({ y: lines.length, x0: 0, x1: content, action });
|
|
4211
|
+
lines.push(this.renderText("Model for ", "muted") + this.renderText(this.rowLabel(this.target), "title"));
|
|
4212
|
+
lines.push(this.renderText("⌕ ", "accent") + (this.query ? sanitizeTerminalText(this.query) : this.renderText("type to search", "dim")));
|
|
4213
|
+
lines.push(this.renderText("─".repeat(content), "border"));
|
|
4214
|
+
const options = this.modelOptions();
|
|
4215
|
+
const current = this.currentValue(this.target, "model") ?? (this.target === ROUTING_ALL_ROW ? undefined : this.inheritLabel(this.target));
|
|
4216
|
+
const rows = this.terminalRows ? Math.floor(this.terminalRows()) : options.length + ROUTING_MODEL_CARD_CHROME_ROWS;
|
|
4217
|
+
const visible = Math.max(3, Math.min(options.length, rows - ROUTING_MODEL_CARD_CHROME_ROWS));
|
|
4218
|
+
const start = Math.max(0, Math.min(this.modelCursor - Math.floor(visible / 2), options.length - visible));
|
|
4219
|
+
for (let index = start; index < Math.min(options.length, start + visible); index += 1) {
|
|
4220
|
+
const option = options[index] ?? "";
|
|
4221
|
+
const focused = index === this.modelCursor;
|
|
4222
|
+
const tone: PanelTone = focused ? "title" : option === ROUTING_CUSTOM_MODEL || option === this.inheritLabel(this.target) ? "muted" : "text";
|
|
4223
|
+
const mark = option === current ? " ✓" : "";
|
|
4224
|
+
const plain = `${focused ? "▸ " : " "}${option}${mark}`;
|
|
4225
|
+
region({ kind: "model-option", index });
|
|
4226
|
+
lines.push(
|
|
4227
|
+
this.hoverWrap(
|
|
4228
|
+
`model-option:${index}`,
|
|
4229
|
+
this.renderText(focused ? "▸ " : " ", "accent") + this.renderText(option, tone) + this.renderText(mark, "good"),
|
|
4230
|
+
plain,
|
|
4231
|
+
content,
|
|
4232
|
+
),
|
|
3585
4233
|
);
|
|
3586
|
-
return;
|
|
3587
4234
|
}
|
|
3588
|
-
|
|
3589
|
-
|
|
3590
|
-
|
|
4235
|
+
lines.push("");
|
|
4236
|
+
lines.push(this.renderText("↑↓ choose · enter pick · esc back", "dim"));
|
|
4237
|
+
return { lines, width, top: 4, left: Math.max(0, Math.floor((inner - width) / 2)) };
|
|
4238
|
+
}
|
|
4239
|
+
|
|
4240
|
+
private effortCard(inner: number): { lines: string[]; width: number; top: number; left: number } {
|
|
4241
|
+
// Provider review roles inherit Pi's persisted default, so their inherit
|
|
4242
|
+
// choice says so (gentle-pi#2271).
|
|
4243
|
+
const inheritLabel = this.target !== ROUTING_ALL_ROW && isProviderReviewRole(this.target)
|
|
4244
|
+
? routingDefaultLabel(this.target, "effort")
|
|
4245
|
+
: "inherit";
|
|
4246
|
+
const chips = this.effortOptions().map((option, index) => {
|
|
4247
|
+
const label = option === INHERIT_THINKING ? inheritLabel : option;
|
|
4248
|
+
const text = index === this.effortCursor ? `[${label}]` : ` ${label} `;
|
|
4249
|
+
return { index, text };
|
|
4250
|
+
});
|
|
4251
|
+
const oneRow = chips.reduce((sum, chip) => sum + chip.text.length + 1, 0) - 1;
|
|
4252
|
+
const width = Math.min(inner - 4, Math.max(44, oneRow + 4));
|
|
4253
|
+
const content = width - 4;
|
|
4254
|
+
// One row when it fits; otherwise the levels wrap onto a second row, so a
|
|
4255
|
+
// narrow terminal never cuts a level in half.
|
|
4256
|
+
const rows: Array<typeof chips> = [[]];
|
|
4257
|
+
let used = 0;
|
|
4258
|
+
for (const chip of chips) {
|
|
4259
|
+
const cost = chip.text.length + (rows[rows.length - 1].length > 0 ? 1 : 0);
|
|
4260
|
+
if (used + cost > content && rows[rows.length - 1].length > 0) {
|
|
4261
|
+
rows.push([]);
|
|
4262
|
+
used = 0;
|
|
4263
|
+
}
|
|
4264
|
+
rows[rows.length - 1].push(chip);
|
|
4265
|
+
used += chip.text.length + (rows[rows.length - 1].length > 1 ? 1 : 0);
|
|
4266
|
+
}
|
|
4267
|
+
const lines: string[] = [];
|
|
4268
|
+
lines.push(this.renderText("Effort for ", "muted") + this.renderText(this.rowLabel(this.target), "title"));
|
|
4269
|
+
lines.push(this.renderText(truncateToWidth(this.effortSource(), content, "…"), "dim"));
|
|
4270
|
+
for (const row of rows) {
|
|
4271
|
+
let column = 0;
|
|
4272
|
+
const drawn: string[] = [];
|
|
4273
|
+
for (const chip of row) {
|
|
4274
|
+
this.hitRegions.push({
|
|
4275
|
+
y: lines.length,
|
|
4276
|
+
x0: column,
|
|
4277
|
+
x1: column + chip.text.length,
|
|
4278
|
+
action: { kind: "effort-option", index: chip.index },
|
|
4279
|
+
});
|
|
4280
|
+
const focused = chip.index === this.effortCursor;
|
|
4281
|
+
drawn.push(this.hoverWrap(`effort-option:${chip.index}`, this.renderText(chip.text, focused ? "title" : "status"), chip.text));
|
|
4282
|
+
column += chip.text.length + 1;
|
|
4283
|
+
}
|
|
4284
|
+
lines.push(drawn.join(" "));
|
|
4285
|
+
}
|
|
4286
|
+
lines.push("");
|
|
4287
|
+
lines.push(this.renderText("←→ choose · enter pick · esc back", "dim"));
|
|
4288
|
+
return { lines, width, top: 6, left: Math.max(0, Math.floor((inner - width) / 2)) };
|
|
4289
|
+
}
|
|
4290
|
+
|
|
4291
|
+
/** One line under the effort card's title: whose levels these are. */
|
|
4292
|
+
private effortSource(): string {
|
|
4293
|
+
const supported = this.supportedLevels(this.target);
|
|
4294
|
+
if (this.target === ROUTING_ALL_ROW) {
|
|
4295
|
+
return supported ? "Levels every agent's model supports." : "Models unknown to the catalog: every level is shown.";
|
|
3591
4296
|
}
|
|
3592
|
-
|
|
3593
|
-
|
|
3594
|
-
|
|
3595
|
-
else this.applyThinkingSelection(selected);
|
|
3596
|
-
this.mode = "agents";
|
|
4297
|
+
const model = this.draft[this.target]?.model;
|
|
4298
|
+
if (model === undefined) return "Inherits its model: every level is shown.";
|
|
4299
|
+
return supported ? `Levels ${model} supports.` : `${model} is not in the catalog: every level is shown.`;
|
|
3597
4300
|
}
|
|
3598
4301
|
|
|
3599
|
-
private
|
|
4302
|
+
private menuCard(inner: number, anchor: number): { lines: string[]; width: number; top: number; left: number } {
|
|
4303
|
+
const items = this.menuItems();
|
|
4304
|
+
const width = Math.min(inner - 2, Math.max(28, ...items.map((item) => visibleWidth(item.label) + 8)));
|
|
4305
|
+
const content = width - 4;
|
|
4306
|
+
const lines: string[] = [];
|
|
4307
|
+
items.forEach((item, index) => {
|
|
4308
|
+
if (item.id === "delete") lines.push(this.renderText("─".repeat(content), "border"));
|
|
4309
|
+
const focused = index === this.menuCursor;
|
|
4310
|
+
const plain = `${focused ? "▸ " : " "}${item.label}`;
|
|
4311
|
+
this.hitRegions.push({ y: lines.length, x0: 0, x1: content, action: { kind: "menu-item", id: item.id } });
|
|
4312
|
+
lines.push(
|
|
4313
|
+
this.hoverWrap(
|
|
4314
|
+
`menu:${item.id}`,
|
|
4315
|
+
this.renderText(focused ? "▸ " : " ", "accent") + this.renderText(item.label, item.tone ?? (focused ? "title" : "text")),
|
|
4316
|
+
plain,
|
|
4317
|
+
content,
|
|
4318
|
+
),
|
|
4319
|
+
);
|
|
4320
|
+
});
|
|
4321
|
+
return { lines, width, top: 2, left: Math.max(0, Math.min(anchor, inner - width)) };
|
|
4322
|
+
}
|
|
4323
|
+
|
|
4324
|
+
private switchCard(inner: number): { lines: string[]; width: number; top: number; left: number } {
|
|
4325
|
+
const width = Math.min(inner - 4, 60);
|
|
4326
|
+
const changes = this.changeCount();
|
|
3600
4327
|
const lines: string[] = [];
|
|
3601
|
-
const line = (text = "", tone?: PanelTone) =>
|
|
3602
|
-
this.renderLine(text, width, tone);
|
|
3603
4328
|
lines.push(
|
|
3604
|
-
|
|
4329
|
+
this.renderText(sanitizeTerminalText(this.profileName), "title")
|
|
4330
|
+
+ this.renderText(` has ${changes} unsaved change${changes === 1 ? "" : "s"}.`, "text"),
|
|
3605
4331
|
);
|
|
3606
4332
|
lines.push("");
|
|
3607
|
-
|
|
3608
|
-
|
|
3609
|
-
|
|
3610
|
-
|
|
3611
|
-
|
|
3612
|
-
|
|
3613
|
-
|
|
3614
|
-
|
|
3615
|
-
|
|
3616
|
-
|
|
4333
|
+
let column = 0;
|
|
4334
|
+
let buttons = "";
|
|
4335
|
+
for (const id of ["switch-save", "switch-discard", "switch-stay"] as const) {
|
|
4336
|
+
const text = `[ ${ROUTING_TABLE_BUTTONS[id]} ]`;
|
|
4337
|
+
this.hitRegions.push({ y: lines.length, x0: column, x1: column + text.length, action: { kind: "button", button: id } });
|
|
4338
|
+
buttons += this.hoverWrap(`button:${id}`, this.renderText(text, id === "switch-save" ? "title" : id === "switch-stay" ? "dim" : "accent"), text) + " ";
|
|
4339
|
+
column += text.length + 2;
|
|
4340
|
+
}
|
|
4341
|
+
lines.push(buttons);
|
|
4342
|
+
lines.push(this.renderText("enter save and switch · d discard · esc stay", "dim"));
|
|
4343
|
+
return { lines, width, top: 6, left: Math.max(0, Math.floor((inner - width) / 2)) };
|
|
4344
|
+
}
|
|
4345
|
+
|
|
4346
|
+
/**
|
|
4347
|
+
* Draws a card over the dimmed table. Card regions were recorded relative to
|
|
4348
|
+
* the card content; they move into frame coordinates here: frame border and
|
|
4349
|
+
* "│ " before the body, the card's own border and "│ " before its content.
|
|
4350
|
+
*/
|
|
4351
|
+
private placeCard(
|
|
4352
|
+
table: string[],
|
|
4353
|
+
card: { lines: string[]; width: number; top: number; left: number },
|
|
4354
|
+
inner: number,
|
|
4355
|
+
): string[] {
|
|
4356
|
+
const regions = this.hitRegions;
|
|
4357
|
+
this.hitRegions = regions.map((region) => ({
|
|
4358
|
+
...region,
|
|
4359
|
+
y: region.y + card.top + 2,
|
|
4360
|
+
x0: 2 + card.left + 2 + region.x0,
|
|
4361
|
+
x1: 2 + card.left + 2 + region.x1,
|
|
4362
|
+
}));
|
|
4363
|
+
const content = card.width - 4;
|
|
4364
|
+
const rule = "─".repeat(card.width - 2);
|
|
4365
|
+
const drawn = [
|
|
4366
|
+
this.renderText(`╭${rule}╮`, "accent"),
|
|
4367
|
+
...card.lines.map((line) => `${this.renderText("│", "accent")} ${this.fit(line, content)} ${this.renderText("│", "accent")}`),
|
|
4368
|
+
this.renderText(`╰${rule}╯`, "accent"),
|
|
4369
|
+
];
|
|
4370
|
+
const body = [...table];
|
|
4371
|
+
const rows = Math.max(body.length, card.top + drawn.length);
|
|
4372
|
+
while (body.length < rows) body.push("");
|
|
4373
|
+
for (let index = 0; index < drawn.length; index += 1) {
|
|
4374
|
+
const target = card.top + index;
|
|
4375
|
+
const right = " ".repeat(Math.max(0, inner - card.left - card.width));
|
|
4376
|
+
body[target] = " ".repeat(card.left) + drawn[index] + right;
|
|
3617
4377
|
}
|
|
3618
|
-
|
|
3619
|
-
|
|
3620
|
-
|
|
4378
|
+
return body;
|
|
4379
|
+
}
|
|
4380
|
+
|
|
4381
|
+
// -- text helpers --------------------------------------------------------------
|
|
4382
|
+
|
|
4383
|
+
/**
|
|
4384
|
+
* The hover fill. It uses the theme's selection background, which reads on
|
|
4385
|
+
* every bundled theme; the pending-tool background it replaced is a shade
|
|
4386
|
+
* away from the terminal background on Gentleman-Cute and never showed.
|
|
4387
|
+
*/
|
|
4388
|
+
private hoverWrap(key: string, styled: string, plain: string, fillTo?: number): string {
|
|
4389
|
+
if (this.hovered !== key || !this.theme) return styled;
|
|
4390
|
+
const text = fillTo === undefined ? plain : plain + " ".repeat(Math.max(0, fillTo - visibleWidth(plain)));
|
|
4391
|
+
return this.theme.bg("selectedBg", this.theme.fg("text", sanitizeTerminalText(text)));
|
|
3621
4392
|
}
|
|
3622
4393
|
|
|
3623
|
-
private
|
|
3624
|
-
const
|
|
3625
|
-
|
|
3626
|
-
|
|
3627
|
-
|
|
3628
|
-
|
|
3629
|
-
|
|
3630
|
-
const
|
|
3631
|
-
|
|
3632
|
-
|
|
3633
|
-
|
|
3634
|
-
|
|
3635
|
-
|
|
3636
|
-
|
|
3637
|
-
|
|
3638
|
-
|
|
3639
|
-
|
|
3640
|
-
|
|
3641
|
-
|
|
3642
|
-
|
|
3643
|
-
|
|
3644
|
-
|
|
3645
|
-
|
|
3646
|
-
|
|
3647
|
-
const effort = this.draft[row]?.thinking ?? routingDefaultLabel(row, "effort");
|
|
3648
|
-
return `${this.renderText(sanitizeTerminalText(row).padEnd(20), "text")} ${this.renderText("model=", "muted")}${this.renderText(model, "status")}${this.renderText(
|
|
3649
|
-
", effort=",
|
|
3650
|
-
"muted",
|
|
3651
|
-
)}${this.renderText(effort, "status")}`;
|
|
3652
|
-
}
|
|
3653
|
-
}
|
|
3654
|
-
|
|
3655
|
-
function renderSddModelPanelForTesting(
|
|
3656
|
-
initialConfig: AgentModelConfig,
|
|
4394
|
+
private renderText(text: string, tone: PanelTone): string {
|
|
4395
|
+
const safe = sanitizeTerminalText(text);
|
|
4396
|
+
if (!this.theme) return safe;
|
|
4397
|
+
return this.theme.fg(PANEL_TONE_COLOR[tone] ?? "text", safe);
|
|
4398
|
+
}
|
|
4399
|
+
|
|
4400
|
+
private fit(line: string, width: number): string {
|
|
4401
|
+
const plain = stripAnsi(line);
|
|
4402
|
+
const visible = visibleWidth(plain);
|
|
4403
|
+
if (visible > width) return truncateToWidth(plain, Math.max(1, width), "…", true);
|
|
4404
|
+
return `${line}${" ".repeat(Math.max(0, width - visible))}`;
|
|
4405
|
+
}
|
|
4406
|
+
}
|
|
4407
|
+
|
|
4408
|
+
interface RoutingTableTestEvent {
|
|
4409
|
+
type: "move" | "click" | "wheel";
|
|
4410
|
+
x: number;
|
|
4411
|
+
y: number;
|
|
4412
|
+
button?: string;
|
|
4413
|
+
wheelDelta?: number;
|
|
4414
|
+
}
|
|
4415
|
+
|
|
4416
|
+
function renderRoutingTableForTesting(
|
|
4417
|
+
draft: AgentModelConfig,
|
|
3657
4418
|
modelOptions: string[],
|
|
3658
4419
|
agents: string[],
|
|
3659
4420
|
width: number,
|
|
3660
4421
|
theme?: Theme,
|
|
3661
4422
|
terminalRows?: number,
|
|
3662
4423
|
inputs: string[] = [],
|
|
4424
|
+
mouseEvents: RoutingTableTestEvent[] = [],
|
|
4425
|
+
results: RoutingTableResult[] = [],
|
|
4426
|
+
options: {
|
|
4427
|
+
file?: AgentProfilesFile;
|
|
4428
|
+
sessionBoundName?: string;
|
|
4429
|
+
pinWinner?: string;
|
|
4430
|
+
profileName?: string;
|
|
4431
|
+
feedback?: string;
|
|
4432
|
+
thinkingSupport?: Record<string, readonly ThinkingLevel[]>;
|
|
4433
|
+
notices?: string[];
|
|
4434
|
+
} = {},
|
|
3663
4435
|
): string[] {
|
|
3664
|
-
const panel = new
|
|
3665
|
-
|
|
4436
|
+
const panel = new RoutingTablePanel(
|
|
4437
|
+
draft,
|
|
3666
4438
|
modelOptions,
|
|
3667
4439
|
agents,
|
|
3668
|
-
() =>
|
|
4440
|
+
(result) => results.push(result),
|
|
3669
4441
|
theme,
|
|
3670
|
-
|
|
4442
|
+
options.profileName ?? "current",
|
|
3671
4443
|
terminalRows === undefined ? undefined : () => terminalRows,
|
|
4444
|
+
options,
|
|
3672
4445
|
);
|
|
3673
|
-
|
|
4446
|
+
panel.render(width);
|
|
4447
|
+
for (const data of inputs) {
|
|
4448
|
+
panel.handleInput(data);
|
|
4449
|
+
panel.render(width);
|
|
4450
|
+
}
|
|
4451
|
+
const send = (event: RoutingTableTestEvent, type: TuiMouseEvent["type"]) =>
|
|
4452
|
+
panel.handleMouse({
|
|
4453
|
+
type,
|
|
4454
|
+
x: event.x,
|
|
4455
|
+
y: event.y,
|
|
4456
|
+
button: event.button ?? (type === "move" ? "none" : "left"),
|
|
4457
|
+
wheelDelta: event.wheelDelta,
|
|
4458
|
+
screenX: event.x,
|
|
4459
|
+
screenY: event.y,
|
|
4460
|
+
width,
|
|
4461
|
+
height: terminalRows ?? 40,
|
|
4462
|
+
} as TuiMouseEvent);
|
|
4463
|
+
for (const event of mouseEvents) {
|
|
4464
|
+
// Real terminals send press then click; replay both so a test catches
|
|
4465
|
+
// any action that would fire twice.
|
|
4466
|
+
if (event.type === "click") send(event, "press");
|
|
4467
|
+
send(event, event.type);
|
|
4468
|
+
panel.render(width);
|
|
4469
|
+
}
|
|
3674
4470
|
return panel.render(width);
|
|
3675
4471
|
}
|
|
3676
4472
|
|
|
3677
|
-
|
|
4473
|
+
interface RoutingTablePanelRequest {
|
|
4474
|
+
name: string;
|
|
4475
|
+
config: AgentModelConfig;
|
|
4476
|
+
file: AgentProfilesFile;
|
|
4477
|
+
sessionBoundName?: string;
|
|
4478
|
+
pinWinner?: string;
|
|
4479
|
+
feedback?: string;
|
|
4480
|
+
notices?: string[];
|
|
4481
|
+
switchProfile?: RoutingProfileSwitcher;
|
|
4482
|
+
}
|
|
4483
|
+
|
|
4484
|
+
/**
|
|
4485
|
+
* What the models screen must not hide: a pin layer that does not resolve
|
|
4486
|
+
* (invalid file, or a name no profile defines) silently returns the repository
|
|
4487
|
+
* to the global routing, and a routing key no agent matches is silently ignored
|
|
4488
|
+
* at launch (issues #2196 and #2400).
|
|
4489
|
+
*/
|
|
4490
|
+
function routingScreenNotices(
|
|
3678
4491
|
ctx: ExtensionContext,
|
|
3679
|
-
|
|
3680
|
-
|
|
3681
|
-
|
|
4492
|
+
file: AgentProfilesFile,
|
|
4493
|
+
shownName: string,
|
|
4494
|
+
sessionBoundName: string | undefined,
|
|
4495
|
+
): string[] {
|
|
4496
|
+
const notices: string[] = [];
|
|
4497
|
+
const pins = evaluateProfilePin(readProfilePinStatus(ctx.cwd), file.profiles);
|
|
4498
|
+
for (const issue of pins.invalid) {
|
|
4499
|
+
notices.push(`The ${issue.source} pin file ${issue.path} is not a valid pin and is ignored. Fix or remove it.`);
|
|
4500
|
+
}
|
|
4501
|
+
for (const issue of pins.stale) {
|
|
4502
|
+
notices.push(`The ${issue.source} pin names "${issue.profile}", which no profile defines, so it is ignored (${issue.path}).`);
|
|
4503
|
+
}
|
|
4504
|
+
const roots = {
|
|
4505
|
+
cwd: ctx.sessionManager?.getCwd?.() ?? ctx.cwd,
|
|
4506
|
+
home: homedir(),
|
|
4507
|
+
agentHome: resolveGentlePiAgentHome(),
|
|
4508
|
+
};
|
|
4509
|
+
let discovered: Array<{ name: string }> = [];
|
|
4510
|
+
try {
|
|
4511
|
+
discovered = discoverAgents(roots).agents;
|
|
4512
|
+
} catch {
|
|
4513
|
+
return notices;
|
|
4514
|
+
}
|
|
4515
|
+
// gentle-pi#2271: provider review roles are routed by key and never have an
|
|
4516
|
+
// agent file, so they always count as matched.
|
|
4517
|
+
const known = [...PROVIDER_REVIEW_ROLES.map((name) => ({ name })), ...discovered];
|
|
4518
|
+
const profileKeys = Object.keys(normalizeModelConfig(file.profiles[shownName]) ?? {}).filter((key) => !isProfileOrchestratorKey(key));
|
|
4519
|
+
const unmatched = unmatchedProfileKeys(Object.fromEntries(profileKeys.map((key) => [key, { model: undefined, thinking: undefined }])), known);
|
|
4520
|
+
const ignored = unmatched.filter((entry) => entry.suggestion === undefined).map((entry) => entry.key);
|
|
4521
|
+
if (ignored.length > 0) {
|
|
4522
|
+
notices.push(`No agent matches ${ignored.map((key) => `"${key}"`).join(", ")} in this profile, so ${ignored.length === 1 ? "it is" : "they are"} ignored at launch.`);
|
|
4523
|
+
}
|
|
4524
|
+
for (const entry of unmatched) {
|
|
4525
|
+
if (entry.suggestion !== undefined) notices.push(`No agent is named "${entry.key}" in this profile; did you mean ${entry.suggestion}?`);
|
|
4526
|
+
}
|
|
4527
|
+
// Issue #2400: model_profiles keys in subagents.json that match nothing and
|
|
4528
|
+
// suggest nothing. A session binding or a winning pin replaces those launches'
|
|
4529
|
+
// routing, so the file's keys do not apply there.
|
|
4530
|
+
if (sessionBoundName === undefined && pinnedEffectiveModelConfig(ctx.cwd) === undefined) {
|
|
4531
|
+
const stray = unmatchedProfileKeys(loadAgentsConfig(roots).modelProfiles, discovered)
|
|
4532
|
+
.filter((entry) => entry.suggestion === undefined && !isProviderReviewRole(entry.key) && !profileKeys.includes(entry.key))
|
|
4533
|
+
.map((entry) => entry.key);
|
|
4534
|
+
if (stray.length > 0) {
|
|
4535
|
+
notices.push(`subagents.json routes ${stray.map((key) => `"${key}"`).join(", ")}, which no agent matches, so ${stray.length === 1 ? "it is" : "they are"} ignored.`);
|
|
4536
|
+
}
|
|
4537
|
+
}
|
|
4538
|
+
return notices;
|
|
4539
|
+
}
|
|
4540
|
+
|
|
4541
|
+
async function showRoutingTablePanel(
|
|
4542
|
+
ctx: ExtensionContext,
|
|
4543
|
+
request: RoutingTablePanelRequest,
|
|
4544
|
+
): Promise<RoutingTableResult> {
|
|
3682
4545
|
const modelOptions = await getPiModelOptions(ctx);
|
|
4546
|
+
const thinkingSupport = await getPiThinkingSupport(ctx);
|
|
3683
4547
|
const agents = modelAssignmentNames(ctx.cwd);
|
|
3684
|
-
return ctx.ui.custom<
|
|
3685
|
-
(tui, theme, _keybindings, done) =>
|
|
3686
|
-
|
|
3687
|
-
|
|
3688
|
-
|
|
4548
|
+
return ctx.ui.custom<RoutingTableResult>(
|
|
4549
|
+
(tui, theme, _keybindings, done) => {
|
|
4550
|
+
const panel = new RoutingTablePanel(
|
|
4551
|
+
request.config,
|
|
4552
|
+
modelOptions,
|
|
4553
|
+
agents,
|
|
4554
|
+
done,
|
|
4555
|
+
theme,
|
|
4556
|
+
request.name,
|
|
4557
|
+
() => Math.max(0, tui.terminal.rows),
|
|
4558
|
+
{
|
|
4559
|
+
file: request.file,
|
|
4560
|
+
sessionBoundName: request.sessionBoundName,
|
|
4561
|
+
pinWinner: request.pinWinner,
|
|
4562
|
+
feedback: request.feedback,
|
|
4563
|
+
thinkingSupport,
|
|
4564
|
+
notices: request.notices,
|
|
4565
|
+
switchProfile: request.switchProfile,
|
|
4566
|
+
},
|
|
4567
|
+
);
|
|
4568
|
+
const container = createNativeFullscreenInteraction({
|
|
4569
|
+
keyboardTarget: panel,
|
|
4570
|
+
requestRender: () => tui.requestRender(),
|
|
4571
|
+
});
|
|
4572
|
+
container.addChild(panel);
|
|
4573
|
+
return container;
|
|
4574
|
+
},
|
|
3689
4575
|
{
|
|
3690
4576
|
overlay: true,
|
|
3691
|
-
// Same fullscreen dimensions as the `/gentle:profiles` panel.
|
|
3692
4577
|
overlayOptions: {
|
|
3693
4578
|
anchor: "center",
|
|
3694
4579
|
width: "100%",
|
|
@@ -3699,148 +4584,74 @@ async function showSddModelPanel(
|
|
|
3699
4584
|
);
|
|
3700
4585
|
}
|
|
3701
4586
|
|
|
3702
|
-
|
|
4587
|
+
/**
|
|
4588
|
+
* Bare `/gentle:profiles` opens the models screen on the profile this
|
|
4589
|
+
* repository resolves, after the routing preflight: legacy project overrides
|
|
4590
|
+
* migrate and a pinned repository is named before any edit. An unreadable
|
|
4591
|
+
* saved routing file does not block the screen; Make default discloses it.
|
|
4592
|
+
*/
|
|
4593
|
+
async function openModelsScreen(ctx: ExtensionContext, live: LiveSession): Promise<void> {
|
|
3703
4594
|
migrateLegacyProjectModelOverrides(ctx.cwd);
|
|
3704
4595
|
// A pinned repository resolves its subagent routing from the profile at launch,
|
|
3705
|
-
// so global routing written
|
|
3706
|
-
// the edits, not after them, is the difference between a note and a surprise.
|
|
4596
|
+
// so global routing written elsewhere will not reach its subagents. Saying it
|
|
4597
|
+
// before the edits, not after them, is the difference between a note and a surprise.
|
|
3707
4598
|
const pinNote = profilePinScopeNote(ctx.cwd);
|
|
3708
4599
|
if (pinNote) ctx.ui.notify(pinNote, "info");
|
|
3709
|
-
const
|
|
3710
|
-
|
|
3711
|
-
|
|
3712
|
-
|
|
3713
|
-
|
|
3714
|
-
|
|
3715
|
-
|
|
3716
|
-
|
|
3717
|
-
|
|
3718
|
-
|
|
4600
|
+
const read = readProfilesFileResult(profilesFilePath(gentleAiConfigHome()));
|
|
4601
|
+
const target = read.status === "valid"
|
|
4602
|
+
? resolveCurrentProfileTarget(ctx.cwd, read)
|
|
4603
|
+
: read.status === "missing"
|
|
4604
|
+
? ({ name: "current", source: "active" } as CurrentProfileTarget)
|
|
4605
|
+
: undefined;
|
|
4606
|
+
await openProfilesPanelLoop(ctx, live, target?.name);
|
|
4607
|
+
}
|
|
4608
|
+
|
|
4609
|
+
/** `/gentle:profiles export-routing`: write the saved global routing to models.export.json. */
|
|
4610
|
+
async function exportRoutingCommand(ctx: ExtensionContext): Promise<void> {
|
|
4611
|
+
try {
|
|
4612
|
+
const count = await exportSavedModelConfig(ctx);
|
|
4613
|
+
ctx.ui.notify(`el Gentleman exported ${count} saved model routing entr${count === 1 ? "y" : "ies"} to ${modelExportPath(ctx.cwd)}.`, "info");
|
|
4614
|
+
} catch (error) {
|
|
4615
|
+
ctx.ui.notify(`Model routing export failed: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
3719
4616
|
}
|
|
3720
|
-
|
|
3721
|
-
|
|
3722
|
-
|
|
3723
|
-
|
|
3724
|
-
|
|
3725
|
-
if (
|
|
3726
|
-
|
|
3727
|
-
|
|
3728
|
-
} else {
|
|
3729
|
-
config = profileSnapshotFrom(config, liveOrchestratorSnapshot(ctx, pi) ?? readOrchestratorSettings(orchestratorSettingsPath()));
|
|
4617
|
+
}
|
|
4618
|
+
|
|
4619
|
+
/** `/gentle:profiles restore-routing`: replace the saved global routing with models.export.json, then apply it. */
|
|
4620
|
+
async function restoreModelExport(ctx: ExtensionContext): Promise<void> {
|
|
4621
|
+
const restored = await readModelExport(ctx);
|
|
4622
|
+
if (!restored) {
|
|
4623
|
+
ctx.ui.notify(`Model routing restore failed: ${modelExportPath(ctx.cwd)} is missing or invalid.`, "warning");
|
|
4624
|
+
return;
|
|
3730
4625
|
}
|
|
3731
|
-
const
|
|
3732
|
-
|
|
3733
|
-
|
|
3734
|
-
|
|
3735
|
-
|
|
3736
|
-
|
|
3737
|
-
|
|
3738
|
-
|
|
3739
|
-
|
|
3740
|
-
|
|
3741
|
-
}
|
|
3742
|
-
result = await showSddModelPanel(ctx, config, profileLabel);
|
|
3743
|
-
continue;
|
|
3744
|
-
}
|
|
3745
|
-
if (result.type === "restore") {
|
|
3746
|
-
const restored = await readModelExport(ctx);
|
|
3747
|
-
if (!restored) {
|
|
3748
|
-
ctx.ui.notify(`Model routing restore failed: ${modelExportPath(ctx.cwd)} is missing or invalid.`, "warning");
|
|
3749
|
-
result = await showSddModelPanel(ctx, config, profileLabel);
|
|
3750
|
-
continue;
|
|
3751
|
-
}
|
|
3752
|
-
const approved = await ctx.ui.confirm("Restore saved model routing?", `Replace ${modelConfigPath(ctx.cwd)} with ${modelExportPath(ctx.cwd)}`);
|
|
3753
|
-
if (approved) {
|
|
3754
|
-
try {
|
|
3755
|
-
await writeModelConfigAsync(ctx.cwd, restored);
|
|
3756
|
-
} catch (error) {
|
|
3757
|
-
ctx.ui.notify(`Model routing restore failed before writing config: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
3758
|
-
result = await showSddModelPanel(ctx, config, profileLabel);
|
|
3759
|
-
continue;
|
|
3760
|
-
}
|
|
3761
|
-
const orchestratorChoice = config[PROFILE_ORCHESTRATOR_KEY];
|
|
3762
|
-
config = cloneModelConfig(restored);
|
|
3763
|
-
delete config[PROFILE_ORCHESTRATOR_KEY];
|
|
3764
|
-
if (orchestratorChoice) config[PROFILE_ORCHESTRATOR_KEY] = orchestratorChoice;
|
|
3765
|
-
try {
|
|
3766
|
-
const applyResult = await applyModelConfigAsync(ctx.cwd, restored);
|
|
3767
|
-
ctx.ui.notify([
|
|
3768
|
-
"el Gentleman restored global model config.",
|
|
3769
|
-
`Import: ${modelExportPath(ctx.cwd)}`,
|
|
3770
|
-
`Global config: ${modelConfigPath(ctx.cwd)}`,
|
|
3771
|
-
`Agents updated: ${applyResult.updated}`,
|
|
3772
|
-
].join("\n"), "info");
|
|
3773
|
-
} catch (error) {
|
|
3774
|
-
ctx.ui.notify([
|
|
3775
|
-
"el Gentleman restored global model config, but applying it to agents failed.",
|
|
3776
|
-
`Global config: ${modelConfigPath(ctx.cwd)}`,
|
|
3777
|
-
`Apply error: ${error instanceof Error ? error.message : String(error)}`,
|
|
3778
|
-
].join("\n"), "warning");
|
|
3779
|
-
}
|
|
3780
|
-
}
|
|
3781
|
-
result = await showSddModelPanel(ctx, config, profileLabel);
|
|
3782
|
-
continue;
|
|
3783
|
-
}
|
|
3784
|
-
const current =
|
|
3785
|
-
result.agent === "all"
|
|
3786
|
-
? "inherit"
|
|
3787
|
-
: (config[result.agent]?.model ?? "inherit");
|
|
3788
|
-
const custom = await ctx.ui.input(
|
|
3789
|
-
`${result.agent === "all" ? "all agents" : sanitizeTerminalText(result.agent)} custom model id`,
|
|
3790
|
-
current === "inherit" ? "provider/model" : sanitizeTerminalText(current),
|
|
3791
|
-
);
|
|
3792
|
-
if (custom === undefined) return;
|
|
3793
|
-
const trimmed = custom.trim();
|
|
3794
|
-
if (trimmed.length > 0) {
|
|
3795
|
-
const model = normalizeModelId(trimmed);
|
|
3796
|
-
if (!model) {
|
|
3797
|
-
ctx.ui.notify(
|
|
3798
|
-
"Custom model id must be a single-line provider/model identifier using letters, numbers, '.', '-', '_', '~', ':', '@', '/', '+', '%' only.",
|
|
3799
|
-
"warning",
|
|
3800
|
-
);
|
|
3801
|
-
result = await showSddModelPanel(ctx, config, profileLabel);
|
|
3802
|
-
continue;
|
|
3803
|
-
}
|
|
3804
|
-
if (result.agent === "all") {
|
|
3805
|
-
const next: AgentModelConfig = { ...config };
|
|
3806
|
-
for (const name of modelAssignmentNames(ctx.cwd)) {
|
|
3807
|
-
next[name] = {
|
|
3808
|
-
...(next[name] ?? {}),
|
|
3809
|
-
model,
|
|
3810
|
-
};
|
|
3811
|
-
}
|
|
3812
|
-
config = next;
|
|
3813
|
-
} else {
|
|
3814
|
-
config = {
|
|
3815
|
-
...config,
|
|
3816
|
-
[result.agent]: {
|
|
3817
|
-
...(config[result.agent] ?? {}),
|
|
3818
|
-
model,
|
|
3819
|
-
},
|
|
3820
|
-
};
|
|
3821
|
-
}
|
|
3822
|
-
}
|
|
3823
|
-
result = await showSddModelPanel(ctx, config, profileLabel);
|
|
4626
|
+
const approved = await ctx.ui.confirm(
|
|
4627
|
+
"Restore saved model routing?",
|
|
4628
|
+
`Replace ${modelConfigPath(ctx.cwd)} with ${modelExportPath(ctx.cwd)}`,
|
|
4629
|
+
);
|
|
4630
|
+
if (!approved) return;
|
|
4631
|
+
try {
|
|
4632
|
+
await writeModelConfigAsync(ctx.cwd, restored);
|
|
4633
|
+
} catch (error) {
|
|
4634
|
+
ctx.ui.notify(`Model routing restore failed before writing config: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
4635
|
+
return;
|
|
3824
4636
|
}
|
|
3825
|
-
|
|
3826
|
-
|
|
3827
|
-
|
|
3828
|
-
|
|
3829
|
-
|
|
3830
|
-
const applyResult = await applyModelConfigAsync(ctx.cwd, routing);
|
|
3831
|
-
ctx.ui.notify(
|
|
3832
|
-
[
|
|
3833
|
-
"el Gentleman global model config saved.",
|
|
4637
|
+
try {
|
|
4638
|
+
const applyResult = await applyModelConfigAsync(ctx.cwd, restored);
|
|
4639
|
+
ctx.ui.notify([
|
|
4640
|
+
"el Gentleman restored global model config.",
|
|
4641
|
+
`Import: ${modelExportPath(ctx.cwd)}`,
|
|
3834
4642
|
`Global config: ${modelConfigPath(ctx.cwd)}`,
|
|
3835
4643
|
`Agents updated: ${applyResult.updated}`,
|
|
3836
|
-
|
|
3837
|
-
|
|
3838
|
-
|
|
3839
|
-
|
|
3840
|
-
|
|
4644
|
+
].join("\n"), "info");
|
|
4645
|
+
} catch (error) {
|
|
4646
|
+
ctx.ui.notify([
|
|
4647
|
+
"el Gentleman restored global model config, but applying it to agents failed.",
|
|
4648
|
+
`Global config: ${modelConfigPath(ctx.cwd)}`,
|
|
4649
|
+
`Apply error: ${error instanceof Error ? error.message : String(error)}`,
|
|
4650
|
+
].join("\n"), "warning");
|
|
4651
|
+
}
|
|
3841
4652
|
}
|
|
3842
4653
|
|
|
3843
|
-
/** The profile
|
|
4654
|
+
/** The profile bare `/gentle:profiles` preselects in the unified panel, and why it is that one. */
|
|
3844
4655
|
interface CurrentProfileTarget {
|
|
3845
4656
|
name: string;
|
|
3846
4657
|
source: "pinned" | "active";
|
|
@@ -3848,99 +4659,21 @@ interface CurrentProfileTarget {
|
|
|
3848
4659
|
|
|
3849
4660
|
/**
|
|
3850
4661
|
* A pinned repository launches with its pinned profile, so that profile is the
|
|
3851
|
-
* one worth updating from here; anywhere else the globally active profile is.
|
|
3852
|
-
* Neither existing yields no target rather than a guess.
|
|
3853
|
-
*/
|
|
3854
|
-
function resolveCurrentProfileTarget(
|
|
3855
|
-
cwd: string,
|
|
3856
|
-
store: ProfilesFileReadResult = readProfilesFileResult(profilesFilePath(gentleAiConfigHome())),
|
|
3857
|
-
): CurrentProfileTarget | undefined {
|
|
3858
|
-
const pin = resolveProfilePin({ cwd, configHome: gentleAiConfigHome() });
|
|
3859
|
-
if (pin) return { name: pin.profile, source: "pinned" };
|
|
3860
|
-
if (store.status !== "valid" || store.file.active === undefined) return undefined;
|
|
3861
|
-
if (!hasOwnProfile(store.file.profiles, store.file.active)) return undefined;
|
|
3862
|
-
return { name: store.file.active, source: "active" };
|
|
3863
|
-
}
|
|
3864
|
-
|
|
3865
|
-
function describeCurrentProfileTarget(target: CurrentProfileTarget | undefined): string {
|
|
3866
|
-
if (!target) return "none";
|
|
3867
|
-
return target.source === "pinned" ? `${target.name} (pinned)` : target.name;
|
|
3868
|
-
}
|
|
3869
|
-
|
|
3870
|
-
/**
|
|
3871
|
-
* The second half of `u` captures saved global agent routing (never the pin)
|
|
3872
|
-
* and the explicit panel choice, including key absence. The global save has
|
|
3873
|
-
* already been reported, so every outcome here speaks only about the profile.
|
|
3874
|
-
*/
|
|
3875
|
-
function updateCurrentProfileFromSavedRouting(ctx: ExtensionContext, orchestrator: AgentRoutingEntry | undefined): void {
|
|
3876
|
-
const path = profilesFilePath(gentleAiConfigHome());
|
|
3877
|
-
const read = readProfilesFileResult(path);
|
|
3878
|
-
if (read.status === "invalid") {
|
|
3879
|
-
ctx.ui.notify(
|
|
3880
|
-
`el Gentleman saved the global routing, but cannot update a profile because ${sanitizeTerminalText(path)} is invalid JSON or not a profiles file. Fix or remove the file, then run /gentle:profiles again.`,
|
|
3881
|
-
"warning",
|
|
3882
|
-
);
|
|
3883
|
-
return;
|
|
3884
|
-
}
|
|
3885
|
-
const snapshot = cloneModelConfig(readGlobalEffectiveModelConfig(ctx.cwd));
|
|
3886
|
-
delete snapshot[PROFILE_ORCHESTRATOR_KEY];
|
|
3887
|
-
if (orchestrator?.model) snapshot[PROFILE_ORCHESTRATOR_KEY] = { ...orchestrator };
|
|
3888
|
-
if (read.status === "missing") {
|
|
3889
|
-
// The same seed `/gentle:profiles` performs on its first open, so pressing
|
|
3890
|
-
// `u` before ever opening that panel lands on the same "current" profile.
|
|
3891
|
-
try {
|
|
3892
|
-
writeProfilesFileSync(path, bootstrapProfilesFile(snapshot));
|
|
3893
|
-
} catch (error) {
|
|
3894
|
-
ctx.ui.notify(
|
|
3895
|
-
`el Gentleman saved the global routing, but could not create ${sanitizeTerminalText(path)}: ${profilesErrorMessage(error)}`,
|
|
3896
|
-
"warning",
|
|
3897
|
-
);
|
|
3898
|
-
return;
|
|
3899
|
-
}
|
|
3900
|
-
ctx.ui.notify(`el Gentleman seeded the "current" profile in ${sanitizeTerminalText(path)} from the routing just saved.`, "info");
|
|
3901
|
-
return;
|
|
3902
|
-
}
|
|
3903
|
-
reportProfilesDrops(ctx, path, read.drops);
|
|
3904
|
-
const target = resolveCurrentProfileTarget(ctx.cwd, read);
|
|
3905
|
-
if (!target) {
|
|
3906
|
-
ctx.ui.notify(
|
|
3907
|
-
"el Gentleman saved the global routing, but no profile is current: none is active and this repository pins none. Apply or create one with /gentle:profiles, then press u here or s there.",
|
|
3908
|
-
"warning",
|
|
3909
|
-
);
|
|
3910
|
-
return;
|
|
3911
|
-
}
|
|
3912
|
-
try {
|
|
3913
|
-
writeProfilesFileSync(path, updateProfile(read.file, target.name, snapshot));
|
|
3914
|
-
} catch (error) {
|
|
3915
|
-
ctx.ui.notify(
|
|
3916
|
-
`el Gentleman saved the global routing, but profile "${target.name}" was not updated: ${profilesErrorMessage(error)}`,
|
|
3917
|
-
"warning",
|
|
3918
|
-
);
|
|
3919
|
-
return;
|
|
3920
|
-
}
|
|
3921
|
-
ctx.ui.notify(
|
|
3922
|
-
[
|
|
3923
|
-
`el Gentleman: Profile "${target.name}" updated from the routing just saved (${target.source === "pinned" ? "this repository's pinned profile" : "the active profile"}).`,
|
|
3924
|
-
`Profiles store: ${sanitizeTerminalText(path)}`,
|
|
3925
|
-
].join("\n"),
|
|
3926
|
-
"info",
|
|
3927
|
-
);
|
|
4662
|
+
* one worth updating from here; anywhere else the globally active profile is.
|
|
4663
|
+
* Neither existing yields no target rather than a guess.
|
|
4664
|
+
*/
|
|
4665
|
+
function resolveCurrentProfileTarget(
|
|
4666
|
+
cwd: string,
|
|
4667
|
+
store: ProfilesFileReadResult = readProfilesFileResult(profilesFilePath(gentleAiConfigHome())),
|
|
4668
|
+
): CurrentProfileTarget | undefined {
|
|
4669
|
+
const pin = resolveProfilePin({ cwd, configHome: gentleAiConfigHome() });
|
|
4670
|
+
if (pin) return { name: pin.profile, source: "pinned" };
|
|
4671
|
+
if (store.status !== "valid" || store.file.active === undefined) return undefined;
|
|
4672
|
+
if (!hasOwnProfile(store.file.profiles, store.file.active)) return undefined;
|
|
4673
|
+
return { name: store.file.active, source: "active" };
|
|
3928
4674
|
}
|
|
3929
4675
|
|
|
3930
|
-
type ProfilesPanelResult =
|
|
3931
|
-
| { type: "apply"; name: string }
|
|
3932
|
-
| { type: "apply-global"; name: string }
|
|
3933
|
-
| { type: "create" }
|
|
3934
|
-
| { type: "update"; name: string }
|
|
3935
|
-
| { type: "duplicate"; name: string }
|
|
3936
|
-
| { type: "rename"; name: string }
|
|
3937
|
-
| { type: "delete"; name: string }
|
|
3938
|
-
| { type: "export"; name: string }
|
|
3939
|
-
| { type: "pin"; source: ProfilePinSource; name: string }
|
|
3940
|
-
| { type: "import" }
|
|
3941
|
-
| { type: "close" };
|
|
3942
4676
|
|
|
3943
|
-
type ProfilesSnapshotHandler = (name: string) => AgentProfilesFile;
|
|
3944
4677
|
|
|
3945
4678
|
/**
|
|
3946
4679
|
* What a panel action hands the reopened panel: a one-line status for the footer,
|
|
@@ -3952,10 +4685,6 @@ interface ProfilesPanelReport {
|
|
|
3952
4685
|
selectedName?: string;
|
|
3953
4686
|
}
|
|
3954
4687
|
|
|
3955
|
-
const PROFILES_PANEL_MIN_BODY_ROWS = 6;
|
|
3956
|
-
|
|
3957
|
-
type ProfilesPanelPointerLayout = AgentsViewLayout & { listTop: number };
|
|
3958
|
-
|
|
3959
4688
|
function hasOwnProfile(profiles: Record<string, unknown>, name: string): boolean {
|
|
3960
4689
|
return Object.prototype.hasOwnProperty.call(profiles, name);
|
|
3961
4690
|
}
|
|
@@ -3987,8 +4716,8 @@ function profilePinStateLabel(
|
|
|
3987
4716
|
* Every line the panel needs to tell a pinned repository the truth: the winning
|
|
3988
4717
|
* layer and its exact file, an explicit warning for a file that is not a pin named
|
|
3989
4718
|
* by path (kept separate from a pin naming a profile this store no longer defines),
|
|
3990
|
-
* and one sentence that a winning pin outranks the
|
|
3991
|
-
*
|
|
4719
|
+
* and one sentence that a winning pin outranks the globally active profile and every
|
|
4720
|
+
* global routing write.
|
|
3992
4721
|
*/
|
|
3993
4722
|
function profilePinDetailLines(
|
|
3994
4723
|
status: ProfilePinStatus | undefined,
|
|
@@ -4005,7 +4734,7 @@ function profilePinDetailLines(
|
|
|
4005
4734
|
lines.push(` the ${issue.source} pin names "${issue.profile}", which this store does not define; ignoring it (${issue.path}).`);
|
|
4006
4735
|
}
|
|
4007
4736
|
if (evaluation.winner) {
|
|
4008
|
-
lines.push(" This repository's subagent routing comes from the pin; the globally active profile and
|
|
4737
|
+
lines.push(" This repository's subagent routing comes from the pin; the globally active profile and global routing writes do not apply here.");
|
|
4009
4738
|
}
|
|
4010
4739
|
return lines;
|
|
4011
4740
|
}
|
|
@@ -4044,15 +4773,15 @@ function followRenamedPin(
|
|
|
4044
4773
|
|
|
4045
4774
|
/**
|
|
4046
4775
|
* One sentence naming the profile this repository resolves, for the commands whose
|
|
4047
|
-
* writes a pin outranks:
|
|
4048
|
-
*
|
|
4776
|
+
* writes a pin outranks: global routing applied from the unified panel does not
|
|
4777
|
+
* reach a pinned repository's subagents. The launch resolver decides, so the note
|
|
4049
4778
|
* names exactly what a launch would use.
|
|
4050
4779
|
*/
|
|
4051
4780
|
function profilePinScopeNote(cwd: string): string | undefined {
|
|
4052
4781
|
const resolution = resolveProfilePin({ cwd, configHome: gentleAiConfigHome() });
|
|
4053
4782
|
if (!resolution) return undefined;
|
|
4054
4783
|
return sanitizeTerminalText(
|
|
4055
|
-
`This repository pins profile "${resolution.profile}" (${resolution.source} pin at ${resolution.path}), so its subagent launches resolve that profile instead of the global routing. Change or remove
|
|
4784
|
+
`This repository pins profile "${resolution.profile}" (${resolution.source} pin at ${resolution.path}), so its subagent launches resolve that profile instead of the global routing. Change or remove it in /gentle:profiles (profile menu ▾ › Use in this repo), or with /gentle:profiles publish <name> for the committed declaration.`,
|
|
4056
4785
|
);
|
|
4057
4786
|
}
|
|
4058
4787
|
|
|
@@ -4088,476 +4817,6 @@ export function repoDeclarationSharing(root: string, path: string): string {
|
|
|
4088
4817
|
}
|
|
4089
4818
|
}
|
|
4090
4819
|
|
|
4091
|
-
/** Keep a detail-pane scroll offset inside the bounds of its own content. */
|
|
4092
|
-
function clampDetailScroll(offset: number, lineCount: number, bodyRows: number): number {
|
|
4093
|
-
return Math.max(0, Math.min(offset, Math.max(0, lineCount - bodyRows)));
|
|
4094
|
-
}
|
|
4095
|
-
|
|
4096
|
-
// Left profile list, right detail panes. Rendering and pointer routing share
|
|
4097
|
-
// one measured layout; the list rows keep native keyboard, hover, press,
|
|
4098
|
-
// click, and wheel handling through NativeChoiceList. The frame takes the full
|
|
4099
|
-
// overlay, so its height follows the terminal rows the same way AgentsView does.
|
|
4100
|
-
class ProfilesPanel implements OverlayComponent {
|
|
4101
|
-
private completed = false;
|
|
4102
|
-
private pointerLayout: ProfilesPanelPointerLayout | undefined;
|
|
4103
|
-
private detailScroll = 0;
|
|
4104
|
-
private lastDetailLineCount = 0;
|
|
4105
|
-
private lastDetailRows = 0;
|
|
4106
|
-
private lastSelectedId: string | undefined;
|
|
4107
|
-
private feedback: string | undefined;
|
|
4108
|
-
readonly list: NativeChoiceList<ProfileListItem>;
|
|
4109
|
-
private file: AgentProfilesFile;
|
|
4110
|
-
private readonly currentConfig: AgentModelConfig;
|
|
4111
|
-
private readonly done: (result: ProfilesPanelResult) => void;
|
|
4112
|
-
private readonly saveSnapshot: ProfilesSnapshotHandler;
|
|
4113
|
-
private readonly requestRender: () => void;
|
|
4114
|
-
private readonly listItems: ProfileListItem[];
|
|
4115
|
-
private readonly theme: Theme | undefined;
|
|
4116
|
-
private readonly rows: () => number;
|
|
4117
|
-
private readonly orchestratorSettings: OrchestratorSettingsReadResult;
|
|
4118
|
-
// Actions reopen the panel, refreshing this snapshot without disk reads during rendering.
|
|
4119
|
-
private readonly pinStatus: ProfilePinStatus | undefined;
|
|
4120
|
-
private readonly sessionBoundName: string | undefined;
|
|
4121
|
-
private readonly discoveredAgentNames: string[];
|
|
4122
|
-
private readonly unmatchedKeys: string[];
|
|
4123
|
-
private readonly liveOrchestrator: (() => AgentRoutingEntry | undefined) | undefined;
|
|
4124
|
-
|
|
4125
|
-
constructor(
|
|
4126
|
-
file: AgentProfilesFile,
|
|
4127
|
-
currentConfig: AgentModelConfig,
|
|
4128
|
-
done: (result: ProfilesPanelResult) => void,
|
|
4129
|
-
keybindings: KeybindingsManager | undefined,
|
|
4130
|
-
theme: Theme | undefined,
|
|
4131
|
-
selectedName: string | undefined,
|
|
4132
|
-
rows: () => number,
|
|
4133
|
-
orchestratorSettings: OrchestratorSettingsReadResult,
|
|
4134
|
-
saveSnapshot: ProfilesSnapshotHandler,
|
|
4135
|
-
requestRender: () => void,
|
|
4136
|
-
pinStatus: () => ProfilePinStatus | undefined,
|
|
4137
|
-
sessionBound: () => string | undefined,
|
|
4138
|
-
discoveredAgentNames: string[],
|
|
4139
|
-
unmatchedKeys: string[],
|
|
4140
|
-
liveOrchestrator?: () => AgentRoutingEntry | undefined,
|
|
4141
|
-
feedback?: string,
|
|
4142
|
-
) {
|
|
4143
|
-
this.file = file;
|
|
4144
|
-
this.feedback = feedback;
|
|
4145
|
-
this.currentConfig = currentConfig;
|
|
4146
|
-
this.done = done;
|
|
4147
|
-
this.saveSnapshot = saveSnapshot;
|
|
4148
|
-
this.requestRender = requestRender;
|
|
4149
|
-
this.theme = theme;
|
|
4150
|
-
this.rows = rows;
|
|
4151
|
-
this.orchestratorSettings = orchestratorSettings;
|
|
4152
|
-
this.pinStatus = pinStatus();
|
|
4153
|
-
this.sessionBoundName = sessionBound();
|
|
4154
|
-
this.discoveredAgentNames = discoveredAgentNames;
|
|
4155
|
-
this.unmatchedKeys = unmatchedKeys;
|
|
4156
|
-
this.liveOrchestrator = liveOrchestrator;
|
|
4157
|
-
const items = buildProfileListItems(file, evaluateProfilePin(this.pinStatus, file.profiles).winner?.profile, this.sessionBoundName);
|
|
4158
|
-
this.listItems = items;
|
|
4159
|
-
this.list = new NativeChoiceList<ProfileListItem>(
|
|
4160
|
-
items,
|
|
4161
|
-
{
|
|
4162
|
-
selectedPrefix: (text) => this.renderText(text, "accent"),
|
|
4163
|
-
selectedText: (text) => this.renderText(text, "accent"),
|
|
4164
|
-
description: (text) => this.renderText(text, "muted"),
|
|
4165
|
-
hoverBackground: (text) => (this.theme ? this.theme.bg("toolPendingBg", text) : text),
|
|
4166
|
-
},
|
|
4167
|
-
keybindings,
|
|
4168
|
-
);
|
|
4169
|
-
this.list.onSelect = (item) => this.finish({ type: "apply", name: item.id });
|
|
4170
|
-
this.list.onCancel = () => this.finish({ type: "close" });
|
|
4171
|
-
const selected = selectedName ? items.findIndex((item) => item.id === selectedName) : -1;
|
|
4172
|
-
if (selected >= 0) this.list.setSelectedIndex(selected);
|
|
4173
|
-
}
|
|
4174
|
-
|
|
4175
|
-
invalidate(): void {
|
|
4176
|
-
this.pointerLayout = undefined;
|
|
4177
|
-
this.list.invalidate();
|
|
4178
|
-
}
|
|
4179
|
-
|
|
4180
|
-
handleInput(data: string): void {
|
|
4181
|
-
if (isKeyRelease(data)) return;
|
|
4182
|
-
if (matchesKey(data, "ctrl+c") || matchesKey(data, "escape")) {
|
|
4183
|
-
this.finish({ type: "close" });
|
|
4184
|
-
return;
|
|
4185
|
-
}
|
|
4186
|
-
const name = this.list.getSelectedItem()?.id;
|
|
4187
|
-
if (matchesKey(data, Key.pageDown) || matchesKey(data, Key.ctrl("j"))) {
|
|
4188
|
-
this.scrollDetail(this.pageRows());
|
|
4189
|
-
return;
|
|
4190
|
-
}
|
|
4191
|
-
if (matchesKey(data, Key.pageUp) || matchesKey(data, Key.ctrl("k"))) {
|
|
4192
|
-
this.scrollDetail(-this.pageRows());
|
|
4193
|
-
return;
|
|
4194
|
-
}
|
|
4195
|
-
// Agents-view line scroll: j/k move the routing one line at a time. The
|
|
4196
|
-
// arrow keys stay with the profile list, exactly as the letters below stay
|
|
4197
|
-
// profile actions.
|
|
4198
|
-
if (data === "j") {
|
|
4199
|
-
this.scrollDetail(1);
|
|
4200
|
-
return;
|
|
4201
|
-
}
|
|
4202
|
-
if (data === "k") {
|
|
4203
|
-
this.scrollDetail(-1);
|
|
4204
|
-
return;
|
|
4205
|
-
}
|
|
4206
|
-
if (data === "c") return this.finish({ type: "create" });
|
|
4207
|
-
if (data === "i") return this.finish({ type: "import" });
|
|
4208
|
-
if (!name) return this.list.handleInput(data);
|
|
4209
|
-
if (data === "s") {
|
|
4210
|
-
try {
|
|
4211
|
-
this.file = this.saveSnapshot(name);
|
|
4212
|
-
this.refreshListItems();
|
|
4213
|
-
this.feedback = `Snapshot saved; live routing unchanged. Profile "${name}" saved from current routing.`;
|
|
4214
|
-
} catch (error) {
|
|
4215
|
-
this.feedback = `Snapshot failed; live routing unchanged. Profile "${name}" was not saved from current routing: ${profilesErrorMessage(error)}`;
|
|
4216
|
-
}
|
|
4217
|
-
this.requestRender();
|
|
4218
|
-
return;
|
|
4219
|
-
}
|
|
4220
|
-
if (data === "a") return this.finish({ type: "apply-global", name });
|
|
4221
|
-
if (data === "d") return this.finish({ type: "duplicate", name });
|
|
4222
|
-
if (data === "r") return this.finish({ type: "rename", name });
|
|
4223
|
-
if (data === "x") return this.finish({ type: "delete", name });
|
|
4224
|
-
if (data === "e") return this.finish({ type: "export", name });
|
|
4225
|
-
if (data === "p") return this.finish({ type: "pin", source: "local", name });
|
|
4226
|
-
if (data === "P") return this.finish({ type: "pin", source: "repo", name });
|
|
4227
|
-
this.list.handleInput(data);
|
|
4228
|
-
}
|
|
4229
|
-
|
|
4230
|
-
handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
|
|
4231
|
-
const layout = this.pointerLayout;
|
|
4232
|
-
if (!layout) return undefined;
|
|
4233
|
-
const inBody = event.y >= layout.listTop && event.y < layout.listTop + layout.bodyRows;
|
|
4234
|
-
if (
|
|
4235
|
-
inBody &&
|
|
4236
|
-
event.type === "wheel" &&
|
|
4237
|
-
event.wheelDelta &&
|
|
4238
|
-
layout.mode === "panes" &&
|
|
4239
|
-
event.x >= layout.threadX &&
|
|
4240
|
-
event.x < layout.threadX + layout.threadWidth
|
|
4241
|
-
) {
|
|
4242
|
-
this.scrollDetail(event.wheelDelta);
|
|
4243
|
-
return { handled: true, render: true };
|
|
4244
|
-
}
|
|
4245
|
-
if (!inBody) return undefined;
|
|
4246
|
-
if (event.x < layout.listX || event.x >= layout.listX + layout.listWidth) return undefined;
|
|
4247
|
-
return this.list.handleMouse({
|
|
4248
|
-
...event,
|
|
4249
|
-
x: event.x - layout.listX,
|
|
4250
|
-
y: event.y - layout.listTop,
|
|
4251
|
-
width: layout.listWidth,
|
|
4252
|
-
height: layout.bodyRows,
|
|
4253
|
-
});
|
|
4254
|
-
}
|
|
4255
|
-
|
|
4256
|
-
render(width: number): string[] {
|
|
4257
|
-
const safeWidth = Math.max(1, width);
|
|
4258
|
-
const listWidth = measureAgentsViewLayout(safeWidth, PROFILES_PANEL_MIN_BODY_ROWS + 3).listWidth;
|
|
4259
|
-
const listLines = this.list.render(listWidth);
|
|
4260
|
-
// The frame fills the overlay, and the overlay fills the terminal, so the
|
|
4261
|
-
// body height comes from the terminal rows rather than from the content.
|
|
4262
|
-
const rows = Math.max(PROFILES_PANEL_MIN_BODY_ROWS + 3, Math.floor(this.rows()));
|
|
4263
|
-
const layout = measureAgentsViewLayout(safeWidth, rows);
|
|
4264
|
-
if (layout.mode === "fallback" || layout.width === 0) {
|
|
4265
|
-
this.pointerLayout = undefined;
|
|
4266
|
-
return [];
|
|
4267
|
-
}
|
|
4268
|
-
const selectedId = this.list.getSelectedItem()?.id;
|
|
4269
|
-
if (selectedId !== this.lastSelectedId) {
|
|
4270
|
-
this.lastSelectedId = selectedId;
|
|
4271
|
-
this.detailScroll = 0;
|
|
4272
|
-
}
|
|
4273
|
-
const detailLines = this.renderDetailLines(layout.threadWidth, layout.mode === "panes");
|
|
4274
|
-
this.lastDetailLineCount = detailLines.length;
|
|
4275
|
-
this.lastDetailRows = layout.bodyRows;
|
|
4276
|
-
this.detailScroll = clampDetailScroll(this.detailScroll, detailLines.length, layout.bodyRows);
|
|
4277
|
-
this.pointerLayout = { ...layout, listTop: 1 };
|
|
4278
|
-
const rule = "─".repeat(Math.max(0, layout.width - 2));
|
|
4279
|
-
const lines: string[] = [this.renderText(`╭${rule}╮`, "border")];
|
|
4280
|
-
for (let row = 0; row < layout.bodyRows; row += 1) {
|
|
4281
|
-
lines.push(this.renderBodyRow(row, layout, listLines, detailLines));
|
|
4282
|
-
}
|
|
4283
|
-
lines.push(this.renderFooterRow(layout.width));
|
|
4284
|
-
lines.push(this.renderText(`╰${rule}╯`, "border"));
|
|
4285
|
-
return lines;
|
|
4286
|
-
}
|
|
4287
|
-
|
|
4288
|
-
private scrollDetail(delta: number): void {
|
|
4289
|
-
this.detailScroll = clampDetailScroll(
|
|
4290
|
-
this.detailScroll + Math.trunc(delta),
|
|
4291
|
-
this.lastDetailLineCount,
|
|
4292
|
-
this.lastDetailRows,
|
|
4293
|
-
);
|
|
4294
|
-
}
|
|
4295
|
-
|
|
4296
|
-
private refreshListItems(): void {
|
|
4297
|
-
// Keep the list instance (and its pointer observer) alive while refreshing the
|
|
4298
|
-
// mutable item records that NativeChoiceList already holds by reference.
|
|
4299
|
-
for (const item of buildProfileListItems(this.file, evaluateProfilePin(this.pinStatus, this.file.profiles).winner?.profile, this.sessionBoundName)) {
|
|
4300
|
-
const current = this.listItems.find((candidate) => candidate.id === item.id);
|
|
4301
|
-
if (current) Object.assign(current, item);
|
|
4302
|
-
}
|
|
4303
|
-
this.list.refreshItems();
|
|
4304
|
-
}
|
|
4305
|
-
|
|
4306
|
-
private pageRows(): number {
|
|
4307
|
-
return Math.max(1, this.lastDetailRows - 1);
|
|
4308
|
-
}
|
|
4309
|
-
|
|
4310
|
-
private finish(result: ProfilesPanelResult): void {
|
|
4311
|
-
if (this.completed) return;
|
|
4312
|
-
this.completed = true;
|
|
4313
|
-
this.list.setDisabled(true);
|
|
4314
|
-
this.done(result);
|
|
4315
|
-
}
|
|
4316
|
-
|
|
4317
|
-
private renderBodyRow(
|
|
4318
|
-
row: number,
|
|
4319
|
-
layout: AgentsViewLayout,
|
|
4320
|
-
listLines: string[],
|
|
4321
|
-
detailLines: string[],
|
|
4322
|
-
): string {
|
|
4323
|
-
if (layout.mode === "panes") {
|
|
4324
|
-
return [
|
|
4325
|
-
this.renderText("│", "border"),
|
|
4326
|
-
" ",
|
|
4327
|
-
this.fitPaneLine(listLines[row] ?? "", layout.listWidth),
|
|
4328
|
-
" ",
|
|
4329
|
-
this.renderText("│", "border"),
|
|
4330
|
-
" ",
|
|
4331
|
-
this.fitPaneLine(detailLines[this.detailScroll + row] ?? "", layout.threadWidth),
|
|
4332
|
-
this.renderText("│", "border"),
|
|
4333
|
-
].join("");
|
|
4334
|
-
}
|
|
4335
|
-
return [
|
|
4336
|
-
this.renderText("│", "border"),
|
|
4337
|
-
" ",
|
|
4338
|
-
this.fitPaneLine(listLines[row] ?? "", layout.listWidth),
|
|
4339
|
-
" ",
|
|
4340
|
-
this.renderText("│", "border"),
|
|
4341
|
-
].join("");
|
|
4342
|
-
}
|
|
4343
|
-
|
|
4344
|
-
private renderFooterRow(width: number): string {
|
|
4345
|
-
const hints =
|
|
4346
|
-
"enter use in this session · a set as global default · c create · s snapshot · d duplicate · r rename · x delete · e export · i import · p pin · P share · j/k line · ctrl+j/k page · esc close";
|
|
4347
|
-
const text = this.feedback ?? hints;
|
|
4348
|
-
return [
|
|
4349
|
-
this.renderText("│", "border"),
|
|
4350
|
-
" ",
|
|
4351
|
-
this.fitPaneLine(this.renderText(text, this.feedback ? "status" : "muted"), width - 4),
|
|
4352
|
-
" ",
|
|
4353
|
-
this.renderText("│", "border"),
|
|
4354
|
-
].join("");
|
|
4355
|
-
}
|
|
4356
|
-
|
|
4357
|
-
private renderDetailLines(width: number, showDetail: boolean): string[] {
|
|
4358
|
-
if (!showDetail) return [];
|
|
4359
|
-
const name = this.list.getSelectedItem()?.id;
|
|
4360
|
-
if (!name || !hasOwnProfile(this.file.profiles, name)) {
|
|
4361
|
-
return [this.renderLine("No profile selected.", width, "muted")];
|
|
4362
|
-
}
|
|
4363
|
-
const config = this.file.profiles[name];
|
|
4364
|
-
const profileRows = profileRoutingRows(config);
|
|
4365
|
-
const currentRows = profileRoutingRows(this.currentConfig);
|
|
4366
|
-
// One shared measurement across both tables, so the same agent sits in the
|
|
4367
|
-
// same column whether it comes from the profile or from models.json.
|
|
4368
|
-
const widths = routingColumnWidths(profileRows, currentRows);
|
|
4369
|
-
return [
|
|
4370
|
-
this.renderLine(name === this.file.active ? `${name} (active)` : name, width, "title"),
|
|
4371
|
-
this.renderLine(
|
|
4372
|
-
`orchestrator ${formatOrchestratorSelection(readProfileOrchestrator(config))}`,
|
|
4373
|
-
width,
|
|
4374
|
-
"text",
|
|
4375
|
-
),
|
|
4376
|
-
this.renderLine(`now ${this.effectiveOrchestratorLabel()}`, width, "muted"),
|
|
4377
|
-
// A pin only redirects subagent routing, so it is reported next to the
|
|
4378
|
-
// orchestrator lines it deliberately does not move. The winning layer's path,
|
|
4379
|
-
// any invalid or stale layer, and the scope sentence all come from the shared
|
|
4380
|
-
// precedence rule the launch resolver uses.
|
|
4381
|
-
...profilePinDetailLines(this.pinStatus, this.file.profiles).map((line) => this.renderLine(line, width, "muted")),
|
|
4382
|
-
// gentle-shell#1064: the binding is stored for this session and outranks
|
|
4383
|
-
// the pin in the panel list, so it is named right after the pin layers.
|
|
4384
|
-
// Since #1558 launches resolve it ahead of pins and the global default,
|
|
4385
|
-
// and nothing was written to make that true.
|
|
4386
|
-
...(this.sessionBoundName === undefined
|
|
4387
|
-
? []
|
|
4388
|
-
: [this.renderLine(`session ${sanitizeTerminalText(this.sessionBoundName)} (session) — launches resolve it ahead of pins and the global default; nothing was written`, width, "muted")]),
|
|
4389
|
-
"",
|
|
4390
|
-
this.renderLine("Profile routing", width, "accent"),
|
|
4391
|
-
...this.indentLines(this.routingLines(profileRows, widths, width), width),
|
|
4392
|
-
"",
|
|
4393
|
-
this.renderLine("Current routing (effective)", width, "accent"),
|
|
4394
|
-
...this.indentLines(this.routingLines(currentRows, widths, width), width),
|
|
4395
|
-
...this.unmatchedKeyLines(currentRows, width),
|
|
4396
|
-
];
|
|
4397
|
-
}
|
|
4398
|
-
|
|
4399
|
-
private unmatchedKeyLines(currentRows: ProfileRoutingRow[], width: number): string[] {
|
|
4400
|
-
// Issue #2400: a model_profiles key that matches no discovered agent and
|
|
4401
|
-
// suggests none never becomes a routing row, so it is listed here, one key
|
|
4402
|
-
// per line, with the same inline-or-↳ note rule as the routing rows. A key
|
|
4403
|
-
// already shown as a current row carries its note there instead.
|
|
4404
|
-
const shown = new Set(currentRows.map((row) => row.agent));
|
|
4405
|
-
const keys = this.unmatchedKeys.filter((key) => !shown.has(key));
|
|
4406
|
-
if (keys.length === 0) return [];
|
|
4407
|
-
const note = "no matching agent";
|
|
4408
|
-
return [
|
|
4409
|
-
"",
|
|
4410
|
-
this.renderLine("Unmatched keys (ignored)", width, "accent"),
|
|
4411
|
-
...this.indentLines(keys.flatMap((key) => {
|
|
4412
|
-
const inline = `${key} ← ${note}`;
|
|
4413
|
-
return visibleWidth(inline) + 2 <= width ? [inline] : [key, ` ↳ ${note}`];
|
|
4414
|
-
}), width),
|
|
4415
|
-
];
|
|
4416
|
-
}
|
|
4417
|
-
|
|
4418
|
-
private effectiveOrchestratorLabel(): string {
|
|
4419
|
-
// The now line answers what this session runs: the live orchestrator wins
|
|
4420
|
-
// when one exists, and the settings read only stands in for sessions
|
|
4421
|
-
// without a live model (and still surfaces an unreadable settings file).
|
|
4422
|
-
const live = this.liveOrchestrator?.();
|
|
4423
|
-
if (live !== undefined) {
|
|
4424
|
-
return formatOrchestratorSelection(live);
|
|
4425
|
-
}
|
|
4426
|
-
const settings = this.orchestratorSettings;
|
|
4427
|
-
if (settings.status === "invalid") {
|
|
4428
|
-
return `unreadable (${sanitizeTerminalText(settings.reason)})`;
|
|
4429
|
-
}
|
|
4430
|
-
return formatOrchestratorSelection(settings.status === "valid" ? settings.entry : undefined);
|
|
4431
|
-
}
|
|
4432
|
-
|
|
4433
|
-
private routingLines(
|
|
4434
|
-
rows: ProfileRoutingRow[],
|
|
4435
|
-
widths: { agent: number; model: number },
|
|
4436
|
-
width: number,
|
|
4437
|
-
): string[] {
|
|
4438
|
-
if (rows.length === 0) {
|
|
4439
|
-
return ["No routing entries — every agent inherits its default model."];
|
|
4440
|
-
}
|
|
4441
|
-
// Issue #2196: a key that names no discovered agent was rendered as a
|
|
4442
|
-
// valid-looking row while its setting was silently ignored at launch, so
|
|
4443
|
-
// each unmatched row now carries its note inline. When the row would push
|
|
4444
|
-
// the note past the pane edge (indentLines adds two columns before
|
|
4445
|
-
// truncating), the note moves to its own line so it is never cut off.
|
|
4446
|
-
const notes = unmatchedRoutingNotes(rows, this.discoveredAgentNames);
|
|
4447
|
-
return rows.flatMap((row) => {
|
|
4448
|
-
const line = formatRoutingRow(row, widths);
|
|
4449
|
-
const note = notes.get(row.agent);
|
|
4450
|
-
if (note === undefined) return [line];
|
|
4451
|
-
const inline = `${line} ← ${note}`;
|
|
4452
|
-
return visibleWidth(inline) + 2 <= width ? [inline] : [line, ` ↳ ${note}`];
|
|
4453
|
-
});
|
|
4454
|
-
}
|
|
4455
|
-
|
|
4456
|
-
private indentLines(lines: string[], width: number): string[] {
|
|
4457
|
-
return lines.map((line) => this.renderLine(` ${line}`, width, "text"));
|
|
4458
|
-
}
|
|
4459
|
-
|
|
4460
|
-
private fitPaneLine(line: string, width: number): string {
|
|
4461
|
-
const visible = stripAnsi(line);
|
|
4462
|
-
if (visible.length > width) {
|
|
4463
|
-
return truncateToWidth(visible, Math.max(1, width), "…", true);
|
|
4464
|
-
}
|
|
4465
|
-
return `${line}${" ".repeat(Math.max(0, width - visible.length))}`;
|
|
4466
|
-
}
|
|
4467
|
-
|
|
4468
|
-
private renderLine(text: string, width: number, tone?: PanelTone): string {
|
|
4469
|
-
const safe = truncateToWidth(
|
|
4470
|
-
sanitizeTerminalText(text),
|
|
4471
|
-
Math.max(1, width),
|
|
4472
|
-
"…",
|
|
4473
|
-
true,
|
|
4474
|
-
);
|
|
4475
|
-
return tone ? this.renderText(safe, tone) : safe;
|
|
4476
|
-
}
|
|
4477
|
-
|
|
4478
|
-
private renderText(text: string, tone: PanelTone): string {
|
|
4479
|
-
const safe = sanitizeTerminalText(text);
|
|
4480
|
-
if (!this.theme) return safe;
|
|
4481
|
-
return this.theme.fg(PANEL_TONE_COLOR[tone], safe);
|
|
4482
|
-
}
|
|
4483
|
-
}
|
|
4484
|
-
|
|
4485
|
-
/** The full-screen profile picker: one visit per action, so every reopen reads fresh store, pin, orchestrator, and session-binding state. */
|
|
4486
|
-
async function showProfilesPanel(
|
|
4487
|
-
ctx: ExtensionContext,
|
|
4488
|
-
file: AgentProfilesFile,
|
|
4489
|
-
currentConfig: AgentModelConfig,
|
|
4490
|
-
selectedName: string | undefined,
|
|
4491
|
-
saveSnapshot: ProfilesSnapshotHandler,
|
|
4492
|
-
live: LiveSession,
|
|
4493
|
-
status?: string,
|
|
4494
|
-
sessionBoundName?: string,
|
|
4495
|
-
): Promise<ProfilesPanelResult> {
|
|
4496
|
-
// Both orchestrator and pin state are snapshots for this panel visit.
|
|
4497
|
-
// Actions (including p/P) reopen the panel and read fresh state.
|
|
4498
|
-
const orchestratorSettings = readOrchestratorSettings(orchestratorSettingsPath());
|
|
4499
|
-
// Issue #2196: the panel flags routing keys that name no discovered agent, so
|
|
4500
|
-
// it needs one discovery snapshot per visit; a failed or partial discovery is
|
|
4501
|
-
// not an error here, it just renders without notes.
|
|
4502
|
-
const discoveryRoots = {
|
|
4503
|
-
cwd: ctx.sessionManager.getCwd?.() ?? ctx.cwd,
|
|
4504
|
-
home: homedir(),
|
|
4505
|
-
agentHome: resolveGentlePiAgentHome(),
|
|
4506
|
-
};
|
|
4507
|
-
const discoveredAgents = discoverAgents(discoveryRoots).agents;
|
|
4508
|
-
// gentle-pi#2271: review-refuter and review-validator are provider review
|
|
4509
|
-
// roles the relay routes by key; they never have an agent file, so they
|
|
4510
|
-
// count as matched instead of being flagged "no matching agent".
|
|
4511
|
-
const discoveredAgentNames = [...new Set([...PROVIDER_REVIEW_ROLES, ...discoveredAgents.map((agent) => agent.name)])];
|
|
4512
|
-
// Issue #2400: launches resolve from the subagents.json model_profiles, which
|
|
4513
|
-
// the effective table only reads per discovered agent, so a key with no match
|
|
4514
|
-
// and no suggestion never appeared. When a session binding or a pin wins, the
|
|
4515
|
-
// table already shows that snapshot's own keys with their notes.
|
|
4516
|
-
const unmatchedKeys = sessionBoundName !== undefined || pinnedEffectiveModelConfig(ctx.cwd) !== undefined
|
|
4517
|
-
? []
|
|
4518
|
-
: unmatchedProfileKeys(loadAgentsConfig(discoveryRoots).modelProfiles, discoveredAgents)
|
|
4519
|
-
.filter((entry) => entry.suggestion === undefined && !isProviderReviewRole(entry.key))
|
|
4520
|
-
.map((entry) => entry.key);
|
|
4521
|
-
return ctx.ui.custom<ProfilesPanelResult>(
|
|
4522
|
-
(tui, theme, keybindings, done) => {
|
|
4523
|
-
const panel = new ProfilesPanel(
|
|
4524
|
-
file,
|
|
4525
|
-
currentConfig,
|
|
4526
|
-
done,
|
|
4527
|
-
keybindings,
|
|
4528
|
-
theme,
|
|
4529
|
-
selectedName,
|
|
4530
|
-
() => Math.max(0, tui.terminal.rows),
|
|
4531
|
-
orchestratorSettings,
|
|
4532
|
-
saveSnapshot,
|
|
4533
|
-
() => tui.requestRender(),
|
|
4534
|
-
() => readProfilePinStatus(ctx.cwd),
|
|
4535
|
-
() => sessionBoundName,
|
|
4536
|
-
discoveredAgentNames,
|
|
4537
|
-
unmatchedKeys,
|
|
4538
|
-
() => liveOrchestratorSnapshot(ctx, live)?.entry,
|
|
4539
|
-
status,
|
|
4540
|
-
);
|
|
4541
|
-
const container = createNativeFullscreenInteraction({
|
|
4542
|
-
keyboardTarget: panel,
|
|
4543
|
-
requestRender: () => tui.requestRender(),
|
|
4544
|
-
mouseObserver: panel.list.createMouseObserver(() => tui.requestRender()),
|
|
4545
|
-
});
|
|
4546
|
-
container.addChild(panel);
|
|
4547
|
-
return container;
|
|
4548
|
-
},
|
|
4549
|
-
{
|
|
4550
|
-
overlay: true,
|
|
4551
|
-
overlayOptions: {
|
|
4552
|
-
anchor: "center",
|
|
4553
|
-
width: "100%",
|
|
4554
|
-
maxHeight: "100%",
|
|
4555
|
-
margin: 0,
|
|
4556
|
-
},
|
|
4557
|
-
},
|
|
4558
|
-
);
|
|
4559
|
-
}
|
|
4560
|
-
|
|
4561
4820
|
function reportProfilesDrops(ctx: ExtensionContext, path: string, drops: ProfilesParseDrops): void {
|
|
4562
4821
|
// Dropped names are by definition the ones that failed validation, so they are
|
|
4563
4822
|
// untrusted input reaching the terminal and must be sanitized like any other
|
|
@@ -4652,35 +4911,43 @@ function profileSnapshotFrom(
|
|
|
4652
4911
|
return snapshot;
|
|
4653
4912
|
}
|
|
4654
4913
|
|
|
4914
|
+
/** The store actions runProfilesPanelAction implements; the loop handles the rest itself. */
|
|
4915
|
+
type ProfileStoreAction = Extract<
|
|
4916
|
+
RoutingTableResult,
|
|
4917
|
+
{ type: "use-session" | "apply-global" | "create" | "duplicate" | "rename" | "delete" | "export-profile" | "import-profile" | "pin" }
|
|
4918
|
+
>;
|
|
4919
|
+
|
|
4655
4920
|
/** Runs one finished panel action against the store and the live session, returning the file the reopened panel should show. */
|
|
4656
4921
|
async function runProfilesPanelAction(
|
|
4657
4922
|
ctx: ExtensionContext,
|
|
4658
4923
|
live: LiveSession,
|
|
4659
4924
|
path: string,
|
|
4660
4925
|
file: AgentProfilesFile,
|
|
4661
|
-
result:
|
|
4926
|
+
result: ProfileStoreAction,
|
|
4662
4927
|
report: ProfilesPanelReport = {},
|
|
4663
4928
|
): Promise<AgentProfilesFile> {
|
|
4664
4929
|
switch (result.type) {
|
|
4665
|
-
case "
|
|
4930
|
+
case "use-session": {
|
|
4666
4931
|
if (!hasOwnProfile(file.profiles, result.name)) return file;
|
|
4667
|
-
//
|
|
4668
|
-
// switches only its live orchestrator. No shared defaults,
|
|
4669
|
-
// stores, agent frontmatter, pins, or declarations are written.
|
|
4932
|
+
// Use in this session binds a complete routing snapshot to this parent
|
|
4933
|
+
// session and switches only its live orchestrator. No shared defaults,
|
|
4934
|
+
// materialized stores, agent frontmatter, pins, or declarations are written.
|
|
4670
4935
|
const sessionId = ctx.sessionManager?.getSessionId?.();
|
|
4671
4936
|
if (typeof sessionId !== "string" || sessionId.length === 0) {
|
|
4937
|
+
report.status = `This Pi session has no id to bind "${result.name}" to. Use Make default instead.`;
|
|
4672
4938
|
ctx.ui.notify(
|
|
4673
|
-
`el Gentleman cannot bind profile "${result.name}" to this session: no parent session id is available here.
|
|
4939
|
+
`el Gentleman cannot bind profile "${result.name}" to this session: no parent session id is available here. Use Make default instead.`,
|
|
4674
4940
|
"warning",
|
|
4675
4941
|
);
|
|
4676
4942
|
return file;
|
|
4677
4943
|
}
|
|
4678
|
-
const snapshot = normalizeModelConfig(file.profiles[result.name]) ?? {};
|
|
4944
|
+
const snapshot = normalizeModelConfig(result.config ?? file.profiles[result.name]) ?? {};
|
|
4679
4945
|
bindSessionProfile(sessionId, result.name, snapshot);
|
|
4680
4946
|
const orchestrator = readProfileOrchestrator(snapshot);
|
|
4681
4947
|
const liveNote = orchestrator === undefined ? "" : await switchLiveOrchestrator(ctx, live, orchestrator);
|
|
4948
|
+
report.status = `This session now uses "${result.name}". Nothing shared was written.${liveNote.replace(/\n/g, " ")}`;
|
|
4682
4949
|
ctx.ui.notify(
|
|
4683
|
-
`el Gentleman bound profile "${result.name}" to this session —
|
|
4950
|
+
`el Gentleman bound profile "${result.name}" to this session — its tab shows ◉. Subagents and reviewers use this session's routing snapshot. Shared defaults were not written: the global routing, pins, and materialized stores are untouched. Make default writes them.${liveNote}`,
|
|
4684
4951
|
"info",
|
|
4685
4952
|
);
|
|
4686
4953
|
return file;
|
|
@@ -4712,7 +4979,7 @@ async function runProfilesPanelAction(
|
|
|
4712
4979
|
ctx.ui.notify(pinNote, "info");
|
|
4713
4980
|
return file;
|
|
4714
4981
|
}
|
|
4715
|
-
const normalized =
|
|
4982
|
+
const normalized = cloneModelConfig(result.config);
|
|
4716
4983
|
const orchestratorEntry = readProfileOrchestrator(normalized);
|
|
4717
4984
|
const hasAgentRoutes = Object.keys(normalized).some((name) => !isProfileOrchestratorKey(name));
|
|
4718
4985
|
// Every global apply confirms before anything is written: applying replaces
|
|
@@ -4988,28 +5255,6 @@ async function runProfilesPanelAction(
|
|
|
4988
5255
|
return file;
|
|
4989
5256
|
}
|
|
4990
5257
|
}
|
|
4991
|
-
case "update": {
|
|
4992
|
-
// A profile is a complete snapshot, so capturing the current routing also
|
|
4993
|
-
// captures the orchestrator the routing is running under. A settings file
|
|
4994
|
-
// that cannot be read leaves the snapshot without an orchestrator entry
|
|
4995
|
-
// rather than inventing one.
|
|
4996
|
-
const snapshot = profileSnapshotFrom(
|
|
4997
|
-
await readEffectiveModelConfigAsync(ctx.cwd),
|
|
4998
|
-
readOrchestratorSettings(orchestratorSettingsPath()),
|
|
4999
|
-
);
|
|
5000
|
-
try {
|
|
5001
|
-
const next = updateProfile(file, result.name, snapshot);
|
|
5002
|
-
writeProfilesFileSync(path, next);
|
|
5003
|
-
ctx.ui.notify(
|
|
5004
|
-
`el Gentleman updated profile "${result.name}" from the current routing in ${modelConfigPath(ctx.cwd)}.`,
|
|
5005
|
-
"info",
|
|
5006
|
-
);
|
|
5007
|
-
return next;
|
|
5008
|
-
} catch (error) {
|
|
5009
|
-
ctx.ui.notify(`Profile not updated: ${profilesErrorMessage(error)}`, "warning");
|
|
5010
|
-
return file;
|
|
5011
|
-
}
|
|
5012
|
-
}
|
|
5013
5258
|
case "duplicate": {
|
|
5014
5259
|
// The Pi host starts the field empty and ignores the placeholder, so the title
|
|
5015
5260
|
// names the suggestion and Enter on an untouched field accepts it.
|
|
@@ -5053,7 +5298,7 @@ async function runProfilesPanelAction(
|
|
|
5053
5298
|
ctx.ui.notify(
|
|
5054
5299
|
`el Gentleman renamed profile "${result.name}" to "${name}".` +
|
|
5055
5300
|
(follow.followed.length > 0 ? `\nUpdated the clone pin: ${follow.followed.map((entry) => sanitizeTerminalText(entry)).join(", ")}.` : "") +
|
|
5056
|
-
(follow.stillDeclared.length > 0 ? `\nThe committed repository declaration ${follow.stillDeclared.map((entry) => sanitizeTerminalText(entry)).join(", ")} still names "${result.name}";
|
|
5301
|
+
(follow.stillDeclared.length > 0 ? `\nThe committed repository declaration ${follow.stillDeclared.map((entry) => sanitizeTerminalText(entry)).join(", ")} still names "${result.name}"; run /gentle:profiles publish ${name} to republish it.` : ""),
|
|
5057
5302
|
"info",
|
|
5058
5303
|
);
|
|
5059
5304
|
report.status = `Profile "${result.name}" renamed to "${name}".`;
|
|
@@ -5085,7 +5330,7 @@ async function runProfilesPanelAction(
|
|
|
5085
5330
|
}
|
|
5086
5331
|
if (namedLayers.length > 0) {
|
|
5087
5332
|
ctx.ui.notify(
|
|
5088
|
-
`Profile "${result.name}" is pinned for this repository (${namedLayers.join(" and ")}). Remove the pin first
|
|
5333
|
+
`Profile "${result.name}" is pinned for this repository (${namedLayers.join(" and ")}). Remove the pin first (profile menu ▾ › Stop using in this repo for the clone pin, /gentle:profiles publish <name> for the declaration), then delete it.`,
|
|
5089
5334
|
"warning",
|
|
5090
5335
|
);
|
|
5091
5336
|
return file;
|
|
@@ -5114,6 +5359,7 @@ async function runProfilesPanelAction(
|
|
|
5114
5359
|
// failure here has nothing to roll back.
|
|
5115
5360
|
const status = readProfilePinStatus(ctx.cwd);
|
|
5116
5361
|
if (!status) {
|
|
5362
|
+
report.status = "This session is not inside a Git worktree, so nothing was pinned.";
|
|
5117
5363
|
ctx.ui.notify(
|
|
5118
5364
|
"el Gentleman cannot pin a profile because this session is not inside a Git worktree. Pinning is per repository; apply the profile globally instead.",
|
|
5119
5365
|
"warning",
|
|
@@ -5128,6 +5374,7 @@ async function runProfilesPanelAction(
|
|
|
5128
5374
|
if (currentlyPinned) clearProfilePinSync(pinPath);
|
|
5129
5375
|
else writeProfilePinSync(pinPath, result.name);
|
|
5130
5376
|
} catch (error) {
|
|
5377
|
+
report.status = `The pin could not be updated: ${profilesErrorMessage(error)}`;
|
|
5131
5378
|
ctx.ui.notify(
|
|
5132
5379
|
`el Gentleman could not update profile pin ${sanitizeTerminalText(pinPath)}: ${profilesErrorMessage(error)}`,
|
|
5133
5380
|
"warning",
|
|
@@ -5153,17 +5400,17 @@ async function runProfilesPanelAction(
|
|
|
5153
5400
|
result.source === "local"
|
|
5154
5401
|
? [
|
|
5155
5402
|
`el Gentleman pinned profile "${result.name}" for this clone in ${sanitizeTerminalText(pinPath)}.`,
|
|
5156
|
-
"Subagent launches in this repository resolve that profile at launch; the orchestrator and every other repository keep their global routing.
|
|
5403
|
+
"Subagent launches in this repository resolve that profile at launch; the orchestrator and every other repository keep their global routing. Choose Stop using in this repo in the profile menu (▾) to unpin.",
|
|
5157
5404
|
].join("\n")
|
|
5158
5405
|
: [
|
|
5159
5406
|
`el Gentleman declared profile "${result.name}" for this worktree in ${sanitizeTerminalText(pinPath)}.`,
|
|
5160
|
-
|
|
5407
|
+
`Subagent launches resolve it at launch. Commit the file to share the routing; a local pin takes precedence over it. Run /gentle:profiles publish ${result.name} again to remove the declaration.`,
|
|
5161
5408
|
].join("\n") + sharingNote,
|
|
5162
5409
|
"info",
|
|
5163
5410
|
);
|
|
5164
5411
|
return file;
|
|
5165
5412
|
}
|
|
5166
|
-
case "export": {
|
|
5413
|
+
case "export-profile": {
|
|
5167
5414
|
if (!hasOwnProfile(file.profiles, result.name)) return file;
|
|
5168
5415
|
const exportPath = profileExportPath(gentleAiConfigHome());
|
|
5169
5416
|
try {
|
|
@@ -5176,7 +5423,7 @@ async function runProfilesPanelAction(
|
|
|
5176
5423
|
}
|
|
5177
5424
|
return file;
|
|
5178
5425
|
}
|
|
5179
|
-
case "import": {
|
|
5426
|
+
case "import-profile": {
|
|
5180
5427
|
const importPath = profileExportPath(gentleAiConfigHome());
|
|
5181
5428
|
let text: string;
|
|
5182
5429
|
try {
|
|
@@ -5226,7 +5473,64 @@ async function runProfilesPanelAction(
|
|
|
5226
5473
|
}
|
|
5227
5474
|
|
|
5228
5475
|
/** `/gentle:profiles`: seed or open the store, then loop the panel over one action at a time until it closes. */
|
|
5229
|
-
|
|
5476
|
+
const PROFILES_COMMAND_USAGE = "Usage: /gentle:profiles [export <name> | import | publish <name> | export-routing | restore-routing]";
|
|
5477
|
+
|
|
5478
|
+
/**
|
|
5479
|
+
* `/gentle:profiles` opens the models screen. File operations live in
|
|
5480
|
+
* subcommands rather than on the screen: `export <name>` and `import` move a
|
|
5481
|
+
* profile through `profiles.export.json`, `publish <name>` toggles the
|
|
5482
|
+
* committed repository declaration, and `export-routing` / `restore-routing`
|
|
5483
|
+
* move the saved global routing (models.json, not a profile) through
|
|
5484
|
+
* `models.export.json`. The routing verbs carry their own names so a profile
|
|
5485
|
+
* can never be mistaken for them.
|
|
5486
|
+
*/
|
|
5487
|
+
async function handleProfilesCommand(ctx: ExtensionContext, live: LiveSession, args = ""): Promise<void> {
|
|
5488
|
+
const [verb = "", ...rest] = args.trim().split(/\s+/).filter(Boolean);
|
|
5489
|
+
if (verb === "") {
|
|
5490
|
+
await openModelsScreen(ctx, live);
|
|
5491
|
+
return;
|
|
5492
|
+
}
|
|
5493
|
+
if (verb === "export-routing" || verb === "restore-routing") {
|
|
5494
|
+
if (rest.length > 0) {
|
|
5495
|
+
ctx.ui.notify(PROFILES_COMMAND_USAGE, "warning");
|
|
5496
|
+
return;
|
|
5497
|
+
}
|
|
5498
|
+
if (verb === "export-routing") await exportRoutingCommand(ctx);
|
|
5499
|
+
else await restoreModelExport(ctx);
|
|
5500
|
+
return;
|
|
5501
|
+
}
|
|
5502
|
+
const store = await loadProfilesStore(ctx);
|
|
5503
|
+
if (!store) return;
|
|
5504
|
+
const name = rest.join(" ");
|
|
5505
|
+
switch (verb) {
|
|
5506
|
+
case "export":
|
|
5507
|
+
case "publish": {
|
|
5508
|
+
if (name === "" || !hasOwnProfile(store.file.profiles, name)) {
|
|
5509
|
+
ctx.ui.notify(
|
|
5510
|
+
name === "" ? PROFILES_COMMAND_USAGE : `No profile named "${sanitizeTerminalText(name)}". ${PROFILES_COMMAND_USAGE}`,
|
|
5511
|
+
"warning",
|
|
5512
|
+
);
|
|
5513
|
+
return;
|
|
5514
|
+
}
|
|
5515
|
+
await runProfilesPanelAction(
|
|
5516
|
+
ctx,
|
|
5517
|
+
live,
|
|
5518
|
+
store.path,
|
|
5519
|
+
store.file,
|
|
5520
|
+
verb === "export" ? { type: "export-profile", name } : { type: "pin", source: "repo", name },
|
|
5521
|
+
);
|
|
5522
|
+
return;
|
|
5523
|
+
}
|
|
5524
|
+
case "import":
|
|
5525
|
+
await runProfilesPanelAction(ctx, live, store.path, store.file, { type: "import-profile" });
|
|
5526
|
+
return;
|
|
5527
|
+
default:
|
|
5528
|
+
ctx.ui.notify(PROFILES_COMMAND_USAGE, "warning");
|
|
5529
|
+
}
|
|
5530
|
+
}
|
|
5531
|
+
|
|
5532
|
+
/** Reads the profiles store, seeding it on first use; undefined after a reported failure. */
|
|
5533
|
+
async function loadProfilesStore(ctx: ExtensionContext): Promise<{ path: string; file: AgentProfilesFile } | undefined> {
|
|
5230
5534
|
const path = profilesFilePath(gentleAiConfigHome());
|
|
5231
5535
|
const read = readProfilesFileResult(path);
|
|
5232
5536
|
if (read.status === "invalid") {
|
|
@@ -5234,78 +5538,215 @@ async function handleProfilesCommand(ctx: ExtensionContext, live: LiveSession):
|
|
|
5234
5538
|
`el Gentleman cannot open agent profiles because ${path} is invalid JSON or not a profiles file. Fix or remove the file, then run /gentle:profiles again.`,
|
|
5235
5539
|
"warning",
|
|
5236
5540
|
);
|
|
5237
|
-
return;
|
|
5541
|
+
return undefined;
|
|
5542
|
+
}
|
|
5543
|
+
if (read.status === "valid") {
|
|
5544
|
+
reportProfilesDrops(ctx, path, read.drops);
|
|
5545
|
+
return { path, file: read.file };
|
|
5546
|
+
}
|
|
5547
|
+
const file = bootstrapProfilesFile(await readEffectiveModelConfigAsync(ctx.cwd));
|
|
5548
|
+
try {
|
|
5549
|
+
writeProfilesFileSync(path, file);
|
|
5550
|
+
} catch (error) {
|
|
5551
|
+
ctx.ui.notify(`el Gentleman could not create ${path}: ${profilesErrorMessage(error)}`, "warning");
|
|
5552
|
+
return undefined;
|
|
5238
5553
|
}
|
|
5239
|
-
|
|
5240
|
-
|
|
5241
|
-
|
|
5554
|
+
ctx.ui.notify(`el Gentleman seeded the "current" profile in ${path} from the routing currently in effect.`, "info");
|
|
5555
|
+
return { path, file };
|
|
5556
|
+
}
|
|
5557
|
+
|
|
5558
|
+
/**
|
|
5559
|
+
* The models screen loop: shows one profile's table, runs what the user chose,
|
|
5560
|
+
* and reopens with fresh store, binding, and pin state; a tab switch stays on
|
|
5561
|
+
* the open screen instead. Bare `/gentle:profiles` passes the profile this
|
|
5562
|
+
* repository resolves so it is the tab on screen. Every
|
|
5563
|
+
* outcome is reported on the screen's feedback line, because notifications stay
|
|
5564
|
+
* hidden behind the fullscreen overlay until it closes.
|
|
5565
|
+
*/
|
|
5566
|
+
async function openProfilesPanelLoop(
|
|
5567
|
+
ctx: ExtensionContext,
|
|
5568
|
+
live: LiveSession,
|
|
5569
|
+
initialSelectedName?: string,
|
|
5570
|
+
): Promise<void> {
|
|
5571
|
+
const store = await loadProfilesStore(ctx);
|
|
5572
|
+
if (!store) return;
|
|
5573
|
+
const { path } = store;
|
|
5574
|
+
let file = store.file;
|
|
5575
|
+
const sessionBoundName = () => readSessionProfileBinding(ctx.sessionManager?.getSessionId?.())?.name;
|
|
5576
|
+
const pinWinner = () => evaluateProfilePin(readProfilePinStatus(ctx.cwd), file.profiles).winner?.profile;
|
|
5577
|
+
let selectedName = initialSelectedName
|
|
5578
|
+
?? resolveCurrentProfileTarget(ctx.cwd, { status: "valid", file, drops: { droppedProfiles: [], droppedAgents: [], droppedActive: undefined } })?.name
|
|
5579
|
+
?? Object.keys(file.profiles)[0];
|
|
5580
|
+
let feedback: string | undefined;
|
|
5581
|
+
let draftOverride: AgentModelConfig | undefined;
|
|
5582
|
+
const writeDraft = (name: string, config: AgentModelConfig): boolean => {
|
|
5242
5583
|
try {
|
|
5243
|
-
|
|
5584
|
+
const next = hasOwnProfile(file.profiles, name)
|
|
5585
|
+
? updateProfile(file, name, cloneModelConfig(config))
|
|
5586
|
+
: createProfile(file, name, cloneModelConfig(config));
|
|
5587
|
+
writeProfilesFileSync(path, next);
|
|
5588
|
+
file = next;
|
|
5589
|
+
return true;
|
|
5244
5590
|
} catch (error) {
|
|
5245
|
-
|
|
5246
|
-
|
|
5247
|
-
"warning",
|
|
5248
|
-
);
|
|
5249
|
-
return;
|
|
5591
|
+
feedback = `"${name}" was not saved: ${profilesErrorMessage(error)}`;
|
|
5592
|
+
return false;
|
|
5250
5593
|
}
|
|
5251
|
-
|
|
5252
|
-
|
|
5253
|
-
|
|
5254
|
-
|
|
5255
|
-
|
|
5256
|
-
|
|
5257
|
-
|
|
5594
|
+
};
|
|
5595
|
+
const switchProfile: RoutingProfileSwitcher = (name, saveFirst) => {
|
|
5596
|
+
const previous = selectedName!;
|
|
5597
|
+
if (saveFirst && !writeDraft(previous, saveFirst)) {
|
|
5598
|
+
const error = feedback ?? `"${previous}" was not saved.`;
|
|
5599
|
+
feedback = undefined;
|
|
5600
|
+
return { error };
|
|
5601
|
+
}
|
|
5602
|
+
selectedName = name;
|
|
5603
|
+
const boundName = sessionBoundName();
|
|
5604
|
+
return {
|
|
5258
5605
|
file,
|
|
5259
|
-
|
|
5260
|
-
|
|
5261
|
-
|
|
5262
|
-
|
|
5263
|
-
|
|
5264
|
-
?? readEffectiveModelConfig(ctx.cwd),
|
|
5265
|
-
// The orchestrator follows the same honesty rule: capture what the
|
|
5266
|
-
// session actually runs (ctx.model plus the live thinking level)
|
|
5267
|
-
// when a live model exists; settings.json defaults stand in only
|
|
5268
|
-
// when it does not (LCubero's finding on #1064).
|
|
5269
|
-
liveOrchestratorSnapshot(ctx, live) ?? readOrchestratorSettings(orchestratorSettingsPath()),
|
|
5270
|
-
),
|
|
5271
|
-
);
|
|
5272
|
-
writeProfilesFileSync(path, next);
|
|
5273
|
-
file = next;
|
|
5274
|
-
return next;
|
|
5606
|
+
sessionBoundName: boundName,
|
|
5607
|
+
pinWinner: pinWinner(),
|
|
5608
|
+
notices: routingScreenNotices(ctx, file, name, boundName),
|
|
5609
|
+
feedback: `${saveFirst ? `Saved "${previous}". ` : ""}Showing "${name}".`,
|
|
5610
|
+
};
|
|
5275
5611
|
};
|
|
5276
|
-
|
|
5277
|
-
|
|
5278
|
-
|
|
5279
|
-
|
|
5280
|
-
|
|
5281
|
-
|
|
5282
|
-
|
|
5283
|
-
|
|
5284
|
-
|
|
5285
|
-
|
|
5286
|
-
|
|
5287
|
-
file
|
|
5288
|
-
|
|
5289
|
-
|
|
5290
|
-
|
|
5291
|
-
|
|
5292
|
-
undefined,
|
|
5293
|
-
sessionBoundName(),
|
|
5294
|
-
);
|
|
5295
|
-
while (result.type !== "close") {
|
|
5296
|
-
const report: ProfilesPanelReport = {};
|
|
5297
|
-
file = await runProfilesPanelAction(ctx, live, path, file, result, report);
|
|
5298
|
-
selectedName = report.selectedName ?? ("name" in result ? result.name : undefined);
|
|
5299
|
-
result = await showProfilesPanel(
|
|
5300
|
-
ctx,
|
|
5612
|
+
const differsFromStore = (name: string, config: AgentModelConfig) =>
|
|
5613
|
+
JSON.stringify(normalizeModelConfig(config) ?? {}) !== JSON.stringify(normalizeModelConfig(file.profiles[name]) ?? {});
|
|
5614
|
+
while (true) {
|
|
5615
|
+
if (selectedName === undefined || !hasOwnProfile(file.profiles, selectedName)) {
|
|
5616
|
+
selectedName = Object.keys(file.profiles)[0];
|
|
5617
|
+
draftOverride = undefined;
|
|
5618
|
+
}
|
|
5619
|
+
if (selectedName === undefined) {
|
|
5620
|
+
ctx.ui.notify("el Gentleman has no profiles left. Run /gentle:profiles again to seed one from the routing in effect.", "info");
|
|
5621
|
+
return;
|
|
5622
|
+
}
|
|
5623
|
+
const stored = cloneModelConfig(normalizeModelConfig(file.profiles[selectedName]) ?? {});
|
|
5624
|
+
const boundName = sessionBoundName();
|
|
5625
|
+
const result = await showRoutingTablePanel(ctx, {
|
|
5626
|
+
name: selectedName,
|
|
5627
|
+
config: draftOverride ?? stored,
|
|
5301
5628
|
file,
|
|
5302
|
-
|
|
5303
|
-
|
|
5304
|
-
|
|
5305
|
-
|
|
5306
|
-
|
|
5307
|
-
|
|
5308
|
-
|
|
5629
|
+
sessionBoundName: boundName,
|
|
5630
|
+
pinWinner: pinWinner(),
|
|
5631
|
+
feedback,
|
|
5632
|
+
notices: routingScreenNotices(ctx, file, selectedName, boundName),
|
|
5633
|
+
switchProfile,
|
|
5634
|
+
});
|
|
5635
|
+
draftOverride = undefined;
|
|
5636
|
+
feedback = undefined;
|
|
5637
|
+
const previous = selectedName;
|
|
5638
|
+
if (result.saveFirst && !writeDraft(previous, result.saveFirst)) {
|
|
5639
|
+
draftOverride = result.saveFirst;
|
|
5640
|
+
continue;
|
|
5641
|
+
}
|
|
5642
|
+
const savedNote = result.saveFirst ? `Saved "${previous}". ` : "";
|
|
5643
|
+
switch (result.type) {
|
|
5644
|
+
case "close":
|
|
5645
|
+
return;
|
|
5646
|
+
case "load":
|
|
5647
|
+
selectedName = result.name;
|
|
5648
|
+
feedback = `${savedNote}Showing "${result.name}".`;
|
|
5649
|
+
continue;
|
|
5650
|
+
case "custom": {
|
|
5651
|
+
const label = result.target === PROFILE_ORCHESTRATOR_KEY
|
|
5652
|
+
? "the orchestrator"
|
|
5653
|
+
: result.target === ROUTING_ALL_ROW ? "every agent" : sanitizeTerminalText(result.target);
|
|
5654
|
+
const answer = await ctx.ui.input(`Custom model id for ${label}`, "provider/model");
|
|
5655
|
+
draftOverride = cloneModelConfig(result.config);
|
|
5656
|
+
if (answer === undefined || answer.trim() === "") {
|
|
5657
|
+
feedback = "No custom model set.";
|
|
5658
|
+
continue;
|
|
5659
|
+
}
|
|
5660
|
+
const model = normalizeModelId(answer.trim());
|
|
5661
|
+
if (!model) {
|
|
5662
|
+
feedback = "Custom model id must be one provider/model line using letters, numbers, '.', '-', '_', '~', ':', '@', '/', '+', '%' only.";
|
|
5663
|
+
continue;
|
|
5664
|
+
}
|
|
5665
|
+
const keys = result.target === ROUTING_ALL_ROW ? modelAssignmentNames(ctx.cwd) : [result.target];
|
|
5666
|
+
for (const key of keys) draftOverride[key] = { ...(draftOverride[key] ?? {}), model };
|
|
5667
|
+
feedback = `${label}: model ${model}. Save keeps it in "${previous}".`;
|
|
5668
|
+
continue;
|
|
5669
|
+
}
|
|
5670
|
+
case "save":
|
|
5671
|
+
if (writeDraft(previous, result.config)) feedback = `Saved "${previous}".`;
|
|
5672
|
+
else draftOverride = result.config;
|
|
5673
|
+
continue;
|
|
5674
|
+
case "use-session": {
|
|
5675
|
+
// What you see is what the session uses: edits are saved first.
|
|
5676
|
+
// A tab switch may have changed the shown profile since this visit opened.
|
|
5677
|
+
const config = result.config ?? cloneModelConfig(normalizeModelConfig(file.profiles[previous]) ?? {});
|
|
5678
|
+
const saved = differsFromStore(previous, config);
|
|
5679
|
+
if (saved && !writeDraft(previous, config)) {
|
|
5680
|
+
draftOverride = config;
|
|
5681
|
+
continue;
|
|
5682
|
+
}
|
|
5683
|
+
const report: ProfilesPanelReport = {};
|
|
5684
|
+
file = await runProfilesPanelAction(ctx, live, path, file, { type: "use-session", name: previous, config }, report);
|
|
5685
|
+
feedback = `${saved ? `Saved "${previous}". ` : ""}${report.status ?? ""}`.trim() || undefined;
|
|
5686
|
+
continue;
|
|
5687
|
+
}
|
|
5688
|
+
case "apply-global": {
|
|
5689
|
+
// Make default saves what you see, then runs the guarded global
|
|
5690
|
+
// apply with its diff confirmation, pin honesty, and rollback.
|
|
5691
|
+
const saved = differsFromStore(previous, result.config);
|
|
5692
|
+
if (saved && !writeDraft(previous, result.config)) {
|
|
5693
|
+
draftOverride = result.config;
|
|
5694
|
+
continue;
|
|
5695
|
+
}
|
|
5696
|
+
file = await runProfilesPanelAction(ctx, live, path, file, { type: "apply-global", name: previous, config: cloneModelConfig(result.config) });
|
|
5697
|
+
const prefix = saved ? `Saved "${previous}". ` : "";
|
|
5698
|
+
feedback = file.active === previous
|
|
5699
|
+
? `${prefix}"${previous}" is the default for new sessions.`
|
|
5700
|
+
: pinWinner() === previous
|
|
5701
|
+
? `${prefix}This repo pins its profile, so "${previous}" now applies to this repo only.`
|
|
5702
|
+
: `${prefix}"${previous}" was not made the default.`;
|
|
5703
|
+
continue;
|
|
5704
|
+
}
|
|
5705
|
+
case "create-live": {
|
|
5706
|
+
const answer = await ctx.ui.input("Name for a profile of what's running now", "e.g. snapshot");
|
|
5707
|
+
const name = answer?.trim() ?? "";
|
|
5708
|
+
if (name === "") {
|
|
5709
|
+
feedback = `${savedNote}No profile created.`;
|
|
5710
|
+
continue;
|
|
5711
|
+
}
|
|
5712
|
+
// Captures the routing this session's launches resolve and the
|
|
5713
|
+
// orchestrator it actually runs, the way the old snapshot did.
|
|
5714
|
+
const snapshot = profileSnapshotFrom(
|
|
5715
|
+
readSessionProfileBinding(ctx.sessionManager?.getSessionId?.())?.modelProfiles
|
|
5716
|
+
?? await readEffectiveModelConfigAsync(ctx.cwd),
|
|
5717
|
+
liveOrchestratorSnapshot(ctx, live) ?? readOrchestratorSettings(orchestratorSettingsPath()),
|
|
5718
|
+
);
|
|
5719
|
+
try {
|
|
5720
|
+
const next = createProfile(file, name, snapshot);
|
|
5721
|
+
writeProfilesFileSync(path, next);
|
|
5722
|
+
file = next;
|
|
5723
|
+
selectedName = name;
|
|
5724
|
+
feedback = `${savedNote}Created "${name}" from what's running now.`;
|
|
5725
|
+
} catch (error) {
|
|
5726
|
+
feedback = `${savedNote}Profile not created: ${profilesErrorMessage(error)}`;
|
|
5727
|
+
}
|
|
5728
|
+
continue;
|
|
5729
|
+
}
|
|
5730
|
+
default: {
|
|
5731
|
+
const report: ProfilesPanelReport = {};
|
|
5732
|
+
file = await runProfilesPanelAction(ctx, live, path, file, result, report);
|
|
5733
|
+
selectedName = report.selectedName ?? selectedName;
|
|
5734
|
+
if (result.type === "delete" && !hasOwnProfile(file.profiles, previous)) {
|
|
5735
|
+
feedback = `Deleted "${previous}".`;
|
|
5736
|
+
} else if (result.type === "pin") {
|
|
5737
|
+
feedback = report.status ?? (pinWinner() === previous
|
|
5738
|
+
? `"${previous}" is used in this repo.`
|
|
5739
|
+
: `"${previous}" is no longer pinned to this repo.`);
|
|
5740
|
+
} else {
|
|
5741
|
+
feedback = report.status ? `${savedNote}${report.status}` : savedNote || undefined;
|
|
5742
|
+
}
|
|
5743
|
+
// Rename, pin, and a declined delete keep the profile on screen; carry
|
|
5744
|
+
// unsaved edits across them instead of reloading from the store.
|
|
5745
|
+
if (result.draft && hasOwnProfile(file.profiles, selectedName) && (selectedName === previous || result.type === "rename")) {
|
|
5746
|
+
draftOverride = result.draft;
|
|
5747
|
+
}
|
|
5748
|
+
}
|
|
5749
|
+
}
|
|
5309
5750
|
}
|
|
5310
5751
|
}
|
|
5311
5752
|
|
|
@@ -9766,7 +10207,8 @@ export const __testing = {
|
|
|
9766
10207
|
setReviewHostRelayRunnerForTesting,
|
|
9767
10208
|
setReviewHostRelayGroupRunnersForTesting,
|
|
9768
10209
|
clearReviewTransportProbeForTesting,
|
|
9769
|
-
|
|
10210
|
+
routingTablePanel: renderRoutingTableForTesting,
|
|
10211
|
+
piThinkingSupport: getPiThinkingSupport,
|
|
9770
10212
|
getOrchestratorPrompt,
|
|
9771
10213
|
renderOrchestratorPrompt,
|
|
9772
10214
|
loadReviewContractPromptFragment,
|
|
@@ -10375,7 +10817,7 @@ function createGentleAiExtensionForTesting(
|
|
|
10375
10817
|
const modelResult = await applySavedModelConfig(ctx);
|
|
10376
10818
|
if (ctx.hasUI && modelResult.invalidPath) {
|
|
10377
10819
|
ctx.ui.notify(
|
|
10378
|
-
`el Gentleman skipped model config because ${modelResult.invalidPath} is invalid JSON or not an object. Fix or remove the file, then run /gentle:
|
|
10820
|
+
`el Gentleman skipped model config because ${modelResult.invalidPath} is invalid JSON or not an object. Fix or remove the file, then run /gentle:profiles again.`,
|
|
10379
10821
|
"warning",
|
|
10380
10822
|
);
|
|
10381
10823
|
return;
|
|
@@ -10670,17 +11112,10 @@ function createGentleAiExtensionForTesting(
|
|
|
10670
11112
|
});
|
|
10671
11113
|
}
|
|
10672
11114
|
|
|
10673
|
-
pi.registerCommand("gentle:models", {
|
|
10674
|
-
description: "Configure global agent models and choose a profile orchestrator.",
|
|
10675
|
-
handler: async (_args, ctx) => {
|
|
10676
|
-
await handleModelsCommand(ctx, pi);
|
|
10677
|
-
},
|
|
10678
|
-
});
|
|
10679
|
-
|
|
10680
11115
|
pi.registerCommand("gentle:profiles", {
|
|
10681
|
-
description: "
|
|
10682
|
-
handler: async (
|
|
10683
|
-
await handleProfilesCommand(ctx, pi);
|
|
11116
|
+
description: "Open the models screen (profiles as tabs). Subcommands: export <name>, import, publish <name>, export-routing, restore-routing.",
|
|
11117
|
+
handler: async (args, ctx) => {
|
|
11118
|
+
await handleProfilesCommand(ctx, pi, typeof args === "string" ? args : "");
|
|
10684
11119
|
},
|
|
10685
11120
|
});
|
|
10686
11121
|
|