gentle-pi 4.4.0 → 4.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +2 -2
  2. package/bin/gentle-shell.mjs +37 -4
  3. package/docs/gentle-music.md +24 -3
  4. package/docs/gentle-shell.md +5 -5
  5. package/docs/readme-reference.md +58 -45
  6. package/extensions/gentle-agents.ts +14 -2
  7. package/extensions/gentle-ai.ts +1672 -1237
  8. package/extensions/gentle-music.ts +73 -8
  9. package/extensions/gentle-shell.ts +88 -8
  10. package/lib/command-palette-catalog.ts +1 -2
  11. package/lib/gentle-shell-bundled.ts +18 -8
  12. package/lib/gentle-shell-launcher.ts +2 -0
  13. package/lib/jobs-sidebar-state.ts +22 -3
  14. package/lib/jobs-widget.ts +112 -0
  15. package/lib/session-profile-freeze.ts +2 -2
  16. package/lib/shell-bar.ts +2 -4
  17. package/lib/shell-sidebar-layout.ts +1 -1
  18. package/lib/vim-editor-adapter.ts +3 -2
  19. package/package.json +1 -1
  20. package/runtime/gentle-shell-bundled.mjs +18 -8
  21. package/runtime/gentle-shell-launcher.mjs +2 -0
  22. package/scripts/bundled-install.mjs +209 -13
  23. package/scripts/installer-downloads.mjs +24 -0
  24. package/scripts/installer-preflight.mjs +1 -1
  25. package/scripts/pi-update.mjs +3 -1
  26. package/tests/bundled-install.test.ts +273 -2
  27. package/tests/command-palette.test.ts +3 -3
  28. package/tests/gentle-agents.test.ts +4 -4
  29. package/tests/gentle-ai.test.ts +810 -609
  30. package/tests/gentle-music.test.ts +117 -0
  31. package/tests/gentle-shell-bundled.test.ts +20 -0
  32. package/tests/gentle-shell-launcher.test.ts +1 -0
  33. package/tests/gentle-shell.test.ts +44 -19
  34. package/tests/installer-downloads.test.ts +32 -0
  35. package/tests/installer-server.test.ts +2 -2
  36. package/tests/jobs-widget.test.ts +138 -0
  37. package/tests/model-profiles-preservation.test.ts +2 -2
  38. package/tests/package-manifest.test.ts +2 -2
  39. package/tests/pi-update.test.ts +5 -0
  40. package/tests/runtime-harness.mjs +188 -211
  41. package/tests/session-profile-freeze.test.ts +6 -4
  42. package/tests/shell-bar.test.ts +4 -4
  43. package/tests/shell-sidebar-layout.test.ts +17 -0
  44. package/tests/vim-editor-adapter.test.ts +2 -2
