@oh-my-pi/pi-coding-agent 14.9.9 → 15.0.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 (128) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/package.json +7 -7
  3. package/scripts/format-prompts.ts +1 -1
  4. package/src/cli/args.ts +2 -2
  5. package/src/cli.ts +1 -0
  6. package/src/commands/acp.ts +24 -0
  7. package/src/commands/launch.ts +6 -4
  8. package/src/commit/agentic/prompts/system.md +1 -1
  9. package/src/config/model-resolver.ts +30 -0
  10. package/src/config/settings-schema.ts +31 -0
  11. package/src/edit/index.ts +22 -1
  12. package/src/edit/modes/patch.ts +10 -0
  13. package/src/edit/modes/replace.ts +3 -0
  14. package/src/edit/renderer.ts +10 -0
  15. package/src/eval/js/context-manager.ts +1 -1
  16. package/src/eval/js/shared/rewrite-imports.ts +120 -48
  17. package/src/eval/js/shared/runtime.ts +31 -4
  18. package/src/eval/js/tool-bridge.ts +43 -21
  19. package/src/extensibility/extensions/runner.ts +54 -1
  20. package/src/extensibility/extensions/types.ts +11 -0
  21. package/src/extensibility/skills.ts +33 -1
  22. package/src/internal-urls/docs-index.generated.ts +6 -6
  23. package/src/internal-urls/index.ts +1 -0
  24. package/src/internal-urls/issue-pr-protocol.ts +577 -0
  25. package/src/internal-urls/router.ts +6 -3
  26. package/src/internal-urls/types.ts +22 -1
  27. package/src/main.ts +13 -9
  28. package/src/modes/acp/acp-agent.ts +361 -54
  29. package/src/modes/acp/acp-client-bridge.ts +152 -0
  30. package/src/modes/acp/acp-event-mapper.ts +180 -15
  31. package/src/modes/acp/terminal-auth.ts +37 -0
  32. package/src/modes/components/read-tool-group.ts +29 -1
  33. package/src/modes/controllers/command-controller.ts +14 -6
  34. package/src/modes/controllers/event-controller.ts +24 -11
  35. package/src/modes/controllers/extension-ui-controller.ts +8 -2
  36. package/src/modes/controllers/input-controller.ts +72 -39
  37. package/src/modes/interactive-mode.ts +71 -7
  38. package/src/modes/rpc/rpc-mode.ts +17 -2
  39. package/src/modes/types.ts +6 -2
  40. package/src/modes/utils/ui-helpers.ts +15 -3
  41. package/src/prompts/agents/designer.md +5 -5
  42. package/src/prompts/agents/explore.md +7 -7
  43. package/src/prompts/agents/init.md +9 -9
  44. package/src/prompts/agents/librarian.md +14 -14
  45. package/src/prompts/agents/plan.md +4 -4
  46. package/src/prompts/agents/reviewer.md +5 -5
  47. package/src/prompts/agents/task.md +10 -10
  48. package/src/prompts/commands/orchestrate.md +2 -2
  49. package/src/prompts/compaction/branch-summary.md +3 -3
  50. package/src/prompts/compaction/compaction-short-summary.md +7 -7
  51. package/src/prompts/compaction/compaction-summary-context.md +1 -1
  52. package/src/prompts/compaction/compaction-summary.md +5 -5
  53. package/src/prompts/compaction/compaction-turn-prefix.md +3 -3
  54. package/src/prompts/compaction/compaction-update-summary.md +11 -11
  55. package/src/prompts/memories/consolidation.md +2 -2
  56. package/src/prompts/memories/read-path.md +1 -1
  57. package/src/prompts/memories/stage_one_input.md +1 -1
  58. package/src/prompts/memories/stage_one_system.md +5 -5
  59. package/src/prompts/review-request.md +4 -4
  60. package/src/prompts/system/agent-creation-architect.md +17 -17
  61. package/src/prompts/system/agent-creation-user.md +2 -2
  62. package/src/prompts/system/commit-message-system.md +2 -2
  63. package/src/prompts/system/custom-system-prompt.md +2 -2
  64. package/src/prompts/system/eager-todo.md +6 -6
  65. package/src/prompts/system/handoff-document.md +1 -1
  66. package/src/prompts/system/plan-mode-active.md +22 -21
  67. package/src/prompts/system/plan-mode-approved.md +4 -4
  68. package/src/prompts/system/plan-mode-compact-instructions.md +16 -0
  69. package/src/prompts/system/plan-mode-reference.md +2 -2
  70. package/src/prompts/system/plan-mode-subagent.md +8 -8
  71. package/src/prompts/system/plan-mode-tool-decision-reminder.md +2 -2
  72. package/src/prompts/system/project-prompt.md +4 -4
  73. package/src/prompts/system/subagent-system-prompt.md +7 -7
  74. package/src/prompts/system/subagent-yield-reminder.md +4 -4
  75. package/src/prompts/system/system-prompt.md +72 -71
  76. package/src/prompts/system/ttsr-interrupt.md +1 -1
  77. package/src/prompts/tools/apply-patch.md +1 -1
  78. package/src/prompts/tools/ast-edit.md +3 -3
  79. package/src/prompts/tools/ast-grep.md +3 -3
  80. package/src/prompts/tools/browser.md +3 -3
  81. package/src/prompts/tools/checkpoint.md +3 -3
  82. package/src/prompts/tools/exit-plan-mode.md +2 -2
  83. package/src/prompts/tools/find.md +3 -3
  84. package/src/prompts/tools/github.md +2 -5
  85. package/src/prompts/tools/hashline.md +6 -6
  86. package/src/prompts/tools/image-gen.md +3 -3
  87. package/src/prompts/tools/irc.md +1 -1
  88. package/src/prompts/tools/lsp.md +2 -2
  89. package/src/prompts/tools/patch.md +6 -6
  90. package/src/prompts/tools/read.md +7 -7
  91. package/src/prompts/tools/replace.md +5 -5
  92. package/src/prompts/tools/retain.md +1 -1
  93. package/src/prompts/tools/rewind.md +2 -2
  94. package/src/prompts/tools/search.md +2 -2
  95. package/src/prompts/tools/ssh.md +2 -2
  96. package/src/prompts/tools/task.md +12 -6
  97. package/src/prompts/tools/web-search.md +2 -2
  98. package/src/prompts/tools/write.md +3 -3
  99. package/src/sdk.ts +69 -12
  100. package/src/session/agent-session.ts +231 -22
  101. package/src/session/client-bridge.ts +81 -0
  102. package/src/session/compaction/errors.ts +31 -0
  103. package/src/session/compaction/index.ts +1 -0
  104. package/src/slash-commands/acp-builtins.ts +46 -0
  105. package/src/slash-commands/builtin-registry.ts +699 -116
  106. package/src/slash-commands/helpers/context-report.ts +39 -0
  107. package/src/slash-commands/helpers/format.ts +23 -0
  108. package/src/slash-commands/helpers/marketplace-manager.ts +25 -0
  109. package/src/slash-commands/helpers/mcp.ts +532 -0
  110. package/src/slash-commands/helpers/parse.ts +85 -0
  111. package/src/slash-commands/helpers/ssh.ts +193 -0
  112. package/src/slash-commands/helpers/todo.ts +279 -0
  113. package/src/slash-commands/helpers/usage-report.ts +91 -0
  114. package/src/slash-commands/types.ts +126 -0
  115. package/src/task/executor.ts +10 -3
  116. package/src/task/index.ts +17 -1
  117. package/src/task/render.ts +6 -3
  118. package/src/tools/bash.ts +176 -2
  119. package/src/tools/conflict-detect.ts +6 -6
  120. package/src/tools/fetch.ts +15 -4
  121. package/src/tools/find.ts +19 -1
  122. package/src/tools/gh-renderer.ts +0 -12
  123. package/src/tools/gh.ts +682 -176
  124. package/src/tools/github-cache.ts +548 -0
  125. package/src/tools/index.ts +3 -0
  126. package/src/tools/read.ts +110 -27
  127. package/src/tools/write.ts +23 -1
  128. package/src/tui/code-cell.ts +70 -2
@@ -1,4 +1,9 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
1
4
  import { getOAuthProviders } from "@oh-my-pi/pi-ai/utils/oauth";
5
+ import { Snowflake, setProjectDir } from "@oh-my-pi/pi-utils";
6
+ import { $ } from "bun";
2
7
  import type { SettingPath, SettingValue } from "../config/settings";
3
8
  import { settings } from "../config/settings";
