@oh-my-pi/pi-coding-agent 17.0.8 → 17.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (340) hide show
  1. package/CHANGELOG.md +87 -1
  2. package/README.md +1 -1
  3. package/dist/cli.js +5495 -5021
  4. package/dist/types/async/job-manager.d.ts +37 -1
  5. package/dist/types/cli/args.d.ts +2 -0
  6. package/dist/types/cli/bench-cli.d.ts +41 -0
  7. package/dist/types/commands/bench.d.ts +15 -3
  8. package/dist/types/commands/launch.d.ts +4 -0
  9. package/dist/types/config/__tests__/model-registry.test.d.ts +1 -0
  10. package/dist/types/config/model-registry.d.ts +30 -1
  11. package/dist/types/config/models-config-schema.d.ts +18 -6
  12. package/dist/types/config/models-config.d.ts +14 -5
  13. package/dist/types/config/provider-globals.d.ts +2 -2
  14. package/dist/types/config/service-tier.d.ts +5 -1
  15. package/dist/types/config/settings-schema.d.ts +350 -50
  16. package/dist/types/config/settings.d.ts +7 -0
  17. package/dist/types/dap/client.d.ts +20 -0
  18. package/dist/types/exec/bash-executor.d.ts +38 -0
  19. package/dist/types/exec/direnv.d.ts +33 -0
  20. package/dist/types/export/html/args.d.ts +15 -0
  21. package/dist/types/export/html/index.d.ts +11 -26
  22. package/dist/types/export/html/web-palette.d.ts +55 -116
  23. package/dist/types/extensibility/extensions/loader.d.ts +3 -0
  24. package/dist/types/extensibility/extensions/types.d.ts +17 -2
  25. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +1 -0
  26. package/dist/types/extensibility/legacy-pi-tui-shim.d.ts +10 -0
  27. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +14 -1
  28. package/dist/types/internal-urls/registry-helpers.d.ts +11 -0
  29. package/dist/types/live/controller.d.ts +40 -0
  30. package/dist/types/live/protocol.d.ts +85 -0
  31. package/dist/types/live/protocol.test.d.ts +1 -0
  32. package/dist/types/live/transport.d.ts +48 -0
  33. package/dist/types/live/visualizer.d.ts +33 -0
  34. package/dist/types/lsp/client.d.ts +18 -1
  35. package/dist/types/mcp/config-writer.test.d.ts +1 -0
  36. package/dist/types/mcp/manager.d.ts +6 -2
  37. package/dist/types/mcp/oauth-discovery.d.ts +2 -0
  38. package/dist/types/mcp/render.d.ts +2 -2
  39. package/dist/types/mcp/smithery-auth.d.ts +1 -1
  40. package/dist/types/mcp/smithery-auth.test.d.ts +1 -0
  41. package/dist/types/mcp/tool-bridge.d.ts +7 -3
  42. package/dist/types/mcp/types.d.ts +6 -0
  43. package/dist/types/memory-backend/tool-names.d.ts +2 -0
  44. package/dist/types/modes/components/login-dialog.d.ts +2 -2
  45. package/dist/types/modes/components/oauth-selector.d.ts +8 -0
  46. package/dist/types/modes/components/plan-review-overlay.d.ts +26 -0
  47. package/dist/types/modes/components/settings-defs.d.ts +8 -1
  48. package/dist/types/modes/components/status-line/component.jj-cache.test.d.ts +1 -0
  49. package/dist/types/modes/components/tool-execution.d.ts +9 -1
  50. package/dist/types/modes/controllers/command-controller.d.ts +1 -1
  51. package/dist/types/modes/controllers/event-controller.d.ts +6 -1
  52. package/dist/types/modes/controllers/live-command-controller.d.ts +14 -0
  53. package/dist/types/modes/controllers/mcp-command-controller.d.ts +3 -0
  54. package/dist/types/modes/interactive-mode.d.ts +5 -0
  55. package/dist/types/modes/rpc/rpc-client.d.ts +10 -1
  56. package/dist/types/modes/rpc/rpc-frame.d.ts +16 -0
  57. package/dist/types/modes/rpc/rpc-messages.d.ts +26 -0
  58. package/dist/types/modes/rpc/rpc-types.d.ts +40 -0
  59. package/dist/types/modes/setup-wizard/scenes/sign-in.d.ts +1 -1
  60. package/dist/types/modes/setup-wizard/scenes/types.d.ts +9 -1
  61. package/dist/types/modes/setup-wizard/scenes/web-search.d.ts +1 -1
  62. package/dist/types/modes/theme/theme.d.ts +1 -1
  63. package/dist/types/modes/types.d.ts +3 -1
  64. package/dist/types/modes/utils/context-usage.d.ts +12 -2
  65. package/dist/types/plan-mode/plan-files.d.ts +10 -0
  66. package/dist/types/registry/agent-lifecycle.d.ts +21 -9
  67. package/dist/types/registry/agent-registry.d.ts +11 -4
  68. package/dist/types/sdk.d.ts +15 -1
  69. package/dist/types/secrets/index.d.ts +15 -2
  70. package/dist/types/secrets/obfuscator.d.ts +113 -15
  71. package/dist/types/session/acp-permission-gate.d.ts +19 -0
  72. package/dist/types/session/agent-session-events.d.ts +84 -0
  73. package/dist/types/session/agent-session-types.d.ts +314 -0
  74. package/dist/types/session/agent-session.d.ts +120 -750
  75. package/dist/types/session/async-job-delivery.d.ts +28 -0
  76. package/dist/types/session/bash-runner.d.ts +75 -0
  77. package/dist/types/session/blob-store.d.ts +11 -1
  78. package/dist/types/session/blob-store.test.d.ts +1 -0
  79. package/dist/types/session/checkpoint-entries.d.ts +28 -0
  80. package/dist/types/session/eval-runner.d.ts +49 -0
  81. package/dist/types/session/irc-bridge.d.ts +46 -0
  82. package/dist/types/session/messages.d.ts +22 -0
  83. package/dist/types/session/model-controls.d.ts +180 -0
  84. package/dist/types/session/prewalk.d.ts +46 -0
  85. package/dist/types/session/queued-messages.d.ts +22 -0
  86. package/dist/types/session/redis-session-storage.d.ts +1 -0
  87. package/dist/types/session/retry-fallback-chains.d.ts +74 -0
  88. package/dist/types/session/role-models.d.ts +15 -0
  89. package/dist/types/session/session-advisors.d.ts +228 -0
  90. package/dist/types/session/session-entries.d.ts +8 -0
  91. package/dist/types/session/session-handoff.d.ts +70 -0
  92. package/dist/types/session/session-history-format.d.ts +6 -1
  93. package/dist/types/session/session-loader.d.ts +6 -4
  94. package/dist/types/session/session-maintenance.d.ts +246 -0
  95. package/dist/types/session/session-manager.d.ts +41 -0
  96. package/dist/types/session/session-memory.d.ts +56 -0
  97. package/dist/types/session/session-provider-boundary.d.ts +62 -0
  98. package/dist/types/session/session-stats.d.ts +48 -0
  99. package/dist/types/session/session-tools.d.ts +143 -0
  100. package/dist/types/session/session-workspace.d.ts +32 -0
  101. package/dist/types/session/stream-guards.d.ts +44 -0
  102. package/dist/types/session/todo-tracker.d.ts +56 -0
  103. package/dist/types/session/ttsr-coordinator.d.ts +51 -0
  104. package/dist/types/session/turn-recovery.d.ts +225 -0
  105. package/dist/types/system-prompt.d.ts +2 -0
  106. package/dist/types/task/executor.d.ts +4 -1
  107. package/dist/types/task/output-manager.d.ts +3 -1
  108. package/dist/types/task/types.d.ts +14 -0
  109. package/dist/types/tools/approval.d.ts +1 -0
  110. package/dist/types/tools/browser/launch.d.ts +5 -0
  111. package/dist/types/tools/hub/jobs.d.ts +1 -1
  112. package/dist/types/tools/image-gen.d.ts +4 -4
  113. package/dist/types/tools/image-providers.d.ts +38 -0
  114. package/dist/types/tools/index.d.ts +8 -0
  115. package/dist/types/tools/path-utils.d.ts +8 -0
  116. package/dist/types/tools/report-tool-issue.d.ts +24 -9
  117. package/dist/types/tools/todo.d.ts +10 -4
  118. package/dist/types/tools/xdev.d.ts +5 -1
  119. package/dist/types/tts/streaming-player.d.ts +14 -0
  120. package/dist/types/tts/vocalizer.d.ts +5 -0
  121. package/dist/types/utils/jj.d.ts +29 -0
  122. package/dist/types/utils/lang-from-path.d.ts +1 -0
  123. package/dist/types/utils/title-generator.d.ts +30 -0
  124. package/dist/types/vibe/runtime.d.ts +36 -8
  125. package/dist/types/web/search/index.d.ts +1 -1
  126. package/dist/types/web/search/provider.d.ts +15 -5
  127. package/dist/types/web/search/providers/firecrawl.d.ts +9 -0
  128. package/dist/types/web/search/types.d.ts +95 -1
  129. package/package.json +13 -12
  130. package/scripts/generate-share-viewer.ts +4 -6
  131. package/scripts/legacy-pi-virtual-module.ts +1 -1
  132. package/src/advisor/__tests__/advisor.test.ts +643 -0
  133. package/src/advisor/runtime.ts +144 -43
  134. package/src/async/job-manager.ts +90 -2
  135. package/src/cli/args.ts +2 -0
  136. package/src/cli/auth-gateway-cli.ts +18 -3
  137. package/src/cli/bench-cli.ts +395 -29
  138. package/src/cli/flag-tables.ts +3 -0
  139. package/src/cli/gc-cli.ts +2 -2
  140. package/src/cli/grievances-cli.ts +2 -2
  141. package/src/cli/usage-cli.ts +11 -2
  142. package/src/collab/host.ts +3 -2
  143. package/src/commands/bench.ts +18 -4
  144. package/src/commands/launch.ts +4 -0
  145. package/src/config/__tests__/model-registry.test.ts +147 -0
  146. package/src/config/model-registry.ts +50 -2
  147. package/src/config/model-resolver.ts +0 -1
  148. package/src/config/models-config-schema.ts +15 -4
  149. package/src/config/provider-globals.ts +9 -9
  150. package/src/config/service-tier.ts +26 -1
  151. package/src/config/settings-schema.ts +250 -54
  152. package/src/config/settings.ts +81 -2
  153. package/src/dap/client.ts +37 -4
  154. package/src/edit/index.ts +49 -12
  155. package/src/eval/py/executor.ts +103 -33
  156. package/src/exec/bash-executor.ts +89 -6
  157. package/src/exec/direnv.ts +145 -0
  158. package/src/export/html/args.ts +20 -0
  159. package/src/export/html/index.ts +45 -47
  160. package/src/export/html/template.css +19 -1
  161. package/src/export/html/template.html +6 -0
  162. package/src/export/html/template.js +21 -0
  163. package/src/export/html/tool-views.generated.js +31 -31
  164. package/src/export/html/web-palette.ts +116 -132
  165. package/src/export/share.ts +249 -49
  166. package/src/extensibility/extensions/loader.ts +29 -1
  167. package/src/extensibility/extensions/runner.ts +6 -0
  168. package/src/extensibility/extensions/types.ts +33 -2
  169. package/src/extensibility/legacy-pi-coding-agent-shim.ts +1 -0
  170. package/src/extensibility/legacy-pi-tui-shim.ts +10 -0
  171. package/src/extensibility/plugins/legacy-pi-compat.ts +870 -311
  172. package/src/hindsight/state.ts +64 -6
  173. package/src/internal-urls/local-protocol.ts +2 -1
  174. package/src/internal-urls/memory-protocol.ts +2 -2
  175. package/src/internal-urls/registry-helpers.ts +40 -0
  176. package/src/internal-urls/skill-protocol.ts +2 -2
  177. package/src/internal-urls/ssh-protocol.ts +2 -1
  178. package/src/internal-urls/vault-protocol.ts +2 -1
  179. package/src/live/audio-worklet.txt +59 -0
  180. package/src/live/browser-runtime.txt +221 -0
  181. package/src/live/controller.ts +464 -0
  182. package/src/live/prompts/agent-final-message.md +3 -0
  183. package/src/live/prompts/live-instructions.md +23 -0
  184. package/src/live/protocol.test.ts +140 -0
  185. package/src/live/protocol.ts +233 -0
  186. package/src/live/transport.ts +490 -0
  187. package/src/live/visualizer.ts +234 -0
  188. package/src/lsp/client.ts +52 -9
  189. package/src/lsp/clients/biome-client.ts +3 -4
  190. package/src/lsp/index.ts +44 -11
  191. package/src/main.ts +9 -1
  192. package/src/mcp/config-writer.test.ts +43 -0
  193. package/src/mcp/config-writer.ts +109 -82
  194. package/src/mcp/manager.ts +39 -8
  195. package/src/mcp/oauth-discovery.ts +10 -2
  196. package/src/mcp/render.ts +94 -35
  197. package/src/mcp/smithery-auth.test.ts +29 -0
  198. package/src/mcp/smithery-auth.ts +3 -2
  199. package/src/mcp/tool-bridge.ts +107 -11
  200. package/src/mcp/types.ts +7 -0
  201. package/src/memories/index.ts +40 -20
  202. package/src/memory-backend/tool-names.ts +2 -0
  203. package/src/mnemopi/backend.ts +24 -7
  204. package/src/modes/acp/acp-agent.ts +56 -10
  205. package/src/modes/acp/acp-event-mapper.ts +8 -1
  206. package/src/modes/components/agent-hub.ts +27 -10
  207. package/src/modes/components/login-dialog.ts +14 -4
  208. package/src/modes/components/oauth-selector.ts +24 -7
  209. package/src/modes/components/plan-review-overlay.ts +350 -35
  210. package/src/modes/components/settings-defs.ts +28 -3
  211. package/src/modes/components/settings-selector.ts +178 -6
  212. package/src/modes/components/status-line/component.jj-cache.test.ts +229 -0
  213. package/src/modes/components/status-line/component.ts +123 -2
  214. package/src/modes/components/tool-execution.test.ts +63 -2
  215. package/src/modes/components/tool-execution.ts +11 -2
  216. package/src/modes/controllers/command-controller.ts +70 -22
  217. package/src/modes/controllers/event-controller.ts +111 -16
  218. package/src/modes/controllers/extension-ui-controller.test.ts +1 -0
  219. package/src/modes/controllers/extension-ui-controller.ts +20 -4
  220. package/src/modes/controllers/live-command-controller.ts +178 -0
  221. package/src/modes/controllers/mcp-command-controller.ts +80 -41
  222. package/src/modes/controllers/omfg-controller.ts +44 -40
  223. package/src/modes/controllers/selector-controller.ts +18 -10
  224. package/src/modes/controllers/session-focus-controller.ts +6 -1
  225. package/src/modes/interactive-mode.ts +160 -38
  226. package/src/modes/rpc/rpc-client.ts +94 -3
  227. package/src/modes/rpc/rpc-frame.ts +164 -4
  228. package/src/modes/rpc/rpc-messages.ts +127 -0
  229. package/src/modes/rpc/rpc-mode.ts +79 -7
  230. package/src/modes/rpc/rpc-types.ts +34 -2
  231. package/src/modes/runtime-init.ts +2 -0
  232. package/src/modes/setup-wizard/scenes/model.ts +5 -7
  233. package/src/modes/setup-wizard/scenes/providers.ts +3 -2
  234. package/src/modes/setup-wizard/scenes/sign-in.ts +8 -2
  235. package/src/modes/setup-wizard/scenes/theme.ts +13 -3
  236. package/src/modes/setup-wizard/scenes/types.ts +9 -1
  237. package/src/modes/setup-wizard/scenes/web-search.ts +23 -10
  238. package/src/modes/setup-wizard/wizard-overlay.ts +1 -1
  239. package/src/modes/theme/theme.ts +1 -1
  240. package/src/modes/types.ts +3 -1
  241. package/src/modes/utils/context-usage.ts +23 -7
  242. package/src/modes/utils/ui-helpers.ts +10 -5
  243. package/src/plan-mode/plan-files.ts +40 -0
  244. package/src/prompts/bench/cache-prefix-chunk.md +1 -0
  245. package/src/prompts/bench/cache-prefix.md +3 -0
  246. package/src/prompts/bench/cache-suffix.md +1 -0
  247. package/src/prompts/goals/guided-goal-system.md +24 -3
  248. package/src/prompts/system/custom-system-prompt.md +1 -1
  249. package/src/prompts/system/project-prompt.md +8 -1
  250. package/src/prompts/system/subagent-async-pending.md +6 -0
  251. package/src/prompts/system/system-prompt.md +1 -1
  252. package/src/prompts/system/workflow-notice.md +48 -40
  253. package/src/prompts/system/xdev-mount-notice.md +4 -0
  254. package/src/prompts/tools/task.md +12 -2
  255. package/src/prompts/tools/todo.md +3 -1
  256. package/src/registry/agent-lifecycle.ts +116 -46
  257. package/src/registry/agent-registry.ts +35 -8
  258. package/src/registry/persisted-agents.ts +26 -2
  259. package/src/sdk.ts +255 -139
  260. package/src/secrets/index.ts +127 -4
  261. package/src/secrets/obfuscator.ts +2328 -105
  262. package/src/session/acp-permission-gate.ts +165 -0
  263. package/src/session/agent-session-error-log.test.ts +1 -1
  264. package/src/session/agent-session-events.ts +66 -0
  265. package/src/session/agent-session-types.ts +334 -0
  266. package/src/session/agent-session.ts +1910 -12213
  267. package/src/session/artifacts.ts +6 -5
  268. package/src/session/async-job-delivery.ts +74 -0
  269. package/src/session/bash-runner.ts +326 -0
  270. package/src/session/blob-store.test.ts +56 -0
  271. package/src/session/blob-store.ts +18 -2
  272. package/src/session/checkpoint-entries.ts +81 -0
  273. package/src/session/eval-runner.ts +212 -0
  274. package/src/session/indexed-session-storage.ts +9 -2
  275. package/src/session/irc-bridge.ts +203 -0
  276. package/src/session/messages.ts +263 -1
  277. package/src/session/model-controls.ts +714 -0
  278. package/src/session/prewalk.ts +252 -0
  279. package/src/session/queued-messages.ts +93 -0
  280. package/src/session/redis-session-storage.ts +51 -11
  281. package/src/session/retry-fallback-chains.ts +455 -0
  282. package/src/session/role-models.ts +85 -0
  283. package/src/session/session-advisors.ts +1679 -0
  284. package/src/session/session-context.test.ts +224 -1
  285. package/src/session/session-context.ts +47 -5
  286. package/src/session/session-entries.ts +8 -0
  287. package/src/session/session-handoff.ts +308 -0
  288. package/src/session/session-history-format.ts +20 -9
  289. package/src/session/session-loader.ts +10 -51
  290. package/src/session/session-maintenance.ts +2983 -0
  291. package/src/session/session-manager.ts +390 -30
  292. package/src/session/session-memory.ts +222 -0
  293. package/src/session/session-provider-boundary.ts +307 -0
  294. package/src/session/session-stats.ts +293 -0
  295. package/src/session/session-tools.ts +943 -0
  296. package/src/session/session-workspace.ts +53 -0
  297. package/src/session/stream-guards.ts +417 -0
  298. package/src/session/todo-tracker.ts +380 -0
  299. package/src/session/ttsr-coordinator.ts +496 -0
  300. package/src/session/turn-recovery.ts +1629 -0
  301. package/src/slash-commands/builtin-registry.ts +92 -11
  302. package/src/slash-commands/helpers/usage-report.ts +25 -4
  303. package/src/system-prompt.ts +41 -17
  304. package/src/task/executor.ts +288 -70
  305. package/src/task/index.ts +148 -111
  306. package/src/task/output-manager.ts +25 -3
  307. package/src/task/persisted-revive.ts +4 -3
  308. package/src/task/structured-subagent.ts +4 -1
  309. package/src/task/types.ts +16 -0
  310. package/src/tools/approval.ts +55 -10
  311. package/src/tools/bash-interactive.ts +26 -0
  312. package/src/tools/bash-skill-urls.ts +28 -2
  313. package/src/tools/bash.ts +339 -123
  314. package/src/tools/browser/launch.ts +9 -1
  315. package/src/tools/browser/tab-supervisor.ts +8 -5
  316. package/src/tools/browser.ts +94 -47
  317. package/src/tools/hub/index.ts +1 -1
  318. package/src/tools/hub/jobs.ts +67 -8
  319. package/src/tools/image-gen.ts +20 -28
  320. package/src/tools/image-providers.ts +50 -0
  321. package/src/tools/index.ts +12 -0
  322. package/src/tools/path-utils.ts +49 -2
  323. package/src/tools/read.ts +156 -88
  324. package/src/tools/renderers.ts +7 -1
  325. package/src/tools/report-tool-issue.ts +79 -28
  326. package/src/tools/todo.ts +101 -11
  327. package/src/tools/vibe.ts +1 -2
  328. package/src/tools/write.ts +51 -1
  329. package/src/tools/xdev.ts +67 -3
  330. package/src/tts/streaming-player.ts +62 -4
  331. package/src/tts/vocalizer.ts +18 -1
  332. package/src/utils/jj.ts +125 -4
  333. package/src/utils/lang-from-path.ts +7 -0
  334. package/src/utils/title-generator.ts +137 -1
  335. package/src/vibe/runtime.ts +852 -72
  336. package/src/web/search/index.ts +3 -6
  337. package/src/web/search/provider.ts +31 -16
  338. package/src/web/search/providers/firecrawl.ts +46 -13
  339. package/src/web/search/providers/xai.ts +7 -1
  340. package/src/web/search/types.ts +8 -1
