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.
Files changed (44) hide show
  1. package/README.md +2 -2
  2. package/bin/gentle-shell.mjs +37 -4
  3. package/docs/gentle-music.md +24 -3
  4. package/docs/gentle-shell.md +5 -5
  5. package/docs/readme-reference.md +58 -45
  6. package/extensions/gentle-agents.ts +14 -2
  7. package/extensions/gentle-ai.ts +1672 -1237
  8. package/extensions/gentle-music.ts +73 -8
  9. package/extensions/gentle-shell.ts +88 -8
  10. package/lib/command-palette-catalog.ts +1 -2
  11. package/lib/gentle-shell-bundled.ts +18 -8
  12. package/lib/gentle-shell-launcher.ts +2 -0
  13. package/lib/jobs-sidebar-state.ts +22 -3
  14. package/lib/jobs-widget.ts +112 -0
  15. package/lib/session-profile-freeze.ts +2 -2
  16. package/lib/shell-bar.ts +2 -4
  17. package/lib/shell-sidebar-layout.ts +1 -1
  18. package/lib/vim-editor-adapter.ts +3 -2
  19. package/package.json +1 -1
  20. package/runtime/gentle-shell-bundled.mjs +18 -8
  21. package/runtime/gentle-shell-launcher.mjs +2 -0
  22. package/scripts/bundled-install.mjs +209 -13
  23. package/scripts/installer-downloads.mjs +24 -0
  24. package/scripts/installer-preflight.mjs +1 -1
  25. package/scripts/pi-update.mjs +3 -1
  26. package/tests/bundled-install.test.ts +273 -2
  27. package/tests/command-palette.test.ts +3 -3
  28. package/tests/gentle-agents.test.ts +4 -4
  29. package/tests/gentle-ai.test.ts +810 -609
  30. package/tests/gentle-music.test.ts +117 -0
  31. package/tests/gentle-shell-bundled.test.ts +20 -0
  32. package/tests/gentle-shell-launcher.test.ts +1 -0
  33. package/tests/gentle-shell.test.ts +44 -19
  34. package/tests/installer-downloads.test.ts +32 -0
  35. package/tests/installer-server.test.ts +2 -2
  36. package/tests/jobs-widget.test.ts +138 -0
  37. package/tests/model-profiles-preservation.test.ts +2 -2
  38. package/tests/package-manifest.test.ts +2 -2
  39. package/tests/pi-update.test.ts +5 -0
  40. package/tests/runtime-harness.mjs +188 -211
  41. package/tests/session-profile-freeze.test.ts +6 -4
  42. package/tests/shell-bar.test.ts +4 -4
  43. package/tests/shell-sidebar-layout.test.ts +17 -0
  44. package/tests/vim-editor-adapter.test.ts +2 -2
