@oh-my-pi/pi-coding-agent 17.0.1 → 17.0.2

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 (126) hide show
  1. package/CHANGELOG.md +92 -20
  2. package/dist/cli.js +3485 -3448
  3. package/dist/types/advisor/config.d.ts +14 -6
  4. package/dist/types/advisor/runtime.d.ts +20 -11
  5. package/dist/types/autolearn/controller.d.ts +1 -0
  6. package/dist/types/config/model-discovery.d.ts +17 -0
  7. package/dist/types/config/model-resolver.d.ts +6 -2
  8. package/dist/types/config/settings-schema.d.ts +32 -7
  9. package/dist/types/config/settings.d.ts +50 -0
  10. package/dist/types/cursor.d.ts +11 -0
  11. package/dist/types/discovery/helpers.d.ts +5 -2
  12. package/dist/types/exec/bash-executor.d.ts +2 -0
  13. package/dist/types/extensibility/extensions/managed-timers.d.ts +15 -0
  14. package/dist/types/extensibility/extensions/runner.d.ts +7 -0
  15. package/dist/types/extensibility/extensions/types.d.ts +18 -0
  16. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +40 -0
  17. package/dist/types/extensibility/shared-events.d.ts +6 -0
  18. package/dist/types/extensibility/utils.d.ts +2 -2
  19. package/dist/types/mcp/transports/stdio.d.ts +13 -1
  20. package/dist/types/modes/components/advisor-config.d.ts +8 -1
  21. package/dist/types/modes/components/hook-editor.d.ts +7 -0
  22. package/dist/types/modes/components/model-hub.d.ts +5 -2
  23. package/dist/types/modes/components/session-selector.d.ts +2 -0
  24. package/dist/types/modes/controllers/command-controller.d.ts +8 -0
  25. package/dist/types/modes/controllers/selector-controller.d.ts +3 -1
  26. package/dist/types/modes/print-mode.d.ts +1 -1
  27. package/dist/types/modes/warp-events.d.ts +24 -0
  28. package/dist/types/modes/warp-events.test.d.ts +1 -0
  29. package/dist/types/plan-mode/model-transition.d.ts +47 -0
  30. package/dist/types/plan-mode/model-transition.test.d.ts +1 -0
  31. package/dist/types/registry/agent-lifecycle.d.ts +26 -1
  32. package/dist/types/sdk.d.ts +13 -2
  33. package/dist/types/session/agent-session.d.ts +34 -10
  34. package/dist/types/session/session-history-format.d.ts +10 -0
  35. package/dist/types/session/session-manager.d.ts +14 -0
  36. package/dist/types/slash-commands/helpers/active-oauth-account.d.ts +9 -0
  37. package/dist/types/task/label.d.ts +1 -1
  38. package/dist/types/telemetry-export.d.ts +34 -9
  39. package/dist/types/tools/approval.d.ts +8 -0
  40. package/dist/types/tools/bash.d.ts +2 -0
  41. package/dist/types/tools/essential-tools.d.ts +29 -0
  42. package/dist/types/tools/image-gen.d.ts +2 -1
  43. package/dist/types/tools/index.d.ts +1 -0
  44. package/dist/types/tools/xdev.d.ts +11 -2
  45. package/dist/types/utils/title-generator.d.ts +2 -1
  46. package/dist/types/web/search/providers/kimi.d.ts +4 -1
  47. package/dist/types/web/search/types.d.ts +1 -1
  48. package/package.json +21 -16
  49. package/src/advisor/__tests__/advisor.test.ts +1304 -42
  50. package/src/advisor/__tests__/config.test.ts +58 -2
  51. package/src/advisor/config.ts +76 -24
  52. package/src/advisor/runtime.ts +445 -92
  53. package/src/autolearn/controller.ts +23 -28
  54. package/src/cli.ts +5 -1
  55. package/src/config/model-discovery.ts +81 -21
  56. package/src/config/model-registry.ts +25 -6
  57. package/src/config/model-resolver.ts +14 -7
  58. package/src/config/settings-schema.ts +42 -6
  59. package/src/config/settings.ts +405 -25
  60. package/src/cursor.ts +20 -3
  61. package/src/debug/report-bundle.ts +40 -4
  62. package/src/discovery/helpers.ts +28 -5
  63. package/src/exec/bash-executor.ts +14 -5
  64. package/src/extensibility/custom-tools/loader.ts +3 -3
  65. package/src/extensibility/custom-tools/wrapper.ts +2 -1
  66. package/src/extensibility/extensions/loader.ts +3 -3
  67. package/src/extensibility/extensions/managed-timers.ts +83 -0
  68. package/src/extensibility/extensions/runner.ts +26 -0
  69. package/src/extensibility/extensions/types.ts +18 -0
  70. package/src/extensibility/extensions/wrapper.ts +2 -1
  71. package/src/extensibility/hooks/loader.ts +3 -3
  72. package/src/extensibility/legacy-pi-coding-agent-shim.ts +96 -1
  73. package/src/extensibility/plugins/legacy-pi-compat.ts +225 -22
  74. package/src/extensibility/plugins/manager.ts +2 -2
  75. package/src/extensibility/shared-events.ts +6 -0
  76. package/src/extensibility/utils.ts +91 -25
  77. package/src/irc/bus.ts +22 -3
  78. package/src/launch/broker.ts +3 -2
  79. package/src/launch/client.ts +2 -2
  80. package/src/launch/presence.ts +2 -2
  81. package/src/lsp/client.ts +1 -1
  82. package/src/main.ts +11 -8
  83. package/src/mcp/manager.ts +9 -3
  84. package/src/mcp/transports/stdio.ts +103 -23
  85. package/src/modes/components/advisor-config.ts +65 -3
  86. package/src/modes/components/ask-dialog.ts +1 -1
  87. package/src/modes/components/hook-editor.ts +18 -3
  88. package/src/modes/components/model-hub.ts +138 -42
  89. package/src/modes/components/session-selector.ts +4 -0
  90. package/src/modes/components/status-line/component.test.ts +1 -0
  91. package/src/modes/components/status-line/segments.ts +21 -6
  92. package/src/modes/controllers/command-controller.ts +167 -47
  93. package/src/modes/controllers/event-controller.ts +5 -0
  94. package/src/modes/controllers/extension-ui-controller.ts +4 -22
  95. package/src/modes/controllers/input-controller.ts +12 -12
  96. package/src/modes/controllers/selector-controller.ts +191 -31
  97. package/src/modes/interactive-mode.ts +139 -54
  98. package/src/modes/print-mode.ts +3 -3
  99. package/src/modes/rpc/host-tools.ts +2 -1
  100. package/src/modes/rpc/rpc-mode.ts +19 -4
  101. package/src/modes/warp-events.test.ts +794 -0
  102. package/src/modes/warp-events.ts +232 -0
  103. package/src/plan-mode/model-transition.test.ts +60 -0
  104. package/src/plan-mode/model-transition.ts +51 -0
  105. package/src/registry/agent-lifecycle.ts +133 -18
  106. package/src/sdk.ts +221 -42
  107. package/src/session/agent-session.ts +1285 -348
  108. package/src/session/session-history-format.ts +20 -5
  109. package/src/session/session-manager.ts +48 -0
  110. package/src/slash-commands/builtin-registry.ts +7 -0
  111. package/src/slash-commands/helpers/active-oauth-account.ts +16 -0
  112. package/src/task/executor.ts +1 -1
  113. package/src/task/label.ts +2 -0
  114. package/src/telemetry-export.ts +453 -97
  115. package/src/tools/approval.ts +11 -0
  116. package/src/tools/bash.ts +71 -38
  117. package/src/tools/essential-tools.ts +45 -0
  118. package/src/tools/gh.ts +169 -2
  119. package/src/tools/image-gen.ts +69 -7
  120. package/src/tools/index.ts +7 -5
  121. package/src/tools/read.ts +48 -3
  122. package/src/tools/write.ts +22 -4
  123. package/src/tools/xdev.ts +14 -3
  124. package/src/utils/title-generator.ts +15 -4
  125. package/src/web/search/providers/kimi.ts +18 -12
  126. package/src/web/search/types.ts +6 -1
