@oh-my-pi/pi-coding-agent 15.12.4 → 15.13.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 (291) hide show
  1. package/CHANGELOG.md +304 -6
  2. package/dist/cli.js +1015 -881
  3. package/dist/types/async/job-manager.d.ts +15 -0
  4. package/dist/types/autolearn/controller.d.ts +25 -0
  5. package/dist/types/autolearn/managed-skills.d.ts +45 -0
  6. package/dist/types/autoresearch/state.d.ts +1 -1
  7. package/dist/types/autoresearch/types.d.ts +1 -1
  8. package/dist/types/cli/args.d.ts +19 -1
  9. package/dist/types/cli/session-picker.d.ts +1 -1
  10. package/dist/types/cli/setup-cli.d.ts +1 -1
  11. package/dist/types/cli/setup-model-picker.d.ts +14 -0
  12. package/dist/types/collab/protocol.d.ts +1 -1
  13. package/dist/types/commands/say.d.ts +24 -0
  14. package/dist/types/config/keybindings.d.ts +3 -3
  15. package/dist/types/config/model-registry.d.ts +10 -0
  16. package/dist/types/config/models-config-schema.d.ts +12 -0
  17. package/dist/types/config/models-config.d.ts +8 -2
  18. package/dist/types/config/settings-schema.d.ts +261 -58
  19. package/dist/types/export/html/index.d.ts +2 -1
  20. package/dist/types/extensibility/extensions/model-api.d.ts +17 -0
  21. package/dist/types/extensibility/extensions/runner.d.ts +3 -1
  22. package/dist/types/extensibility/extensions/types.d.ts +47 -1
  23. package/dist/types/extensibility/hooks/index.d.ts +2 -1
  24. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +9 -0
  25. package/dist/types/extensibility/plugins/loader.d.ts +11 -0
  26. package/dist/types/extensibility/shared-events.d.ts +1 -1
  27. package/dist/types/extensibility/skills.d.ts +10 -0
  28. package/dist/types/goals/guided-setup.d.ts +18 -0
  29. package/dist/types/goals/state.d.ts +1 -1
  30. package/dist/types/hindsight/transcript.d.ts +1 -1
  31. package/dist/types/index.d.ts +5 -0
  32. package/dist/types/internal-urls/local-protocol.d.ts +4 -2
  33. package/dist/types/main.d.ts +4 -3
  34. package/dist/types/mcp/startup-events.d.ts +11 -0
  35. package/dist/types/memories/index.d.ts +7 -0
  36. package/dist/types/memory-backend/local-backend.d.ts +4 -3
  37. package/dist/types/mnemopi/config.d.ts +4 -4
  38. package/dist/types/modes/components/agent-hub.d.ts +6 -0
  39. package/dist/types/modes/components/assistant-message.d.ts +1 -2
  40. package/dist/types/modes/components/compaction-summary-message.d.ts +15 -1
  41. package/dist/types/modes/components/custom-editor.d.ts +39 -1
  42. package/dist/types/modes/components/custom-editor.test.d.ts +1 -0
  43. package/dist/types/modes/components/session-selector.d.ts +1 -1
  44. package/dist/types/modes/components/tool-execution.d.ts +26 -16
  45. package/dist/types/modes/components/transcript-container.d.ts +23 -2
  46. package/dist/types/modes/components/tree-selector.d.ts +1 -1
  47. package/dist/types/modes/components/usage-row.d.ts +3 -0
  48. package/dist/types/modes/controllers/command-controller.d.ts +2 -2
  49. package/dist/types/modes/controllers/input-controller.d.ts +14 -0
  50. package/dist/types/modes/controllers/selector-controller.d.ts +3 -1
  51. package/dist/types/modes/gradient-highlight.d.ts +9 -4
  52. package/dist/types/modes/image-references.d.ts +6 -0
  53. package/dist/types/modes/interactive-mode.d.ts +27 -3
  54. package/dist/types/modes/magic-keywords.d.ts +13 -1
  55. package/dist/types/modes/rpc/rpc-mode.d.ts +35 -1
  56. package/dist/types/modes/rpc/rpc-types.d.ts +9 -1
  57. package/dist/types/modes/runtime-init.d.ts +4 -0
  58. package/dist/types/modes/theme/theme.d.ts +13 -2
  59. package/dist/types/modes/types.d.ts +8 -2
  60. package/dist/types/modes/utils/ui-helpers.d.ts +1 -1
  61. package/dist/types/registry/agent-registry.d.ts +17 -0
  62. package/dist/types/secrets/obfuscator.d.ts +1 -1
  63. package/dist/types/session/agent-session.d.ts +14 -2
  64. package/dist/types/session/indexed-session-storage.d.ts +3 -4
  65. package/dist/types/session/session-context.d.ts +39 -0
  66. package/dist/types/session/session-entries.d.ts +159 -0
  67. package/dist/types/session/session-listing.d.ts +69 -0
  68. package/dist/types/session/session-loader.d.ts +16 -0
  69. package/dist/types/session/session-manager.d.ts +82 -474
  70. package/dist/types/session/session-migrations.d.ts +12 -0
  71. package/dist/types/session/session-paths.d.ts +25 -0
  72. package/dist/types/session/session-persistence.d.ts +8 -0
  73. package/dist/types/session/session-storage.d.ts +11 -12
  74. package/dist/types/session/snapcompact-inline.d.ts +12 -1
  75. package/dist/types/session/snapcompact-savings-journal.d.ts +46 -0
  76. package/dist/types/session/tool-choice-queue.d.ts +6 -6
  77. package/dist/types/stt/asr-client.d.ts +90 -0
  78. package/dist/types/stt/asr-protocol.d.ts +97 -0
  79. package/dist/types/stt/asr-worker.d.ts +2 -0
  80. package/dist/types/stt/downloader.d.ts +38 -0
  81. package/dist/types/stt/endpointer.d.ts +59 -0
  82. package/dist/types/stt/index.d.ts +5 -1
  83. package/dist/types/stt/models.d.ts +120 -0
  84. package/dist/types/stt/recorder.d.ts +17 -0
  85. package/dist/types/stt/stt-controller.d.ts +6 -0
  86. package/dist/types/stt/transcriber.d.ts +5 -7
  87. package/dist/types/stt/wav.d.ts +29 -0
  88. package/dist/types/system-prompt.d.ts +4 -0
  89. package/dist/types/task/executor.d.ts +2 -0
  90. package/dist/types/task/index.d.ts +9 -1
  91. package/dist/types/task/types.d.ts +36 -0
  92. package/dist/types/tools/bash.d.ts +2 -2
  93. package/dist/types/tools/eval-render.d.ts +1 -1
  94. package/dist/types/tools/index.d.ts +11 -1
  95. package/dist/types/tools/irc.d.ts +1 -0
  96. package/dist/types/tools/learn.d.ts +51 -0
  97. package/dist/types/tools/manage-skill.d.ts +40 -0
  98. package/dist/types/tools/plan-mode-guard.d.ts +10 -0
  99. package/dist/types/tools/renderers.d.ts +7 -11
  100. package/dist/types/tools/ssh.d.ts +1 -1
  101. package/dist/types/tools/todo.d.ts +1 -1
  102. package/dist/types/tools/tts.d.ts +25 -0
  103. package/dist/types/tools/write.d.ts +1 -1
  104. package/dist/types/tts/downloader.d.ts +20 -0
  105. package/dist/types/tts/index.d.ts +8 -0
  106. package/dist/types/tts/models.d.ts +82 -0
  107. package/dist/types/tts/player.d.ts +32 -0
  108. package/dist/types/tts/runtime.d.ts +6 -0
  109. package/dist/types/tts/streaming-player.d.ts +41 -0
  110. package/dist/types/tts/tts-client.d.ts +93 -0
  111. package/dist/types/tts/tts-protocol.d.ts +95 -0
  112. package/dist/types/tts/tts-worker.d.ts +2 -0
  113. package/dist/types/tts/vocalizer.d.ts +41 -0
  114. package/dist/types/tts/wav.d.ts +8 -0
  115. package/dist/types/utils/tool-choice.d.ts +8 -0
  116. package/dist/types/utils/tools-manager.d.ts +2 -1
  117. package/dist/types/utils/tools-manager.test.d.ts +1 -0
  118. package/dist/types/web/scrapers/github.d.ts +1 -1
  119. package/package.json +15 -14
  120. package/src/async/job-manager.ts +49 -0
  121. package/src/autolearn/controller.ts +139 -0
  122. package/src/autolearn/managed-skills.ts +257 -0
  123. package/src/autoresearch/state.ts +1 -1
  124. package/src/autoresearch/types.ts +1 -1
  125. package/src/cli/args.ts +56 -2
  126. package/src/cli/session-picker.ts +2 -1
  127. package/src/cli/setup-cli.ts +148 -47
  128. package/src/cli/setup-model-picker.ts +43 -0
  129. package/src/cli-commands.ts +1 -0
  130. package/src/cli.ts +45 -13
  131. package/src/collab/host.ts +1 -1
  132. package/src/collab/protocol.ts +1 -1
  133. package/src/commands/say.ts +102 -0
  134. package/src/commands/setup.ts +1 -1
  135. package/src/commit/agentic/tools/analyze-file.ts +3 -0
  136. package/src/config/keybindings.ts +2 -2
  137. package/src/config/model-discovery.ts +11 -5
  138. package/src/config/model-registry.ts +64 -9
  139. package/src/config/models-config-schema.ts +4 -1
  140. package/src/config/models-config.ts +2 -1
  141. package/src/config/settings-schema.ts +248 -32
  142. package/src/config/settings.ts +10 -0
  143. package/src/discovery/builtin.ts +23 -1
  144. package/src/discovery/claude-plugins.ts +44 -5
  145. package/src/discovery/helpers.ts +41 -1
  146. package/src/eval/__tests__/budget-bridge.test.ts +1 -1
  147. package/src/eval/js/shared/prelude.txt +69 -17
  148. package/src/export/html/index.ts +3 -6
  149. package/src/extensibility/extensions/model-api.ts +41 -0
  150. package/src/extensibility/extensions/runner.ts +4 -0
  151. package/src/extensibility/extensions/types.ts +52 -1
  152. package/src/extensibility/extensions/wrapper.ts +41 -5
  153. package/src/extensibility/hooks/index.ts +2 -1
  154. package/src/extensibility/plugins/legacy-pi-compat.ts +43 -13
  155. package/src/extensibility/plugins/loader.ts +30 -19
  156. package/src/extensibility/plugins/manager.ts +221 -90
  157. package/src/extensibility/shared-events.ts +1 -1
  158. package/src/extensibility/skills.ts +96 -15
  159. package/src/goals/guided-setup.ts +133 -0
  160. package/src/goals/state.ts +1 -1
  161. package/src/hindsight/transcript.ts +1 -1
  162. package/src/index.ts +5 -0
  163. package/src/internal-urls/docs-index.generated.ts +10 -10
  164. package/src/internal-urls/history-protocol.ts +1 -1
  165. package/src/internal-urls/local-protocol.ts +29 -7
  166. package/src/main.ts +27 -7
  167. package/src/mcp/startup-events.ts +21 -0
  168. package/src/mcp/transports/stdio.ts +2 -1
  169. package/src/memories/index.ts +146 -11
  170. package/src/memory-backend/local-backend.ts +11 -5
  171. package/src/mnemopi/backend.ts +1 -0
  172. package/src/mnemopi/config.ts +26 -10
  173. package/src/modes/acp/acp-agent.ts +3 -5
  174. package/src/modes/components/agent-hub.ts +49 -4
  175. package/src/modes/components/assistant-message.ts +4 -37
  176. package/src/modes/components/compaction-summary-message.ts +125 -26
  177. package/src/modes/components/custom-editor.test.ts +96 -0
  178. package/src/modes/components/custom-editor.ts +164 -8
  179. package/src/modes/components/session-selector.ts +1 -1
  180. package/src/modes/components/settings-defs.ts +7 -0
  181. package/src/modes/components/tool-execution.ts +82 -43
  182. package/src/modes/components/transcript-container.ts +70 -1
  183. package/src/modes/components/tree-selector.ts +1 -1
  184. package/src/modes/components/usage-row.ts +18 -0
  185. package/src/modes/components/user-message.ts +4 -2
  186. package/src/modes/controllers/command-controller.ts +14 -4
  187. package/src/modes/controllers/event-controller.ts +78 -11
  188. package/src/modes/controllers/extension-ui-controller.ts +6 -0
  189. package/src/modes/controllers/input-controller.ts +258 -27
  190. package/src/modes/controllers/selector-controller.ts +12 -2
  191. package/src/modes/gradient-highlight.ts +21 -9
  192. package/src/modes/image-references.ts +20 -0
  193. package/src/modes/interactive-mode.ts +286 -40
  194. package/src/modes/magic-keywords.ts +27 -5
  195. package/src/modes/rpc/rpc-mode.ts +146 -14
  196. package/src/modes/rpc/rpc-subagents.ts +2 -2
  197. package/src/modes/rpc/rpc-types.ts +8 -2
  198. package/src/modes/runtime-init.ts +28 -3
  199. package/src/modes/theme/theme.ts +98 -50
  200. package/src/modes/types.ts +6 -2
  201. package/src/modes/utils/hotkeys-markdown.ts +1 -1
  202. package/src/modes/utils/ui-helpers.ts +34 -6
  203. package/src/priority.json +5 -1
  204. package/src/prompts/agents/task.md +1 -0
  205. package/src/prompts/goals/guided-goal-interview.md +8 -0
  206. package/src/prompts/goals/guided-goal-system.md +12 -0
  207. package/src/prompts/memories/read-path.md +6 -0
  208. package/src/prompts/system/autolearn-guidance-learn.md +1 -0
  209. package/src/prompts/system/autolearn-guidance.md +7 -0
  210. package/src/prompts/system/autolearn-nudge.md +3 -0
  211. package/src/prompts/system/eager-task.md +7 -0
  212. package/src/prompts/system/eager-todo.md +11 -6
  213. package/src/prompts/system/subagent-system-prompt.md +4 -0
  214. package/src/prompts/system/system-prompt.md +10 -5
  215. package/src/prompts/system/title-marker-instruction.md +1 -0
  216. package/src/prompts/system/title-system-marker.md +16 -0
  217. package/src/prompts/tools/job.md +1 -0
  218. package/src/prompts/tools/learn.md +7 -0
  219. package/src/prompts/tools/manage-skill.md +9 -0
  220. package/src/prompts/tools/task.md +3 -0
  221. package/src/registry/agent-registry.ts +30 -0
  222. package/src/sdk.ts +88 -24
  223. package/src/secrets/obfuscator.ts +1 -1
  224. package/src/session/agent-session.ts +209 -87
  225. package/src/session/history-storage.ts +2 -2
  226. package/src/session/indexed-session-storage.ts +7 -17
  227. package/src/session/session-context.ts +352 -0
  228. package/src/session/session-entries.ts +194 -0
  229. package/src/session/session-listing.ts +588 -0
  230. package/src/session/session-loader.ts +106 -0
  231. package/src/session/session-manager.ts +933 -3145
  232. package/src/session/session-migrations.ts +78 -0
  233. package/src/session/session-paths.ts +193 -0
  234. package/src/session/session-persistence.ts +131 -0
  235. package/src/session/session-storage.ts +91 -50
  236. package/src/session/snapcompact-inline.ts +21 -1
  237. package/src/session/snapcompact-savings-journal.ts +113 -0
  238. package/src/session/tool-choice-queue.ts +23 -11
  239. package/src/slash-commands/builtin-registry.ts +25 -3
  240. package/src/stt/asr-client.ts +520 -0
  241. package/src/stt/asr-protocol.ts +65 -0
  242. package/src/stt/asr-worker.ts +790 -0
  243. package/src/stt/downloader.ts +107 -47
  244. package/src/stt/endpointer.ts +259 -0
  245. package/src/stt/index.ts +5 -1
  246. package/src/stt/models.ts +150 -0
  247. package/src/stt/recorder.ts +247 -60
  248. package/src/stt/stt-controller.ts +201 -22
  249. package/src/stt/transcriber.ts +37 -68
  250. package/src/stt/wav.ts +173 -0
  251. package/src/system-prompt.ts +8 -0
  252. package/src/task/agents.ts +1 -2
  253. package/src/task/executor.ts +49 -15
  254. package/src/task/index.ts +60 -6
  255. package/src/task/render.ts +83 -8
  256. package/src/task/types.ts +53 -0
  257. package/src/tools/ask.ts +8 -0
  258. package/src/tools/bash.ts +4 -3
  259. package/src/tools/eval-render.ts +4 -3
  260. package/src/tools/index.ts +40 -4
  261. package/src/tools/irc.ts +10 -2
  262. package/src/tools/job.ts +14 -2
  263. package/src/tools/learn.ts +144 -0
  264. package/src/tools/manage-skill.ts +104 -0
  265. package/src/tools/plan-mode-guard.ts +53 -19
  266. package/src/tools/renderers.ts +7 -11
  267. package/src/tools/ssh.ts +4 -3
  268. package/src/tools/todo.ts +1 -1
  269. package/src/tools/tts.ts +203 -92
  270. package/src/tools/write.ts +18 -2
  271. package/src/tts/downloader.ts +64 -0
  272. package/src/tts/index.ts +8 -0
  273. package/src/tts/models.ts +137 -0
  274. package/src/tts/player.ts +137 -0
  275. package/src/tts/runtime.ts +21 -0
  276. package/src/tts/streaming-player.ts +266 -0
  277. package/src/tts/tts-client.ts +647 -0
  278. package/src/tts/tts-protocol.ts +60 -0
  279. package/src/tts/tts-worker.ts +497 -0
  280. package/src/tts/vocalizer.ts +162 -0
  281. package/src/tts/wav.ts +58 -0
  282. package/src/utils/title-generator.ts +48 -5
  283. package/src/utils/tool-choice.ts +16 -0
  284. package/src/utils/tools-manager.test.ts +25 -0
  285. package/src/utils/tools-manager.ts +19 -1
  286. package/src/web/scrapers/github.ts +96 -0
  287. package/src/web/search/index.ts +13 -0
  288. package/src/web/search/providers/searxng.ts +13 -1
  289. package/dist/types/stt/setup.d.ts +0 -18
  290. package/src/stt/setup.ts +0 -52
  291. package/src/stt/transcribe.py +0 -70
