@oh-my-pi/pi-coding-agent 17.2.8 → 17.2.10

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 (246) hide show
  1. package/CHANGELOG.md +94 -1
  2. package/dist/{CHANGELOG-0b0w17se.md → CHANGELOG-rhwzpexa.md} +94 -1
  3. package/dist/cli.js +13216 -15614
  4. package/dist/types/async/job-manager.d.ts +12 -0
  5. package/dist/types/cli/args.d.ts +1 -0
  6. package/dist/types/config/model-resolver.d.ts +13 -0
  7. package/dist/types/discovery/agents.d.ts +11 -0
  8. package/dist/types/extensibility/custom-commands/types.d.ts +5 -5
  9. package/dist/types/extensibility/custom-tools/types.d.ts +1 -1
  10. package/dist/types/extensibility/extensions/loader.d.ts +2 -2
  11. package/dist/types/extensibility/extensions/types.d.ts +34 -8
  12. package/dist/types/extensibility/hooks/types.d.ts +5 -5
  13. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +10 -0
  14. package/dist/types/hindsight/state.d.ts +0 -1
  15. package/dist/types/index.d.ts +2 -2
  16. package/dist/types/lsp/utils.d.ts +8 -0
  17. package/dist/types/main.d.ts +1 -1
  18. package/dist/types/modes/acp/acp-agent.d.ts +1 -1
  19. package/dist/types/modes/acp/acp-client-bridge.d.ts +1 -1
  20. package/dist/types/modes/acp/acp-event-mapper.d.ts +1 -1
  21. package/dist/types/modes/acp/acp-mode.d.ts +1 -1
  22. package/dist/types/modes/components/agent-hub-projection.d.ts +38 -0
  23. package/dist/types/modes/components/agent-hub-renderer.d.ts +41 -0
  24. package/dist/types/modes/components/agent-hub.d.ts +12 -5
  25. package/dist/types/modes/components/model-browser.d.ts +9 -1
  26. package/dist/types/modes/interactive-mode.d.ts +8 -1
  27. package/dist/types/modes/session-observer-registry.d.ts +2 -0
  28. package/dist/types/registry/agent-lifecycle.d.ts +1 -1
  29. package/dist/types/registry/agent-registry.d.ts +47 -0
  30. package/dist/types/registry/persisted-agents.d.ts +3 -1
  31. package/dist/types/session/agent-session-types.d.ts +7 -0
  32. package/dist/types/session/agent-session.d.ts +8 -4
  33. package/dist/types/session/session-entries.d.ts +10 -0
  34. package/dist/types/session/session-loader.d.ts +11 -1
  35. package/dist/types/session/session-manager.d.ts +9 -1
  36. package/dist/types/session/session-tools.d.ts +10 -1
  37. package/dist/types/session/turn-recovery.d.ts +6 -2
  38. package/dist/types/slash-commands/acp-builtins.d.ts +1 -1
  39. package/dist/types/slash-commands/available-commands.d.ts +1 -1
  40. package/dist/types/task/executor.d.ts +10 -0
  41. package/dist/types/task/index.d.ts +2 -3
  42. package/dist/types/task/read-only-policy.d.ts +3 -0
  43. package/dist/types/task/structured-subagent.d.ts +2 -0
  44. package/dist/types/task/types.d.ts +4 -0
  45. package/dist/types/tools/bash.d.ts +3 -3
  46. package/dist/types/tools/browser/launch.d.ts +3 -0
  47. package/dist/types/tools/browser/tab-worker.d.ts +2 -2
  48. package/dist/types/tools/path-utils.d.ts +8 -0
  49. package/dist/types/tools/read.d.ts +3 -5
  50. package/dist/types/tools/run-scope.d.ts +27 -1
  51. package/dist/types/tools/shell-tokenize.d.ts +21 -3
  52. package/dist/types/tools/terminal-output.d.ts +1 -1
  53. package/dist/types/utils/changelog.d.ts +3 -2
  54. package/dist/types/utils/late-cleanup.d.ts +2 -0
  55. package/dist/types/utils/turndown.d.ts +1 -1
  56. package/dist/types/vibe/runtime.d.ts +4 -3
  57. package/dist/types/web/scrapers/readthedocs.d.ts +0 -3
  58. package/dist/types/web/search/providers/browser-headers.d.ts +2 -3
  59. package/examples/custom-tools/README.md +1 -1
  60. package/examples/extensions/README.md +1 -1
  61. package/examples/extensions/api-demo.ts +5 -6
  62. package/examples/extensions/chalk-logger.ts +1 -1
  63. package/examples/extensions/hello.ts +2 -2
  64. package/examples/extensions/reload-runtime.ts +1 -1
  65. package/examples/extensions/tools.ts +2 -2
  66. package/examples/extensions/with-deps/index.ts +1 -1
  67. package/examples/sdk/06-extensions.ts +1 -1
  68. package/package.json +15 -31
  69. package/scripts/bundle-dist.ts +1 -11
  70. package/scripts/omp +11 -2
  71. package/src/advisor/runtime.ts +54 -0
  72. package/src/async/job-manager.ts +26 -0
  73. package/src/cli/agents-cli.ts +1 -1
  74. package/src/cli/args.ts +24 -2
  75. package/src/cli/auth-broker-cli.ts +1 -1
  76. package/src/cli/auth-gateway-cli.ts +1 -1
  77. package/src/cli/bench-cli.ts +1 -1
  78. package/src/cli/claude-trace-cli.ts +1 -1
  79. package/src/cli/config-cli.ts +1 -1
  80. package/src/cli/dry-balance-cli.ts +1 -1
  81. package/src/cli/file-processor.ts +1 -1
  82. package/src/cli/flag-tables.ts +4 -0
  83. package/src/cli/grep-cli.ts +1 -1
  84. package/src/cli/grievances-cli.ts +1 -1
  85. package/src/cli/help-extra.ts +1 -1
  86. package/src/cli/models-cli.ts +1 -1
  87. package/src/cli/plugin-cli.ts +1 -1
  88. package/src/cli/read-cli.ts +1 -1
  89. package/src/cli/setup-cli.ts +1 -1
  90. package/src/cli/shell-cli.ts +1 -1
  91. package/src/cli/ssh-cli.ts +1 -1
  92. package/src/cli/stats-cli.ts +4 -4
  93. package/src/cli/tiny-models-cli.ts +1 -1
  94. package/src/cli/ttsr-cli.ts +1 -1
  95. package/src/cli/update-cli.ts +3 -57
  96. package/src/cli/usage-cli.ts +1 -1
  97. package/src/cli/web-search-cli.ts +1 -1
  98. package/src/cli/worktree-cli.ts +1 -1
  99. package/src/commands/say.ts +1 -1
  100. package/src/commands/token.ts +1 -1
  101. package/src/commit/agentic/agent.ts +1 -1
  102. package/src/commit/cli.ts +1 -1
  103. package/src/config/config-file.ts +2 -2
  104. package/src/config/keybindings.ts +3 -3
  105. package/src/config/model-discovery.ts +36 -2
  106. package/src/config/model-registry.ts +4 -1
  107. package/src/config/model-resolver.ts +54 -7
  108. package/src/discovery/agents.ts +73 -3
  109. package/src/discovery/builtin-rules/ts-no-inline-cast-access.md +5 -4
  110. package/src/discovery/builtin-rules/ts-no-tiny-functions.md +1 -1
  111. package/src/discovery/claude.ts +8 -5
  112. package/src/discovery/cursor.ts +8 -5
  113. package/src/discovery/gemini.ts +11 -8
  114. package/src/discovery/vscode.ts +1 -0
  115. package/src/discovery/windsurf.ts +4 -2
  116. package/src/edit/hashline/filesystem.ts +7 -7
  117. package/src/eval/agent-bridge.ts +1 -3
  118. package/src/eval/jl/prelude.jl +4 -4
  119. package/src/eval/js/shared/prelude.txt +2 -2
  120. package/src/eval/py/prelude.py +0 -3
  121. package/src/eval/py/runner.py +38 -1
  122. package/src/eval/rb/prelude.rb +1 -2
  123. package/src/extensibility/custom-commands/loader.ts +4 -4
  124. package/src/extensibility/custom-commands/types.ts +5 -5
  125. package/src/extensibility/custom-tools/loader.ts +4 -4
  126. package/src/extensibility/custom-tools/types.ts +1 -1
  127. package/src/extensibility/extensions/loader.ts +7 -6
  128. package/src/extensibility/extensions/types.ts +39 -8
  129. package/src/extensibility/hooks/loader.ts +4 -5
  130. package/src/extensibility/hooks/types.ts +5 -5
  131. package/src/extensibility/legacy-pi-coding-agent-shim.ts +23 -0
  132. package/src/extensibility/plugins/legacy-pi-compat.ts +53 -0
  133. package/src/hindsight/state.ts +2 -22
  134. package/src/index.ts +2 -2
  135. package/src/internal-urls/memory-protocol.ts +33 -0
  136. package/src/launch/terminal-output.ts +1 -1
  137. package/src/lsp/client.ts +2 -2
  138. package/src/lsp/utils.ts +29 -0
  139. package/src/main.ts +85 -21
  140. package/src/markit/converters/docx.ts +1 -1
  141. package/src/markit/converters/epub.ts +1 -1
  142. package/src/markit/converters/pptx.ts +1 -1
  143. package/src/markit/converters/xlsx.ts +1 -1
  144. package/src/modes/acp/acp-agent.ts +7 -6
  145. package/src/modes/acp/acp-client-bridge.ts +1 -1
  146. package/src/modes/acp/acp-event-mapper.ts +1 -1
  147. package/src/modes/acp/acp-mode.ts +1 -1
  148. package/src/modes/components/agent-hub-projection.ts +248 -0
  149. package/src/modes/components/agent-hub-renderer.ts +194 -0
  150. package/src/modes/components/agent-hub.ts +670 -192
  151. package/src/modes/components/agent-transcript-viewer.ts +1 -3
  152. package/src/modes/components/assistant-message.ts +1 -1
  153. package/src/modes/components/bordered-loader.ts +1 -1
  154. package/src/modes/components/custom-editor.ts +2 -2
  155. package/src/modes/components/extensions/inspector-panel.ts +11 -6
  156. package/src/modes/components/footer.ts +1 -3
  157. package/src/modes/components/hook-input.ts +1 -1
  158. package/src/modes/components/hook-selector.ts +2 -2
  159. package/src/modes/components/model-browser.ts +11 -3
  160. package/src/modes/components/model-hub.ts +4 -1
  161. package/src/modes/components/session-selector.ts +2 -2
  162. package/src/modes/components/status-line/segments.ts +4 -3
  163. package/src/modes/controllers/event-controller.ts +188 -7
  164. package/src/modes/controllers/extension-ui-controller.ts +4 -3
  165. package/src/modes/controllers/mcp-command-controller.ts +81 -20
  166. package/src/modes/controllers/selector-controller.ts +40 -49
  167. package/src/modes/controllers/streaming-reveal.ts +1 -1
  168. package/src/modes/interactive-mode.ts +13 -2
  169. package/src/modes/print-mode.ts +29 -12
  170. package/src/modes/rpc/rpc-mode.ts +2 -2
  171. package/src/modes/runtime-init.ts +1 -1
  172. package/src/modes/session-observer-registry.ts +5 -0
  173. package/src/modes/theme/theme.ts +2 -2
  174. package/src/prompts/tools/computer.md +1 -1
  175. package/src/registry/agent-lifecycle.ts +35 -10
  176. package/src/registry/agent-registry.ts +65 -2
  177. package/src/registry/persisted-agents.ts +341 -16
  178. package/src/sdk.ts +11 -5
  179. package/src/session/agent-session-types.ts +8 -0
  180. package/src/session/agent-session.ts +177 -188
  181. package/src/session/session-entries.ts +10 -0
  182. package/src/session/session-listing.ts +1 -1
  183. package/src/session/session-loader.ts +74 -8
  184. package/src/session/session-manager.ts +22 -2
  185. package/src/session/session-paths.ts +105 -110
  186. package/src/session/session-tools.ts +28 -1
  187. package/src/session/settings-stream-fn.ts +7 -3
  188. package/src/session/turn-recovery.ts +236 -18
  189. package/src/session/unexpected-stop-classifier.ts +12 -3
  190. package/src/slash-commands/acp-builtins.ts +1 -1
  191. package/src/slash-commands/available-commands.ts +1 -1
  192. package/src/slash-commands/helpers/stats-dashboard.ts +2 -1
  193. package/src/task/agents.ts +1 -1
  194. package/src/task/executor.ts +173 -47
  195. package/src/task/index.ts +9 -28
  196. package/src/task/isolation-runner.ts +43 -12
  197. package/src/task/persisted-revive.ts +12 -5
  198. package/src/task/read-only-policy.ts +27 -0
  199. package/src/task/structured-subagent.ts +31 -6
  200. package/src/task/types.ts +4 -0
  201. package/src/tiny/worker.ts +1 -1
  202. package/src/tools/auto-generated-guard.ts +1 -1
  203. package/src/tools/bash-interactive.ts +4 -4
  204. package/src/tools/bash-interceptor.ts +8 -4
  205. package/src/tools/bash.ts +6 -6
  206. package/src/tools/browser/cmux/cmux-tab.ts +66 -18
  207. package/src/tools/browser/launch.ts +76 -19
  208. package/src/tools/browser/readable.ts +9 -9
  209. package/src/tools/browser/tab-supervisor.ts +12 -3
  210. package/src/tools/browser/tab-worker-entry.ts +1 -1
  211. package/src/tools/browser/tab-worker.ts +101 -12
  212. package/src/tools/fetch.ts +1 -1
  213. package/src/tools/glob.ts +4 -2
  214. package/src/tools/path-utils.ts +22 -5
  215. package/src/tools/read.ts +22 -5
  216. package/src/tools/run-scope.ts +290 -4
  217. package/src/tools/shell-tokenize.ts +38 -9
  218. package/src/tools/terminal-output.ts +1 -1
  219. package/src/tools/todo.ts +1 -1
  220. package/src/utils/changelog.ts +5 -4
  221. package/src/utils/clipboard.ts +10 -2
  222. package/src/utils/external-editor.ts +4 -2
  223. package/src/utils/jj.ts +1 -1
  224. package/src/utils/late-cleanup.ts +17 -0
  225. package/src/utils/turndown.ts +1 -2
  226. package/src/vibe/runtime.ts +202 -58
  227. package/src/web/scrapers/arxiv.ts +1 -1
  228. package/src/web/scrapers/go-pkg.ts +1 -1
  229. package/src/web/scrapers/hackage.ts +1 -12
  230. package/src/web/scrapers/iacr.ts +1 -1
  231. package/src/web/scrapers/readthedocs.ts +2 -1
  232. package/src/web/scrapers/twitter.ts +2 -2
  233. package/src/web/scrapers/types.ts +1 -1
  234. package/src/web/scrapers/wikipedia.ts +1 -1
  235. package/src/web/search/providers/browser-headers.ts +12 -39
  236. package/src/web/search/providers/codex.ts +3 -26
  237. package/src/web/search/providers/duckduckgo.ts +1 -1
  238. package/src/web/search/providers/ecosia.ts +2 -2
  239. package/src/web/search/providers/exa.ts +1 -1
  240. package/src/web/search/providers/firecrawl.ts +1 -1
  241. package/src/web/search/providers/google.ts +1 -1
  242. package/src/web/search/providers/mojeek.ts +2 -2
  243. package/src/web/search/providers/perplexity.ts +1 -1
  244. package/src/web/search/providers/public.ts +1 -1
  245. package/src/web/search/providers/startpage.ts +2 -2
  246. package/src/markit/converters/mammoth.d.ts +0 -24