@@ -1,7 +1,9 @@
1
- import { beforeAll, describe, expect, it } from "bun:test";
1
+ import { afterEach, beforeAll, describe, expect, it } from "bun:test";
2
2
  import type { AgentTool } from "@oh-my-pi/pi-agent-core";
3
3
  import { type Component, Text } from "@oh-my-pi/pi-tui";
4
- import { Settings } from "../../config/settings";
4
+ import { Settings, settings } from "../../config/settings";
5
+ import { renderMCPResult } from "../../mcp/render";
6
+ import type { MCPToolDetails } from "../../mcp/tool-bridge";
5
7
  import { getThemeByName, setThemeInstance, theme } from "../theme/theme";
6
8
  import { ToolExecutionComponent, type ToolExecutionUi } from "./tool-execution";
7
9
 
@@ -26,6 +28,10 @@ describe("ToolExecutionComponent custom renderer failures", () => {
26
28
  setThemeInstance(loaded);
27
29
  });
28
30
 
31
+ afterEach(() => {
32
+ settings.set("mcp.renderMarkdownResults", true);
33
+ });
34
+
29
35
  it("falls back to the custom tool label when a renderCall child component throws during render", () => {
30
36
  const tool: AgentTool = {
31
37
  name: "graphify_graph",
@@ -99,3 +105,58 @@ describe("ToolExecutionComponent custom renderer failures", () => {
99
105
  expect(text).toContain(rawResultText);
100
106
  });
101
107
  });
108
+
109
+ describe("MCP result Markdown rendering", () => {
110
+ const details: MCPToolDetails = {
111
+ serverName: "context-mode",
112
+ mcpToolName: "ctx_search",
113
+ };
114
+
115
+ beforeAll(async () => {
116
+ await Settings.init({ inMemory: true });
117
+ const loaded = await getThemeByName("dark");
118
+ if (!loaded) throw new Error("theme unavailable");
119
+ setThemeInstance(loaded);
120
+ });
121
+
122
+ afterEach(() => {
123
+ settings.set("mcp.renderMarkdownResults", true);
124
+ });
125
+
126
+ it("renders inline Markdown by default", () => {
127
+ const component = renderMCPResult(
128
+ { content: [{ type: "text", text: "**bold result** and `code`" }], details },
129
+ { expanded: true, isPartial: false },
130
+ theme,
131
+ );
132
+ const rendered = visibleText(component.render(80));
133
+
134
+ expect(rendered).toContain("bold result and code");
135
+ expect(rendered).not.toContain("**bold result**");
136
+ expect(rendered).not.toContain("`code`");
137
+ });
138
+
139
+ it("keeps Markdown syntax literal when the setting is disabled", () => {
140
+ settings.set("mcp.renderMarkdownResults", false);
141
+ const component = renderMCPResult(
142
+ { content: [{ type: "text", text: "**bold result**" }], details },
143
+ { expanded: true, isPartial: false },
144
+ theme,
145
+ );
146
+
147
+ expect(visibleText(component.render(80))).toContain("**bold result**");
148
+ });
149
+
150
+ it("preserves structured JSON rendering when Markdown is enabled", () => {
151
+ settings.set("mcp.renderMarkdownResults", true);
152
+ const component = renderMCPResult(
153
+ { content: [{ type: "text", text: '{"status":"**ok**"}' }], details },
154
+ { expanded: true, isPartial: false },
155
+ theme,
156
+ );
157
+ const rendered = visibleText(component.render(80));
158
+
159
+ expect(rendered).toContain("status");
160
+ expect(rendered).toContain("**ok**");
161
+ });
162
+ });
@@ -782,9 +782,18 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
782
782
  return this.isTranscriptBlockFinalized() ? undefined : 0;
783
783
  }