@@ -8,6 +8,7 @@ import {
8
8
  Image,
9
9
  ImageProtocol,
10
10
  imageFallback,
11
+ type NativeScrollbackLiveRegion,
11
12
  Spacer,
12
13
  TERMINAL,
13
14
  Text,
@@ -16,7 +17,7 @@ import {
16
17
  import { getProjectDir, logger, sanitizeText } from "@oh-my-pi/pi-utils";
17
18
  import { EDIT_MODE_STRATEGIES, type EditMode, type PerFileDiffPreview } from "../../edit";
18
19
  import type { Theme } from "../../modes/theme/theme";
19
- import { theme } from "../../modes/theme/theme";
20
+ import { getThemeEpoch, theme } from "../../modes/theme/theme";
20
21
  import { BASH_DEFAULT_PREVIEW_LINES } from "../../tools/bash";
21
22
  import { EVAL_DEFAULT_PREVIEW_LINES } from "../../tools/eval";
22
23
  import { isWaitingPollDetails } from "../../tools/job";
@@ -148,10 +149,16 @@ export interface ToolExecutionHandle {
148
149
  /** Drive pending-tool redraws at 30fps for live tool headers and displaceable
149
150
  * poll blocks. The TUI throttles at the same cadence, and static frames diff to
150
151
  * a no-op redraw at ~zero cost. */
151
- const SPINNER_RENDER_INTERVAL_MS = 1000 / 30;
152
+ export const SPINNER_RENDER_INTERVAL_MS = 1000 / 30;
152
153
  /** Advance the spinner glyph at its classic ~12.5fps step, decoupled from the
153
154
  * render cadence (mirrors `Loader`). */
154
- const SPINNER_GLYPH_ADVANCE_MS = 80;
155
+ export const SPINNER_GLYPH_ADVANCE_MS = 80;
156
+
157
+ /** Phase-locked spinner glyph index shared by every live tool block so parallel
158
+ * spinners advance in lockstep instead of each tracking its own start time. */
159
+ export function sharedSpinnerFrame(frameCount: number, now: number = performance.now()): number {
160
+ return frameCount > 0 ? Math.floor(now / SPINNER_GLYPH_ADVANCE_MS) % frameCount : 0;
161
+ }
155
162
 
156
163
  // Stable per-instance counter so each tool execution's inline images get a
157
164
  // graphics id that survives child re-creation (the image budget keys off it).
@@ -160,7 +167,7 @@ let toolExecutionInstanceSeq = 0;
160
167
  /**
161
168
  * Component that renders a tool call with its result (updateable)
162
169
  */
163
- export class ToolExecutionComponent extends Container {
170
+ export class ToolExecutionComponent extends Container implements NativeScrollbackLiveRegion {
164
171
  #contentBox: Box; // Used for custom tools and bash visual truncation
165
172
  #contentText: Text; // For built-in tools (with its own padding/bg)
166
173
  #multiFileBoxes: (Box | Spacer)[] = []; // Extra boxes for multi-file edit results
@@ -176,6 +183,22 @@ export class ToolExecutionComponent extends Container {
176
183
  #editAllowFuzzy: boolean | undefined;
177
184
  #snapshots?: SnapshotStore;
178
185
  #isPartial = true;
186
+ #resultVersion = 0;
187
+ #lastDisplayKey: string | undefined;
188
+ // Bumped whenever a render input that #rebuildDisplay consumes but the memo
189
+ // key cannot cheaply hash changes: streamed call args, the async edit-diff
190
+ // preview, and Kitty PNG conversions. Folded into the dirty key so those
191
+ // updates are not swallowed by the memo (see #updateDisplay).
192
+ #displayInputVersion = 0;
193
+ // Set once #rebuildDisplay has populated the display. Replaces a
194
+ // #contentBox.children.length probe so the memo fast-path also covers the
195
+ // #contentText fallback path (which leaves #contentBox empty).
196
+ #displayBuilt = false;
197
+ // Number of Image children the last rebuild emitted. Only when this is > 0 does
198
+ // the memo key fold in viewport-dependent image sizing (resolveImageOptions),
199
+ // so a terminal resize re-shapes image-bearing results to rescale them without
200
+ // forcing the common image-free result to re-shape on every resize tick.
201
+ #renderedImageCount = 0;
179
202
  #tool?: AgentTool;
180
203
  #ui: TUI;
181
204
  #cwd: string;
@@ -196,7 +219,6 @@ export class ToolExecutionComponent extends Container {
196
219
  // Spinner animation for partial task results
197
220
  #spinnerFrame?: number;
198
221
  #spinnerInterval?: NodeJS.Timeout;
199
- #lastSpinnerAdvanceAt = 0;
200
222
  // Todo write completion strikethrough reveal animation
201
223
  #todoStrikeInterval?: NodeJS.Timeout;
202
224
  // Track if args are still being streamed (for edit/write spinner)
@@ -281,6 +303,7 @@ export class ToolExecutionComponent extends Container {
281
303
  // signals "nothing meaningful changed" and the renderer can skip.
282
304
  if (args === this.#args) return;
283
305
  this.#args = args;
306
+ this.#displayInputVersion++;
284
307
  this.#updateSpinnerAnimation();
285
308
  this.#editDiffInFlight = this.#runPreviewDiff();
286
309
  this.#updateDisplay();
@@ -365,6 +388,7 @@ export class ToolExecutionComponent extends Container {
365
388
  if (controller.signal.aborted) return;
366
389
  if (previews) {
367
390
  this.#editDiffPreview = isStreaming ? stabilizeStreamingPreviews(previews) : previews;
391
+ this.#displayInputVersion++;
368
392
  this.#updateDisplay();
369
393
  this.#ui.requestRender();
370
394
  }
@@ -393,6 +417,7 @@ export class ToolExecutionComponent extends Container {
393
417
  return;
394
418
  }
395
419
  this.#result = result;
420
+ this.#resultVersion++;
396
421
  this.#isPartial = isPartial;
397
422
  // A `job` poll that found every watched job still running is transient
398
423
  // "still waiting" chrome; keep the block displaceable so the next `job`
@@ -446,6 +471,7 @@ export class ToolExecutionComponent extends Container {
446
471
  .toBase64()
447
472
  .then(data => {
448
473
  this.#convertedImages.set(index, { data, mimeType: "image/png" });
474
+ this.#displayInputVersion++;
449
475
  this.#updateDisplay();
450
476
  this.#ui.requestRender();
451
477
  })
@@ -470,32 +496,18 @@ export class ToolExecutionComponent extends Container {
470
496
  // once the block leaves the live region.
471
497
  const needsSpinner = isStreamingArgs || isPartialTask || this.isDisplaceableBlock();
472
498
  if (needsSpinner && !this.#spinnerInterval) {
473
- const now = performance.now();
474
499
  const frameCount = theme.spinnerFrames.length;
475
- this.#lastSpinnerAdvanceAt = now;
476
- if (frameCount > 0 && this.#spinnerFrame === undefined) {
477
- this.#spinnerFrame = 0;
478
- this.#renderState.spinnerFrame = 0;
479
- }
500
+ const frame = sharedSpinnerFrame(frameCount);
501
+ this.#spinnerFrame = frame;
502
+ this.#renderState.spinnerFrame = frame;
480
503
  this.#spinnerInterval = setInterval(() => {
481
504
  // If a detached task interval from an older render path is still live,
482
505
  // stop it the instant the block leaves the repaintable region.
483
506
  if (this.#maybeFreezeBackgroundTask()) return;
484
507
  const now = performance.now();
485
508
  const frameCount = theme.spinnerFrames.length;
486
- // Redraw at 30fps, but keep the spinner glyph phase-locked to its
487
- // classic ~12.5fps cadence. Advancing the anchor by elapsed frames
488
- // instead of resetting to `now` avoids the 30fps timer quantizing the
489
- // glyph down to one step every three ticks.
490
- if (frameCount > 0) {
491
- const elapsed = now - this.#lastSpinnerAdvanceAt;
492
- if (elapsed >= SPINNER_GLYPH_ADVANCE_MS) {
493
- const steps = Math.floor(elapsed / SPINNER_GLYPH_ADVANCE_MS);
494
- this.#spinnerFrame = ((this.#spinnerFrame ?? 0) + steps) % frameCount;
495
- this.#renderState.spinnerFrame = this.#spinnerFrame;
496
- this.#lastSpinnerAdvanceAt += steps * SPINNER_GLYPH_ADVANCE_MS;
497
- }
498
- }
509
+ this.#spinnerFrame = sharedSpinnerFrame(frameCount, now);
510
+ this.#renderState.spinnerFrame = this.#spinnerFrame;
499
511
  this.#ui.requestRender();
500
512
  }, SPINNER_RENDER_INTERVAL_MS);
501
513
  } else if (!needsSpinner && this.#spinnerInterval) {
@@ -568,6 +580,17 @@ export class ToolExecutionComponent extends Container {
568
580
  }
569
581
  }
570
582
 
583
+ /**
584
+ * Standalone harnesses may mount a tool component directly under `TUI`
585
+ * instead of inside `TranscriptContainer`. In that shape the component must
586
+ * report its own live-region seam for provisional previews, or the core
587
+ * renderer treats it like shell output and commits tail-window edit/eval/bash
588
+ * previews to immutable native scrollback before the result replaces them.
589
+ */
590
+ getNativeScrollbackLiveRegionStart(): number | undefined {
591
+ return !this.isTranscriptBlockFinalized() && !this.isTranscriptBlockCommitStable() ? 0 : undefined;
592
+ }
593
+
571
594
  /**
572
595
  * Whether this block has reached a terminal state for transcript freezing.
573
596
  * Reports `false` while it can still visually change so the
@@ -591,28 +614,20 @@ export class ToolExecutionComponent extends Container {
591
614
 
592
615
  /**
593
616
  * Whether this still-live block's settled rows may enter native scrollback
594
- * (see `FinalizableBlock.isTranscriptBlockCommitStable`). Classification is
595
- * per renderer (`ToolRenderer.provisionalPendingPreview`): tail-window
596
- * streaming views (edit's streamed-diff tail, bash/ssh command caps, eval
597
- * cells) are re-anchored top-first by the result render, so promoting
598
- * their visually static head — e.g. an edit preview idling on its last
599
- * frame while the apply + LSP pass runs — would strand a stale copy of
600
- * the call box above the final block the moment the result lands. Every
601
- * other pending preview streams top-anchored append-shaped rows the
602
- * result render preserves (a task call's context/assignment markdown, a
603
- * write's content), so it stays commit-eligible — a call taller than the
604
- * viewport scrolls into native history mid-stream instead of reading as
605
- * cut off until the result. Expanded blocks always stream top-anchored
606
- * (the over-tall write/eval scrollback contract). Displaceable waiting
607
- * polls are removed wholesale by the next poll and must never commit.
617
+ * (see `FinalizableBlock.isTranscriptBlockCommitStable`). Renderers classify
618
+ * pending views by durability instead of by tool name: a provisional view is
619
+ * allowed to be useful on screen, but finalization may replace or re-anchor
620
+ * it wholesale, so committing any of its rows would strand stale preview
621
+ * bytes in immutable scrollback. Non-provisional views stream rows whose
622
+ * committed prefix survives the remaining transitions.
608
623
  */
609
624
  isTranscriptBlockCommitStable(): boolean {
610
625
  if (this.#displaceable) return false;
611
- if (this.#expanded || this.isTranscriptBlockFinalized()) return true;
612
- if ((this.#tool as { provisionalPendingPreview?: boolean } | undefined)?.provisionalPendingPreview) {
613
- return false;
614
- }
615
- return !toolRenderers[this.#toolName]?.provisionalPendingPreview;
626
+ if (this.isTranscriptBlockFinalized()) return true;
627
+ const tool = this.#tool as { provisionalPendingPreview?: boolean | "collapsed" } | undefined;
628
+ const provisionalPendingPreview =
629
+ tool?.provisionalPendingPreview ?? toolRenderers[this.#toolName]?.provisionalPendingPreview;
630
+ return provisionalPendingPreview !== true && (provisionalPendingPreview !== "collapsed" || this.#expanded);
616
631
  }
617
632
 
618
633
  /**
@@ -674,6 +689,29 @@ export class ToolExecutionComponent extends Container {
674
689
  }
675
690
 
676
691
  #updateDisplay(): void {
692
+ // `TERMINAL.imageProtocol` is resolved by an async capability probe during
693
+ // TUI startup, so a result rendered before it lands must re-shape once it
694
+ // does (it gates Image children vs text fallback in #rebuildDisplay); keyed
695
+ // here for the same reason markdown.ts keys its render cache on it.
696
+ const key = `${this.#resultVersion}|${this.#expanded}|${this.#isPartial}|${this.#spinnerFrame ?? "-"}|${this.#showImages}|${getThemeEpoch()}|${this.#displayInputVersion}|${this.#backgroundTaskFrozen}|${TERMINAL.imageProtocol ?? "-"}|${this.#imageSizeKey()}`;
697
+ if (key === this.#lastDisplayKey && this.#displayBuilt) return;
698
+ this.#lastDisplayKey = key;
699
+
700
+ this.#rebuildDisplay();
701
+ this.#displayBuilt = true;
702
+ }
703
+
704
+ // Viewport-/settings-dependent image sizing folded into the memo key only when
705
+ // the last rebuild actually emitted images, so a terminal resize re-shapes an
706
+ // image-bearing result (to rescale it) without re-shaping every image-free
707
+ // result on each resize tick.
708
+ #imageSizeKey(): string {
709
+ if (this.#renderedImageCount === 0) return "-";
710
+ const o = resolveImageOptions();
711
+ return `${o.maxWidthCells}:${o.maxHeightCells ?? "-"}`;
712
+ }
713
+
714
+ #rebuildDisplay(): void {
677
715
  // Sync shared mutable render state for component closures
678
716
  this.#renderState.expanded = this.#expanded;
679
717
  this.#renderState.isPartial = this.#isPartial;
@@ -917,6 +955,7 @@ export class ToolExecutionComponent extends Container {
917
955
  }
918
956
  }
919
957
  }
958
+ this.#renderedImageCount = this.#imageComponents.length;
920
959
  }
921
960
 
922
961
  #getCallArgsForRender(): any {
@@ -4,6 +4,7 @@ import {
4
4
  type NativeScrollbackCommittedRows,
5
5
  type NativeScrollbackLiveRegion,
6
6
  type RenderStablePrefix,
7
+ type ViewportTailProvider,
7
8
  } from "@oh-my-pi/pi-tui";
8
9
 
9
10
  const kSnapshot = Symbol("transcript.liveDiffSnapshot");
@@ -139,6 +140,8 @@ interface BlockSegment {
139
140
  }
140
141
 
141
142
  const EMPTY_SEGMENTS: BlockSegment[] = [];
143
+ /** Shared empty result for an empty viewport-tail render (no allocation). */
144
+ const EMPTY_TAIL: readonly string[] = [];
142
145
 
143
146
  interface LiveCommitState {
144
147
  appendOnly: boolean;
@@ -415,7 +418,7 @@ function deriveLiveCommitState(
415
418
  */
416
419
  export class TranscriptContainer
417
420
  extends Container
418
- implements NativeScrollbackLiveRegion, NativeScrollbackCommittedRows, RenderStablePrefix
421
+ implements NativeScrollbackLiveRegion, NativeScrollbackCommittedRows, RenderStablePrefix, ViewportTailProvider
419
422
  {
420
423
  // Bumped to retire every block's diff snapshot at once (theme change /
421
424
  // clear); a snapshot is only honored when its stored generation matches.
@@ -432,6 +435,14 @@ export class TranscriptContainer
432
435
  // until it re-earns append-only via VOLATILE_REARM_FRAMES clean frames;
433
436
  // the engine then backfills the stalled gap.
434
437
  #nativeScrollbackCommitSafeEnd: number | undefined;
438
+ // Local line index up to which the leading run of live blocks is DURABLE: a
439
+ // commit-stable block's full body is permanent content even while its interior
440
+ // rows re-lay-out (a streaming markdown table re-aligning columns), so the
441
+ // engine must append their scroll-off snapshot rather than drop it. Reported
442
+ // separately from the byte-stable commit-safe end because these rows may still
443
+ // drift after commit; the engine commits them audit-exempt. Provisional
444
+ // (commit-unstable) blocks never extend it.
445
+ #nativeScrollbackSnapshotSafeEnd: number | undefined;
435
446
  // Persistent assembled transcript rows. Rows before the stable floor are
436
447
  // byte-identical to the previous render; rows at/after it were re-pushed.
437
448
  #lines: string[] = [];
@@ -476,6 +487,10 @@ export class TranscriptContainer
476
487
  return this.#nativeScrollbackCommitSafeEnd;
477
488
  }
478
489
 
490
+ getNativeScrollbackSnapshotSafeEnd(): number | undefined {
491
+ return this.#nativeScrollbackSnapshotSafeEnd;
492
+ }
493
+
479
494
  /**
480
495
  * Whether `component` sits below a still-mutating block — i.e. inside the
481
496
  * live region, where its rows cannot have been committed to native
@@ -520,10 +535,54 @@ export class TranscriptContainer
520
535
  return index === children.length - 1;
521
536
  }
522
537
 
538
+ /**
539
+ * Render only the bottom `maxRows` rows of the transcript at `width`, walking
540
+ * blocks from the last toward the first and stopping the instant enough rows
541
+ * are collected — blocks above the fold are never rendered. The engine's
542
+ * resize viewport fast path uses this so a drag (a SIGWINCH burst, each event
543
+ * a fresh width that misses every per-width cache) re-lays-out only the
544
+ * handful of visible blocks instead of the whole history every event.
545
+ *
546
+ * State-isolated by contract: touches none of the persistent full-compose
547
+ * fields (#lines, #segments, the per-block diff snapshots, the commit/stable
548
+ * bookkeeping), so the authoritative full render on settle reconciles exactly
549
+ * as if this never ran. Calling each block's render() still warms its own
550
+ * per-width cache, which that settle render then reuses for free.
551
+ *
552
+ * Consecutive visible blocks are joined by exactly one blank separator, the
553
+ * same rule render() applies, so the result equals the bottom of a full
554
+ * render except for an at-most-one-row separator on the topmost included
555
+ * block — a transient discrepancy the settle paint overwrites.
556
+ */
557
+ renderViewportTail(width: number, maxRows: number): readonly string[] {
558
+ width = Math.max(1, width);
559
+ if (maxRows <= 0) return EMPTY_TAIL;
560
+ const collected: (readonly string[])[] = [];
561
+ let total = 0;
562
+ for (let i = this.children.length - 1; i >= 0 && total < maxRows; i--) {
563
+ const contribution = stripPlainBlankEdges(this.children[i]!.render(width));
564
+ if (contribution.length === 0) continue;
565
+ // One blank separator sits between this block and the (already
566
+ // collected) visible block below it.
567
+ if (collected.length > 0) total += 1;
568
+ collected.push(contribution);
569
+ total += contribution.length;
570
+ }
571
+ if (collected.length === 0) return EMPTY_TAIL;
572
+ const rows: string[] = [];
573
+ for (let k = collected.length - 1; k >= 0; k--) {
574
+ if (rows.length > 0) rows.push("");
575
+ const body = collected[k]!;
576
+ for (let j = 0; j < body.length; j++) rows.push(body[j]!);
577
+ }
578
+ return rows.length > maxRows ? rows.slice(rows.length - maxRows) : rows;
579
+ }
580
+
523
581
  override render(width: number): readonly string[] {
524
582
  width = Math.max(1, width);
525
583
  this.#nativeScrollbackLiveRegionStart = undefined;
526
584
  this.#nativeScrollbackCommitSafeEnd = undefined;
585
+ this.#nativeScrollbackSnapshotSafeEnd = undefined;
527
586
 
528
587
  const count = this.children.length;
529
588
 
@@ -696,6 +755,16 @@ export class TranscriptContainer
696
755
  if (safeLength > 0) {
697
756
  this.#nativeScrollbackCommitSafeEnd = blockStart + safeLength;
698
757
  }
758
+ // Durable snapshot end: a commit-stable block's whole body is durable
759
+ // content — its scrolled-off rows are permanent even while interior
760
+ // rows re-lay-out (a streaming table re-aligning columns), so the
761
+ // engine must commit their snapshot on scroll-off rather than drop it.
762
+ // Finalized blocks are wholly durable; provisional (commit-unstable)
763
+ // blocks offer nothing beyond their byte-stable safe length.
764
+ const snapshotLength = finalized || isBlockCommitStable(child) ? contribution.length : safeLength;
765
+ if (snapshotLength > 0) {
766
+ this.#nativeScrollbackSnapshotSafeEnd = blockStart + snapshotLength;
767
+ }
699
768
  // A finalized, fully safe block may let the contiguous safe run extend
700
769
  // into blocks rendered below it. A still-live block keeps pushing lower
701
770
  // rows around as it grows, so the run closes there.
@@ -15,7 +15,7 @@ import {
15
15
  import type { TreeFilterMode } from "../../config/settings-schema";
16
16
  import { theme } from "../../modes/theme/theme";
17
17
  import { matchesAppInterrupt, matchesSelectDown, matchesSelectUp } from "../../modes/utils/keybinding-matchers";
18
- import type { SessionTreeNode } from "../../session/session-manager";
18
+ import type { SessionTreeNode } from "../../session/session-entries";
19
19
  import { shortenPath } from "../../tools/render-utils";
20
20
  import { toPathList } from "../../tools/search";
21
21
  import { DynamicBorder } from "./dynamic-border";
@@ -0,0 +1,18 @@
1
+ import type { Usage } from "@oh-my-pi/pi-ai";
2
+ import { Container, Spacer, Text } from "@oh-my-pi/pi-tui";
3
+ import { formatNumber } from "@oh-my-pi/pi-utils";
4
+ import { theme } from "../../modes/theme/theme";
5
+
6
+ export function createUsageRowBlock(usage: Usage): Container {
7
+ const totalInput = usage.input + usage.cacheWrite;
8
+ const parts: string[] = [];
9
+ parts.push(`${theme.icon.input} ${formatNumber(totalInput)}`);
10
+ parts.push(`${theme.icon.output} ${formatNumber(usage.output)}`);
11
+ if (usage.cacheRead > 0) {
12
+ parts.push(`cache: ${formatNumber(usage.cacheRead)}`);
13
+ }
14
+ const block = new Container();
15
+ block.addChild(new Spacer(1));
16
+ block.addChild(new Text(theme.fg("dim", parts.join(" ")), 1, 0));
17
+ return block;
18
+ }
@@ -4,9 +4,11 @@ import { imageReferenceHyperlink, renderPlaceholders } from "../image-references
4
4
  import { highlightMagicKeywords } from "../magic-keywords";
5
5
 
6
6
  // OSC 133 shell integration: marks prompt zones for terminal multiplexers
7
+ // Do not emit OSC 133 C ("command start") here: the transcript has no matching
8
+ // command-finished marker, so terminals can group later assistant/tool output
9
+ // under the first submitted prompt.
7
10
  const OSC133_ZONE_START = "\x1b]133;A\x07";
8
11
  const OSC133_ZONE_END = "\x1b]133;B\x07";
9
- const OSC133_ZONE_FINAL = "\x1b]133;C\x07";
10
12
 
11
13
  /**
12
14
  * Component that renders a user message
@@ -58,7 +60,7 @@ export class UserMessageComponent extends Container {
58
60
  }
59
61
  const wrapped = lines.slice();
60
62
  wrapped[0] = OSC133_ZONE_START + wrapped[0];
61
- wrapped[wrapped.length - 1] = wrapped[wrapped.length - 1] + OSC133_ZONE_END + OSC133_ZONE_FINAL;
63
+ wrapped[wrapped.length - 1] = wrapped[wrapped.length - 1] + OSC133_ZONE_END;
62
64
  this.#zoneSource = lines;
63
65
  this.#zoneLines = wrapped;
64
66
  return wrapped;
@@ -38,7 +38,7 @@ import { buildHotkeysMarkdown } from "../../modes/utils/hotkeys-markdown";
38
38
  import { buildToolsMarkdown } from "../../modes/utils/tools-markdown";
39
39
  import type { AsyncJobSnapshotItem } from "../../session/agent-session";
40
40
  import type { AuthStorage, OAuthAccountIdentity } from "../../session/auth-storage";
41
- import type { NewSessionOptions } from "../../session/session-manager";
41
+ import type { NewSessionOptions } from "../../session/session-entries";
42
42
  import { formatShakeSummary, type ShakeMode, type ShakeResult } from "../../session/shake-types";
43
43
  import { limitMatchesActiveAccount } from "../../slash-commands/helpers/active-oauth-account";
44
44
  import { outputMeta } from "../../tools/output-meta";
@@ -965,7 +965,10 @@ export class CommandController {
965
965
  this.ctx.ui.requestRender();
966
966
  }
967
967
 
968
- async handleCompactCommand(customInstructions?: string): Promise<CompactionOutcome> {
968
+ async handleCompactCommand(
969
+ customInstructions?: string,
970
+ beforeFlush?: (outcome: CompactionOutcome) => void | Promise<void>,
971
+ ): Promise<CompactionOutcome> {
969
972
  const entries = this.ctx.sessionManager.getEntries();
970
973
  const messageCount = entries.filter(e => e.type === "message").length;
971
974
 
@@ -974,7 +977,7 @@ export class CommandController {
974
977
  return "ok";
975
978
  }
976
979
 
977
- return this.executeCompaction(customInstructions, false);
980
+ return this.executeCompaction(customInstructions, false, beforeFlush);
978
981
  }
979
982
 
980
983
  /**
@@ -1019,6 +1022,7 @@ export class CommandController {
1019
1022
  async executeCompaction(
1020
1023
  customInstructionsOrOptions?: string | CompactOptions,
1021
1024
  isAuto = false,
1025
+ beforeFlush?: (outcome: CompactionOutcome) => void | Promise<void>,
1022
1026
  ): Promise<CompactionOutcome> {
1023
1027
  if (this.ctx.loadingAnimation) {
1024
1028
  this.ctx.loadingAnimation.stop();
@@ -1026,7 +1030,6 @@ export class CommandController {
1026
1030
  }
1027
1031
  this.ctx.statusContainer.clear();
1028
1032
 
1029
- this.ctx.chatContainer.addChild(new Spacer(1));
1030
1033
  const label = isAuto ? "Auto-compacting context... (esc to cancel)" : "Compacting context... (esc to cancel)";
1031
1034
  const compactingLoader = new Loader(
1032
1035
  this.ctx.ui,
@@ -1047,6 +1050,8 @@ export class CommandController {
1047
1050
  : undefined;
1048
1051
  await this.ctx.session.compact(instructions, options);
1049
1052
 
1053
+ compactingLoader.stop();
1054
+ this.ctx.statusContainer.clear();
1050
1055
  this.ctx.rebuildChatFromMessages();
1051
1056
 
1052
1057
  this.ctx.statusLine.invalidate();
@@ -1064,6 +1069,11 @@ export class CommandController {
1064
1069
  compactingLoader.stop();
1065
1070
  this.ctx.statusContainer.clear();
1066
1071
  }
1072
+ // Run the caller's pre-flush hook (e.g. the plan-approval model transition)
1073
+ // before queued user input is dispatched, so any turn queued during
1074
+ // compaction executes on the post-compaction model rather than the model
1075
+ // compaction itself ran on.
1076
+ if (beforeFlush) await beforeFlush(outcome);
1067
1077
  await this.ctx.flushCompactionQueue({ willRetry: false });
1068
1078
  return outcome;
1069
1079
  }
@@ -2,6 +2,7 @@ import { INTENT_FIELD } from "@oh-my-pi/pi-agent-core";
2
2
  import { calculatePromptTokens } from "@oh-my-pi/pi-agent-core/compaction/compaction";
3
3
  import type { AssistantMessage, ImageContent } from "@oh-my-pi/pi-ai";
4
4
  import { type Component, Loader, TERMINAL } from "@oh-my-pi/pi-tui";
5
+ import { extractTextContent } from "../../commit/utils";
5
6
  import { settings } from "../../config/settings";
6
7
  import { getFileSnapshotStore } from "../../edit/file-snapshot-store";
7
8
  import { AssistantMessageComponent } from "../../modes/components/assistant-message";
@@ -13,12 +14,14 @@ import {
13
14
  import { TodoReminderComponent } from "../../modes/components/todo-reminder";
14
15
  import { ToolExecutionComponent } from "../../modes/components/tool-execution";
15
16
  import { TtsrNotificationComponent } from "../../modes/components/ttsr-notification";
17
+ import { createUsageRowBlock } from "../../modes/components/usage-row";
16
18
  import { getSymbolTheme, theme } from "../../modes/theme/theme";
17
19
  import type { InteractiveModeContext, TodoPhase } from "../../modes/types";
18
20
  import type { PlanApprovalDetails } from "../../plan-mode/approved-plan";
19
21
  import type { AgentSessionEvent } from "../../session/agent-session";
20
22
  import { isSilentAbort, readQueueChipText, resolveAbortLabel } from "../../session/messages";
21
23
  import type { ResolveToolDetails } from "../../tools/resolve";
24
+ import { vocalizer } from "../../tts/vocalizer";
22
25
  import { hasVisibleThinking } from "../../utils/thinking-display";
23
26
  import { interruptHint } from "../shared";
24
27
  import { StreamingRevealController } from "./streaming-reveal";
@@ -92,8 +95,8 @@ export class EventController {
92
95
  this.#handlers = {
93
96
  agent_start: e => this.#handleAgentStart(e),
94
97
  agent_end: e => this.#handleAgentEnd(e),
95
- turn_start: async () => {},
96
- turn_end: async () => {},
98
+ turn_start: async () => this.#handleTurnStart(),
99
+ turn_end: async e => this.#handleTurnEnd(e),
97
100
  message_start: e => this.#handleMessageStart(e),
98
101
  message_update: e => this.#handleMessageUpdate(e),
99
102
  message_end: e => this.#handleMessageEnd(e),
@@ -430,7 +433,47 @@ export class EventController {
430
433
  }
431
434
  }
432
435
 
436
+ /** A new turn interrupts any speech still queued/playing from the previous one. */
437
+ #handleTurnStart(): void {
438
+ vocalizer.clear();
439
+ }
440
+
441
+ /**
442
+ * Speak streamed assistant output as a side effect of the turn. The mode
443
+ * decides which deltas feed the vocalizer (the vocalizer re-checks enabled):
444
+ * assistant|all speak text; all also speaks thinking; yield speaks nothing
445
+ * live (the final message is spoken at turn end).
446
+ */
447
+ #vocalizeDelta(event: Extract<AgentSessionEvent, { type: "message_update" }>): void {
448
+ if (!settings.get("speech.enabled")) return;
449
+ const mode = settings.get("speech.mode");
450
+ const delta = event.assistantMessageEvent;
451
+ if (delta.type === "text_delta" && (mode === "assistant" || mode === "all")) {
452
+ vocalizer.pushDelta(delta.delta);
453
+ } else if (delta.type === "thinking_delta" && mode === "all") {
454
+ vocalizer.pushDelta(delta.delta);
455
+ }
456
+ }
457
+
458
+ /**
459
+ * End-of-turn vocalization: yield mode speaks the final assistant message in
460
+ * one shot here (the only mode that is post-hoc); every other mode just makes
461
+ * sure the live buffer's trailing partial gets flushed.
462
+ */
463
+ #handleTurnEnd(event: Extract<AgentSessionEvent, { type: "turn_end" }>): void {
464
+ if (!settings.get("speech.enabled")) return;
465
+ if (settings.get("speech.mode") !== "yield") {
466
+ vocalizer.flush();
467
+ return;
468
+ }
469
+ if (event.message.role !== "assistant") return;
470
+ if (event.message.stopReason === "aborted") return; // interrupted: never speak the aborted partial
471
+ const text = extractTextContent(event.message);
472
+ if (text) vocalizer.speak(text);
473
+ }
474
+
433
475
  async #handleMessageUpdate(event: Extract<AgentSessionEvent, { type: "message_update" }>): Promise<void> {
476
+ this.#vocalizeDelta(event);
434
477
  if (this.ctx.streamingComponent && event.message.role === "assistant") {
435
478
  this.ctx.streamingMessage = event.message;
436
479
  this.#streamingReveal.setTarget(this.ctx.streamingMessage);
@@ -454,14 +497,7 @@ export class EventController {
454
497
  // stream (a big write/edit/eval) sits below a still-live block and
455
498
  // can never reach native scrollback: the head of the preview is
456
499
  // neither committed nor on screen and the transcript reads as cut.
457
- // Skipped when the per-turn usage row is enabled: that row is only
458
- // known at message_end and appends to this block, which would shift
459
- // committed tool rows below it every turn (audit recommit →
460
- // duplicated preview copies in scrollback).
461
- if (
462
- this.ctx.streamingMessage.content.some(content => content.type === "toolCall") &&
463
- !settings.get("display.showTokenUsage")
464
- ) {
500
+ if (this.ctx.streamingMessage.content.some(content => content.type === "toolCall")) {
465
501
  this.ctx.streamingComponent.markTranscriptBlockFinalized();
466
502
  }
467
503
  for (const content of this.ctx.streamingMessage.content) {
@@ -566,6 +602,17 @@ export class EventController {
566
602
 
567
603
  async #handleMessageEnd(event: Extract<AgentSessionEvent, { type: "message_end" }>): Promise<void> {
568
604
  if (event.message.role === "user") return;
605
+ if (event.message.role === "assistant" && settings.get("speech.enabled")) {
606
+ if (event.message.stopReason === "aborted") {
607
+ // Esc / Ctrl+C / interrupt: stop speaking now and drop the trailing partial.
608
+ vocalizer.clear();
609
+ } else {
610
+ const mode = settings.get("speech.mode");
611
+ // Speak the last partial sentence of a completed message; yield mode
612
+ // instead speaks the whole final message at turn end.
613
+ if (mode === "assistant" || mode === "all") vocalizer.flush();
614
+ }
615
+ }
569
616
  if (this.ctx.streamingComponent && event.message.role === "assistant") {
570
617
  this.ctx.streamingMessage = event.message;
571
618
  this.#streamingReveal.stop();
@@ -614,8 +661,10 @@ export class EventController {
614
661
  this.#resolveDisplaceablePoll();
615
662
  }
616
663
  this.#lastAssistantComponent = this.ctx.streamingComponent;
617
- this.#lastAssistantComponent.setUsageInfo(event.message.usage);
618
664
  this.#lastAssistantComponent.markTranscriptBlockFinalized();
665
+ if (settings.get("display.showTokenUsage")) {
666
+ this.ctx.chatContainer.addChild(createUsageRowBlock(event.message.usage));
667
+ }
619
668
  this.ctx.streamingComponent = undefined;
620
669
  this.ctx.streamingMessage = undefined;
621
670
  // Pin a turn-ending provider error (e.g. Anthropic content-filter block)
@@ -844,10 +893,27 @@ export class EventController {
844
893
  this.sendCompletionNotification();
845
894
  }
846
895
 
896
+ /**
897
+ * Tear down the live "Working…" loader: stop its animation timer AND clear the
898
+ * reference. A transient overlay (auto-compaction / auto-retry) that only ran
899
+ * `statusContainer.clear()` detached the loader from the container but left
900
+ * `ctx.loadingAnimation` set, so the resumed turn's `agent_start` →
901
+ * `ensureLoadingAnimation()` (guarded by `if (!this.loadingAnimation)`) skipped
902
+ * re-adding it and the spinner vanished while the agent kept streaming. Nulling
903
+ * the reference here lets the next `agent_start` recreate and re-attach it.
904
+ */
905
+ #stopWorkingLoader(): void {
906
+ if (this.ctx.loadingAnimation) {
907
+ this.ctx.loadingAnimation.stop();
908
+ this.ctx.loadingAnimation = undefined;
909
+ }
910
+ }
911
+
847
912
  async #handleAutoCompactionStart(
848
913
  event: Extract<AgentSessionEvent, { type: "auto_compaction_start" }>,
849
914
  ): Promise<void> {
850
915
  this.#cancelIdleCompaction();
916
+ this.#stopWorkingLoader();
851
917
  this.ctx.statusContainer.clear();
852
918
  const reasonText =
853
919
  event.reason === "overflow"
@@ -933,6 +999,7 @@ export class EventController {
933
999
  }
934
1000
 
935
1001
  async #handleAutoRetryStart(event: Extract<AgentSessionEvent, { type: "auto_retry_start" }>): Promise<void> {
1002
+ this.#stopWorkingLoader();
936
1003
  this.ctx.statusContainer.clear();
937
1004
  const delaySeconds = Math.round(event.delayMs / 1000);
938
1005
  this.ctx.retryLoader = new Loader(
@@ -17,6 +17,7 @@ import type {
17
17
  TerminalInputHandler,
18
18
  } from "../../extensibility/extensions";
19
19
  import { getSessionSlashCommands } from "../../extensibility/extensions/get-commands-handler";
20
+ import { createExtensionModelQuery } from "../../extensibility/extensions/model-api";
20
21
  import { HookEditorComponent } from "../../modes/components/hook-editor";
21
22
  import { HookInputComponent } from "../../modes/components/hook-input";
22
23
  import { HookSelectorComponent, type HookSelectorSlider } from "../../modes/components/hook-selector";
@@ -491,6 +492,11 @@ export class ExtensionUiController {
491
492
  sessionManager: this.ctx.session.sessionManager,
492
493
  modelRegistry: this.ctx.session.modelRegistry,
493
494
  model: this.ctx.session.model,
495
+ models: createExtensionModelQuery(
496
+ this.ctx.session.modelRegistry,
497
+ this.ctx.session.settings,
498
+ () => this.ctx.session.model,
499
+ ),
494
500
  isIdle: () => !this.ctx.session.isStreaming,
495
501
  hasPendingMessages: () => this.ctx.session.queuedMessageCount > 0,
496
502
  abort: () => {