@xingular/harness 1.1.1 → 1.2.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 (223) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +28 -25
  3. package/dist/cli.js +6675 -2501
  4. package/dist/sdk/accounts/accounts.d.ts +27 -3
  5. package/dist/sdk/accounts/accounts.js +78 -24
  6. package/dist/sdk/accounts/secrets.d.ts +12 -0
  7. package/dist/sdk/accounts/secrets.js +114 -7
  8. package/dist/sdk/accounts/store.d.ts +26 -1
  9. package/dist/sdk/accounts/store.js +1 -1
  10. package/dist/sdk/agents/builtin.js +15 -1
  11. package/dist/sdk/agents/planResearch.d.ts +5 -0
  12. package/dist/sdk/agents/planResearch.js +19 -0
  13. package/dist/sdk/agents/profiles.d.ts +2 -1
  14. package/dist/sdk/agents/profiles.js +9 -4
  15. package/dist/sdk/annotations/pageScript.js +349 -48
  16. package/dist/sdk/annotations/part.js +34 -23
  17. package/dist/sdk/annotations/store.d.ts +7 -0
  18. package/dist/sdk/annotations/store.js +13 -5
  19. package/dist/sdk/browser/backend.d.ts +16 -0
  20. package/dist/sdk/browser/backend.js +17 -0
  21. package/dist/sdk/browser/contracts.d.ts +19 -0
  22. package/dist/sdk/browser/contracts.js +116 -0
  23. package/dist/sdk/browser/tools.d.ts +1 -4
  24. package/dist/sdk/browser/tools.js +14 -91
  25. package/dist/sdk/cli.js +5 -4
  26. package/dist/sdk/commands/list.d.ts +3 -0
  27. package/dist/sdk/commands/list.js +75 -0
  28. package/dist/sdk/commands/registry.d.ts +250 -0
  29. package/dist/sdk/commands/registry.js +47 -0
  30. package/dist/sdk/commands/routes.d.ts +16 -0
  31. package/dist/sdk/commands/routes.js +33 -0
  32. package/dist/sdk/core/commandBudget.d.ts +23 -0
  33. package/dist/sdk/core/commandBudget.js +57 -0
  34. package/dist/sdk/core/index.js +17 -1
  35. package/dist/sdk/core/interactiveProfile.d.ts +46 -0
  36. package/dist/sdk/core/interactiveProfile.js +143 -0
  37. package/dist/sdk/core/workspace.d.ts +13 -0
  38. package/dist/sdk/core/workspace.js +88 -0
  39. package/dist/sdk/core/workspaceBashTool.d.ts +4 -0
  40. package/dist/sdk/core/workspaceBashTool.js +36 -0
  41. package/dist/sdk/core/workspaceSandbox.d.ts +31 -0
  42. package/dist/sdk/core/workspaceSandbox.js +320 -0
  43. package/dist/sdk/core/workspaceWriteOperations.d.ts +22 -0
  44. package/dist/sdk/core/workspaceWriteOperations.js +206 -0
  45. package/dist/sdk/doctor/types.d.ts +2 -2
  46. package/dist/sdk/harness.d.ts +7 -0
  47. package/dist/sdk/harness.js +42 -2
  48. package/dist/sdk/index.d.ts +6 -1
  49. package/dist/sdk/index.js +3 -0
  50. package/dist/sdk/pi/runtime.d.ts +2 -1
  51. package/dist/sdk/pi/runtime.js +2 -2
  52. package/dist/sdk/plugins/builtin.js +29 -12
  53. package/dist/sdk/plugins/builtinRegistry.d.ts +10 -3
  54. package/dist/sdk/plugins/builtinRegistry.js +30 -8
  55. package/dist/sdk/plugins/catalogue.js +2 -1
  56. package/dist/sdk/plugins/commandPrefix.d.ts +8 -0
  57. package/dist/sdk/plugins/commandPrefix.js +21 -0
  58. package/dist/sdk/plugins/firstParty.d.ts +1 -1
  59. package/dist/sdk/plugins/firstParty.js +8 -2
  60. package/dist/sdk/plugins/inventory.js +4 -3
  61. package/dist/sdk/plugins/manifest.d.ts +2 -0
  62. package/dist/sdk/plugins/manifest.js +2 -1
  63. package/dist/sdk/plugins/surfaces.d.ts +5 -0
  64. package/dist/sdk/plugins/surfaces.js +11 -0
  65. package/dist/sdk/plugins/types.d.ts +15 -0
  66. package/dist/sdk/prompt/compile.d.ts +14 -0
  67. package/dist/sdk/prompt/compile.js +65 -29
  68. package/dist/sdk/prompt/standard.d.ts +17 -0
  69. package/dist/sdk/prompt/standard.js +21 -0
  70. package/dist/sdk/server/methods.js +18 -0
  71. package/dist/sdk/server/remote.js +36 -11
  72. package/dist/sdk/sessions/compactionCheckpoint.d.ts +11 -0
  73. package/dist/sdk/sessions/compactionCheckpoint.js +79 -0
  74. package/dist/sdk/sessions/continuation.d.ts +3 -0
  75. package/dist/sdk/sessions/continuation.js +5 -1
  76. package/dist/sdk/sessions/contracts.d.ts +168 -0
  77. package/dist/sdk/sessions/contracts.js +59 -1
  78. package/dist/sdk/sessions/control.d.ts +72 -0
  79. package/dist/sdk/sessions/control.js +158 -0
  80. package/dist/sdk/sessions/initialBash.d.ts +7 -0
  81. package/dist/sdk/sessions/initialBash.js +7 -0
  82. package/dist/sdk/sessions/planMode.d.ts +14 -3
  83. package/dist/sdk/sessions/planMode.js +73 -16
  84. package/dist/sdk/sessions/registry.d.ts +1 -1
  85. package/dist/sdk/sessions/registry.js +2 -2
  86. package/dist/sdk/sessions/runStore.d.ts +14 -0
  87. package/dist/sdk/sessions/runStore.js +35 -0
  88. package/dist/sdk/sessions/runner.d.ts +40 -2
  89. package/dist/sdk/sessions/runner.js +161 -13
  90. package/dist/sdk/sessions/sessions.d.ts +23 -3
  91. package/dist/sdk/sessions/sessions.js +202 -17
  92. package/dist/sdk/sessions/subagentService.d.ts +9 -0
  93. package/dist/sdk/sessions/subagentService.js +63 -4
  94. package/dist/sdk/sessions/workSlice.d.ts +103 -0
  95. package/dist/sdk/sessions/workSlice.js +297 -0
  96. package/dist/sdk/sessions/workspaceBoundary.d.ts +15 -0
  97. package/dist/sdk/sessions/workspaceBoundary.js +46 -0
  98. package/dist/sdk/terminal/browserControl.js +2 -1
  99. package/dist/sdk/terminal/controller.d.ts +11 -3
  100. package/dist/sdk/terminal/controller.js +40 -13
  101. package/dist/sdk/terminal/opentui/theme.d.ts +43 -0
  102. package/dist/sdk/terminal/opentui/theme.js +87 -0
  103. package/dist/sdk/terminal/piExtension.d.ts +6 -1
  104. package/dist/sdk/terminal/piExtension.js +154 -54
  105. package/dist/sdk/terminal/standalone.d.ts +7 -0
  106. package/dist/sdk/terminal/standalone.js +72 -13
  107. package/dist/sdk/terminal/subagentMonitor.d.ts +11 -2
  108. package/dist/sdk/terminal/subagentMonitor.js +32 -1
  109. package/dist/sdk/terminal/terminals.d.ts +19 -2
  110. package/dist/sdk/terminal/terminals.js +245 -35
  111. package/dist/sdk/terminal/tui/annotateCommand.d.ts +1 -1
  112. package/dist/sdk/terminal/tui/annotateCommand.js +3 -3
  113. package/dist/sdk/terminal/tui/commandPages.d.ts +3 -1
  114. package/dist/sdk/terminal/tui/commandPages.js +3 -0
  115. package/dist/sdk/terminal/tui/commands.d.ts +2 -0
  116. package/dist/sdk/terminal/tui/commands.js +205 -124
  117. package/dist/sdk/terminal/tui/list.d.ts +14 -1
  118. package/dist/sdk/terminal/tui/list.js +96 -14
  119. package/dist/sdk/terminal/tui/pageOverlay.js +13 -4
  120. package/dist/sdk/terminal/tui/pages/annotatePage.d.ts +1 -1
  121. package/dist/sdk/terminal/tui/pages/notesPage.d.ts +5 -5
  122. package/dist/sdk/terminal/tui/pages/notesPage.js +18 -18
  123. package/dist/sdk/terminal/tui/pages/pluginsPage.d.ts +0 -5
  124. package/dist/sdk/terminal/tui/pages/pluginsPage.js +9 -3
  125. package/dist/sdk/terminal/tui/pages/subagentsPage.d.ts +1 -0
  126. package/dist/sdk/terminal/tui/pages/subagentsPage.js +98 -16
  127. package/dist/sdk/terminal/tui/pages/usagePage.d.ts +43 -0
  128. package/dist/sdk/terminal/tui/pages/usagePage.js +370 -0
  129. package/dist/sdk/terminal/tui/piAdapter.d.ts +1 -2
  130. package/dist/sdk/terminal/tui/piAdapter.js +3 -5
  131. package/dist/sdk/terminal/tui/pluginCommands.d.ts +2 -0
  132. package/dist/sdk/terminal/tui/pluginCommands.js +13 -9
  133. package/dist/sdk/terminal/tui/runtime.js +23 -3
  134. package/dist/sdk/terminal/tui/sessionPicker.d.ts +40 -0
  135. package/dist/sdk/terminal/tui/sessionPicker.js +141 -0
  136. package/dist/sdk/tools/checklist.js +1 -0
  137. package/dist/sdk/tools/checkpoint.d.ts +27 -0
  138. package/dist/sdk/tools/checkpoint.js +154 -0
  139. package/dist/sdk/tools/getCapabilities.d.ts +31 -0
  140. package/dist/sdk/tools/getCapabilities.js +34 -0
  141. package/dist/sdk/tools/spawnSubagent.js +4 -3
  142. package/dist/sdk/usage/analytics.d.ts +20 -0
  143. package/dist/sdk/usage/analytics.js +151 -0
  144. package/dist/sdk/usage/ledger.d.ts +38 -0
  145. package/dist/sdk/usage/ledger.js +142 -0
  146. package/dist/sdk/usage/limits/adapters.d.ts +11 -0
  147. package/dist/sdk/usage/limits/adapters.js +349 -0
  148. package/dist/sdk/usage/limits/capture.d.ts +8 -0
  149. package/dist/sdk/usage/limits/capture.js +47 -0
  150. package/dist/sdk/usage/limits/http.d.ts +12 -0
  151. package/dist/sdk/usage/limits/http.js +51 -0
  152. package/dist/sdk/usage/limits/normalize.d.ts +15 -0
  153. package/dist/sdk/usage/limits/normalize.js +211 -0
  154. package/dist/sdk/usage/limits/service.d.ts +30 -0
  155. package/dist/sdk/usage/limits/service.js +176 -0
  156. package/dist/sdk/usage/limits/types.d.ts +65 -0
  157. package/dist/sdk/usage/limits/types.js +1 -0
  158. package/dist/sdk/usage/serverMethods.d.ts +3 -0
  159. package/dist/sdk/usage/serverMethods.js +22 -0
  160. package/dist/sdk/usage/service.d.ts +32 -0
  161. package/dist/sdk/usage/service.js +66 -0
  162. package/dist/sdk/usage/settings.d.ts +7 -0
  163. package/dist/sdk/usage/settings.js +28 -0
  164. package/dist/sdk/usage/types.d.ts +60 -0
  165. package/dist/sdk/usage/types.js +4 -0
  166. package/dist/terminal-opentui.js +5207 -1883
  167. package/dist/terminal-pi.js +1658 -271
  168. package/docs/architecture/02-sessions-and-runs.md +128 -26
  169. package/docs/architecture/04-prompt.md +50 -12
  170. package/docs/architecture/06-plugins-and-capabilities.md +24 -7
  171. package/docs/architecture/07-tools-and-gate.md +104 -24
  172. package/docs/architecture/08-memory-and-history.md +4 -2
  173. package/docs/architecture/09-accounts-and-connectors.md +31 -1
  174. package/docs/architecture/10-server-protocol.md +58 -3
  175. package/docs/architecture/11-terminal.md +105 -42
  176. package/docs/architecture/12-storage.md +30 -2
  177. package/docs/architecture/13-world-tools.md +22 -1
  178. package/docs/architecture/14-surfaces-and-parity.md +54 -7
  179. package/docs/architecture/README.md +3 -1
  180. package/docs/architecture/proposals/PROP-0017-page-annotations.md +6 -2
  181. package/docs/architecture/proposals/PROP-0018-annotation-queue-and-app-targets.md +1 -1
  182. package/docs/architecture/proposals/PROP-0031-plugin-command-owner-namespaces.md +54 -0
  183. package/docs/architecture/proposals/PROP-0032-planning-quality.md +196 -0
  184. package/docs/architecture/proposals/PROP-0033-compaction-plan-checkpoints.md +75 -0
  185. package/docs/architecture/proposals/PROP-0034-conversation-fast-mode.md +120 -0
  186. package/docs/architecture/proposals/PROP-0035-retain-subagent-status.md +47 -0
  187. package/docs/architecture/proposals/PROP-0036-first-class-harness-commands.md +59 -0
  188. package/docs/architecture/proposals/PROP-0037-workspace-write-boundary.md +63 -0
  189. package/docs/architecture/proposals/PROP-0038-annotate-open-shadow-roots.md +73 -0
  190. package/docs/architecture/proposals/PROP-0039-workspace-boundary-hardening.md +139 -0
  191. package/docs/architecture/proposals/PROP-0040-one-launch-path.md +87 -0
  192. package/docs/architecture/proposals/PROP-0041-bounded-work-slices.md +247 -0
  193. package/docs/architecture/proposals/PROP-0042-usage-and-provider-limits.md +127 -0
  194. package/docs/architecture/proposals/PROP-0043-read-only-plan-bash.md +74 -0
  195. package/docs/architecture/proposals/README.md +14 -0
  196. package/docs/decisions/ADR-0001-owned-terminal.md +1 -1
  197. package/docs/decisions/ADR-0021-required-command-namespaces.md +1 -1
  198. package/docs/decisions/ADR-0031-plugin-command-owner-namespaces.md +37 -0
  199. package/docs/decisions/ADR-0032-compaction-run-checkpoints.md +33 -0
  200. package/docs/decisions/ADR-0033-plan-mode-research-delegation.md +47 -0
  201. package/docs/decisions/ADR-0034-first-class-harness-terminal-commands.md +25 -0
  202. package/docs/decisions/ADR-0035-one-launch-path.md +40 -0
  203. package/docs/decisions/ADR-0036-workspace-write-operation-boundary.md +54 -0
  204. package/docs/decisions/ADR-0037-usage-ledger-and-provider-limits.md +44 -0
  205. package/docs/decisions/ADR-0038-plan-bash-os-read-only.md +29 -0
  206. package/docs/experiments/EXP-0003-performance-audit/data/session-runtime-replication.json +420 -0
  207. package/docs/experiments/EXP-0003-performance-audit/data/session-runtime.json +420 -0
  208. package/docs/experiments/EXP-0003-performance-audit/data/terminal-compact.json +376 -0
  209. package/docs/experiments/EXP-0003-performance-audit/data/terminal-history.json +422 -0
  210. package/docs/experiments/EXP-0003-performance-audit/data/terminal-question.json +95 -0
  211. package/docs/experiments/EXP-0003-performance-audit/data/terminal-wide.json +1036 -0
  212. package/docs/experiments/EXP-0003-performance-audit/plan.md +109 -0
  213. package/docs/experiments/EXP-0003-performance-audit/results.md +151 -0
  214. package/docs/handoff-2026-10-25-app-parity-and-solo-tabs.md +202 -0
  215. package/docs/parity.md +80 -24
  216. package/docs/releases/1.1.1.md +28 -0
  217. package/docs/releases/1.2.0.md +49 -0
  218. package/docs/releases/4.0.0.md +10 -9
  219. package/docs/releases/README.md +35 -30
  220. package/docs/study/00-index.md +3 -0
  221. package/docs/study/STUDY-0003-long-horizon-runs.md +210 -0
  222. package/docs/terminal-tui.md +100 -59
  223. package/package.json +4 -3