@@ -33,7 +33,7 @@ const EXPECTED_BANNER_COMMANDS = [
33
33
  const EXPECTED_COMMANDS = [
34
34
  "gentle:install-delegation",
35
35
  "gentle:install-review",
36
- "gentle:models",
36
+ "gentle:profiles",
37
37
  "gentle:persona",
38
38
  "gentle:status",
39
39
  "gentle:doctor",
@@ -43,6 +43,8 @@ const EXPECTED_COMMANDS = [
43
43
 
44
44
  const FORBIDDEN_COMPAT_COMMANDS = [
45
45
  "gentle:install-assets",
46
+ // Retired: /gentle:profiles opens the same models screen.
47
+ "gentle:models",
46
48
  "gentle-ai:models",
47
49
  "gentleman:models",
48
50
  "gentle-ai:persona",
@@ -1301,16 +1303,16 @@ async function run() {
1301
1303
  assert.doesNotMatch(invalidGlobalSkippedAgent, /model: legacy\/provider-model/);
1302
1304
  assert.equal(legacyCtx.ui.notifications.at(-1).level, "warning");
1303
1305
  assert.match(legacyCtx.ui.notifications.at(-1).message, /skipped model config/);
1306
+ // An unreadable models.json does not block the models screen (Make default
1307
+ // discloses it), and closing the screen leaves the file untouched.
1304
1308
  let modelPanelOpened = false;
1305
1309
  legacyCtx.ui.custom = () => {
1306
1310
  modelPanelOpened = true;
1307
- return Promise.resolve({ type: "save", config: {} });
1311
+ return Promise.resolve({ type: "close" });
1308
1312
  };
1309
- await commands.get("gentle:models").handler("", legacyCtx);
1310
- assert.equal(modelPanelOpened, false);
1313
+ await commands.get("gentle:profiles").handler("", legacyCtx);
1314
+ assert.equal(modelPanelOpened, true);
1311
1315
  assert.equal(await readFile(globalModelsPath, "utf8"), "{ invalid json");
1312
- assert.equal(legacyCtx.ui.notifications.at(-1).level, "warning");
1313
- assert.match(legacyCtx.ui.notifications.at(-1).message, /cannot open model config/);
1314
1316
  await writeFile(globalModelsPath, JSON.stringify({}, null, 2));
1315
1317
  await hooks.get("session_start")[0]({ reason: "startup" }, legacyCtx);
1316
1318
  const emptyGlobalPreservesAgent = await readFile(
@@ -1629,61 +1631,50 @@ async function run() {
1629
1631
  { provider: "safe", id: "model" },
1630
1632
  { provider: "evil\u001b]52;c;Zm9v\u0007", id: "model" },
1631
1633
  ];
1632
- ctx.ui.custom = (factory) => {
1633
- const panel = factory({ terminal: { rows: 24 } }, null, null, () => undefined);
1634
- const initialLines = panel.render(120);
1635
- const plainInitialLines = initialLines.map(stripAnsi);
1636
- assert.ok(
1637
- plainInitialLines[0].startsWith("╭") && plainInitialLines.at(-1).startsWith("╰"),
1638
- "model panel should render inside a bordered card",
1639
- );
1640
- assert.ok(
1641
- initialLines.length === 24,
1642
- "model panel should fill the 24-row terminal like /gentle:profiles",
1643
- );
1644
- assert.ok(
1645
- plainInitialLines.some((line) => /↓ \d+ more agent\(s\)/.test(line)),
1646
- "long model agent list should render a down-scroll indicator",
1647
- );
1648
- assert.ok(
1649
- plainInitialLines.some((line) => line.includes("Continue")),
1650
- "long model agent list should keep Continue visible",
1651
- );
1652
- assert.doesNotMatch(
1653
- initialLines.join("\n"),
1654
- /\u001b\]|\u0007/,
1655
- "model panel must strip unsafe terminal control sequences from agent labels",
1656
- );
1657
- assert.doesNotMatch(
1658
- plainInitialLines.join("\n"),
1659
- /\]52|\[31m/,
1660
- "model panel must strip user-provided terminal escapes from labels",
1661
- );
1662
- for (let i = 0; i < 20; i++) panel.handleInput("j");
1663
- const scrolledLines = panel.render(120);
1664
- const plainScrolledLines = scrolledLines.map(stripAnsi);
1665
- assert.ok(
1666
- scrolledLines.length === 24,
1667
- "scrolled model agent list should keep filling the terminal",
1668
- );
1669
- assert.ok(
1670
- plainScrolledLines.some((line) => /↑ \d+ more agent\(s\)/.test(line)),
1671
- "long model agent list should render an up-scroll indicator after navigation",
1672
- );
1673
- panel.handleInput("G");
1674
- const bottomLines = panel.render(120);
1675
- const plainBottomLines = bottomLines.map(stripAnsi);
1676
- assert.ok(
1677
- bottomLines.length === 24,
1678
- "bottom model agent list should keep filling the terminal",
1679
- );
1680
- assert.ok(
1681
- plainBottomLines.some((line) => line.includes("▸ ← Back")),
1682
- "G should jump to the Back action",
1683
- );
1684
- return Promise.resolve({ type: "cancel" });
1685
- };
1686
- await commands.get("gentle:models").handler("", ctx);
1634
+ // /gentle:profiles opens the models screen on the profile this repository
1635
+ // resolves: one table, profiles as tabs. Its render contract: a bordered
1636
+ // card filling the terminal, scroll indicators for long agent lists, and
1637
+ // sanitized labels everywhere, including the model card.
1638
+ let panelVisits = 0;
1639
+ ctx.ui.custom = (factory) =>
1640
+ new Promise((resolve) => {
1641
+ panelVisits += 1;
1642
+ const panel = factory({ terminal: { rows: 24 }, requestRender() {} }, null, null, resolve);
1643
+ const initialLines = panel.render(120);
1644
+ const plainInitialLines = initialLines.map(stripAnsi);
1645
+ assert.ok(
1646
+ plainInitialLines[0].startsWith("╭") && plainInitialLines.at(-1).startsWith("╰"),
1647
+ "the models screen should render inside a bordered card",
1648
+ );
1649
+ assert.equal(initialLines.length, 24, "the models screen should fill the 24-row terminal");
1650
+ assert.match(plainInitialLines[1], /current/, "the tab line names the profile on screen");
1651
+ assert.ok(
1652
+ plainInitialLines.some((line) => /↓ \d+ more/.test(line)),
1653
+ "a long agent list should render a down-scroll indicator",
1654
+ );
1655
+ assert.doesNotMatch(initialLines.join("\n"), /\u001b\]|\u0007/, "labels must not carry unsafe control sequences");
1656
+ assert.doesNotMatch(plainInitialLines.join("\n"), /\]52|\[31m/, "labels must not carry user-provided escapes");
1657
+ for (let i = 0; i < 20; i++) panel.handleInput("j");
1658
+ const plainScrolledLines = panel.render(120).map(stripAnsi);
1659
+ assert.equal(plainScrolledLines.length, 24, "the scrolled list keeps filling the terminal");
1660
+ assert.ok(
1661
+ plainScrolledLines.some((line) => /↑ \d+ more/.test(line)),
1662
+ "navigation down should render an up-scroll indicator",
1663
+ );
1664
+ for (let i = 0; i < 40; i++) panel.handleInput("j");
1665
+ assert.ok(
1666
+ panel.render(120).map(stripAnsi).some((line) => /▸\s+worker\b/.test(line)),
1667
+ "moving past the end lands on the last agent row",
1668
+ );
1669
+ panel.handleInput("\r"); // the model card lists the registry, sanitized
1670
+ const card = panel.render(120);
1671
+ assert.match(card.map(stripAnsi).join("\n"), /Model for worker/);
1672
+ assert.doesNotMatch(card.join("\n"), /\u001b\]|\u0007/, "registry labels are sanitized in the model card");
1673
+ panel.handleInput("\u001b"); // close the card
1674
+ panel.handleInput("\u001b"); // close the screen: nothing changed, one esc is enough
1675
+ });
1676
+ await commands.get("gentle:profiles").handler("", ctx);
1677
+ assert.equal(panelVisits, 1, "closing an unchanged screen ends the command");
1687
1678
 
1688
1679
  await hooks.get("session_start")[0]({ reason: "startup" }, ctx);
1689
1680
  const legacyAppliedAgent = await readFile(
@@ -1693,154 +1684,140 @@ async function run() {
1693
1684
  assert.match(legacyAppliedAgent, /model: openai\/gpt-5/);
1694
1685
  assert.doesNotMatch(legacyAppliedAgent, /thinking:/);
1695
1686
 
1696
- ctx.ui.custom = () =>
1697
- Promise.resolve({
1698
- type: "save",
1699
- config: {
1700
- "sdd-apply": { model: "openai/gpt-5", thinking: "high" },
1701
- worker: { model: "openai/gpt-5-mini", thinking: "low" },
1702
- researcher: { model: "openai/gpt-5-mini", thinking: "low" },
1703
- "project-special": { model: "openai/gpt-5-mini", thinking: "low" },
1704
- "global-special": { model: "openai/gpt-5-mini", thinking: "low" },
1705
- },
1706
- });
1707
- await commands.get("gentle:models").handler("", ctx);
1708
- assert.doesNotMatch(
1709
- ctx.ui.notifications.at(-1).message,
1710
- /[\u001b\u0007]/,
1711
- "model save notification must strip terminal control sequences from discovered agent names",
1712
- );
1713
-
1714
- const savedConfig = JSON.parse(
1715
- await readFile(globalModelsPath, "utf8"),
1716
- );
1717
- assert.deepEqual(savedConfig["sdd-apply"], {
1718
- model: "openai/gpt-5",
1719
- thinking: "high",
1720
- });
1721
- assert.equal(
1722
- existsSync(join(modelsCwd, ".pi", "gentle-ai", "models.json")),
1723
- false,
1724
- "/gentle:models must save model routing globally, not per project",
1725
- );
1726
-
1727
- const applyAgent = await readFile(
1728
- join(modelsCwd, ".pi", "agents", "sdd-apply.md"),
1729
- "utf8",
1730
- );
1731
- assert.match(applyAgent, /model: openai\/gpt-5/);
1732
- assert.match(applyAgent, /thinking: high/);
1733
-
1734
- const projectSubagents = JSON.parse(
1735
- await readFile(join(modelsCwd, ".pi", "subagents.json"), "utf8"),
1736
- );
1737
- assert.equal(
1738
- projectSubagents.model_profiles["sdd-apply"].model,
1739
- "openai/gpt-5",
1740
- );
1741
- assert.equal(projectSubagents.model_profiles["sdd-apply"].effort, "high");
1742
- assert.equal(
1743
- projectSubagents.model_profiles.worker.model,
1744
- "openai/gpt-5-mini",
1745
- );
1746
- assert.equal(projectSubagents.model_profiles.worker.effort, "low");
1747
- const routedProjectSubagentWorker = await readFile(
1748
- join(modelsCwd, ".pi", "subagents", "worker.md"),
1687
+ // Save writes the table into the profile store only (kind
1688
+ // gentle-pi.agent_model_profiles). Global models.json, subagents.json, and
1689
+ // agent frontmatter stay untouched — Make default is the guarded global apply.
1690
+ const projectSubagentsBefore = await readFile(
1691
+ join(modelsCwd, ".pi", "subagents.json"),
1749
1692
  "utf8",
1750
1693
  );
1751
- assert.match(routedProjectSubagentWorker, /model: openai\/gpt-5-mini/);
1752
- assert.match(routedProjectSubagentWorker, /thinking: low/);
1753
- const shadowedProjectAgentWorker = await readFile(
1754
- join(modelsCwd, ".pi", "agents", "worker.md"),
1694
+ const agentFrontmatterBefore = await readFile(
1695
+ join(modelsCwd, ".pi", "agents", "sdd-apply.md"),
1755
1696
  "utf8",
1756
1697
  );
1757
- assert.match(shadowedProjectAgentWorker, /model: existing\/project-worker/);
1758
- assert.match(shadowedProjectAgentWorker, /thinking: high/);
1759
- assert.equal(
1760
- projectSubagents.model_profiles["project-special"].model,
1761
- "openai/gpt-5-mini",
1762
- );
1763
- assert.equal(projectSubagents.model_profiles["project-special"].effort, "low");
1764
- const globalSubagents = JSON.parse(await readFile(globalSubagentsPath, "utf8"));
1698
+ const pickEffort = (panel, steps) => {
1699
+ panel.handleInput("e");
1700
+ for (let i = 0; i < 10; i++) panel.handleInput("\u001b[D"); // back to inherit
1701
+ for (let i = 0; i < steps; i++) panel.handleInput("\u001b[C");
1702
+ panel.handleInput("\r");
1703
+ };
1704
+ let saveVisits = 0;
1705
+ ctx.ui.custom = (factory) =>
1706
+ new Promise((resolve) => {
1707
+ saveVisits += 1;
1708
+ const panel = factory({ terminal: { rows: 24 }, requestRender() {} }, null, null, resolve);
1709
+ if (saveVisits === 1) {
1710
+ panel.handleInput("j"); // the Model ▾ / Effort ▾ header row: every agent
1711
+ panel.handleInput("\r");
1712
+ for (const character of "safe") panel.handleInput(character); // type-to-search
1713
+ panel.handleInput("\r"); // safe/model for every agent
1714
+ pickEffort(panel, 5); // inherit off minimal low medium [high]
1715
+ panel.handleInput("\u0013"); // ctrl+s
1716
+ return;
1717
+ }
1718
+ panel.handleInput("\u001b");
1719
+ });
1720
+ await commands.get("gentle:profiles").handler("", ctx);
1721
+ assert.equal(saveVisits, 2, "Save reopens the screen once before it closes");
1722
+
1723
+ const profilesPath = join(globalConfigHome, "profiles.json");
1724
+ const savedStore = JSON.parse(await readFile(profilesPath, "utf8"));
1725
+ assert.equal(savedStore.kind, "gentle-pi.agent_model_profiles");
1726
+ assert.equal(savedStore.version, 1);
1727
+ assert.deepEqual(savedStore.profiles.current["sdd-apply"], { model: "safe/model", thinking: "high" });
1728
+ assert.deepEqual(savedStore.profiles.current["project-special"], { model: "safe/model", thinking: "high" });
1765
1729
  assert.equal(
1766
- globalSubagents.model_profiles.researcher.model,
1767
- "openai/gpt-5-mini",
1730
+ await readFile(globalModelsPath, "utf8"),
1731
+ JSON.stringify({ "sdd-apply": "openai/gpt-5" }, null, 2),
1732
+ "Save must keep routing edits in the profile store, not write global models.json",
1768
1733
  );
1769
- assert.equal(globalSubagents.model_profiles.researcher.effort, "low");
1770
1734
  assert.equal(
1771
- globalSubagents.model_profiles["global-special"].model,
1772
- "openai/gpt-5-mini",
1735
+ existsSync(join(modelsCwd, ".pi", "gentle-ai", "models.json")),
1736
+ false,
1737
+ "Save must keep routing edits in the profile store, not per project",
1773
1738
  );
1774
- assert.equal(globalSubagents.model_profiles["global-special"].effort, "low");
1739
+ assert.equal(await readFile(join(modelsCwd, ".pi", "subagents.json"), "utf8"), projectSubagentsBefore);
1740
+ const untouchedAgent = await readFile(join(modelsCwd, ".pi", "agents", "sdd-apply.md"), "utf8");
1741
+ assert.equal(untouchedAgent, agentFrontmatterBefore);
1742
+ assert.doesNotMatch(untouchedAgent, /thinking:/);
1775
1743
  assert.equal(existsSync(join(modelsCwd, ".pi", "settings.json")), false);
1776
1744
 
1777
1745
  const kittyE = "\x1b[101u";
1778
1746
  assert.notEqual(kittyE, "e");
1779
1747
  assert.equal(matchesKey(kittyE, "e"), true);
1780
1748
 
1781
- let customPanelCalls = 0;
1749
+ // Custom model ids: Custom model id… in the model card hands the draft back,
1750
+ // ctx.ui.input asks for the id, the screen reopens with it, and Save stores it.
1751
+ const chooseCustom = (panel) => {
1752
+ panel.handleInput("\r");
1753
+ for (const character of "zzzz") panel.handleInput(character); // nothing matches: only Custom model id… is left
1754
+ panel.handleInput("\r");
1755
+ };
1756
+ let customVisits = 0;
1782
1757
  ctx.ui.input = async () => "custom/provider-model";
1783
1758
  ctx.ui.custom = (factory) =>
1784
1759
  new Promise((resolve) => {
1785
- customPanelCalls += 1;
1786
- const panel = factory(null, null, null, resolve);
1787
- if (customPanelCalls === 1) {
1788
- panel.handleInput(kittyE); // effort picker for all agents
1789
- for (let i = 0; i < 4; i++) panel.handleInput("j"); // medium
1760
+ customVisits += 1;
1761
+ const panel = factory({ terminal: { rows: 24 }, requestRender() {} }, null, null, resolve);
1762
+ if (customVisits === 1) {
1763
+ panel.handleInput("j"); // every agent
1764
+ panel.handleInput(kittyE); // the effort row (a kitty "e" still matches)
1765
+ for (let i = 0; i < 10; i++) panel.handleInput("\u001b[D");
1766
+ for (let i = 0; i < 4; i++) panel.handleInput("\u001b[C"); // medium
1790
1767
  panel.handleInput("\r");
1791
- panel.handleInput("c"); // custom model from the same unsaved draft
1768
+ chooseCustom(panel); // custom model for every agent, same unsaved draft
1769
+ return;
1770
+ }
1771
+ if (customVisits === 2) {
1772
+ panel.handleInput("\u0013"); // ctrl+s saves the draft reopened after the custom id
1792
1773
  return;
1793
1774
  }
1794
- panel.handleInput("\u0013"); // ctrl+s saves the draft reopened after custom model input
1775
+ panel.handleInput("\u001b");
1795
1776
  });
1796
- await commands.get("gentle:models").handler("", ctx);
1797
-
1798
- const customSavedConfig = JSON.parse(
1799
- await readFile(globalModelsPath, "utf8"),
1800
- );
1801
- assert.deepEqual(customSavedConfig["sdd-apply"], {
1777
+ await commands.get("gentle:profiles").handler("", ctx);
1778
+ assert.equal(customVisits, 3);
1779
+ const customSavedStore = JSON.parse(await readFile(profilesPath, "utf8"));
1780
+ assert.deepEqual(customSavedStore.profiles.current["sdd-apply"], {
1802
1781
  model: "custom/provider-model",
1803
1782
  thinking: "medium",
1804
1783
  });
1805
1784
 
1806
- let invalidCustomCalls = 0;
1785
+ // An invalid custom id is refused on the screen and changes nothing.
1786
+ let invalidCustomVisits = 0;
1807
1787
  ctx.ui.input = async () => "bad\nmodel: injected";
1808
1788
  ctx.ui.custom = (factory) =>
1809
1789
  new Promise((resolve) => {
1810
- invalidCustomCalls += 1;
1811
- const panel = factory(null, null, null, resolve);
1812
- if (invalidCustomCalls === 1) {
1813
- panel.handleInput("c");
1790
+ invalidCustomVisits += 1;
1791
+ const panel = factory({ terminal: { rows: 24 }, requestRender() {} }, null, null, resolve);
1792
+ if (invalidCustomVisits === 1) {
1793
+ panel.handleInput("j");
1794
+ chooseCustom(panel);
1814
1795
  return;
1815
1796
  }
1816
- panel.handleInput("\u001b");
1797
+ if (invalidCustomVisits === 2) {
1798
+ assert.match(panel.render(120).map(stripAnsi).join(" "), /Custom model id must be one provider\/model line/);
1799
+ }
1800
+ panel.handleInput("\u001b"); // nothing changed: one esc closes
1817
1801
  });
1818
- await commands.get("gentle:models").handler("", ctx);
1819
- assert.match(
1820
- ctx.ui.notifications.at(-1).message,
1821
- /Custom model id must be a single-line/,
1822
- );
1823
- const rejectedCustomConfig = JSON.parse(
1824
- await readFile(globalModelsPath, "utf8"),
1825
- );
1826
- assert.deepEqual(rejectedCustomConfig["sdd-apply"], {
1802
+ await commands.get("gentle:profiles").handler("", ctx);
1803
+ assert.equal(invalidCustomVisits, 2);
1804
+ const rejectedCustomStore = JSON.parse(await readFile(profilesPath, "utf8"));
1805
+ assert.deepEqual(rejectedCustomStore.profiles.current["sdd-apply"], {
1827
1806
  model: "custom/provider-model",
1828
1807
  thinking: "medium",
1829
1808
  });
1830
1809
 
1831
- let exportPanelCalls = 0;
1810
+ // models.json export and restore are subcommands; neither opens the screen.
1811
+ let screenOpened = false;
1832
1812
  ctx.ui.custom = () => {
1833
- exportPanelCalls += 1;
1834
- return Promise.resolve(exportPanelCalls === 1 ? { type: "export", config: {} } : { type: "cancel" });
1813
+ screenOpened = true;
1814
+ return Promise.resolve({ type: "close" });
1835
1815
  };
1836
- await commands.get("gentle:models").handler("", ctx);
1816
+ await commands.get("gentle:profiles").handler("export-routing", ctx);
1837
1817
  const exported = JSON.parse(await readFile(join(globalConfigHome, "models.export.json"), "utf8"));
1838
1818
  assert.equal(exported.kind, "gentle-pi.agent_model_routing");
1839
1819
  assert.equal(exported.version, 1);
1840
- assert.deepEqual(exported.agents["sdd-apply"], {
1841
- model: "custom/provider-model",
1842
- thinking: "medium",
1843
- });
1820
+ assert.deepEqual(exported.agents["sdd-apply"], { model: "openai/gpt-5" }, "export reads the saved global routing");
1844
1821
 
1845
1822
  await writeFile(
1846
1823
  join(globalConfigHome, "models.export.json"),
@@ -1850,59 +1827,47 @@ async function run() {
1850
1827
  agents: { "sdd-apply": { model: "restore/provider", thinking: "high" } },
1851
1828
  }, null, 2),
1852
1829
  );
1853
- let restorePanelCalls = 0;
1854
1830
  ctx.ui.confirm = async () => true;
1855
- ctx.ui.custom = () => {
1856
- restorePanelCalls += 1;
1857
- return Promise.resolve(restorePanelCalls === 1 ? { type: "restore", config: {} } : { type: "cancel" });
1858
- };
1859
- await commands.get("gentle:models").handler("", ctx);
1831
+ await commands.get("gentle:profiles").handler("restore-routing", ctx);
1832
+ assert.equal(screenOpened, false, "export and restore never open the screen");
1860
1833
  const restoredConfig = JSON.parse(await readFile(globalModelsPath, "utf8"));
1861
- assert.deepEqual(restoredConfig["sdd-apply"], {
1862
- model: "restore/provider",
1863
- thinking: "high",
1864
- });
1834
+ assert.deepEqual(restoredConfig["sdd-apply"], { model: "restore/provider", thinking: "high" });
1865
1835
  const restoredAgent = await readFile(join(modelsCwd, ".pi", "agents", "sdd-apply.md"), "utf8");
1866
1836
  assert.match(restoredAgent, /model: restore\/provider/);
1867
1837
  assert.match(restoredAgent, /thinking: high/);
1838
+ // Restore is global-only: the profile store keeps its own routing.
1839
+ const afterRestoreStore = JSON.parse(await readFile(profilesPath, "utf8"));
1840
+ assert.equal(afterRestoreStore.profiles.current["sdd-apply"].model, "custom/provider-model");
1868
1841
 
1869
- // issue #286: `thinking: "max"` must survive save normalization and
1870
- // reach both subagents.json (effort) and agent frontmatter (thinking).
1871
- ctx.ui.custom = () =>
1872
- Promise.resolve({
1873
- type: "save",
1874
- config: { "sdd-apply": { model: "openai/gpt-5", thinking: "max" } },
1875
- });
1876
- await commands.get("gentle:models").handler("", ctx);
1877
- const maxSavedConfig = JSON.parse(await readFile(globalModelsPath, "utf8"));
1878
- assert.equal(maxSavedConfig["sdd-apply"].thinking, "max");
1879
- const maxSubagents = JSON.parse(
1880
- await readFile(join(modelsCwd, ".pi", "subagents.json"), "utf8"),
1881
- );
1882
- assert.equal(maxSubagents.model_profiles["sdd-apply"].effort, "max");
1883
- const maxApplyAgent = await readFile(
1884
- join(modelsCwd, ".pi", "agents", "sdd-apply.md"),
1885
- "utf8",
1886
- );
1887
- assert.match(maxApplyAgent, /thinking: max/);
1888
-
1889
- // issue #286: effort picker must offer `max` after `xhigh` and save it.
1890
- let maxPickerCalls = 0;
1842
+ // issue #286: `thinking: "max"` must survive save normalization into the
1843
+ // profile store, and the effort row must offer `max` after `xhigh`.
1844
+ let maxVisits = 0;
1891
1845
  ctx.ui.custom = (factory) =>
1892
1846
  new Promise((resolve) => {
1893
- maxPickerCalls += 1;
1894
- const panel = factory(null, null, null, resolve);
1895
- if (maxPickerCalls === 1) {
1896
- panel.handleInput(kittyE); // open effort picker (set-all row)
1897
- for (let i = 0; i < 7; i++) panel.handleInput("j"); // max
1847
+ maxVisits += 1;
1848
+ const panel = factory({ terminal: { rows: 24 }, requestRender() {} }, null, null, resolve);
1849
+ if (maxVisits === 1) {
1850
+ panel.handleInput("j"); // every agent
1851
+ panel.handleInput("e");
1852
+ const strip = panel.render(120).map(stripAnsi).join("\n");
1853
+ const xhighIndex = strip.indexOf("xhigh");
1854
+ assert.ok(xhighIndex >= 0 && strip.indexOf("max", xhighIndex + 5) > xhighIndex, "the effort row must offer max after xhigh");
1855
+ for (let i = 0; i < 10; i++) panel.handleInput("\u001b[C"); // to the last level: max
1898
1856
  panel.handleInput("\r");
1899
- panel.handleInput("\u0013"); // ctrl+s saves the draft
1857
+ panel.handleInput("\u0013");
1900
1858
  return;
1901
1859
  }
1860
+ panel.handleInput("\u001b");
1902
1861
  });
1903
- await commands.get("gentle:models").handler("", ctx);
1904
- const pickerMaxConfig = JSON.parse(await readFile(globalModelsPath, "utf8"));
1905
- assert.equal(pickerMaxConfig["sdd-apply"].thinking, "max");
1862
+ await commands.get("gentle:profiles").handler("", ctx);
1863
+ assert.equal(maxVisits, 2);
1864
+ const maxStore = JSON.parse(await readFile(profilesPath, "utf8"));
1865
+ assert.equal(maxStore.profiles.current["sdd-apply"].thinking, "max");
1866
+ assert.equal(
1867
+ maxStore.profiles.current["sdd-apply"].model,
1868
+ "custom/provider-model",
1869
+ "an effort-only save keeps the profile's saved model",
1870
+ );
1906
1871
  } finally {
1907
1872
  await rm(modelsCwd, { recursive: true, force: true });
1908
1873
  await rm(globalModelsPath, { force: true });
@@ -1919,7 +1884,19 @@ async function run() {
1919
1884
  }
1920
1885
  }
1921
1886
 
1922
- run().catch((error) => {
1887
+ // A run that awaits a promise nobody settles (a mocked panel that never
1888
+ // finishes) lets the event loop drain, and Node would exit 0 with every later
1889
+ // section skipped. Treat an unfinished run as a failure instead.
1890
+ let runCompleted = false;
1891
+ process.on("exit", () => {
1892
+ if (runCompleted || process.exitCode) return;
1893
+ console.error("runtime harness exited before run() completed: a section awaited a promise that never settled");
1894
+ process.exitCode = 1;
1895
+ });
1896
+
1897
+ run().then(() => {
1898
+ runCompleted = true;
1899
+ }).catch((error) => {
1923
1900
  console.error(error);
1924
1901
  process.exitCode = 1;
1925
1902
  }).finally(async () => {
@@ -282,14 +282,14 @@ test("drift notice: shown once per distinct change of the defaults", () => {
282
282
  f.pinLocal("local");
283
283
  assert.equal(
284
284
  inheritedProfileDriftNotice(opts),
285
- 'el Gentleman: the default profile changed to "local" (local), this session keeps "frontier" (local). Press Enter on a profile in /gentle:profiles to adopt it.',
285
+ 'el Gentleman: the default profile changed to "local" (local), this session keeps "frontier" (local). Open /gentle:profiles and choose Use in this session to adopt one.',
286
286
  );
287
287
  assert.equal(inheritedProfileDriftNotice(opts), undefined, "the same change is not repeated");
288
288
  f.pinLocal(undefined);
289
289
  f.setActive("team");
290
290
  assert.equal(
291
291
  inheritedProfileDriftNotice(opts),
292
- 'el Gentleman: the default profile changed to "team" (global), this session keeps "frontier" (local). Press Enter on a profile in /gentle:profiles to adopt it.',
292
+ 'el Gentleman: the default profile changed to "team" (global), this session keeps "frontier" (local). Open /gentle:profiles and choose Use in this session to adopt one.',
293
293
  );
294
294
  assert.equal(inheritedProfileDriftNotice(opts), undefined);
295
295
  assert.equal(readFrozenInheritedProfile("session-a")?.profile?.name, "frontier", "the notice never adopts the new default");
@@ -305,6 +305,8 @@ test("drift notice: an explicit binding learns its profile definition changed in
305
305
  const notice = inheritedProfileDriftNotice(opts);
306
306
  assert.match(notice ?? "", /the profile definition changed to "team"/);
307
307
  assert.match(notice ?? "", /this session keeps the binding from selection/);
308
+ assert.match(notice ?? "", /Open \/gentle:profiles and choose Use in this session to adopt one\.$/, "the notice names the current adoption action");
309
+ assert.doesNotMatch(notice ?? "", /Press Enter/, "the retired Enter instruction never comes back");
308
310
  assert.equal(inheritedProfileDriftNotice(opts), undefined, "the same change is not repeated");
309
311
 
310
312
  f.editProfile("team", { worker: { model: "openai/older" } });
@@ -344,14 +346,14 @@ test("drift notice covers a session frozen without a profile and a default that
344
346
  f.pinLocal("frontier");
345
347
  assert.equal(
346
348
  inheritedProfileDriftNotice(none),
347
- 'el Gentleman: the default profile changed to "frontier" (local), this session keeps no profile. Press Enter on a profile in /gentle:profiles to adopt it.',
349
+ 'el Gentleman: the default profile changed to "frontier" (local), this session keeps no profile. Open /gentle:profiles and choose Use in this session to adopt one.',
348
350
  );
349
351
  const kept = options(f, "session-kept");
350
352
  freezeSessionProfileAtStartup(kept);
351
353
  f.pinLocal(undefined);
352
354
  assert.equal(
353
355
  inheritedProfileDriftNotice(kept),
354
- 'el Gentleman: the default profile changed to no profile, this session keeps "frontier" (local). Press Enter on a profile in /gentle:profiles to adopt it.',
356
+ 'el Gentleman: the default profile changed to no profile, this session keeps "frontier" (local). Open /gentle:profiles and choose Use in this session to adopt one.',
355
357
  );
356
358
  });
357
359
 
@@ -107,7 +107,7 @@ test("visual visibility hides only selected optional status segments", () => {
107
107
  assert.doesNotMatch(renderShellHeaderBar(buildShellHeaderModel(data), plainTheme, 160, undefined, settings).text, /gpt-5\.5|ctx|\$9\.49|usage/);
108
108
  });
109
109
 
110
- test("Status groups running job descriptions beside RDD without a compact count", () => {
110
+ test("Status keeps RDD beside structured groups while running jobs moved to their own card", () => {
111
111
  const data = model({ jobs: [
112
112
  { id: "job-2", label: "CI for PR 1860", startedAt: 2 },
113
113
  { id: "job-1", label: "Build runtime", startedAt: 1 },
@@ -115,12 +115,12 @@ test("Status groups running job descriptions beside RDD without a compact count"
115
115
  for (const width of [30, 60]) {
116
116
  const lines = renderShellSidebarBar(data, plainTheme, width);
117
117
  const text = lines.join("\n");
118
- assert.match(text, /Jobs[\s\S]*CI for PR 1860[\s\S]*Build runtime/);
118
+ // Running jobs live in their own sidebar card; the Status card never
119
+ // prints them, so the two surfaces cannot duplicate one job.
120
+ assert.doesNotMatch(text, /Jobs|CI for PR 1860|Build runtime/);
119
121
  assert.match(text, /🌹 RDD/);
120
- assert.doesNotMatch(text, /2 jobs/);
121
122
  assert.ok(lines.every((line) => visibleWidth(line) <= width));
122
123
  }
123
- assert.doesNotMatch(renderShellSidebarBar(model({ jobs: [] }), plainTheme, 60).join("\n"), /Jobs/);
124
124
  });
125
125
 
126
126
  test("header shows only the running job count when requested and preserves it at mobile widths", () => {
@@ -589,6 +589,23 @@ test("a rail digest refreshes live state that no invalidation announces", (t) =>
589
589
  assert.equal(todoRenders, 2, "explicit invalidation still reaches a rail without a digest");
590
590
  });
591
591
 
592
+ test("the jobs rail section paints between Status and Agents", (t) => {
593
+ const f = fixture();
594
+ sidebarPart(f.tui, "jobs", { render: () => ["jobs"], invalidate() {} }, { digest: () => "jobs", render: () => ["Jobs card"], invalidate() {} });
595
+ sidebarPart(f.tui, "agents", { render: () => ["agents"], invalidate() {} }, { digest: () => "agents", render: () => ["Agents card"], invalidate() {} });
596
+ sidebarPart(f.tui, "todo", { render: () => ["todo"], invalidate() {} }, { digest: () => "todo", render: () => ["Todo card"], invalidate() {} });
597
+ t.after(installSidebar(f.tui, theme));
598
+ const text = renderLayoutFrame(f.root, 140, 20, () => {}).lines.join("\n");
599
+ const footer = text.indexOf("Status");
600
+ const jobs = text.indexOf("Jobs card");
601
+ const agents = text.indexOf("Agents card");
602
+ const todo = text.indexOf("Todo card");
603
+ for (const [name, index] of [["Status", footer], ["Jobs card", jobs], ["Agents card", agents], ["Todo card", todo]] as const) {
604
+ assert.ok(index >= 0, `${name} paints in the rail`);
605
+ }
606
+ assert.ok(footer < jobs && jobs < agents && agents < todo, `sections paint in order, got: ${text}`);
607
+ });
608
+
592
609
  test("a theme switch repaints rail sections whose digest did not change", (t) => {
593
610
  const f = fixture();
594
611
  // Pi hands extensions a live theme proxy and announces a switch only through
@@ -277,12 +277,12 @@ test("resolveVimRuntime resolves local bundle cli entrypoint when provided", ()
277
277
  });
278
278
 
279
279
  test("audited Pi editor releases are one frozen exact allowlist that includes the installed TUI", () => {
280
- assert.deepEqual([...AUDITED_PI_EDITOR_VERSIONS], ["0.99.1", "0.99.2", "1.0.0", "1.1.0", "1.2.0"]);
280
+ assert.deepEqual([...AUDITED_PI_EDITOR_VERSIONS], ["0.99.1", "0.99.2", "1.0.0", "1.1.0", "1.2.0", "1.3.0", "1.4.0"]);
281
281
  assert.equal(bundledAgent.VERSION, INSTALLED_PI, "bundled and local metadata must describe the same actual installed release");
282
282
  assert.ok(Object.isFrozen(AUDITED_PI_EDITOR_VERSIONS));
283
283
  assert.throws(() => (AUDITED_PI_EDITOR_VERSIONS as unknown as string[]).push("0.99.3"), TypeError);
284
284
  assert.ok(isAuditedPiEditorVersion(INSTALLED_PI), `installed pi-tui ${INSTALLED_PI} must be audited`);
285
- for (const unknown of ["0.99.3", "0.99.10", "0.99", "v0.99.2", " 0.99.2", "0.99.2-rc.1", ">=0.99.2", "1.0.1", ">=1.0.0", "v1.0.0", " 1.0.0", "1.0.0-rc.1", "1.2.1", "1.2.0-rc.1", "v1.2.0", ">=1.2.0", undefined, 99.2]) {
285
+ for (const unknown of ["0.99.3", "0.99.10", "0.99", "v0.99.2", " 0.99.2", "0.99.2-rc.1", ">=0.99.2", "1.0.1", ">=1.0.0", "v1.0.0", " 1.0.0", "1.0.0-rc.1", "1.2.1", "1.2.0-rc.1", "v1.2.0", ">=1.2.0", "1.4.1", "1.5.0", "1.4.0-rc.1", undefined, 99.2]) {
286
286
  assert.equal(isAuditedPiEditorVersion(unknown), false, String(unknown));
287
287
  }
288
288
  });