@@ -572,8 +572,14 @@ export class MCPManager {
572
572
  };
573
573
  }
574
574
 
575
+ /**
576
+ * Ownership is matched via `mcpServerName`, never a `mcp__${name}_` name
577
+ * prefix: tool names are lossy-sanitized, so one server's sanitized name
578
+ * can prefix another's (`atlassian` vs `atlassian:atlassian`) and a name
579
+ * with sanitized characters never prefix-matches its own tools at all.
580
+ */
575
581
  #replaceServerTools(name: string, tools: CustomTool<TSchema, MCPToolDetails>[]): void {
576
- this.#tools = this.#tools.filter(t => !t.name.startsWith(`mcp__${name}_`));
582
+ this.#tools = this.#tools.filter(t => t.mcpServerName !== name);
577
583
  this.#tools.push(...tools);
578
584
  // Stable sort by name so reconnect order does not perturb the array.
579
585
  // See `sortMCPToolsByName` for the cache-stability rationale.
@@ -762,8 +768,8 @@ export class MCPManager {
762
768
  }
763
769
 
764
770
  // Remove tools from this server and notify consumers
765
- const hadTools = this.#tools.some(t => t.name.startsWith(`mcp__${name}_`));
766
- this.#tools = this.#tools.filter(t => !t.name.startsWith(`mcp__${name}_`));
771
+ const hadTools = this.#tools.some(t => t.mcpServerName === name);
772
+ this.#tools = this.#tools.filter(t => t.mcpServerName !== name);
767
773
  if (hadTools) this.#onToolsChanged?.(this.#tools);
