@oh-my-pi/pi-coding-agent 16.3.0 → 16.3.3

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 (163) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/dist/cli.js +3855 -3798
  3. package/dist/types/cli/update-cli.d.ts +1 -0
  4. package/dist/types/cli/update-cli.test.d.ts +1 -0
  5. package/dist/types/commands/update.d.ts +5 -0
  6. package/dist/types/config/config-file.d.ts +1 -1
  7. package/dist/types/config/settings-schema.d.ts +20 -0
  8. package/dist/types/dap/client.d.ts +9 -1
  9. package/dist/types/discovery/helpers.d.ts +2 -0
  10. package/dist/types/edit/file-snapshot-store.d.ts +9 -1
  11. package/dist/types/edit/hashline/execute.d.ts +1 -1
  12. package/dist/types/edit/hashline/params.d.ts +3 -6
  13. package/dist/types/edit/renderer.d.ts +1 -0
  14. package/dist/types/extensibility/plugins/parser.d.ts +2 -1
  15. package/dist/types/hindsight/client.d.ts +15 -11
  16. package/dist/types/hindsight/client.test.d.ts +1 -0
  17. package/dist/types/irc/bus.d.ts +5 -4
  18. package/dist/types/mcp/smithery-registry.d.ts +1 -0
  19. package/dist/types/mcp/smithery-registry.test.d.ts +1 -0
  20. package/dist/types/modes/components/advisor-message.d.ts +4 -2
  21. package/dist/types/modes/components/tool-execution.d.ts +9 -6
  22. package/dist/types/modes/components/transcript-container.d.ts +1 -0
  23. package/dist/types/modes/theme/theme.d.ts +1 -1
  24. package/dist/types/session/agent-session.d.ts +13 -13
  25. package/dist/types/session/indexed-session-storage.d.ts +2 -2
  26. package/dist/types/session/session-storage.d.ts +31 -3
  27. package/dist/types/ssh/__tests__/connection-manager-timeout.test.d.ts +1 -0
  28. package/dist/types/ssh/__tests__/sshfs-mount.test.d.ts +1 -0
  29. package/dist/types/ssh/connection-manager.d.ts +19 -0
  30. package/dist/types/ssh/sshfs-mount.d.ts +10 -1
  31. package/dist/types/subprocess/worker-client.d.ts +20 -6
  32. package/dist/types/task/discovery.d.ts +5 -2
  33. package/dist/types/task/renderer.d.ts +1 -0
  34. package/dist/types/tools/ast-grep.d.ts +4 -2
  35. package/dist/types/tools/bash.d.ts +27 -0
  36. package/dist/types/tools/browser/render.d.ts +2 -0
  37. package/dist/types/tools/debug.d.ts +1 -0
  38. package/dist/types/tools/eval-render.d.ts +2 -0
  39. package/dist/types/tools/glob.d.ts +4 -2
  40. package/dist/types/tools/grep.d.ts +4 -3
  41. package/dist/types/tools/path-utils.d.ts +7 -0
  42. package/dist/types/tools/renderers.d.ts +24 -0
  43. package/dist/types/tools/ssh.d.ts +4 -1
  44. package/dist/types/tts/index.d.ts +2 -0
  45. package/dist/types/tts/speakable.d.ts +47 -0
  46. package/dist/types/tts/speech-enhancer.d.ts +46 -0
  47. package/dist/types/tts/streaming-player.d.ts +1 -2
  48. package/dist/types/tts/tts-client.d.ts +11 -10
  49. package/dist/types/tts/tts-protocol.d.ts +7 -0
  50. package/dist/types/tts/vocalizer.d.ts +15 -8
  51. package/dist/types/utils/fetch-timeout.d.ts +4 -0
  52. package/dist/types/utils/git.d.ts +2 -0
  53. package/dist/types/web/search/providers/base.d.ts +1 -0
  54. package/dist/types/web/search/providers/gemini.d.ts +1 -0
  55. package/package.json +12 -12
  56. package/scripts/generate-legacy-pi-bundled-registry.ts +8 -2
  57. package/src/advisor/__tests__/advisor.test.ts +1 -1
  58. package/src/cli/gallery-fixtures/fs.ts +2 -2
  59. package/src/cli/gallery-fixtures/search.ts +2 -2
  60. package/src/cli/models-cli.ts +19 -0
  61. package/src/cli/update-cli.test.ts +28 -0
  62. package/src/cli/update-cli.ts +35 -8
  63. package/src/cli.ts +27 -5
  64. package/src/commands/update.ts +8 -2
  65. package/src/config/config-file.ts +6 -6
  66. package/src/config/model-resolver.ts +31 -10
  67. package/src/config/settings-schema.ts +21 -0
  68. package/src/cursor.ts +1 -1
  69. package/src/dap/client.ts +134 -36
  70. package/src/discovery/helpers.ts +8 -0
  71. package/src/edit/file-snapshot-store.ts +12 -1
  72. package/src/edit/hashline/diff.ts +4 -13
  73. package/src/edit/hashline/execute.ts +1 -1
  74. package/src/edit/hashline/params.ts +5 -12
  75. package/src/edit/renderer.ts +4 -2
  76. package/src/edit/streaming.ts +15 -5
  77. package/src/export/html/tool-views.generated.js +34 -34
  78. package/src/extensibility/custom-tools/loader.ts +3 -3
  79. package/src/extensibility/extensions/loader.ts +10 -3
  80. package/src/extensibility/legacy-pi-coding-agent-shim.ts +2 -2
  81. package/src/extensibility/plugins/installer.ts +12 -3
  82. package/src/extensibility/plugins/legacy-pi-compat.ts +87 -11
  83. package/src/extensibility/plugins/loader.ts +30 -1
  84. package/src/extensibility/plugins/manager.ts +32 -8
  85. package/src/extensibility/plugins/parser.ts +7 -5
  86. package/src/extensibility/tool-event-input.ts +1 -1
  87. package/src/hindsight/client.test.ts +33 -0
  88. package/src/hindsight/client.ts +42 -22
  89. package/src/internal-urls/docs-index.generated.txt +1 -1
  90. package/src/irc/bus.ts +5 -4
  91. package/src/main.ts +7 -1
  92. package/src/mcp/oauth-flow.ts +93 -4
  93. package/src/mcp/smithery-auth.ts +3 -0
  94. package/src/mcp/smithery-connect.ts +9 -0
  95. package/src/mcp/smithery-registry.test.ts +51 -0
  96. package/src/mcp/smithery-registry.ts +27 -4
  97. package/src/modes/components/__tests__/move-overlay.test.ts +72 -1
  98. package/src/modes/components/advisor-message.ts +13 -10
  99. package/src/modes/components/assistant-message.ts +1 -1
  100. package/src/modes/components/move-overlay.ts +35 -23
  101. package/src/modes/components/status-line/component.ts +11 -4
  102. package/src/modes/components/tool-execution.ts +119 -20
  103. package/src/modes/components/transcript-container.ts +26 -0
  104. package/src/modes/components/tree-selector.ts +10 -3
  105. package/src/modes/controllers/event-controller.ts +23 -3
  106. package/src/modes/controllers/tool-args-reveal.ts +1 -1
  107. package/src/modes/interactive-mode.ts +1 -0
  108. package/src/modes/rpc/rpc-client.ts +29 -16
  109. package/src/modes/theme/shimmer.ts +49 -15
  110. package/src/modes/theme/theme.ts +7 -0
  111. package/src/prompts/goals/goal-mode-context.md +4 -0
  112. package/src/prompts/goals/goal-todo-context.md +10 -2
  113. package/src/prompts/system/speech-rewrite.md +15 -0
  114. package/src/prompts/system/tiny-title-system.md +1 -1
  115. package/src/prompts/system/title-system-marker.md +2 -1
  116. package/src/prompts/system/title-system.md +2 -1
  117. package/src/prompts/tools/ast-grep.md +3 -3
  118. package/src/prompts/tools/glob.md +1 -1
  119. package/src/prompts/tools/grep.md +1 -1
  120. package/src/sdk.ts +8 -5
  121. package/src/session/agent-session.ts +329 -79
  122. package/src/session/indexed-session-storage.ts +40 -3
  123. package/src/session/session-history-format.ts +6 -2
  124. package/src/session/session-manager.ts +83 -14
  125. package/src/session/session-storage.ts +112 -26
  126. package/src/slash-commands/helpers/usage-report.ts +6 -1
  127. package/src/ssh/__tests__/connection-manager-timeout.test.ts +61 -0
  128. package/src/ssh/__tests__/sshfs-mount.test.ts +13 -0
  129. package/src/ssh/connection-manager.ts +44 -11
  130. package/src/ssh/sshfs-mount.ts +27 -4
  131. package/src/subprocess/worker-client.ts +161 -10
  132. package/src/task/discovery.ts +25 -2
  133. package/src/task/executor.ts +24 -4
  134. package/src/task/render.ts +26 -12
  135. package/src/task/renderer.ts +1 -0
  136. package/src/task/worktree.ts +144 -16
  137. package/src/tiny/text.ts +109 -17
  138. package/src/tools/ast-grep.ts +20 -17
  139. package/src/tools/bash.ts +46 -9
  140. package/src/tools/browser/render.ts +2 -0
  141. package/src/tools/debug.ts +1 -0
  142. package/src/tools/eval-render.ts +5 -2
  143. package/src/tools/gh-renderer.ts +3 -0
  144. package/src/tools/glob.ts +24 -20
  145. package/src/tools/grep.ts +39 -37
  146. package/src/tools/path-utils.ts +55 -10
  147. package/src/tools/read.ts +23 -6
  148. package/src/tools/renderers.ts +24 -0
  149. package/src/tools/ssh.ts +25 -7
  150. package/src/tts/index.ts +2 -0
  151. package/src/tts/speakable.ts +382 -0
  152. package/src/tts/speech-enhancer.ts +204 -0
  153. package/src/tts/streaming-player.ts +71 -16
  154. package/src/tts/tts-client.ts +11 -10
  155. package/src/tts/tts-protocol.ts +14 -5
  156. package/src/tts/tts-worker.ts +52 -49
  157. package/src/tts/vocalizer.ts +277 -46
  158. package/src/utils/clipboard.ts +49 -12
  159. package/src/utils/fetch-timeout.ts +10 -0
  160. package/src/utils/git.ts +8 -0
  161. package/src/web/search/index.ts +8 -0
  162. package/src/web/search/providers/base.ts +1 -0
  163. package/src/web/search/providers/gemini.ts +23 -6