@@ -613,9 +613,7 @@ export class AgentTranscriptViewer implements Component {
613
613
  }
614
614
 
615
615
  #statsLine(): string {
616
- const observed: ObservableSession | undefined = this.deps.observers
617
- ?.getSessions()
618
- .find(s => s.id === this.deps.agentId);
616
+ const observed: ObservableSession | undefined = this.deps.observers?.getSession(this.deps.agentId);
619
617
  const progress = observed?.progress;
620
618
  if (!progress) return "";
621
619
  const stats: string[] = [];
@@ -11,7 +11,7 @@ import {
11
11
  Text,
12
12
  } from "@oh-my-pi/pi-tui";
13
13
  import { formatNumber } from "@oh-my-pi/pi-utils";
14
- import chalk from "chalk";
14
+ import chalk from "@oh-my-pi/pi-utils/chalk";
15
15
  import type { AssistantThinkingRenderer } from "../../extensibility/extensions/types";
16
16
  import { getMarkdownTheme, theme } from "../../modes/theme/theme";
17
17
  import { expandKeyHint, getPreviewLines, resolveImageOptions, TRUNCATE_LENGTHS } from "../../tools/render-utils";
@@ -35,7 +35,7 @@ export class BorderedLoader extends Container {
35
35
  this.#loader.handleInput(data);
36
36
  }
