@oh-my-pi/pi-coding-agent 15.11.1 → 15.11.3

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 (69) hide show
  1. package/CHANGELOG.md +36 -1
  2. package/dist/cli.js +643 -627
  3. package/dist/types/config/settings-schema.d.ts +36 -0
  4. package/dist/types/extensibility/custom-commands/types.d.ts +6 -3
  5. package/dist/types/extensibility/custom-tools/loader.d.ts +2 -1
  6. package/dist/types/extensibility/custom-tools/types.d.ts +8 -4
  7. package/dist/types/extensibility/extensions/types.d.ts +2 -2
  8. package/dist/types/extensibility/hooks/types.d.ts +8 -4
  9. package/dist/types/irc/bus.d.ts +15 -2
  10. package/dist/types/lsp/format-options.d.ts +32 -0
  11. package/dist/types/mnemopi/state.d.ts +29 -1
  12. package/dist/types/modes/components/plan-review-overlay.d.ts +2 -0
  13. package/dist/types/modes/rpc/rpc-client.d.ts +10 -1
  14. package/dist/types/modes/rpc/rpc-mode.d.ts +2 -0
  15. package/dist/types/modes/rpc/rpc-types.d.ts +30 -0
  16. package/dist/types/modes/theme/theme.d.ts +1 -1
  17. package/dist/types/session/agent-session.d.ts +17 -3
  18. package/dist/types/slash-commands/available-commands.d.ts +34 -0
  19. package/dist/types/tools/bash.d.ts +1 -1
  20. package/dist/types/tools/browser/attach.d.ts +4 -4
  21. package/dist/types/tools/browser/registry.d.ts +1 -0
  22. package/dist/types/tools/irc.d.ts +3 -2
  23. package/dist/types/tools/path-utils.d.ts +5 -5
  24. package/dist/types/utils/git.d.ts +1 -1
  25. package/package.json +11 -11
  26. package/src/config/settings-schema.ts +40 -0
  27. package/src/exec/bash-executor.ts +21 -6
  28. package/src/extensibility/custom-commands/loader.ts +3 -1
  29. package/src/extensibility/custom-commands/types.ts +6 -3
  30. package/src/extensibility/custom-tools/loader.ts +4 -7
  31. package/src/extensibility/custom-tools/types.ts +8 -4
  32. package/src/extensibility/extensions/loader.ts +2 -1
  33. package/src/extensibility/extensions/types.ts +2 -2
  34. package/src/extensibility/hooks/loader.ts +3 -1
  35. package/src/extensibility/hooks/types.ts +8 -4
  36. package/src/internal-urls/docs-index.generated.ts +4 -4
  37. package/src/irc/bus.ts +14 -3
  38. package/src/lsp/clients/lsp-linter-client.ts +2 -10
  39. package/src/lsp/defaults.json +6 -0
  40. package/src/lsp/format-options.ts +119 -0
  41. package/src/lsp/index.ts +2 -10
  42. package/src/lsp/render.ts +2 -28
  43. package/src/memories/index.ts +2 -0
  44. package/src/mnemopi/backend.ts +4 -8
  45. package/src/mnemopi/state.ts +42 -3
  46. package/src/modes/acp/acp-agent.ts +4 -67
  47. package/src/modes/components/plan-review-overlay.ts +32 -3
  48. package/src/modes/controllers/streaming-reveal.ts +16 -8
  49. package/src/modes/interactive-mode.ts +54 -2
  50. package/src/modes/rpc/rpc-client.ts +32 -0
  51. package/src/modes/rpc/rpc-mode.ts +82 -7
  52. package/src/modes/rpc/rpc-types.ts +23 -0
  53. package/src/modes/theme/theme.ts +7 -7
  54. package/src/modes/utils/ui-helpers.ts +13 -4
  55. package/src/prompts/memories/consolidation_system.md +4 -0
  56. package/src/prompts/system/irc-autoreply.md +6 -0
  57. package/src/prompts/system/irc-incoming.md +1 -1
  58. package/src/prompts/tools/bash.md +1 -0
  59. package/src/prompts/tools/irc.md +1 -1
  60. package/src/session/agent-session.ts +96 -7
  61. package/src/slash-commands/available-commands.ts +105 -0
  62. package/src/tools/bash.ts +5 -1
  63. package/src/tools/browser/attach.ts +26 -7
  64. package/src/tools/browser/registry.ts +11 -1
  65. package/src/tools/irc.ts +16 -4
  66. package/src/tools/job.ts +7 -3
  67. package/src/tools/path-utils.ts +56 -25
  68. package/src/tools/search.ts +11 -0
  69. package/src/utils/git.ts +7 -2