@@ -4,7 +4,6 @@ import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
4
4
  import type { AssistantMessage, UsageLimit, UsageReport } from "@oh-my-pi/pi-ai";
5
5
  import { type Component, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
6
6
  import { getProjectDir } from "@oh-my-pi/pi-utils";
7
- import { $ } from "bun";
8
7
  import { settings } from "../../../config/settings";
9
8
  import type { AgentSession } from "../../../session/agent-session";
10
9
  import type { OAuthAccountIdentity } from "../../../session/auth-storage";
@@ -707,14 +706,22 @@ export class StatusLineComponent implements Component {
707
706
  }
708
707
  };
709
708
  try {
710
- // Requires `gh repo set-default` to be configured; fails gracefully if not
711
- const result = await $`gh pr view --json number,url`.cwd(lookupCwd).quiet().nothrow();
709
+ // Route through the shared `gh` helper so the child inherits
710
+ // `GH_NON_INTERACTIVE_ENV` (disables terminal/keychain prompts) and
711
+ // hard-terminates on the git command deadline instead of stalling
712
+ // the status-line indefinitely (#4234). Requires `gh repo set-default`;
713
+ // non-zero exit still falls through to the null cache below.
714
+ const result = await git.github.run(
715
+ lookupCwd,
716
+ ["pr", "view", "--json", "number,url"],
717
+ AbortSignal.timeout(git.GIT_COMMAND_TIMEOUT_MS),
718
+ );
712
719
  if (this.#disposed) return;
713
720
  if (result.exitCode !== 0) {
714
721
  setCachedPr(null);
715
722
  return;
716
723
  }
717
- const pr = JSON.parse(result.stdout.toString()) as { number: number; url: string };
724
+ const pr = JSON.parse(result.stdout) as { number: number; url: string };
718
725
  if (typeof pr.number === "number") {
719
726
  setCachedPr({ number: pr.number, url: pr.url });
720
727
  } else {
@@ -182,12 +182,14 @@ export interface ToolExecutionHandle {
182
182
  setExpanded(expanded: boolean): void;
183
183
  }
184
184
 
185
- /** Drive pending-tool redraws at 30fps for live tool headers and displaceable
186
- * poll blocks. The TUI throttles at the same cadence, and static frames diff to
187
- * a no-op redraw at ~zero cost. */
188
- export const SPINNER_RENDER_INTERVAL_MS = 1000 / 30;
189
- /** Advance the spinner glyph at its classic ~12.5fps step, decoupled from the
190
- * render cadence (mirrors `Loader`). */
185
+ /** Redraw live tool blocks at the spinner's glyph-advance rate. Rendering more
186
+ * often produced identical frames the previous 30fps cadence emitted ~2.4
187
+ * paints per glyph step, and although the terminal I/O layer dedupes those, the
188
+ * compose pipeline still ran end-to-end per frame (issue #4353). Matching the
189
+ * render tick to the glyph tick halves the paints during tool execution with no
190
+ * visible change. */
191
+ export const SPINNER_RENDER_INTERVAL_MS = 80;
192
+ /** Advance the spinner glyph at its classic ~12.5fps step (mirrors `Loader`). */
191
193
  export const SPINNER_GLYPH_ADVANCE_MS = 80;
192
194
 
193
195
  /** Phase-locked spinner glyph index shared by every live tool block so parallel
@@ -281,6 +283,14 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
281
283
  // history, so progress renders static gray and further partial snapshots are
282
284
  // dropped (see #maybeFreezeBackgroundTask).
283
285
  #backgroundTaskFrozen = false;
286
+ // Set on each `render()` when the last painted shape carried the streamed
287
+ // SSH-style placeholder / partial-result chrome. Reset gates key off these
288
+ // so a topology-changing update that lands before the shape reaches the
289
+ // terminal never triggers a full-viewport replay (which on direct terminals
290
+ // wipes native scrollback and flashes the user's history — reviewer note on
291
+ // PR #4315).
292
+ #placeholderShapePainted = false;
293
+ #partialResultShapePainted = false;
284
294
  #renderState: {
285
295
  spinnerFrame?: number;
286
296
  expanded: boolean;
@@ -334,6 +344,7 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
334
344
  // so keep their horizontal padding even when the user enables tight layout.
335
345
  this.setIgnoreTight(true);
336
346
 
347
+ this.#updateSpinnerAnimation();
337
348
  this.#updateDisplay();
338
349
  this.#schedulePreviewDiff();
339
350
  }
@@ -484,6 +495,12 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
484
495
  if (isPartial && this.#toolName === "task" && this.#maybeFreezeBackgroundTask()) {
485
496
  return;
486
497
  }
498
+ const hadNoResult = this.#result === undefined;
499
+ const wasPartialResult = this.#result !== undefined && this.#isPartial;
500
+ const placeholderPainted = this.#placeholderShapePainted;
501
+ const partialResultPainted = this.#partialResultShapePainted;
502
+ this.#placeholderShapePainted = false;
503
+ this.#partialResultShapePainted = false;
487
504
  this.#result = result;
488
505
  this.#resultVersion++;
489
506
  this.#isPartial = isPartial;
@@ -495,6 +512,11 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
495
512
  this.#updateSpinnerAnimation();
496
513
  this.#updateTodoStrikeAnimation();
497
514
  this.#updateDisplay();
515
+ this.#resetDisplayForResultTopologyChange(
516
+ hadNoResult && placeholderPainted,
517
+ wasPartialResult && partialResultPainted,
518
+ isPartial,
519
+ );
498
520
  // Convert non-PNG images to PNG for Kitty protocol (async)
499
521
  this.#maybeConvertImagesForKitty();
500
522
  }
@@ -546,21 +568,47 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
546
568
  }
547
569
 
548
570
  /**
549
- * Start or stop spinner animation for result states that visibly tick.
571
+ * Start or stop spinner animation for live states that visibly tick.
550
572
  */
551
573
  #updateSpinnerAnimation(): void {
552
- // Spinner for: task tool with partial result, edit/write while args
553
- // streaming, or a still-running job poll. Todo snapshots stay live for
554
- // displacement but should remain visually static.
574
+ // Live partial tool blocks stay repaintable until a terminal result seals
575
+ // them. Todo snapshots and detached background tool progress are deliberate
576
+ // static exceptions because their rows can be superseded or committed to
577
+ // scrollback while later updates continue elsewhere.
555
578
  const isStreamingArgs = !this.#argsComplete && (isEditLikeToolName(this.#toolName) || this.#toolName === "write");
556
579
  const isBackgroundAsyncRunning =
557
580
  (this.#result?.details as { async?: { state?: string } } | undefined)?.async?.state === "running";
558
- const isBackgroundAsyncTask = this.#toolName === "task" && isBackgroundAsyncRunning;
559
- const isPartialTask = this.#isPartial && this.#toolName === "task" && !isBackgroundAsyncTask;
560
- // Detached async task progress rows are static now; progress snapshots
561
- // still call #maybeFreezeBackgroundTask before applying so rows settle
562
- // once the block leaves the live region.
563
- const needsSpinner = isStreamingArgs || isPartialTask || this.#displaceableByToolName === "job";
581
+ const renderer = toolRenderers[this.#toolName] as
582
+ | {
583
+ animatedPendingPreview?: boolean | ((args: unknown) => boolean);
584
+ animatedPartialResult?: boolean | ((args: unknown) => boolean);
585
+ }
586
+ | undefined;
587
+ const pendingAnimation = renderer?.animatedPendingPreview;
588
+ const partialAnimation = renderer?.animatedPartialResult;
589
+ const pendingCallConsumesSpinner =
590
+ this.#result === undefined &&
591
+ (renderer === undefined
592
+ ? // Only the generic #formatToolExecution fallback consumes the frame;
593
+ // a custom renderCall/renderResult pair routes through the custom
594
+ // branch whose pending label is a static tool-name Text.
595
+ !this.#tool?.renderCall && !this.#tool?.renderResult
596
+ : typeof pendingAnimation === "function"
597
+ ? pendingAnimation(this.#args)
598
+ : pendingAnimation === true);
599
+ const partialResultConsumesSpinner =
600
+ this.#result !== undefined &&
601
+ (renderer === undefined
602
+ ? !this.#tool?.renderCall && !this.#tool?.renderResult
603
+ : typeof partialAnimation === "function"
604
+ ? partialAnimation(this.#args)
605
+ : partialAnimation === true);
606
+ const isLivePartialTool =
607
+ this.#isPartial &&
608
+ this.#toolName !== "todo" &&
609
+ !isBackgroundAsyncRunning &&
610
+ (pendingCallConsumesSpinner || partialResultConsumesSpinner);
611
+ const needsSpinner = isStreamingArgs || isLivePartialTool || this.#displaceableByToolName === "job";
564
612
  if (needsSpinner && !this.#spinnerInterval) {
565
613
  const frameCount = theme.spinnerFrames.length;
566
614
  const frame = sharedSpinnerFrame(frameCount);
@@ -803,6 +851,49 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
803
851
  this.#displayBuilt = true;
804
852
  }
805
853
 
854
+ #rendererFlag(name: "forceFirstResultViewportRepaint" | "forceResultViewportRepaintOnSettle"): boolean {
855
+ const toolValue = (this.#tool as Record<string, unknown> | undefined)?.[name];
856
+ const rendererValue = toolRenderers[this.#toolName]?.[name];
857
+ return toolValue === true || (toolValue === undefined && rendererValue === true);
858
+ }
859
+
860
+ /**
861
+ * True while the last painted shape uses the streamed placeholder path
862
+ * (`⏳ SSH: […]` / `$ …`) — the render call ran with `__partialJson` args
863
+ * and no result. Kept as a per-paint fact so a topology-changing update
864
+ * that lands before the placeholder reaches the terminal skips the reset.
865
+ */
866
+ #isPlaceholderShapeAtRender(): boolean {
867
+ if (this.#result !== undefined) return false;
868
+ if (!this.#rendererFlag("forceFirstResultViewportRepaint")) return false;
869
+ return partialJsonOf(this.#args) !== undefined;
870
+ }
871
+
872
+ #resetDisplayForResultTopologyChange(
873
+ firstResultAfterPlaceholderPaint: boolean,
874
+ partialResultPaintedBeforeSettle: boolean,
875
+ isPartial: boolean,
876
+ ): void {
877
+ const firstResultReplacesStreamedPlaceholder =
878
+ firstResultAfterPlaceholderPaint && this.#rendererFlag("forceFirstResultViewportRepaint");
879
+ const provisionalResultSettled =
880
+ partialResultPaintedBeforeSettle && !isPartial && this.#rendererFlag("forceResultViewportRepaintOnSettle");
881
+ if (firstResultReplacesStreamedPlaceholder || provisionalResultSettled) {
882
+ this.#ui.resetDisplay();
883
+ }
884
+ }
885
+
886
+ override render(width: number): readonly string[] {
887
+ const lines = super.render(width);
888
+ // Update the paint-tracking flags after `super.render(width)` — the
889
+ // override runs on every compose the parent Container performs, so a
890
+ // frame that never gets composed leaves the flags false and prevents a
891
+ // spurious `resetDisplay()`.
892
+ this.#placeholderShapePainted = this.#isPlaceholderShapeAtRender();
893
+ this.#partialResultShapePainted = this.#result !== undefined && this.#isPartial;
894
+ return lines;
895
+ }
896
+
806
897
  // Viewport-/settings-dependent image sizing folded into the memo key only when
807
898
  // the last rebuild actually emitted images, so a terminal resize re-shapes an
808
899
  // image-bearing result (to rescale it) without re-shaping every image-free
@@ -846,7 +937,8 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
846
937
  if (shouldRenderCall) {
847
938
  if (tool.renderCall) {
848
939
  try {
849
- const callComponent = tool.renderCall(this.#getCallArgsForRender(), this.#renderState, theme);
940
+ const callArgs = this.#getCallArgsForRender();
941
+ const callComponent = tool.renderCall(callArgs, this.#renderState, theme);
850
942
  if (callComponent) this.#contentBox.addChild(callComponent as Component);
851
943
  } catch (err) {
852
944
  logger.warn("Tool renderer failed", { tool: this.#toolName, error: String(err) });
@@ -980,7 +1072,8 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
980
1072
  if (shouldRenderCall) {
981
1073
  // Render call component
982
1074
  try {
983
- const callComponent = renderer.renderCall(this.#getCallArgsForRender(), this.#renderState, theme);
1075
+ const callArgs = this.#getCallArgsForRender();
1076
+ const callComponent = renderer.renderCall(callArgs, this.#renderState, theme);
984
1077
  if (callComponent) this.#contentBox.addChild(callComponent);
985
1078
  } catch (err) {
986
1079
  logger.warn("Tool renderer failed", { tool: this.#toolName, error: String(err) });
@@ -1172,8 +1265,14 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
1172
1265
  */
1173
1266
  #formatToolExecution(contentWidth: number): string {
1174
1267
  const lines: string[] = [];
1175
- const icon = this.#isPartial ? "pending" : this.#result?.isError ? "error" : "done";
1176
- lines.push(renderStatusLine({ icon, title: this.#toolLabel }, theme));
1268
+ const icon = this.#isPartial
1269
+ ? this.#spinnerFrame !== undefined
1270
+ ? "running"
1271
+ : "pending"
1272
+ : this.#result?.isError
1273
+ ? "error"
1274
+ : "done";
1275
+ lines.push(renderStatusLine({ icon, spinnerFrame: this.#spinnerFrame, title: this.#toolLabel }, theme));
1177
1276
 
1178
1277
  const argsObject = this.#args && typeof this.#args === "object" ? (this.#args as Record<string, unknown>) : null;
1179
1278
  if (!this.#expanded && argsObject && Object.keys(argsObject).length > 0) {
@@ -443,6 +443,10 @@ export class TranscriptContainer
443
443
  // drift after commit; the engine commits them audit-exempt. Provisional
444
444
  // (commit-unstable) blocks never extend it.
445
445
  #nativeScrollbackSnapshotSafeEnd: number | undefined;
446
+ // Local line index through which lower finalized siblings are safe to OFFER to
447
+ // native scrollback while still audited. Unlike snapshotSafeEnd, rows below a
448
+ // live block are not durable: growth above them must repair stale history.
449
+ #nativeScrollbackOfferSafeEnd: number | undefined;
446
450
  // Persistent assembled transcript rows. Rows before the stable floor are
447
451
  // byte-identical to the previous render; rows at/after it were re-pushed.
448
452
  #lines: string[] = [];
@@ -491,6 +495,10 @@ export class TranscriptContainer
491
495
  return this.#nativeScrollbackSnapshotSafeEnd;
492
496
  }
493
497
 
498
+ getNativeScrollbackOfferSafeEnd(): number | undefined {
499
+ return this.#nativeScrollbackOfferSafeEnd;
500
+ }
501
+
494
502
  /**
495
503
  * Whether `component` sits below a still-mutating block — i.e. inside the
496
504
  * live region, where its rows cannot have been committed to native
@@ -583,6 +591,7 @@ export class TranscriptContainer
583
591
  this.#nativeScrollbackLiveRegionStart = undefined;
584
592
  this.#nativeScrollbackCommitSafeEnd = undefined;
585
593
  this.#nativeScrollbackSnapshotSafeEnd = undefined;
594
+ this.#nativeScrollbackOfferSafeEnd = undefined;
586
595
 
587
596
  const count = this.children.length;
588
597
 
@@ -629,6 +638,14 @@ export class TranscriptContainer
629
638
  // liveStartIndex; empty leading blocks (or a separator) must not claim it
630
639
  // early.
631
640
  let liveRecorded = false;
641
+ // Prefix boundary for finalized siblings rendered below the first live
642
+ // block. These rows may be offered to native scrollback, but they cannot
643
+ // extend snapshotSafeEnd because a live block above can still move them.
644
+ let offerSafeEnd: number | undefined;
645
+ // Offer rows must be a contiguous finalized run below the first live
646
+ // block. A later live/provisional block can still push rows below it, so
647
+ // finalized siblings after that barrier must stay forced-overflow.
648
+ let offerSafeOpen = true;
632
649
  // Frame row cursor: rows emitted (reused or pushed) so far.
633
650
  let row = 0;
634
651
  let stableRows = 0;
@@ -701,6 +718,7 @@ export class TranscriptContainer
701
718
  // everything below it.
702
719
  if (contribution.length === 0) {
703
720
  if (i >= liveStartIndex && commitSafeOpen && !finalized) commitSafeOpen = false;
721
+ if (i > liveStartIndex && !finalized) offerSafeOpen = false;
704
722
  if (chainStable && !(reusable && previous.rowCount === 0 && previous.startRow === row)) {
705
723
  chainStable = false;
706
724
  lines.length = row;
@@ -770,6 +788,13 @@ export class TranscriptContainer
770
788
  // rows around as it grows, so the run closes there.
771
789
  if (!(finalized && safeLength >= contribution.length)) commitSafeOpen = false;
772
790
  }
791
+ if (i > liveStartIndex) {
792
+ if (offerSafeOpen && finalized) {
793
+ offerSafeEnd = blockStart + contribution.length;
794
+ } else if (!finalized) {
795
+ offerSafeOpen = false;
796
+ }
797
+ }
773
798
 
774
799
  segments[i] = {
775
800
  component: child,
@@ -788,6 +813,7 @@ export class TranscriptContainer
788
813
  // Trailing shrink: blocks removed from the tail leave stale rows behind
789
814
  // when every surviving segment was reused.
790
815
  if (lines.length !== row) lines.length = row;
816
+ this.#nativeScrollbackOfferSafeEnd = offerSafeEnd;
791
817
  this.#segments = segments;
792
818
  this.#stableRowsFloor = Math.min(stableFloorBefore, stableRows, row);
793
819
  return lines;
@@ -15,7 +15,7 @@ import type { TreeFilterMode } from "../../config/settings-schema";
15
15
  import { theme } from "../../modes/theme/theme";
16
16
  import { matchesAppInterrupt, matchesSelectDown, matchesSelectUp } from "../../modes/utils/keybinding-matchers";
17
17
  import type { SessionTreeNode } from "../../session/session-entries";
18
- import { toPathList } from "../../tools/grep";
18
+ import { toPathList } from "../../tools/path-utils";
19
19
  import { shortenPath } from "../../tools/render-utils";
20
20
  import { canonicalizeMessage } from "../../utils/thinking-display";
21
21
  import { DynamicBorder } from "./dynamic-border";
@@ -753,8 +753,15 @@ class TreeList implements Component {
753
753
  return `[grep: /${pattern}/ in ${shortenPath(scope)}]`;
754
754
  }
755
755
  case "glob": {
756
- const paths = Array.isArray(args.paths) ? args.paths.join(", ") : String(args.pattern || ".");
757
- return `[glob: ${shortenPath(paths)}]`;
756
+ const globInput =
757
+ typeof args.path === "string"
758
+ ? args.path
759
+ : typeof args.paths === "string" || Array.isArray(args.paths)
760
+ ? args.paths
761
+ : undefined;
762
+ const paths = toPathList(globInput);
763
+ const scope = paths.length > 0 ? paths.join(", ") : ".";
764
+ return `[glob: ${shortenPath(scope)}]`;
758
765
  }
759
766
  case "ls": {
760
767
  const path = shortenPath(String(args.path || "."));
@@ -27,6 +27,7 @@ import { isSilentAbort, readQueueChipText, resolveAbortLabel } from "../../sessi
27
27
  import { previewLine, TRUNCATE_LENGTHS } from "../../tools/render-utils";
28
28
  import type { ResolveToolDetails } from "../../tools/resolve";
29
29
  import { nextActionableTask } from "../../tools/todo";
30
+ import { SpeechEnhancer } from "../../tts/speech-enhancer";
30
31
  import { vocalizer } from "../../tts/vocalizer";
31
32
  import { canonicalizeMessage } from "../../utils/thinking-display";
32
33
  import { interruptHint } from "../shared";
@@ -111,6 +112,21 @@ export class EventController {
111
112
  #terminalProgressActive = false;
112
113
 
113
114
  constructor(private ctx: InteractiveModeContext) {
115
+ // Enhanced speech (`speech.enhanced`) rewrites blocks through the
116
+ // tiny/smol role with this session's registry and credentials; the
117
+ // vocalizer falls back to mechanical cleanup when unset. Tolerates
118
+ // partial contexts (tests, minimal embeddings) by wiring null.
119
+ const session = ctx.session;
120
+ vocalizer.setEnhancer(
121
+ session?.modelRegistry && session.agent && session.settings
122
+ ? new SpeechEnhancer({
123
+ settings: session.settings,
124
+ registry: session.modelRegistry,
125
+ sessionId: session.sessionId,
126
+ metadataResolver: provider => session.agent.metadataForProvider(provider),
127
+ })
128
+ : null,
129
+ );
114
130
  this.#streamingReveal = new StreamingRevealController({
115
131
  getSmoothStreaming: () => this.ctx.settings.get("display.smoothStreaming"),
116
132
  getHideThinkingBlock: () => this.ctx.effectiveHideThinkingBlock,
@@ -286,9 +302,13 @@ export class EventController {
286
302
  await this.ctx.init();
287
303
  }
288
304
 
289
- this.ctx.statusLine.invalidate();
290
- this.ctx.ui.requestRender();
291
-
305
+ // Each handler explicitly requests a render (or leaves it out, when it
306
+ // changed nothing visible). A blanket pre-render fired on every event —
307
+ // including the ~hundreds of `message_update` deltas per streaming turn —
308
+ // doubled the paint rate: the pre-render's frame fires while the handler
309
+ // is awaiting, then the handler's own final requestRender schedules a
310
+ // second identical frame. Removing it lets the render cadence follow real
311
+ // state changes rather than event volume (issue #4353).
292
312
  const run = this.#handlers[event.type] as (e: AgentSessionEvent) => Promise<void>;
293
313
  await run(event);
294
314
  }
@@ -13,7 +13,7 @@ type ToolArgsRevealComponent = {
13
13
  // patch/replace `edits[].diff`) still fall through to the throttled parse.
14
14
  const STREAMING_STRING_KEYS_BY_TOOL: Record<string, readonly string[]> = {
15
15
  write: ["content"],
16
- edit: ["input"],
16
+ edit: ["input", "_input"],
17
17
  eval: ["code"],
18
18
  };
19
19
 
@@ -3584,6 +3584,7 @@ export class InteractiveMode implements InteractiveModeContext {
3584
3584
  } else if (!this.statusContainer.children.includes(this.loadingAnimation)) {
3585
3585
  this.statusContainer.clear();
3586
3586
  this.statusContainer.addChild(this.loadingAnimation);
3587
+ this.ui.requestRender();
3587
3588
  }
3588
3589
  this.applyPendingWorkingMessage();
3589
3590
  }
@@ -256,18 +256,19 @@ export class RpcClient {
256
256
  args.push(...this.options.args);
257
257
  }
258
258
 
259
- this.#process = ptree.spawn(["bun", cliPath, ...args], {
259
+ const child = ptree.spawn(["bun", cliPath, ...args], {
260
260
  cwd: this.options.cwd,
261
261
  env: { ...Bun.env, ...this.options.env },
262
262
  stdin: "pipe",
263
263
  });
264
+ this.#process = child;
264
265
 
265
266
  // Wait for the "ready" signal or process exit
266
267
  const { promise: readyPromise, resolve: readyResolve, reject: readyReject } = Promise.withResolvers<void>();
267
268
  let readySettled = false;
268
269
 
269
270
  // Process lines in background, intercepting the ready signal
270
- const lines = readJsonl(this.#process.stdout, this.#abortController.signal);
271
+ const lines = readJsonl(child.stdout, this.#abortController.signal);
271
272
  void (async () => {
272
273
  for await (const line of lines) {
273
274
  if (!readySettled && isRecord(line) && line.type === "ready") {
@@ -277,10 +278,16 @@ export class RpcClient {
277
278
  }
278
279
  this.#handleLine(line);
279
280
  }
280
- // Stream ended without ready signal — process exited
281
+ // Stream ended without the ready signal — the child exited or is
282
+ // exiting. Defer to the exit handler below: ptree resolves
283
+ // `exited` only after stderr is fully drained (nonzero exits), so
284
+ // rejecting here would snapshot a partial stderr tail and lose
285
+ // the actual startup error.
286
+ if (readySettled) return;
287
+ await child.exited.catch(() => {});
281
288
  if (!readySettled) {
282
289
  readySettled = true;
283
- readyReject(new Error(`Agent process exited before ready. Stderr: ${this.#process?.peekStderr() ?? ""}`));
290
+ readyReject(new Error(`Agent process exited before ready. Stderr: ${child.peekStderr()}`));
284
291
  }
285
292
  })().catch((err: Error) => {
286
293
  if (!readySettled) {
@@ -290,22 +297,26 @@ export class RpcClient {
290
297
  });
291
298
 
292
299
  // Also race against process exit (in case stdout closes before we read it)
293
- void this.#process.exited.then((exitCode: number) => {
294
- if (!readySettled) {
300
+ void child.exited.then(
301
+ (exitCode: number) => {
302
+ if (readySettled) return;
295
303
  readySettled = true;
296
- readyReject(
297
- new Error(`Agent process exited with code ${exitCode}. Stderr: ${this.#process?.peekStderr() ?? ""}`),
298
- );
299
- }
300
- });
304
+ readyReject(new Error(`Agent process exited with code ${exitCode}. Stderr: ${child.peekStderr()}`));
305
+ },
306
+ (err: Error) => {
307
+ // Killed or reaped without an exit code (e.g. stop() during
308
+ // startup); surface it instead of leaking an unhandled rejection.
309
+ if (readySettled) return;
310
+ readySettled = true;
311
+ readyReject(new Error(`Agent process exited before ready. Stderr: ${child.peekStderr()}`, { cause: err }));
312
+ },
313
+ );
301
314
 
302
315
  // Timeout to prevent hanging forever
303
316
  const readyTimeout = this.#startTimeout(30000, () => {
304
317
  if (readySettled) return;
305
318
  readySettled = true;
306
- readyReject(
307
- new Error(`Timeout waiting for agent to become ready. Stderr: ${this.#process?.peekStderr() ?? ""}`),
308
- );
319
+ readyReject(new Error(`Timeout waiting for agent to become ready. Stderr: ${child.peekStderr()}`));
309
320
  });
310
321
 
311
322
  try {
@@ -318,12 +329,14 @@ export class RpcClient {
318
329
  // state so the caller (or a retry via start() again) does not
319
330
  // leak the abandoned process (issue #4079).
320
331
  try {
321
- this.#process?.kill();
332
+ child.kill();
322
333
  } catch {
323
334
  // best-effort cleanup
324
335
  }
325
336
  this.#abortController.abort();
326
- this.#process = null;
337
+ if (this.#process === child) {
338
+ this.#process = null;
339
+ }
327
340
  throw err;
328
341
  } finally {
329
342
  clearTimeout(readyTimeout);
@@ -180,12 +180,16 @@ export function shimmerSegments(segments: readonly ShimmerSegment[], theme: Shim
180
180
  const mode = resolveMode();
181
181
 
182
182
  // Pre-scan: total code-point count (positions the band) and resolved palette.
183
+ // The per-segment string is kept verbatim — iterating UTF-16 units with a
184
+ // surrogate-pair guard produces the same code points as `Array.from(text)`
185
+ // at zero per-frame allocation (previously the #1 hotspot at ~10% of profiled
186
+ // CPU during streaming — the working message is shimmered every animation
187
+ // frame at 30fps and `Array.from` reallocated the code-point array each tick).
183
188
  let total = 0;
184
- const perSeg: { chars: string[]; palette: ShimmerPalette }[] = [];
189
+ const perSeg: { text: string; palette: ShimmerPalette }[] = [];
185
190
  for (const seg of segments) {
186
- const chars = Array.from(seg.text);
187
- total += chars.length;
188
- perSeg.push({ chars, palette: seg.palette ?? DEFAULT_SHIMMER_PALETTE });
191
+ total += countCodePoints(seg.text);
192
+ perSeg.push({ text: seg.text, palette: seg.palette ?? DEFAULT_SHIMMER_PALETTE });
189
193
  }
190
194
  if (total === 0) return "";
191
195
 
@@ -193,9 +197,9 @@ export function shimmerSegments(segments: readonly ShimmerSegment[], theme: Shim
193
197
  // tier so the working line stays legible without movement.
194
198
  if (mode === "disabled") {
195
199
  let out = "";
196
- for (const { chars, palette } of perSeg) {
200
+ for (const { text, palette } of perSeg) {
197
201
  const seq = compile(theme, palette).mid;
198
- out += `${seq.open}${chars.join("")}${seq.close}`;
202
+ out += `${seq.open}${text}${seq.close}`;
199
203
  }
200
204
  return out;
201
205
  }
@@ -205,31 +209,61 @@ export function shimmerSegments(segments: readonly ShimmerSegment[], theme: Shim
205
209
 
206
210
  let out = "";
207
211
  let index = 0;
208
- for (const { chars, palette } of perSeg) {
212
+ for (const { text, palette } of perSeg) {
209
213
  const compiled = compile(theme, palette);
210
214
  let runTier: Tier | null = null;
211
- let runBuf = "";
212
- for (let i = 0; i < chars.length; i++) {
215
+ let runStart = 0;
216
+ let runEnd = 0;
217
+ let i = 0;
218
+ while (i < text.length) {
219
+ // Detect a surrogate pair so a single code point (e.g. an emoji) stays
220
+ // atomic; the band position is measured in code points, not UTF-16 units.
221
+ const c = text.charCodeAt(i);
222
+ let step = 1;
223
+ if (c >= 0xd800 && c <= 0xdbff && i + 1 < text.length) {
224
+ const c2 = text.charCodeAt(i + 1);
225
+ if (c2 >= 0xdc00 && c2 <= 0xdfff) step = 2;
226
+ }
213
227
  const tier = tierFor(intensityFn(time, index, total));
214
228
  if (tier !== runTier) {
215
- if (runTier !== null) {
229
+ if (runTier !== null && runEnd > runStart) {
216
230
  const seq = compiled[runTier];
217
- out += `${seq.open}${runBuf}${seq.close}`;
218
- runBuf = "";
231
+ out += `${seq.open}${text.slice(runStart, runEnd)}${seq.close}`;
219
232
  }
220
233
  runTier = tier;
234
+ runStart = i;
221
235
  }
222
- runBuf += chars[i];
236
+ runEnd = i + step;
223
237
  index++;
238
+ i += step;
224
239
  }
225
- if (runTier !== null && runBuf.length > 0) {
240
+ if (runTier !== null && runEnd > runStart) {
226
241
  const seq = compiled[runTier];
227
- out += `${seq.open}${runBuf}${seq.close}`;
242
+ out += `${seq.open}${text.slice(runStart, runEnd)}${seq.close}`;
228
243
  }
229
244
  }
230
245
  return out;
231
246
  }
232
247
 
248
+ function countCodePoints(text: string): number {
249
+ let n = 0;
250
+ let i = 0;
251
+ while (i < text.length) {
252
+ const c = text.charCodeAt(i);
253
+ if (c >= 0xd800 && c <= 0xdbff && i + 1 < text.length) {
254
+ const c2 = text.charCodeAt(i + 1);
255
+ if (c2 >= 0xdc00 && c2 <= 0xdfff) {
256
+ i += 2;
257
+ n++;
258
+ continue;
259
+ }
260
+ }
261
+ i++;
262
+ n++;
263
+ }
264
+ return n;
265
+ }
266
+
233
267
  export function shimmerText(text: string, theme: ShimmerTheme, palette?: ShimmerPalette): string {
234
268
  return shimmerSegments([{ text, palette }], theme);
235
269
  }
@@ -159,6 +159,8 @@ export type SymbolKey =
159
159
  | "md.hrChar"
160
160
  | "md.bullet"
161
161
  | "md.colorSwatch"
162
+ // Advisor note rail
163
+ | "advisor.rail"
162
164
  // Language/file type icons
163
165
  | "lang.default"
164
166
  | "lang.typescript"
@@ -364,6 +366,8 @@ const UNICODE_SYMBOLS: SymbolMap = {
364
366
  "md.hrChar": "─",
365
367
  "md.bullet": "•",
366
368
  "md.colorSwatch": "■",
369
+ // Advisor note rail (heavier than md.quoteBorder so notes read as a distinct voice)
370
+ "advisor.rail": "▎",
367
371
  // Language/file icons (emoji-centric, no Nerd Font required)
368
372
  "lang.default": "⌘",
369
373
  "lang.typescript": "🟦",
@@ -675,6 +679,8 @@ const NERD_SYMBOLS: SymbolMap = {
675
679
  "md.bullet": "\uf111",
676
680
  // pick: ■ | alt: (U+F096)
677
681
  "md.colorSwatch": "■",
682
+ // pick: ▎ | alt: ┃ │
683
+ "advisor.rail": "▎",
678
684
  // Language icons (nerd font devicons)
679
685
  "lang.default": "",
680
686
  "lang.typescript": "\u{E628}",
@@ -877,6 +883,7 @@ const ASCII_SYMBOLS: SymbolMap = {
877
883
  "md.hrChar": "-",
878
884
  "md.bullet": "*",
879
885
  "md.colorSwatch": "[]",
886
+ "advisor.rail": "|",
880
887
  // Language icons (ASCII uses abbreviations)
881
888
  "lang.default": "code",
882
889
  "lang.typescript": "ts",
@@ -0,0 +1,4 @@
1
+ {{goalContext}}
2
+ {{#if todoContext}}
3
+ {{todoContext}}
4
+ {{/if}}