768
774
 
769
775
  // Notify prompt consumers so stale commands are cleared
@@ -54,6 +54,18 @@ export interface StdioSpawnCommand {
54
54
  * grandchildren keep stdout routed through our pipe (#3544).
55
55
  */
56
56
  detached: boolean;
57
+ /**
58
+ * Pass argv to `Bun.spawn` verbatim (Windows only), suppressing the
59
+ * default libuv backslash-quoting.
60
+ *
61
+ * Set when `cmd` already holds a `cmd.exe /d /e:ON /v:OFF /c "<line>"`
62
+ * command line escaped for `cmd.exe`'s parser (see `buildCmdExeArgv`).
63
+ * libuv's quoting targets `CommandLineToArgvW`, not `cmd.exe`, so letting
64
+ * it re-quote a batch launch would corrupt arguments and re-open the
65
+ * `%VAR%` / quote-injection holes the escaping closes (BatBadBut,
66
+ * CVE-2024-24576).
67
+ */
68
+ windowsVerbatimArguments?: boolean;
57
69
  }
58
70
 
59
71
  /** Inputs used to resolve platform-specific stdio spawn behavior. */
@@ -203,23 +215,6 @@ async function resolveWindowsNpmShimCommand(
203
215
  };
204
216
  }
205
217
 
206
- function quoteCmdArg(value: string): string {
207
- if (value.length === 0) return '""';
208
- let result = '"';
209
- for (const char of value) {
210
- if (char === '"') {
211
- result += '^"';
212
- } else if (char === "^") {
213
- result += "^^";
214
- } else if (char === "%") {
215
- result += "^%";
216
- } else {
217
- result += char;
218
- }
219
- }
220
- return `${result}"`;
221
- }
222
-
223
218
  function isWindowsBatchCommand(command: string): boolean {
224
219
  return WINDOWS_BATCH_EXTENSIONS.has(path.extname(command).toLowerCase());
225
220
  }
@@ -229,10 +224,93 @@ function resolveComSpec(env: Record<string, string | undefined>): string {
229
224
  return comspec && comspec.length > 0 ? comspec : "cmd.exe";
230
225
  }
231
226
 