4
9
  import {
@@ -14,8 +19,31 @@ import {
14
19
  getPluginsCacheDir,
15
20
  MarketplaceManager,
16
21
  } from "../extensibility/plugins/marketplace";
22
+ import { resolveMemoryBackend } from "../memory-backend";
17
23
  import type { InteractiveModeContext } from "../modes/types";
24
+ import { getChangelogPath, parseChangelog } from "../utils/changelog";
25
+ import { buildContextReportText } from "./helpers/context-report";
26
+ import { formatDuration } from "./helpers/format";
27
+ import { createMarketplaceManager } from "./helpers/marketplace-manager";
28
+ import { handleMcpAcp } from "./helpers/mcp";
29
+ import { commandConsumed, errorMessage, parseSlashCommand, parseSubcommand, usage } from "./helpers/parse";
30
+ import { handleSshAcp } from "./helpers/ssh";
31
+ import { handleTodoAcp } from "./helpers/todo";
32
+ import { buildUsageReportText } from "./helpers/usage-report";
18
33
  import { parseMarketplaceInstallArgs, parsePluginScopeArgs } from "./marketplace-install-parser";
34
+ import type {
35
+ BuiltinSlashCommand,
36
+ ParsedSlashCommand,
37
+ SlashCommandResult,
38
+ SlashCommandRuntime,
39
+ SlashCommandSpec,
40
+ TuiSlashCommandRuntime,
41
+ } from "./types";
42
+
43
+ export type { BuiltinSlashCommand, SubcommandDef } from "./types";
44
+
45
+ /** TUI-specific runtime accepted by `executeBuiltinSlashCommand`. */
46
+ export type BuiltinSlashCommandRuntime = TuiSlashCommandRuntime;
19
47
 
20
48
  function refreshStatusLine(ctx: InteractiveModeContext): void {
21
49
  ctx.statusLine.invalidate();
@@ -23,84 +51,17 @@ function refreshStatusLine(ctx: InteractiveModeContext): void {
23
51
  ctx.ui.requestRender();
24
52
  }
25
53
 
26
- /** Declarative subcommand definition for commands like /mcp. */
27
- export interface SubcommandDef {
28
- name: string;
29
- description: string;
30
- /** Usage hint shown as dim ghost text, e.g. "<name> [--scope project|user]". */
31
- usage?: string;
32
- }
33
-
34
- /** Declarative builtin slash command definition used by autocomplete and help UI. */
35
- export interface BuiltinSlashCommand {
36
- name: string;
37
- description: string;
38
- /** Subcommands for dropdown completion (e.g. /mcp add, /mcp list). */
39
- subcommands?: SubcommandDef[];
40
- /** Static inline hint when command takes a simple argument (no subcommands). */
41
- inlineHint?: string;
42
- }
43
-
44
- interface ParsedBuiltinSlashCommand {
45
- name: string;
46
- args: string;
47
- text: string;
48
- }
49
-
50
- interface BuiltinSlashCommandSpec extends BuiltinSlashCommand {
51
- aliases?: string[];
52
- allowArgs?: boolean;
53
- /**
54
- * Handle the command. Return a string to pass remaining text through as prompt input.
55
- * Return void/undefined to consume the input entirely.
56
- */
57
- handle: (
58
- command: ParsedBuiltinSlashCommand,
59
- runtime: BuiltinSlashCommandRuntime,
60
- // biome-ignore lint/suspicious/noConfusingVoidType: void needed so async handlers returning nothing are assignable
61
- ) => Promise<string | void> | string | void;
62
- }
63
-
64
- export interface BuiltinSlashCommandRuntime {
65
- ctx: InteractiveModeContext;
66
- handleBackgroundCommand: () => void;
67
- }
68
-
69
- function parseBuiltinSlashCommand(text: string): ParsedBuiltinSlashCommand | null {
70
- if (!text.startsWith("/")) return null;
71
- const body = text.slice(1);
72
- if (!body) return null;
73
-
74
- const firstWhitespace = body.search(/\s/);
75
- const firstColon = body.indexOf(":");
76
- const firstSeparator =
77
- firstWhitespace === -1 ? firstColon : firstColon === -1 ? firstWhitespace : Math.min(firstWhitespace, firstColon);
78
-
79
- if (firstSeparator === -1) {
80
- return {
81
- name: body,
82
- args: "",
83
- text,
84
- };
85
- }
86
-
87
- return {
88
- name: body.slice(0, firstSeparator),
89
- args: body.slice(firstSeparator + 1).trim(),
90
- text,
91
- };
92
- }
93
-
94
- const shutdownHandler = (_command: ParsedBuiltinSlashCommand, runtime: BuiltinSlashCommandRuntime): void => {
54
+ const shutdownHandlerTui = (_command: ParsedSlashCommand, runtime: TuiSlashCommandRuntime): SlashCommandResult => {
95
55
  runtime.ctx.editor.setText("");
96
56
  void runtime.ctx.shutdown();
57
+ return commandConsumed();
97
58
  };
98
59
 
99
- const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
60
+ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
100
61
  {
101
62
  name: "settings",
102
63
  description: "Open settings menu",
103
- handle: (_command, runtime) => {
64
+ handleTui: (_command, runtime) => {
104
65
  runtime.ctx.showSettingsSelector();
105
66
  runtime.ctx.editor.setText("");
106
67
  },
@@ -110,7 +71,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
110
71
  description: "Toggle plan mode (agent plans before executing)",
111
72
  inlineHint: "[prompt]",
112
73
  allowArgs: true,
113
- handle: async (command, runtime) => {
74
+ handleTui: async (command, runtime) => {
114
75
  await runtime.ctx.handlePlanModeCommand(command.args || undefined);
115
76
  runtime.ctx.editor.setText("");
116
77
  },
@@ -121,7 +82,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
121
82
  "Toggle loop mode. While enabled, the next prompt you send re-submits after every yield. Esc cancels the current iteration; /loop again to disable.",
122
83
  inlineHint: "[count|duration]",
123
84
  allowArgs: true,
124
- handle: async (command, runtime) => {
85
+ handleTui: async (command, runtime) => {
125
86
  await runtime.ctx.handleLoopCommand(command.args);
126
87
  runtime.ctx.editor.setText("");
127
88
  },
@@ -130,7 +91,38 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
130
91
  name: "model",
131
92
  aliases: ["models"],
132
93
  description: "Select model (opens selector UI)",
133
- handle: (_command, runtime) => {
94
+ acpDescription: "Show current model selection",
95
+ handle: async (command, runtime) => {
96
+ if (command.args) {
97
+ const modelId = command.args.trim();
98
+ const availableModels = runtime.session.getAvailableModels?.() ?? [];
99
+ const match = availableModels.find(
100
+ model => model.id === modelId || `${model.provider}/${model.id}` === modelId,
101
+ );
102
+ if (!match) {
103
+ return usage(
104
+ `Unknown model: ${modelId}. Use ACP \`session/setModel\` for picker-driven selection or list available models with /model.`,
105
+ runtime,
106
+ );
107
+ }
108
+ try {
109
+ await runtime.session.setModel(match);
110
+ await runtime.output(`Model set to ${match.provider}/${match.id}.`);
111
+ await runtime.notifyTitleChanged?.();
112
+ await runtime.notifyConfigChanged?.();
113
+ return commandConsumed();
114
+ } catch (err) {
115
+ return usage(`Failed to set model: ${errorMessage(err)}`, runtime);
116
+ }
117
+ }
118
+
119
+ const model = runtime.session.model;
120
+ await runtime.output(
121
+ model ? `Current model: ${model.provider}/${model.id}` : "No model is currently selected.",
122
+ );
123
+ return commandConsumed();
124
+ },
125
+ handleTui: (_command, runtime) => {
134
126
  runtime.ctx.showModelSelector();
135
127
  runtime.ctx.editor.setText("");
136
128
  },
@@ -138,13 +130,38 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
138
130
  {
139
131
  name: "fast",
140
132
  description: "Toggle fast mode (OpenAI service tier priority)",
133
+ acpDescription: "Toggle fast mode",
134
+ acpInputHint: "[on|off|status]",
141
135
  subcommands: [
142
136
  { name: "on", description: "Enable fast mode" },
143
137
  { name: "off", description: "Disable fast mode" },
144
138
  { name: "status", description: "Show fast mode status" },
145
139
  ],
146
140
  allowArgs: true,
147
- handle: (command, runtime) => {
141
+ handle: async (command, runtime) => {
142
+ const arg = command.args.toLowerCase();
143
+ if (!arg || arg === "toggle") {
144
+ const enabled = runtime.session.toggleFastMode();
145
+ await runtime.output(`Fast mode ${enabled ? "enabled" : "disabled"}.`);
146
+ return commandConsumed();
147
+ }
148
+ if (arg === "on") {
149
+ runtime.session.setFastMode(true);
150
+ await runtime.output("Fast mode enabled.");
151
+ return commandConsumed();
152
+ }
153
+ if (arg === "off") {
154
+ runtime.session.setFastMode(false);
155
+ await runtime.output("Fast mode disabled.");
156
+ return commandConsumed();
157
+ }
158
+ if (arg === "status") {
159
+ await runtime.output(`Fast mode is ${runtime.session.isFastModeEnabled() ? "on" : "off"}.`);
160
+ return commandConsumed();
161
+ }
162
+ return usage("Usage: /fast [on|off|status]", runtime);
163
+ },
164
+ handleTui: (command, runtime) => {
148
165
  const arg = command.args.trim().toLowerCase();
149
166
  if (!arg || arg === "toggle") {
150
167
  const enabled = runtime.ctx.session.toggleFastMode();
@@ -183,6 +200,23 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
183
200
  inlineHint: "[path]",
184
201
  allowArgs: true,
185
202
  handle: async (command, runtime) => {
203
+ const arg = command.args.trim();
204
+ // Match the interactive `/export` behavior: clipboard aliases are not a
205
+ // valid export target. Without this, the literal value (`copy`,
206
+ // `--copy`, `clipboard`) is passed to `exportToHtml` and becomes the
207
+ // output filename.
208
+ if (arg === "--copy" || arg === "clipboard" || arg === "copy") {
209
+ return usage("Use /dump to copy the session to clipboard.", runtime);
210
+ }
211
+ try {
212
+ const filePath = await runtime.session.exportToHtml(arg || undefined);
213
+ await runtime.output(`Session exported to: ${filePath}`);
214
+ return commandConsumed();
215
+ } catch (err) {
216
+ return usage(`Failed to export session: ${errorMessage(err)}`, runtime);
217
+ }
218
+ },
219
+ handleTui: async (command, runtime) => {
186
220
  await runtime.ctx.handleExportCommand(command.text);
187
221
  runtime.ctx.editor.setText("");
188
222
  },
@@ -190,7 +224,13 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
190
224
  {
191
225
  name: "dump",
192
226
  description: "Copy session transcript to clipboard",
227
+ acpDescription: "Return full transcript as plain text",
193
228
  handle: async (_command, runtime) => {
229
+ const text = runtime.session.formatSessionAsText();
230
+ await runtime.output(text || "No messages to dump yet.");
231
+ return commandConsumed();
232
+ },
233
+ handleTui: async (_command, runtime) => {
194
234
  await runtime.ctx.handleDumpCommand();
195
235
  runtime.ctx.editor.setText("");
196
236
  },
@@ -199,6 +239,32 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
199
239
  name: "share",
200
240
  description: "Share session as a secret GitHub gist",
201
241
  handle: async (_command, runtime) => {
242
+ const tmpFile = path.join(os.tmpdir(), `${Snowflake.next()}.html`);
243
+ try {
244
+ try {
245
+ await runtime.session.exportToHtml(tmpFile);
246
+ } catch (err) {
247
+ return usage(`Failed to export session: ${errorMessage(err)}`, runtime);
248
+ }
249
+ const result = await $`gh gist create --public=false ${tmpFile}`.quiet().nothrow();
250
+ if (result.exitCode !== 0) {
251
+ return usage(
252
+ `Failed to create gist: ${result.stderr.toString("utf-8").trim() || "unknown error"}`,
253
+ runtime,
254
+ );
255
+ }
256
+ const gistUrl = result.stdout.toString("utf-8").trim();
257
+ const gistId = gistUrl.split("/").pop();
258
+ if (!gistId) return usage("Failed to parse gist ID from gh output", runtime);
259
+ await runtime.output(`Share URL: https://gistpreview.github.io/?${gistId}\nGist: ${gistUrl}`);
260
+ return commandConsumed();
261
+ } catch {
262
+ return usage("GitHub CLI (gh) is required for /share. Install it from https://cli.github.com/.", runtime);
263
+ } finally {
264
+ await fs.rm(tmpFile, { force: true }).catch(() => {});
265
+ }
266
+ },
267
+ handleTui: async (_command, runtime) => {
202
268
  await runtime.ctx.handleShareCommand();
203
269
  runtime.ctx.editor.setText("");
204
270
  },
@@ -206,12 +272,40 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
206
272
  {
207
273
  name: "browser",
208
274
  description: "Toggle browser headless vs visible mode",
275
+ acpInputHint: "[headless|visible]",
209
276
  subcommands: [
210
277
  { name: "headless", description: "Switch to headless mode" },
211
278
  { name: "visible", description: "Switch to visible mode" },
212
279
  ],
213
280
  allowArgs: true,
214
281
  handle: async (command, runtime) => {
282
+ const arg = command.args.toLowerCase();
283
+ const enabled = runtime.settings.get("browser.enabled" as SettingPath) as boolean;
284
+ if (!enabled) return usage("Browser tool is disabled (enable in settings).", runtime);
285
+ const current = runtime.settings.get("browser.headless" as SettingPath) as boolean;
286
+ let next = current;
287
+ if (!arg) next = !current;
288
+ else if (arg === "headless" || arg === "hidden") next = true;
289
+ else if (arg === "visible" || arg === "show" || arg === "headful") next = false;
290
+ else return usage("Usage: /browser [headless|visible]", runtime);
291
+ runtime.settings.set("browser.headless" as SettingPath, next as SettingValue<SettingPath>);
292
+ const tool = runtime.session.getToolByName("browser");
293
+ if (tool && "restartForModeChange" in tool) {
294
+ try {
295
+ await (tool as { restartForModeChange: () => Promise<void> }).restartForModeChange();
296
+ } catch (err) {
297
+ // Setting was already mutated; surface the restart failure so the
298
+ // user knows the browser is in an inconsistent state.
299
+ await runtime.output(
300
+ `Browser mode set to ${next ? "headless" : "visible"}, but restart failed: ${errorMessage(err)}`,
301
+ );
302
+ return commandConsumed();
303
+ }
304
+ }
305
+ await runtime.output(`Browser mode: ${next ? "headless" : "visible"}`);
306
+ return commandConsumed();
307
+ },
308
+ handleTui: async (command, runtime) => {
215
309
  const arg = command.args.toLowerCase();
216
310
  const current = settings.get("browser.headless" as SettingPath) as boolean;
217
311
  let next = current;
@@ -222,9 +316,9 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
222
316
  }
223
317
  if (!arg) {
224
318
  next = !current;
225
- } else if (["headless", "hidden"].includes(arg)) {
319
+ } else if (arg === "headless" || arg === "hidden") {
226
320
  next = true;
227
- } else if (["visible", "show", "headful"].includes(arg)) {
321
+ } else if (arg === "visible" || arg === "show" || arg === "headful") {
228
322
  next = false;
229
323
  } else {
230
324
  runtime.ctx.showStatus("Usage: /browser [headless|visible]");
@@ -237,9 +331,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
237
331
  try {
238
332
  await (tool as { restartForModeChange: () => Promise<void> }).restartForModeChange();
239
333
  } catch (error) {
240
- runtime.ctx.showWarning(
241
- `Failed to restart browser: ${error instanceof Error ? error.message : String(error)}`,
242
- );
334
+ runtime.ctx.showWarning(`Failed to restart browser: ${errorMessage(error)}`);
243
335
  runtime.ctx.editor.setText("");
244
336
  return;
245
337
  }
@@ -258,7 +350,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
258
350
  { name: "cmd", description: "Copy last bash/python command" },
259
351
  ],
260
352
  allowArgs: true,
261
- handle: async (command, runtime) => {
353
+ handleTui: async (command, runtime) => {
262
354
  const sub = command.args.trim().toLowerCase() || undefined;
263
355
  await runtime.ctx.handleCopyCommand(sub);
264
356
  runtime.ctx.editor.setText("");
@@ -267,6 +359,8 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
267
359
  {
268
360
  name: "todo",
269
361
  description: "View or modify the agent's todo list",
362
+ acpDescription: "Manage todos",
363
+ acpInputHint: "<subcommand>",
270
364
  subcommands: [
271
365
  { name: "edit", description: "Open todos in $EDITOR (Markdown round-trip)" },
272
366
  { name: "copy", description: "Copy todos as Markdown to clipboard" },
@@ -283,7 +377,8 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
283
377
  { name: "rm", description: "Remove task/phase/all (fuzzy-matched)", usage: "[<task|phase>]" },
284
378
  ],
285
379
  allowArgs: true,
286
- handle: async (command, runtime) => {
380
+ handle: handleTodoAcp,
381
+ handleTui: async (command, runtime) => {
287
382
  await runtime.ctx.handleTodoCommand(command.args);
288
383
  runtime.ctx.editor.setText("");
289
384
  },
@@ -291,12 +386,46 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
291
386
  {
292
387
  name: "session",
293
388
  description: "Session management commands",
389
+ acpDescription: "Show session information",
390
+ acpInputHint: "info|delete",
294
391
  subcommands: [
295
392
  { name: "info", description: "Show session info and stats" },
296
393
  { name: "delete", description: "Delete current session and return to selector" },
297
394
  ],
298
395
  allowArgs: true,
299
396
  handle: async (command, runtime) => {
397
+ if (!command.args || command.args === "info") {
398
+ await runtime.output(
399
+ [
400
+ `Session: ${runtime.session.sessionId}`,
401
+ `Title: ${runtime.session.sessionName}`,
402
+ `CWD: ${runtime.cwd}`,
403
+ ].join("\n"),
404
+ );
405
+ return commandConsumed();
406
+ }
407
+ if (command.args === "delete") {
408
+ if (runtime.session.isStreaming) return usage("Cannot delete the session while streaming.", runtime);
409
+ const sessionFile = runtime.sessionManager.getSessionFile();
410
+ if (!sessionFile) return usage("No session file to delete (in-memory session).", runtime);
411
+ // Route through the active SessionManager so the persist writer is
412
+ // closed before the file is deleted. Constructing a fresh
413
+ // FileSessionStorage and calling deleteSessionWithArtifacts leaves
414
+ // the active writer attached to the now-deleted path, so the next
415
+ // prompt would silently resurrect or corrupt the "deleted" file.
416
+ try {
417
+ await runtime.sessionManager.dropSession(sessionFile);
418
+ } catch (err) {
419
+ return usage(`Failed to delete session: ${errorMessage(err)}`, runtime);
420
+ }
421
+ await runtime.output(
422
+ `Session deleted: ${sessionFile}. Use ACP \`session/load\` to switch to another session.`,
423
+ );
424
+ return commandConsumed();
425
+ }
426
+ return usage("Usage: /session [info|delete]", runtime);
427
+ },
428
+ handleTui: async (command, runtime) => {
300
429
  const sub = command.args.trim().toLowerCase() || "info";
301
430
  if (sub === "delete") {
302
431
  runtime.ctx.editor.setText("");
@@ -311,7 +440,35 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
311
440
  {
312
441
  name: "jobs",
313
442
  description: "Show async background jobs status",
443
+ acpDescription: "Show background jobs",
314
444
  handle: async (_command, runtime) => {
445
+ const snapshot = runtime.session.getAsyncJobSnapshot({ recentLimit: 5 });
446
+ if (!snapshot || (snapshot.running.length === 0 && snapshot.recent.length === 0)) {
447
+ await runtime.output(
448
+ "No background jobs running. (Background jobs run async tools — e.g. long-running bash, debug, or task subagents that would otherwise tie up a turn. They appear here while alive and for ~5 minutes after.)",
449
+ );
450
+ return commandConsumed();
451
+ }
452
+ const now = Date.now();
453
+ const lines: string[] = ["Background Jobs", `Running: ${snapshot.running.length}`];
454
+ if (snapshot.running.length > 0) {
455
+ lines.push("", "Running Jobs");
456
+ for (const job of snapshot.running) {
457
+ lines.push(` [${job.id}] ${job.type} (${job.status}) — ${formatDuration(now - job.startTime)}`);
458
+ lines.push(` ${job.label}`);
459
+ }
460
+ }
461
+ if (snapshot.recent.length > 0) {
462
+ lines.push("", "Recent Jobs");
463
+ for (const job of snapshot.recent) {
464
+ lines.push(` [${job.id}] ${job.type} (${job.status}) — ${formatDuration(now - job.startTime)}`);
465
+ lines.push(` ${job.label}`);
466
+ }
467
+ }
468
+ await runtime.output(lines.join("\n"));
469
+ return commandConsumed();
470
+ },
471
+ handleTui: async (_command, runtime) => {
315
472
  await runtime.ctx.handleJobsCommand();
316
473
  runtime.ctx.editor.setText("");
317
474
  },
@@ -319,7 +476,12 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
319
476
  {
320
477
  name: "usage",
321
478
  description: "Show provider usage and limits",
479
+ acpDescription: "Show token usage",
322
480
  handle: async (_command, runtime) => {
481
+ await runtime.output(await buildUsageReportText(runtime));
482
+ return commandConsumed();
483
+ },
484
+ handleTui: async (_command, runtime) => {
323
485
  await runtime.ctx.handleUsageCommand();
324
486
  runtime.ctx.editor.setText("");
325
487
  },
@@ -327,9 +489,28 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
327
489
  {
328
490
  name: "changelog",
329
491
  description: "Show changelog entries",
492
+ acpDescription: "Show changelog",
493
+ acpInputHint: "[full]",
330
494
  subcommands: [{ name: "full", description: "Show complete changelog" }],
331
495
  allowArgs: true,
332
496
  handle: async (command, runtime) => {
497
+ const changelogPath = getChangelogPath();
498
+ const allEntries = await parseChangelog(changelogPath);
499
+ const showFull = command.args.trim().toLowerCase() === "full";
500
+ const entriesToShow = showFull ? allEntries : allEntries.slice(0, 3);
501
+ if (entriesToShow.length === 0) {
502
+ await runtime.output("No changelog entries found.");
503
+ return commandConsumed();
504
+ }
505
+ await runtime.output(
506
+ [...entriesToShow]
507
+ .reverse()
508
+ .map(entry => entry.content)
509
+ .join("\n\n"),
510
+ );
511
+ return commandConsumed();
512
+ },
513
+ handleTui: async (command, runtime) => {
333
514
  const showFull = command.args.split(/\s+/).filter(Boolean).includes("full");
334
515
  await runtime.ctx.handleChangelogCommand(showFull);
335
516
  runtime.ctx.editor.setText("");
@@ -338,7 +519,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
338
519
  {
339
520
  name: "hotkeys",
340
521
  description: "Show all keyboard shortcuts",
341
- handle: (_command, runtime) => {
522
+ handleTui: (_command, runtime) => {
342
523
  runtime.ctx.handleHotkeysCommand();
343
524
  runtime.ctx.editor.setText("");
344
525
  },
@@ -346,7 +527,18 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
346
527
  {
347
528
  name: "tools",
348
529
  description: "Show tools currently visible to the agent",
349
- handle: (_command, runtime) => {
530
+ acpDescription: "Show available tools",
531
+ handle: async (_command, runtime) => {
532
+ const active = runtime.session.getActiveToolNames();
533
+ const all = runtime.session.getAllToolNames();
534
+ if (all.length === 0) {
535
+ await runtime.output("No tools are available.");
536
+ return commandConsumed();
537
+ }
538
+ await runtime.output(all.map(name => `${active.includes(name) ? "*" : "-"} ${name}`).join("\n"));
539
+ return commandConsumed();
540
+ },
541
+ handleTui: (_command, runtime) => {
350
542
  runtime.ctx.handleToolsCommand();
351
543
  runtime.ctx.editor.setText("");
352
544
  },
@@ -354,7 +546,12 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
354
546
  {
355
547
  name: "context",
356
548
  description: "Show estimated context usage breakdown",
357
- handle: (_command, runtime) => {
549
+ acpDescription: "Show context usage",
550
+ handle: async (_command, runtime) => {
551
+ await runtime.output(buildContextReportText(runtime));
552
+ return commandConsumed();
553
+ },
554
+ handleTui: (_command, runtime) => {
358
555
  runtime.ctx.handleContextCommand();
359
556
  runtime.ctx.editor.setText("");
360
557
  },
@@ -363,7 +560,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
363
560
  name: "extensions",
364
561
  aliases: ["status"],
365
562
  description: "Open Extension Control Center dashboard",
366
- handle: (_command, runtime) => {
563
+ handleTui: (_command, runtime) => {
367
564
  runtime.ctx.showExtensionsDashboard();
368
565
  runtime.ctx.editor.setText("");
369
566
  },
@@ -371,7 +568,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
371
568
  {
372
569
  name: "agents",
373
570
  description: "Open Agent Control Center dashboard",
374
- handle: (_command, runtime) => {
571
+ handleTui: (_command, runtime) => {
375
572
  runtime.ctx.showAgentsDashboard();
376
573
  runtime.ctx.editor.setText("");
377
574
  },
@@ -379,7 +576,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
379
576
  {
380
577
  name: "branch",
381
578
  description: "Create a new branch from a previous message",
382
- handle: (_command, runtime) => {
579
+ handleTui: (_command, runtime) => {
383
580
  if (settings.get("doubleEscapeAction") === "tree") {
384
581
  runtime.ctx.showTreeSelector();
385
582
  } else {
@@ -391,7 +588,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
391
588
  {
392
589
  name: "fork",
393
590
  description: "Create a new fork from a previous message",
394
- handle: async (_command, runtime) => {
591
+ handleTui: async (_command, runtime) => {
395
592
  runtime.ctx.editor.setText("");
396
593
  await runtime.ctx.handleForkCommand();
397
594
  },
@@ -399,7 +596,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
399
596
  {
400
597
  name: "tree",
401
598
  description: "Navigate session tree (switch branches)",
402
- handle: (_command, runtime) => {
599
+ handleTui: (_command, runtime) => {
403
600
  runtime.ctx.showTreeSelector();
404
601
  runtime.ctx.editor.setText("");
405
602
  },
@@ -409,7 +606,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
409
606
  description: "Login with OAuth provider",
410
607
  inlineHint: "[provider|redirect URL]",
411
608
  allowArgs: true,
412
- handle: (command, runtime) => {
609
+ handleTui: (command, runtime) => {
413
610
  const manualInput = runtime.ctx.oauthManualInput;
414
611
  const args = command.args.trim();
415
612
  if (args.length > 0) {
@@ -455,7 +652,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
455
652
  {
456
653
  name: "logout",
457
654
  description: "Logout from OAuth provider",
458
- handle: (_command, runtime) => {
655
+ handleTui: (_command, runtime) => {
459
656
  void runtime.ctx.showOAuthSelector("logout");
460
657
  runtime.ctx.editor.setText("");
461
658
  },
@@ -463,6 +660,8 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
463
660
  {
464
661
  name: "mcp",
465
662
  description: "Manage MCP servers (add, list, remove, test)",
663
+ acpDescription: "Manage MCP servers",
664
+ inlineHint: "<subcommand>",
466
665
  subcommands: [
467
666
  {
468
667
  name: "add",
@@ -491,7 +690,8 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
491
690
  { name: "help", description: "Show help message" },
492
691
  ],
493
692
  allowArgs: true,
494
- handle: async (command, runtime) => {
693
+ handle: handleMcpAcp,
694
+ handleTui: async (command, runtime) => {
495
695
  runtime.ctx.editor.addToHistory(command.text);
496
696
  runtime.ctx.editor.setText("");
497
697
  await runtime.ctx.handleMCPCommand(command.text);
@@ -500,6 +700,8 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
500
700
  {
501
701
  name: "ssh",
502
702
  description: "Manage SSH hosts (add, list, remove)",
703
+ acpDescription: "Manage SSH connections",
704
+ inlineHint: "<subcommand>",
503
705
  subcommands: [
504
706
  {
505
707
  name: "add",
@@ -511,7 +713,8 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
511
713
  { name: "help", description: "Show help message" },
512
714
  ],
513
715
  allowArgs: true,
514
- handle: async (command, runtime) => {
716
+ handle: handleSshAcp,
717
+ handleTui: async (command, runtime) => {
515
718
  runtime.ctx.editor.addToHistory(command.text);
516
719
  runtime.ctx.editor.setText("");
517
720
  await runtime.ctx.handleSSHCommand(command.text);
@@ -520,7 +723,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
520
723
  {
521
724
  name: "new",
522
725
  description: "Start a new session",
523
- handle: async (_command, runtime) => {
726
+ handleTui: async (_command, runtime) => {
524
727
  runtime.ctx.editor.setText("");
525
728
  await runtime.ctx.handleClearCommand();
526
729
  },
@@ -528,7 +731,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
528
731
  {
529
732
  name: "drop",
530
733
  description: "Delete the current session and start a new one",
531
- handle: async (_command, runtime) => {
734
+ handleTui: async (_command, runtime) => {
532
735
  runtime.ctx.editor.setText("");
533
736
  await runtime.ctx.handleDropCommand();
534
737
  },
@@ -536,9 +739,31 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
536
739
  {
537
740
  name: "compact",
538
741
  description: "Manually compact the session context",
742
+ acpDescription: "Compact the conversation",
539
743
  inlineHint: "[focus instructions]",
540
744
  allowArgs: true,
541
745
  handle: async (command, runtime) => {
746
+ const before = runtime.session.getContextUsage?.();
747
+ const beforeTokens = before?.tokens;
748
+ try {
749
+ await runtime.session.compact(command.args || undefined);
750
+ } catch (err) {
751
+ // Compaction precondition failures (no model, already compacted, too
752
+ // small) and provider errors propagate as plain Errors; surface them
753
+ // via runtime.output so they don't fail the ACP prompt turn.
754
+ return usage(`Compaction failed: ${errorMessage(err)}`, runtime);
755
+ }
756
+ const after = runtime.session.getContextUsage?.();
757
+ const afterTokens = after?.tokens;
758
+ if (beforeTokens != null && afterTokens != null) {
759
+ const saved = beforeTokens - afterTokens;
760
+ await runtime.output(`Compaction complete. Tokens: ${beforeTokens} -> ${afterTokens} (saved ${saved}).`);
761
+ } else {
762
+ await runtime.output("Compaction complete.");
763
+ }
764
+ return commandConsumed();
765
+ },
766
+ handleTui: async (command, runtime) => {
542
767
  const customInstructions = command.args || undefined;
543
768
  runtime.ctx.editor.setText("");
544
769
  await runtime.ctx.handleCompactCommand(customInstructions);
@@ -549,7 +774,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
549
774
  description: "Hand off session context to a new session",
550
775
  inlineHint: "[focus instructions]",
551
776
  allowArgs: true,
552
- handle: async (command, runtime) => {
777
+ handleTui: async (command, runtime) => {
553
778
  const customInstructions = command.args || undefined;
554
779
  runtime.ctx.editor.setText("");
555
780
  await runtime.ctx.handleHandoffCommand(customInstructions);
@@ -558,7 +783,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
558
783
  {
559
784
  name: "resume",
560
785
  description: "Resume a different session",
561
- handle: (_command, runtime) => {
786
+ handleTui: (_command, runtime) => {
562
787
  runtime.ctx.showSessionSelector();
563
788
  runtime.ctx.editor.setText("");
564
789
  },
@@ -568,7 +793,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
568
793
  description: "Ask an ephemeral side question using the current session context",
569
794
  inlineHint: "<question>",
570
795
  allowArgs: true,
571
- handle: async (command, runtime) => {
796
+ handleTui: async (command, runtime) => {
572
797
  const question = command.text.slice(`/${command.name}`.length).trim();
573
798
  runtime.ctx.editor.setText("");
574
799
  await runtime.ctx.handleBtwCommand(question);
@@ -577,7 +802,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
577
802
  {
578
803
  name: "retry",
579
804
  description: "Retry the last failed agent turn",
580
- handle: async (_command, runtime) => {
805
+ handleTui: async (_command, runtime) => {
581
806
  const didRetry = await runtime.ctx.session.retry();
582
807
  if (!didRetry) {
583
808
  runtime.ctx.showStatus("Nothing to retry");
@@ -589,7 +814,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
589
814
  name: "background",
590
815
  aliases: ["bg"],
591
816
  description: "Detach UI and continue running in background",
592
- handle: (_command, runtime) => {
817
+ handleTui: (_command, runtime) => {
593
818
  runtime.ctx.editor.setText("");
594
819
  runtime.handleBackgroundCommand();
595
820
  },
@@ -597,7 +822,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
597
822
  {
598
823
  name: "debug",
599
824
  description: "Open debug tools selector",
600
- handle: (_command, runtime) => {
825
+ handleTui: (_command, runtime) => {
601
826
  runtime.ctx.showDebugSelector();
602
827
  runtime.ctx.editor.setText("");
603
828
  },
@@ -605,6 +830,8 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
605
830
  {
606
831
  name: "memory",
607
832
  description: "Inspect and operate memory maintenance",
833
+ acpDescription: "Manage memory",
834
+ acpInputHint: "<subcommand>",
608
835
  subcommands: [
609
836
  { name: "view", description: "Show current memory injection payload" },
610
837
  { name: "clear", description: "Clear persisted memory data and artifacts" },
@@ -624,6 +851,41 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
624
851
  ],
625
852
  allowArgs: true,
626
853
  handle: async (command, runtime) => {
854
+ const verb = (command.args.trim().split(/\s+/)[0] ?? "").toLowerCase() || "view";
855
+ const backend = resolveMemoryBackend(runtime.settings);
856
+ switch (verb) {
857
+ case "view": {
858
+ const payload = await backend.buildDeveloperInstructions(
859
+ runtime.settings.getAgentDir(),
860
+ runtime.settings,
861
+ runtime.session,
862
+ );
863
+ await runtime.output(payload || "Memory payload is empty.");
864
+ return commandConsumed();
865
+ }
866
+ case "clear":
867
+ case "reset": {
868
+ await backend.clear(runtime.settings.getAgentDir(), runtime.cwd, runtime.session);
869
+ await runtime.session.refreshBaseSystemPrompt();
870
+ await runtime.output("Memory cleared.");
871
+ return commandConsumed();
872
+ }
873
+ case "enqueue":
874
+ case "rebuild": {
875
+ await backend.enqueue(runtime.settings.getAgentDir(), runtime.cwd, runtime.session);
876
+ await runtime.output("Memory consolidation enqueued.");
877
+ return commandConsumed();
878
+ }
879
+ case "mm":
880
+ return usage(
881
+ "Mental-model maintenance via /memory mm is unsupported in ACP mode; use the hindsight HTTP API directly.",
882
+ runtime,
883
+ );
884
+ default:
885
+ return usage("Usage: /memory <view|clear|reset|enqueue|rebuild>", runtime);
886
+ }
887
+ },
888
+ handleTui: async (command, runtime) => {
627
889
  runtime.ctx.editor.setText("");
628
890
  await runtime.ctx.handleMemoryCommand(command.text);
629
891
  },
@@ -634,6 +896,17 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
634
896
  inlineHint: "<title>",
635
897
  allowArgs: true,
636
898
  handle: async (command, runtime) => {
899
+ if (!command.args) return usage("Usage: /rename <title>", runtime);
900
+ const ok = await runtime.sessionManager.setSessionName(command.args, "user");
901
+ if (!ok) {
902
+ await runtime.output("Session name not changed (a user-set name takes precedence).");
903
+ return commandConsumed();
904
+ }
905
+ await runtime.notifyTitleChanged?.();
906
+ await runtime.output(`Session renamed to ${command.args}.`);
907
+ return commandConsumed();
908
+ },
909
+ handleTui: async (command, runtime) => {
637
910
  const title = command.args.trim();
638
911
  if (!title) {
639
912
  runtime.ctx.showError("Usage: /rename <title>");
@@ -644,13 +917,38 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
644
917
  await runtime.ctx.handleRenameCommand(title);
645
918
  },
646
919
  },
647
-
648
920
  {
649
921
  name: "move",
650
922
  description: "Move session to a different working directory",
923
+ acpDescription: "Move the current session file",
651
924
  inlineHint: "<path>",
652
925
  allowArgs: true,
653
926
  handle: async (command, runtime) => {
927
+ if (runtime.session.isStreaming) return usage("Cannot move while streaming.", runtime);
928
+ if (!command.args) return usage("Usage: /move <path>", runtime);
929
+ const resolvedPath = path.resolve(runtime.cwd, command.args);
930
+ let isDirectory: boolean;
931
+ try {
932
+ isDirectory = (await fs.stat(resolvedPath)).isDirectory();
933
+ } catch {
934
+ return usage(`Directory does not exist or is not a directory: ${resolvedPath}`, runtime);
935
+ }
936
+ if (!isDirectory) return usage(`Directory does not exist or is not a directory: ${resolvedPath}`, runtime);
937
+ try {
938
+ await runtime.sessionManager.flush();
939
+ await runtime.sessionManager.moveTo(resolvedPath);
940
+ } catch (err) {
941
+ return usage(`Move failed: ${errorMessage(err)}`, runtime);
942
+ }
943
+ setProjectDir(resolvedPath);
944
+ // Reload plugin/capability caches so the next prompt sees commands and
945
+ // capabilities scoped to the new cwd.
946
+ await runtime.reloadPlugins();
947
+ await runtime.notifyTitleChanged?.();
948
+ await runtime.output(`Session moved to ${runtime.sessionManager.getCwd()}.`);
949
+ return commandConsumed();
950
+ },
951
+ handleTui: async (command, runtime) => {
654
952
  const targetPath = command.args;
655
953
  if (!targetPath) {
656
954
  runtime.ctx.showError("Usage: /move <path>");
@@ -664,11 +962,13 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
664
962
  {
665
963
  name: "exit",
666
964
  description: "Exit the application",
667
- handle: shutdownHandler,
965
+ handleTui: shutdownHandlerTui,
668
966
  },
669
967
  {
670
968
  name: "marketplace",
671
969
  description: "Manage marketplace plugin sources and installed plugins",
970
+ acpDescription: "Manage plugins from marketplaces",
971
+ acpInputHint: "<subcommand>",
672
972
  subcommands: [
673
973
  { name: "add", description: "Add a marketplace source", usage: "<source>" },
674
974
  { name: "remove", description: "Remove a marketplace source", usage: "<name>" },
@@ -687,6 +987,175 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
687
987
  ],
688
988
  allowArgs: true,
689
989
  handle: async (command, runtime) => {
990
+ const { verb, rest } = parseSubcommand(command.args);
991
+ if (!verb) {
992
+ try {
993
+ const manager = await createMarketplaceManager(runtime);
994
+ const marketplaces = await manager.listMarketplaces();
995
+ if (marketplaces.length === 0) {
996
+ await runtime.output(
997
+ "No marketplaces configured.\n\nGet started:\n /marketplace add anthropics/claude-plugins-official\n\nThen browse with /marketplace discover",
998
+ );
999
+ } else {
1000
+ const lines = marketplaces.map(m => ` ${m.name} ${m.sourceUri}`);
1001
+ await runtime.output(
1002
+ `Marketplaces:\n${lines.join("\n")}\n\nUse /marketplace discover to browse plugins, or /marketplace help for all commands`,
1003
+ );
1004
+ }
1005
+ return commandConsumed();
1006
+ } catch (err) {
1007
+ return usage(`Marketplace error: ${errorMessage(err)}`, runtime);
1008
+ }
1009
+ }
1010
+ if (verb === "help") {
1011
+ await runtime.output(
1012
+ [
1013
+ "Marketplace commands:",
1014
+ " /marketplace List configured marketplaces",
1015
+ " /marketplace add <source> Add a marketplace (e.g. owner/repo)",
1016
+ " /marketplace remove <name> Remove a marketplace",
1017
+ " /marketplace update [name] Re-fetch catalog(s)",
1018
+ " /marketplace list List configured marketplaces",
1019
+ " /marketplace discover [marketplace] Browse available plugins",
1020
+ " /marketplace install <name@marketplace> Install a plugin",
1021
+ " /marketplace uninstall <name@marketplace> Uninstall a plugin",
1022
+ " /marketplace installed List installed plugins",
1023
+ " /marketplace upgrade [name@marketplace] Upgrade plugin(s)",
1024
+ "",
1025
+ "Quick start:",
1026
+ " /marketplace add anthropics/claude-plugins-official",
1027
+ ].join("\n"),
1028
+ );
1029
+ return commandConsumed();
1030
+ }
1031
+ if ((verb === "install" || verb === "uninstall") && !rest) {
1032
+ return usage(
1033
+ "Interactive plugin pickers are TUI-only. Pass an explicit name@marketplace argument.",
1034
+ runtime,
1035
+ );
1036
+ }
1037
+ try {
1038
+ const manager = await createMarketplaceManager(runtime);
1039
+ switch (verb) {
1040
+ case "add": {
1041
+ if (!rest) return usage("Usage: /marketplace add <source>", runtime);
1042
+ const entry = await manager.addMarketplace(rest);
1043
+ await runtime.output(`Added marketplace: ${entry.name}`);
1044
+ return commandConsumed();
1045
+ }
1046
+ case "remove":
1047
+ case "rm": {
1048
+ if (!rest) return usage("Usage: /marketplace remove <name>", runtime);
1049
+ await manager.removeMarketplace(rest);
1050
+ await runtime.output(`Removed marketplace: ${rest}`);
1051
+ return commandConsumed();
1052
+ }
1053
+ case "update": {
1054
+ if (rest) {
1055
+ await manager.updateMarketplace(rest);
1056
+ await runtime.output(`Updated marketplace: ${rest}`);
1057
+ } else {
1058
+ const results = await manager.updateAllMarketplaces();
1059
+ await runtime.output(`Updated ${results.length} marketplace(s)`);
1060
+ }
1061
+ return commandConsumed();
1062
+ }
1063
+ case "list": {
1064
+ const marketplaces = await manager.listMarketplaces();
1065
+ if (marketplaces.length === 0) {
1066
+ await runtime.output("No marketplaces configured.");
1067
+ } else {
1068
+ const lines = marketplaces.map(m => ` ${m.name} ${m.sourceUri}`);
1069
+ await runtime.output(`Marketplaces:\n${lines.join("\n")}`);
1070
+ }
1071
+ return commandConsumed();
1072
+ }
1073
+ case "discover": {
1074
+ const plugins = await manager.listAvailablePlugins(rest || undefined);
1075
+ if (plugins.length === 0) {
1076
+ const marketplaces = await manager.listMarketplaces();
1077
+ await runtime.output(
1078
+ marketplaces.length === 0
1079
+ ? "No marketplaces configured. Try:\n /marketplace add anthropics/claude-plugins-official"
1080
+ : "No plugins available in configured marketplaces",
1081
+ );
1082
+ return commandConsumed();
1083
+ }
1084
+ const lines = ["Available plugins:"];
1085
+ for (const plugin of plugins) {
1086
+ lines.push(` - ${plugin.name}${plugin.version ? `@${plugin.version}` : ""}`);
1087
+ if (plugin.description) lines.push(` ${plugin.description}`);
1088
+ }
1089
+ await runtime.output(lines.join("\n"));
1090
+ return commandConsumed();
1091
+ }
1092
+ case "install": {
1093
+ const parsed = parseMarketplaceInstallArgs(rest);
1094
+ if ("error" in parsed) return usage(parsed.error, runtime);
1095
+ const atIndex = parsed.installSpec.lastIndexOf("@");
1096
+ const pluginName = parsed.installSpec.slice(0, atIndex);
1097
+ const marketplace = parsed.installSpec.slice(atIndex + 1);
1098
+ await manager.installPlugin(pluginName, marketplace, { force: parsed.force, scope: parsed.scope });
1099
+ await runtime.reloadPlugins();
1100
+ await runtime.output(`Installed ${pluginName} from ${marketplace}`);
1101
+ return commandConsumed();
1102
+ }
1103
+ case "uninstall": {
1104
+ const parsed = parsePluginScopeArgs(
1105
+ rest,
1106
+ "Usage: /marketplace uninstall [--scope user|project] <name@marketplace>",
1107
+ );
1108
+ if ("error" in parsed) return usage(parsed.error, runtime);
1109
+ await manager.uninstallPlugin(parsed.pluginId, parsed.scope);
1110
+ await runtime.reloadPlugins();
1111
+ await runtime.output(`Uninstalled ${parsed.pluginId}`);
1112
+ return commandConsumed();
1113
+ }
1114
+ case "installed": {
1115
+ const installed = await manager.listInstalledPlugins();
1116
+ if (installed.length === 0) {
1117
+ await runtime.output("No marketplace plugins installed");
1118
+ } else {
1119
+ const lines = installed.map(
1120
+ p => ` ${p.id} [${p.scope}]${p.shadowedBy ? " [shadowed]" : ""} (${p.entries.length} entry)`,
1121
+ );
1122
+ await runtime.output(`Installed plugins:\n${lines.join("\n")}`);
1123
+ }
1124
+ return commandConsumed();
1125
+ }
1126
+ case "upgrade": {
1127
+ if (rest) {
1128
+ const parsed = parsePluginScopeArgs(
1129
+ rest,
1130
+ "Usage: /marketplace upgrade [--scope user|project] <name@marketplace>",
1131
+ );
1132
+ if ("error" in parsed) return usage(parsed.error, runtime);
1133
+ const result = await manager.upgradePlugin(parsed.pluginId, parsed.scope);
1134
+ await runtime.reloadPlugins();
1135
+ await runtime.output(`Upgraded ${parsed.pluginId} to ${result.version}`);
1136
+ return commandConsumed();
1137
+ }
1138
+ const results = await manager.upgradeAllPlugins();
1139
+ if (results.length === 0) {
1140
+ await runtime.output("All marketplace plugins are up to date");
1141
+ } else {
1142
+ await runtime.reloadPlugins();
1143
+ const lines = results.map(r => ` ${r.pluginId}: ${r.from} -> ${r.to}`);
1144
+ await runtime.output(`Upgraded ${results.length} plugin(s):\n${lines.join("\n")}`);
1145
+ }
1146
+ return commandConsumed();
1147
+ }
1148
+ default:
1149
+ return usage(
1150
+ `Unknown /marketplace subcommand: ${verb}. Use /marketplace help for available commands.`,
1151
+ runtime,
1152
+ );
1153
+ }
1154
+ } catch (err) {
1155
+ return usage(`Marketplace error: ${errorMessage(err)}`, runtime);
1156
+ }
1157
+ },
1158
+ handleTui: async (command, runtime) => {
690
1159
  runtime.ctx.editor.setText("");
691
1160
  const args = command.args.trim().split(/\s+/);
692
1161
  const sub = args[0] || "install";
@@ -877,6 +1346,8 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
877
1346
  {
878
1347
  name: "plugins",
879
1348
  description: "View and manage installed plugins",
1349
+ acpDescription: "Manage plugins",
1350
+ acpInputHint: "[list|enable|disable]",
880
1351
  subcommands: [
881
1352
  { name: "list", description: "List all installed plugins (npm + marketplace)" },
882
1353
  { name: "enable", description: "Enable a marketplace plugin", usage: "<name@marketplace>" },
@@ -884,6 +1355,53 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
884
1355
  ],
885
1356
  allowArgs: true,
886
1357
  handle: async (command, runtime) => {
1358
+ const { verb, rest } = parseSubcommand(command.args);
1359
+ try {
1360
+ if (verb === "enable" || verb === "disable") {
1361
+ const parsed = parsePluginScopeArgs(
1362
+ rest,
1363
+ `Usage: /plugins ${verb} [--scope user|project] <name@marketplace>`,
1364
+ );
1365
+ if ("error" in parsed) return usage(parsed.error, runtime);
1366
+ const manager = await createMarketplaceManager(runtime);
1367
+ const isEnable = verb === "enable";
1368
+ await manager.setPluginEnabled(parsed.pluginId, isEnable, parsed.scope);
1369
+ await runtime.reloadPlugins();
1370
+ await runtime.output(`${isEnable ? "Enabled" : "Disabled"} ${parsed.pluginId}`);
1371
+ return commandConsumed();
1372
+ }
1373
+ // Default: list
1374
+ const lines: string[] = [];
1375
+ const npmManager = new PluginManager();
1376
+ const npmPlugins = await npmManager.list();
1377
+ if (npmPlugins.length > 0) {
1378
+ lines.push("npm plugins:");
1379
+ for (const plugin of npmPlugins) {
1380
+ const status = plugin.enabled === false ? " (disabled)" : "";
1381
+ lines.push(` ${plugin.name}@${plugin.version}${status}`);
1382
+ }
1383
+ }
1384
+
1385
+ const marketplaceManager = await createMarketplaceManager(runtime);
1386
+ const marketplacePlugins = await marketplaceManager.listInstalledPlugins();
1387
+ if (marketplacePlugins.length > 0) {
1388
+ if (lines.length > 0) lines.push("");
1389
+ lines.push("marketplace plugins:");
1390
+ for (const plugin of marketplacePlugins) {
1391
+ const entry = plugin.entries[0];
1392
+ const status = entry?.enabled === false ? " (disabled)" : "";
1393
+ const shadowed = plugin.shadowedBy ? " [shadowed]" : "";
1394
+ lines.push(` ${plugin.id} v${entry?.version ?? "?"}${status} [${plugin.scope}]${shadowed}`);
1395
+ }
1396
+ }
1397
+
1398
+ await runtime.output(lines.length === 0 ? "No plugins installed" : lines.join("\n"));
1399
+ return commandConsumed();
1400
+ } catch (err) {
1401
+ return usage(`Plugin error: ${errorMessage(err)}`, runtime);
1402
+ }
1403
+ },
1404
+ handleTui: async (command, runtime) => {
887
1405
  runtime.ctx.editor.setText("");
888
1406
  const args = command.args.trim().split(/\s+/);
889
1407
  const sub = args[0] || "list";
@@ -958,7 +1476,13 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
958
1476
  {
959
1477
  name: "reload-plugins",
960
1478
  description: "Reload all plugins (skills, commands, hooks, tools, agents, MCP)",
1479
+ acpDescription: "Reload all plugins",
961
1480
  handle: async (_command, runtime) => {
1481
+ await runtime.reloadPlugins();
1482
+ await runtime.output("Plugins reloaded.");
1483
+ return commandConsumed();
1484
+ },
1485
+ handleTui: async (_command, runtime) => {
962
1486
  // Invalidate registry fs caches and the plugin roots cache so
963
1487
  // listClaudePluginRoots re-reads from disk on next access.
964
1488
  const projectPath = await resolveActiveProjectRegistryPath(runtime.ctx.sessionManager.getCwd());
@@ -971,9 +1495,23 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
971
1495
  {
972
1496
  name: "force",
973
1497
  description: "Force next turn to use a specific tool",
1498
+ aliases: ["force:"],
974
1499
  inlineHint: "<tool-name> [prompt]",
975
1500
  allowArgs: true,
976
- handle: (command, runtime) => {
1501
+ handle: async (command, runtime) => {
1502
+ const spaceIdx = command.args.indexOf(" ");
1503
+ const toolName = spaceIdx === -1 ? command.args : command.args.slice(0, spaceIdx);
1504
+ const prompt = spaceIdx === -1 ? "" : command.args.slice(spaceIdx + 1).trim();
1505
+ if (!toolName) return usage("Usage: /force:<tool-name> [prompt]", runtime);
1506
+ try {
1507
+ runtime.session.setForcedToolChoice(toolName);
1508
+ } catch (err) {
1509
+ return usage(errorMessage(err), runtime);
1510
+ }
1511
+ await runtime.output(`Next turn forced to use ${toolName}.`);
1512
+ return prompt ? { prompt } : commandConsumed();
1513
+ },
1514
+ handleTui: (command, runtime) => {
977
1515
  const spaceIdx = command.args.indexOf(" ");
978
1516
  const toolName = spaceIdx === -1 ? command.args : command.args.slice(0, spaceIdx);
979
1517
  const prompt = spaceIdx === -1 ? "" : command.args.slice(spaceIdx + 1).trim();
@@ -988,7 +1526,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
988
1526
  runtime.ctx.session.setForcedToolChoice(toolName);
989
1527
  runtime.ctx.showStatus(`Next turn forced to use ${toolName}.`);
990
1528
  } catch (error) {
991
- runtime.ctx.showError(error instanceof Error ? error.message : String(error));
1529
+ runtime.ctx.showError(errorMessage(error));
992
1530
  runtime.ctx.editor.setText("");
993
1531
  return;
994
1532
  }
@@ -996,17 +1534,17 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
996
1534
  runtime.ctx.editor.setText("");
997
1535
 
998
1536
  // If a prompt was provided, pass it through as input
999
- if (prompt) return prompt;
1537
+ if (prompt) return { prompt };
1000
1538
  },
1001
1539
  },
1002
1540
  {
1003
1541
  name: "quit",
1004
1542
  description: "Quit the application",
1005
- handle: shutdownHandler,
1543
+ handleTui: shutdownHandlerTui,
1006
1544
  },
1007
1545
  ];
1008
1546
 
1009
- const BUILTIN_SLASH_COMMAND_LOOKUP = new Map<string, BuiltinSlashCommandSpec>();
1547
+ const BUILTIN_SLASH_COMMAND_LOOKUP = new Map<string, SlashCommandSpec>();
1010
1548
  for (const command of BUILTIN_SLASH_COMMAND_REGISTRY) {
1011
1549
  BUILTIN_SLASH_COMMAND_LOOKUP.set(command.name, command);
1012
1550
  for (const alias of command.aliases ?? []) {
@@ -1025,17 +1563,24 @@ export const BUILTIN_SLASH_COMMAND_DEFS: ReadonlyArray<BuiltinSlashCommand> = BU
1025
1563
  );
1026
1564
 
1027
1565
  /**
1028
- * Execute a builtin slash command when it matches known command syntax.
1566
+ * Unified registry exposed for cross-mode tooling. Each spec carries at least
1567
+ * one of `handle` / `handleTui`. The TUI dispatcher prefers `handleTui`; the
1568
+ * ACP dispatcher requires `handle` and skips TUI-only entries.
1569
+ */
1570
+ export const BUILTIN_SLASH_COMMANDS_INTERNAL: ReadonlyArray<SlashCommandSpec> = BUILTIN_SLASH_COMMAND_REGISTRY;
1571
+
1572
+ /**
1573
+ * Execute a builtin slash command in the interactive TUI.
1029
1574
  *
1030
- * Returns `false` when no builtin matched. Returns `true` when a command consumed
1031
- * the input entirely. Returns a `string` when the command was handled but remaining
1032
- * text should be sent as a prompt.
1575
+ * Returns `false` when no builtin matched. Returns `true` when a command
1576
+ * consumed the input entirely. Returns a `string` when the command was handled
1577
+ * but remaining text should be sent as a prompt.
1033
1578
  */
1034
1579
  export async function executeBuiltinSlashCommand(
1035
1580
  text: string,
1036
1581
  runtime: BuiltinSlashCommandRuntime,
1037
1582
  ): Promise<string | boolean> {
1038
- const parsed = parseBuiltinSlashCommand(text);
1583
+ const parsed = parseSlashCommand(text);
1039
1584
  if (!parsed) return false;
1040
1585
 
1041
1586
  const command = BUILTIN_SLASH_COMMAND_LOOKUP.get(parsed.name);
@@ -1043,7 +1588,45 @@ export async function executeBuiltinSlashCommand(
1043
1588
  if (parsed.args.length > 0 && !command.allowArgs) {
1044
1589
  return false;
1045
1590
  }
1591
+ if (command.handleTui) {
1592
+ const result = await command.handleTui(parsed, runtime);
1593
+ if (result && typeof result === "object" && "prompt" in result) return result.prompt;
1594
+ return true;
1595
+ }
1596
+ if (command.handle) {
1597
+ // No TUI-specific override → adapt the ACP/text-mode `handle` to the
1598
+ // TUI by routing `runtime.output` through `ctx.showStatus`, clearing
1599
+ // the editor after the call, and reusing the active session's plugin
1600
+ // reload pipeline. Spec authors get a single body usable from either
1601
+ // dispatcher without forcing every TUI test to construct the full
1602
+ // `SlashCommandRuntime` shape.
1603
+ const ctx = runtime.ctx;
1604
+ const adapted: SlashCommandRuntime = {
1605
+ session: ctx.session,
1606
+ sessionManager: ctx.sessionManager,
1607
+ settings: ctx.settings,
1608
+ cwd: ctx.sessionManager.getCwd(),
1609
+ output: (text: string) => {
1610
+ ctx.showStatus(text);
1611
+ },
1612
+ refreshCommands: () => ctx.refreshSlashCommandState(),
1613
+ reloadPlugins: async () => {
1614
+ const projectPath = await resolveActiveProjectRegistryPath(ctx.sessionManager.getCwd());
1615
+ clearPluginRootsAndCaches(projectPath ? [projectPath] : undefined);
1616
+ await ctx.refreshSlashCommandState();
1617
+ },
1618
+ };
1619
+ const result = await command.handle(parsed, adapted);
1620
+ ctx.editor.setText("");
1621
+ if (result && typeof result === "object" && "prompt" in result) return result.prompt;
1622
+ return true;
1623
+ }
1624
+ return false;
1625
+ }
1046
1626
 
1047
- const remaining = await command.handle(parsed, runtime);
1048
- return remaining ?? true;
1627
+ /** Look up a unified spec by name or alias. Used by the ACP dispatcher. */
1628
+ export function lookupBuiltinSlashCommand(name: string): SlashCommandSpec | undefined {
1629
+ return BUILTIN_SLASH_COMMAND_LOOKUP.get(name);
1049
1630
  }
1631
+
1632
+ export type { ParsedSlashCommand, SlashCommandResult, SlashCommandRuntime, SlashCommandSpec, TuiSlashCommandRuntime };