784
784
 
785
- /** Keeps the in-flight `vibe_wait` TV wall out of immutable native scrollback. */
785
+ /**
786
+ * Keeps in-flight TV-wall frames out of immutable native scrollback: the
787
+ * `vibe_wait` wall and displaceable snapshots (`hub` waiting polls, `todo`
788
+ * lists). Their frames replace each other rather than append, and their
789
+ * rows mutate every spinner tick — an unpinned commit records a per-tick
790
+ * frozen snapshot AND force-seals the block (see TranscriptContainer's
791
+ * committed-snapshot seal), so the next poll stacks a new frame instead of
792
+ * displacing this one.
793
+ */
786
794
  isNativeScrollbackLiveRegionPinned(): boolean {
787
- return this.#toolName === "vibe_wait" && !this.isTranscriptBlockFinalized();
795
+ if (this.isTranscriptBlockFinalized()) return false;
796
+ return this.#toolName === "vibe_wait" || this.#displaceableByToolName !== undefined;
788
797
  }
789
798
 
790
799
  /**
@@ -15,6 +15,7 @@ import { formatDuration, Snowflake, sanitizeText } from "@oh-my-pi/pi-utils";
15
15
  import { shouldEnableAppendOnlyContext } from "../../config/append-only-context-mode";
16
16
  import { type BashResult, isPersistentShellCdCommand } from "../../exec/bash-executor";
17
17
  import { type LoadedCustomShare, loadCustomShare } from "../../export/custom-share";
18
+ import { parseExportArgs } from "../../export/html/args";
18
19
  import { shareSession } from "../../export/share";
19
20
  import type { CompactOptions } from "../../extensibility/extensions/types";
20
21
  import {
@@ -76,16 +77,14 @@ export class CommandController {
76
77
  }
77
78
 
78
79
  async handleExportCommand(text: string): Promise<void> {
79
- const parts = text.split(/\s+/);
80
- const arg = parts.length > 1 ? parts[1] : undefined;
81
-
82
- if (arg === "--copy" || arg === "clipboard" || arg === "copy") {
83
- this.ctx.showWarning("Use /dump to copy the session to clipboard.");
84
- return;
85
- }
86
-
87
80
  try {
88
- const filePath = await this.ctx.session.exportToHtml(arg);
81
+ const { outputPath, useUserThemes } = parseExportArgs(text.slice("/export".length));
82
+ if (outputPath === "--copy" || outputPath === "clipboard" || outputPath === "copy") {
83
+ this.ctx.showWarning("Use /dump to copy the session to clipboard.");
84
+ return;
85
+ }
86
+
87
+ const filePath = await this.ctx.session.exportToHtml(outputPath, useUserThemes);
89
88
  this.ctx.showStatus(`Session exported to: ${filePath}`);
90
89
  this.openInBrowser(filePath);
91
90
  } catch (error: unknown) {
@@ -550,8 +549,14 @@ export class CommandController {
550
549
  this.ctx.session.sessionId,
551
550
  )
552
551
  : undefined;
553
- const output = renderUsageReports(usageReports, theme, Date.now(), availableWidth, provider =>
554
- provider === currentProvider ? activeAccount : undefined,
552
+ const usageModelSelectors = this.ctx.session.getUsageReportingModelSelectors(usageReports);
553
+ const output = renderUsageReports(
554
+ usageReports,
555
+ theme,
556
+ Date.now(),
557
+ availableWidth,
558
+ provider => (provider === currentProvider ? activeAccount : undefined),
559
+ usageModelSelectors,
555
560
  );
556
561
  this.ctx.present([new Spacer(1), new Text(output, 1, 0)]);
557
562
  }
@@ -1068,7 +1073,7 @@ export class CommandController {
1068
1073
  }
1069
1074
 
1070
1075
  try {
1071
- await this.ctx.sessionManager.moveTo(resolvedPath);
1076
+ await this.ctx.session.moveSession(resolvedPath);
1072
1077
  } catch (err) {
1073
1078
  this.ctx.showError(`Move failed: ${err instanceof Error ? err.message : String(err)}`);
1074
1079
  return;
@@ -1593,11 +1598,28 @@ function padColumn(text: string, width: number): string {
1593
1598
  return `${text}${padding(width - visible)}`;
1594
1599
  }
1595
1600
 
1596
- function resolveAggregateStatus(limits: UsageLimit[]): UsageLimit["status"] {
1601
+ type AggregateDisplayStatus = NonNullable<UsageLimit["status"]> | "neutral";
1602
+
1603
+ function isUsedOnlyAbsoluteAmount(limit: UsageLimit): boolean {
1604
+ const amount = limit.amount;
1605
+ return (
1606
+ amount.unit !== "percent" &&
1607
+ amount.unit !== "unknown" &&
1608
+ amount.used !== undefined &&
1609
+ Number.isFinite(amount.used) &&
1610
+ amount.limit === undefined &&
1611
+ amount.remaining === undefined &&
1612
+ resolveUsedFraction(limit) === undefined
1613
+ );
1614
+ }
1615
+
1616
+ function resolveAggregateStatus(limits: UsageLimit[]): AggregateDisplayStatus {
1597
1617
  const hasOk = limits.some(limit => limit.status === "ok");
1598
1618
  const hasWarning = limits.some(limit => limit.status === "warning");
1599
1619
  const hasExhausted = limits.some(limit => limit.status === "exhausted");
1600
- if (!hasOk && !hasWarning && !hasExhausted) return "unknown";
1620
+ if (!hasOk && !hasWarning && !hasExhausted) {
1621
+ return limits.length > 0 && limits.every(isUsedOnlyAbsoluteAmount) ? "neutral" : "unknown";
1622
+ }
1601
1623
  if (hasOk) {
1602
1624
  return hasWarning || hasExhausted ? "warning" : "ok";
1603
1625
  }
@@ -1625,6 +1647,8 @@ function formatAggregateAmount(limits: UsageLimit[]): string {
1625
1647
  return `${formatNumber(remainingPct)}% free`;
1626
1648
  }
1627
1649
 
1650
+ if (limits.length > 0 && limits.every(isUsedOnlyAbsoluteAmount)) return "";
1651
+
1628
1652
  // Count unique accounts from limit scopes — not limits.length.
1629
1653
  const uniqueAccountIds = new Set(
1630
1654
  limits.map(limit => limit.scope.accountId).filter((id): id is string => typeof id === "string" && id.length > 0),
@@ -1636,17 +1660,24 @@ function formatAggregateAmount(limits: UsageLimit[]): string {
1636
1660
  }
1637
1661
 
1638
1662
  function resolveResetRange(limits: UsageLimit[], nowMs: number): string | null {
1639
- const absolute = limits
1640
- .map(limit => limit.window?.resetsAt)
1641
- .filter((value): value is number => value !== undefined && Number.isFinite(value) && value > nowMs);
1642
- if (absolute.length === 0) return null;
1643
- const offsets = absolute.map(value => value - nowMs);
1663
+ const windows = limits
1664
+ .map(limit => limit.window)
1665
+ .filter(
1666
+ (window): window is NonNullable<UsageLimit["window"]> =>
1667
+ window?.resetsAt !== undefined && Number.isFinite(window.resetsAt) && window.resetsAt > nowMs,
1668
+ );
1669
+ if (windows.length === 0) return null;
1670
+ // Use the shared verb when every contributing window agrees (e.g. all "tick");
1671
+ // mixed or absent labels fall back to the generic "resets".
1672
+ const labels = new Set(windows.map(window => window.resetLabel ?? "resets"));
1673
+ const verb = labels.size === 1 ? [...labels][0]! : "resets";
1674
+ const offsets = windows.map(window => window.resetsAt! - nowMs);
1644
1675
  const minReset = Math.min(...offsets);
1645
1676
  const maxReset = Math.max(...offsets);
1646
1677
  if (maxReset - minReset > 60_000) {
1647
- return `resets in ${formatDuration(minReset)}–${formatDuration(maxReset)}`;
1678
+ return `${verb} in ${formatDuration(minReset)}–${formatDuration(maxReset)}`;
1648
1679
  }
1649
- return `resets in ${formatDuration(minReset)}`;
1680
+ return `${verb} in ${formatDuration(minReset)}`;
1650
1681
  }
1651
1682
  /**
1652
1683
  * Compact one-line quota summary for a single advisor's provider.
@@ -1700,7 +1731,8 @@ export function formatCompactQuota(
1700
1731
  return `Quota: ${lines.join(" │ ")}`;
1701
1732
  }
1702
1733
 
1703
- function resolveStatusIcon(status: UsageLimit["status"], uiTheme: typeof theme): string {
1734
+ function resolveStatusIcon(status: AggregateDisplayStatus, uiTheme: typeof theme): string {
1735
+ if (status === "neutral") return uiTheme.fg("dim", uiTheme.status.info);
1704
1736
  if (status === "exhausted") return uiTheme.fg("error", uiTheme.status.error);
1705
1737
  if (status === "warning") return uiTheme.fg("warning", uiTheme.status.warning);
1706
1738
  if (status === "ok") return uiTheme.fg("success", uiTheme.status.success);
@@ -1715,6 +1747,14 @@ function resolveStatusColor(status: UsageLimit["status"]): "success" | "warning"
1715
1747
  }
1716
1748
 
1717
1749
  function renderUsageBar(limit: UsageLimit, uiTheme: typeof theme, barWidth: number): string {
1750
+ const usedAmount = limit.amount.used;
1751
+ if (usedAmount !== undefined && isUsedOnlyAbsoluteAmount(limit)) {
1752
+ const used =
1753
+ limit.amount.unit === "usd"
1754
+ ? `$${usedAmount.toFixed(2)}`
1755
+ : `${formatNumber(usedAmount, 2)} ${limit.amount.unit}`;
1756
+ return uiTheme.fg("dim", truncateJobLabel(`${used} used`, barWidth));
1757
+ }
1718
1758
  const fraction = resolveUsedFraction(limit);
1719
1759
  if (fraction === undefined) {
1720
1760
  return uiTheme.fg("dim", "·".repeat(barWidth));
@@ -1752,6 +1792,7 @@ export function renderUsageReports(
1752
1792
  nowMs: number,
1753
1793
  availableWidth: number,
1754
1794
  resolveActiveAccount?: (provider: string) => OAuthAccountIdentity | undefined,
1795
+ usageModelSelectors: readonly string[] = [],
1755
1796
  ): string {
1756
1797
  const lines: string[] = [];
1757
1798
  const latestFetchedAt = Math.max(...reports.map(report => report.fetchedAt ?? 0));
@@ -1805,6 +1846,13 @@ export function renderUsageReports(
1805
1846
  if (activeAccountLabel) {
1806
1847
  lines.push(` ${uiTheme.fg("accent", "in use by this session:")} ${activeAccountLabel}`);
1807
1848
  }
1849
+ const reportingModels = usageModelSelectors.filter(selector => selector.startsWith(`${provider}/`));
1850
+ if (reportingModels.length > 0) {
1851
+ lines.push(` ${uiTheme.fg("accent", "Models with usage data")}`);
1852
+ for (const selector of reportingModels) {
1853
+ lines.push(` ${replaceTabs(truncateToWidth(sanitizeText(selector), availableWidth - 4))}`);
1854
+ }
1855
+ }
1808
1856
 
1809
1857
  // Provider-wide disclaimers (e.g. "OMP-observed spend only") render once
1810
1858
  // above the per-account sections instead of duplicating onto every limit.
@@ -23,12 +23,14 @@ import type { InteractiveModeContext, TodoPhase } from "../../modes/types";
23
23
  import idleRecapPrompt from "../../prompts/system/recap-user.md" with { type: "text" };
24
24
  import type { AgentSessionEvent } from "../../session/agent-session";
25
25
  import { isSilentAbort, readQueueChipText, resolveAbortLabel } from "../../session/messages";
26
+ import { type ApprovalMode, resolveApproval } from "../../tools/approval";
26
27
  import { previewLine, TRUNCATE_LENGTHS } from "../../tools/render-utils";
27
28
  import { PROPOSE_DEVICE_NAME, writeDeviceDispatch } from "../../tools/resolve";
28
29
  import { nextActionableTask } from "../../tools/todo";
29
30
  import { SpeechEnhancer } from "../../tts/speech-enhancer";
30
31
  import { vocalizer } from "../../tts/vocalizer";
31
32
  import { canonicalizeMessage } from "../../utils/thinking-display";
33
+ import { setTerminalTitleState } from "../../utils/title-generator";
32
34
  import { interruptHint } from "../shared";
33
35
  import { createAssistantMessageComponent } from "../utils/interactive-context-helpers";
34
36
  import {
@@ -81,6 +83,9 @@ export class EventController {
81
83
  #renderedCustomMessages = new Set<string>();
82
84
  #lastIntent: string | undefined = undefined;
83
85
  #backgroundTaskCallIds = new Set<string>();
86
+ /** Tool calls whose approval prompt drove the title into `attention`; cleared
87
+ * at their tool_execution_end so the title returns to `working`. */
88
+ #approvalAttentionToolCallIds = new Set<string>();
84
89
  #readToolCallArgs = new Map<string, Record<string, unknown>>();
