@oh-my-pi/pi-coding-agent 17.0.9 → 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 (285) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +1 -1
  3. package/dist/cli.js +4659 -4261
  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/model-registry.d.ts +3 -1
  10. package/dist/types/config/models-config-schema.d.ts +18 -6
  11. package/dist/types/config/models-config.d.ts +14 -5
  12. package/dist/types/config/provider-globals.d.ts +2 -2
  13. package/dist/types/config/service-tier.d.ts +5 -1
  14. package/dist/types/config/settings-schema.d.ts +316 -47
  15. package/dist/types/config/settings.d.ts +7 -0
  16. package/dist/types/dap/client.d.ts +20 -0
  17. package/dist/types/exec/bash-executor.d.ts +38 -0
  18. package/dist/types/exec/direnv.d.ts +33 -0
  19. package/dist/types/export/html/args.d.ts +15 -0
  20. package/dist/types/export/html/index.d.ts +11 -26
  21. package/dist/types/export/html/web-palette.d.ts +55 -116
  22. package/dist/types/extensibility/extensions/loader.d.ts +3 -0
  23. package/dist/types/extensibility/extensions/types.d.ts +17 -2
  24. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +13 -0
  25. package/dist/types/live/controller.d.ts +40 -0
  26. package/dist/types/live/protocol.d.ts +85 -0
  27. package/dist/types/live/protocol.test.d.ts +1 -0
  28. package/dist/types/live/transport.d.ts +48 -0
  29. package/dist/types/live/visualizer.d.ts +33 -0
  30. package/dist/types/lsp/client.d.ts +16 -1
  31. package/dist/types/mcp/config-writer.test.d.ts +1 -0
  32. package/dist/types/mcp/oauth-discovery.d.ts +2 -0
  33. package/dist/types/mcp/smithery-auth.d.ts +1 -1
  34. package/dist/types/mcp/smithery-auth.test.d.ts +1 -0
  35. package/dist/types/memory-backend/tool-names.d.ts +2 -0
  36. package/dist/types/modes/components/login-dialog.d.ts +2 -2
  37. package/dist/types/modes/components/plan-review-overlay.d.ts +26 -0
  38. package/dist/types/modes/components/settings-defs.d.ts +7 -1
  39. package/dist/types/modes/components/status-line/component.jj-cache.test.d.ts +1 -0
  40. package/dist/types/modes/components/tool-execution.d.ts +9 -1
  41. package/dist/types/modes/controllers/command-controller.d.ts +1 -1
  42. package/dist/types/modes/controllers/event-controller.d.ts +6 -1
  43. package/dist/types/modes/controllers/live-command-controller.d.ts +14 -0
  44. package/dist/types/modes/interactive-mode.d.ts +5 -0
  45. package/dist/types/modes/theme/theme.d.ts +1 -1
  46. package/dist/types/modes/types.d.ts +3 -1
  47. package/dist/types/modes/utils/context-usage.d.ts +12 -2
  48. package/dist/types/plan-mode/plan-files.d.ts +10 -0
  49. package/dist/types/registry/agent-lifecycle.d.ts +21 -9
  50. package/dist/types/registry/agent-registry.d.ts +11 -4
  51. package/dist/types/sdk.d.ts +15 -1
  52. package/dist/types/secrets/index.d.ts +15 -2
  53. package/dist/types/secrets/obfuscator.d.ts +113 -15
  54. package/dist/types/session/acp-permission-gate.d.ts +19 -0
  55. package/dist/types/session/agent-session-events.d.ts +84 -0
  56. package/dist/types/session/agent-session-types.d.ts +314 -0
  57. package/dist/types/session/agent-session.d.ts +118 -755
  58. package/dist/types/session/async-job-delivery.d.ts +28 -0
  59. package/dist/types/session/bash-runner.d.ts +75 -0
  60. package/dist/types/session/blob-store.d.ts +11 -1
  61. package/dist/types/session/blob-store.test.d.ts +1 -0
  62. package/dist/types/session/checkpoint-entries.d.ts +28 -0
  63. package/dist/types/session/eval-runner.d.ts +49 -0
  64. package/dist/types/session/irc-bridge.d.ts +46 -0
  65. package/dist/types/session/messages.d.ts +22 -0
  66. package/dist/types/session/model-controls.d.ts +180 -0
  67. package/dist/types/session/prewalk.d.ts +46 -0
  68. package/dist/types/session/queued-messages.d.ts +22 -0
  69. package/dist/types/session/redis-session-storage.d.ts +1 -0
  70. package/dist/types/session/retry-fallback-chains.d.ts +74 -0
  71. package/dist/types/session/role-models.d.ts +15 -0
  72. package/dist/types/session/session-advisors.d.ts +228 -0
  73. package/dist/types/session/session-entries.d.ts +8 -0
  74. package/dist/types/session/session-handoff.d.ts +70 -0
  75. package/dist/types/session/session-history-format.d.ts +6 -1
  76. package/dist/types/session/session-maintenance.d.ts +246 -0
  77. package/dist/types/session/session-manager.d.ts +41 -0
  78. package/dist/types/session/session-memory.d.ts +56 -0
  79. package/dist/types/session/session-provider-boundary.d.ts +62 -0
  80. package/dist/types/session/session-stats.d.ts +48 -0
  81. package/dist/types/session/session-tools.d.ts +143 -0
  82. package/dist/types/session/session-workspace.d.ts +32 -0
  83. package/dist/types/session/stream-guards.d.ts +44 -0
  84. package/dist/types/session/todo-tracker.d.ts +56 -0
  85. package/dist/types/session/ttsr-coordinator.d.ts +51 -0
  86. package/dist/types/session/turn-recovery.d.ts +225 -0
  87. package/dist/types/system-prompt.d.ts +2 -0
  88. package/dist/types/task/executor.d.ts +4 -1
  89. package/dist/types/task/output-manager.d.ts +3 -1
  90. package/dist/types/tools/approval.d.ts +1 -0
  91. package/dist/types/tools/browser/launch.d.ts +5 -0
  92. package/dist/types/tools/image-gen.d.ts +4 -4
  93. package/dist/types/tools/image-providers.d.ts +38 -0
  94. package/dist/types/tools/index.d.ts +5 -0
  95. package/dist/types/tools/path-utils.d.ts +8 -0
  96. package/dist/types/tools/todo.d.ts +10 -4
  97. package/dist/types/tools/xdev.d.ts +5 -1
  98. package/dist/types/tts/streaming-player.d.ts +14 -0
  99. package/dist/types/tts/vocalizer.d.ts +5 -0
  100. package/dist/types/utils/jj.d.ts +29 -0
  101. package/dist/types/utils/lang-from-path.d.ts +1 -0
  102. package/dist/types/utils/title-generator.d.ts +30 -0
  103. package/dist/types/vibe/runtime.d.ts +36 -8
  104. package/dist/types/web/search/index.d.ts +1 -1
  105. package/dist/types/web/search/provider.d.ts +15 -5
  106. package/dist/types/web/search/types.d.ts +94 -0
  107. package/package.json +12 -12
  108. package/scripts/generate-share-viewer.ts +4 -6
  109. package/src/advisor/__tests__/advisor.test.ts +643 -0
  110. package/src/advisor/runtime.ts +144 -43
  111. package/src/async/job-manager.ts +90 -2
  112. package/src/cli/args.ts +2 -0
  113. package/src/cli/auth-gateway-cli.ts +18 -3
  114. package/src/cli/bench-cli.ts +395 -29
  115. package/src/cli/flag-tables.ts +3 -0
  116. package/src/cli/gc-cli.ts +2 -2
  117. package/src/cli/usage-cli.ts +10 -1
  118. package/src/collab/host.ts +3 -2
  119. package/src/commands/bench.ts +18 -4
  120. package/src/commands/launch.ts +4 -0
  121. package/src/config/model-registry.ts +10 -2
  122. package/src/config/models-config-schema.ts +15 -4
  123. package/src/config/provider-globals.ts +9 -9
  124. package/src/config/service-tier.ts +26 -1
  125. package/src/config/settings-schema.ts +209 -51
  126. package/src/config/settings.ts +81 -2
  127. package/src/dap/client.ts +37 -4
  128. package/src/edit/index.ts +49 -12
  129. package/src/eval/py/executor.ts +103 -33
  130. package/src/exec/bash-executor.ts +89 -6
  131. package/src/exec/direnv.ts +145 -0
  132. package/src/export/html/args.ts +20 -0
  133. package/src/export/html/index.ts +45 -47
  134. package/src/export/html/template.css +19 -1
  135. package/src/export/html/template.html +6 -0
  136. package/src/export/html/template.js +21 -0
  137. package/src/export/html/tool-views.generated.js +31 -31
  138. package/src/export/html/web-palette.ts +116 -132
  139. package/src/export/share.ts +249 -49
  140. package/src/extensibility/extensions/loader.ts +29 -1
  141. package/src/extensibility/extensions/runner.ts +6 -0
  142. package/src/extensibility/extensions/types.ts +33 -2
  143. package/src/extensibility/plugins/legacy-pi-compat.ts +19 -1
  144. package/src/hindsight/state.ts +64 -6
  145. package/src/internal-urls/local-protocol.ts +2 -1
  146. package/src/internal-urls/memory-protocol.ts +2 -2
  147. package/src/internal-urls/skill-protocol.ts +2 -2
  148. package/src/internal-urls/ssh-protocol.ts +2 -1
  149. package/src/internal-urls/vault-protocol.ts +2 -1
  150. package/src/live/audio-worklet.txt +59 -0
  151. package/src/live/browser-runtime.txt +221 -0
  152. package/src/live/controller.ts +464 -0
  153. package/src/live/prompts/agent-final-message.md +3 -0
  154. package/src/live/prompts/live-instructions.md +23 -0
  155. package/src/live/protocol.test.ts +140 -0
  156. package/src/live/protocol.ts +233 -0
  157. package/src/live/transport.ts +490 -0
  158. package/src/live/visualizer.ts +234 -0
  159. package/src/lsp/client.ts +29 -9
  160. package/src/lsp/index.ts +19 -4
  161. package/src/main.ts +8 -1
  162. package/src/mcp/config-writer.test.ts +43 -0
  163. package/src/mcp/config-writer.ts +109 -82
  164. package/src/mcp/oauth-discovery.ts +10 -2
  165. package/src/mcp/smithery-auth.test.ts +29 -0
  166. package/src/mcp/smithery-auth.ts +3 -2
  167. package/src/memories/index.ts +40 -20
  168. package/src/memory-backend/tool-names.ts +2 -0
  169. package/src/mnemopi/backend.ts +24 -7
  170. package/src/modes/acp/acp-agent.ts +56 -10
  171. package/src/modes/acp/acp-event-mapper.ts +8 -1
  172. package/src/modes/components/agent-hub.ts +1 -1
  173. package/src/modes/components/login-dialog.ts +14 -4
  174. package/src/modes/components/plan-review-overlay.ts +350 -35
  175. package/src/modes/components/settings-defs.ts +23 -1
  176. package/src/modes/components/settings-selector.ts +169 -1
  177. package/src/modes/components/status-line/component.jj-cache.test.ts +229 -0
  178. package/src/modes/components/status-line/component.ts +123 -2
  179. package/src/modes/components/tool-execution.ts +11 -2
  180. package/src/modes/controllers/command-controller.ts +56 -15
  181. package/src/modes/controllers/event-controller.ts +111 -16
  182. package/src/modes/controllers/extension-ui-controller.test.ts +1 -0
  183. package/src/modes/controllers/extension-ui-controller.ts +20 -4
  184. package/src/modes/controllers/live-command-controller.ts +178 -0
  185. package/src/modes/controllers/mcp-command-controller.ts +10 -1
  186. package/src/modes/controllers/omfg-controller.ts +44 -40
  187. package/src/modes/controllers/selector-controller.ts +18 -10
  188. package/src/modes/controllers/session-focus-controller.ts +6 -1
  189. package/src/modes/interactive-mode.ts +157 -38
  190. package/src/modes/runtime-init.ts +2 -0
  191. package/src/modes/setup-wizard/scenes/web-search.ts +17 -9
  192. package/src/modes/theme/theme.ts +1 -1
  193. package/src/modes/types.ts +3 -1
  194. package/src/modes/utils/context-usage.ts +23 -7
  195. package/src/modes/utils/ui-helpers.ts +10 -5
  196. package/src/plan-mode/plan-files.ts +40 -0
  197. package/src/prompts/bench/cache-prefix-chunk.md +1 -0
  198. package/src/prompts/bench/cache-prefix.md +3 -0
  199. package/src/prompts/bench/cache-suffix.md +1 -0
  200. package/src/prompts/system/custom-system-prompt.md +1 -1
  201. package/src/prompts/system/project-prompt.md +8 -1
  202. package/src/prompts/system/subagent-async-pending.md +6 -0
  203. package/src/prompts/system/system-prompt.md +1 -1
  204. package/src/prompts/system/workflow-notice.md +48 -40
  205. package/src/prompts/system/xdev-mount-notice.md +4 -0
  206. package/src/prompts/tools/todo.md +3 -1
  207. package/src/registry/agent-lifecycle.ts +116 -46
  208. package/src/registry/agent-registry.ts +35 -8
  209. package/src/registry/persisted-agents.ts +26 -2
  210. package/src/sdk.ts +210 -136
  211. package/src/secrets/index.ts +127 -4
  212. package/src/secrets/obfuscator.ts +2328 -105
  213. package/src/session/acp-permission-gate.ts +165 -0
  214. package/src/session/agent-session-error-log.test.ts +1 -1
  215. package/src/session/agent-session-events.ts +66 -0
  216. package/src/session/agent-session-types.ts +334 -0
  217. package/src/session/agent-session.ts +1892 -12226
  218. package/src/session/artifacts.ts +6 -5
  219. package/src/session/async-job-delivery.ts +74 -0
  220. package/src/session/bash-runner.ts +326 -0
  221. package/src/session/blob-store.test.ts +56 -0
  222. package/src/session/blob-store.ts +18 -2
  223. package/src/session/checkpoint-entries.ts +81 -0
  224. package/src/session/eval-runner.ts +212 -0
  225. package/src/session/indexed-session-storage.ts +9 -2
  226. package/src/session/irc-bridge.ts +203 -0
  227. package/src/session/messages.ts +263 -1
  228. package/src/session/model-controls.ts +714 -0
  229. package/src/session/prewalk.ts +252 -0
  230. package/src/session/queued-messages.ts +93 -0
  231. package/src/session/redis-session-storage.ts +51 -11
  232. package/src/session/retry-fallback-chains.ts +455 -0
  233. package/src/session/role-models.ts +85 -0
  234. package/src/session/session-advisors.ts +1679 -0
  235. package/src/session/session-context.ts +6 -3
  236. package/src/session/session-entries.ts +8 -0
  237. package/src/session/session-handoff.ts +308 -0
  238. package/src/session/session-history-format.ts +20 -9
  239. package/src/session/session-loader.ts +0 -46
  240. package/src/session/session-maintenance.ts +2983 -0
  241. package/src/session/session-manager.ts +390 -30
  242. package/src/session/session-memory.ts +222 -0
  243. package/src/session/session-provider-boundary.ts +307 -0
  244. package/src/session/session-stats.ts +293 -0
  245. package/src/session/session-tools.ts +943 -0
  246. package/src/session/session-workspace.ts +53 -0
  247. package/src/session/stream-guards.ts +417 -0
  248. package/src/session/todo-tracker.ts +380 -0
  249. package/src/session/ttsr-coordinator.ts +496 -0
  250. package/src/session/turn-recovery.ts +1629 -0
  251. package/src/slash-commands/builtin-registry.ts +92 -11
  252. package/src/slash-commands/helpers/usage-report.ts +22 -3
  253. package/src/system-prompt.ts +41 -17
  254. package/src/task/executor.ts +285 -70
  255. package/src/task/index.ts +98 -105
  256. package/src/task/output-manager.ts +25 -3
  257. package/src/task/persisted-revive.ts +4 -3
  258. package/src/task/structured-subagent.ts +1 -0
  259. package/src/tools/approval.ts +55 -10
  260. package/src/tools/bash-interactive.ts +26 -0
  261. package/src/tools/bash-skill-urls.ts +28 -2
  262. package/src/tools/bash.ts +339 -123
  263. package/src/tools/browser/launch.ts +9 -1
  264. package/src/tools/browser/tab-supervisor.ts +8 -5
  265. package/src/tools/browser.ts +94 -47
  266. package/src/tools/image-gen.ts +20 -28
  267. package/src/tools/image-providers.ts +50 -0
  268. package/src/tools/index.ts +9 -0
  269. package/src/tools/path-utils.ts +49 -2
  270. package/src/tools/read.ts +156 -88
  271. package/src/tools/renderers.ts +7 -1
  272. package/src/tools/todo.ts +101 -11
  273. package/src/tools/vibe.ts +1 -2
  274. package/src/tools/write.ts +51 -1
  275. package/src/tools/xdev.ts +67 -3
  276. package/src/tts/streaming-player.ts +62 -4
  277. package/src/tts/vocalizer.ts +18 -1
  278. package/src/utils/jj.ts +125 -4
  279. package/src/utils/lang-from-path.ts +7 -0
  280. package/src/utils/title-generator.ts +137 -1
  281. package/src/vibe/runtime.ts +852 -72
  282. package/src/web/search/index.ts +3 -6
  283. package/src/web/search/provider.ts +31 -16
  284. package/src/web/search/providers/xai.ts +7 -1
  285. package/src/web/search/types.ts +3 -0
