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
@@ -7,6 +7,7 @@ import { dirname, join } from "node:path";
7
7
  import test from "node:test";
8
8
  import { gzipSync } from "node:zlib";
9
9
  import { initTheme } from "@earendil-works/pi-coding-agent";
10
+ import { visibleWidth } from "@earendil-works/pi-tui";
10
11
  import { validateToolArguments } from "@earendil-works/pi-ai";
11
12
  import type {
12
13
  ExtensionAPI,
@@ -15,9 +16,9 @@ import type {
15
16
  ToolCallEventResult,
16
17
  } from "@earendil-works/pi-coding-agent";
17
18
  import { __testing, applyModelConfig, applyModelConfigAsync, createGentleAiExtension } from "../extensions/gentle-ai.ts";
18
- import { PROFILES_KIND, PROFILES_VERSION, readProfilesFileResult } from "../lib/agent-profiles.ts";
19
+ import { PROFILES_KIND, PROFILES_VERSION, bootstrapProfilesFile, readProfilesFileResult, type AgentProfilesFile } from "../lib/agent-profiles.ts";
19
20
  import { bindSessionProfile, readSessionProfileBinding, resetSessionProfileBindingsForTesting } from "../lib/session-profile-binding.ts";
20
- import type { AgentRoutingEntry, ThinkingLevel } from "../lib/model-routing-authority.ts";
21
+ import type { AgentModelConfig, AgentRoutingEntry, ThinkingLevel } from "../lib/model-routing-authority.ts";
21
22
  type LiveSession = Pick<ExtensionAPI, "setModel" | "setThinkingLevel" | "getThinkingLevel">;
22
23
  import { PROFILE_PIN_KIND, PROFILE_PIN_VERSION, setProfilePinWorktreeResolverForTesting, writeProfilePinSync } from "../lib/agent-profile-pin.ts";
23
24
  import { NATIVE_REVIEW_ERROR_CODE, NativeReviewCliError, type NativeReviewCli } from "../lib/native-review-cli.ts";
@@ -367,6 +368,17 @@ test("registered Gentle Review tools preserve result envelopes and preview usefu
367
368
  interface RoutingConsumerPanel {
368
369
  render(width: number): string[];
369
370
  handleInput(data: string): void;
371
+ handleMouse?(event: {
372
+ type: "move" | "click" | "press" | "wheel";
373
+ x: number;
374
+ y: number;
375
+ button?: string;
376
+ wheelDelta?: number;
377
+ screenX?: number;
378
+ screenY?: number;
379
+ width?: number;
380
+ height?: number;
381
+ }): { handled?: boolean; render?: boolean } | undefined;
370
382
  }
371
383
 
372
384
  function routingConsumerFixture(t: test.TestContext, agents = ["worker"]) {
@@ -436,6 +448,9 @@ function routingConsumerFixture(t: test.TestContext, agents = ["worker"]) {
436
448
  // untouched field returns, and undefined is Esc.
437
449
  const inputAnswers: Array<string | undefined> = [];
438
450
  const inputPrompts: Array<{ title: string; placeholder: string | undefined }> = [];
451
+ // The models screen binds the session profile through the parent session id; a
452
+ // test can point it at another session (or none) without rebuilding the context.
453
+ let sessionId: string | undefined = "session-panel";
439
454
  // The orchestrator model is looked up in the registry before switching.
440
455
  const registryModels = [
441
456
  { provider: "openai", id: "alpha" },
@@ -449,7 +464,7 @@ function routingConsumerFixture(t: test.TestContext, agents = ["worker"]) {
449
464
  hasUI: true,
450
465
  // A real Pi session always has an id; the profiles panel resolves the
451
466
  // session binding through it, so the fixture models one stable session.
452
- sessionManager: { getSessionId: () => "session-panel" },
467
+ sessionManager: { getSessionId: () => sessionId },
453
468
  modelRegistry: {
454
469
  getAvailable: async () => registryModels.filter((model) => model.provider === "openai"),
455
470
  find: (provider: string, id: string) => registryModels.find((model) => model.provider === provider && model.id === id),
@@ -496,11 +511,18 @@ function routingConsumerFixture(t: test.TestContext, agents = ["worker"]) {
496
511
  onInput(action: (panel: RoutingConsumerPanel) => void) { onInput = action; },
497
512
  answerInputs(...answers: Array<string | undefined>) { inputAnswers.push(...answers); },
498
513
  inputPrompts,
499
- run: (name: string) => commands.get(name)!.handler("", ctx),
514
+ setSessionId(next: string | undefined) { sessionId = next; },
515
+ run: (name: string, args = "") => commands.get(name)!.handler(args, ctx),
500
516
  };
501
517
  }
502
518
 
503
- test("models saves and clears independent provider review roles without local artifacts", async (t) => {
519
+ const RIGHT_KEY = "\x1b[C";
520
+
521
+ function typeInto(panel: { handleInput(data: string): void }, text: string): void {
522
+ for (const character of text) panel.handleInput(character);
523
+ }
524
+
525
+ test("models screen saves and clears provider review roles into the profile store without local artifacts", async (t) => {
504
526
  const fixture = routingConsumerFixture(t, []);
505
527
  const roles = ["review-refuter", "review-validator"];
506
528
  assert.deepEqual(
@@ -509,6 +531,7 @@ test("models saves and clears independent provider review roles without local ar
509
531
  "requires zero discovered agents; check package-sibling legacy agent directories",
510
532
  );
511
533
 
534
+ const storePath = join(fixture.configHome, "profiles.json");
512
535
  const assertNoLocalArtifacts = () => {
513
536
  for (const base of [join(fixture.root, ".pi"), fixture.agentHome]) {
514
537
  assert.equal(existsSync(join(base, "subagents.json")), false);
@@ -518,59 +541,63 @@ test("models saves and clears independent provider review roles without local ar
518
541
  }
519
542
  }
520
543
  }
544
+ assert.equal(existsSync(fixture.globalPath), false, "routing edits stay in the profile store until an explicit apply");
521
545
  };
546
+
547
+ let visits = 0;
522
548
  fixture.onInput((panel) => {
523
- const initial = renderComponent(panel);
524
- for (const role of roles) {
525
- assert.equal(initial.split(role).length - 1, 1);
526
- }
527
- assert.match(initial, /Pi persisted default model/);
528
- assert.match(initial, /Pi persisted default effort/);
529
- panel.handleInput("j"); // Skip the snapshot-only orchestrator row.
530
- for (const [index, model] of ["alpha", "beta"].entries()) {
549
+ visits += 1;
550
+ if (visits === 1) {
551
+ // Rows: orchestrator, set-every-agent header, then the two roles.
531
552
  panel.handleInput("j");
532
- panel.handleInput("\r");
533
- assert.match(renderComponent(panel), /Pi persisted default model/);
534
- assert.doesNotMatch(renderComponent(panel), /Inherit active\/default model/);
535
- for (const character of model) panel.handleInput(character);
536
- panel.handleInput("\r");
537
- panel.handleInput("e");
538
- assert.match(renderComponent(panel), /Pi persisted default effort/);
539
- assert.doesNotMatch(renderComponent(panel), /Inherit effort/);
540
- for (let step = 0; step <= index; step++) panel.handleInput("j");
541
- panel.handleInput("\r");
553
+ panel.handleInput("j");
554
+ for (const [index, model] of ["alpha", "beta"].entries()) {
555
+ if (index > 0) panel.handleInput("j");
556
+ panel.handleInput("\r"); // model card for the role
557
+ assert.match(renderComponent(panel), /Pi persisted default model/);
558
+ assert.doesNotMatch(renderComponent(panel), /Inherit active\/default model/);
559
+ typeInto(panel, model);
560
+ panel.handleInput("\r");
561
+ panel.handleInput("e"); // effort strip for the same role
562
+ assert.match(renderComponent(panel), /Pi persisted default effort/);
563
+ for (let step = 0; step <= index; step++) panel.handleInput(RIGHT_KEY);
564
+ panel.handleInput("\r");
565
+ }
566
+ panel.handleInput("\x13"); // save the draft into the profile
567
+ return;
542
568
  }
543
- panel.handleInput("\x13");
569
+ panel.handleInput("\x1b");
544
570
  });
545
- await fixture.run("gentle:models");
546
- assert.deepEqual(JSON.parse(readFileSync(fixture.globalPath, "utf8")), {
571
+ await fixture.run("gentle:profiles");
572
+ assert.equal(visits, 2);
573
+ assert.deepEqual(JSON.parse(readFileSync(storePath, "utf8")).profiles.current, {
547
574
  "review-refuter": { model: "openai/alpha", thinking: "off" },
548
575
  "review-validator": { model: "openai/beta", thinking: "minimal" },
549
576
  });
550
577
  assertNoLocalArtifacts();
551
578
 
579
+ // Clearing entries stays in the store, too.
580
+ visits = 0;
552
581
  fixture.onInput((panel) => {
553
- panel.handleInput("j");
554
- panel.handleInput("j");
555
- panel.handleInput("i");
556
- panel.handleInput("\x13");
557
- });
558
- await fixture.run("gentle:models");
559
- assert.deepEqual(JSON.parse(readFileSync(fixture.globalPath, "utf8")), {
560
- "review-refuter": {},
561
- "review-validator": { model: "openai/beta", thinking: "minimal" },
562
- });
563
- assertNoLocalArtifacts();
564
-
565
- fixture.onInput((panel) => {
566
- panel.handleInput("j");
567
- panel.handleInput("j");
568
- panel.handleInput("j");
569
- panel.handleInput("i");
570
- panel.handleInput("\x13");
582
+ visits += 1;
583
+ if (visits === 1) {
584
+ panel.handleInput("j");
585
+ panel.handleInput("j");
586
+ for (const role of roles) {
587
+ if (role !== "review-refuter") panel.handleInput("j");
588
+ panel.handleInput("\r"); // model card; the cursor starts on the current value
589
+ for (let step = 0; step < 10; step++) panel.handleInput("\x1b[A"); // up to the inherit label, first in the list
590
+ assert.match(renderComponent(panel), /▸ Pi persisted default model/);
591
+ panel.handleInput("\r");
592
+ panel.handleInput("e");
593
+ for (let step = 0; step < 10; step++) panel.handleInput("\x1b[D"); // back to inherit, first in the row
594
+ panel.handleInput("\r");
595
+ }
596
+ panel.handleInput("\x13");
597
+ } else panel.handleInput("\x1b");
571
598
  });
572
- await fixture.run("gentle:models");
573
- assert.deepEqual(JSON.parse(readFileSync(fixture.globalPath, "utf8")), {
599
+ await fixture.run("gentle:profiles");
600
+ assert.deepEqual(JSON.parse(readFileSync(storePath, "utf8")).profiles.current, {
574
601
  "review-refuter": {},
575
602
  "review-validator": {},
576
603
  });
@@ -608,10 +635,22 @@ test("provider review roles skip migration and both projection paths even when d
608
635
  rolePaths.forEach((path, index) => assert.equal(readFileSync(path, "utf8"), originals[index]));
609
636
  for (const path of profilePaths) assert.equal(readFileSync(path, "utf8"), profile);
610
637
  };
611
- await fixture.run("gentle:models");
638
+ let visits = 0;
639
+ fixture.onInput((panel) => {
640
+ visits += 1;
641
+ if (visits === 1) {
642
+ // Rows: orchestrator, set-every-agent header, then the roles and worker.
643
+ const table = renderComponent(panel);
644
+ for (const role of roles) assert.equal(table.split(role).length - 1, 1);
645
+ assert.match(table, /worker\s+inherit/, "a discoverable agent with no profile entry reads as inherit");
646
+ panel.handleInput("\x1b");
647
+ return;
648
+ }
649
+ panel.handleInput("\x1b");
650
+ });
651
+ await fixture.run("gentle:profiles");
652
+ assert.equal(visits, 1);
612
653
  assertReservedUnchanged();
613
- for (const role of roles) assert.equal(fixture.panels[0].split(role).length - 1, 1);
614
- assert.match(fixture.panels[0], /worker\s+model=inherit, effort=inherit/);
615
654
  for (const apply of [applyModelConfig, applyModelConfigAsync]) {
616
655
  await apply(fixture.root, assignments);
617
656
  assertReservedUnchanged();
@@ -623,28 +662,23 @@ test("provider review roles skip migration and both projection paths even when d
623
662
  assert.ok(JSON.parse(readFileSync(profilePaths[0], "utf8")).model_profiles.worker);
624
663
  });
625
664
 
626
- test("models rejects invalid project routing with its selected source path", async (t) => {
665
+ test("an invalid saved routing file does not block the models screen", async (t) => {
627
666
  const fixture = routingConsumerFixture(t);
628
667
  writeFileSync(fixture.projectPath, "[]");
629
- await fixture.run("gentle:models");
630
- assert.equal(fixture.notifications[0]?.severity, "warning");
631
- assert.ok(fixture.notifications[0]?.message.includes(fixture.projectPath));
632
- assert.equal(fixture.panelVisits(), 0);
668
+ fixture.onInput((panel) => panel.handleInput("\x1b"));
669
+ await fixture.run("gentle:profiles");
670
+ assert.equal(fixture.panelVisits(), 1, "Make default discloses the unreadable routing instead");
633
671
  });
634
672
 
635
- test("export re-reads saved routing and rejects invalid project before creating its destination parent", async (t) => {
673
+ test("models export re-reads saved routing and rejects invalid project before creating its destination parent", async (t) => {
636
674
  const fixture = routingConsumerFixture(t);
637
- writeFileSync(fixture.projectPath, '{"worker":"openai/gpt-5"}');
638
- fixture.onPanel(() => {
639
- if (fixture.panelVisits() > 1) return { type: "cancel", config: {} };
640
- writeFileSync(fixture.projectPath, "[]");
641
- return { type: "export", config: {} };
642
- });
643
- await fixture.run("gentle:models");
644
- assert.equal(fixture.notifications[0]?.severity, "warning");
645
- assert.ok(fixture.notifications[0]?.message.includes(`Invalid model config: ${fixture.projectPath}`));
675
+ writeFileSync(fixture.projectPath, "[]");
676
+ await fixture.run("gentle:profiles", "export-routing");
677
+ assert.equal(fixture.panelVisits(), 0, "export is a subcommand: no screen opens");
678
+ const exportWarning = fixture.notifications.find((entry) => entry.severity === "warning");
679
+ assert.equal(exportWarning?.severity, "warning");
680
+ assert.ok(exportWarning?.message.includes(`Invalid model config: ${fixture.projectPath}`));
646
681
  assert.equal(existsSync(fixture.exportPath), false);
647
- assert.equal(existsSync(fixture.configHome), false);
648
682
  });
649
683
 
650
684
  test("status reports invalid saved routing path instead of default agent routing", async (t) => {
@@ -664,15 +698,14 @@ test("models exports missing, normalized project, and global-precedence saved ro
664
698
  const fixture = routingConsumerFixture(t);
665
699
  if (source !== "missing") writeFileSync(fixture.projectPath, '{"worker":" openai/gpt-5 ","ignored":null}');
666
700
  if (source === "global") writeMarkdown(fixture.globalPath, '{"worker":{"model":" anthropic/opus ","thinking":"high"}}');
667
- fixture.onPanel(() => ({ type: fixture.panelVisits() === 1 ? "export" : "cancel", config: {} }));
668
- await fixture.run("gentle:models");
701
+ await fixture.run("gentle:profiles", "export-routing");
669
702
  const agents = source === "missing" ? {} : source === "project"
670
703
  ? { worker: { model: "openai/gpt-5" } }
671
704
  : { worker: { model: "anthropic/opus", thinking: "high" } };
672
705
  assert.deepEqual(JSON.parse(readFileSync(fixture.exportPath, "utf8")).agents, agents);
673
- assert.equal(fixture.notifications[0]?.severity, "info");
674
- assert.match(fixture.notifications[0]!.message, /exported/);
675
- assert.equal(fixture.panelVisits(), 2);
706
+ const exported = fixture.notifications.find((entry) => /exported/.test(entry.message));
707
+ assert.equal(exported?.severity, "info");
708
+ assert.equal(fixture.panelVisits(), 0);
676
709
  await fixture.run("gentle:status");
677
710
  const report = fixture.notifications.at(-1)!.message;
678
711
  assert.ok(report.includes(`Saved model routing: ${source === "missing" ? "missing" : "valid"}`));
@@ -680,27 +713,25 @@ test("models exports missing, normalized project, and global-precedence saved ro
680
713
  const expectedRouting = source === "missing" ? "inherit, effort=inherit"
681
714
  : source === "project" ? "openai/gpt-5, effort=inherit" : "anthropic/opus, effort=high";
682
715
  assert.ok(report.includes(`worker: model=${expectedRouting}`), report);
683
- assert.ok(fixture.panels[0].includes(`model=${expectedRouting}`), fixture.panels[0]);
684
716
  });
685
717
  }
686
718
  });
687
719
 
688
720
  test("invalid global routing overrides valid project in models, status, and export re-read", async (t) => {
689
721
  for (const atExport of [false, true]) {
690
- await t.test(atExport ? "invalidated during panel" : "invalid before panel", async (t) => {
722
+ await t.test(atExport ? "export" : "screen", async (t) => {
691
723
  const fixture = routingConsumerFixture(t);
692
724
  writeFileSync(fixture.projectPath, '{"worker":"openai/gpt-5"}');
693
- if (!atExport) writeMarkdown(fixture.globalPath, "[]");
694
- fixture.onPanel(() => {
695
- if (fixture.panelVisits() > 1) return { type: "cancel", config: {} };
696
- writeMarkdown(fixture.globalPath, "[]");
697
- return { type: "export", config: {} };
698
- });
699
- await fixture.run("gentle:models");
700
- assert.equal(fixture.notifications[0]?.severity, "warning");
701
- assert.ok(fixture.notifications[0]?.message.includes(fixture.globalPath));
702
- assert.match(fixture.notifications[0]!.message, atExport ? /export failed/ : /cannot open model config/);
703
- assert.equal(fixture.panelVisits(), atExport ? 2 : 0);
725
+ writeMarkdown(fixture.globalPath, "[]");
726
+ fixture.onInput((panel) => panel.handleInput("\x1b"));
727
+ await fixture.run("gentle:profiles", atExport ? "export-routing" : "");
728
+ if (atExport) {
729
+ const routingWarning = fixture.notifications.find((entry) => entry.severity === "warning");
730
+ assert.equal(routingWarning?.severity, "warning");
731
+ assert.ok(routingWarning?.message.includes(fixture.globalPath));
732
+ assert.match(routingWarning!.message, /export failed/);
733
+ }
734
+ assert.equal(fixture.panelVisits(), atExport ? 0 : 1, "export opens no screen; the screen opens and Make default discloses the unreadable routing");
704
735
  assert.equal(existsSync(fixture.exportPath), false);
705
736
  await fixture.run("gentle:status");
706
737
  const report = fixture.notifications.at(-1)!;
@@ -1267,64 +1298,252 @@ test("per-JD-agent model assignment keeps judge-a and judge-b profiles divergent
1267
1298
  );
1268
1299
  });
1269
1300
 
1270
- test("model panel render does not auto-apply the Gentle theme and sanitizes agent labels", () => {
1271
- const lines = __testing.renderSddModelPanel(
1272
- {},
1273
- ["openai/gpt-5.5"],
1274
- ["safe-agent\x1b[31m"],
1275
- 72,
1301
+ // ---------------------------------------------------------------------------
1302
+ // Models screen (/gentle:profiles): one table, profiles as tabs.
1303
+ // ---------------------------------------------------------------------------
1304
+
1305
+ const SCREEN_AGENTS = ["gentle-ai-explore", "gentle-ai-verify", "gentle-ai-worker"];
1306
+ const SCREEN_MODELS = ["nan/deepseek-v4-flash", "nan/glm5.3", "openai/gpt-5.5"];
1307
+
1308
+ function screenConfig(): AgentModelConfig {
1309
+ const config: AgentModelConfig = { orchestrator: { model: "openai-codex/gpt-6.1-sol", thinking: "max" } };
1310
+ for (const agent of SCREEN_AGENTS) config[agent] = { model: "nan/deepseek-v4-flash", thinking: "high" };
1311
+ return config;
1312
+ }
1313
+
1314
+ function screenFile(): AgentProfilesFile {
1315
+ const file = bootstrapProfilesFile(screenConfig());
1316
+ return { ...file, profiles: { ...file.profiles, nan: { orchestrator: { model: "nan/glm5.3", thinking: "high" } } }, active: "current" };
1317
+ }
1318
+
1319
+ type ScreenEvent = { type: "move" | "click" | "wheel"; x: number; y: number; wheelDelta?: number };
1320
+
1321
+ function renderScreen(
1322
+ inputs: string[] = [],
1323
+ mouse: ScreenEvent[] = [],
1324
+ results: unknown[] = [],
1325
+ options: { width?: number; rows?: number; theme?: Theme; thinkingSupport?: Record<string, ThinkingLevel[]>; notices?: string[] } = {},
1326
+ ): string[] {
1327
+ return __testing.routingTablePanel(
1328
+ screenConfig(),
1329
+ SCREEN_MODELS,
1330
+ SCREEN_AGENTS,
1331
+ options.width ?? 100,
1332
+ options.theme,
1333
+ options.rows ?? 24,
1334
+ inputs,
1335
+ mouse,
1336
+ results as never[],
1337
+ { file: screenFile(), sessionBoundName: "current", thinkingSupport: options.thinkingSupport, notices: options.notices },
1276
1338
  );
1339
+ }
1340
+
1341
+ /** Where a piece of text is on a rendered screen, as pointer coordinates. */
1342
+ function screenAt(lines: string[], needle: string): { x: number; y: number } {
1343
+ const plain = lines.map(stripAnsi);
1344
+ const y = plain.findIndex((line) => line.includes(needle));
1345
+ assert.ok(y >= 0, `not on screen: ${needle}`);
1346
+ return { x: plain[y].indexOf(needle) + 1, y };
1347
+ }
1348
+
1349
+ test("models screen render does not auto-apply the Gentle theme and sanitizes agent labels", () => {
1350
+ const lines = __testing.routingTablePanel({}, ["openai/gpt-5.5"], ["safe-agent\x1b[31m"], 72);
1277
1351
  const rendered = lines.join("\n");
1278
1352
  const plain = stripAnsi(rendered);
1279
-
1280
1353
  assert.doesNotMatch(cardBody(rendered), /\x1b\[38;2;71;85;105m/);
1281
1354
  assert.doesNotMatch(cardBody(rendered), /\x1b\[38;2;125;211;252m/);
1282
- assert.match(plain, /Assign Models and Effort to Agents/);
1283
- assert.match(plain, /safe-agent\s+model=inherit, effort=inherit/);
1355
+ assert.match(plain, /current/);
1356
+ assert.match(plain, /safe-agent\s+inherit\s+inherit/);
1284
1357
  assert.doesNotMatch(plain, /\[31m/);
1285
1358
  });
1286
1359
 
1287
- test("model panel fills the terminal height like the profiles panel", () => {
1288
- const lines = __testing.renderSddModelPanel({}, ["openai/gpt-5.5"], ["safe-agent"], 72, undefined, 40);
1360
+ test("models screen fills the terminal height and keeps every line inside the frame", () => {
1361
+ const lines = renderScreen([], [], [], { width: 72, rows: 40 });
1289
1362
  assert.equal(lines.length, 40);
1290
1363
  const plain = lines.map(stripAnsi);
1291
1364
  assert.match(plain[0] ?? "", /^╭─+╮$/);
1292
1365
  assert.match(plain[39] ?? "", /^╰─+╯$/);
1293
- for (const line of plain) assert.equal(line.length, 72);
1366
+ for (const line of plain) assert.equal(visibleWidth(line), 72);
1294
1367
  });
1295
1368
 
1296
- test("model panel lists grow with the terminal height instead of a fixed window", () => {
1369
+ test("models screen shows profiles as tabs with their scope, the orchestrator first, and three buttons", () => {
1370
+ const plain = renderScreen().map(stripAnsi);
1371
+ assert.match(plain[1] ?? "", /current ◉★ ▾\s+nan\s+\+ New/, "tabs carry ◉ session and ★ default; ▾ opens the profile menu");
1372
+ assert.match(plain[3] ?? "", /current\s+Used in this session\. Default for new sessions\./);
1373
+ assert.match(plain[5] ?? "", /▸ Orchestrator\s+openai-codex\/gpt-6\.1-sol\s+max/);
1374
+ assert.match(plain.join("\n"), /Agent\s+Model ▾\s+Effort ▾\s+set every agent/);
1375
+ assert.match(plain.join("\n"), /\[ Use in this session \] \[ Make default \] \[ Save \]\s+Saved/);
1376
+ assert.match(plain.join("\n"), /←→ profile {2}↑↓ agent {2}enter model {2}e effort {2}s save {2}u use {2}d default {2}n new {2}m menu {2}esc close/);
1377
+ assert.doesNotMatch(plain.join("\n"), /⋯/, "no ellipsis-like glyph: it reads as cut text");
1378
+ });
1379
+
1380
+ test("models screen agent rows grow with the terminal height", () => {
1297
1381
  const agents = Array.from({ length: 40 }, (_, i) => `agent-${String(i).padStart(2, "0")}`);
1298
1382
  const models = Array.from({ length: 60 }, (_, i) => `provider/model-${String(i).padStart(2, "0")}`);
1299
- const agentLines = __testing
1300
- .renderSddModelPanel({}, models, agents, 100, undefined, 40)
1301
- .map(stripAnsi);
1302
- assert.equal(agentLines.length, 40);
1303
- // 40 rows minus 15 rows of chrome: agents, "Set all", and the orchestrator share the space.
1304
- assert.equal(agentLines.filter((line) => /(agent-\d\d|Set all agents)\s+model=|orchestrator\s+leave untouched/.test(line)).length, 25);
1305
- assert.ok(agentLines.some((line) => /x export/.test(line)));
1306
-
1307
- const pickerLines = __testing
1308
- .renderSddModelPanel({}, models, agents, 100, undefined, 40, ["\r"])
1309
- .map(stripAnsi);
1310
- assert.equal(pickerLines.length, 40);
1311
- // 40 rows minus 8 rows of chrome.
1312
- assert.equal(pickerLines.filter((line) => /provider\/model-\d\d/.test(line)).length, 32);
1383
+ const table = __testing.routingTablePanel({}, models, agents, 100, undefined, 40).map(stripAnsi);
1384
+ assert.equal(table.length, 40);
1385
+ // 40 rows minus the frame, 12 fixed body rows, and two scroll indicators.
1386
+ assert.equal(table.filter((line) => /agent-\d\d\s/.test(line)).length, 24);
1387
+ assert.ok(table.some((line) => /↓ \d+ more/.test(line)));
1388
+ const card = __testing.routingTablePanel({}, models, agents, 100, undefined, 40, ["\r"]).map(stripAnsi);
1389
+ // The model card windows its options to the terminal height minus its chrome.
1390
+ assert.equal(card.filter((line) => /provider\/model-\d\d/.test(line)).length, 28);
1313
1391
  });
1314
1392
 
1315
- test("model panel render uses the Pi-provided current theme when supplied", () => {
1316
- const currentTheme = {
1317
- fg(_color: string, text: string): string {
1318
- return `\x1b[35m${text}\x1b[39m`;
1319
- },
1320
- } as unknown as Theme;
1393
+ test("models screen uses the Pi-provided theme when supplied", () => {
1394
+ const theme = { fg: (_color: string, text: string) => `\x1b[35m${text}\x1b[39m`, bg: (_color: string, text: string) => text } as unknown as Theme;
1395
+ assert.match(renderScreen([], [], [], { theme }).join("\n"), /\x1b\[35m/);
1396
+ });
1321
1397
 
1322
- const rendered = __testing
1323
- .renderSddModelPanel({}, ["openai/gpt-5.5"], ["safe-agent"], 72, currentTheme)
1324
- .join("\n");
1398
+ test("models screen pointer: hover uses selectedBg, and one click runs exactly one action", () => {
1399
+ const base = renderScreen();
1400
+ const hoverTheme = { fg: (_color: string, text: string) => text, bg: (color: string, text: string) => `<${color}>${text}</${color}>` } as unknown as Theme;
1401
+ const worker = screenAt(base, "gentle-ai-worker");
1402
+ const hovered = renderScreen([], [{ type: "move", ...worker }], [], { theme: hoverTheme }).join("\n");
1403
+ assert.match(hovered, /<selectedBg>\s*gentle-ai-worker/, "row hover fills with the selection background");
1404
+ assert.doesNotMatch(hovered, /toolPendingBg/, "the pending background is a shade off the terminal background");
1405
+
1406
+ const model = screenAt(base, "nan/deepseek-v4-flash");
1407
+ const card = renderScreen([], [{ type: "click", x: model.x, y: worker.y }]).map(stripAnsi);
1408
+ assert.ok(card.some((line) => line.includes("Model for gentle-ai-worker")), "a click on a model opens that agent's card");
1409
+ assert.match(card[1] ?? "", /current ◉★ ▾\s+nan/, "the screen stays behind the card");
1410
+ const option = screenAt(card, "openai/gpt-5.5");
1411
+ const picked = renderScreen([], [{ type: "click", x: model.x, y: worker.y }, { type: "click", ...option }]).map(stripAnsi);
1412
+ assert.ok(picked.some((line) => /gentle-ai-worker\s+openai\/gpt-5\.5/.test(line)));
1413
+ assert.ok(picked.some((line) => /gentle-ai-explore\s+nan\/deepseek-v4-flash/.test(line)), "other agents are untouched");
1414
+ assert.ok(picked.some((line) => line.includes("● 1 unsaved")), "press and click of one click change one value once");
1415
+
1416
+ const effort = screenAt(base, "high");
1417
+ const strip = renderScreen([], [{ type: "click", x: effort.x, y: worker.y }]);
1418
+ const chip = screenAt(strip, " low ");
1419
+ const efforted = renderScreen([], [{ type: "click", x: effort.x, y: worker.y }, { type: "click", ...chip }]).map(stripAnsi);
1420
+ assert.ok(efforted.some((line) => /gentle-ai-worker\s+nan\/deepseek-v4-flash\s+low/.test(line)));
1421
+
1422
+ const header = screenAt(base, "Model ▾");
1423
+ const all = renderScreen([], [{ type: "click", ...header }]);
1424
+ const glm = screenAt(all, "nan/glm5.3");
1425
+ const allSet = renderScreen([], [{ type: "click", ...header }, { type: "click", ...glm }]).map(stripAnsi);
1426
+ assert.equal(allSet.filter((line) => /gentle-ai-\w+\s+nan\/glm5\.3\s/.test(line)).length, 3, "Model ▾ sets every agent");
1427
+ assert.ok(allSet.some((line) => /Orchestrator\s+openai-codex/.test(line)), "and never the orchestrator");
1428
+
1429
+ const outside = renderScreen([], [{ type: "click", x: model.x, y: worker.y }, { type: "click", x: 3, y: 21 }]).map(stripAnsi);
1430
+ assert.ok(!outside.some((line) => line.includes("Model for")), "a click outside a card closes it");
1431
+
1432
+ const results: Array<{ type: string; name?: string; saveFirst?: AgentModelConfig }> = [];
1433
+ renderScreen([], [{ type: "click", ...screenAt(base, "[ Use in this session ]") }], results);
1434
+ renderScreen([], [{ type: "click", ...screenAt(base, "[ Make default ]") }], results);
1435
+ renderScreen([], [{ type: "click", ...screenAt(base, " nan ") }], results);
1436
+ assert.deepEqual(results.map((result) => result.type), ["use-session", "apply-global", "load"]);
1437
+
1438
+ const menu = renderScreen([], [{ type: "click", ...screenAt(base, "▾") }]);
1439
+ const renamed: Array<{ type: string }> = [];
1440
+ renderScreen([], [{ type: "click", ...screenAt(base, "▾") }, { type: "click", ...screenAt(menu, "Rename") }], renamed);
1441
+ assert.equal(renamed[0]?.type, "rename");
1442
+
1443
+ const newMenu = renderScreen([], [{ type: "click", ...screenAt(base, "+ New") }]);
1444
+ const created: Array<{ type: string }> = [];
1445
+ renderScreen([], [{ type: "click", ...screenAt(base, "+ New") }, { type: "click", ...screenAt(newMenu, "From what's running now") }], created);
1446
+ assert.deepEqual(created.map((result) => result.type), ["create-live"]);
1447
+ });
1325
1448
 
1326
- assert.match(rendered, /\x1b\[35m/);
1327
- assert.match(stripAnsi(rendered), /Assign Models and Effort to Agents/);
1449
+ test("models screen asks before leaving a profile with unsaved changes", () => {
1450
+ const base = renderScreen();
1451
+ const worker = screenAt(base, "gentle-ai-worker");
1452
+ const model = screenAt(base, "nan/deepseek-v4-flash");
1453
+ const card = renderScreen([], [{ type: "click", x: model.x, y: worker.y }]);
1454
+ const option = screenAt(card, "openai/gpt-5.5");
1455
+ const edit = [{ type: "click" as const, x: model.x, y: worker.y }, { type: "click" as const, ...option }];
1456
+ const confirm = renderScreen([], [...edit, { type: "click", ...screenAt(base, " nan ") }]);
1457
+ assert.ok(confirm.map(stripAnsi).some((line) => line.includes("current has 1 unsaved change.")));
1458
+ for (const [button, expected] of [
1459
+ ["[ Save and switch ]", { type: "load", name: "nan", saved: "openai/gpt-5.5" }],
1460
+ ["[ Discard ]", { type: "load", name: "nan", saved: undefined }],
1461
+ ["[ Stay ]", undefined],
1462
+ ] as const) {
1463
+ const results: Array<{ type: string; name?: string; saveFirst?: AgentModelConfig }> = [];
1464
+ renderScreen([], [...edit, { type: "click", ...screenAt(base, " nan ") }, { type: "click", ...screenAt(confirm, button) }], results);
1465
+ if (expected === undefined) {
1466
+ assert.deepEqual(results, [], "Stay keeps the screen and the edits");
1467
+ continue;
1468
+ }
1469
+ assert.equal(results[0]?.type, expected.type);
1470
+ assert.equal(results[0]?.name, expected.name);
1471
+ assert.equal(results[0]?.saveFirst?.["gentle-ai-worker"]?.model, expected.saved);
1472
+ }
1473
+ });
1474
+
1475
+ test("models screen keys: esc twice discards unsaved edits, and each key means one thing", () => {
1476
+ const armed: unknown[] = [];
1477
+ const lines = renderScreen(["j", "j", "j", "\r", "g", "l", "m", "\r", "\x1b"], [], armed).map(stripAnsi);
1478
+ assert.deepEqual(armed, [], "the first esc with unsaved edits only warns");
1479
+ assert.match(profilesPanelFooter(lines.join("\n")), /Unsaved changes\. Press esc again to close without saving\./);
1480
+ const closed: Array<{ type: string }> = [];
1481
+ renderScreen(["j", "j", "j", "\r", "g", "l", "m", "\r", "\x1b", "\x1b"], [], closed);
1482
+ assert.deepEqual(closed.map((result) => result.type), ["close"]);
1483
+
1484
+ const saved: Array<{ type: string }> = [];
1485
+ renderScreen(["s"], [], saved);
1486
+ assert.deepEqual(saved, [], "s with nothing to save is a no-op");
1487
+ assert.match(profilesPanelFooter(renderScreen(["s"]).map(stripAnsi).join("\n")), /Nothing to save\./);
1488
+ const keyed: Array<{ type: string }> = [];
1489
+ for (const key of ["u", "d", "\x1b[C"]) renderScreen([key], [], keyed);
1490
+ assert.deepEqual(keyed.map((result) => result.type), ["use-session", "apply-global", "load"]);
1491
+
1492
+ // Terminals using the kitty keyboard protocol send letters as escape
1493
+ // sequences: "e" arrives as \x1b[101u and must still open the effort row.
1494
+ assert.ok(renderScreen(["\x1b[101u"]).map(stripAnsi).some((line) => line.includes("Effort for the orchestrator")));
1495
+ const kittyKeyed: Array<{ type: string }> = [];
1496
+ renderScreen(["\x1b[117u"], [], kittyKeyed); // u
1497
+ assert.deepEqual(kittyKeyed.map((result) => result.type), ["use-session"]);
1498
+ });
1499
+
1500
+ test("models screen fits narrow terminals without cutting text", () => {
1501
+ for (const width of [60, 70, 80, 90]) {
1502
+ for (const keys of [[], ["e"], ["\r"], ["m"], ["n"]]) {
1503
+ const plain = renderScreen(keys, [], [], { width, rows: 24 }).map(stripAnsi);
1504
+ assert.equal(plain.filter((line) => visibleWidth(line) !== width).length, 0, `${width} ${keys}`);
1505
+ const cut = plain.filter((line) => line.replace("Custom model id…", "").includes("…") && !/openai-codex|deepseek|glm/.test(line));
1506
+ assert.deepEqual(cut, [], `${width} ${keys}`);
1507
+ }
1508
+ }
1509
+ const narrow = renderScreen([], [], [], { width: 60 }).map(stripAnsi).join("\n");
1510
+ assert.match(narrow, /\[ Use here \] \[ Default \] \[ Save \]/, "short labels when the full ones do not fit");
1511
+ });
1512
+
1513
+ test("models screen offers only the effort levels each model supports and shows where Pi lands the rest", async () => {
1514
+ const thinkingSupport = {
1515
+ "nan/deepseek-v4-flash": ["off", "minimal", "low", "medium", "high"],
1516
+ "openai-codex/gpt-6.1-sol": ["off", "minimal", "low", "medium", "high", "xhigh"],
1517
+ } satisfies Record<string, ThinkingLevel[]>;
1518
+ const table = renderScreen([], [], [], { thinkingSupport }).map(stripAnsi);
1519
+ assert.ok(table.some((line) => /Orchestrator\s+openai-codex\/gpt-6\.1-sol\s+max → xhigh/.test(line)));
1520
+ const card = renderScreen(["j", "j", "e"], [], [], { thinkingSupport }).map(stripAnsi);
1521
+ assert.ok(card.some((line) => line.includes("Levels nan/deepseek-v4-flash supports.")));
1522
+ const chips = card.find((line) => line.includes("inherit") && line.includes("off")) ?? "";
1523
+ assert.match(chips, /\[high\]/);
1524
+ assert.doesNotMatch(chips, /xhigh|max/);
1525
+
1526
+ const ctx = { modelRegistry: { getAvailable: async () => [
1527
+ { provider: "openai", id: "alpha" },
1528
+ { provider: "nan", id: "chat", reasoning: false },
1529
+ { provider: "nan", id: "think", reasoning: true },
1530
+ { provider: "openai", id: "deep", reasoning: true, thinkingLevelMap: { xhigh: "xhigh", max: null } },
1531
+ ] } } as unknown as ExtensionContext;
1532
+ const support = await __testing.piThinkingSupport(ctx);
1533
+ assert.equal(support["openai/alpha"], undefined, "no reasoning flag: unknown, every level offered");
1534
+ assert.deepEqual(support["nan/chat"], ["off"]);
1535
+ assert.deepEqual(support["nan/think"], ["off", "minimal", "low", "medium", "high"]);
1536
+ assert.deepEqual(support["openai/deep"], ["off", "minimal", "low", "medium", "high", "xhigh"]);
1537
+ });
1538
+
1539
+ test("models screen lists configuration problems under the profile name without cutting them", () => {
1540
+ const notices = [
1541
+ "The local pin file /home/user/project/.git/gentle-ai/profile-pin.json is not a valid pin and is ignored. Fix or remove it.",
1542
+ 'No agent matches "sdd-design" in this profile, so it is ignored at launch.',
1543
+ ];
1544
+ const plain = renderScreen([], [], [], { width: 80, notices }).map(stripAnsi);
1545
+ assert.ok(plain.some((line) => line.includes('⚠ No agent matches "sdd-design"')));
1546
+ assert.ok(!plain.some((line) => line.includes("…")));
1328
1547
  });
1329
1548
 
1330
1549
  test("delivery commands bypass RDD under every mode outcome while command safety remains independent", async () => {
@@ -2022,18 +2241,59 @@ test("bash tool_call confirms every compound action and centers a long git -C pu
2022
2241
  // /gentle:profiles reopens its panel after actions that finish the interaction,
2023
2242
  // so a test that applies once must confirm on the first visit and close on the
2024
2243
  // next, or the panel and the action loop feed each other forever.
2025
- function applyOnce(
2026
- fixture: { onInput(action: (panel: { handleInput(data: string): void }) => void): void },
2244
+ type ScreenPanel = { handleInput(data: string): void; render(width: number): string[] };
2245
+
2246
+ /** The profile the models screen shows, read from the line under the tabs. */
2247
+ function shownProfile(panel: ScreenPanel): string {
2248
+ const line = stripAnsi(renderComponent(panel)).split("\n")[3] ?? "";
2249
+ return line.replace(/^│\s*/, "").split(/\s{3}|\s*│/)[0]?.trim() ?? "";
2250
+ }
2251
+
2252
+ /** The profile names on the tab line, in order. */
2253
+ function tabNames(panel: ScreenPanel): string[] {
2254
+ const line = stripAnsi(renderComponent(panel)).split("\n")[1] ?? "";
2255
+ return line
2256
+ .replace(/^│/, "")
2257
+ .replace(/│\s*$/, "")
2258
+ .split(/\s{2,}/)
2259
+ .map((token) => token.trim())
2260
+ .filter((token) => token !== "" && token !== "+ New" && token !== "‹" && token !== "›")
2261
+ .map((token) => token.split(" ")[0] ?? "");
2262
+ }
2263
+
2264
+ /**
2265
+ * Drives the models screen to a profile (a clean tab switch redraws the open
2266
+ * screen in place), presses `key` on it, and closes the screen on every later
2267
+ * visit. Without a name it acts on the first tab, the profile the old list
2268
+ * opened on.
2269
+ */
2270
+ function actOnProfile(
2271
+ fixture: { onInput(action: (panel: ScreenPanel) => void): void },
2272
+ key: string | readonly string[],
2273
+ name?: string,
2027
2274
  ): void {
2028
- let visits = 0;
2029
- // gentle-shell#1064: Enter now binds the parent session, so the legacy global
2030
- // apply these tests exercise is the explicit `a` key.
2275
+ let acted = false;
2031
2276
  fixture.onInput((panel) => {
2032
- visits += 1;
2033
- panel.handleInput(visits === 1 ? "a" : "\x1b");
2277
+ if (acted) {
2278
+ panel.handleInput("\x1b");
2279
+ return;
2280
+ }
2281
+ const tabs = tabNames(panel);
2282
+ const target = name ?? tabs[0];
2283
+ for (let step = 0; step < tabs.length && shownProfile(panel) !== target; step++) {
2284
+ panel.handleInput(tabs.indexOf(target) < tabs.indexOf(shownProfile(panel)) ? "\x1b[D" : "\x1b[C");
2285
+ }
2286
+ assert.equal(shownProfile(panel), target);
2287
+ acted = true;
2288
+ for (const data of typeof key === "string" ? [key] : key) panel.handleInput(data);
2034
2289
  });
2035
2290
  }
2036
2291
 
2292
+ /** The guarded global apply: Make default (`d`) on a profile, then close. */
2293
+ function applyOnce(fixture: { onInput(action: (panel: ScreenPanel) => void): void }, name?: string): void {
2294
+ actOnProfile(fixture, "d", name);
2295
+ }
2296
+
2037
2297
  function profilesStoreFixture(t: test.TestContext) {
2038
2298
  const fixture = routingConsumerFixture(t, ["worker"]);
2039
2299
  const storePath = join(fixture.configHome, "profiles.json");
@@ -2806,7 +3066,7 @@ test("applying a populated profile with explicit confirmation applies the routin
2806
3066
  test("applying a profile replaces materialized routing for agents the profile omits", async (t) => {
2807
3067
  const { fixture, writeStore, writeSettings } = profilesStoreFixture(t);
2808
3068
  writeSettings();
2809
- // Routing materialized earlier (a previous profile, /gentle:models, or a
3069
+ // Routing materialized earlier (a previous profile, the models screen, or a
2810
3070
  // migration) for an agent the new profile does not mention.
2811
3071
  const helperPath = join(fixture.root, ".pi", "agents", "helper.md");
2812
3072
  writeMarkdown(helperPath, "---\nname: helper\ndescription: Helper\nmodel: openai/beta\nthinking: high\n---\nbody\n");
@@ -2858,7 +3118,7 @@ test("a failed apply restores the previous profile's routing with the same repla
2858
3118
  assert.equal(store.active, "old");
2859
3119
  });
2860
3120
 
2861
- test("s snapshots current routing in place without applying or reopening the profiles panel", async (t) => {
3121
+ test("From what's running now creates a profile from the routing in effect without applying it", async (t) => {
2862
3122
  const { fixture, settingsPath, writeStore, writeSettings } = profilesStoreFixture(t);
2863
3123
  writeSettings();
2864
3124
  mkdirSync(fixture.configHome, { recursive: true });
@@ -2872,119 +3132,105 @@ test("s snapshots current routing in place without applying or reopening the pro
2872
3132
  worker: readFileSync(workerPath, "utf8"),
2873
3133
  settings: readFileSync(settingsPath, "utf8"),
2874
3134
  };
2875
- writeStore({
2876
- "a-target": {},
2877
- "z-active": { worker: { model: "openai/beta" } },
2878
- }, "z-active");
2879
-
2880
- let firstPanel: RoutingConsumerPanel | undefined;
2881
- fixture.onInput((panel) => {
2882
- if (firstPanel === undefined) {
2883
- firstPanel = panel;
2884
- panel.handleInput("s");
2885
- panel.handleInput("\x1b");
2886
- } else {
2887
- panel.handleInput("\x1b");
2888
- }
2889
- });
2890
- await fixture.run("gentle:profiles");
3135
+ writeStore({ "a-target": {}, "z-active": { worker: { model: "openai/beta" } } }, "z-active");
3136
+ const reopened = await runProfilesNameAction(fixture, SCREEN_KEYS.newFromLive, "captured");
2891
3137
 
2892
3138
  const store = JSON.parse(readFileSync(join(fixture.configHome, "profiles.json"), "utf8"));
2893
- assert.deepEqual(store.profiles["a-target"], {
3139
+ assert.deepEqual(store.profiles.captured, {
2894
3140
  worker: { model: "openai/alpha", thinking: "minimal" },
2895
3141
  orchestrator: { model: "nan/deepseek-v4-flash", thinking: "high" },
2896
3142
  });
3143
+ assert.deepEqual(store.profiles["a-target"], {}, "no existing profile is overwritten");
2897
3144
  assert.deepEqual(store.profiles["z-active"], { worker: { model: "openai/beta" } });
2898
3145
  assert.equal(store.active, "z-active");
2899
- assert.match(fixture.panels[0] ?? "", /enter use in this session · a set as global default · c create · s snapshot/);
2900
3146
  assert.equal(readFileSync(fixture.globalPath, "utf8"), before.models);
2901
3147
  assert.equal(readFileSync(subagentsPath, "utf8"), before.subagents);
2902
3148
  assert.equal(readFileSync(workerPath, "utf8"), before.worker);
2903
3149
  assert.equal(readFileSync(settingsPath, "utf8"), before.settings);
2904
- assert.equal(fixture.panelVisits(), 1, "snapshot keeps the same panel open");
2905
- assert.ok(firstPanel);
2906
- const targetRow = renderComponent(firstPanel!).split("\n");
2907
- const targetIndex = targetRow.findIndex((line) => line.includes("a-target"));
2908
- assert.ok(targetIndex >= 0, "selected profile remains in the list");
2909
- assert.match(targetRow[targetIndex + 1] ?? "", /1 role/);
2910
- assert.match(renderComponent(firstPanel!), /Snapshot saved; live routing unchanged\. Profile "a-target" saved from current routing\./);
3150
+ assert.equal(selectedProfileTitle(reopened), "captured", "the screen shows the new profile");
3151
+ assert.match(profilesPanelFooter(reopened), /Created "captured" from what's running now\./);
2911
3152
  });
2912
3153
 
2913
- test("s snapshots the live session's orchestrator over the settings defaults", async (t) => {
3154
+ test("From what's running now captures the live session's orchestrator over the settings defaults", async (t) => {
2914
3155
  const { fixture, settingsPath, writeStore, writeSettings } = profilesStoreFixture(t);
2915
3156
  writeSettings();
2916
- writeStore({
2917
- "a-target": {},
2918
- "z-active": { worker: { model: "openai/beta" } },
2919
- }, "z-active");
3157
+ writeStore({ "z-active": { worker: { model: "openai/beta" } } }, "z-active");
2920
3158
  // The session runs live on openai/omega at low effort while settings.json still
2921
- // defaults to nan/deepseek-v4-flash at high: the snapshot must capture the
2922
- // session the user is actually in, not the default new sessions would get.
3159
+ // defaults to nan/deepseek-v4-flash at high: the capture must be the session the
3160
+ // user is actually in, not the default new sessions would get.
2923
3161
  fixture.setLiveModel("openai", "omega", "low");
2924
3162
  const settingsBefore = readFileSync(settingsPath, "utf8");
2925
- fixture.onInput((panel) => {
2926
- panel.handleInput("s");
2927
- panel.handleInput("\x1b");
2928
- });
2929
- await fixture.run("gentle:profiles");
3163
+ await runProfilesNameAction(fixture, SCREEN_KEYS.newFromLive, "captured");
2930
3164
  const store = JSON.parse(readFileSync(join(fixture.configHome, "profiles.json"), "utf8"));
2931
- assert.deepEqual(
2932
- store.profiles["a-target"].orchestrator,
2933
- { model: "openai/omega", thinking: "low" },
2934
- "the orchestrator entry comes from the live session, not settings.json",
2935
- );
3165
+ assert.deepEqual(store.profiles.captured.orchestrator, { model: "openai/omega", thinking: "low" });
2936
3166
  assert.equal(readFileSync(settingsPath, "utf8"), settingsBefore, "settings are untouched");
2937
3167
  });
2938
3168
 
3169
+ /** Moves the cursor to the orchestrator row, the first row of the table. */
2939
3170
  function chooseOrchestrator(panel: RoutingConsumerPanel): void {
2940
- panel.handleInput("g");
2941
- panel.handleInput("j");
2942
- assert.match(renderComponent(panel), /▸ orchestrator/);
3171
+ for (let step = 0; step < 4; step++) panel.handleInput("\x1b[A");
3172
+ assert.match(renderComponent(panel), /▸ Orchestrator/);
2943
3173
  }
2944
3174
 
3175
+ /** Opens the model card on the cursor row, types a search, and picks the first match. */
2945
3176
  function chooseModel(panel: RoutingConsumerPanel, query: string): void {
2946
3177
  panel.handleInput("\r");
2947
3178
  for (const character of query) panel.handleInput(character);
2948
3179
  panel.handleInput("\r");
2949
3180
  }
2950
3181
 
3182
+ /** Opens the effort row on the cursor row and picks a level by moving the bracket to it. */
2951
3183
  function chooseEffort(panel: RoutingConsumerPanel, effort: string): void {
2952
3184
  panel.handleInput("e");
2953
- for (let step = 0; step < 10 && !renderComponent(panel).includes(`▸ ${effort}`); step++) panel.handleInput("j");
2954
- assert.ok(renderComponent(panel).includes(`▸ ${effort}`));
3185
+ for (let step = 0; step < 10; step++) panel.handleInput("\x1b[D");
3186
+ for (let step = 0; step < 10 && !renderComponent(panel).includes(`[${effort}]`); step++) panel.handleInput("\x1b[C");
3187
+ assert.ok(renderComponent(panel).includes(`[${effort}]`), `effort ${effort} is offered`);
2955
3188
  panel.handleInput("\r");
2956
3189
  }
2957
3190
 
2958
- test("models explicit orchestrator choice wins over live/defaults and agent bulk edits", async (t) => {
3191
+ /** Moves the cursor to an agent row by name. */
3192
+ function chooseAgent(panel: RoutingConsumerPanel, agent: string): void {
3193
+ for (let step = 0; step < 20 && !new RegExp(`▸ ${agent}\\s`).test(renderComponent(panel)); step++) panel.handleInput("j");
3194
+ assert.match(renderComponent(panel), new RegExp(`▸ ${agent}\\s`));
3195
+ }
3196
+
3197
+ test("models screen saves the explicit orchestrator choice into the profile", async (t) => {
2959
3198
  const { fixture, storePath, settingsPath, writeStore, writeSettings } = profilesStoreFixture(t);
2960
- // Enter in /gentle:profiles binds the fixture session in a process-wide store.
3199
+ // Use in this session binds the fixture session in a process-wide store.
2961
3200
  t.after(() => resetSessionProfileBindingsForTesting());
2962
3201
  writeSettings();
2963
3202
  const settingsBefore = readFileSync(settingsPath, "utf8");
2964
3203
  fixture.setLiveModel("openai", "alpha", "high");
2965
3204
  writeStore({ team: { orchestrator: { model: "nan/glm5.3", thinking: "max" } } }, "team");
3205
+ let visits = 0;
2966
3206
  fixture.onInput((panel) => {
2967
- assert.match(renderComponent(panel), /orchestrator\s+model=nan\/glm5.3, effort=max/);
2968
- chooseOrchestrator(panel);
2969
- chooseModel(panel, "beta");
2970
- chooseEffort(panel, "low");
2971
- panel.handleInput("g");
2972
- chooseModel(panel, "alpha");
2973
- chooseEffort(panel, "high");
2974
- assert.match(renderComponent(panel), /orchestrator\s+model=openai\/beta, effort=low/);
2975
- panel.handleInput("u");
3207
+ visits += 1;
3208
+ if (visits === 1) {
3209
+ assert.match(renderComponent(panel), /Orchestrator\s+nan\/glm5\.3\s+max/);
3210
+ chooseOrchestrator(panel);
3211
+ chooseModel(panel, "beta");
3212
+ chooseEffort(panel, "low");
3213
+ chooseAgent(panel, "worker");
3214
+ chooseModel(panel, "alpha");
3215
+ chooseEffort(panel, "high");
3216
+ assert.match(renderComponent(panel), /Orchestrator\s+openai\/beta\s+low/);
3217
+ panel.handleInput("\x13");
3218
+ return;
3219
+ }
3220
+ panel.handleInput("\x1b");
2976
3221
  });
2977
- await fixture.run("gentle:models");
3222
+ await fixture.run("gentle:profiles");
2978
3223
  const snapshot = JSON.parse(readFileSync(storePath, "utf8")).profiles.team;
2979
3224
  assert.deepEqual(snapshot.orchestrator, { model: "openai/beta", thinking: "low" });
2980
3225
  assert.deepEqual(snapshot.worker, { model: "openai/alpha", thinking: "high" });
2981
- assert.equal("orchestrator" in JSON.parse(readFileSync(fixture.globalPath, "utf8")), false);
2982
- assert.equal("orchestrator" in JSON.parse(readFileSync(join(fixture.root, ".pi", "subagents.json"), "utf8")).model_profiles, false);
3226
+ assert.equal(existsSync(fixture.globalPath), false, "Save never writes global routing");
3227
+ assert.equal(existsSync(join(fixture.root, ".pi", "subagents.json")), false, "Save never materializes agent routing");
2983
3228
  assert.equal(readFileSync(settingsPath, "utf8"), settingsBefore);
2984
3229
  assert.deepEqual(fixture.liveSwitches, [], "saving never switches the live model or effort");
2985
3230
  assert.equal(fixture.notifications.some((entry) => entry.severity === "warning"), false);
2986
- let profileVisits = 0;
2987
- fixture.onInput((panel) => { panel.handleInput(profileVisits++ === 0 ? "\r" : "\x1b"); });
3231
+
3232
+ // Use in this session still drives the live session.
3233
+ actOnProfile(fixture, "u");
2988
3234
  await fixture.run("gentle:profiles");
2989
3235
  assert.deepEqual(fixture.liveSwitches, [
2990
3236
  { kind: "model", provider: "openai", id: "beta" },
@@ -2993,137 +3239,130 @@ test("models explicit orchestrator choice wins over live/defaults and agent bulk
2993
3239
  assert.equal(readFileSync(settingsPath, "utf8"), settingsBefore);
2994
3240
  });
2995
3241
 
2996
- test("models leave untouched removes stale orchestrator and preserves live model/effort on apply", async (t) => {
3242
+ test("models screen leave untouched removes a stale profile orchestrator and keeps the live model on use", async (t) => {
2997
3243
  const { fixture, storePath, settingsPath, writeStore, writeSettings } = profilesStoreFixture(t);
2998
- // Enter in /gentle:profiles binds the fixture session in a process-wide store.
2999
3244
  t.after(() => resetSessionProfileBindingsForTesting());
3000
3245
  writeSettings();
3001
3246
  const settingsBefore = readFileSync(settingsPath, "utf8");
3002
3247
  fixture.setLiveModel("openai", "alpha", "low");
3003
3248
  writeStore({ team: { orchestrator: { model: "openai/beta", thinking: "high" } } }, "team");
3004
3249
  writeFileSync(fixture.globalPath, JSON.stringify({ orchestrator: { model: "openai/beta", thinking: "high" }, worker: { model: "openai/alpha" } }));
3005
- fixture.answerInputs("");
3250
+ const globalBefore = readFileSync(fixture.globalPath, "utf8");
3006
3251
  let visits = 0;
3007
3252
  fixture.onInput((panel) => {
3008
- if (visits++ === 0) {
3253
+ visits += 1;
3254
+ if (visits === 1) {
3009
3255
  chooseOrchestrator(panel);
3010
3256
  chooseModel(panel, "leave untouched");
3011
- panel.handleInput("e");
3012
- assert.match(renderComponent(panel), /orchestrator\s+leave untouched/);
3013
- panel.handleInput("j");
3014
- panel.handleInput("c");
3015
- } else {
3016
- assert.match(renderComponent(panel), /orchestrator\s+leave untouched/);
3017
- panel.handleInput("u");
3257
+ assert.match(renderComponent(panel), /Orchestrator\s+leave untouched/);
3258
+ panel.handleInput("\x13");
3259
+ return;
3018
3260
  }
3261
+ panel.handleInput("\x1b");
3019
3262
  });
3020
- await fixture.run("gentle:models");
3263
+ await fixture.run("gentle:profiles");
3021
3264
  assert.equal(Object.hasOwn(JSON.parse(readFileSync(storePath, "utf8")).profiles.team, "orchestrator"), false);
3022
- assert.equal(Object.hasOwn(JSON.parse(readFileSync(fixture.globalPath, "utf8")), "orchestrator"), false);
3265
+ assert.equal(readFileSync(fixture.globalPath, "utf8"), globalBefore, "Save never writes global routing");
3023
3266
  assert.equal(readFileSync(settingsPath, "utf8"), settingsBefore);
3024
3267
  assert.deepEqual(fixture.liveSwitches, []);
3025
- let profileVisits = 0;
3026
- fixture.onInput((panel) => {
3027
- assert.match(renderComponent(panel), /orchestrator\s+inherit/);
3028
- panel.handleInput(profileVisits++ === 0 ? "\r" : "\x1b");
3029
- });
3268
+ actOnProfile(fixture, "u");
3030
3269
  await fixture.run("gentle:profiles");
3031
- assert.deepEqual(fixture.liveSwitches, [], "Enter preserves both live model and effort without the key");
3270
+ assert.deepEqual(fixture.liveSwitches, [], "a profile without an orchestrator keeps the live model and effort");
3032
3271
  assert.equal(readFileSync(settingsPath, "utf8"), settingsBefore);
3033
3272
  });
3034
3273
 
3035
- for (const [name, key] of [["ctrl+s", "\x13"], ["cancel", "\x1b"]]) {
3036
- test(`models ${name} never snapshots or applies the chosen orchestrator`, async (t) => {
3274
+ for (const [name, keys] of [["ctrl+s", ["\x13"]], ["esc twice", ["\x1b", "\x1b"]]] as const) {
3275
+ test(`models screen ${name} ${name === "ctrl+s" ? "persists the profile without touching global state" : "persists nothing"}`, async (t) => {
3037
3276
  const { fixture, storePath, settingsPath, writeStore, writeSettings } = profilesStoreFixture(t);
3038
3277
  writeSettings();
3039
3278
  fixture.setLiveModel("openai", "alpha", "high");
3040
3279
  writeStore({ team: { orchestrator: { model: "openai/alpha", thinking: "high" } } }, "team");
3041
- const before = [readFileSync(storePath, "utf8"), readFileSync(settingsPath, "utf8")];
3280
+ const storeBefore = readFileSync(storePath, "utf8");
3281
+ const settingsBefore = readFileSync(settingsPath, "utf8");
3282
+ let visits = 0;
3042
3283
  fixture.onInput((panel) => {
3043
- chooseOrchestrator(panel);
3044
- chooseModel(panel, "beta");
3045
- chooseEffort(panel, "low");
3046
- panel.handleInput(key!);
3284
+ visits += 1;
3285
+ if (visits === 1) {
3286
+ chooseOrchestrator(panel);
3287
+ chooseModel(panel, "beta");
3288
+ chooseEffort(panel, "low");
3289
+ for (const key of keys) panel.handleInput(key);
3290
+ return;
3291
+ }
3292
+ panel.handleInput("\x1b");
3047
3293
  });
3048
- await fixture.run("gentle:models");
3049
- assert.deepEqual([readFileSync(storePath, "utf8"), readFileSync(settingsPath, "utf8")], before);
3294
+ await fixture.run("gentle:profiles");
3050
3295
  assert.deepEqual(fixture.liveSwitches, []);
3051
- if (name === "ctrl+s") assert.deepEqual(JSON.parse(readFileSync(fixture.globalPath, "utf8")), {});
3052
- else assert.equal(existsSync(fixture.globalPath), false);
3296
+ assert.equal(readFileSync(settingsPath, "utf8"), settingsBefore);
3297
+ if (name === "ctrl+s") {
3298
+ assert.deepEqual(JSON.parse(readFileSync(storePath, "utf8")).profiles.team.orchestrator, { model: "openai/beta", thinking: "low" });
3299
+ assert.equal(existsSync(fixture.globalPath), false);
3300
+ } else {
3301
+ assert.equal(readFileSync(storePath, "utf8"), storeBefore);
3302
+ assert.equal(visits, 1, "the second esc closes the screen");
3303
+ }
3053
3304
  });
3054
3305
  }
3055
3306
 
3056
- for (const action of ["bulk inherit", "custom", "export/restore"]) {
3057
- for (const leaveUntouched of [false, true]) {
3058
- test(`models choice survives ${action} with ${leaveUntouched ? "no key" : "explicit pair"}`, async (t) => {
3059
- const { fixture, storePath, settingsPath, writeStore, writeSettings } = profilesStoreFixture(t);
3060
- writeSettings();
3061
- fixture.setLiveModel("openai", "alpha", "high");
3062
- writeStore({ team: { orchestrator: { model: "openai/alpha", thinking: "high" } } }, "team");
3063
- writeFileSync(fixture.globalPath, JSON.stringify({ worker: { model: "openai/alpha" } }));
3064
- const settingsBefore = readFileSync(settingsPath, "utf8");
3065
- if (action === "custom") fixture.answerInputs("openai/alpha");
3307
+ test("models screen Model ▾ and Effort ▾ set every agent to inherit and leave the orchestrator alone", async (t) => {
3308
+ const { fixture, storePath, writeStore } = profilesStoreFixture(t);
3309
+ writeStore({ team: { orchestrator: { model: "openai/alpha", thinking: "high" }, worker: { model: "openai/beta", thinking: "low" } } }, "team");
3310
+ let visits = 0;
3311
+ fixture.onInput((panel) => {
3312
+ visits += 1;
3313
+ if (visits === 1) {
3314
+ chooseOrchestrator(panel);
3315
+ panel.handleInput("j"); // the header row: every agent
3316
+ assert.match(renderComponent(panel), /▸ Agent\s+Model ▾/);
3317
+ chooseModel(panel, "inherit");
3318
+ panel.handleInput("e");
3319
+ for (let step = 0; step < 10; step++) panel.handleInput("\x1b[D");
3320
+ panel.handleInput("\r");
3321
+ panel.handleInput("\x13");
3322
+ return;
3323
+ }
3324
+ panel.handleInput("\x1b");
3325
+ });
3326
+ await fixture.run("gentle:profiles");
3327
+ const profile = JSON.parse(readFileSync(storePath, "utf8")).profiles.team;
3328
+ assert.deepEqual(profile.worker, {});
3329
+ assert.deepEqual(profile.orchestrator, { model: "openai/alpha", thinking: "high" });
3330
+ });
3331
+
3332
+ test("models screen custom model id: a valid id lands in the draft, an invalid one changes nothing", async (t) => {
3333
+ for (const [answer, expected] of [["openai/custom-1", "openai/custom-1"], ["bad\nmodel: injected", "openai/beta"]] as const) {
3334
+ await t.test(answer.includes("\n") ? "invalid" : "valid", async (t) => {
3335
+ const { fixture, storePath, writeStore } = profilesStoreFixture(t);
3336
+ writeStore({ team: { worker: { model: "openai/beta" } } }, "team");
3337
+ fixture.answerInputs(answer);
3066
3338
  let visits = 0;
3067
- const assertChoice = (panel: RoutingConsumerPanel) => {
3068
- assert.match(renderComponent(panel), leaveUntouched
3069
- ? /orchestrator\s+leave untouched/
3070
- : /orchestrator\s+model=openai\/beta, effort=low/);
3071
- };
3072
3339
  fixture.onInput((panel) => {
3073
- visits++;
3074
- assert.ok(visits <= 3, "reopens must be bounded");
3340
+ visits += 1;
3075
3341
  if (visits === 1) {
3076
- chooseOrchestrator(panel);
3077
- if (leaveUntouched) panel.handleInput("i");
3078
- else {
3079
- chooseModel(panel, "beta");
3080
- chooseEffort(panel, "low");
3081
- }
3082
- assertChoice(panel);
3083
- if (action === "bulk inherit") {
3084
- panel.handleInput("g");
3085
- panel.handleInput("i");
3086
- assertChoice(panel);
3087
- panel.handleInput("u");
3088
- } else if (action === "custom") {
3089
- panel.handleInput("g");
3090
- for (let step = 0; step < 4; step++) panel.handleInput("j");
3091
- assert.match(renderComponent(panel), /▸ worker/);
3092
- panel.handleInput("c");
3093
- } else panel.handleInput("x");
3094
- } else {
3095
- assertChoice(panel);
3096
- panel.handleInput(action === "export/restore" && visits === 2 ? "r" : "u");
3342
+ chooseAgent(panel, "worker");
3343
+ panel.handleInput("\r");
3344
+ for (const character of "zzz") panel.handleInput(character); // no catalog match: only Custom model id… is left
3345
+ assert.match(renderComponent(panel), /▸ Custom model id…/);
3346
+ panel.handleInput("\r");
3347
+ return;
3097
3348
  }
3349
+ if (visits === 2) {
3350
+ assert.match(renderComponent(panel), new RegExp(`worker\\s+${expected.replace("/", "\\/")}`));
3351
+ // A rejected id leaves nothing to save, so the screen just closes.
3352
+ panel.handleInput(expected === "openai/beta" ? "\x1b" : "\x13");
3353
+ return;
3354
+ }
3355
+ panel.handleInput("\x1b");
3098
3356
  });
3099
- await fixture.run("gentle:models");
3100
- const profile = JSON.parse(readFileSync(storePath, "utf8")).profiles.team;
3101
- assert.equal(Object.hasOwn(profile, "orchestrator"), !leaveUntouched);
3102
- if (!leaveUntouched) assert.deepEqual(profile.orchestrator, { model: "openai/beta", thinking: "low" });
3103
- assert.deepEqual(profile.worker, action === "bulk inherit" ? {} : { model: "openai/alpha" });
3104
- assert.equal(Object.hasOwn(JSON.parse(readFileSync(fixture.globalPath, "utf8")), "orchestrator"), false);
3105
- assert.equal(readFileSync(settingsPath, "utf8"), settingsBefore);
3106
- assert.deepEqual(fixture.liveSwitches, []);
3107
- assert.equal(fixture.notifications.some((entry) => entry.severity === "warning"), false);
3108
- assert.equal(visits, action === "bulk inherit" ? 1 : action === "custom" ? 2 : 3);
3109
- assert.equal(fixture.confirmCalls.length, action === "export/restore" ? 1 : 0);
3357
+ await fixture.run("gentle:profiles");
3358
+ assert.match(fixture.inputPrompts[0]?.title ?? "", /Custom model id for worker/);
3359
+ assert.equal(JSON.parse(readFileSync(storePath, "utf8")).profiles.team.worker.model, expected);
3360
+ if (expected === "openai/beta") assert.match(fixture.panels[1] ?? "", /Custom model id must be one provider\/model line/);
3110
3361
  });
3111
3362
  }
3112
- }
3113
-
3114
- // /gentle:models can finish with `u`: the global save `ctrl+s` performs, followed
3115
- // by a snapshot of saved agent routing and the explicitly chosen orchestrator.
3116
- function pickWorkerModelThenUpdateProfile(panel: RoutingConsumerPanel): void {
3117
- // Rows: "Set all agents", "orchestrator", two provider review roles, then "worker".
3118
- for (let step = 0; step < 4; step += 1) panel.handleInput("j");
3119
- assert.match(renderComponent(panel), /▸ worker/);
3120
- panel.handleInput("\r");
3121
- for (const character of "alpha") panel.handleInput(character);
3122
- panel.handleInput("\r");
3123
- panel.handleInput("u");
3124
- }
3363
+ });
3125
3364
 
3126
- test("u preserves an active profile's absent orchestrator when editing agents", async (t) => {
3365
+ test("models screen Save preserves an absent orchestrator and sibling profiles", async (t) => {
3127
3366
  const { fixture, storePath, settingsPath, writeStore, writeSettings } = profilesStoreFixture(t);
3128
3367
  writeSettings();
3129
3368
  const settingsBefore = readFileSync(settingsPath, "utf8");
@@ -3132,35 +3371,30 @@ test("u preserves an active profile's absent orchestrator when editing agents",
3132
3371
  team: { worker: { model: "openai/beta" } },
3133
3372
  other: { worker: { model: "openai/beta", thinking: "high" } },
3134
3373
  }, "team");
3374
+ let visits = 0;
3135
3375
  fixture.onInput((panel) => {
3136
- assert.match(renderComponent(panel), /Current profile: team/);
3137
- assert.match(renderComponent(panel), /orchestrator\s+leave untouched/);
3138
- assert.match(renderComponent(panel), /u save choice in "team"/);
3139
- pickWorkerModelThenUpdateProfile(panel);
3376
+ visits += 1;
3377
+ if (visits === 1) {
3378
+ assert.match(renderComponent(panel), /Orchestrator\s+leave untouched/);
3379
+ chooseAgent(panel, "worker");
3380
+ chooseModel(panel, "alpha");
3381
+ panel.handleInput("\x13");
3382
+ return;
3383
+ }
3384
+ panel.handleInput("\x1b");
3140
3385
  });
3141
- await fixture.run("gentle:models");
3142
-
3143
- assert.deepEqual(JSON.parse(readFileSync(fixture.globalPath, "utf8")), { worker: { model: "openai/alpha" } });
3386
+ await fixture.run("gentle:profiles");
3387
+ assert.equal(existsSync(fixture.globalPath), false, "Save never writes global routing");
3144
3388
  const store = JSON.parse(readFileSync(storePath, "utf8"));
3145
- assert.deepEqual(store.profiles.team, {
3146
- worker: { model: "openai/alpha" },
3147
- });
3389
+ assert.deepEqual(store.profiles.team, { worker: { model: "openai/alpha" } });
3148
3390
  assert.deepEqual(fixture.liveSwitches, []);
3149
3391
  assert.equal(readFileSync(settingsPath, "utf8"), settingsBefore, "global defaults remain unchanged");
3150
3392
  assert.deepEqual(store.profiles.other, { worker: { model: "openai/beta", thinking: "high" } });
3151
3393
  assert.equal(store.active, "team");
3152
- assert.equal(fixture.panelVisits(), 1, "u finishes the interaction");
3153
- assert.ok(
3154
- fixture.notifications.some((entry) => entry.severity === "info" && /global model config saved/.test(entry.message)),
3155
- "the global save is still reported",
3156
- );
3157
- assert.ok(
3158
- fixture.notifications.some((entry) => entry.severity === "info" && /Profile "team" updated from the routing just saved/.test(entry.message)),
3159
- `profile update is reported: ${JSON.stringify(fixture.notifications)}`,
3160
- );
3394
+ assert.equal(visits, 2);
3161
3395
  });
3162
3396
 
3163
- test("u preserves the pinned profile's stored orchestrator instead of live or active values", async (t) => {
3397
+ test("gentle:profiles opens on the pinned profile and Save preserves its stored orchestrator", async (t) => {
3164
3398
  const { fixture, storePath, settingsPath, writeStore, writeSettings, writePin, localPinPath } = profilesStoreFixture(t);
3165
3399
  writeSettings();
3166
3400
  const settingsBefore = readFileSync(settingsPath, "utf8");
@@ -3170,97 +3404,55 @@ test("u preserves the pinned profile's stored orchestrator instead of live or ac
3170
3404
  other: { worker: { model: "openai/beta", thinking: "high" }, orchestrator: { model: "openai/beta", thinking: "high" } },
3171
3405
  }, "team");
3172
3406
  writePin(localPinPath, "other");
3407
+ let visits = 0;
3173
3408
  fixture.onInput((panel) => {
3174
- assert.match(renderComponent(panel), /Current profile: other \(pinned\)/);
3175
- assert.match(renderComponent(panel), /orchestrator\s+model=openai\/beta, effort=high/);
3176
- pickWorkerModelThenUpdateProfile(panel);
3409
+ visits += 1;
3410
+ if (visits === 1) {
3411
+ assert.equal(shownProfile(panel), "other", "the pin preselects its profile");
3412
+ assert.match(renderComponent(panel), /Orchestrator\s+openai\/beta\s+high/);
3413
+ chooseAgent(panel, "worker");
3414
+ chooseModel(panel, "alpha");
3415
+ panel.handleInput("\x13");
3416
+ return;
3417
+ }
3418
+ panel.handleInput("\x1b");
3177
3419
  });
3178
- await fixture.run("gentle:models");
3179
-
3180
- assert.deepEqual(JSON.parse(readFileSync(fixture.globalPath, "utf8")), { worker: { model: "openai/alpha" } });
3420
+ await fixture.run("gentle:profiles");
3181
3421
  const store = JSON.parse(readFileSync(storePath, "utf8"));
3182
3422
  assert.deepEqual(store.profiles.other, {
3183
- worker: { model: "openai/alpha" },
3423
+ worker: { model: "openai/alpha", thinking: "high" },
3184
3424
  orchestrator: { model: "openai/beta", thinking: "high" },
3185
3425
  });
3186
3426
  assert.deepEqual(fixture.liveSwitches, []);
3187
3427
  assert.equal(readFileSync(settingsPath, "utf8"), settingsBefore);
3188
3428
  assert.deepEqual(store.profiles.team, { worker: { model: "openai/beta" } });
3189
3429
  assert.equal(store.active, "team");
3190
- assert.ok(
3191
- fixture.notifications.some((entry) => entry.severity === "info" && /Profile "other" updated from the routing just saved/.test(entry.message)),
3192
- `pinned profile update is reported: ${JSON.stringify(fixture.notifications)}`,
3193
- );
3430
+ assert.equal(visits, 2);
3194
3431
  });
3195
3432
 
3196
- test("u keeps the global save and reports when no profile is current", async (t) => {
3197
- const { fixture, storePath, writeStore, writeSettings } = profilesStoreFixture(t);
3198
- writeSettings();
3199
- fixture.setLiveModel("openai", "alpha", "low");
3200
- writeStore({ team: { worker: { model: "openai/beta" } } });
3201
- const before = readFileSync(storePath, "utf8");
3202
- fixture.onInput((panel) => {
3203
- assert.match(renderComponent(panel), /Current profile: none/);
3204
- assert.match(renderComponent(panel), /orchestrator\s+model=openai\/alpha, effort=low/);
3205
- pickWorkerModelThenUpdateProfile(panel);
3206
- });
3207
- await fixture.run("gentle:models");
3208
-
3209
- assert.deepEqual(JSON.parse(readFileSync(fixture.globalPath, "utf8")), { worker: { model: "openai/alpha" } });
3210
- assert.equal(readFileSync(storePath, "utf8"), before, "the store is untouched");
3211
- assert.ok(
3212
- fixture.notifications.some((entry) => entry.severity === "warning" && /no profile is current/.test(entry.message) && /\/gentle:profiles/.test(entry.message)),
3213
- `missing current profile is reported: ${JSON.stringify(fixture.notifications)}`,
3214
- );
3215
- });
3216
-
3217
- test("u seeds the profiles store the way /gentle:profiles does when it is missing", async (t) => {
3433
+ test("gentle:profiles seeds the profiles store when it is missing", async (t) => {
3218
3434
  const { fixture, storePath, writeSettings } = profilesStoreFixture(t);
3219
3435
  writeSettings();
3220
3436
  assert.equal(existsSync(storePath), false);
3437
+ let visits = 0;
3221
3438
  fixture.onInput((panel) => {
3222
- assert.match(renderComponent(panel), /Current profile: none/);
3223
- assert.match(renderComponent(panel), /orchestrator\s+model=nan\/deepseek-v4-flash, effort=high/);
3224
- pickWorkerModelThenUpdateProfile(panel);
3439
+ visits += 1;
3440
+ if (visits === 1) {
3441
+ assert.equal(shownProfile(panel), "current");
3442
+ assert.match(renderComponent(panel), /Orchestrator\s+leave untouched/, "the seed captures agent routing only");
3443
+ }
3444
+ panel.handleInput("\x1b");
3225
3445
  });
3226
- await fixture.run("gentle:models");
3227
-
3446
+ await fixture.run("gentle:profiles");
3228
3447
  const store = JSON.parse(readFileSync(storePath, "utf8"));
3229
3448
  assert.equal(store.kind, PROFILES_KIND);
3230
- assert.deepEqual(store.profiles.current, {
3231
- worker: { model: "openai/alpha" },
3232
- orchestrator: { model: "nan/deepseek-v4-flash", thinking: "high" },
3233
- });
3234
- assert.equal(store.active, "current");
3449
+ assert.deepEqual(store.profiles.current, {});
3450
+ assert.equal(store.active, undefined);
3235
3451
  assert.ok(
3236
3452
  fixture.notifications.some((entry) => entry.severity === "info" && /seeded the "current" profile/.test(entry.message)),
3237
3453
  `seeding is reported: ${JSON.stringify(fixture.notifications)}`,
3238
3454
  );
3239
- });
3240
-
3241
- test("snapshot feedback keeps both outcomes visible for long profile names at narrow widths", async (t) => {
3242
- const { fixture, storePath, writeStore } = profilesStoreFixture(t);
3243
- const longName = `a${"x".repeat(63)}`;
3244
- writeStore({ [longName]: {} });
3245
- let firstPanel: RoutingConsumerPanel | undefined;
3246
- fixture.onInput((panel) => {
3247
- if (firstPanel === undefined) {
3248
- firstPanel = panel;
3249
- panel.handleInput("s");
3250
- const success = stripAnsi(panel.render(60).join("\n"));
3251
- assert.match(success, /Snapshot saved; live routing unchanged\./);
3252
- rmSync(storePath);
3253
- mkdirSync(storePath);
3254
- panel.handleInput("s");
3255
- panel.handleInput("\x1b");
3256
- } else {
3257
- panel.handleInput("\x1b");
3258
- }
3259
- });
3260
- await fixture.run("gentle:profiles");
3261
- assert.ok(firstPanel);
3262
- const constrained = stripAnsi(firstPanel!.render(60).join("\n"));
3263
- assert.match(constrained, /Snapshot failed; live routing unchanged\./);
3455
+ assert.equal(visits, 1);
3264
3456
  });
3265
3457
 
3266
3458
  test("the profiles command seeds and shows the routing the runtime uses when models.json is sparse", async (t) => {
@@ -3283,10 +3475,8 @@ test("the profiles command seeds and shows the routing the runtime uses when mod
3283
3475
  helper: { model: "openai/beta" },
3284
3476
  });
3285
3477
  assert.ok(rendered);
3286
- assert.match(rendered, /Current routing \(effective\)/);
3287
- assert.match(rendered, /worker\s+openai\/alpha\s+high/);
3478
+ assert.match(rendered, /worker\s+openai\/alpha\s+high/, "the seeded profile shows the routing in effect");
3288
3479
  assert.match(rendered, /helper\s+openai\/beta/);
3289
- assert.doesNotMatch(rendered, /No routing entries/);
3290
3480
  });
3291
3481
 
3292
3482
  for (const reader of ["readEffectiveModelConfig", "readEffectiveModelConfigAsync"] as const) {
@@ -3320,7 +3510,10 @@ test("effective routing prefers models.json over the materialized stores", (t) =
3320
3510
  assert.equal(existsSync(join(fixture.root, ".pi", "gentle-ai", "models.json")), false, "reading never writes");
3321
3511
  });
3322
3512
 
3323
- test("the profiles panel fills the terminal, lists routing per agent, and scrolls", async (t) => {
3513
+ // Issue #2196: a profile key that names no discovered agent was silently
3514
+ // ignored at launch. gentle-pi#2271: provider review roles are routed by key
3515
+ // and never have an agent file, so they are never flagged.
3516
+ test("the models screen flags profile keys no agent matches and never flags provider review roles", async (t) => {
3324
3517
  const { fixture, writeStore } = profilesStoreFixture(t);
3325
3518
  writeStore({
3326
3519
  team: {
@@ -3330,55 +3523,22 @@ test("the profiles panel fills the terminal, lists routing per agent, and scroll
3330
3523
  "review-refuter": { model: "nan/glm5.3", thinking: "high" },
3331
3524
  },
3332
3525
  }, "team");
3333
- let rendered: string | undefined;
3334
- let panel: { render(width: number): string[] } | undefined;
3526
+ let rendered = "";
3335
3527
  fixture.onInput((visited) => {
3336
- panel = visited;
3337
- rendered = renderComponent(visited);
3528
+ rendered = stripAnsi(renderComponent(visited));
3338
3529
  visited.handleInput("\x1b");
3339
3530
  });
3340
3531
  await fixture.run("gentle:profiles");
3341
-
3342
- assert.ok(rendered);
3343
- const lines = rendered.split("\n");
3344
- // The frame spans the terminal height the fake TUI reports.
3345
- assert.equal(lines.length, 24, `expected 24 rows, got ${lines.length}`);
3346
- assert.match(lines[0], /^╭/);
3347
- assert.match(lines.at(-1)!, /^╰/);
3348
- const text = rendered;
3349
- // Routing is listed one agent per line, aligned in columns, never collapsed
3350
- // into "N agents → model: a, b, …" summaries.
3351
- assert.match(text, /Profile routing/);
3352
- assert.match(text, /Current routing \(effective\)/);
3353
- assert.match(text, /orchestrator\s+nan\/glm5\.3 · high/);
3354
- assert.match(text, /worker\s+openai\/alpha\s+high/);
3355
- assert.match(text, /sdd-design\s+nan\/glm5\.3\s+high\s+← no matching agent/);
3356
- // A matched key stays a plain row: the note is reserved for keys that name
3357
- // no discovered agent (issue #2196).
3358
- assert.doesNotMatch(text, /worker\s+openai\/alpha\s+high\s+←/);
3359
- // gentle-pi#2271: review-refuter and review-validator are provider review
3360
- // roles the relay routes by key; they never have an agent file, so the
3361
- // panel must not flag them as unmatched.
3362
- assert.match(text, /review-refuter\s+nan\/glm5\.3\s+high/);
3363
- assert.doesNotMatch(text, /review-refuter[^\n]*(←|↳)/);
3364
- assert.match(text, /sdd-design\s+nan\/glm5\.3\s+high/);
3365
- assert.doesNotMatch(text, /agents? → /);
3366
-
3367
- // A taller terminal renders a taller frame with the same content.
3368
- fixture.tui.terminal.rows = 40;
3369
- const taller = renderComponent(panel);
3370
- assert.equal(taller.split("\n").length, 40);
3371
-
3372
- // A short terminal clamps instead of crashing, and keeps both borders.
3373
- fixture.tui.terminal.rows = 9;
3374
- const short = stripAnsi(panel.render(120).map((line) => line.replace(/[ \t]+$/g, "")).join("\n"));
3375
- const shortLines = short.split("\n");
3376
- assert.equal(shortLines.length, 9);
3377
- assert.match(shortLines[0], /^╭/);
3378
- assert.match(shortLines.at(-1)!, /^╰/);
3532
+ const flat = (rendered: string) => rendered.replace(/\s*│\s*/g, " ").replace(/\s+/g, " ");
3533
+ assert.equal(rendered.split("\n").length, 24, "the frame spans the terminal height");
3534
+ assert.match(flat(rendered), /⚠ No agent matches "sdd-design" in this profile, so it is ignored at launch\./);
3535
+ assert.doesNotMatch(flat(rendered), /review-refuter"/, "provider review roles are never flagged");
3536
+ assert.match(rendered, /Orchestrator\s+nan\/glm5\.3\s+high/);
3537
+ assert.match(rendered, /worker\s+openai\/alpha\s+high/);
3538
+ assert.match(rendered, /review-refuter\s+nan\/glm5\.3\s+high/);
3379
3539
  });
3380
3540
 
3381
- test("a narrow detail pane keeps the unmatched-key note visible", async (t) => {
3541
+ test("at 60 columns the unmatched-key notice wraps instead of being cut", async (t) => {
3382
3542
  const { fixture, writeStore } = profilesStoreFixture(t);
3383
3543
  writeStore({
3384
3544
  team: {
@@ -3391,21 +3551,18 @@ test("a narrow detail pane keeps the unmatched-key note visible", async (t) => {
3391
3551
  panel = visited;
3392
3552
  visited.handleInput("\x1b");
3393
3553
  });
3394
- fixture.tui.terminal.rows = 40;
3395
3554
  await fixture.run("gentle:profiles");
3396
-
3397
3555
  assert.ok(panel);
3398
- // At 60 columns the routing row fills the detail pane, so the note wraps
3399
- // onto its own line instead of being truncated away (issue #2196).
3400
3556
  const narrow = stripAnsi(panel.render(60).join("\n"));
3401
- assert.match(narrow, /↳ no matching agent/);
3557
+ const flat = (rendered: string) => rendered.replace(/\s*│\s*/g, " ").replace(/\s+/g, " ");
3558
+ assert.match(flat(narrow), /⚠ No agent matches "sdd-design" in this profile, so it is ignored at launch\./);
3559
+ assert.ok(!narrow.split("\n").some((line) => line.includes("…")), "nothing is cut");
3402
3560
  });
3403
3561
 
3404
- // Issue #2400: a model_profiles key that matches no discovered agent and has
3405
- // no suggestion never became a "Current routing (effective)" row, because that
3406
- // table is built per discovered agent. The panel now lists those keys under
3407
- // their own heading; a key with a suggestion is left to the launch warning.
3408
- test("the profiles panel lists model_profiles keys that match no agent and suggest none", async (t) => {
3562
+ // Issue #2400: a subagents.json model_profiles key that matches no discovered
3563
+ // agent and suggests none is ignored at launch; the screen names it. A key with
3564
+ // a suggestion is left to the launch warning.
3565
+ test("the models screen names subagents.json keys that match no agent and suggest none", async (t) => {
3409
3566
  const { fixture, writeStore } = profilesStoreFixture(t);
3410
3567
  writeStore({ team: { worker: { model: "openai/alpha", thinking: "high" } } }, "team");
3411
3568
  writeFileSync(join(fixture.root, ".pi", "subagents.json"), `${JSON.stringify({
@@ -3423,102 +3580,97 @@ test("the profiles panel lists model_profiles keys that match no agent and sugge
3423
3580
  });
3424
3581
  fixture.tui.terminal.rows = 40;
3425
3582
  await fixture.run("gentle:profiles");
3426
-
3427
3583
  assert.ok(panel);
3428
- const wide = stripAnsi(panel.render(120).join("\n"));
3429
- assert.match(wide, /Unmatched keys \(ignored\)/);
3430
- assert.match(wide, /explor\s+← no matching agent/);
3431
- assert.match(wide, /sdd-orchestratr-x\s+← no matching agent/);
3432
- assert.doesNotMatch(wide, /gentle-ai-worker/, "a key with a suggestion is not listed as unmatched");
3433
-
3434
- // At 60 columns the detail pane is 32 wide: the heading and the key stay
3435
- // whole, and a note that does not fit moves to its own line.
3436
- const narrow = stripAnsi(panel.render(60).join("\n"));
3437
- assert.match(narrow, /Unmatched keys \(ignored\)/);
3438
- assert.match(narrow, /explor\s+← no matching agent/);
3439
- assert.match(narrow, /sdd-orchestratr-x[^\n]*\n[^\n]*↳ no matching agent/);
3584
+ const flat = (rendered: string) => rendered.replace(/\s*│\s*/g, " ").replace(/\s+/g, " ");
3585
+ for (const width of [120, 60]) {
3586
+ const text = stripAnsi(panel.render(width).join("\n"));
3587
+ assert.match(flat(text), /⚠ subagents\.json routes "explor", "sdd-orchestratr-x", which no agent matches, so they are ignored\./);
3588
+ assert.doesNotMatch(text, /gentle-ai-worker/, "a key with a suggestion is not listed");
3589
+ assert.ok(!text.split("\n").some((line) => line.includes("…")), `nothing is cut at ${width}`);
3590
+ }
3440
3591
  });
3441
3592
 
3442
- // Issue #2400: when a pin wins, launches route from the pinned snapshot, whose
3443
- // keys are already current rows with their own notes, so the subagents.json
3444
- // keys are not listed as the routing in effect.
3445
- test("the unmatched-keys list stays out of the panel while a pin wins", async (t) => {
3593
+ // Issue #2400: when a pin wins, launches route from the pinned snapshot, so the
3594
+ // subagents.json keys are not the routing in effect and are not listed.
3595
+ test("the subagents.json notice stays off the screen while a pin wins", async (t) => {
3446
3596
  const { fixture, writeStore, writePin, localPinPath } = profilesStoreFixture(t);
3447
3597
  writeStore({ team: { worker: { model: "openai/alpha", thinking: "high" } } }, "team");
3448
3598
  writePin(localPinPath, "team");
3449
3599
  writeFileSync(join(fixture.root, ".pi", "subagents.json"), `${JSON.stringify({ model_profiles: { explor: { model: "openai/beta" } } }, null, 2)}\n`);
3450
- let panel: { render(width: number): string[] } | undefined;
3600
+ let text = "";
3451
3601
  fixture.onInput((visited) => {
3452
- panel = visited;
3602
+ text = stripAnsi(renderComponent(visited));
3453
3603
  visited.handleInput("\x1b");
3454
3604
  });
3455
- fixture.tui.terminal.rows = 40;
3456
3605
  await fixture.run("gentle:profiles");
3457
-
3458
- assert.ok(panel);
3459
- const text = stripAnsi(panel.render(120).join("\n"));
3460
- assert.match(text, /Current routing \(effective\)/);
3461
- assert.doesNotMatch(text, /Unmatched keys/);
3462
- });
3463
-
3464
- test("j and k scroll the detail pane one line at a time, like the agents view", async (t) => {
3465
- const { fixture, writeStore } = profilesStoreFixture(t);
3466
- const routing: Record<string, { model: string; thinking: string }> = {};
3467
- for (let index = 1; index <= 25; index += 1) {
3468
- routing[`agent-${String(index).padStart(2, "0")}`] = { model: "openai/alpha", thinking: "high" };
3469
- }
3470
- writeStore({ team: routing }, "team");
3471
- let panel: { handleInput(data: string): void; render(width: number): string[] } | undefined;
3472
- fixture.onInput((visited) => {
3473
- panel = visited;
3474
- visited.handleInput("\x1b");
3475
- });
3476
- await fixture.run("gentle:profiles");
3477
- assert.ok(panel, "the panel must open");
3478
- const body = () => panel!.render(120).slice(1, -2).map((line) => line.replace(/[ \t]+$/, "")).join("\n");
3479
- const firstAgentRow = (text: string) => text.split("\n").findIndex((line) => line.includes("agent-01"));
3480
- const before = body();
3481
- assert.ok(firstAgentRow(before) >= 0, "the first routing row must be visible before scrolling");
3482
- panel!.handleInput("j");
3483
- const afterJ = body();
3484
- assert.notEqual(firstAgentRow(afterJ), firstAgentRow(before), "j must scroll the detail down by one line");
3485
- panel!.handleInput("k");
3486
- assert.equal(firstAgentRow(body()), firstAgentRow(before), "k must scroll the detail back up");
3606
+ assert.match(text, /Used in this repo\./);
3607
+ assert.doesNotMatch(text, /subagents\.json routes/);
3487
3608
  });
3488
3609
 
3489
3610
  // The name prompt behind c, d, and r: the Pi host starts the field empty and
3490
3611
  // ignores the placeholder, and every outcome must show in the reopened panel,
3491
3612
  // because the fullscreen overlay hides notifications until it closes.
3613
+ /** The keys that reach each profile action on the models screen. */
3614
+ const SCREEN_KEYS = {
3615
+ rename: ["m", "\r"],
3616
+ duplicate: ["m", "j", "\r"],
3617
+ useInRepo: ["m", "j", "j", "\r"],
3618
+ delete: ["m", "j", "j", "j", "\r"],
3619
+ newCopy: ["n", "\r"],
3620
+ newFromLive: ["n", "j", "\r"],
3621
+ newEmpty: ["n", "j", "j", "\r"],
3622
+ } as const;
3623
+
3624
+ /**
3625
+ * Runs one screen action on a profile (the shown one by default) with scripted
3626
+ * name-prompt answers, then returns the screen the loop reopened with.
3627
+ */
3492
3628
  async function runProfilesNameAction(
3493
3629
  fixture: ReturnType<typeof routingConsumerFixture>,
3494
- keys: string | string[],
3630
+ keys: readonly string[],
3631
+ ...answers: Array<string | undefined>
3632
+ ): Promise<string> {
3633
+ return runProfilesNameActionOn(fixture, undefined, keys, ...answers);
3634
+ }
3635
+
3636
+ async function runProfilesNameActionOn(
3637
+ fixture: ReturnType<typeof routingConsumerFixture>,
3638
+ name: string | undefined,
3639
+ keys: readonly string[],
3495
3640
  ...answers: Array<string | undefined>
3496
3641
  ): Promise<string> {
3497
3642
  fixture.answerInputs(...answers);
3498
- let visits = 0;
3643
+ let acted = false;
3499
3644
  fixture.onInput((panel) => {
3500
- visits += 1;
3501
- if (visits > 1) return panel.handleInput("\x1b");
3502
- for (const key of [keys].flat()) panel.handleInput(key);
3645
+ if (acted) return panel.handleInput("\x1b");
3646
+ const tabs = tabNames(panel);
3647
+ for (let step = 0; name !== undefined && step < tabs.length && shownProfile(panel) !== name; step++) {
3648
+ panel.handleInput(tabs.indexOf(name) < tabs.indexOf(shownProfile(panel)) ? "\x1b[D" : "\x1b[C");
3649
+ }
3650
+ if (name !== undefined) assert.equal(shownProfile(panel), name);
3651
+ acted = true;
3652
+ for (const key of keys) panel.handleInput(key);
3503
3653
  });
3504
3654
  await fixture.run("gentle:profiles");
3505
- assert.equal(fixture.panels.length, 2, "the panel reopens once after the action");
3506
- return fixture.panels[1];
3655
+ return fixture.panels.at(-1) ?? "";
3507
3656
  }
3508
3657
 
3509
- /** The selected profile, read from the detail pane title of a rendered panel. */
3658
+ /** The profile a rendered screen shows. */
3510
3659
  function selectedProfileTitle(rendered: string): string {
3511
- return (rendered.split("\n")[1]?.split("│")[2] ?? "").replace("(active)", "").trim();
3660
+ return (rendered.split("\n")[3] ?? "").replace(/^│\s*/, "").split(/\s{3}|\s*│/)[0]?.trim() ?? "";
3512
3661
  }
3513
3662
 
3663
+ /** The feedback lines under the key hints of a rendered screen. */
3514
3664
  function profilesPanelFooter(rendered: string): string {
3515
- return rendered.split("\n").at(-2) ?? "";
3665
+ const lines = rendered.split("\n");
3666
+ const hints = lines.findIndex((line) => line.includes("enter model"));
3667
+ return lines.slice(hints + 1, hints + 3).map((line) => line.replace(/^│\s*/, "").replace(/\s*│$/, "")).join(" ").trim();
3516
3668
  }
3517
3669
 
3518
3670
  test("c creates the named profile, selects it, and reports it in the reopened panel", async (t) => {
3519
3671
  const { fixture, storePath, writeStore } = profilesStoreFixture(t);
3520
3672
  writeStore({ team: { worker: { model: "openai/alpha" } } }, "team");
3521
- const reopened = await runProfilesNameAction(fixture, "c", " deep-work ");
3673
+ const reopened = await runProfilesNameAction(fixture, SCREEN_KEYS.newEmpty, " deep-work ");
3522
3674
 
3523
3675
  const store = JSON.parse(readFileSync(storePath, "utf8"));
3524
3676
  assert.deepEqual(Object.keys(store.profiles), ["team", "deep-work"]);
@@ -3530,7 +3682,7 @@ test("c with an empty name creates nothing and says so in the reopened panel", a
3530
3682
  const { fixture, storePath, writeStore } = profilesStoreFixture(t);
3531
3683
  writeStore({ team: { worker: { model: "openai/alpha" } } }, "team");
3532
3684
  const before = readFileSync(storePath, "utf8");
3533
- const reopened = await runProfilesNameAction(fixture, "c", " ");
3685
+ const reopened = await runProfilesNameAction(fixture, SCREEN_KEYS.newEmpty, " ");
3534
3686
 
3535
3687
  assert.equal(readFileSync(storePath, "utf8"), before, "nothing is written");
3536
3688
  assert.equal(selectedProfileTitle(reopened), "team");
@@ -3540,7 +3692,7 @@ test("c with an empty name creates nothing and says so in the reopened panel", a
3540
3692
  test("d with an empty name duplicates to the suggested <name>-copy and selects it", async (t) => {
3541
3693
  const { fixture, storePath, writeStore } = profilesStoreFixture(t);
3542
3694
  writeStore({ team: { worker: { model: "openai/alpha" } } }, "team");
3543
- const reopened = await runProfilesNameAction(fixture, "d", "");
3695
+ const reopened = await runProfilesNameAction(fixture, SCREEN_KEYS.duplicate, "");
3544
3696
 
3545
3697
  // The host never shows the placeholder, so the suggestion lives in the title.
3546
3698
  assert.match(fixture.inputPrompts[0]?.title ?? "", /Duplicate profile "team" as \(empty = team-copy\)/);
@@ -3554,7 +3706,7 @@ test("d onto an existing name writes nothing and shows the conflict in the reope
3554
3706
  const { fixture, storePath, writeStore } = profilesStoreFixture(t);
3555
3707
  writeStore({ team: { worker: { model: "openai/alpha" } }, other: {} }, "team");
3556
3708
  const before = readFileSync(storePath, "utf8");
3557
- const reopened = await runProfilesNameAction(fixture, "d", "other");
3709
+ const reopened = await runProfilesNameAction(fixture, SCREEN_KEYS.duplicate, "other");
3558
3710
 
3559
3711
  assert.equal(readFileSync(storePath, "utf8"), before, "nothing is written");
3560
3712
  assert.equal(selectedProfileTitle(reopened), "team");
@@ -3565,7 +3717,7 @@ test("r with an empty name leaves the profile unchanged and says so in the reope
3565
3717
  const { fixture, storePath, writeStore } = profilesStoreFixture(t);
3566
3718
  writeStore({ team: { worker: { model: "openai/alpha" } } }, "team");
3567
3719
  const before = readFileSync(storePath, "utf8");
3568
- const reopened = await runProfilesNameAction(fixture, "r", "");
3720
+ const reopened = await runProfilesNameAction(fixture, SCREEN_KEYS.rename, "");
3569
3721
 
3570
3722
  assert.match(fixture.inputPrompts[0]?.title ?? "", /Rename profile "team" to \(empty = keep team\)/);
3571
3723
  assert.equal(readFileSync(storePath, "utf8"), before, "nothing is written");
@@ -3576,8 +3728,8 @@ test("r with an empty name leaves the profile unchanged and says so in the reope
3576
3728
  test("r renames the profile, keeps it selected under the new name, and reports it", async (t) => {
3577
3729
  const { fixture, storePath, writeStore } = profilesStoreFixture(t);
3578
3730
  writeStore({ team: { worker: { model: "openai/alpha" } }, other: {} }, "team");
3579
- // Rename the second row, so the reopened selection cannot land on it by default.
3580
- const reopened = await runProfilesNameAction(fixture, ["\x1b[B", "r"], "focus");
3731
+ // Rename the second tab, so the reopened screen cannot land on it by default.
3732
+ const reopened = await runProfilesNameActionOn(fixture, "other", SCREEN_KEYS.rename, "focus");
3581
3733
 
3582
3734
  const store = JSON.parse(readFileSync(storePath, "utf8"));
3583
3735
  assert.deepEqual(Object.keys(store.profiles), ["team", "focus"]);
@@ -3590,16 +3742,17 @@ test("escape on the name prompt cancels without writing and says so in the reope
3590
3742
  const { fixture, storePath, writeStore } = profilesStoreFixture(t);
3591
3743
  writeStore({ team: { worker: { model: "openai/alpha" } } }, "team");
3592
3744
  const before = readFileSync(storePath, "utf8");
3593
- const reopened = await runProfilesNameAction(fixture, "d", undefined);
3745
+ const reopened = await runProfilesNameAction(fixture, SCREEN_KEYS.duplicate, undefined);
3594
3746
 
3595
3747
  assert.equal(readFileSync(storePath, "utf8"), before, "nothing is written");
3596
3748
  assert.match(profilesPanelFooter(reopened), /Duplicate cancelled\./);
3597
3749
  });
3598
3750
 
3599
- // The pin is the per-repository layer of the profiles command: `p` writes the
3600
- // clone-scoped pin inside the Git common directory, `P` writes the committable
3601
- // per-worktree declaration. Neither writes routing.
3602
- test("p pins the selected profile for the clone without touching the global routing", async (t) => {
3751
+ // The pin is the per-repository layer of the profiles command: Use in this repo
3752
+ // (profile menu) writes the clone-scoped pin inside the Git common directory,
3753
+ // `/gentle:profiles publish <name>` writes the committable per-worktree
3754
+ // declaration. Neither writes routing.
3755
+ test("Use in this repo pins the shown profile for the clone without touching the global routing", async (t) => {
3603
3756
  const { fixture, storePath, localPinPath, repoPinPath, writeStore } = profilesStoreFixture(t);
3604
3757
  writeStore({ team: { worker: { model: "openai/alpha" } } }, "team");
3605
3758
  const storeBefore = readFileSync(storePath, "utf8");
@@ -3608,7 +3761,7 @@ test("p pins the selected profile for the clone without touching the global rout
3608
3761
  fixture.onInput((panel) => {
3609
3762
  if (firstPanel === undefined) {
3610
3763
  firstPanel = panel;
3611
- panel.handleInput("p");
3764
+ for (const key of SCREEN_KEYS.useInRepo) panel.handleInput(key);
3612
3765
  } else {
3613
3766
  reopenedPanel = panel;
3614
3767
  panel.handleInput("\x1b");
@@ -3620,18 +3773,18 @@ test("p pins the selected profile for the clone without touching the global rout
3620
3773
  readFileSync(localPinPath, "utf8"),
3621
3774
  `${JSON.stringify({ kind: PROFILE_PIN_KIND, version: PROFILE_PIN_VERSION, profile: "team" }, null, 2)}\n`,
3622
3775
  );
3623
- assert.equal(existsSync(repoPinPath), false, "p writes the clone-scoped layer only");
3776
+ assert.equal(existsSync(repoPinPath), false, "Use in this repo writes the clone-scoped layer only");
3624
3777
  assert.equal(readFileSync(storePath, "utf8"), storeBefore, "a pin stores a name, never routing");
3625
3778
  assert.match(fixture.notifications.at(-1)?.message ?? "", /pinned profile "team" for this clone in /);
3626
3779
  assert.ok(firstPanel);
3627
- // The panel reports the layer that would win, next to the orchestrator lines it
3628
- // deliberately does not move.
3780
+ // The reopened screen marks the profile used in this repo (⌂) and says so.
3629
3781
  assert.ok(reopenedPanel);
3630
- assert.match(stripAnsi(renderComponent(reopenedPanel)), /pin\s+local: team/);
3631
- // Rendering an existing panel must not resolve Git or read the pin again.
3782
+ assert.match(stripAnsi(renderComponent(reopenedPanel)), /team ★⌂/);
3783
+ assert.match(stripAnsi(renderComponent(reopenedPanel)), /Used in this repo\./);
3784
+ // Rendering an existing screen must not resolve Git or read the pin again.
3632
3785
  setProfilePinWorktreeResolverForTesting(() => { throw new Error("unexpected pin read during render"); });
3633
- assert.doesNotMatch(stripAnsi(renderComponent(firstPanel!)), /pin\s+local: team/);
3634
- assert.match(stripAnsi(renderComponent(reopenedPanel)), /pin\s+local: team/);
3786
+ assert.doesNotMatch(stripAnsi(renderComponent(firstPanel!)), /Used in this repo\./);
3787
+ assert.match(stripAnsi(renderComponent(reopenedPanel)), /Used in this repo\./);
3635
3788
  });
3636
3789
 
3637
3790
  test("the profile pin scope note sanitizes its worktree-derived path", (t) => {
@@ -3649,26 +3802,17 @@ test("the profile pin scope note sanitizes its worktree-derived path", (t) => {
3649
3802
  assert.match(note, /profile-pin\.json/);
3650
3803
  });
3651
3804
 
3652
- test("P declares the profile in the worktree so the routing can be committed", async (t) => {
3805
+ test("publish declares the profile in the worktree so the routing can be committed", async (t) => {
3653
3806
  const { fixture, localPinPath, repoPinPath, writeStore } = profilesStoreFixture(t);
3654
3807
  writeStore({ team: { worker: { model: "openai/alpha" } } }, "team");
3655
- let firstPanel: RoutingConsumerPanel | undefined;
3656
- fixture.onInput((panel) => {
3657
- if (firstPanel === undefined) {
3658
- firstPanel = panel;
3659
- panel.handleInput("P");
3660
- panel.handleInput("\x1b");
3661
- } else {
3662
- panel.handleInput("\x1b");
3663
- }
3664
- });
3665
- await fixture.run("gentle:profiles");
3808
+ await fixture.run("gentle:profiles", "publish team");
3809
+ assert.equal(fixture.panelVisits(), 0, "publish is a subcommand: no screen opens");
3666
3810
 
3667
3811
  assert.equal(
3668
3812
  readFileSync(repoPinPath, "utf8"),
3669
3813
  `${JSON.stringify({ kind: PROFILE_PIN_KIND, version: PROFILE_PIN_VERSION, profile: "team" }, null, 2)}\n`,
3670
3814
  );
3671
- assert.equal(existsSync(localPinPath), false, "P writes the worktree declaration only");
3815
+ assert.equal(existsSync(localPinPath), false, "publish writes the worktree declaration only");
3672
3816
  assert.match(fixture.notifications.at(-1)?.message ?? "", /declared profile "team" for this worktree in /);
3673
3817
  });
3674
3818
 
@@ -3682,7 +3826,7 @@ test("a stale pin is named as missing instead of silently changing nothing", asy
3682
3826
  panel.handleInput("\x1b");
3683
3827
  });
3684
3828
  await fixture.run("gentle:profiles");
3685
- assert.match(rendered ?? "", /pin\s+local: deleted-profile \(missing from this store\)/);
3829
+ assert.match(rendered ?? "", /⚠ The local pin names "deleted-profile", which no profile defines, so it is\s+(│\s+)?ignored/);
3686
3830
  });
3687
3831
 
3688
3832
  test("pinning outside a Git worktree warns and writes nothing", async (t) => {
@@ -3694,15 +3838,17 @@ test("pinning outside a Git worktree warns and writes nothing", async (t) => {
3694
3838
  fixture.onInput((panel) => {
3695
3839
  assert.ok(++visits <= 2, "outside-worktree fixture repeatedly reopens and pins the panel");
3696
3840
  // A completed panel ignores Escape; close the next visit instead.
3697
- panel.handleInput(visits === 1 ? "p" : "\x1b");
3841
+ if (visits === 1) for (const key of SCREEN_KEYS.useInRepo) panel.handleInput(key);
3842
+ else panel.handleInput("\x1b");
3698
3843
  });
3699
3844
  await fixture.run("gentle:profiles");
3700
3845
  assert.equal(existsSync(localPinPath), false);
3701
3846
  assert.equal(existsSync(repoPinPath), false);
3702
3847
  assert.match(fixture.notifications.at(-1)?.message ?? "", /not inside a Git worktree/);
3848
+ assert.match(profilesPanelFooter(fixture.panels.at(-1) ?? ""), /not inside a Git worktree, so nothing was pinned/);
3703
3849
  });
3704
3850
 
3705
- test("pressing p again on the pinned profile removes the clone pin", async (t) => {
3851
+ test("Stop using in this repo removes the clone pin", async (t) => {
3706
3852
  const { fixture, localPinPath, writeStore, writePin } = profilesStoreFixture(t);
3707
3853
  writeStore({ team: { worker: { model: "openai/alpha" } } }, "team");
3708
3854
  writePin(localPinPath, "team");
@@ -3710,18 +3856,19 @@ test("pressing p again on the pinned profile removes the clone pin", async (t) =
3710
3856
  fixture.onInput((panel) => {
3711
3857
  if (firstPanel === undefined) {
3712
3858
  firstPanel = panel;
3713
- panel.handleInput("p");
3714
- panel.handleInput("\x1b");
3859
+ panel.handleInput("m");
3860
+ assert.match(stripAnsi(renderComponent(panel)), /Stop using in this repo/, "the menu names the toggle by its effect");
3861
+ for (const key of SCREEN_KEYS.useInRepo.slice(1)) panel.handleInput(key);
3715
3862
  } else {
3716
3863
  panel.handleInput("\x1b");
3717
3864
  }
3718
3865
  });
3719
3866
  await fixture.run("gentle:profiles");
3720
- assert.equal(existsSync(localPinPath), false, "a second p removes the layer instead of rewriting it");
3867
+ assert.equal(existsSync(localPinPath), false, "using it again removes the layer instead of rewriting it");
3721
3868
  assert.match(fixture.notifications.at(-1)?.message ?? "", /removed the local pin for this clone/);
3722
3869
  });
3723
3870
 
3724
- test("the profile list marks the layer that wins with (pinned)", async (t) => {
3871
+ test("the tab of the pinned profile carries ⌂ and the screen opens on it", async (t) => {
3725
3872
  const { fixture, localPinPath, writeStore, writePin } = profilesStoreFixture(t);
3726
3873
  writeStore({
3727
3874
  team: { worker: { model: "openai/alpha" } },
@@ -3734,8 +3881,10 @@ test("the profile list marks the layer that wins with (pinned)", async (t) => {
3734
3881
  panel.handleInput("\x1b");
3735
3882
  });
3736
3883
  await fixture.run("gentle:profiles");
3737
- assert.match(rendered ?? "", /other \(pinned\)/);
3738
- assert.doesNotMatch(rendered ?? "", /team \(pinned\)/);
3884
+ const tabLine = (rendered ?? "").split("\n")[1] ?? "";
3885
+ assert.match(tabLine, /other ⌂/);
3886
+ assert.doesNotMatch(tabLine, /team ★⌂/);
3887
+ assert.equal(selectedProfileTitle(rendered ?? ""), "other", "a winning pin is the profile the screen opens on");
3739
3888
  });
3740
3889
 
3741
3890
  test("an invalid pin file is surfaced by the panel instead of reading as no pin", async (t) => {
@@ -3749,8 +3898,9 @@ test("an invalid pin file is surfaced by the panel instead of reading as no pin"
3749
3898
  panel.handleInput("\x1b");
3750
3899
  });
3751
3900
  await fixture.run("gentle:profiles");
3752
- assert.match(rendered ?? "", /invalid pin file/);
3753
- assert.doesNotMatch(rendered ?? "", /\(missing from this store\)/);
3901
+ // The notice wraps between words; the frame's border columns sit between the lines.
3902
+ assert.match((rendered ?? "").replace(/\s*│\s*/g, " ").replace(/\s+/g, " "), /⚠ The local pin file .* is not a valid pin and is ignored\./);
3903
+ assert.doesNotMatch(rendered ?? "", /which no profile defines/);
3754
3904
  assert.equal(readFileSync(localPinPath, "utf8"), "{ not json\n", "reading never rewrites a broken pin");
3755
3905
  });
3756
3906
 
@@ -3794,16 +3944,8 @@ test("deleting a profile is refused when a non-winning pin layer names it", asyn
3794
3944
  // does not win but still names a live profile for this repository.
3795
3945
  writePin(localPinPath, "other");
3796
3946
  writePin(repoPinPath, "team");
3797
- let firstPanel: RoutingConsumerPanel | undefined;
3798
- fixture.onInput((panel) => {
3799
- if (firstPanel === undefined) {
3800
- firstPanel = panel;
3801
- panel.handleInput("x");
3802
- panel.handleInput("\x1b");
3803
- } else {
3804
- panel.handleInput("\x1b");
3805
- }
3806
- });
3947
+ // The screen opens on "other" (the winning pin); go to "team" and delete it.
3948
+ actOnProfile(fixture, SCREEN_KEYS.delete, "team");
3807
3949
  await fixture.run("gentle:profiles");
3808
3950
  assert.equal(existsSync(repoPinPath), true, "the declaration survives the refused delete");
3809
3951
  assert.match(fixture.notifications.at(-1)?.message ?? "", /is pinned for this repository/);
@@ -4009,7 +4151,7 @@ test("Enter switches the live orchestrator, binds the profile, and writes nothin
4009
4151
  getThinkingLevel(): ThinkingLevel { return "medium"; },
4010
4152
  };
4011
4153
  const file = readValidProfilesStore(storePath);
4012
- await __testing.runProfilesPanelAction(ctx, live, storePath, file, { type: "apply", name: "team" }, {});
4154
+ await __testing.runProfilesPanelAction(ctx, live, storePath, file, { type: "use-session", name: "team" }, {});
4013
4155
  assert.deepEqual(models, [{ provider: "openai", id: "beta" }]);
4014
4156
  assert.deepEqual(thinking, ["high"]);
4015
4157
  const binding = readSessionProfileBinding("session-panel");
@@ -4021,7 +4163,7 @@ test("Enter switches the live orchestrator, binds the profile, and writes nothin
4021
4163
  assert.equal(existsSync(join(fixture.root, ".pi", "subagents.json")), false, "no materialized store is written");
4022
4164
  const applied = notifications.at(-1)?.message ?? "";
4023
4165
  assert.match(applied, /bound profile "team" to this session/);
4024
- assert.match(applied, /shown as "team \(session\)"/);
4166
+ assert.match(applied, /its tab shows ◉/);
4025
4167
  assert.match(applied, /This session now runs on openai\/beta · high/);
4026
4168
  assert.match(applied, /Subagents and reviewers use this session's routing snapshot/);
4027
4169
  assert.match(applied, /global routing, pins, and materialized stores are untouched/);
@@ -4051,7 +4193,7 @@ test("Enter switches only the selecting session's orchestrator and leaves shared
4051
4193
  setThinkingLevel: (level: ThinkingLevel) => { thinking.push(level); },
4052
4194
  getThinkingLevel: (): ThinkingLevel => "medium",
4053
4195
  } as unknown as LiveSession;
4054
- await __testing.runProfilesPanelAction(ctx, live, storePath, readValidProfilesStore(storePath), { type: "apply", name: "team" });
4196
+ await __testing.runProfilesPanelAction(ctx, live, storePath, readValidProfilesStore(storePath), { type: "use-session", name: "team" });
4055
4197
  assert.deepEqual(switches, ["openai/alpha"]);
4056
4198
  assert.deepEqual(thinking, ["high"]);
4057
4199
  assert.equal(readSessionProfileBinding("selecting-session")?.name, "team");
@@ -4105,7 +4247,7 @@ test("Enter preserves session-only routing when the orchestrator is absent or ca
4105
4247
  setThinkingLevel() { thinkingCalls++; },
4106
4248
  getThinkingLevel(): ThinkingLevel { return "medium"; },
4107
4249
  };
4108
- await __testing.runProfilesPanelAction(ctx, live, storePath, readValidProfilesStore(storePath), { type: "apply", name: "team" }, {});
4250
+ await __testing.runProfilesPanelAction(ctx, live, storePath, readValidProfilesStore(storePath), { type: "use-session", name: "team" }, {});
4109
4251
  assert.equal(modelCalls, rejected ? 1 : 0);
4110
4252
  assert.equal(thinkingCalls, 0);
4111
4253
  assert.equal(readSessionProfileBinding("session-panel")?.modelProfiles.worker?.model, "openai/alpha");
@@ -4129,7 +4271,7 @@ test("a keeps the legacy global apply semantics", async (t) => {
4129
4271
  } as unknown as ExtensionContext;
4130
4272
  const live = { setModel: async () => true, setThinkingLevel() {}, getThinkingLevel(): ThinkingLevel { return "medium"; } };
4131
4273
  const file = readValidProfilesStore(storePath);
4132
- await __testing.runProfilesPanelAction(ctx, live, storePath, file, { type: "apply-global", name: "team" }, {});
4274
+ await __testing.runProfilesPanelAction(ctx, live, storePath, file, { type: "apply-global", name: "team", config: file.profiles["team"] }, {});
4133
4275
  assert.equal(JSON.parse(readFileSync(storePath, "utf8")).active, "team", "the global store claims the profile");
4134
4276
  assert.ok(existsSync(fixture.globalPath), "the global routing is materialized");
4135
4277
  resetSessionProfileBindingsForTesting();
@@ -4150,7 +4292,7 @@ test("Enter with a winning pin binds the session and never touches the pin layer
4150
4292
  } as unknown as ExtensionContext;
4151
4293
  const live = { setModel: async () => true, setThinkingLevel() {}, getThinkingLevel(): ThinkingLevel { return "medium"; } };
4152
4294
  const file = readValidProfilesStore(storePath);
4153
- await __testing.runProfilesPanelAction(ctx, live, storePath, file, { type: "apply", name: "team" }, {});
4295
+ await __testing.runProfilesPanelAction(ctx, live, storePath, file, { type: "use-session", name: "team" }, {});
4154
4296
  assert.equal(readSessionProfileBinding("session-panel")?.name, "team");
4155
4297
  assert.equal(readFileSync(localPinPath, "utf8"), pinBefore, "the clone pin is untouched");
4156
4298
  assert.equal(readFileSync(storePath, "utf8"), storeBefore, "the store is untouched");
@@ -4158,69 +4300,22 @@ test("Enter with a winning pin binds the session and never touches the pin layer
4158
4300
  resetSessionProfileBindingsForTesting();
4159
4301
  });
4160
4302
 
4161
- test("a session-bound panel renders the binding snapshot as the current routing", async (t) => {
4162
- const { fixture, storePath, writeStore } = profilesStoreFixture(t);
4163
- writeStore({ team: { worker: { model: "openai/beta" } } }, "team");
4164
- mkdirSync(fixture.configHome, { recursive: true });
4165
- writeFileSync(fixture.globalPath, `${JSON.stringify({ worker: { model: "openai/alpha" } }, null, 2)}\n`);
4166
- t.after(() => resetSessionProfileBindingsForTesting());
4167
- bindSessionProfile("session-panel", "team", { worker: { model: "openai/gamma" } });
4168
- fixture.onInput((panel) => {
4169
- const rendered = renderComponent(panel);
4170
- assert.match(rendered, /Current routing \(effective\)/);
4171
- assert.match(rendered, /openai\/gamma/, "the current routing is the session binding's snapshot");
4172
- assert.doesNotMatch(rendered, /openai\/alpha/, "the global routing stays out of a bound session's panel");
4173
- assert.match(
4174
- rendered,
4175
- /team \(session\) — launches resolve it ahead of pins/,
4176
- "the session line states what the binding governs post-#1558 (the panel truncates long lines)",
4177
- );
4178
- assert.doesNotMatch(rendered, /launch routing is unchanged/, "no stale slice-1 claim survives in the panel");
4179
- panel.handleInput("\x1b");
4180
- });
4181
- await fixture.run("gentle:profiles");
4182
- resetSessionProfileBindingsForTesting();
4183
- });
4184
-
4185
- test("the now line reports the live session's orchestrator over the settings defaults", async (t) => {
4186
- const { fixture, writeStore, writeSettings } = profilesStoreFixture(t);
4187
- writeSettings();
4188
- writeStore({ team: { worker: { model: "openai/beta" } } }, "team");
4189
- // The session runs live on openai/omega at low effort while settings.json still
4190
- // defaults to nan/deepseek-v4-flash at high: the panel's now line must report
4191
- // what this session actually runs, not the default new sessions would get.
4192
- fixture.setLiveModel("openai", "omega", "low");
4193
- fixture.onInput((panel) => {
4194
- const rendered = renderComponent(panel);
4195
- assert.match(rendered, /now\s+openai\/omega/, "the now line shows the live session's orchestrator");
4196
- assert.doesNotMatch(rendered, /now\s+nan\//, "the settings default stays off the now line when a live model runs");
4197
- panel.handleInput("\x1b");
4198
- });
4199
- await fixture.run("gentle:profiles");
4200
- });
4201
-
4202
- test("the (session) marker survives a snapshot refresh of the panel list", async (t) => {
4203
- const { fixture, storePath, writeStore } = profilesStoreFixture(t);
4204
- writeStore({ team: { worker: { model: "openai/beta" } } }, "team");
4205
- mkdirSync(fixture.configHome, { recursive: true });
4206
- writeFileSync(fixture.globalPath, `${JSON.stringify({ worker: { model: "openai/alpha" } }, null, 2)}\n`);
4303
+ test("a session-bound profile carries ◉ and says it is used in this session", async (t) => {
4304
+ const { fixture, writeStore } = profilesStoreFixture(t);
4305
+ writeStore({ team: { worker: { model: "openai/beta" } }, other: {} }, "team");
4207
4306
  t.after(() => resetSessionProfileBindingsForTesting());
4208
4307
  bindSessionProfile("session-panel", "team", { worker: { model: "openai/gamma" } });
4308
+ let rendered = "";
4209
4309
  fixture.onInput((panel) => {
4210
- assert.match(renderComponent(panel), /team \(active\) \(session\)/);
4211
- panel.handleInput("s");
4212
- assert.match(renderComponent(panel), /Snapshot saved; live routing unchanged\./);
4213
- assert.match(renderComponent(panel), /team \(active\) \(session\)/, "the refreshed list keeps the session marker");
4310
+ rendered = stripAnsi(renderComponent(panel));
4214
4311
  panel.handleInput("\x1b");
4215
4312
  });
4216
4313
  await fixture.run("gentle:profiles");
4217
- // The snapshot must capture the routing the panel showed as current, so the
4218
- // saved team profile carries the session binding's model, never the global
4219
- // layer underneath it.
4220
- const saved = readValidProfilesStore(storePath);
4221
- assert.equal(saved.profiles.team?.worker?.model, "openai/gamma", "the saved profile carries the session-bound routing");
4222
- assert.notEqual(saved.profiles.team?.worker?.model, "openai/alpha", "the global routing never leaks into the saved snapshot");
4223
- resetSessionProfileBindingsForTesting();
4314
+ const tabLine = rendered.split("\n")[1] ?? "";
4315
+ assert.match(tabLine, /team ◉★/);
4316
+ assert.doesNotMatch(tabLine, /other ◉/);
4317
+ assert.match(rendered, /Used in this session\. Default for new sessions\./);
4318
+ assert.doesNotMatch(rendered, /launch routing is unchanged/, "no stale slice-1 claim survives on the screen");
4224
4319
  });
4225
4320
 
4226
4321
  test("Enter without a parent session id fails loud and writes nothing", async (t) => {
@@ -4235,10 +4330,116 @@ test("Enter without a parent session id fails loud and writes nothing", async (t
4235
4330
  } as unknown as ExtensionContext;
4236
4331
  const live = { setModel: async () => true, setThinkingLevel() {}, getThinkingLevel(): ThinkingLevel { return "medium"; } };
4237
4332
  const file = readValidProfilesStore(storePath);
4238
- await __testing.runProfilesPanelAction(ctx, live, storePath, file, { type: "apply", name: "team" }, {});
4333
+ await __testing.runProfilesPanelAction(ctx, live, storePath, file, { type: "use-session", name: "team" }, {});
4239
4334
  assert.equal(readSessionProfileBinding(undefined), undefined);
4240
4335
  assert.equal(readFileSync(storePath, "utf8"), before);
4241
4336
  assert.equal(existsSync(fixture.globalPath), false);
4242
4337
  assert.equal(notifications.at(-1)?.severity, "warning");
4243
4338
  resetSessionProfileBindingsForTesting();
4244
4339
  });
4340
+
4341
+ test("file operations are subcommands: profiles restore-routing, export and import, and usage for anything else", async (t) => {
4342
+ const { fixture, storePath, writeStore } = profilesStoreFixture(t);
4343
+ writeStore({ team: { worker: { model: "openai/alpha", thinking: "high" } } }, "team");
4344
+
4345
+ // /gentle:profiles restore-routing: confirm, then write models.json and materialize it.
4346
+ writeMarkdown(fixture.exportPath, `${JSON.stringify({ kind: "gentle-pi.agent_model_routing", version: 1, agents: { worker: { model: "openai/beta", thinking: "low" } } }, null, 2)}\n`);
4347
+ fixture.onConfirm(async () => false);
4348
+ await fixture.run("gentle:profiles", "restore-routing");
4349
+ assert.equal(existsSync(fixture.globalPath), false, "a declined restore writes nothing");
4350
+ fixture.onConfirm(async () => true);
4351
+ await fixture.run("gentle:profiles", "restore-routing");
4352
+ assert.deepEqual(JSON.parse(readFileSync(fixture.globalPath, "utf8")).worker, { model: "openai/beta", thinking: "low" });
4353
+ assert.match(fixture.notifications.at(-1)?.message ?? "", /restored global model config/);
4354
+ assert.match(readFileSync(join(fixture.root, ".pi", "agents", "worker.md"), "utf8"), /model: openai\/beta/);
4355
+
4356
+ // /gentle:profiles export-routing writes the saved routing, not a profile.
4357
+ await fixture.run("gentle:profiles", "export-routing");
4358
+ assert.deepEqual(JSON.parse(readFileSync(fixture.exportPath, "utf8")).agents, { worker: { model: "openai/beta", thinking: "low" } });
4359
+ assert.match(fixture.notifications.at(-1)?.message ?? "", /exported 1 saved model routing entry/);
4360
+
4361
+ // /gentle:profiles export <name> then import it back over a changed store.
4362
+ await fixture.run("gentle:profiles", "export team");
4363
+ const exportFile = join(fixture.configHome, "profiles.export.json");
4364
+ assert.ok(existsSync(exportFile));
4365
+ assert.match(fixture.notifications.at(-1)?.message ?? "", /exported profile "team"/);
4366
+ writeStore({ team: { worker: { model: "openai/gamma" } } }, "team");
4367
+ await fixture.run("gentle:profiles", "import");
4368
+ assert.deepEqual(JSON.parse(readFileSync(storePath, "utf8")).profiles.team, { worker: { model: "openai/alpha", thinking: "high" } }, "import replaces after confirmation");
4369
+
4370
+ for (const args of ["export", "export ghost", "publish", "frobnicate", "restore", "export-routing extra"]) {
4371
+ await fixture.run("gentle:profiles", args);
4372
+ assert.match(fixture.notifications.at(-1)?.message ?? "", /Usage: \/gentle:profiles \[export <name> \| import \| publish <name> \| export-routing \| restore-routing\]/, args);
4373
+ }
4374
+ assert.equal(fixture.panelVisits(), 0, "no subcommand opens the screen");
4375
+ });
4376
+
4377
+ test("gentle:models is gone: gentle:profiles is the only models and profiles command", () => {
4378
+ const commands = new Map<string, { description?: string }>();
4379
+ createGentleAiExtension({ nativeReviewCli: null })({
4380
+ on() {},
4381
+ registerTool() {},
4382
+ registerCommand(name: string, command: { description?: string }) { commands.set(name, command); },
4383
+ } as unknown as ExtensionAPI);
4384
+ assert.equal(commands.has("gentle:models"), false);
4385
+ assert.match(commands.get("gentle:profiles")?.description ?? "", /export-routing, restore-routing/);
4386
+ for (const [name, command] of commands) {
4387
+ assert.doesNotMatch(command.description ?? "", /gentle:models/, name);
4388
+ }
4389
+ });
4390
+
4391
+ test("gentle:profiles still opens the screen and keeps export <name>, import, and publish <name>", async (t) => {
4392
+ const { fixture, storePath, repoPinPath, writeStore } = profilesStoreFixture(t);
4393
+ writeStore({ team: { worker: { model: "openai/alpha" } } }, "team");
4394
+ fixture.onInput((panel) => {
4395
+ assert.equal(shownProfile(panel), "team");
4396
+ panel.handleInput("\x1b");
4397
+ });
4398
+ await fixture.run("gentle:profiles");
4399
+ assert.equal(fixture.panelVisits(), 1);
4400
+ await fixture.run("gentle:profiles", "export team");
4401
+ assert.match(fixture.notifications.at(-1)?.message ?? "", /exported profile "team"/);
4402
+ writeStore({ team: { worker: { model: "openai/gamma" } } }, "team");
4403
+ await fixture.run("gentle:profiles", "import");
4404
+ assert.deepEqual(JSON.parse(readFileSync(storePath, "utf8")).profiles.team, { worker: { model: "openai/alpha" } });
4405
+ await fixture.run("gentle:profiles", "publish team");
4406
+ assert.equal(JSON.parse(readFileSync(repoPinPath, "utf8")).profile, "team");
4407
+ assert.equal(fixture.panelVisits(), 1, "subcommands open no screen");
4408
+ });
4409
+
4410
+ test("switching profile tabs redraws in place: the overlay never closes and reopens", async (t) => {
4411
+ const { fixture, storePath, writeStore } = profilesStoreFixture(t);
4412
+ writeStore({
4413
+ team: { worker: { model: "openai/alpha" } },
4414
+ other: { worker: { model: "openai/beta", thinking: "high" } },
4415
+ }, "team");
4416
+ const frames: string[] = [];
4417
+ let visits = 0;
4418
+ fixture.onInput((panel) => {
4419
+ visits += 1;
4420
+ if (visits > 1) {
4421
+ panel.handleInput("\x1b");
4422
+ return;
4423
+ }
4424
+ frames.push(stripAnsi(renderComponent(panel)));
4425
+ panel.handleInput(RIGHT_KEY); // clean switch to "other"
4426
+ frames.push(stripAnsi(renderComponent(panel)));
4427
+ chooseAgent(panel, "worker");
4428
+ chooseModel(panel, "alpha");
4429
+ panel.handleInput("\x1b[D"); // back to "team" with an unsaved edit: asks first
4430
+ frames.push(stripAnsi(renderComponent(panel)));
4431
+ panel.handleInput("s"); // Save and switch
4432
+ frames.push(stripAnsi(renderComponent(panel)));
4433
+ panel.handleInput("\x1b");
4434
+ });
4435
+ await fixture.run("gentle:profiles");
4436
+ assert.equal(fixture.panelVisits(), 1, "a tab switch keeps the one overlay on screen");
4437
+ const [first, switched, asking, saved] = frames;
4438
+ assert.match(first ?? "", /worker\s+openai\/alpha/);
4439
+ assert.match(switched ?? "", /worker\s+openai\/beta\s+high/, "the switched frame is already the new profile");
4440
+ assert.match(profilesPanelFooter(switched ?? ""), /Showing "other"\./);
4441
+ assert.match(asking ?? "", /other has 1 unsaved change\./);
4442
+ assert.match(saved ?? "", /worker\s+openai\/alpha/);
4443
+ assert.match(profilesPanelFooter(saved ?? ""), /Saved "other"\. Showing "team"\./);
4444
+ assert.deepEqual(JSON.parse(readFileSync(storePath, "utf8")).profiles.other, { worker: { model: "openai/alpha", thinking: "high" } });
4445
+ });