232
- /** `cmd /s /c` strips one outer quote pair; keep inner argv quotes intact. */
233
- function buildCmdExeCommand(command: string, args: readonly string[]): string {
234
- const quotedCommand = [command, ...args].map(quoteCmdArg).join(" ");
235
- return `"${quotedCommand}"`;
227
+ // Argument bytes cmd.exe delivers unchanged without quoting. Anything outside
228
+ // this set (spaces, quotes, `%`, shell metacharacters, non-ASCII) forces the
229
+ // quoted+escaped path below. Mirrors the fuzz-tested allow-list from Zig's
230
+ // BatBadBut mitigation.
231
+ const CMD_SAFE_ARG = /^[A-Za-z0-9#$*+\-./:?@\\_]+$/;
232
+
233
+ /**
234
+ * Escape the interior of a `cmd.exe`-quoted token: neutralize `%VAR%` expansion
235
+ * and double any backslash run that precedes a quote (including the caller's
236
+ * closing quote) so `CommandLineToArgvW` delivers the backslashes literally.
237
+ *
238
+ * `cmd.exe` re-parses the whole `/c` string and expands `%…%` *before* the
239
+ * batch shim's own argv split runs, so both the command path and every argument
240
+ * must pass through this. Percent → `%%cd:~,%` (which expands to nothing,
241
+ * leaving a literal `%`) and `"` → `""` are the documented BatBadBut mitigation
242
+ * (CVE-2024-24576). The caller supplies the surrounding double quotes.
243
+ *
244
+ * @see https://flatt.tech/research/posts/batbadbut-you-cant-securely-execute-commands-on-windows/
245
+ */
246
+ function escapeCmdQuotedInterior(value: string): string {
247
+ let out = "";
248
+ let backslashes = 0;
249
+ for (const ch of value) {
250
+ if (ch === "\\") {
251
+ backslashes += 1;
252
+ out += ch;
253
+ } else if (ch === '"') {
254
+ out += "\\".repeat(backslashes);
255
+ out += '""';
256
+ backslashes = 0;
257
+ } else if (ch === "%") {
258
+ out += "%%cd:~,%";
259
+ backslashes = 0;
260
+ } else {
261
+ backslashes = 0;
262
+ out += ch;
263
+ }
264
+ }
265
+ // Double the trailing backslash run so it stays literal before the closing
266
+ // quote the caller appends.
267
+ out += "\\".repeat(backslashes);
268
+ return out;
269
+ }
270
+
271
+ /** Reject bytes that cannot round-trip through `cmd.exe`'s `/c` command line. */
272
+ function assertCmdBatchToken(value: string, kind: "command" | "argument"): void {
273
+ // NUL/LF act as an end-of-command marker and CR is stripped, so any of them
274
+ // would silently truncate or corrupt the launch.
275
+ if (/[\0\r\n]/.test(value)) {
276
+ throw new Error(`Windows batch MCP ${kind} cannot contain NUL, CR, or LF characters`);
277
+ }
278
+ }
279
+
280
+ /**
281
+ * Escape one argument for `cmd.exe`'s command-line pre-parse so a `.cmd`/`.bat`
282
+ * shim receives it verbatim. Quotes only when the argument is empty, ends in a
283
+ * backslash, or holds a byte outside {@link CMD_SAFE_ARG}; the quoted body is
284
+ * escaped by {@link escapeCmdQuotedInterior}.
285
+ *
286
+ * @throws when the argument contains NUL, CR, or LF (see {@link assertCmdBatchToken}).
287
+ */
288
+ function escapeCmdBatchArg(arg: string): string {
289
+ assertCmdBatchToken(arg, "argument");
290
+ const needsQuotes = arg.length === 0 || arg.endsWith("\\") || !CMD_SAFE_ARG.test(arg);
291
+ // An unquoted arg is pure allow-list bytes (no `%`, `"`, or trailing `\`), so
292
+ // it needs no interior escaping.
293
+ return needsQuotes ? `"${escapeCmdQuotedInterior(arg)}"` : arg;
294
+ }
295
+
296
+ /**
297
+ * Build the `cmd.exe` argv for a Windows `.cmd`/`.bat` (or unresolved bare)
298
+ * MCP command.
299
+ *
300
+ * The trailing element is a single `/c` string wrapped in an outer quote pair
301
+ * that `cmd.exe` strips (its opening-quote rule). The command token is always
302
+ * quoted and, like every argument, escaped so a `%` in the resolved path (e.g.
303
+ * `C:\work\%TOKEN%\server.cmd`) is not expanded before the shim launches.
304
+ * `/e:ON` keeps command extensions on (required for the `%%cd:~,%` trick) and
305
+ * `/v:OFF` disables delayed expansion. The result MUST be spawned with
306
+ * `windowsVerbatimArguments` so libuv passes it through unmodified.
307
+ */
308
+ function buildCmdExeArgv(comspec: string, command: string, args: readonly string[]): string[] {
309
+ assertCmdBatchToken(command, "command");
310
+ let line = `""${escapeCmdQuotedInterior(command)}"`;
311
+ for (const arg of args) line += ` ${escapeCmdBatchArg(arg)}`;
312
+ line += '"';
313
+ return [comspec, "/d", "/e:ON", "/v:OFF", "/c", line];
236
314
  }
237
315
 
238
316
  /**
@@ -245,7 +323,7 @@ function buildCmdExeCommand(command: string, args: readonly string[]): string {
245
323
  * only appends `.exe` for extensionless names — `.cmd`/`.bat` are never
246
324
  * tried, so `npx` (which exists only as `npx.cmd` on Windows) crashes the
247
325
  * subprocess immediately. When the resolver can't pin the command down,
248
- * route through `cmd.exe /d /s /c` so Windows's own PATHEXT lookup runs.
326
+ * route through `cmd.exe` so Windows's own PATHEXT lookup runs.
249
327
  */
250
328
  export async function resolveStdioSpawnCommand(
251
329
  config: MCPStdioServerConfig,
@@ -271,9 +349,10 @@ export async function resolveStdioSpawnCommand(
271
349
  if (!needsCmdExe) return { cmd: [resolvedCommand, ...args], windowsHide, detached };
272
350
 
273
351
  return {
274
- cmd: [resolveComSpec(options.env), "/d", "/s", "/c", buildCmdExeCommand(resolvedCommand, args)],
352
+ cmd: buildCmdExeArgv(resolveComSpec(options.env), resolvedCommand, args),
275
353
  windowsHide,
276
354
  detached,
355
+ windowsVerbatimArguments: true,
277
356
  };
278
357
  }
279
358
 
@@ -388,6 +467,7 @@ export class StdioTransport implements MCPTransport {
388
467
  stderr: "pipe",
389
468
  windowsHide: spawnCommand.windowsHide,
390
469
  detached: spawnCommand.detached,
470
+ windowsVerbatimArguments: spawnCommand.windowsVerbatimArguments,
391
471
  });
392
472
 
393
473
  this.#connected = true;
@@ -16,7 +16,7 @@
16
16
  * `save` callback.
17
17
  */
18
18
  import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
19
- import type { Model } from "@oh-my-pi/pi-ai";
19
+ import type { Model, UsageReport } from "@oh-my-pi/pi-ai";
20
20
  import { getSupportedEfforts } from "@oh-my-pi/pi-catalog/model-thinking";
21
21
  import {
22
22
  type Component,
@@ -38,6 +38,9 @@ import {
38
38
  import type { ModelRegistry } from "../../config/model-registry";
39
39
  import { formatModelSelectorValue } from "../../config/model-resolver";
40
40
  import type { Settings } from "../../config/settings";
41
+ import type { PerAdvisorStat } from "../../session/agent-session";
42
+ import type { OAuthAccountIdentity } from "../../session/auth-storage";
43
+ import { formatCompactQuota } from "../controllers/command-controller";
41
44
  import { getSelectListTheme, theme } from "../theme/theme";
42
45
  import { HookEditorComponent } from "./hook-editor";
43
46
  import { buildBrowserItems, ModelBrowser, sortModelItems } from "./model-browser";
@@ -63,6 +66,11 @@ export interface AdvisorConfigCallbacks {
63
66
  requestRender: () => void;
64
67
  /** Surface a transient status/warning line to the user. */
65
68
  notify: (message: string) => void;
69
+ /** Live advisor usage stats; lets the preview show tokens/cost per advisor. */
70
+ getAdvisorStats?: () => PerAdvisorStat[];
71
+ getUsageReports?: () => Promise<UsageReport[] | null>;
72
+ /** Resolve the active OAuth identity for quota filtering (per-advisor account stickiness). */
73
+ resolveActiveAccount?: (provider: string, sessionId?: string) => OAuthAccountIdentity | undefined;
66
74
  }
67
75
 
68
76
  export interface AdvisorConfigDeps {
@@ -126,6 +134,8 @@ export class AdvisorConfigOverlayComponent implements Component {
126
134
  #cb: AdvisorConfigCallbacks;
127
135
  #scope: AdvisorConfigScope;
128
136
  #doc: WatchdogConfigDoc;
137
+ /** Cached usage reports (quota/window/reset) prefetched on overlay open. */
138
+ #cachedReports: UsageReport[] | null = null;
129
139
  #dirty = false;
130
140
 
131
141
  #screen: Screen = "list";
@@ -157,6 +167,16 @@ export class AdvisorConfigOverlayComponent implements Component {
157
167
  this.#doc = doc;
158
168
  this.#ensureRosterVisible();
159
169
  this.#showList();
170
+ // Prefetch usage reports for quota display; non-fatal if unavailable.
171
+ if (callbacks.getUsageReports) {
172
+ void callbacks
173
+ .getUsageReports()
174
+ .then(r => {
175
+ this.#cachedReports = r;
176
+ this.#cb.requestRender();
177
+ })
178
+ .catch(() => {});
179
+ }
160
180
  }
161
181
 
162
182
  // ───────────────────────────── render ─────────────────────────────
@@ -275,6 +295,7 @@ export class AdvisorConfigOverlayComponent implements Component {
275
295
  const lines = [
276
296
  theme.bold(advisor.name || "(unnamed)"),
277
297
  "",
298
+ `${theme.fg("dim", "Enabled:")} ${advisor.enabled === false ? "○ off" : "● on"}`,
278
299
  `${theme.fg("dim", "Model:")} ${model}`,
279
300
  `${theme.fg("dim", "Tools:")} ${tools}`,
280
301
  "",
@@ -282,6 +303,34 @@ export class AdvisorConfigOverlayComponent implements Component {
282
303
  ];
283
304
  const instr = advisor.instructions?.trim();
284
305
  lines.push(...(instr ? wrap(instr, bodyWidth) : [theme.fg("muted", "(none)")]));
306
+ // Show live usage stats when available from the session.
307
+ const liveStat = this.#cb.getAdvisorStats?.()?.find(s => s.name === (advisor.name || "default"));
308
+ if (liveStat && (liveStat.status === "running" || liveStat.status === "quota_exhausted")) {
309
+ lines.push("", theme.fg("dim", "Usage:"));
310
+ const spendParts: string[] = [
311
+ `${liveStat.tokens.input.toLocaleString()} in`,
312
+ `${liveStat.tokens.output.toLocaleString()} out`,
313
+ ];
314
+ if (liveStat.tokens.cacheRead > 0) spendParts.push(`${liveStat.tokens.cacheRead.toLocaleString()} cache`);
315
+ lines.push(theme.fg("dim", ` Tokens: ${spendParts.join(", ")}`));
316
+ if (liveStat.cost > 0) lines.push(theme.fg("dim", ` Cost: $${liveStat.cost.toFixed(4)}`));
317
+ if (liveStat.contextWindow > 0) {
318
+ const pct = Math.round((liveStat.contextTokens / liveStat.contextWindow) * 100);
319
+ lines.push(
320
+ theme.fg(
321
+ "dim",
322
+ ` Context: ${liveStat.contextTokens.toLocaleString()}/${liveStat.contextWindow.toLocaleString()} (${pct}%)`,
323
+ ),
324
+ );
325
+ }
326
+ }
327
+ const quotaProvider =
328
+ (advisor.model?.includes("/") ? advisor.model.split("/")[0] : null) ?? liveStat?.model?.provider;
329
+ if (this.#cachedReports && quotaProvider) {
330
+ const activeAccount = this.#cb.resolveActiveAccount?.(quotaProvider, liveStat?.sessionId);
331
+ const quota = formatCompactQuota(quotaProvider, this.#cachedReports, Date.now(), activeAccount);
332
+ if (quota) lines.push(theme.fg("dim", ` ${quota}`));
333
+ }
285
334
  return lines.map(line => truncateToWidth(line, bodyWidth));
286
335
  }
287
336
 
@@ -311,7 +360,8 @@ export class AdvisorConfigOverlayComponent implements Component {
311
360
  advisor.name === "default" &&
312
361
  !advisor.model?.trim() &&
313
362
  advisor.tools === undefined &&
314
- !advisor.instructions?.trim()
363
+ !advisor.instructions?.trim() &&
364
+ advisor.enabled !== false
315
365
  );
316
366
  }
317
367
 
@@ -325,7 +375,7 @@ export class AdvisorConfigOverlayComponent implements Component {
325
375
  this.#ensureRosterVisible();
326
376
  const items: SelectItem[] = this.#doc.advisors.map((advisor, index) => ({
327
377
  value: `advisor:${index}`,
328
- label: advisor.name || "(unnamed)",
378
+ label: `${advisor.enabled === false ? "○" : "●"} ${advisor.name || "(unnamed)"}`,
329
379
  description: this.#advisorSummary(advisor),
330
380
  }));
331
381
  items.push({ value: "add", label: "+ Add advisor" });
@@ -395,6 +445,11 @@ export class AdvisorConfigOverlayComponent implements Component {
395
445
  const toolsDescription = formatAdvisorTools(advisor.tools, "no tools");
396
446
  const items: SelectItem[] = [
397
447
  { value: "name", label: "Name", description: advisor.name },
448
+ {
449
+ value: "toggleEnabled",
450
+ label: "Enabled",
451
+ description: advisor.enabled === false ? "○ off" : "● on",
452
+ },
398
453
  { value: "model", label: "Model", description: modelDescription },
399
454
  ];
400
455
  if (advisor.model?.trim()) {
@@ -414,6 +469,13 @@ export class AdvisorConfigOverlayComponent implements Component {
414
469
 
415
470
  #onDetailSelect(index: number, field: string): void {
416
471
  switch (field) {
472
+ case "toggleEnabled": {
473
+ const a = this.#doc.advisors[index];
474
+ a.enabled = a.enabled === false ? undefined : false;
475
+ this.#dirty = true;
476
+ this.#showDetail(index);
477
+ return;
478
+ }
417
479
  case "name":
418
480
  this.#showNameEditor(index);
419
481
  return;
@@ -556,7 +556,7 @@ export class AskDialogComponent implements Component {
556
556
  }
557
557
  const isEnter = matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n";
558
558
  const isSpace = matchesKey(keyData, "space") || keyData === " ";
559
- if (!isEnter && !isSpace) return;
559
+ if (!isEnter && !(question.multi && isSpace)) return;
560
560
  if (rowItem.kind === "other") {
561
561
  void this.#promptForCustomInput(question, state, rowItem);
562
562
  return;
@@ -20,6 +20,13 @@ import { DynamicBorder } from "./dynamic-border";
20
20
  export interface HookEditorOptions {
21
21
  /** When true, use prompt-style keybindings with the legacy ask prompt chrome. */
22
22
  promptStyle?: boolean;
23
+ /**
24
+ * Max rows the inner Editor may occupy. When omitted, the editor is
25
+ * bounded to the current terminal height minus the component's chrome
26
+ * (≈10 rows) so long content scrolls instead of pushing the submit
27
+ * hint out of view.
28
+ */
29
+ maxHeight?: number;
23
30
  }
24
31
 
25
32
  /** Interactive multiline dialog used by hooks and the ask tool's Other response. */
@@ -50,8 +57,11 @@ export class HookEditorComponent extends Container implements Focusable {
50
57
  this.addChild(new DynamicBorder());
51
58
  this.addChild(new Spacer(1));
52
59
 
53
- // Title
54
- this.addChild(new Text(theme.fg("accent", title), 1, 0));
60
+ // Title. Prompt-style renders the borderless editor's `> ` gutter at
61
+ // column 0, so pad the title to match; hook-style keeps the 1-col indent
62
+ // that lines up with its bordered editor body (#5313).
63
+ const chromePadX = this.#promptStyle ? 0 : 1;
64
+ this.addChild(new Text(theme.fg("accent", title), chromePadX, 0));
55
65
  this.addChild(new Spacer(1));
56
66
 
57
67
  // Editor
@@ -61,6 +71,11 @@ export class HookEditorComponent extends Container implements Focusable {
61
71
  this.#editor.setPromptGutter("> ");
62
72
  this.#editor.disableSubmit = true;
63
73
  }
74
+ // Bound the editor so long content scrolls instead of pushing the
75
+ // submit hint off-screen. Caller may override via options.maxHeight.
76
+ const termRows = this.#tui.terminal?.rows ?? process.stdout.rows ?? 40;
77
+ this.#editor.setMaxHeight(options?.maxHeight ?? Math.max(3, termRows - 12));
78
+ this.#editor.setScrollbarVisible(true);
64
79
  if (prefill) {
65
80
  this.#editor.setText(prefill);
66
81
  }
@@ -72,7 +87,7 @@ export class HookEditorComponent extends Container implements Focusable {
72
87
  const hint = this.#promptStyle
73
88
  ? "enter or ctrl+q submit esc cancel ctrl+g external editor"
74
89
  : "ctrl+q/ctrl+enter submit esc cancel ctrl+g external editor";
75
- this.addChild(new Text(theme.fg("dim", hint), 1, 0));
90
+ this.addChild(new Text(theme.fg("dim", hint), chromePadX, 0));
76
91
 
77
92
  this.addChild(new Spacer(1));
78
93
  this.addChild(new DynamicBorder());