37
37
 
38
- dispose(): void {
38
+ override dispose(): void {
39
39
  this.#loader.dispose();
40
40
  }
41
41
  }
@@ -466,7 +466,7 @@ export class CustomEditor extends Editor {
466
466
  /** Decorate magic keywords, attachments, and the queue-composer header/list markers.
467
467
  * Queue shorthand reserves its first logical line as a dim `Queueing` label; sequential
468
468
  * item markers use the accent color so separate follow-ups remain visible while composing. */
469
- decorateText = (text: string): string => {
469
+ override decorateText = (text: string): string => {
470
470
  const editorText = this.getText();
471
471
  const animated = this.focused && this.#shimmerEnabled() && hasMagicKeyword(editorText);
472
472
  const phase = animated ? (Date.now() % CustomEditor.SHIMMER_PERIOD_MS) / CustomEditor.SHIMMER_PERIOD_MS : 0;
@@ -775,7 +775,7 @@ export class CustomEditor extends Editor {
775
775
  void promise.then(this.#onPasteSettled, this.#onPasteSettled);
776
776
  }
777
777
 
778
- handleInput(data: string): void {
778
+ override handleInput(data: string): void {
779
779
  // Serialize behind any in-flight async paste so a trailing Enter / follow-up key can't
780
780
  // submit before the clipboard image reaches `pendingImages` (Codex PR #3602 review).
781
781
  if (this.#pasteInFlight > 0) {
@@ -4,7 +4,7 @@
4
4
  * Shows name, description, origin, status, and kind-specific preview.
5
5
  */
6
6
  import * as os from "node:os";
7
- import { isZodSchema, zodToWireSchema } from "@oh-my-pi/pi-ai/utils/schema";
7
+ import { arkToWireSchema, isArkSchema } from "@oh-my-pi/pi-ai/utils/schema";
8
8
  import { type Component, truncateToWidth, wrapTextWithAnsi } from "@oh-my-pi/pi-tui";
9
9
  import { theme } from "../../../modes/theme/theme";
10
10
  import { shortenPath } from "../../../tools/render-utils";
@@ -168,16 +168,21 @@ export class InspectorPanel implements Component {
168
168
  lines.push(theme.fg("dim", theme.boxRound.horizontal.repeat(Math.min(width - 2, 40))));
169
169
 
170
170
  try {
171
- const tool = raw as any;
172
- const wire = (s: unknown): any => (isZodSchema(s) ? zodToWireSchema(s) : s);
173
- const paramSchema = wire(tool?.parameters);
174
- const inputSchema = wire(tool?.inputSchema);
171
+ const tool = raw as { parameters?: unknown; inputSchema?: unknown };
172
+ const wire = (schema: unknown): unknown => (isArkSchema(schema) ? arkToWireSchema(schema) : schema);
173
+ const paramSchema = wire(tool.parameters) as Record<string, unknown> | undefined;
174
+ const inputSchema = wire(tool.inputSchema) as Record<string, unknown> | undefined;
175
175
  const params = paramSchema?.properties || inputSchema?.properties || {};
176
176
 
177
177
  if (Object.keys(params).length === 0) {
178
178
  lines.push(theme.fg("dim", " (no arguments)"));
179
179
  } else {
180
- const required = new Set(paramSchema?.required || inputSchema?.required || []);
180
+ const requiredValue = paramSchema?.required ?? inputSchema?.required;
181
+ const required = new Set(
182
+ Array.isArray(requiredValue)
183
+ ? requiredValue.filter((value): value is string => typeof value === "string")
184
+ : [],
185
+ );
181
186
 
182
187
  for (const [name, spec] of Object.entries(params)) {
183
188
  const param = spec as any;
@@ -212,9 +212,7 @@ export class FooterComponent implements Component {
212
212
  rightSide = `${modelName} • ${resolved ? resolved : `${theme.thinking.autoPending} auto`}`;
213
213
  } else {
214
214
  const thinkingLevel = state.thinkingLevel ?? ThinkingLevel.Off;
215
- if (thinkingLevel !== ThinkingLevel.Off) {
216
- rightSide = `${modelName} • ${thinkingLevel}`;
217
- }
215
+ rightSide = `${modelName} ${thinkingLevel}`;
218
216
  }
219
217
  }
220
218
 
@@ -81,7 +81,7 @@ export class HookInputComponent extends Container {
81
81
  this.#input.pasteText(text);
82
82
  }
83
83
 
84
- dispose(): void {
84
+ override dispose(): void {
85
85
  this.#countdown?.dispose();
86
86
  }
87
87
  }
@@ -135,7 +135,7 @@ class OutlinedList extends Container {
135
135
  this.invalidate();
136
136
  }
137
137
 
138
- render(width: number): readonly string[] {
138
+ override render(width: number): readonly string[] {
139
139
  const borderColor = (text: string) => theme.fg("border", text);
140
140
  const horizontal = borderColor(theme.boxRound.horizontal.repeat(Math.max(1, width)));
141
141
  const innerWidth = Math.max(1, width - 2);
@@ -685,7 +685,7 @@ export class HookSelectorComponent extends Container {
685
685
  return super.render(renderWidth);
686
686
  }
687
687
 
688
- dispose(): void {
688
+ override dispose(): void {
689
689
  this.#countdown?.dispose();
690
690
  }
691
691
  }
@@ -254,8 +254,16 @@ export function thinkingLevelGlyph(level: ConfiguredThinkingLevel): string {
254
254
  }
255
255
 
256
256
  /**
257
- * A slim role chip: `●default ◉` — solid dot for configured assignments,
257
+ * A slim role chip: `● default ◉` — solid dot for configured assignments,
258
258
  * hollow for auto-selected fallbacks, thinking glyph attached when set.
259
+ *
260
+ * The space after the status glyph is load-bearing. Under the `nerd` preset
261
+ * these are Nerd Font private-use icons (U+F111 / U+F10C) whose glyphs are
262
+ * drawn two cells wide, while `visibleWidth` counts them as one
263
+ * (`ambiguousIsNarrow: true` in tui/utils.ts — the PUA block is
264
+ * East_Asian_Width=Ambiguous). Without a separator the icon overhangs and
265
+ * eats the label's first character (`● default` renders as `●efault`).
266
+ * Mirrors the spacing already used for `status.success` in model-hub.
259
267
  */
260
268
  export function formatRoleChip(role: string, assignment: RoleAssignment, settings: Settings): string {
261
269
  const info = getRoleInfo(role, settings);
@@ -263,9 +271,9 @@ export function formatRoleChip(role: string, assignment: RoleAssignment, setting
263
271
  const glyph = thinkingLevelGlyph(assignment.thinkingLevel);
264
272
  const suffix = glyph ? ` ${theme.fg("dim", glyph)}` : "";
265
273
  if (assignment.autoSelected) {
266
- return theme.fg("dim", `${theme.status.shadowed}${label}`) + suffix;
274
+ return theme.fg("dim", `${theme.status.shadowed} ${label}`) + suffix;
267
275
  }
268
- return theme.fg(info.color ?? "muted", `${theme.status.enabled}${label}`) + suffix;
276
+ return theme.fg(info.color ?? "muted", `${theme.status.enabled} ${label}`) + suffix;
269
277
  }
270
278
 
271
279
  /** `$in/out` per-million cost pair; `free` when both legs are zero. */
@@ -837,7 +837,10 @@ export class ModelHubComponent implements Component {
837
837
  chips.push({
838
838
  label,
839
839
  styled: assignedHere
840
- ? theme.fg(info.color ?? "muted", `${theme.status.enabled}${label}`) +
840
+ ? // Separator required: under the `nerd` preset this glyph is a
841
+ // two-cell-wide PUA icon that `visibleWidth` counts as one, so
842
+ // without it the icon overhangs and eats `label`'s first char.
843
+ theme.fg(info.color ?? "muted", `${theme.status.enabled} ${label}`) +
841
844
  theme.fg("dim", ` ${theme.status.success}`)
842
845
  : theme.fg(info.color ?? "muted", label),
843
846
  role,
@@ -905,7 +905,7 @@ export class SessionSelectorComponent extends Container {
905
905
  * is mounted the list is detached from the child tree, so Container's
906
906
  * child-walking dispose would miss its pending history-merge timer.
907
907
  */
908
- dispose(): void {
908
+ override dispose(): void {
909
909
  this.#sessionList.dispose();
910
910
  super.dispose();
911
911
  }
@@ -971,7 +971,7 @@ export class SessionSelectorComponent extends Container {
971
971
  * footer is always visible and never drifts as the list window resizes. The
972
972
  * in-editor selector just appends the footer directly.
973
973
  */
974
- render(width: number): readonly string[] {
974
+ override render(width: number): readonly string[] {
975
975
  const lines: string[] = [];
976
976
  for (const child of this.children) {
977
977
  const childLines = child.render(width);
@@ -117,9 +117,10 @@ const modelSegment: StatusLineSegment = {
117
117
  : `${theme.thinking.autoPending} auto`;
118
118
  } else {
119
119
  const level = state.thinkingLevel ?? ThinkingLevel.Off;
120
- if (level !== ThinkingLevel.Off) {
121
- thinkingDisplay = theme.thinking[level as keyof typeof theme.thinking] ?? "";
122
- }
120
+ thinkingDisplay =
121
+ level === ThinkingLevel.Off
122
+ ? `${theme.status.disabled} off`
123
+ : (theme.thinking[level as keyof typeof theme.thinking] ?? level);
123
124
  }
124
125
  }
125
126
 
@@ -170,6 +170,26 @@ export class EventController {
170
170
  #prevHideThinking = false;
171
171
  #handlers: AgentSessionEventHandlers;
172
172
  #terminalProgressActive = false;
173
+ // Coalescing window for `message_update` events at the subscription boundary.
174
+ // `message_update` carries the CUMULATIVE assistant message (every update
175
+ // re-lists all content blocks), so when a burst of deltas arrives faster than
176
+ // this window only the latest snapshot needs to rebuild streaming state — the
177
+ // intermediate rebuilds are redundant work. The TUI already caps the paint
178
+ // rate via its own render cadence; this caps the per-token handler work that
179
+ // feeds it. Speech stays intact: `#vocalizeDelta` runs at ARRIVAL for every
180
+ // delta before the snapshot is coalesced away.
181
+ #pendingMessageUpdate: Extract<AgentSessionEvent, { type: "message_update" }> | undefined = undefined;
182
+ #messageUpdateTimer: NodeJS.Timeout | undefined = undefined;
183
+ /** Tail of the serialized dispatch chain; see #runSerialized. */
184
+ #dispatchTail: Promise<void> = Promise.resolve();
185
+ /** Whether a chained run is currently in flight (awaiting its own awaits). */
186
+ #dispatchInFlight = false;
187
+ // Deltas already fed to speech at arrival by the coalescer. `#handleMessageUpdate`
188
+ // also vocalizes so the direct `handleEvent` path (tests, session focus replay)
189
+ // keeps working — the WeakSet makes the coalesced path speak each delta exactly
190
+ // once instead of twice.
191
+ #vocalizedMessageUpdates = new WeakSet<object>();
192
+ static readonly #MESSAGE_UPDATE_COALESCE_MS = 33;
173
193
 
174
194
  constructor(private ctx: InteractiveModeContext) {
175
195
  // Enhanced speech (`speech.enhanced`) rewrites blocks through the
@@ -253,6 +273,11 @@ export class EventController {
253
273
  }
254
274
 
255
275
  dispose(): void {
276
+ if (this.#messageUpdateTimer) {
277
+ clearTimeout(this.#messageUpdateTimer);
278
+ this.#messageUpdateTimer = undefined;
279
+ }
280
+ this.#pendingMessageUpdate = undefined;
256
281
  this.#streamingReveal.stop();
257
282
  this.#toolArgsReveal.stop();
258
283
  this.#cancelIdleCompaction();
@@ -425,10 +450,135 @@ export class EventController {
425
450
  }
426
451
 
427
452
  subscribeToAgent(): void {
453
+ // Serialize non-update dispatch behind any in-flight handler run:
454
+ // AgentSession.#emit fires listeners fire-and-forget (it does not await
455
+ // listener promises), so without this a rapid stream tail
456
+ // (message_update → message_end → agent_end) could let a later callback
457
+ // overtake the coalesced flush's handler mid-await — agent_end removing
458
+ // `streamingComponent` before #handleMessageEnd finalizes and records
459
+ // the final message (issue #7443 follow-up). When the tail has settled,
460
+ // dispatch stays synchronous: the flush's streaming rebuild runs before
461
+ // the listener's first await, preserving the timing the coalescing
462
+ // tests assert on. `message_update` enqueue is itself synchronous and
463
+ // needs no serialization.
428
464
  this.ctx.unsubscribe = this.ctx.session.subscribe(async (event: AgentSessionEvent) => {
429
- await this.handleEvent(event);
465
+ // Coalesce the cumulative `message_update` deltas of a streaming turn
466
+ // into at most one handler run per window. `#handleMessageUpdate` is
467
+ // synchronous, so without this every token re-runs the whole
468
+ // streaming rebuild (splitAssistantMessageToolTimeline, reveal
469
+ // setTarget, per-block tool-call reconciliation) even though the TUI
470
+ // paints at most ~30fps — at 40-100 tps the handler work then
471
+ // dominates the CPU profile of an idle-looking streaming session
472
+ // (issue #7443). Only the latest snapshot is meaningful; non-update
473
+ // events flush the pending snapshot first so ordering is preserved.
474
+ if (event.type === "message_update") {
475
+ this.#enqueueMessageUpdate(event);
476
+ return;
477
+ }
478
+ await this.#runSerialized(async () => {
479
+ await this.#flushPendingMessageUpdate();
480
+ await this.handleEvent(event);
481
+ });
430
482
  });
431
483
  }
484
+
485
+ /**
486
+ * Run `run` in the serialized dispatch chain: every run is its own link on
487
+ * the tail, so a burst of events queued behind an in-flight run start one
488
+ * after the other, never concurrently. This closes two races (issue #7443
489
+ * follow-up): a rapid stream tail (message_update → message_end →
490
+ * agent_end) cannot overtake the coalesced flush mid-await — agent_end
491
+ * removing `streamingComponent` before #handleMessageEnd finalizes and
492
+ * records the final message — and two+ events landing in the same window
493
+ * cannot all resume from one shared await and dispatch in parallel. When
494
+ * the chain is drained, `run` starts synchronously (no intermediate
495
+ * microtask), preserving the synchronous-flush timing the coalescing
496
+ * tests assert on. A rejection propagates to the caller (the session's
497
+ * fire-and-forget emit) and the next event starts a fresh chain link
498
+ * instead of being dropped.
499
+ */
500
+ async #runSerialized(run: () => Promise<void>): Promise<void> {
501
+ if (this.#dispatchInFlight) {
502
+ // Queue behind the CURRENT tail: the next run starts only after
503
+ // the previous one settles. Each waiter gets its own link, so a
504
+ // burst cannot fan out from the same shared await.
505
+ const link = this.#dispatchTail.then(
506
+ () => run(),
507
+ () => run(),
508
+ );
509
+ this.#dispatchTail = link;
510
+ void link.then(
511
+ () => {
512
+ // Only the tail owner clears the flag: a later chained
513
+ // link clears it when it settles as the tail.
514
+ if (this.#dispatchTail === link) this.#dispatchInFlight = false;
515
+ },
516
+ () => {
517
+ if (this.#dispatchTail === link) this.#dispatchInFlight = false;
518
+ },
519
+ );
520
+ await link;
521
+ return;
522
+ }
523
+ this.#dispatchInFlight = true;
524
+ const link = run();
525
+ this.#dispatchTail = link;
526
+ void link.then(
527
+ () => {
528
+ if (this.#dispatchTail === link) this.#dispatchInFlight = false;
529
+ },
530
+ () => {
531
+ if (this.#dispatchTail === link) this.#dispatchInFlight = false;
532
+ },
533
+ );
534
+ await link;
535
+ }
536
+
537
+ /**
538
+ * Queue a streaming `message_update` for the next coalesced handler run.
539
+ * Speech is per-delta, so the delta is vocalized at arrival before the
540
+ * snapshot is (possibly) superseded by a newer one.
541
+ */
542
+ #enqueueMessageUpdate(event: Extract<AgentSessionEvent, { type: "message_update" }>): void {
543
+ // Speech is per-delta: every delta is spoken at arrival even when its
544
+ // cumulative snapshot is later superseded and never rebuilt.
545
+ this.#vocalizeDelta(event);
546
+ this.#vocalizedMessageUpdates.add(event);
547
+ this.#pendingMessageUpdate = event;
548
+ if (this.#messageUpdateTimer) return;
549
+ this.#messageUpdateTimer = setTimeout(() => {
550
+ this.#messageUpdateTimer = undefined;
551
+ // Mirror AgentSession.#emit: attach a catch so a streaming rebuild
552
+ // failure surfaces as a logged warning instead of a process-level
553
+ // unhandled rejection (the timer path has no listener to attach one).
554
+ // Runs inside the serialized dispatch chain so a message_end /
555
+ // agent_end landing mid-window cannot overtake this flush (issue
556
+ // #7443 follow-up).
557
+ void this.#runSerialized(async () => {
558
+ await this.#flushPendingMessageUpdate();
559
+ }).catch(err => {
560
+ logger.warn("Message update flush rejected", {
561
+ error: err instanceof Error ? err.message : String(err),
562
+ });
563
+ });
564
+ }, EventController.#MESSAGE_UPDATE_COALESCE_MS);
565
+ }
566
+
567
+ /**
568
+ * Run the coalesced `message_update` handler on the latest pending snapshot
569
+ * (dropping any superseded intermediates) and clear the queue. Safe to call
570
+ * more than once; no-ops when nothing is pending.
571
+ */
572
+ async #flushPendingMessageUpdate(): Promise<void> {
573
+ if (this.#messageUpdateTimer) {
574
+ clearTimeout(this.#messageUpdateTimer);
575
+ this.#messageUpdateTimer = undefined;
576
+ }
577
+ const event = this.#pendingMessageUpdate;
578
+ if (!event) return;
579
+ this.#pendingMessageUpdate = undefined;
580
+ await this.handleEvent(event);
581
+ }
432
582
  /**
433
583
  * Clear every transcript-anchored/turn-scoped piece of state. Used by the
434
584
  * session focus proxy when re-pointing the transcript at another session:
@@ -436,6 +586,11 @@ export class EventController {
436
586
  * session's transcript and must not bleed into the new one.
437
587
  */
438
588
  resetTranscriptAnchors(): void {
589
+ if (this.#messageUpdateTimer) {
590
+ clearTimeout(this.#messageUpdateTimer);
591
+ this.#messageUpdateTimer = undefined;
592
+ }
593
+ this.#pendingMessageUpdate = undefined;
439
594
  this.#resetReadGroup();
440
595
  this.#lastVisibleBlockCount = 0;
441
596
  this.#renderedCustomMessages.clear();
@@ -814,7 +969,9 @@ export class EventController {
814
969
 
815
970
  async #handleMessageUpdate(event: Extract<AgentSessionEvent, { type: "message_update" }>): Promise<void> {
816
971
  this.#ensureWorkingLoaderWhileStreaming();
817
- this.#vocalizeDelta(event);
972
+ if (!this.#vocalizedMessageUpdates.delete(event)) {
973
+ this.#vocalizeDelta(event);
974
+ }
818
975
  if (this.ctx.streamingComponent && event.message.role === "assistant") {
819
976
  const unlockedThinkingVisibility = this.ctx.noteDisplayableThinkingContent(event.message);
820
977
  if (unlockedThinkingVisibility) {
@@ -1448,11 +1605,29 @@ export class EventController {
1448
1605
  typeof details.title === "string" &&
1449
1606
  typeof details.planExists === "boolean"
1450
1607
  ) {
1451
- await this.ctx.handlePlanApproval({
1452
- planFilePath: details.planFilePath,
1453
- title: details.title,
1454
- planExists: details.planExists,
1455
- });
1608
+ // Dispatch the approval WITHOUT blocking the serialized event
1609
+ // dispatch chain. `handlePlanApproval` -> `#approvePlan` awaits
1610
+ // `session.prompt` for the ENTIRE approved-execution turn; awaiting
1611
+ // it here (this handler runs inside `#runSerialized`) would hold the
1612
+ // single dispatch link for the whole run, so the run's own
1613
+ // agent_start / message_start / tool / coalesced message_update
1614
+ // events queue behind it on `#dispatchTail` and the chat stays blank
1615
+ // until execution finishes (issue #7684, follow-up to #5688 which
1616
+ // only closed the overlay). Detaching frees the link the moment this
1617
+ // handler returns; the approval overlay and the turn's live events
1618
+ // then render. The approval flow surfaces its own failures via
1619
+ // `showError`, so only an unexpected rejection is logged here.
1620
+ void this.ctx
1621
+ .handlePlanApproval({
1622
+ planFilePath: details.planFilePath,
1623
+ title: details.title,
1624
+ planExists: details.planExists,
1625
+ })
1626
+ .catch(err => {
1627
+ logger.warn("Plan approval dispatch failed", {
1628
+ error: err instanceof Error ? err.message : String(err),
1629
+ });
1630
+ });
1456
1631
  }
1457
1632
  }
1458
1633
  }
@@ -1479,6 +1654,12 @@ export class EventController {
1479
1654
  // model/thinking level until the terminal settle.
1480
1655
  if (event.isTerminal === false) {
1481
1656
  await this.ctx.flushPendingModelSwitch();
1657
+ // Reaching here means the first guard passed, so `isStreaming` is already
1658
+ // false: a command issued from now on mounts immediately. Leaving earlier
1659
+ // panels queued would render them out of order, minutes later, after the
1660
+ // user was told they were only waiting for the turn. The transcript is
1661
+ // quiescent at a settle, which is the condition #4806 wanted.
1662
+ this.ctx.flushPendingCommandOutput();
1482
1663
  return;
1483
1664
  }
1484
1665
  setTerminalTitleState("idle");
@@ -128,7 +128,7 @@ export class ExtensionUiController {
128
128
  };
129
129
  this.ctx.setToolUIContext(uiContext, true);
130
130
  this.#toolUIContext = uiContext;
131
- this.ctx.session.setUsageFallbackConfirmer?.(confirmation => {
131
+ this.ctx.session.setUsageFallbackConfirmer?.((confirmation, signal) => {
132
132
  const reserve =
133
133
  confirmation.remainingPercent === undefined
134
134
  ? "inside the configured reserve margin"
@@ -136,6 +136,7 @@ export class ExtensionUiController {
136
136
  return this.showHookConfirm(
137
137
  "Coding-plan reserve reached",
138
138
  `${confirmation.from} has ${reserve}. Switch to ${confirmation.to}? Choose No to keep using the current plan.`,
139
+ { signal },
139
140
  );
140
141
  });
141
142
 
@@ -165,7 +166,7 @@ export class ExtensionUiController {
165
166
  this.ctx.sessionManager.appendLabelChange(targetId, label);
166
167
  },
167
168
  getActiveTools: () => this.ctx.session.getEnabledToolNames(),
168
- getAllTools: () => this.ctx.session.getAllToolNames(),
169
+ getAllTools: () => this.ctx.session.getAllToolInfos(),
169
170
  setActiveTools: toolNames => this.ctx.session.setActiveToolsByName(toolNames),
170
171
  setModel: async model => {
171
172
  const key = await this.ctx.session.modelRegistry.getApiKey(model);
@@ -398,7 +399,7 @@ export class ExtensionUiController {
398
399
  this.ctx.sessionManager.appendLabelChange(targetId, label);
399
400
  },
400
401
  getActiveTools: () => this.ctx.session.getEnabledToolNames(),
401
- getAllTools: () => this.ctx.session.getAllToolNames(),
402
+ getAllTools: () => this.ctx.session.getAllToolInfos(),
402
403
  setActiveTools: toolNames => this.ctx.session.setActiveToolsByName(toolNames),
403
404
  setModel: async model => {
404
405
  const key = await this.ctx.session.modelRegistry.getApiKey(model);
@@ -89,6 +89,66 @@ function raceAbortSignal<T>(promise: Promise<T>, signal: AbortSignal, createErro
89
89
  });
90
90
  }
91
91
 
92
+ type ActiveMCPOAuthFlow = {
93
+ cancel: (reason: string) => void;
94
+ completion: Promise<void>;
95
+ complete: () => void;
96
+ };
97
+
98
+ type MCPOAuthFlowCoordinator = {
99
+ active?: ActiveMCPOAuthFlow;
100
+ transition: Promise<void>;
101
+ };
102
+
103
+ const mcpOAuthFlowCoordinators = new WeakMap<object, MCPOAuthFlowCoordinator>();
104
+ const MCP_OAUTH_SUPERSEDED_REASON = "MCP OAuth flow superseded by a new login";
105
+
106
+ /**
107
+ * Serialize MCP OAuth ownership across slash-command controller instances.
108
+ * Interactive mode creates a new controller for every command, while the
109
+ * manual-input manager remains stable for the session and is therefore the
110
+ * lifecycle key.
111
+ */
112
+ async function claimMCPOAuthFlow(owner: object, cancel: (reason: string) => void): Promise<{ release: () => void }> {
113
+ let coordinator = mcpOAuthFlowCoordinators.get(owner);
114
+ if (!coordinator) {
115
+ coordinator = { transition: Promise.resolve() };
116
+ mcpOAuthFlowCoordinators.set(owner, coordinator);
117
+ }
118
+
119
+ const precedingTransition = coordinator.transition;
120
+ const transition = Promise.withResolvers<void>();
121
+ coordinator.transition = transition.promise;
122
+ await precedingTransition;
123
+
124
+ try {
125
+ const active = coordinator.active;
126
+ if (active) {
127
+ active.cancel(MCP_OAUTH_SUPERSEDED_REASON);
128
+ await active.completion;
129
+ }
130
+
131
+ const completion = Promise.withResolvers<void>();
132
+ const flow: ActiveMCPOAuthFlow = {
133
+ cancel,
134
+ completion: completion.promise,
135
+ complete: () => completion.resolve(),
136
+ };
137
+ coordinator.active = flow;
138
+ let released = false;
139
+ return {
140
+ release: () => {
141
+ if (released) return;
142
+ released = true;
143
+ if (coordinator.active === flow) coordinator.active = undefined;
144
+ flow.complete();
145
+ },
146
+ };
147
+ } finally {
148
+ transition.resolve();
149
+ }
150
+ }
151
+
92
152
  /**
93
153
  * Minimum column budget for URL wrapping. Below this the terminal is
94
154
  * effectively unusable, but we still emit chunks so no character is silently
@@ -782,28 +842,23 @@ export class MCPCommandController {
782
842
  const resolvedClientSecret = clientSecret.trim() || undefined;
783
843
 
784
844
  const manualInput = this.ctx.oauthManualInput;
785
- if (manualInput.hasPending()) {
786
- const pendingProvider = manualInput.pendingProviderId ?? "another provider";
787
- throw new Error(
788
- `OAuth login already in progress for ${pendingProvider}. Complete or cancel it before starting MCP OAuth.`,
789
- );
790
- }
791
845
  let manualInputClaim: { promise: Promise<string>; clear: (reason?: string) => void } | undefined;
792
846
  const oauthTimeout = new AbortController();
793
- // User Esc and external aborts route through here; the timeout path sets
794
- // its own reason and leaves this flag false so the catch can distinguish
795
- // "user cancelled" (status) from "deadline elapsed" (error).
796
- let userCancelled = false;
797
- const requestUserCancel = (reason: string): void => {
798
- userCancelled = true;
847
+ // Esc, external aborts, and a replacement MCP flow route through here;
848
+ // the timeout path sets its own reason and leaves this flag false so the
849
+ // catch can distinguish cancellation (status) from deadline failure.
850
+ let cancellationRequested = false;
851
+ const requestCancellation = (reason: string): void => {
852
+ cancellationRequested = true;
799
853
  if (!oauthTimeout.signal.aborted) oauthTimeout.abort(reason);
800
854
  };
855
+ const flowClaim = await claimMCPOAuthFlow(manualInput, requestCancellation);
801
856
  const originalOnEscape = this.ctx.editor.onEscape;
802
- this.ctx.editor.onEscape = () => requestUserCancel(MCP_OAUTH_USER_CANCEL_REASON);
857
+ this.ctx.editor.onEscape = () => requestCancellation(MCP_OAUTH_USER_CANCEL_REASON);
803
858
  const externalSignal = opts?.abortSignal;
804
859
  const onExternalAbort = (): void => {
805
860
  const reason = externalSignal?.reason;
806
- requestUserCancel(typeof reason === "string" ? reason : MCP_OAUTH_USER_CANCEL_REASON);
861
+ requestCancellation(typeof reason === "string" ? reason : MCP_OAUTH_USER_CANCEL_REASON);
807
862
  };
808
863
  if (externalSignal?.aborted) {
809
864
  onExternalAbort();
@@ -811,6 +866,12 @@ export class MCPCommandController {
811
866
  externalSignal?.addEventListener("abort", onExternalAbort, { once: true });
812
867
  }
813
868
  try {
869
+ if (manualInput.hasPending()) {
870
+ const pendingProvider = manualInput.pendingProviderId ?? "another provider";
871
+ throw new Error(
872
+ `OAuth login already in progress for ${pendingProvider}. Complete or cancel it before starting MCP OAuth.`,
873
+ );
874
+ }
814
875
  // Create OAuth flow
815
876
  const flow = new MCPOAuthFlow(
816
877
  {
@@ -887,7 +948,7 @@ export class MCPCommandController {
887
948
 
888
949
  const createAbortError = (): Error => {
889
950
  const reason = String(oauthTimeout.signal.reason ?? "MCP OAuth flow aborted");
890
- return userCancelled ? new MCPOAuthCancelledError() : new Error(reason);
951
+ return cancellationRequested ? new MCPOAuthCancelledError() : new Error(reason);
891
952
  };
892
953
  if (oauthTimeout.signal.aborted) throw createAbortError();
893
954
 
@@ -934,11 +995,10 @@ export class MCPCommandController {
934
995
  resource: flow.resource,
935
996
  };
936
997
  } catch (error) {
937
- // User-initiated cancel (Esc or external signal) neutral status, not
938
- // a failure. Check the flag we set in `requestUserCancel`, not the
939
- // abort reason: the timeout path also aborts but with a different
940
- // reason, and we want it to surface as a timeout error below.
941
- if (userCancelled) {
998
+ // Esc, an external abort, or a newer MCP flow are neutral
999
+ // cancellations. The timeout path also aborts the controller but does
1000
+ // not set this flag, so it remains a surfaced error.
1001
+ if (cancellationRequested) {
942
1002
  throw new MCPOAuthCancelledError();
943
1003
  }
944
1004
 
@@ -960,6 +1020,7 @@ export class MCPCommandController {
960
1020
  this.ctx.editor.onEscape = originalOnEscape;
961
1021
  externalSignal?.removeEventListener("abort", onExternalAbort);
962
1022
  manualInputClaim?.clear("Manual MCP OAuth input cleared");
1023
+ flowClaim.release();
963
1024
  }
964
1025
  }
965
1026