@@ -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),
@@ -1707,7 +1731,8 @@ export function formatCompactQuota(
1707
1731
  return `Quota: ${lines.join(" │ ")}`;
1708
1732
  }
1709
1733
 
1710
- 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);
1711
1736
  if (status === "exhausted") return uiTheme.fg("error", uiTheme.status.error);
1712
1737
  if (status === "warning") return uiTheme.fg("warning", uiTheme.status.warning);
1713
1738
  if (status === "ok") return uiTheme.fg("success", uiTheme.status.success);
@@ -1722,6 +1747,14 @@ function resolveStatusColor(status: UsageLimit["status"]): "success" | "warning"
1722
1747
  }
1723
1748
 
1724
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
+ }
1725
1758
  const fraction = resolveUsedFraction(limit);
1726
1759
  if (fraction === undefined) {
1727
1760
  return uiTheme.fg("dim", "·".repeat(barWidth));
@@ -1759,6 +1792,7 @@ export function renderUsageReports(
1759
1792
  nowMs: number,
1760
1793
  availableWidth: number,
1761
1794
  resolveActiveAccount?: (provider: string) => OAuthAccountIdentity | undefined,
1795
+ usageModelSelectors: readonly string[] = [],
1762
1796
  ): string {
1763
1797
  const lines: string[] = [];
1764
1798
  const latestFetchedAt = Math.max(...reports.map(report => report.fetchedAt ?? 0));
@@ -1812,6 +1846,13 @@ export function renderUsageReports(
1812
1846
  if (activeAccountLabel) {
1813
1847
  lines.push(` ${uiTheme.fg("accent", "in use by this session:")} ${activeAccountLabel}`);
1814
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
+ }
1815
1856
 
1816
1857
  // Provider-wide disclaimers (e.g. "OMP-observed spend only") render once
1817
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> {
@@ -0,0 +1,178 @@
1
+ import { logger } from "@oh-my-pi/pi-utils";
2
+ import { LiveSessionController } from "../../live/controller";
3
+ import { LiveVisualizer } from "../../live/visualizer";
4
+ import { vocalizer } from "../../tts/vocalizer";
5
+ import type { CustomEditor } from "../components/custom-editor";
6
+ import type { InteractiveModeContext } from "../types";
7
+
8
+ const ANIMATION_INTERVAL_MS = 80;
9
+
10
+ function errorFrom(cause: unknown): Error {
11
+ return cause instanceof Error ? cause : new Error(String(cause));
12
+ }
13
+
14
+ /** Owns the editor-replacing visualizer and realtime session lifecycle for `/live`. */
15
+ export class LiveCommandController {
16
+ readonly #ctx: InteractiveModeContext;
17
+
18
+ #session: LiveSessionController | undefined;
19
+ #settling: Promise<void> | undefined;
20
+ #visualizer: LiveVisualizer | undefined;
21
+ #detachedEditor: CustomEditor | undefined;
22
+ #animationInterval: NodeJS.Timeout | undefined;
23
+ #previousShowHardwareCursor: boolean | undefined;
24
+ #previousUseTerminalCursor: boolean | undefined;
25
+ #resumeVocalizer: (() => void) | undefined;
26
+
27
+ constructor(ctx: InteractiveModeContext) {
28
+ this.#ctx = ctx;
29
+ }
30
+
31
+ /** Whether a live session is connected, connecting, or closing. */
32
+ get active(): boolean {
33
+ return this.#session !== undefined || this.#settling !== undefined;
34
+ }
35
+
36
+ /** Start live mode, or stop the currently active session. */
37
+ async handleCommand(): Promise<void> {
38
+ if (this.#session) {
39
+ await this.stop();
40
+ return;
41
+ }
42
+ if (this.#settling) await this.#settling;
43
+ await this.#start();
44
+ }
45
+
46
+ /** Stop the active live session and restore the editor. */
47
+ async stop(): Promise<void> {
48
+ const session = this.#session;
49
+ if (!session) {
50
+ if (this.#settling) await this.#settling;
51
+ return;
52
+ }
53
+ try {
54
+ await session.stop();
55
+ } catch (cause) {
56
+ this.#finish(session, errorFrom(cause));
57
+ } finally {
58
+ this.#finish(session);
59
+ }
60
+ }
61
+
62
+ /** Release UI resources during synchronous InteractiveMode teardown. */
63
+ dispose(): void {
64
+ const session = this.#session;
65
+ if (session) {
66
+ this.#finish(session);
67
+ void session.stop().catch(cause => {
68
+ logger.debug("Live session teardown failed", { error: errorFrom(cause).message });
69
+ });
70
+ } else {
71
+ this.#restoreEditor();
72
+ }
73
+ }
74
+
75
+ async #start(): Promise<void> {
76
+ const visualizer = new LiveVisualizer({
77
+ onStop: () => {
78
+ void this.stop().catch(cause => this.#ctx.showError(errorFrom(cause).message));
79
+ },
80
+ onToggleMute: () => this.#session?.toggleMute(),
81
+ });
82
+ this.#mountVisualizer(visualizer);
83
+
84
+ let session: LiveSessionController;
85
+ session = new LiveSessionController({
86
+ session: this.#ctx.session,
87
+ extractAssistantText: message => this.#ctx.extractAssistantText(message),
88
+ callbacks: {
89
+ onPhase: phase => {
90
+ if (this.#visualizer !== visualizer) return;
91
+ visualizer.setPhase(phase);
92
+ this.#ctx.ui.requestComponentRender(visualizer);
93
+ },
94
+ onLevels: (input, output) => {
95
+ if (this.#visualizer !== visualizer) return;
96
+ visualizer.setLevels(input, output);
97
+ this.#ctx.ui.requestComponentRender(visualizer);
98
+ },
99
+ onTranscript: transcript => {
100
+ if (this.#visualizer !== visualizer) return;
101
+ visualizer.setTranscript(transcript);
102
+ this.#ctx.ui.requestComponentRender(visualizer);
103
+ },
104
+ onTerminal: error => this.#finish(session, error),
105
+ },
106
+ });
107
+ this.#session = session;
108
+
109
+ try {
110
+ await session.start();
111
+ } catch (cause) {
112
+ if (this.#session === session) {
113
+ await session.stop();
114
+ this.#finish(session, errorFrom(cause));
115
+ }
116
+ }
117
+ }
118
+
119
+ #mountVisualizer(visualizer: LiveVisualizer): void {
120
+ this.#visualizer = visualizer;
121
+ this.#detachedEditor = this.#ctx.editor;
122
+ this.#previousShowHardwareCursor = this.#ctx.ui.getShowHardwareCursor();
123
+ this.#previousUseTerminalCursor = this.#ctx.editor.getUseTerminalCursor();
124
+ this.#ctx.ui.setShowHardwareCursor(false);
125
+ this.#ctx.editor.setUseTerminalCursor(false);
126
+ this.#ctx.editorContainer.clear();
127
+ this.#ctx.editorContainer.addChild(visualizer);
128
+ this.#ctx.ui.setFocus(visualizer);
129
+ this.#resumeVocalizer = vocalizer.suspend();
130
+ let frame = 0;
131
+ this.#animationInterval = setInterval(() => {
132
+ if (this.#visualizer !== visualizer) return;
133
+ frame += 1;
134
+ visualizer.setFrame(frame);
135
+ this.#ctx.ui.requestComponentRender(visualizer);
136
+ }, ANIMATION_INTERVAL_MS);
137
+ this.#ctx.ui.requestRender();
138
+ }
139
+
140
+ #finish(session: LiveSessionController, error?: Error): void {
141
+ if (this.#session !== session) return;
142
+ this.#session = undefined;
143
+ this.#restoreEditor();
144
+ if (error) this.#ctx.showError(error.message);
145
+ const settling = session.stop().catch(cause => {
146
+ logger.debug("Live session cleanup failed", { error: errorFrom(cause).message });
147
+ });
148
+ this.#settling = settling;
149
+ void settling.finally(() => {
150
+ if (this.#settling === settling) this.#settling = undefined;
151
+ });
152
+ }
153
+
154
+ #restoreEditor(): void {
155
+ if (this.#animationInterval) {
156
+ clearInterval(this.#animationInterval);
157
+ this.#animationInterval = undefined;
158
+ }
159
+ this.#resumeVocalizer?.();
160
+ this.#resumeVocalizer = undefined;
161
+ const editor = this.#detachedEditor;
162
+ this.#detachedEditor = undefined;
163
+ this.#visualizer = undefined;
164
+ if (!editor) return;
165
+ this.#ctx.editorContainer.clear();
166
+ this.#ctx.editorContainer.addChild(editor);
167
+ if (this.#previousShowHardwareCursor !== undefined) {
168
+ this.#ctx.ui.setShowHardwareCursor(this.#previousShowHardwareCursor);
169
+ }
170
+ if (this.#previousUseTerminalCursor !== undefined) {
171
+ editor.setUseTerminalCursor(this.#previousUseTerminalCursor);
172
+ }
173
+ this.#previousShowHardwareCursor = undefined;
174
+ this.#previousUseTerminalCursor = undefined;
175
+ this.#ctx.ui.setFocus(editor);
176
+ this.#ctx.ui.requestRender();
177
+ }
178
+ }