@@ -15,6 +15,7 @@ export interface TerminalCommandHost {
15
15
  control: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>;
16
16
  ui: ExtensionUIContext;
17
17
  cwd(): string;
18
+ branch(): string | null;
18
19
  sessionDirectory(): string;
19
20
  refresh(): void;
20
21
  changed(): void;
@@ -43,4 +44,5 @@ export declare function createTerminalCommands(host: TerminalCommandHost): {
43
44
  completions: () => TerminalCommandCompletion[];
44
45
  skills: () => SkillEntry[];
45
46
  expandSkill: (text: string) => string;
47
+ legacyAliases: () => string[];
46
48
  };
@@ -1,49 +1,34 @@
1
1
  import { isAbsolute, relative, resolve } from 'node:path';
2
2
  import { SessionManager } from '@earendil-works/pi-coding-agent';
3
+ import { CORE_COMMANDS, coreCommand } from "../../commands/registry.js";
4
+ import { commandRoutes, promptCommandAvailable } from "../../commands/routes.js";
3
5
  import { BUILTIN_PLUGIN_REGISTRY } from "../../plugins/builtin.js";
6
+ import { pluginCommandPrefix } from "../../plugins/commandPrefix.js";
4
7
  import { PageOverlay } from "./pageOverlay.js";
5
8
  import { ModelPicker } from "./modelPicker.js";
6
9
  import { TreePicker } from "./treePicker.js";
7
10
  import { createCommandPage } from "./commandPages.js";
8
11
  import { pageOverlayOptions } from "./dialogGeometry.js";
12
+ import { ResumeSessionsPage } from "./sessionPicker.js";
9
13
  import { expandSkillInvocation } from "./skillInvocation.js";
10
14
  import { groupTerminalCommands } from "../commandHelp.js";
11
15
  import { createBuiltinPluginCommandHandlers } from "./pluginCommands.js";
12
16
  export { parseAnnotate } from "./pluginCommands.js";
13
- const BUILTINS = [
14
- { name: 'help', description: 'List the commands', group: 'Core' },
15
- { name: 'plan', description: 'Plan one request, read-only: /plan <brief>', group: 'Run' },
16
- { name: 'btw', aliases: ['side'], description: 'Ask a side question without interrupting: /btw <question>', group: 'Run' },
17
- { name: 'new', description: 'Start a new session', group: 'Session' },
18
- { name: 'stop', description: 'Stop the current reply', group: 'Run' },
19
- { name: 'session', aliases: ['resume'], description: 'Choose a saved session in this account and folder', group: 'Session' },
20
- { name: 'history', aliases: ['tree'], description: 'Navigate branches in the current session', group: 'Session' },
21
- { name: 'fork', description: 'Fork from an earlier message', group: 'Session' },
22
- { name: 'clone', description: 'Clone this branch into a new session', group: 'Session' },
23
- { name: 'model', description: 'Choose the model', group: 'Model' },
24
- { name: 'effort', description: 'Choose the thinking effort', group: 'Model' },
25
- { name: 'thinking', description: 'Toggle transcript thinking visibility: /thinking [show|hide]', group: 'Model' },
26
- { name: 'name', description: 'Name this session', group: 'Session' },
27
- { name: 'compact', description: 'Compact the context', group: 'Session' },
28
- { name: 'copy', description: 'Copy the last reply', group: 'Inspect' },
29
- { name: 'export', description: 'Export this branch to HTML or JSONL', group: 'Inspect' },
30
- { name: 'reload', description: 'Reload trusted resources and keybindings', group: 'Harness' },
31
- { name: 'accounts', description: 'Sign in, switch, check and remove Pi accounts', group: 'Harness' },
32
- { name: 'permissions', description: 'Full Permission and per-tool approvals', group: 'Harness' },
33
- { name: 'harness', description: 'Browse foundation, variant and all resources', group: 'Harness' },
34
- { name: 'skills', description: 'View, inspect and use skills; discover more', group: 'Harness' },
35
- { name: 'connections', description: 'Manage local and remote MCP connections', group: 'Harness' },
36
- { name: 'mcp', description: 'Open Connections in the Harness browser', group: 'Harness' },
37
- { name: 'tools', description: 'Inspect available tools, owners and approvals', group: 'Harness' },
38
- { name: 'plugins', description: 'Switch plugins on or off and trust their code', group: 'Harness' },
39
- { name: 'hub', description: 'Browse and install reviewed skills, plugins and documents', group: 'Harness' },
40
- { name: 'components', description: 'Browse skills, prompts and rules for this Harness scope', group: 'Harness' },
41
- { name: 'settings', description: 'Session, terminal and browser settings', group: 'Harness' },
42
- { name: 'log', description: "Harness diagnostics and this conversation's run events", group: 'Inspect' },
43
- { name: 'hotkeys', description: 'Keyboard controls', group: 'Core' },
44
- { name: 'quit', description: 'Restore the terminal and exit', group: 'Core' }
45
- ];
17
+ const BUILTINS = CORE_COMMANDS.map((command) => ({
18
+ name: command.name,
19
+ ...('aliases' in command ? { aliases: [...command.aliases] } : {}),
20
+ description: command.description,
21
+ group: command.group
22
+ }));
46
23
  const COMMAND_ALIASES = new Map(BUILTINS.flatMap(command => (command.aliases ?? []).map(alias => [alias, command.name])));
24
+ const CORE_PROMPT_NAMES = new Set([...BUILTINS.map(command => command.name), ...COMMAND_ALIASES.keys()]);
25
+ const LEGACY_BUILTIN_COMMAND_ALIASES = new Map([
26
+ ['doctor:doctor', 'doctor'], ['plugin:doctor', 'doctor'],
27
+ ['worktrees:worktree', 'worktree'], ['plugin:worktree', 'worktree'],
28
+ ['history:trajectory', 'trajectory'], ['plugin:trajectory', 'trajectory'],
29
+ ['subagents:subagents', 'subagents'], ['plugin:subagents', 'subagents'],
30
+ ['subagents:agents', 'agents'], ['plugin:agents', 'agents']
31
+ ]);
47
32
  export function createTerminalCommands(host) {
48
33
  const notify = (text) => host.ui.notify(text, 'info');
49
34
  const choose = async (title, items, label) => {
@@ -70,37 +55,31 @@ export function createTerminalCommands(host) {
70
55
  }
71
56
  const pluginHandlers = createBuiltinPluginCommandHandlers(host, openPage);
72
57
  const pluginCommands = BUILTIN_PLUGIN_REGISTRY.commands();
73
- const coreNames = new Set([...BUILTINS.map(command => command.name), ...COMMAND_ALIASES.keys()]);
74
- for (const command of pluginCommands) {
75
- if (command.namespace === 'unprefixed' && coreNames.has(command.name)) {
76
- throw new Error(`Required plugin command collides with a core command: ${command.pluginId}:${command.name}`);
77
- }
78
- }
58
+ const builtinPrefixes = new Map(pluginCommands.map(command => [command.prefix, command.pluginId]));
59
+ const prefixCache = new WeakMap();
79
60
  const extensionCommands = () => {
80
- const byName = new Map();
81
- for (const command of host.session().extensionRunner.getRegisteredCommands()) {
82
- byName.set(command.name, [...(byName.get(command.name) ?? []), command]);
83
- }
84
- const unique = new Map();
85
- const duplicates = new Map();
86
- for (const [name, commands] of byName) {
87
- if (commands.length === 1)
88
- unique.set(name, commands[0]);
89
- else
90
- duplicates.set(name, commands);
91
- }
92
- return { unique, duplicates };
61
+ return commandRoutes(host.session().extensionRunner.getRegisteredCommands().map(command => {
62
+ const source = command.sourceInfo;
63
+ let prefix = prefixCache.get(source);
64
+ if (!prefix) {
65
+ prefix = pluginCommandPrefix(source);
66
+ prefixCache.set(source, prefix);
67
+ }
68
+ const owner = source.origin === 'package'
69
+ ? `package:${source.baseDir ?? source.source}`
70
+ : `extension:${source.path}`;
71
+ return { ...command, prefix, owner, path: source.path };
72
+ }));
93
73
  };
94
- const unprefixedCommandNames = (session) => new Set([
74
+ const coreCommandNames = (session) => new Set([
95
75
  ...BUILTINS.flatMap(command => [command.name, ...(command.aliases ?? [])]),
96
- ...pluginCommands.filter(command => command.namespace === 'unprefixed').map(command => command.name),
97
76
  ...session.resourceLoader.getPrompts().prompts.map(prompt => prompt.name)
98
77
  ]);
99
- const runBuiltinPluginCommand = async (name, argument) => {
78
+ const runBuiltinPluginCommand = async (command, argument) => {
100
79
  try {
101
- const handler = pluginHandlers.get(name);
80
+ const handler = pluginHandlers.get(`${command.pluginId}:${command.name}`);
102
81
  if (!handler)
103
- throw new Error(`No terminal handler for built-in plugin command ${name}.`);
82
+ throw new Error(`No terminal handler for built-in plugin command ${command.pluginId}:${command.name}.`);
104
83
  await handler(argument);
105
84
  host.refresh();
106
85
  }
@@ -108,33 +87,141 @@ export function createTerminalCommands(host) {
108
87
  host.ui.notify(error instanceof Error ? error.message : String(error), 'error');
109
88
  }
110
89
  };
90
+ const runExtensionCommand = async (registered, argument, route) => {
91
+ try {
92
+ const command = host.session().extensionRunner.getCommand(registered.invocationName);
93
+ if (!command)
94
+ throw new Error(`Plugin command /${route} is no longer registered. Reload trusted resources.`);
95
+ await command.handler(argument, host.session().extensionRunner.createCommandContext());
96
+ host.refresh();
97
+ }
98
+ catch (error) {
99
+ host.ui.notify(error instanceof Error ? error.message : String(error), 'error');
100
+ }
101
+ };
102
+ const runLegacyPluginCommand = async (name, argument) => {
103
+ const session = host.session();
104
+ if (coreCommandNames(session).has(name)) {
105
+ host.ui.notify(`Use /${name}; /plugin:${name} is unavailable because a core command or prompt already uses that name.`, 'error');
106
+ return;
107
+ }
108
+ const builtins = BUILTIN_PLUGIN_REGISTRY.commandsNamed(name);
109
+ const extensions = extensionCommands();
110
+ if (builtins.length > 1) {
111
+ host.ui.notify(`Legacy plugin command /plugin:${name} is ambiguous. Use its owner-qualified command.`, 'error');
112
+ return;
113
+ }
114
+ if (builtins.length === 1) {
115
+ const competitors = extensions.byName.get(name) ?? [];
116
+ if (competitors.length) {
117
+ host.ui.notify(`Extension command /plugin:${name} from ${competitors.map(command => command.sourceInfo.path).join(', ')} is unavailable because the name belongs to the built-in ${builtins[0].pluginId} plugin.`, 'error');
118
+ }
119
+ await runBuiltinPluginCommand(builtins[0], argument);
120
+ return;
121
+ }
122
+ const matches = extensions.byName.get(name) ?? [];
123
+ if (matches.length > 1) {
124
+ host.ui.notify(`Legacy plugin command /plugin:${name} is ambiguous. Registered by: ${matches.map(command => command.sourceInfo.path).join(', ')}. Use its owner-qualified command.`, 'error');
125
+ return;
126
+ }
127
+ if (matches.length === 1) {
128
+ if (extensions.conflictingPrefixes.has(matches[0].prefix)) {
129
+ host.ui.notify(`Plugin prefix /${matches[0].prefix}: is ambiguous or reserved. Owners: ${extensions.conflictingPrefixes.get(matches[0].prefix).join(', ')}.`, 'error');
130
+ return;
131
+ }
132
+ await runExtensionCommand(matches[0], argument, `plugin:${name}`);
133
+ return;
134
+ }
135
+ host.ui.notify(`Unknown plugin command: /plugin:${name}. Use /help to list the commands.`, 'error');
136
+ };
137
+ const runNamespacedCommand = async (typedName, argument) => {
138
+ const separator = typedName.indexOf(':');
139
+ if (separator < 1)
140
+ return false;
141
+ const prefix = typedName.slice(0, separator);
142
+ const name = typedName.slice(separator + 1);
143
+ if (prefix === 'plugin') {
144
+ await runLegacyPluginCommand(name, argument);
145
+ return true;
146
+ }
147
+ const builtin = name ? BUILTIN_PLUGIN_REGISTRY.command(prefix, name) : BUILTIN_PLUGIN_REGISTRY.defaultCommand(prefix);
148
+ const extensions = extensionCommands();
149
+ const isBuiltinPrefix = builtinPrefixes.has(prefix);
150
+ const isExtensionPrefix = extensions.ownersByPrefix.has(prefix);
151
+ if (!builtin && !isBuiltinPrefix && !isExtensionPrefix)
152
+ return false;
153
+ if (builtin) {
154
+ const competitors = extensions.routes.get(`${prefix}:${builtin.name}`) ?? [];
155
+ if (competitors.length) {
156
+ host.ui.notify(`Extension command /${prefix}:${builtin.name} from ${competitors.map(command => command.sourceInfo.path).join(', ')} is unavailable because the prefix belongs to built-in ${builtin.pluginId}.`, 'error');
157
+ }
158
+ await runBuiltinPluginCommand(builtin, argument);
159
+ return true;
160
+ }
161
+ if (extensions.conflictingPrefixes.has(prefix)) {
162
+ host.ui.notify(`Plugin prefix /${prefix}: is ambiguous or reserved. Owners: ${extensions.conflictingPrefixes.get(prefix).join(', ')}.`, 'error');
163
+ return true;
164
+ }
165
+ if (!name) {
166
+ host.ui.notify(`Plugin /${prefix}: has no default command. Use /help to list its commands.`, 'error');
167
+ return true;
168
+ }
169
+ const matches = extensions.routes.get(`${prefix}:${name}`) ?? [];
170
+ if (matches.length > 1) {
171
+ host.ui.notify(`Plugin command /${prefix}:${name} is ambiguous. Registered by: ${matches.map(command => command.sourceInfo.path).join(', ')}.`, 'error');
172
+ return true;
173
+ }
174
+ if (matches.length === 1) {
175
+ await runExtensionCommand(matches[0], argument, `${prefix}:${name}`);
176
+ return true;
177
+ }
178
+ host.ui.notify(`Unknown plugin command: /${typedName}. Use /help to list the commands.`, 'error');
179
+ return true;
180
+ };
181
+ const legacyAliases = () => {
182
+ const session = host.session();
183
+ const coreNames = coreCommandNames(session);
184
+ const aliases = new Set(LEGACY_BUILTIN_COMMAND_ALIASES.keys());
185
+ for (const command of pluginCommands) {
186
+ if (coreNames.has(command.name))
187
+ continue;
188
+ aliases.add(`plugin:${command.name}`);
189
+ if (command.legacyBare)
190
+ aliases.add(command.name);
191
+ }
192
+ for (const name of extensionCommands().byName.keys()) {
193
+ if (!coreNames.has(name))
194
+ aliases.add(`plugin:${name}`);
195
+ }
196
+ return [...aliases];
197
+ };
111
198
  const completions = () => {
112
199
  const session = host.session();
113
200
  const prompts = session.resourceLoader.getPrompts().prompts;
114
201
  const commands = new Map(BUILTINS.map(command => [command.name, command]));
115
- const bareNames = unprefixedCommandNames(session);
116
- const namespacedBuiltins = new Set();
117
202
  for (const command of pluginCommands) {
118
- if (command.namespace === 'unprefixed') {
119
- commands.set(command.name, { name: command.name, description: command.description, group: 'Harness' });
120
- }
121
- else {
122
- namespacedBuiltins.add(command.name);
123
- if (!bareNames.has(command.name)) {
124
- const invocation = `plugin:${command.name}`;
125
- commands.set(invocation, { name: invocation, description: command.description, group: 'Plugins' });
126
- }
203
+ const invocation = `${command.prefix}:${command.name}`;
204
+ commands.set(invocation, { name: invocation, description: command.description, group: 'Plugins' });
205
+ }
206
+ for (const module of BUILTIN_PLUGIN_REGISTRY.modules) {
207
+ const defaultCommand = module.defaultCommand && BUILTIN_PLUGIN_REGISTRY.command(module.commandPrefix ?? module.descriptor.id, module.defaultCommand);
208
+ if (defaultCommand) {
209
+ const invocation = `${defaultCommand.prefix}:`;
210
+ commands.set(invocation, { name: invocation, description: defaultCommand.description, group: 'Plugins' });
127
211
  }
128
212
  }
129
213
  const extensions = extensionCommands();
130
- for (const [name, command] of extensions.unique) {
131
- if (!bareNames.has(name) && !namespacedBuiltins.has(name)) {
132
- const invocation = `plugin:${name}`;
133
- commands.set(invocation, { name: invocation, description: command.description, group: 'Plugins' });
134
- }
214
+ for (const [route, matches] of extensions.routes) {
215
+ const prefix = route.slice(0, route.indexOf(':'));
216
+ if (matches.length !== 1 || extensions.conflictingPrefixes.has(prefix))
217
+ continue;
218
+ if (BUILTIN_PLUGIN_REGISTRY.command(prefix, matches[0].name))
219
+ continue;
220
+ commands.set(route, { name: route, description: matches[0].description, group: 'Plugins' });
135
221
  }
222
+ const extensionNames = new Set(extensions.byName.keys());
136
223
  for (const prompt of prompts) {
137
- if (!commands.has(prompt.name) && !COMMAND_ALIASES.has(prompt.name))
224
+ if (promptCommandAvailable(prompt.name, new Set([...commands.keys(), ...COMMAND_ALIASES.keys()]), extensionNames))
138
225
  commands.set(prompt.name, { name: prompt.name, description: prompt.description, group: 'Prompts' });
139
226
  }
140
227
  return [...commands.values()];
@@ -145,65 +232,34 @@ export function createTerminalCommands(host) {
145
232
  return false;
146
233
  const typedName = match[1];
147
234
  const argument = match[2]?.trim() ?? '';
148
- if (typedName.startsWith('plugin:')) {
149
- const pluginName = typedName.slice('plugin:'.length);
150
- const session = host.session();
151
- if (unprefixedCommandNames(session).has(pluginName)) {
152
- host.ui.notify(`Use /${pluginName}; /plugin:${pluginName} is unavailable because an unprefixed command already uses that name.`, 'error');
153
- return true;
154
- }
155
- const builtin = BUILTIN_PLUGIN_REGISTRY.command(pluginName);
156
- const extensions = extensionCommands();
157
- if (builtin?.namespace === 'plugin') {
158
- const collisions = extensions.unique.get(pluginName) ? [extensions.unique.get(pluginName)] : extensions.duplicates.get(pluginName) ?? [];
159
- if (collisions.length) {
160
- const owners = collisions.map(command => command.sourceInfo.path).join(', ');
161
- host.ui.notify(`Extension command /plugin:${pluginName} from ${owners} is unavailable because the name belongs to the built-in ${builtin.pluginId} plugin.`, 'error');
162
- }
163
- await runBuiltinPluginCommand(pluginName, argument);
164
- return true;
165
- }
166
- const duplicate = extensions.duplicates.get(pluginName);
167
- if (duplicate) {
168
- const owners = duplicate.map(command => command.sourceInfo.path).join(', ');
169
- host.ui.notify(`Plugin command /plugin:${pluginName} is ambiguous. Registered by: ${owners}. Rename or remove one command.`, 'error');
235
+ const legacyBuiltin = LEGACY_BUILTIN_COMMAND_ALIASES.get(typedName);
236
+ if (!legacyBuiltin && await runNamespacedCommand(typedName, argument))
237
+ return true;
238
+ const session = host.session();
239
+ const name = coreCommand(legacyBuiltin ?? typedName)?.name ?? legacyBuiltin ?? typedName;
240
+ const builtin = BUILTINS.find(item => item.name === name);
241
+ if (!builtin && !coreCommandNames(session).has(name)) {
242
+ const legacyBare = BUILTIN_PLUGIN_REGISTRY.commandsNamed(name).filter(command => command.legacyBare);
243
+ if (legacyBare.length > 1) {
244
+ host.ui.notify(`Legacy command /${name} is ambiguous. Use its owner-qualified command.`, 'error');
170
245
  return true;
171
246
  }
172
- const registered = extensions.unique.get(pluginName);
173
- if (registered) {
174
- try {
175
- const command = host.session().extensionRunner.getCommand(registered.invocationName);
176
- if (!command)
177
- throw new Error(`Plugin command /plugin:${pluginName} is no longer registered. Reload trusted resources.`);
178
- await command.handler(argument, host.session().extensionRunner.createCommandContext());
179
- host.refresh();
180
- }
181
- catch (error) {
182
- host.ui.notify(error instanceof Error ? error.message : String(error), 'error');
183
- }
247
+ if (legacyBare.length === 1) {
248
+ await runBuiltinPluginCommand(legacyBare[0], argument);
184
249
  return true;
185
250
  }
186
- host.ui.notify(`Unknown plugin command: /${typedName}. Use /help to list the commands.`, 'error');
187
- return true;
188
- }
189
- const name = COMMAND_ALIASES.get(typedName) ?? typedName;
190
- const builtin = BUILTINS.find(item => item.name === name);
191
- const requiredPluginCommand = BUILTIN_PLUGIN_REGISTRY.command(name);
192
- if (!builtin && requiredPluginCommand?.namespace === 'unprefixed') {
193
- await runBuiltinPluginCommand(name, argument);
194
- return true;
195
251
  }
196
252
  if (!builtin) {
197
253
  // A skill is offered as $name; the typed /skill:name form still reaches the SDK, which expands it.
198
254
  if (skills().some(skill => `skill:${skill.name}` === name))
199
255
  return false;
200
- if (host.session().resourceLoader.getPrompts().prompts.some(prompt => prompt.name === name))
256
+ const prompt = session.resourceLoader.getPrompts().prompts.some(item => item.name === name);
257
+ if (prompt && promptCommandAvailable(name, CORE_PROMPT_NAMES, new Set(extensionCommands().byName.keys())))
201
258
  return false;
202
259
  host.ui.notify(`Unknown command: /${name}. Use /help to list commands.`, 'error');
203
260
  return true;
204
261
  }
205
262
  try {
206
- const session = host.session();
207
263
  switch (name) {
208
264
  case 'help':
209
265
  host.showHelp(groupTerminalCommands(completions()));
@@ -221,7 +277,7 @@ export function createTerminalCommands(host) {
221
277
  await host.btw(argument);
222
278
  break;
223
279
  case 'stop': {
224
- if (!session.isStreaming)
280
+ if (session.isIdle)
225
281
  throw new Error('Nothing is running.');
226
282
  await host.runtime().interrupt();
227
283
  break;
@@ -232,11 +288,21 @@ export function createTerminalCommands(host) {
232
288
  case 'session': {
233
289
  let path = argument;
234
290
  if (!path) {
235
- const sessions = await SessionManager.list(host.cwd(), host.sessionDirectory());
236
- const selected = await choose('Resume session', sessions, (item, index) => `${index + 1}. ${item.name ?? item.firstMessage ?? item.id} ${item.modified.toLocaleString()}`);
291
+ const selected = await host.ui.custom((tui, _theme, _keys, done) => {
292
+ const page = new ResumeSessionsPage({
293
+ theme: () => host.ui.theme,
294
+ cwd: () => host.cwd(),
295
+ branch: () => host.branch(),
296
+ listSessions: signal => SessionManager.list(host.cwd(), host.sessionDirectory(), undefined, signal),
297
+ select: done,
298
+ cancel: () => done(undefined),
299
+ changed: host.changed
300
+ });
301
+ return new PageOverlay({ page, tui, theme: () => host.ui.theme, close: () => done(undefined), changed: host.changed });
302
+ }, { overlay: true, overlayOptions: pageOverlayOptions('fullscreen') });
237
303
  if (!selected)
238
304
  break;
239
- path = selected.path;
305
+ path = selected;
240
306
  }
241
307
  const target = resolve(host.cwd(), path);
242
308
  const within = relative(host.sessionDirectory(), target);
@@ -324,6 +390,21 @@ export function createTerminalCommands(host) {
324
390
  case 'settings':
325
391
  await openPage('settings');
326
392
  break;
393
+ case 'doctor':
394
+ await openPage('doctor');
395
+ break;
396
+ case 'worktree':
397
+ await openPage('worktree');
398
+ break;
399
+ case 'trajectory':
400
+ await openPage('trajectory', { runId: argument });
401
+ break;
402
+ case 'subagents':
403
+ await openPage('subagents');
404
+ break;
405
+ case 'agents':
406
+ await openPage('agents');
407
+ break;
327
408
  case 'permissions':
328
409
  await openPage('permissions');
329
410
  break;
@@ -395,5 +476,5 @@ export function createTerminalCommands(host) {
395
476
  }
396
477
  return true;
397
478
  }
398
- return { execute, completions, skills, expandSkill };
479
+ return { execute, completions, skills, expandSkill, legacyAliases };
399
480
  }
@@ -2,11 +2,20 @@ import type { Theme } from '@earendil-works/pi-coding-agent';
2
2
  import { type Component, type Focusable, type TuiMouseEventResult } from '@earendil-works/pi-tui';
3
3
  import { type TerminalMouseInput } from "./mouse.js";
4
4
  export type RowTone = 'success' | 'warning' | 'error' | 'muted' | 'dim' | 'accent';
5
+ export interface ListLabelPart {
6
+ text: string;
7
+ color?: string;
8
+ tone?: RowTone;
9
+ }
5
10
  export interface ListRow<T = unknown> {
6
11
  id: string;
7
12
  label: string;
13
+ /** Optional colored spans for the label; selected rows keep the shared high-contrast text. */
14
+ labelParts?: readonly ListLabelPart[];
8
15
  /** Shown at the right edge, in `tone`. */
9
16
  detail?: string;
17
+ /** An optional muted second line beneath the label. */
18
+ subtitle?: string;
10
19
  tone?: RowTone;
11
20
  /** Rows with the same consecutive group share a heading (not shown while searching). */
12
21
  group?: string;
@@ -22,6 +31,8 @@ export interface FilterListOptions<T> {
22
31
  search?: 'always' | 'never';
23
32
  /** Rows the list may use; the search field is part of it. Defaults to what `render` is given. */
24
33
  limit?: () => number;
34
+ /** Blank lines between visible rows. */
35
+ rowGap?: number;
25
36
  empty?: string;
26
37
  noun?: readonly [string, string];
27
38
  /** Short context included in the search row while a query is active. */
@@ -59,6 +70,7 @@ export declare class FilterList<T = unknown> implements Component, Focusable {
59
70
  handleMouse(event: TerminalMouseInput): TuiMouseEventResult | undefined;
60
71
  invalidate(): void;
61
72
  private limit;
73
+ private rowGap;
62
74
  private mode;
63
75
  private canSearch;
64
76
  private searchVisible;
@@ -67,7 +79,8 @@ export declare class FilterList<T = unknown> implements Component, Focusable {
67
79
  private move;
68
80
  handleInput(data: string): boolean;
69
81
  private entries;
70
- private line;
82
+ private lines;
83
+ private labelParts;
71
84
  /** `rows` is the most the list may use, the search row included. */
72
85
  render(width: number, rows?: number): string[];
73
86
  }
@@ -1,4 +1,4 @@
1
- import { fuzzyFilter, matchesKey, truncateToWidth, visibleWidth } from '@earendil-works/pi-tui';
1
+ import { fuzzyFilter, matchesKey, parseColor, truncateToWidth, visibleWidth } from '@earendil-works/pi-tui';
2
2
  import { safeText, selectedRow, selectionMuted, selectionText } from "./chrome.js";
3
3
  import { SEARCH_FIELD_ROWS, SearchField, searchFieldRowsFor } from "./search.js";
4
4
  import { isPrimaryClick } from "./mouse.js";
@@ -25,7 +25,10 @@ export class FilterList {
25
25
  get total() { return this.rows.length; }
26
26
  get shown() { return this.visible.length; }
27
27
  /** Rows the list would use if nothing cut it: the search field, headings and rows. */
28
- get height() { return (this.searchVisible() ? this.renderedSearchRows : 0) + this.entries().length; }
28
+ get height() {
29
+ return (this.searchVisible() ? this.renderedSearchRows : 0)
30
+ + this.entries().reduce((height, entry) => height + (entry.row?.subtitle === undefined ? 1 : 2), 0);
31
+ }
29
32
  /** Rows at the top of `render` that a frame must keep when it has to cut the list. */
30
33
  get pinnedRows() { return this.searchVisible() ? this.renderedSearchRows : 0; }
31
34
  setRows(rows) {
@@ -61,6 +64,10 @@ export class FilterList {
61
64
  }
62
65
  invalidate() { this.search.invalidate(); }
63
66
  limit() { return Math.max(1, Math.floor(this.options.limit?.() ?? Number.POSITIVE_INFINITY)); }
67
+ rowGap() {
68
+ const gap = this.options.rowGap ?? 0;
69
+ return Number.isFinite(gap) ? Math.max(0, Math.floor(gap)) : 0;
70
+ }
64
71
  mode() { return this.options.search ?? 'always'; }
65
72
  canSearch() { return this.mode() === 'always'; }
66
73
  searchVisible() { return this.canSearch() || this.query.length > 0; }
@@ -68,7 +75,7 @@ export class FilterList {
68
75
  refilter() {
69
76
  const query = this.query.trim();
70
77
  this.visible = query
71
- ? fuzzyFilter(this.rows, query, row => row.searchText ?? `${row.label} ${row.detail ?? ''} ${row.group ?? ''}`)
78
+ ? fuzzyFilter(this.rows, query, row => row.searchText ?? `${row.label} ${row.subtitle ?? ''} ${row.detail ?? ''} ${row.group ?? ''}`)
72
79
  : this.rows;
73
80
  if (!this.visible.some(row => row.id === this.selectedId && !row.disabled))
74
81
  this.selectedId = this.firstSelectable()?.id;
@@ -136,15 +143,20 @@ export class FilterList {
136
143
  entries() {
137
144
  const entries = [];
138
145
  let last;
139
- for (const row of this.visible) {
146
+ const gap = this.rowGap();
147
+ for (const [index, row] of this.visible.entries()) {
140
148
  if (!this.query && row.group && row.group !== last)
141
149
  entries.push({ heading: row.group });
142
150
  last = row.group;
143
151
  entries.push({ row });
152
+ if (index < this.visible.length - 1) {
153
+ for (let line = 0; line < gap; line += 1)
154
+ entries.push({ spacer: true });
155
+ }
144
156
  }
145
157
  return entries;
146
158
  }
147
- line(row, width) {
159
+ lines(row, width) {
148
160
  const theme = this.options.theme();
149
161
  const selected = row.id === this.selectedId;
150
162
  const detail = row.detail ? safeText(row.detail) : '';
@@ -153,10 +165,40 @@ export class FilterList {
153
165
  const label = truncateToWidth(safeText(row.label), Math.max(0, room - (showDetail ? visibleWidth(detail) + 2 : 0)), '…');
154
166
  const gap = ' '.repeat(Math.max(0, room - visibleWidth(label) - (showDetail ? visibleWidth(detail) : 0)));
155
167
  const tone = (text) => theme.fg(row.tone ?? 'muted', text);
156
- const main = (selected ? '❯ ' : ' ') + label;
157
- if (selected)
158
- return selectedRow(selectionText(theme, main) + gap + (showDetail ? selectionMuted(theme, detail) : ''), width, theme);
159
- return (row.disabled ? theme.fg('dim', main) : main) + gap + (showDetail ? tone(detail) : '');
168
+ const prefix = selected ? '❯ ' : ' ';
169
+ const labelText = selected || row.disabled || !row.labelParts?.length ? label : this.labelParts(row.labelParts, label, theme);
170
+ const main = prefix + label;
171
+ const primary = selected
172
+ ? selectedRow(selectionText(theme, main) + gap + (showDetail ? selectionMuted(theme, detail) : ''), width, theme)
173
+ : (row.disabled ? theme.fg('dim', main) : prefix + labelText) + gap + (showDetail ? tone(detail) : '');
174
+ if (row.subtitle === undefined)
175
+ return [primary];
176
+ const subtitle = truncateToWidth(` ${safeText(row.subtitle)}`, width, '…');
177
+ const secondary = selected
178
+ ? selectedRow(selectionMuted(theme, subtitle), width, theme)
179
+ : theme.fg(row.disabled ? 'dim' : 'muted', subtitle);
180
+ return [primary, secondary];
181
+ }
182
+ labelParts(parts, visibleLabel, theme) {
183
+ const source = parts.map(part => safeText(part.text)).join('');
184
+ const truncated = visibleWidth(source) > visibleWidth(visibleLabel);
185
+ let remaining = Math.max(0, visibleWidth(visibleLabel) - (truncated ? 1 : 0));
186
+ const output = [];
187
+ for (const part of parts) {
188
+ if (remaining <= 0)
189
+ break;
190
+ const text = safeText(part.text);
191
+ const fitted = truncateToWidth(text, remaining, '');
192
+ if (fitted) {
193
+ output.push(part.color ? theme.style(fitted, { fg: parseColor(part.color) }) : part.tone ? theme.fg(part.tone, fitted) : fitted);
194
+ }
195
+ remaining -= visibleWidth(fitted);
196
+ if (visibleWidth(fitted) < visibleWidth(text))
197
+ break;
198
+ }
199
+ if (truncated)
200
+ output.push('…');
201
+ return output.join('');
160
202
  }
161
203
  /** `rows` is the most the list may use, the search row included. */
162
204
  render(width, rows) {
@@ -186,11 +228,51 @@ export class FilterList {
186
228
  return lines.map(line => truncateToWidth(line, width, ''));
187
229
  }
188
230
  const at = Math.max(0, entries.findIndex(entry => entry.row?.id === this.selectedId));
189
- const start = Math.max(0, Math.min(Math.max(0, entries.length - budget), at - Math.floor(budget / 2)));
190
- for (const entry of entries.slice(start, start + budget)) {
191
- lines.push(entry.row ? this.line(entry.row, width) : theme.fg('dim', safeText(entry.heading ?? '')));
192
- this.renderedRows.push(entry.row);
231
+ let start = at;
232
+ let end = at + 1;
233
+ let used = entries[at]?.row?.subtitle === undefined ? 1 : 2;
234
+ let preferBefore = true;
235
+ while (start > 0 || end < entries.length) {
236
+ const before = entries[start - 1];
237
+ const after = entries[end];
238
+ const beforeHeight = before ? (before.row?.subtitle === undefined ? 1 : 2) : Number.POSITIVE_INFINITY;
239
+ const afterHeight = after ? (after.row?.subtitle === undefined ? 1 : 2) : Number.POSITIVE_INFINITY;
240
+ if (preferBefore && before && used + beforeHeight <= budget) {
241
+ start -= 1;
242
+ used += beforeHeight;
243
+ }
244
+ else if (after && used + afterHeight <= budget) {
245
+ end += 1;
246
+ used += afterHeight;
247
+ }
248
+ else if (before && used + beforeHeight <= budget) {
249
+ start -= 1;
250
+ used += beforeHeight;
251
+ }
252
+ else
253
+ break;
254
+ preferBefore = !preferBefore;
255
+ }
256
+ if (entries[start]?.spacer)
257
+ start += 1;
258
+ if (entries[end - 1]?.spacer)
259
+ end -= 1;
260
+ for (const entry of entries.slice(start, end)) {
261
+ if (entry.row) {
262
+ const rendered = this.lines(entry.row, width);
263
+ lines.push(...rendered);
264
+ this.renderedRows.push(...rendered.map(() => entry.row));
265
+ }
266
+ else if (entry.spacer) {
267
+ lines.push('');
268
+ this.renderedRows.push(undefined);
269
+ }
270
+ else {
271
+ lines.push(theme.fg('dim', safeText(entry.heading ?? '')));
272
+ this.renderedRows.push(undefined);
273
+ }
193
274
  }
194
- return lines.map(line => truncateToWidth(line, width, ''));
275
+ this.renderedRows = this.renderedRows.slice(0, rowBudget);
276
+ return lines.slice(0, rowBudget).map(line => truncateToWidth(line, width, ''));
195
277
  }
196
278
  }