package/src/irc/bus.ts CHANGED
@@ -7,7 +7,11 @@
7
7
  * AgentLifecycleManager, idle agents are woken with a real turn, and busy
8
8
  * agents receive the message as a non-interrupting aside at the next step
9
9
  * boundary (see AgentSession.deliverIrcMessage). Replies are real turns by
10
- * the recipient, observed via `wait`.
10
+ * the recipient, observed via `wait` — with one exception: when the sender
11
+ * awaits a reply and the recipient is mid-turn with async execution
12
+ * disabled, the recipient session generates an ephemeral side-channel
13
+ * auto-reply (it may be blocked in a synchronous task spawn whose batch
14
+ * includes the sender, so a real turn could never happen in time).
11
15
  */
12
16
 
13
17
  import { logger, Snowflake } from "@oh-my-pi/pi-utils";
@@ -80,8 +84,15 @@ export class IrcBus {
80
84
  * context, so buffering it too would double-deliver via a later
81
85
  * `wait`/`inbox` and inflate unread counts. Only a failed live hand-off
82
86
  * is buffered for the recipient to drain later.
87
+ *
88
+ * `opts.expectsReply` marks sends whose caller is blocked on an answer
89
+ * (`send await:true`). It is forwarded to the recipient session so a
90
+ * mid-turn recipient that cannot reach a step boundary (async execution
91
+ * disabled — e.g. blocked in a synchronous task spawn awaiting the
92
+ * sender's own batch) can generate an ephemeral side-channel auto-reply
93
+ * instead of stranding the sender until timeout.
83
94
  */
84
- async send(msg: Omit<IrcMessage, "id" | "ts">): Promise<IrcDeliveryReceipt> {
95
+ async send(msg: Omit<IrcMessage, "id" | "ts">, opts?: { expectsReply?: boolean }): Promise<IrcDeliveryReceipt> {
85
96
  const message: IrcMessage = { ...msg, id: Snowflake.next(), ts: Date.now() };
86
97
  const ref = this.#registry.get(message.to);
87
98
  if (!ref || ref.status === "aborted") {
@@ -118,7 +129,7 @@ export class IrcBus {
118
129
  }
119
130
 
120
131
  try {
121
- const delivery = await session.deliverIrcMessage(message);
132
+ const delivery = await session.deliverIrcMessage(message, opts);
122
133
  this.#relayToMainUi(message);
123
134
  return { to: message.to, outcome: revived ? "revived" : delivery };
124
135
  } catch (error) {
@@ -4,18 +4,10 @@
4
4
  */
5
5
  import { getOrCreateClient, notifySaved, sendRequest, syncContent } from "../../lsp/client";
6
6
  import { applyTextEditsToString } from "../../lsp/edits";
7
+ import { resolveFormatOptions } from "../../lsp/format-options";
7
8
  import type { Diagnostic, LinterClient, LspClient, ServerConfig, TextEdit } from "../../lsp/types";
8
9
  import { fileToUri } from "../../lsp/utils";
9
10
 
10
- /** Default formatting options for LSP */
11
- const DEFAULT_FORMAT_OPTIONS = {
12
- tabSize: 3,
13
- insertSpaces: true,
14
- trimTrailingWhitespace: true,
15
- insertFinalNewline: true,
16
- trimFinalNewlines: true,
17
- };
18
-
19
11
  /**
20
12
  * LSP-based linter client implementation.
21
13
  * Wraps the existing LSP client infrastructure.
@@ -56,7 +48,7 @@ export class LspLinterClient implements LinterClient {
56
48
  // Request formatting
57
49
  const edits = (await sendRequest(client, "textDocument/formatting", {
58
50
  textDocument: { uri },
59
- options: DEFAULT_FORMAT_OPTIONS,
51
+ options: resolveFormatOptions(filePath, content),
60
52
  })) as TextEdit[] | null;
61
53
 
62
54
  if (!edits || edits.length === 0) {
@@ -248,6 +248,12 @@
248
248
  }
249
249
  }
250
250
  },
251
+ "expert": {
252
+ "command": "expert",
253
+ "args": ["--stdio"],
254
+ "fileTypes": [".ex", ".exs", ".heex", ".eex"],
255
+ "rootMarkers": ["mix.exs", "mix.lock"]
256
+ },
251
257
  "erlangls": {
252
258
  "command": "erlang_ls",
253
259
  "args": [],
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Per-file LSP `FormattingOptions` resolution.
3
+ *
4
+ * Replaces the historical hardcoded `{ tabSize: 3, insertSpaces: true }` default
5
+ * that fed every `textDocument/formatting` request — it silently re-indented
6
+ * 2-space YAML (and any LSP-formatted file) on every write/edit (issue #2329).
7
+ *
8
+ * Precedence, highest to lowest:
9
+ * 1. `.editorconfig` in the file's chain (`indent_style`, `indent_size`, `tab_width`).
10
+ * 2. Indent detected from the file content the agent is about to write.
11
+ * 3. Hardcoded fallback — 2 spaces, matching the dominant convention for YAML,
12
+ * JSON, JS/TS, Python (PEP 8 is 4 but most LSP servers honour their own
13
+ * defaults when ours don't disagree), and most config formats. The previous
14
+ * `3` default was an unusual stride that actively damaged every file with
15
+ * a 2/4-space convention.
16
+ */
17
+ import { getEditorConfigFormatting } from "@oh-my-pi/pi-utils";
18
+
19
+ /** Subset of the LSP `FormattingOptions` we send. */
20
+ export interface LspFormattingOptions {
21
+ tabSize: number;
22
+ insertSpaces: boolean;
23
+ trimTrailingWhitespace: boolean;
24
+ insertFinalNewline: boolean;
25
+ trimFinalNewlines: boolean;
26
+ }
27
+
28
+ /** Sensible fallback when neither `.editorconfig` nor file content pins the indent. */
29
+ const FALLBACK_TAB_SIZE = 2;
30
+ const FALLBACK_INSERT_SPACES = true;
31
+
32
+ /** Static flags we always pass — these have no per-file analogue and match common formatter expectations. */
33
+ const TRIM_OPTIONS = {
34
+ trimTrailingWhitespace: true,
35
+ insertFinalNewline: true,
36
+ trimFinalNewlines: true,
37
+ } as const;
38
+
39
+ interface DetectedIndent {
40
+ tabSize?: number;
41
+ insertSpaces?: boolean;
42
+ }
43
+
44
+ /**
45
+ * Sniff `insertSpaces` and the indent unit from `content`.
46
+ *
47
+ * Walks the buffer once: the first indented line decides spaces vs tabs; for
48
+ * space indents, the GCD of all space-indent widths gives the stride (so a
49
+ * 2/4/6 file reports `2`, a 4/8 file reports `4`). Returns `undefined` for any
50
+ * field the content does not pin so a higher-precedence override (editorconfig)
51
+ * can win without being overwritten by sniffing noise.
52
+ */
53
+ export function detectIndentFromContent(content: string): DetectedIndent {
54
+ if (content.length === 0) return {};
55
+
56
+ let insertSpaces: boolean | undefined;
57
+ let unit = 0;
58
+
59
+ // Split is the cheapest reliable line walk on arbitrary text; the
60
+ // per-line regex matches are O(leading whitespace) so total cost is
61
+ // linear in the file's indented prefix bytes.
62
+ for (const line of content.split("\n")) {
63
+ // Skip blank/whitespace-only lines — they carry no indent signal.
64
+ if (line.length === 0 || line.trim().length === 0) continue;
65
+
66
+ const first = line[0];
67
+ if (first !== " " && first !== "\t") continue;
68
+
69
+ if (insertSpaces === undefined) {
70
+ insertSpaces = first === " ";
71
+ }
72
+
73
+ // Tab-indented file: the unit is one tab per level; tabSize is a
74
+ // display concern, leave it to caller defaults / editorconfig.
75
+ if (first === "\t") continue;
76
+
77
+ // Space-indented: count the leading spaces (stop at first tab to avoid
78
+ // mixing). GCD across non-zero widths converges on the stride.
79
+ let n = 0;
80
+ while (n < line.length && line[n] === " ") n++;
81
+ if (n === 0) continue;
82
+ unit = unit === 0 ? n : gcd(unit, n);
83
+ }
84
+
85
+ const result: DetectedIndent = {};
86
+ if (insertSpaces !== undefined) result.insertSpaces = insertSpaces;
87
+ if (unit > 0 && insertSpaces === true) result.tabSize = unit;
88
+ return result;
89
+ }
90
+
91
+ function gcd(a: number, b: number): number {
92
+ let x = a;
93
+ let y = b;
94
+ while (y !== 0) {
95
+ const t = y;
96
+ y = x % y;
97
+ x = t;
98
+ }
99
+ return x;
100
+ }
101
+
102
+ /**
103
+ * Resolve the `FormattingOptions` payload for a `textDocument/formatting` request
104
+ * targeting `filePath` with `content`.
105
+ *
106
+ * The two fields that actually affect on-disk bytes (`tabSize`, `insertSpaces`)
107
+ * are layered: editorconfig wins, then content sniffing, then the fallback.
108
+ * Trim/final-newline flags are static.
109
+ */
110
+ export function resolveFormatOptions(filePath: string, content: string): LspFormattingOptions {
111
+ const fromConfig = getEditorConfigFormatting(filePath);
112
+ const detected = detectIndentFromContent(content);
113
+
114
+ return {
115
+ tabSize: fromConfig.tabSize ?? detected.tabSize ?? FALLBACK_TAB_SIZE,
116
+ insertSpaces: fromConfig.insertSpaces ?? detected.insertSpaces ?? FALLBACK_INSERT_SPACES,
117
+ ...TRIM_OPTIONS,
118
+ };
119
+ }
package/src/lsp/index.ts CHANGED
@@ -39,6 +39,7 @@ import {
39
39
  flattenWorkspaceTextEdits,
40
40
  rangesOverlap,
41
41
  } from "./edits";
42
+ import { resolveFormatOptions } from "./format-options";
42
43
  import { detectLspmux } from "./lspmux";
43
44
  import {
44
45
  type CodeAction,
@@ -779,15 +780,6 @@ export enum FileFormatResult {
779
780
  FORMATTED = "formatted",
780
781
  }
781
782
 
782
- /** Default formatting options for LSP */
783
- const DEFAULT_FORMAT_OPTIONS = {
784
- tabSize: 3,
785
- insertSpaces: true,
786
- trimTrailingWhitespace: true,
787
- insertFinalNewline: true,
788
- trimFinalNewlines: true,
789
- };
790
-
791
783
  /**
792
784
  * Format content using LSP or custom linter client.
793
785
  *
@@ -834,7 +826,7 @@ async function formatContent(
834
826
  "textDocument/formatting",
835
827
  {
836
828
  textDocument: { uri },
837
- options: DEFAULT_FORMAT_OPTIONS,
829
+ options: resolveFormatOptions(absolutePath, content),
838
830
  },
839
831
  signal,
840
832
  )) as TextEdit[] | null;
package/src/lsp/render.ts CHANGED
@@ -8,9 +8,8 @@
8
8
  * - Collapsible/expandable views
9
9
  */
10
10
  import type { RenderResultOptions } from "@oh-my-pi/pi-agent-core";
11
- import { type HighlightColors, highlightCode as nativeHighlightCode, supportsLanguage } from "@oh-my-pi/pi-natives";
12
11
  import { type Component, Text } from "@oh-my-pi/pi-tui";
13
- import { getLanguageFromPath, type Theme } from "../modes/theme/theme";
12
+ import { getLanguageFromPath, highlightCode as highlightThemeCode, type Theme } from "../modes/theme/theme";
14
13
  import {
15
14
  formatExpandHint,
16
15
  formatMoreItems,
@@ -219,7 +218,7 @@ function renderHover(
219
218
  const beforeCode = fullText.slice(0, codeStart).trimEnd();
220
219
  const afterCode = fullText.slice(fullText.indexOf("```", 3) + 3).trim();
221
220
 
222
- const codeLines = highlightCode(code, lang, theme);
221
+ const codeLines = highlightThemeCode(code, lang, theme);
223
222
  const icon = theme.styledSymbol("status.info", "accent");
224
223
  const langLabel = lang ? theme.fg("mdCodeBlockBorder", ` ${lang}`) : "";
225
224
 
@@ -274,31 +273,6 @@ function renderHover(
274
273
  return output.split("\n");
275
274
  }
276
275
 
277
- /**
278
- * Syntax highlight code using native highlighter.
279
- */
280
- function highlightCode(codeText: string, language: string, theme: Theme): string[] {
281
- const validLang = language && supportsLanguage(language) ? language : undefined;
282
- try {
283
- const colors: HighlightColors = {
284
- comment: theme.getFgAnsi("syntaxComment"),
285
- keyword: theme.getFgAnsi("syntaxKeyword"),
286
- function: theme.getFgAnsi("syntaxFunction"),
287
- variable: theme.getFgAnsi("syntaxVariable"),
288
- string: theme.getFgAnsi("syntaxString"),
289
- number: theme.getFgAnsi("syntaxNumber"),
290
- type: theme.getFgAnsi("syntaxType"),
291
- operator: theme.getFgAnsi("syntaxOperator"),
292
- punctuation: theme.getFgAnsi("syntaxPunctuation"),
293
- inserted: theme.getFgAnsi("toolDiffAdded"),
294
- deleted: theme.getFgAnsi("toolDiffRemoved"),
295
- };
296
- return nativeHighlightCode(codeText, validLang, colors).split("\n");
297
- } catch {
298
- return codeText.split("\n");
299
- }
300
- }
301
-
302
276
  // =============================================================================
303
277
  // Diagnostics Rendering
304
278
  // =============================================================================
@@ -11,6 +11,7 @@ import type { ModelRegistry } from "../config/model-registry";
11
11
  import { getModelMatchPreferences, resolveModelRoleValue } from "../config/model-resolver";
12
12
  import type { Settings } from "../config/settings";
13
13
  import consolidationTemplate from "../prompts/memories/consolidation.md" with { type: "text" };
14
+ import consolidationSystemTemplate from "../prompts/memories/consolidation_system.md" with { type: "text" };
14
15
  import readPathTemplate from "../prompts/memories/read-path.md" with { type: "text" };
15
16
  import stageOneInputTemplate from "../prompts/memories/stage_one_input.md" with { type: "text" };
16
17
  import stageOneSystemTemplate from "../prompts/memories/stage_one_system.md" with { type: "text" };
@@ -752,6 +753,7 @@ async function runConsolidationModel(options: {
752
753
  const response = await completeSimple(
753
754
  model,
754
755
  {
756
+ systemPrompt: [consolidationSystemTemplate],
755
757
  messages: [{ role: "user", content: [{ type: "text", text: input }], timestamp: Date.now() }],
756
758
  },
757
759
  {
@@ -82,7 +82,7 @@ export const mnemopiBackend: MemoryBackend = {
82
82
  hasRecalledForFirstTurn: true,
83
83
  }),
84
84
  );
85
- previous?.dispose();
85
+ await previous?.dispose();
86
86
  return;
87
87
  }
88
88
 
@@ -91,7 +91,7 @@ export const mnemopiBackend: MemoryBackend = {
91
91
  await Promise.all([loadMnemopi(), loadMnemopiCore()]);
92
92
  const state = new MnemopiSessionState({ sessionId, config, session });
93
93
  const previous = setMnemopiSessionState(session, state);
94
- previous?.dispose();
94
+ await previous?.dispose();
95
95
  state.attachSessionListeners();
96
96
  } catch (error) {
97
97
  logger.warn("Mnemopi: backend startup failed; memory backend inert.", { error: String(error) });
@@ -115,7 +115,7 @@ export const mnemopiBackend: MemoryBackend = {
115
115
 
116
116
  async clear(agentDir, _cwd, session): Promise<void> {
117
117
  const previous = session ? setMnemopiSessionState(session, undefined) : undefined;
118
- previous?.dispose();
118
+ await previous?.dispose({ consolidate: false });
119
119
  const config = previous?.config ?? (session ? loadMnemopiConfig(session.settings, agentDir) : undefined);
120
120
  if (!config) return;
121
121
  await loadMnemopiCore();
@@ -136,11 +136,7 @@ export const mnemopiBackend: MemoryBackend = {
136
136
  state = new MnemopiSessionState({ sessionId: session.sessionId, config, session });
137
137
  setMnemopiSessionState(session, state);
138
138
  }
139
- await state?.forceRetainCurrentSession();
140
- // Drain the background fact extraction scheduled by the final retain
141
- // before the process can exit, otherwise the last turn's facts are lost.
142
- await state?.memory.flushExtractions();
143
- state?.memory.sleepAllSessions(false);
139
+ await state?.consolidate();
144
140
  } catch (error) {
145
141
  logger.warn("Mnemopi: enqueue failed.", { error: String(error) });
146
142
  }
@@ -370,12 +370,51 @@ export class MnemopiSessionState {
370
370
  }
371
371
  }
372
372
 
373
- dispose(): void {
373
+ /**
374
+ * Drain in-flight fact extraction and run beam consolidation on every owned
375
+ * bank, after capturing the current transcript. Mirrors the manual
376
+ * `/memory enqueue` slash command, but stops short of closing the DBs so
377
+ * callers can keep using the state. {@link dispose} composes this with the
378
+ * close step so normal session shutdown promotes working memory to
379
+ * episodic/gists/graph automatically (see issue #2320).
380
+ *
381
+ * Aliased subagent states share `scoped` (and therefore the actual SQLite
382
+ * banks) with their parent. `consolidate()` deliberately does NOT
383
+ * short-circuit on `aliasOf`: `forceRetainCurrentSession` already guards
384
+ * itself, and an explicit `/memory enqueue` invoked from within a subagent
385
+ * still needs to flush extractions and sleep the parent's shared banks —
386
+ * otherwise enqueue would report success while leaving the subagent's
387
+ * retained memories unconsolidated until the parent eventually shuts down
388
+ * (PR #2327 review).
389
+ */
390
+ async consolidate(): Promise<void> {
391
+ await this.forceRetainCurrentSession();
392
+ for (const memory of this.scoped.owned) {
393
+ await memory.flushExtractions();
394
+ memory.sleepAllSessions(false);
395
+ }
396
+ }
397
+
398
+ /**
399
+ * Release the per-session resources. Defaults to running {@link consolidate}
400
+ * before closing handles so normal session shutdown promotes working memory
401
+ * into long-term storage. Callers that are about to delete the DB files —
402
+ * e.g. `mnemopiBackend.clear` — pass `{ consolidate: false }` to skip the
403
+ * extraction/sleep pass, since spending tokens on memories that will be
404
+ * wiped on the next line is wasted work (PR #2327 review).
405
+ */
406
+ async dispose(options: { consolidate?: boolean } = {}): Promise<void> {
374
407
  this.unsubscribe?.();
375
408
  this.unsubscribe = undefined;
376
- if (!this.aliasOf) {
377
- for (const memory of this.scoped.owned) memory.close();
409
+ if (this.aliasOf) return;
410
+ if (options.consolidate !== false) {
411
+ try {
412
+ await this.consolidate();
413
+ } catch (error) {
414
+ logger.warn("Mnemopi: consolidation on dispose failed.", { error: String(error) });
415
+ }
378
416
  }
417
+ for (const memory of this.scoped.owned) memory.close();
379
418
  }
380
419
  }
381
420
 
@@ -56,7 +56,7 @@ import {
56
56
  } from "../../extensibility/extensions";
57
57
  import { runExtensionCompact } from "../../extensibility/extensions/compact-handler";
58
58
  import { getSessionSlashCommands } from "../../extensibility/extensions/get-commands-handler";
59
- import { buildSkillPromptMessage, getSkillSlashCommandName } from "../../extensibility/skills";
59
+ import { buildSkillPromptMessage } from "../../extensibility/skills";
60
60
  import { loadSlashCommands } from "../../extensibility/slash-commands";
61
61
  import { resolveLocalUrlToPath } from "../../internal-urls";
62
62
  import { MCPManager } from "../../mcp/manager";
@@ -71,12 +71,8 @@ import {
71
71
  type SessionInfo as StoredSessionInfo,
72
72
  type UsageStatistics,
73
73
  } from "../../session/session-manager";
74
- import {
75
- ACP_BUILTIN_RESERVED_NAMES,
76
- ACP_BUILTIN_SLASH_COMMANDS,
77
- executeAcpBuiltinSlashCommand,
78
- isAcpBuiltinShadowedName,
79
- } from "../../slash-commands/acp-builtins";
74
+ import { executeAcpBuiltinSlashCommand } from "../../slash-commands/acp-builtins";
75
+ import { buildAvailableSlashCommands, toAcpAvailableCommands } from "../../slash-commands/available-commands";
80
76
  import { AUTO_THINKING, parseConfiguredThinkingLevel } from "../../thinking";
81
77
  import { normalizeLocalScheme } from "../../tools/path-utils";
82
78
  import { runResolveInvocation } from "../../tools/resolve";
@@ -1662,66 +1658,7 @@ export class AcpAgent implements Agent {
1662
1658
  }
1663
1659
 
1664
1660
  async #buildAvailableCommands(session: AgentSession): Promise<AvailableCommand[]> {
1665
- const commands: AvailableCommand[] = [];
1666
- const seenNames = new Set<string>();
1667
- const appendCommand = (command: AvailableCommand): void => {
1668
- if (seenNames.has(command.name)) {
1669
- return;
1670
- }
1671
- seenNames.add(command.name);
1672
- commands.push(command);
1673
- };
1674
-
1675
- // Advertise in the order dispatch resolves them (mirrors AgentSession
1676
- // dispatch: builtins → skills → extensions → custom TS → file-based).
1677
- // `appendCommand` dedupes by name so earlier entries win; extension
1678
- // commands therefore correctly shadow custom TS commands of the same
1679
- // name, matching the runtime behaviour of #tryExecuteExtensionCommand
1680
- // running before #tryExecuteCustomCommand.
1681
- for (const command of ACP_BUILTIN_SLASH_COMMANDS) {
1682
- appendCommand(command);
1683
- }
1684
-
1685
- if (session.skillsSettings?.enableSkillCommands) {
1686
- for (const skill of session.skills) {
1687
- appendCommand({
1688
- name: getSkillSlashCommandName(skill),
1689
- description: skill.description || `Run ${skill.name} skill`,
1690
- input: { hint: "arguments" },
1691
- });
1692
- }
1693
- }
1694
-
1695
- for (const command of session.extensionRunner?.getRegisteredCommands(ACP_BUILTIN_RESERVED_NAMES) ?? []) {
1696
- // Reserved-set filtering in getRegisteredCommands only covers exact
1697
- // names; colon-namespaced names whose prefix is a builtin (e.g.
1698
- // `model:foo`) would still dispatch to the builtin in ACP.
1699
- if (isAcpBuiltinShadowedName(command.name)) {
1700
- continue;
1701
- }
1702
- appendCommand({
1703
- name: command.name,
1704
- description: command.description ?? "(extension command)",
1705
- input: { hint: "arguments" },
1706
- });
1707
- }
1708
-
1709
- for (const command of session.customCommands) {
1710
- appendCommand({
1711
- name: command.command.name,
1712
- description: command.command.description,
1713
- input: { hint: "arguments" },
1714
- });
1715
- }
1716
-
1717
- for (const command of await loadSlashCommands({ cwd: session.sessionManager.getCwd() })) {
1718
- appendCommand({
1719
- name: command.name,
1720
- description: command.description,
1721
- });
1722
- }
1723
-
1724
- return commands;
1661
+ return toAcpAvailableCommands(await buildAvailableSlashCommands(session));
1725
1662
  }
1726
1663
 
1727
1664
  #toSessionInfo(session: StoredSessionInfo): SessionInfo {
@@ -83,6 +83,8 @@ export interface PlanReviewOverlayCallbacks {
83
83
  onCancel: () => void;
84
84
  /** Invoked when the external-editor key is pressed (overlay stays open). */
85
85
  onExternalEditor?: () => void;
86
+ /** Invoked when the external-editor key edits the active annotation draft. */
87
+ onAnnotationExternalEditor?: (draft: string, commit: (text: string | null) => void) => void;
86
88
  /** Invoked with the new full plan text after an in-overlay delete/undo. */
87
89
  onPlanEdited?: (content: string) => void;
88
90
  /** Invoked with the Refine feedback markdown whenever annotations change. */
@@ -282,6 +284,12 @@ export class PlanReviewOverlay implements Component {
282
284
  handleInput(keyData: string): void {
283
285
  if (keyData.startsWith("\x1b[<") && this.#handleMouse(keyData)) return;
284
286
  if (this.#annotating) {
287
+ if (this.callbacks.onAnnotationExternalEditor && matchesAppExternalEditor(keyData)) {
288
+ this.callbacks.onAnnotationExternalEditor(this.#input.getValue(), text => {
289
+ if (text !== null) this.#submitAnnotation(text);
290
+ });
291
+ return;
292
+ }
285
293
  this.#input.handleInput(keyData);
286
294
  return;
287
295
  }
@@ -603,11 +611,23 @@ export class PlanReviewOverlay implements Component {
603
611
  }
604
612
  for (const section of annotated) {
605
613
  feedback += `\n## ${section.title}\n`;
606
- for (const note of section.annotations) feedback += `- ${note}\n`;
614
+ for (const note of section.annotations) feedback += this.#formatAnnotationFeedback(note);
607
615
  }
608
616
  this.callbacks.onFeedbackChange?.(feedback);
609
617
  }
610
618
 
619
+ #formatAnnotationFeedback(note: string): string {
620
+ if (!note.includes("\n")) return `- ${note}\n`;
621
+ const fence = this.#markdownFenceFor(note);
622
+ return `${fence}md\n${note}\n${fence}\n`;
623
+ }
624
+
625
+ #markdownFenceFor(text: string): string {
626
+ let fence = "```";
627
+ while (text.includes(fence)) fence += "`";
628
+ return fence;
629
+ }
630
+
611
631
  #renderSliderLines(): string[] {
612
632
  const slider = this.#slider;
613
633
  if (!slider) return [];
@@ -676,7 +696,14 @@ export class PlanReviewOverlay implements Component {
676
696
  if (section.level >= 1 && section.annotations.length > 0 && rendered.length > 0) {
677
697
  lines.push(rendered[0]!);
678
698
  for (const note of section.annotations) {
679
- lines.push(`${theme.fg("warning", "▎ ")}${theme.fg("dim", "note: ")}${theme.fg("accent", note)}`);
699
+ const noteLines = note.split(/\r?\n/);
700
+ for (let j = 0; j < noteLines.length; j++) {
701
+ const prefix =
702
+ j === 0
703
+ ? `${theme.fg("warning", "▎ ")}${theme.fg("dim", "note: ")}`
704
+ : `${theme.fg("warning", "▎ ")}${theme.fg("dim", " ")}`;
705
+ lines.push(`${prefix}${theme.fg("accent", noteLines[j] ?? "")}`);
706
+ }
680
707
  }
681
708
  for (let k = 1; k < rendered.length; k++) lines.push(rendered[k]!);
682
709
  } else {
@@ -749,7 +776,9 @@ export class PlanReviewOverlay implements Component {
749
776
  const section = this.#sections[this.#toc[this.#tocCursor]!];
750
777
  const title = section?.title ?? "";
751
778
  const caption = `${theme.fg("dim", "Annotate")} ${theme.fg("accent", `‹${title}›`)}`;
752
- return [caption, this.#input.render(innerWidth)[0] ?? ""];
779
+ const hintParts = ["enter save", "esc cancel"];
780
+ if (this.#externalEditorLabel) hintParts.push(`${this.#externalEditorLabel} editor`);
781
+ return [caption, this.#input.render(innerWidth)[0] ?? "", theme.fg("dim", hintParts.join(" · "))];
753
782
  }
754
783
  return [theme.fg("dim", this.#buildHelp())];
755
784
  }
@@ -163,7 +163,7 @@ export class StreamingRevealController {
163
163
  this.#hideThinkingBlock = this.#getHideThinkingBlock();
164
164
  this.#smoothStreaming = this.#getSmoothStreaming();
165
165
  if (!this.#smoothStreaming) {
166
- component.updateContent(message);
166
+ component.updateContent(message, { transient: true });
167
167
  return;
168
168
  }
169
169
  const total = this.#visibleUnits(message);
@@ -171,10 +171,12 @@ export class StreamingRevealController {
171
171
  // A tool call is a transcript-order boundary: finish any leading
172
172
  // assistant text before EventController renders the separate tool card.
173
173
  this.#revealed = total;
174
- component.updateContent(buildDisplayMessage(message, this.#revealed, this.#hideThinkingBlock, this.#countOf));
174
+ component.updateContent(buildDisplayMessage(message, this.#revealed, this.#hideThinkingBlock, this.#countOf), {
175
+ transient: true,
176
+ });
175
177
  return;
176
178
  }
177
- this.#renderCurrent(total);
179
+ this.#renderCurrent();
178
180
  this.#syncTimer(total);
179
181
  }
180
182
 
@@ -182,7 +184,7 @@ export class StreamingRevealController {
182
184
  this.#target = message;
183
185
  if (!this.#component) return;
184
186
  if (!this.#smoothStreaming) {
185
- this.#component.updateContent(message);
187
+ this.#component.updateContent(message, { transient: true });
186
188
  return;
187
189
  }
188
190
  const total = this.#visibleUnits(message);
@@ -193,13 +195,16 @@ export class StreamingRevealController {
193
195
  this.#stopTimer();
194
196
  this.#component.updateContent(
195
197
  buildDisplayMessage(message, this.#revealed, this.#hideThinkingBlock, this.#countOf),
198
+ {
199
+ transient: true,
200
+ },
196
201
  );
197
202
  return;
198
203
  }
199
204
  if (this.#revealed > total) {
200
205
  this.#revealed = total;
201
206
  }
202
- this.#renderCurrent(total);
207
+ this.#renderCurrent();
203
208
  this.#syncTimer(total);
204
209
  }
205
210
 
@@ -225,11 +230,14 @@ export class StreamingRevealController {
225
230
  return total;
226
231
  }
227
232
 
228
- #renderCurrent(total = this.#target ? this.#visibleUnits(this.#target) : 0): void {
233
+ #renderCurrent(): void {
229
234
  if (!this.#target || !this.#component) return;
235
+ // Every controller render is an in-flight streaming snapshot, even when
236
+ // smooth reveal has temporarily caught up to the current target. The
237
+ // message_end handler performs the only stable non-transient render.
230
238
  this.#component.updateContent(
231
239
  buildDisplayMessage(this.#target, this.#revealed, this.#hideThinkingBlock, this.#countOf),
232
- { transient: this.#revealed < total },
240
+ { transient: true },
233
241
  );
234
242
  }
235
243
 
@@ -269,7 +277,7 @@ export class StreamingRevealController {
269
277
  }
270
278
  this.#revealed = Math.min(total, this.#revealed + nextStep(total - this.#revealed));
271
279
  component.updateContent(buildDisplayMessage(target, this.#revealed, this.#hideThinkingBlock, this.#countOf), {
272
- transient: this.#revealed < total,
280
+ transient: true,
273
281
  });
274
282
  this.#requestRender();
275
283
  if (this.#revealed >= total) {