gentle-pi 4.4.0 → 4.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/bin/gentle-shell.mjs +37 -4
- package/docs/gentle-music.md +24 -3
- package/docs/gentle-shell.md +5 -5
- package/docs/readme-reference.md +58 -45
- package/extensions/gentle-agents.ts +14 -2
- package/extensions/gentle-ai.ts +1672 -1237
- package/extensions/gentle-music.ts +73 -8
- package/extensions/gentle-shell.ts +88 -8
- package/lib/command-palette-catalog.ts +1 -2
- package/lib/gentle-shell-bundled.ts +18 -8
- package/lib/gentle-shell-launcher.ts +2 -0
- package/lib/jobs-sidebar-state.ts +22 -3
- package/lib/jobs-widget.ts +112 -0
- package/lib/session-profile-freeze.ts +2 -2
- package/lib/shell-bar.ts +2 -4
- package/lib/shell-sidebar-layout.ts +1 -1
- package/lib/vim-editor-adapter.ts +3 -2
- package/package.json +1 -1
- package/runtime/gentle-shell-bundled.mjs +18 -8
- package/runtime/gentle-shell-launcher.mjs +2 -0
- package/scripts/bundled-install.mjs +209 -13
- package/scripts/installer-downloads.mjs +24 -0
- package/scripts/installer-preflight.mjs +1 -1
- package/scripts/pi-update.mjs +3 -1
- package/tests/bundled-install.test.ts +273 -2
- package/tests/command-palette.test.ts +3 -3
- package/tests/gentle-agents.test.ts +4 -4
- package/tests/gentle-ai.test.ts +810 -609
- package/tests/gentle-music.test.ts +117 -0
- package/tests/gentle-shell-bundled.test.ts +20 -0
- package/tests/gentle-shell-launcher.test.ts +1 -0
- package/tests/gentle-shell.test.ts +44 -19
- package/tests/installer-downloads.test.ts +32 -0
- package/tests/installer-server.test.ts +2 -2
- package/tests/jobs-widget.test.ts +138 -0
- package/tests/model-profiles-preservation.test.ts +2 -2
- package/tests/package-manifest.test.ts +2 -2
- package/tests/pi-update.test.ts +5 -0
- package/tests/runtime-harness.mjs +188 -211
- package/tests/session-profile-freeze.test.ts +6 -4
- package/tests/shell-bar.test.ts +4 -4
- package/tests/shell-sidebar-layout.test.ts +17 -0
- package/tests/vim-editor-adapter.test.ts +2 -2
package/tests/gentle-ai.test.ts
CHANGED
|
@@ -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: () =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
524
|
-
|
|
525
|
-
|
|
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("
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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("\
|
|
569
|
+
panel.handleInput("\x1b");
|
|
544
570
|
});
|
|
545
|
-
await fixture.run("gentle:
|
|
546
|
-
assert.
|
|
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
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
panel.handleInput("
|
|
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:
|
|
573
|
-
assert.deepEqual(JSON.parse(readFileSync(
|
|
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
|
-
|
|
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("
|
|
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
|
-
|
|
630
|
-
|
|
631
|
-
assert.
|
|
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,
|
|
638
|
-
fixture.
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
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
|
-
|
|
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
|
-
|
|
674
|
-
assert.
|
|
675
|
-
assert.equal(fixture.panelVisits(),
|
|
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 ? "
|
|
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
|
-
|
|
694
|
-
fixture.
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
assert.
|
|
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
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
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, /
|
|
1283
|
-
assert.match(plain, /safe-agent\s+
|
|
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("
|
|
1288
|
-
const lines =
|
|
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
|
|
1366
|
+
for (const line of plain) assert.equal(visibleWidth(line), 72);
|
|
1294
1367
|
});
|
|
1295
1368
|
|
|
1296
|
-
test("
|
|
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
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
assert.equal(
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
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("
|
|
1316
|
-
const
|
|
1317
|
-
|
|
1318
|
-
|
|
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
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
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
|
-
|
|
1327
|
-
|
|
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
|
-
|
|
2026
|
-
|
|
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
|
|
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
|
-
|
|
2033
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
|
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(
|
|
2905
|
-
assert.
|
|
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
|
|
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
|
|
2922
|
-
//
|
|
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.
|
|
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("
|
|
2941
|
-
|
|
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
|
|
2954
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
|
|
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:
|
|
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(
|
|
2982
|
-
assert.equal(
|
|
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
|
-
|
|
2987
|
-
|
|
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
|
|
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.
|
|
3250
|
+
const globalBefore = readFileSync(fixture.globalPath, "utf8");
|
|
3006
3251
|
let visits = 0;
|
|
3007
3252
|
fixture.onInput((panel) => {
|
|
3008
|
-
|
|
3253
|
+
visits += 1;
|
|
3254
|
+
if (visits === 1) {
|
|
3009
3255
|
chooseOrchestrator(panel);
|
|
3010
3256
|
chooseModel(panel, "leave untouched");
|
|
3011
|
-
|
|
3012
|
-
|
|
3013
|
-
|
|
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:
|
|
3263
|
+
await fixture.run("gentle:profiles");
|
|
3021
3264
|
assert.equal(Object.hasOwn(JSON.parse(readFileSync(storePath, "utf8")).profiles.team, "orchestrator"), false);
|
|
3022
|
-
assert.equal(
|
|
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
|
-
|
|
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, [], "
|
|
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,
|
|
3036
|
-
test(`models ${name}
|
|
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
|
|
3280
|
+
const storeBefore = readFileSync(storePath, "utf8");
|
|
3281
|
+
const settingsBefore = readFileSync(settingsPath, "utf8");
|
|
3282
|
+
let visits = 0;
|
|
3042
3283
|
fixture.onInput((panel) => {
|
|
3043
|
-
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
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:
|
|
3049
|
-
assert.deepEqual([readFileSync(storePath, "utf8"), readFileSync(settingsPath, "utf8")], before);
|
|
3294
|
+
await fixture.run("gentle:profiles");
|
|
3050
3295
|
assert.deepEqual(fixture.liveSwitches, []);
|
|
3051
|
-
|
|
3052
|
-
|
|
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
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
|
|
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
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
|
|
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:
|
|
3100
|
-
|
|
3101
|
-
assert.equal(
|
|
3102
|
-
if (
|
|
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("
|
|
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
|
-
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
|
|
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:
|
|
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(
|
|
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("
|
|
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
|
-
|
|
3175
|
-
|
|
3176
|
-
|
|
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:
|
|
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.
|
|
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("
|
|
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
|
-
|
|
3223
|
-
|
|
3224
|
-
|
|
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:
|
|
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
|
-
|
|
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, /
|
|
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
|
-
|
|
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
|
|
3334
|
-
let panel: { render(width: number): string[] } | undefined;
|
|
3526
|
+
let rendered = "";
|
|
3335
3527
|
fixture.onInput((visited) => {
|
|
3336
|
-
|
|
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.
|
|
3343
|
-
|
|
3344
|
-
|
|
3345
|
-
assert.
|
|
3346
|
-
assert.match(
|
|
3347
|
-
assert.match(
|
|
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("
|
|
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
|
-
|
|
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
|
|
3405
|
-
//
|
|
3406
|
-
//
|
|
3407
|
-
|
|
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
|
|
3429
|
-
|
|
3430
|
-
|
|
3431
|
-
|
|
3432
|
-
|
|
3433
|
-
|
|
3434
|
-
|
|
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,
|
|
3443
|
-
// keys are
|
|
3444
|
-
|
|
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
|
|
3600
|
+
let text = "";
|
|
3451
3601
|
fixture.onInput((visited) => {
|
|
3452
|
-
|
|
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.
|
|
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:
|
|
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
|
|
3643
|
+
let acted = false;
|
|
3499
3644
|
fixture.onInput((panel) => {
|
|
3500
|
-
|
|
3501
|
-
|
|
3502
|
-
for (
|
|
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
|
-
|
|
3506
|
-
return fixture.panels[1];
|
|
3655
|
+
return fixture.panels.at(-1) ?? "";
|
|
3507
3656
|
}
|
|
3508
3657
|
|
|
3509
|
-
/** The
|
|
3658
|
+
/** The profile a rendered screen shows. */
|
|
3510
3659
|
function selectedProfileTitle(rendered: string): string {
|
|
3511
|
-
return (rendered.split("\n")[
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|
3580
|
-
const reopened = await
|
|
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,
|
|
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:
|
|
3600
|
-
// clone-scoped pin inside the Git common directory,
|
|
3601
|
-
//
|
|
3602
|
-
|
|
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(
|
|
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, "
|
|
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
|
|
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)), /
|
|
3631
|
-
|
|
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!)), /
|
|
3634
|
-
assert.match(stripAnsi(renderComponent(reopenedPanel)), /
|
|
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("
|
|
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
|
-
|
|
3656
|
-
fixture.
|
|
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, "
|
|
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 ?? "",
|
|
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
|
-
|
|
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("
|
|
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("
|
|
3714
|
-
|
|
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, "
|
|
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
|
|
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
|
-
|
|
3738
|
-
assert.
|
|
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
|
-
|
|
3753
|
-
assert.
|
|
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
|
-
|
|
3798
|
-
fixture.
|
|
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: "
|
|
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, /
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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
|
|
4162
|
-
const { fixture,
|
|
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
|
-
|
|
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
|
-
|
|
4218
|
-
|
|
4219
|
-
|
|
4220
|
-
|
|
4221
|
-
assert.
|
|
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: "
|
|
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
|
+
});
|