85
90
  #readToolCallAssistantComponents = new Map<string, AssistantMessageComponent>();
86
91
  #toolTimelineComponents = new Map<string, Component>();
@@ -93,6 +98,15 @@ export class EventController {
93
98
  #pinnedErrorComponent: AssistantMessageComponent | undefined = undefined;
94
99
  #retrySupersededAssistantComponents = new Map<string, AssistantMessageComponent>();
95
100
  #retrySupersededAssistantQueue: AssistantMessageComponent[] = [];
101
+ // Set when `auto_retry_start` fires and cleared by `auto_retry_end` (both
102
+ // outcomes) — true for exactly the window a retry is outstanding. Gates
103
+ // `sendErrorNotification`: the wire-level `agent_end` for a retryable
104
+ // failure is coalesced with every other attempt in the same saga while the
105
+ // prompt is in flight (see `AgentSession#emitSessionEvent`), so the single
106
+ // `agent_end` that survives to reach this controller can be either a
107
+ // mid-retry blip or the final settle — only the retry lifecycle events
108
+ // (never deferred) can tell them apart.
109
+ #retryPending = false;
96
110
  #idleCompactionTimer?: NodeJS.Timeout;
97
111
  #idleRecapTimer?: NodeJS.Timeout;
98
112
  // In-flight ephemeral recap turn; aborted by #cancelIdleRecap when any
@@ -149,7 +163,7 @@ export class EventController {
149
163
  this.#handlers = {
150
164
  agent_start: e => this.#handleAgentStart(e),
151
165
  agent_end: e => this.#handleAgentEnd(e),
152
- turn_start: async () => this.#handleTurnStart(),
166
+ turn_start: async () => {},
153
167
  turn_end: async e => this.#handleTurnEnd(e),
154
168
  message_start: e => this.#handleMessageStart(e),
155
169
  message_update: e => this.#handleMessageUpdate(e),
@@ -322,10 +336,12 @@ export class EventController {
322
336
  this.#toolTimelineComponents.clear();
323
337
  this.#postToolAssistantComponents.clear();
324
338
  this.#backgroundTaskCallIds.clear();
339
+ this.#approvalAttentionToolCallIds.clear();
325
340
  this.#readToolCallArgs.clear();
326
341
  this.#readToolCallAssistantComponents.clear();
327
342
  this.#lastAssistantComponent = undefined;
328
343
  this.#pinnedErrorComponent = undefined;
344
+ this.#retryPending = this.ctx.viewSession.isRetrying;
329
345
  this.#cancelIdleCompaction();
330
346
  this.#cancelIdleRecap();
331
347
  for (const timer of this.#ircExpiryTimers.values()) {
@@ -428,6 +444,7 @@ export class EventController {
428
444
  this.ctx.statusLine.markActivityStart();
429
445
  this.#setTerminalProgress(true);
430
446
  this.ctx.ensureLoadingAnimation();
447
+ setTerminalTitleState("working");
431
448
  this.ctx.ui.requestRender();
432
449
  }
433
450
 
@@ -449,6 +466,7 @@ export class EventController {
449
466
  }
450
467
  this.ctx.ui.requestRender();
451
468
  } else if (event.message.role === "user") {
469
+ vocalizer.clear();
452
470
  const textContent = this.ctx.getUserMessageText(event.message);
453
471
  const imageBlocks =
454
472
  typeof event.message.content === "string"
@@ -644,11 +662,6 @@ export class EventController {
644
662
  }
645
663
  }
646
664
 
647
- /** A new turn interrupts any speech still queued/playing from the previous one. */
648
- #handleTurnStart(): void {
649
- vocalizer.clear();
650
- }
651
-
652
665
  /**
653
666
  * Speak streamed assistant output as a side effect of the turn. The mode
654
667
  * decides which deltas feed the vocalizer (the vocalizer re-checks enabled):
@@ -943,6 +956,10 @@ export class EventController {
943
956
  async #handleToolExecutionStart(event: Extract<AgentSessionEvent, { type: "tool_execution_start" }>): Promise<void> {
944
957
  this.#ensureWorkingLoaderWhileStreaming();
945
958
  this.#updateWorkingMessageFromIntent(event.intent);
959
+ if (event.toolName === "ask" || this.#toolWillPromptForApproval(event.toolName, event.args)) {
960
+ this.#approvalAttentionToolCallIds.add(event.toolCallId);
961
+ setTerminalTitleState("attention");
962
+ }
946
963
  this.#resolveDisplaceablePoll(event.toolName);
947
964
  if (!this.ctx.pendingTools.has(event.toolCallId)) {
948
965
  if (event.toolName === "read" && readArgsCollapseIntoGroup(event.args)) {
@@ -1005,6 +1022,23 @@ export class EventController {
1005
1022
  }
1006
1023
  }
1007
1024
 
1025
+ /**
1026
+ * Whether this tool call will block on an approval prompt before executing.
1027
+ * The extension wrapper waits on `uiContext.select(...)` after emitting
1028
+ * `tool_execution_start`, so an approval-mode / per-tool `prompt` policy is
1029
+ * user-blocking — the title should read `attention`, not `working`. Mirrors
1030
+ * the wrapper's `resolveApproval` inputs (approvalMode + tools.approval); uses
1031
+ * `resolveApproval` rather than `requiresApproval` so a `deny` policy does not
1032
+ * throw in the render path.
1033
+ */
1034
+ #toolWillPromptForApproval(toolName: string, args: unknown): boolean {
1035
+ const tool = this.ctx.viewSession.getToolByName(toolName);
1036
+ if (!tool) return false;
1037
+ const mode = (settings.get("tools.approvalMode") ?? "yolo") as ApprovalMode;
1038
+ const userPolicies = (settings.get("tools.approval") ?? {}) as Record<string, unknown>;
1039
+ return resolveApproval(tool, args, mode, userPolicies).policy === "prompt";
1040
+ }
1041
+
1008
1042
  async #handleToolExecutionUpdate(
1009
1043
  event: Extract<AgentSessionEvent, { type: "tool_execution_update" }>,
1010
1044
  ): Promise<void> {
@@ -1040,6 +1074,17 @@ export class EventController {
1040
1074
  // which only fire `tool_execution_end`, never `_update` — do not leave
1041
1075
  // the UI looking idle while the session keeps streaming (#3857).
1042
1076
  this.#ensureWorkingLoaderWhileStreaming();
1077
+ // Return to `working` only when the LAST outstanding user-blocking prompt
1078
+ // resolves: with queued approval prompts (always-ask/write), the first tool
1079
+ // to finish must not clear the attention signal while another prompt still
1080
+ // waits. `ask` ids are in the set too (added at tool_execution_start), so
1081
+ // the delete also covers them without leaking ids until turn end.
1082
+ if (
1083
+ this.#approvalAttentionToolCallIds.delete(event.toolCallId) &&
1084
+ this.#approvalAttentionToolCallIds.size === 0
1085
+ ) {
1086
+ setTerminalTitleState("working");
1087
+ }
1043
1088
  if (event.toolName === "read") {
1044
1089
  if (this.#inlineReadToolImages(event.toolCallId, event.result)) {
1045
1090
  const component = this.ctx.pendingTools.get(event.toolCallId);
@@ -1137,7 +1182,7 @@ export class EventController {
1137
1182
  }
1138
1183
  }
1139
1184
  }
1140
- async #handleAgentEnd(_event: Extract<AgentSessionEvent, { type: "agent_end" }>): Promise<void> {
1185
+ async #handleAgentEnd(event: Extract<AgentSessionEvent, { type: "agent_end" }>): Promise<void> {
1141
1186
  // A superseded agent_end: the agent is already streaming a fresh turn, so
1142
1187
  // this event belongs to a turn that has already been replaced. The session
1143
1188
  // dispatches to listeners fire-and-forget across an async extension-emit hop
@@ -1148,11 +1193,12 @@ export class EventController {
1148
1193
  // the loader and finalizes it at its own agent_end (isStreaming === false by
1149
1194
  // then). Mirrors the collab guest's !isStreaming loader reconciler.
1150
1195
  if (this.ctx.session.isStreaming) return;
1196
+ setTerminalTitleState("idle");
1151
1197
 
1152
- await this.#finishAgentEnd();
1198
+ await this.#finishAgentEnd(event);
1153
1199
  }
1154
1200
 
1155
- async #finishAgentEnd(): Promise<void> {
1201
+ async #finishAgentEnd(event: Extract<AgentSessionEvent, { type: "agent_end" }>): Promise<void> {
1156
1202
  this.#setTerminalProgress(false);
1157
1203
  this.ctx.statusLine.markActivityEnd();
1158
1204
  this.#streamingReveal.stop();
@@ -1186,6 +1232,7 @@ export class EventController {
1186
1232
  this.#backgroundTaskCallIds = new Set(
1187
1233
  Array.from(this.#backgroundTaskCallIds).filter(toolCallId => this.ctx.pendingTools.has(toolCallId)),
1188
1234
  );
1235
+ this.#approvalAttentionToolCallIds.clear();
1189
1236
  this.#readToolCallArgs.clear();
1190
1237
  this.#readToolCallAssistantComponents.clear();
1191
1238
  this.#toolTimelineComponents.clear();
@@ -1200,7 +1247,8 @@ export class EventController {
1200
1247
  this.ctx.ui.requestRender();
1201
1248
  this.#scheduleIdleCompaction();
1202
1249
  this.#scheduleIdleRecap();
1203
- this.sendCompletionNotification();
1250
+ this.sendErrorNotification(event);
1251
+ this.sendCompletionNotification(event);
1204
1252
  }
1205
1253
 
1206
1254
  /**
@@ -1358,6 +1406,7 @@ export class EventController {
1358
1406
  }
1359
1407
 
1360
1408
  async #handleAutoRetryStart(event: Extract<AgentSessionEvent, { type: "auto_retry_start" }>): Promise<void> {
1409
+ this.#retryPending = true;
1361
1410
  this.#trackRetrySupersededAssistantComponent(this.#lastAssistantComponent);
1362
1411
  this.#stopWorkingLoader();
1363
1412
  this.ctx.statusContainer.disposeChildren();
@@ -1381,6 +1430,7 @@ export class EventController {
1381
1430
  }
1382
1431
 
1383
1432
  async #handleAutoRetryEnd(event: Extract<AgentSessionEvent, { type: "auto_retry_end" }>): Promise<void> {
1433
+ this.#retryPending = false;
1384
1434
  if (this.ctx.retryLoader) {
1385
1435
  this.ctx.retryLoader.stop();
1386
1436
  this.ctx.retryLoader = undefined;
@@ -1568,7 +1618,51 @@ export class EventController {
1568
1618
  return this.ctx.viewSession.getContextUsage()?.tokens ?? 0;
1569
1619
  }
1570
1620
 
1571
- sendCompletionNotification(): void {
1621
+ sendErrorNotification(event: Extract<AgentSessionEvent, { type: "agent_end" }>): void {
1622
+ // A running async job or queued delivery will wake the session again, so
1623
+ // its current agent_end is a scheduling pause rather than a user-visible
1624
+ // terminal failure. AgentSession marks that stable outcome before
1625
+ // dispatching the deferred event; do not infer it from mutable job state.
1626
+ if (event.isTerminal === false) return;
1627
+
1628
+ // `AgentSession` defers and coalesces the wire-level `agent_end` while a
1629
+ // prompt is still in flight (see `#emitSessionEvent` in agent-session.ts):
1630
+ // every mid-retry attempt's own settle is superseded, so this method often
1631
+ // sees only ONE `agent_end` for an entire multi-attempt retry saga — which
1632
+ // can be the final failure, not an intermediate one. Gate purely on the
1633
+ // retry lifecycle (`auto_retry_start`/`auto_retry_end`, which are never
1634
+ // deferred) rather than consuming this flag against whichever `agent_end`
1635
+ // happens to arrive first: `#retryPending` is true only for the actual
1636
+ // window a retry is outstanding, so the settled failure that survives
1637
+ // coalescing is never mistaken for a mid-retry blip.
1638
+ if (this.#retryPending) return;
1639
+
1640
+ // Warp structured OSC 777 already drives native completion UX when the
1641
+ // protocol is negotiated — avoid a second legacy desktop/OSC-9 toast.
1642
+ if (isWarpCliAgentProtocolActive()) return;
1643
+
1644
+ const notify = settings.get("error.notify");
1645
+ if (notify === "off") return;
1646
+
1647
+ // Read the turn's own outcome from `agent_end.messages`, not the mutable
1648
+ // active context: a classifier-refusal failure is final (stopReason ===
1649
+ // "error") but gets pruned from `viewSession`'s active context before this
1650
+ // handler runs (see `#removeAssistantMessageFromActiveContext` in
1651
+ // agent-session.ts), so `getLastAssistantMessage()` would see a stale or
1652
+ // absent assistant and silently drop the notification.
1653
+ const last = event.messages.findLast((message): message is AssistantMessage => message.role === "assistant");
1654
+ if (last?.stopReason !== "error") return;
1655
+
1656
+ const sessionName = this.ctx.sessionManager.getSessionName();
1657
+ TERMINAL.sendNotification({
1658
+ title: sessionName || "Oh My Pi",
1659
+ body: "Stopped with error",
1660
+ type: "error",
1661
+ actions: "focus",
1662
+ });
1663
+ }
1664
+
1665
+ sendCompletionNotification(event: Extract<AgentSessionEvent, { type: "agent_end" }>): void {
1572
1666
  const notify = settings.get("completion.notify");
1573
1667
  if (notify === "off") return;
1574
1668
 
@@ -1576,11 +1670,12 @@ export class EventController {
1576
1670
  // protocol is negotiated — avoid a second legacy desktop/OSC-9 toast.
1577
1671
  if (isWarpCliAgentProtocolActive()) return;
1578
1672
 
1579
- // Skip when the turn was aborted (e.g. ask cancelled with Ctrl+C) or
1580
- // errored those are not "Task complete" events. Mirrors the gate
1581
- // already used by #currentContextTokens, #handleMessageEnd, and the
1582
- // retry / TTSR / compaction skip paths across agent-session.ts.
1583
- const last = this.ctx.viewSession.getLastAssistantMessage?.();
1673
+ // Read the turn's own outcome from `agent_end.messages`, not the mutable
1674
+ // active context (see `sendErrorNotification` above for why `viewSession`'s
1675
+ // snapshot can be stale): an aborted or errored turn is not "Task
1676
+ // complete", and using the same event `sendErrorNotification` just read
1677
+ // keeps the two notifications mutually exclusive for one settled turn.
1678
+ const last = event.messages.findLast((message): message is AssistantMessage => message.role === "assistant");
1584
1679
  if (last?.stopReason === "aborted" || last?.stopReason === "error") return;
1585
1680
 
1586
1681
  const sessionName = this.ctx.sessionManager.getSessionName();
@@ -17,6 +17,7 @@ function makeHarness() {
17
17
  },
18
18
  session: {
19
19
  extensionRunner: undefined,
20
+ setUsageFallbackConfirmer: vi.fn(),
20
21
  },
21
22
  setToolUIContext(context: ExtensionUIContext, hasUI: boolean): void {
22
23
  expect(hasUI).toBe(true);
@@ -28,7 +28,7 @@ import { HookSelectorComponent, type HookSelectorSlider } from "../../modes/comp
28
28
  import { getAvailableThemesWithPaths, getThemeByName, setTheme, type Theme, theme } from "../../modes/theme/theme";
29
29
  import type { InteractiveModeContext, InteractiveSelectorDialogOptions } from "../../modes/types";
30
30
  import { normalizeCustomMessagePayload, USER_INTERRUPT_LABEL } from "../../session/messages";
31
- import { setSessionTerminalTitle, setTerminalTitle } from "../../utils/title-generator";
31
+ import { setExtensionTerminalTitle, setSessionTerminalTitle } from "../../utils/title-generator";
32
32
 
33
33
  const MAX_WIDGET_LINES = 10;
34
34
  const ASK_OTHER_OPTION = "Other (type your own)";
@@ -94,7 +94,7 @@ export class ExtensionUiController {
94
94
  setStatus: (key, text) => this.setHookStatus(key, text),
95
95
  setWorkingMessage: message => this.ctx.setWorkingMessage(message),
96
96
  setWidget: (key, content, options) => this.setHookWidget(key, content, options),
97
- setTitle: title => setTerminalTitle(title),
97
+ setTitle: title => setExtensionTerminalTitle(title),
98
98
  custom: (factory, options) => this.showHookCustom(factory, options),
99
99
  setEditorText: text => {
100
100
  this.ctx.editor.setText(text);
@@ -128,6 +128,16 @@ export class ExtensionUiController {
128
128
  };
129
129
  this.ctx.setToolUIContext(uiContext, true);
130
130
  this.#toolUIContext = uiContext;
131
+ this.ctx.session.setUsageFallbackConfirmer?.(confirmation => {
132
+ const reserve =
133
+ confirmation.remainingPercent === undefined
134
+ ? "inside the configured reserve margin"
135
+ : `${confirmation.remainingPercent.toFixed(1)}% remaining`;
136
+ return this.showHookConfirm(
137
+ "Coding-plan reserve reached",
138
+ `${confirmation.from} has ${reserve}. Switch to ${confirmation.to}? Choose No to keep using the current plan.`,
139
+ );
140
+ });
131
141
 
132
142
  const extensionRunner = this.ctx.session.extensionRunner;
133
143
  if (!extensionRunner) {
@@ -165,6 +175,8 @@ export class ExtensionUiController {
165
175
  },
166
176
  getThinkingLevel: () => this.ctx.session.thinkingLevel,
167
177
  setThinkingLevel: level => this.ctx.session.setThinkingLevel(level),
178
+ getServiceTiers: () => this.ctx.session.serviceTierByFamily,
179
+ setServiceTier: (family, tier) => this.ctx.session.setServiceTierFamily(family, tier),
168
180
  getCommands: () => getSessionSlashCommands(this.ctx.session),
169
181
  getSessionName: () => this.ctx.sessionManager.getSessionName(),
170
182
  setSessionName: name => this.#updateSessionName(name),
@@ -396,6 +408,8 @@ export class ExtensionUiController {
396
408
  },
397
409
  getThinkingLevel: () => this.ctx.session.thinkingLevel,
398
410
  setThinkingLevel: (level, persist) => this.ctx.session.setThinkingLevel(level, persist),
411
+ getServiceTiers: () => this.ctx.session.serviceTierByFamily,
412
+ setServiceTier: (family, tier) => this.ctx.session.setServiceTierFamily(family, tier),
399
413
  getCommands: () => getSessionSlashCommands(this.ctx.session),
400
414
  getSessionName: () => this.ctx.sessionManager.getSessionName(),
401
415
  setSessionName: name => this.#updateSessionName(name),
@@ -532,7 +546,7 @@ export class ExtensionUiController {
532
546
  * Show a tool error in the chat.
533
547
  */
534
548
  showToolError(toolName: string, error: string): void {
535
- const errorText = new Text(theme.fg("error", `Tool "${toolName}" error: ${error}`), 1, 0);
549
+ const errorText = new Text(`Tool "${toolName}" error: ${error}`, 1, 0).setStyleFn(t => theme.fg("error", t));
536
550
  this.ctx.present(errorText);
537
551
  }
538
552
 
@@ -1092,7 +1106,9 @@ export class ExtensionUiController {
1092
1106
  }
1093
1107
 
1094
1108
  showExtensionError(extensionPath: string, error: string): void {
1095
- const errorText = new Text(theme.fg("error", `Extension "${extensionPath}" error: ${error}`), 1, 0);
1109
+ const errorText = new Text(`Extension "${extensionPath}" error: ${error}`, 1, 0).setStyleFn(t =>
1110
+ theme.fg("error", t),
1111
+ );
1096
1112
  this.ctx.present(errorText);
1097
1113
  }
1098
1114
  async #handleInteractiveCompact(instructionsOrOptions: string | CompactOptions | undefined): Promise<void> {