@@ -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 { Key, Text, isKeyRelease, matchesKey, truncateToWidth, visibleWidth, type KeybindingsManager, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
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 `/gentle:models` clears an agent set to inherit.
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
- type ModelPanelResult =
3124
- | { type: "save"; config: AgentModelConfig }
3125
- // `save`, then snapshot saved agent routing and the chosen orchestrator into
3126
- // the current profile without changing the global orchestrator defaults.
3127
- | { type: "save-profile"; config: AgentModelConfig }
3128
- | { type: "custom"; agent: string | "all"; config: AgentModelConfig }
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
- class SddModelPanel implements OverlayComponent {
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 mode: "agents" | "models" | "effort" = "agents";
3159
- private selectedRow = SET_ALL_AGENTS;
3305
+ private query = "";
3160
3306
  private modelCursor = 0;
3161
3307
  private effortCursor = 0;
3162
- private query = "";
3163
- private readonly draft: AgentModelConfig;
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 modelOptions: string[];
3167
- private readonly done: (result: ModelPanelResult) => void;
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
- initialConfig: AgentModelConfig,
3334
+ draft: AgentModelConfig,
3176
3335
  modelOptions: string[],
3177
3336
  agents: string[],
3178
- done: (result: ModelPanelResult) => void,
3337
+ done: (result: RoutingTableResult) => void,
3179
3338
  theme?: Theme,
3180
- profileLabel = "none",
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.draft = cloneModelConfig(initialConfig);
3184
- this.rows = [SET_ALL_AGENTS, PROFILE_ORCHESTRATOR_KEY, ...agents];
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.modelOptions = modelOptions;
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
- handleInput(data: string): void {
3196
- if (this.mode === "models") {
3197
- this.handleModelInput(data);
3198
- return;
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
- render(width: number): string[] {
3208
- const innerWidth = Math.max(1, width - 4);
3209
- const lines =
3210
- this.mode === "models"
3211
- ? this.renderModelPicker(innerWidth)
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 fitStyledLine(line: string, width: number): string {
3245
- const visible = stripAnsi(line);
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 renderLine(text = "", width: number, tone?: PanelTone): string {
3253
- const safe = truncateToWidth(
3254
- sanitizeTerminalText(text),
3255
- Math.max(1, width),
3256
- "…",
3257
- true,
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 renderText(text: string, tone: PanelTone): string {
3263
- const safe = sanitizeTerminalText(text);
3264
- if (!this.theme) return safe;
3265
- return this.theme.fg(PANEL_TONE_COLOR[tone], safe);
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 renderCursor(focused: boolean): string {
3269
- return focused ? this.renderText("▸", "accent") : " ";
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 handleAgentInput(data: string): void {
3273
- const maxCursor = this.rows.length + 1;
3274
- if (matchesKey(data, "ctrl+c") || matchesKey(data, "escape")) {
3275
- this.done({ type: "cancel" });
3276
- return;
3277
- }
3278
- if (matchesKey(data, "ctrl+s")) {
3279
- this.done({ type: "save", config: this.draft });
3280
- return;
3281
- }
3282
- if (matchesKey(data, "u")) {
3283
- this.done({ type: "save-profile", config: this.draft });
3284
- return;
3285
- }
3286
- if (matchesKey(data, "down") || matchesKey(data, "j")) {
3287
- this.cursor = Math.min(maxCursor, this.cursor + 1);
3288
- return;
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
- if (matchesKey(data, "up") || matchesKey(data, "k")) {
3291
- this.cursor = Math.max(0, this.cursor - 1);
3292
- return;
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
- if (matchesKey(data, "g")) {
3295
- this.cursor = 0;
3296
- return;
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
- if (data === "G") {
3299
- this.cursor = maxCursor;
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
- if (matchesKey(data, "i")) {
3303
- this.applyInherit();
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
- if (matchesKey(data, "e")) {
3307
- this.selectedRow = this.rows[this.cursor] ?? SET_ALL_AGENTS;
3308
- if (this.selectedRow === PROFILE_ORCHESTRATOR_KEY && !this.draft[this.selectedRow]?.model) return;
3309
- this.mode = "effort";
3310
- this.effortCursor = 0;
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
- if (matchesKey(data, "x")) {
3314
- this.done({ type: "export", config: this.draft });
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
- if (matchesKey(data, "r")) {
3318
- this.done({ type: "restore", config: this.draft });
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
- if (matchesKey(data, "c")) {
3322
- const row = this.rows[this.cursor];
3323
- if (row === SET_ALL_AGENTS)
3324
- this.done({ type: "custom", agent: "all", config: this.draft });
3325
- else if (row)
3326
- this.done({ type: "custom", agent: row, config: this.draft });
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
- if (!matchesKey(data, "return")) return;
3330
- if (this.cursor === this.rows.length) {
3331
- this.done({ type: "save", config: this.draft });
3332
- return;
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
- if (this.cursor === this.rows.length + 1) {
3335
- this.done({ type: "cancel" });
3336
- return;
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
- private handleModelInput(data: string): void {
3345
- const options = this.filteredModelOptions();
3691
+ // -- keyboard ----------------------------------------------------------------
3692
+
3693
+ handleInput(data: string): void {
3694
+ if (isKeyRelease(data)) return;
3346
3695
  if (matchesKey(data, "ctrl+c")) {
3347
- this.done({ type: "cancel" });
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.mode = "agents";
3352
- this.query = "";
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
- if (matchesKey(data, "backspace")) {
3356
- this.query = this.query.slice(0, -1);
3357
- this.modelCursor = Math.min(
3358
- this.modelCursor,
3359
- Math.max(0, this.filteredModelOptions().length - 1),
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.modelCursor = Math.min(
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, "up") || matchesKey(data, "k")) {
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, "return")) {
3375
- const selected = options[this.modelCursor];
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 applyModelSelection(model: string | undefined): void {
3402
- const row = this.rows[this.cursor];
3403
- if (row === SET_ALL_AGENTS) {
3404
- for (const name of this.agents) this.setModel(name, model);
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 (!row) return;
3408
- this.setModel(row, model);
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 applyThinkingSelection(thinking: ThinkingLevel | undefined): void {
3412
- const row = this.selectedRow;
3413
- if (row === SET_ALL_AGENTS) {
3414
- for (const name of this.agents) this.setThinking(name, thinking);
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
- this.setThinking(row, thinking);
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 (row) this.clearEntry(row);
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 setModel(name: string, model: string | undefined): void {
3430
- if (name === PROFILE_ORCHESTRATOR_KEY && model === undefined) {
3431
- delete this.draft[name];
3432
- return;
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
- const current = this.draft[name] ?? {};
3435
- if (model === undefined) delete current.model;
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 setThinking(name: string, thinking: ThinkingLevel | undefined): void {
3442
- if (name === PROFILE_ORCHESTRATOR_KEY && !this.draft[name]?.model) return;
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 clearEntry(name: string): void {
3451
- if (name === PROFILE_ORCHESTRATOR_KEY) delete this.draft[name];
3452
- else this.draft[name] = {};
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 filteredModelOptions(): string[] {
3456
- const options = this.selectedRow === PROFILE_ORCHESTRATOR_KEY
3457
- ? this.modelOptions.map((option) => option === INHERIT_MODEL ? LEAVE_ORCHESTRATOR_UNTOUCHED : option)
3458
- : this.modelOptions;
3459
- const query = this.query.trim().toLowerCase();
3460
- if (!query) return options;
3461
- return options.filter((option) =>
3462
- option.toLowerCase().includes(query),
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 renderAgentList(width: number): string[] {
3935
+ private tableLines(inner: number): string[] {
3467
3936
  const lines: string[] = [];
3468
- const line = (text = "", tone?: PanelTone) =>
3469
- this.renderLine(text, width, tone);
3470
- lines.push(line("Assign Models and Effort to Agents", "title"));
3471
- lines.push(line(`Current profile: ${this.profileLabel}`, "muted"));
3472
- lines.push("");
3473
- lines.push(line("Current assignments:", "muted"));
3474
- lines.push("");
3475
- const visibleRows = Math.min(
3476
- this.visibleListRows(AGENT_LIST_MAX_VISIBLE_ROWS, AGENT_LIST_CHROME_ROWS),
3477
- this.rows.length,
3478
- );
3479
- const listCursor = Math.min(this.cursor, this.rows.length - 1);
3480
- const start = Math.max(
3481
- 0,
3482
- Math.min(
3483
- listCursor - Math.floor(visibleRows / 2),
3484
- Math.max(0, this.rows.length - visibleRows),
3485
- ),
3486
- );
3487
- const end = Math.min(this.rows.length, start + visibleRows);
3488
- if (start > 0) lines.push(line(` ↑ ${start} more agent(s)`, "muted"));
3489
- for (let i = start; i < end; i++) {
3490
- const row = this.rows[i] ?? SET_ALL_AGENTS;
3491
- const focused = i === this.cursor;
3492
- const label =
3493
- row === SET_ALL_AGENTS
3494
- ? this.renderSetAllLabel(row)
3495
- : this.renderAgentLabel(row);
3496
- lines.push(`${this.renderCursor(focused)} ${label}`);
3497
- }
3498
- if (end < this.rows.length)
3499
- lines.push(line(` ↓ ${this.rows.length - end} more agent(s)`, "muted"));
3500
- lines.push("");
3501
- lines.push(
3502
- `${this.renderCursor(this.cursor === this.rows.length)} ${this.renderText(
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
- `${this.renderCursor(this.cursor === this.rows.length + 1)} ${this.renderText(
3509
- "← Back",
3510
- this.cursor === this.rows.length + 1 ? "accent" : "text",
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
- // Two rows: editing keys, then the keys that leave the panel. One row no
3515
- // longer fits the minimum width once the profile key joins the save keys.
3516
- lines.push(
3517
- line(
3518
- `j/k scroll • enter model/save • e effort • i ${this.rows[this.cursor] === PROFILE_ORCHESTRATOR_KEY ? LEAVE_ORCHESTRATOR_UNTOUCHED : isProviderReviewRole(this.rows[this.cursor] ?? "") ? "Pi persisted defaults" : "inherit"} • c custom`,
3519
- "muted",
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
- line(
3524
- `x export • r restore • ctrl+s save agents • u save choice in "${this.profileLabel}" • esc back`,
3525
- "muted",
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
- private renderModelPicker(width: number): string[] {
3532
- const lines: string[] = [];
3533
- const options = this.filteredModelOptions();
3534
- const line = (text = "", tone?: PanelTone) =>
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
- lines.push("");
3544
- const visibleRows = this.visibleListRows(MODEL_LIST_MAX_VISIBLE_ROWS, MODEL_LIST_CHROME_ROWS);
3545
- const start = Math.max(
3546
- 0,
3547
- Math.min(
3548
- this.modelCursor - Math.floor(visibleRows / 2),
3549
- Math.max(0, options.length - visibleRows),
3550
- ),
3551
- );
3552
- const end = Math.min(options.length, start + visibleRows);
3553
- for (let i = start; i < end; i++) {
3554
- const focused = i === this.modelCursor;
3555
- lines.push(
3556
- `${this.renderCursor(focused)} ${this.renderText(
3557
- options[i] === INHERIT_MODEL && isProviderReviewRole(this.selectedRow)
3558
- ? routingDefaultLabel(this.selectedRow, "model")
3559
- : (options[i] ?? ""),
3560
- focused ? "status" : "text",
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
- if (options.length === 0) lines.push(line(" No matching models", "muted"));
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
- private handleEffortInput(data: string): void {
3573
- if (matchesKey(data, "ctrl+c")) {
3574
- this.done({ type: "cancel" });
3575
- return;
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
- if (matchesKey(data, "escape")) {
3578
- this.mode = "agents";
3579
- return;
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
- if (matchesKey(data, "down") || matchesKey(data, "j")) {
3582
- this.effortCursor = Math.min(
3583
- Math.max(0, THINKING_OPTIONS.length - 1),
3584
- this.effortCursor + 1,
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
- if (matchesKey(data, "up") || matchesKey(data, "k")) {
3589
- this.effortCursor = Math.max(0, this.effortCursor - 1);
3590
- return;
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
- if (!matchesKey(data, "return")) return;
3593
- const selected = THINKING_OPTIONS[this.effortCursor];
3594
- if (selected === INHERIT_THINKING) this.applyThinkingSelection(undefined);
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 renderEffortPicker(width: number): string[] {
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
- line(`Select effort for ${sanitizeTerminalText(this.selectedRow)}`, "title"),
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
- for (let i = 0; i < THINKING_OPTIONS.length; i++) {
3608
- const focused = i === this.effortCursor;
3609
- lines.push(
3610
- `${this.renderCursor(focused)} ${this.renderText(
3611
- THINKING_OPTIONS[i] === INHERIT_THINKING && isProviderReviewRole(this.selectedRow)
3612
- ? routingDefaultLabel(this.selectedRow, "effort")
3613
- : (THINKING_OPTIONS[i] ?? ""),
3614
- focused ? "status" : "text",
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
- lines.push("");
3619
- lines.push(line("j/k: navigate • enter: select • esc: back", "muted"));
3620
- return lines;
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 renderSetAllLabel(row: string): string {
3624
- const models = this.agents
3625
- .map((name) => this.draft[name]?.model ?? routingDefaultLabel(name, "model"));
3626
- const efforts = this.agents
3627
- .map((name) => this.draft[name]?.thinking ?? routingDefaultLabel(name, "effort"));
3628
- const firstModel = models[0] ?? "inherit";
3629
- const firstEffort = efforts[0] ?? "inherit";
3630
- const modelLabel = models.every((value) => value === firstModel)
3631
- ? firstModel
3632
- : "mixed";
3633
- const effortLabel = efforts.every((value) => value === firstEffort)
3634
- ? firstEffort
3635
- : "mixed";
3636
- return `${this.renderText(sanitizeTerminalText(row).padEnd(20), "text")} ${this.renderText("model=", "muted")}${this.renderText(modelLabel, "status")}${this.renderText(
3637
- ", effort=",
3638
- "muted",
3639
- )}${this.renderText(effortLabel, "status")}`;
3640
- }
3641
-
3642
- private renderAgentLabel(row: string): string {
3643
- if (row === PROFILE_ORCHESTRATOR_KEY && !this.draft[row]?.model) {
3644
- return `${this.renderText(row.padEnd(20), "text")} ${this.renderText(LEAVE_ORCHESTRATOR_UNTOUCHED, "status")}`;
3645
- }
3646
- const model = this.draft[row]?.model ?? routingDefaultLabel(row, "model");
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 SddModelPanel(
3665
- initialConfig,
4436
+ const panel = new RoutingTablePanel(
4437
+ draft,
3666
4438
  modelOptions,
3667
4439
  agents,
3668
- () => {},
4440
+ (result) => results.push(result),
3669
4441
  theme,
3670
- undefined,
4442
+ options.profileName ?? "current",
3671
4443
  terminalRows === undefined ? undefined : () => terminalRows,
4444
+ options,
3672
4445
  );
3673
- for (const data of inputs) panel.handleInput(data);
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
- async function showSddModelPanel(
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
- config: AgentModelConfig,
3680
- profileLabel: string,
3681
- ): Promise<ModelPanelResult> {
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<ModelPanelResult>(
3685
- (tui, theme, _keybindings, done) =>
3686
- new SddModelPanel(config, modelOptions, agents, done, theme, profileLabel, () =>
3687
- Math.max(0, tui.terminal.rows),
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
- async function handleModelsCommand(ctx: ExtensionContext, pi: ExtensionAPI): Promise<void> {
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 here will not reach its subagents. Saying it before
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 savedConfig = await readModelRoutingAuthorityAsync(
3710
- modelConfigPath(ctx.cwd),
3711
- legacyProjectModelConfigPath(ctx.cwd),
3712
- );
3713
- if (savedConfig.status === "invalid") {
3714
- ctx.ui.notify(
3715
- `el Gentleman cannot open model config because ${savedConfig.path} is invalid JSON or not an object. Fix or remove the file, then run /gentle:models again.`,
3716
- "warning",
3717
- );
3718
- return;
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
- let config = savedConfig.status === "valid" ? cloneModelConfig(savedConfig.config) : {};
3721
- const store = readProfilesFileResult(profilesFilePath(gentleAiConfigHome()));
3722
- const target = resolveCurrentProfileTarget(ctx.cwd, store);
3723
- // Existing profiles own the initial choice, including absence. Reopens keep the draft.
3724
- delete config[PROFILE_ORCHESTRATOR_KEY];
3725
- if (store.status === "valid" && target && hasOwnProfile(store.file.profiles, target.name)) {
3726
- const entry = store.file.profiles[target.name][PROFILE_ORCHESTRATOR_KEY];
3727
- if (entry) config[PROFILE_ORCHESTRATOR_KEY] = { ...entry };
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 profileLabel = describeCurrentProfileTarget(target);
3732
- let result = await showSddModelPanel(ctx, config, profileLabel);
3733
- while (result.type === "custom" || result.type === "export" || result.type === "restore") {
3734
- config = cloneModelConfig(result.config);
3735
- if (result.type === "export") {
3736
- try {
3737
- const count = await exportSavedModelConfig(ctx);
3738
- ctx.ui.notify(`el Gentleman exported ${count} saved model routing entr${count === 1 ? "y" : "ies"} to ${modelExportPath(ctx.cwd)}.`, "info");
3739
- } catch (error) {
3740
- ctx.ui.notify(`Model routing export failed: ${error instanceof Error ? error.message : String(error)}`, "warning");
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
- if (result.type !== "save" && result.type !== "save-profile") return;
3826
- // The panel's orchestrator is snapshot-only, never a global agent route.
3827
- const routing = cloneModelConfig(result.config);
3828
- delete routing[PROFILE_ORCHESTRATOR_KEY];
3829
- writeModelConfig(ctx.cwd, routing);
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
- ...describeModelConfig(ctx.cwd, routing),
3837
- ].join("\n"),
3838
- "info",
3839
- );
3840
- if (result.type === "save-profile") updateCurrentProfileFromSavedRouting(ctx, result.config[PROFILE_ORCHESTRATOR_KEY]);
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 `u` in `/gentle:models` writes to, and why it is that one. */
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 global active profile and every
3991
- * `/gentle:models` write.
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 /gentle:models writes do not apply here.");
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: `/gentle:models` materializes global routing that a pinned
4048
- * repository will not use for its subagents. The launch resolver decides, so the note
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 the pin with /gentle:profiles (p or P).`,
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: Exclude<ProfilesPanelResult, { type: "close" }>,
4926
+ result: ProfileStoreAction,
4662
4927
  report: ProfilesPanelReport = {},
4663
4928
  ): Promise<AgentProfilesFile> {
4664
4929
  switch (result.type) {
4665
- case "apply": {
4930
+ case "use-session": {
4666
4931
  if (!hasOwnProfile(file.profiles, result.name)) return file;
4667
- // Enter applies a complete routing snapshot to this parent session and
4668
- // switches only its live orchestrator. No shared defaults, materialized
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. Set it as the global default with a instead.`,
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 — shown as "${result.name} (session)". Subagents and reviewers use this session's routing snapshot. Shared defaults were not written: the global routing, pins, and materialized stores are untouched. Set as global default with a.${liveNote}`,
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 = normalizeModelConfig(file.profiles[result.name]) ?? {};
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}"; press P on "${name}" to republish it.` : ""),
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 with /gentle:profiles (p removes the clone pin, P removes the declaration), then delete it.`,
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. Press p again to unpin.",
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
- "Subagent launches resolve it at launch. Commit the file to share the routing; a local pin takes precedence over it. Press P again to remove the declaration.",
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
- async function handleProfilesCommand(ctx: ExtensionContext, live: LiveSession): Promise<void> {
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
- let file: AgentProfilesFile;
5240
- if (read.status === "missing") {
5241
- file = bootstrapProfilesFile(await readEffectiveModelConfigAsync(ctx.cwd));
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
- writeProfilesFileSync(path, file);
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
- ctx.ui.notify(
5246
- `el Gentleman could not create ${path}: ${profilesErrorMessage(error)}`,
5247
- "warning",
5248
- );
5249
- return;
5591
+ feedback = `"${name}" was not saved: ${profilesErrorMessage(error)}`;
5592
+ return false;
5250
5593
  }
5251
- ctx.ui.notify(`el Gentleman seeded the "current" profile in ${path} from the routing currently in effect.`, "info");
5252
- } else {
5253
- file = read.file;
5254
- reportProfilesDrops(ctx, path, read.drops);
5255
- }
5256
- const saveSnapshot: ProfilesSnapshotHandler = (name) => {
5257
- const next = updateProfile(
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
- name,
5260
- profileSnapshotFrom(
5261
- // The snapshot captures the same routing the panel shows as current,
5262
- // so a session binding outranks the shared layers here too.
5263
- readSessionProfileBinding(ctx.sessionManager?.getSessionId?.())?.modelProfiles
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
- let selectedName: string | undefined;
5277
- const sessionBoundName = () => readSessionProfileBinding(ctx.sessionManager?.getSessionId?.())?.name;
5278
- // The panel's "Current routing (effective)" table shows what this session's
5279
- // launches resolve right now, and a session binding outranks every shared
5280
- // layer, so a bound session reads its snapshot as the current routing while an
5281
- // unbound session keeps reading the effective config exactly as before.
5282
- const currentRoutingForPanel = async () =>
5283
- readSessionProfileBinding(ctx.sessionManager?.getSessionId?.())?.modelProfiles
5284
- ?? await readEffectiveModelConfigAsync(ctx.cwd);
5285
- let result = await showProfilesPanel(
5286
- ctx,
5287
- file,
5288
- await currentRoutingForPanel(),
5289
- selectedName,
5290
- saveSnapshot,
5291
- live,
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
- await currentRoutingForPanel(),
5303
- selectedName,
5304
- saveSnapshot,
5305
- live,
5306
- report.status,
5307
- sessionBoundName(),
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
- renderSddModelPanel: renderSddModelPanelForTesting,
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:models again.`,
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: "Create, switch, and manage global agent-model profiles for el Gentleman.",
10682
- handler: async (_args, ctx) => {
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