@oh-my-pi/pi-coding-agent 15.9.5 → 15.10.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 (192) hide show
  1. package/CHANGELOG.md +98 -1
  2. package/dist/types/cli/args.d.ts +1 -1
  3. package/dist/types/cli/gallery-cli.d.ts +43 -0
  4. package/dist/types/cli/gallery-fixtures/agentic.d.ts +2 -0
  5. package/dist/types/cli/gallery-fixtures/codeintel.d.ts +3 -0
  6. package/dist/types/cli/gallery-fixtures/edit.d.ts +3 -0
  7. package/dist/types/cli/gallery-fixtures/fs.d.ts +2 -0
  8. package/dist/types/cli/gallery-fixtures/index.d.ts +4 -0
  9. package/dist/types/cli/gallery-fixtures/interaction.d.ts +3 -0
  10. package/dist/types/cli/gallery-fixtures/memory.d.ts +2 -0
  11. package/dist/types/cli/gallery-fixtures/misc.d.ts +3 -0
  12. package/dist/types/cli/gallery-fixtures/search.d.ts +3 -0
  13. package/dist/types/cli/gallery-fixtures/shell.d.ts +3 -0
  14. package/dist/types/cli/gallery-fixtures/types.d.ts +44 -0
  15. package/dist/types/cli/gallery-fixtures/web.d.ts +2 -0
  16. package/dist/types/cli/gallery-screenshot.d.ts +35 -0
  17. package/dist/types/commands/gallery.d.ts +47 -0
  18. package/dist/types/config/keybindings.d.ts +10 -2
  19. package/dist/types/config/model-id-affixes.d.ts +2 -0
  20. package/dist/types/config/model-registry.d.ts +8 -1
  21. package/dist/types/config/settings-schema.d.ts +43 -7
  22. package/dist/types/edit/file-snapshot-store.d.ts +1 -1
  23. package/dist/types/eval/backend.d.ts +6 -6
  24. package/dist/types/eval/bridge-timeout.d.ts +27 -0
  25. package/dist/types/eval/idle-timeout.d.ts +16 -14
  26. package/dist/types/eval/js/executor.d.ts +3 -3
  27. package/dist/types/eval/py/executor.d.ts +2 -2
  28. package/dist/types/eval/py/spawn-options.d.ts +58 -0
  29. package/dist/types/extensibility/plugins/marketplace-auto-update.d.ts +8 -0
  30. package/dist/types/lsp/types.d.ts +10 -0
  31. package/dist/types/main.d.ts +3 -2
  32. package/dist/types/memory-backend/index.d.ts +2 -1
  33. package/dist/types/memory-backend/resolve.d.ts +1 -1
  34. package/dist/types/memory-backend/types.d.ts +1 -1
  35. package/dist/types/modes/components/assistant-message.d.ts +5 -0
  36. package/dist/types/modes/components/copy-selector.d.ts +22 -0
  37. package/dist/types/modes/components/custom-editor.d.ts +2 -1
  38. package/dist/types/modes/components/model-selector.d.ts +1 -0
  39. package/dist/types/modes/components/tool-execution.d.ts +18 -0
  40. package/dist/types/modes/controllers/command-controller.d.ts +0 -1
  41. package/dist/types/modes/controllers/selector-controller.d.ts +2 -1
  42. package/dist/types/modes/index.d.ts +5 -4
  43. package/dist/types/modes/interactive-mode.d.ts +2 -2
  44. package/dist/types/modes/setup-version.d.ts +11 -0
  45. package/dist/types/modes/setup-wizard/index.d.ts +2 -1
  46. package/dist/types/modes/setup-wizard/scenes/web-search.d.ts +2 -1
  47. package/dist/types/modes/types.d.ts +2 -2
  48. package/dist/types/modes/utils/copy-targets.d.ts +53 -0
  49. package/dist/types/sdk.d.ts +1 -1
  50. package/dist/types/task/executor.d.ts +7 -0
  51. package/dist/types/telemetry-export.d.ts +1 -1
  52. package/dist/types/tools/eval-render.d.ts +1 -0
  53. package/dist/types/tools/fetch.d.ts +15 -7
  54. package/dist/types/tools/render-utils.d.ts +33 -0
  55. package/dist/types/tools/renderers.d.ts +16 -2
  56. package/dist/types/tools/search.d.ts +1 -1
  57. package/dist/types/tools/write.d.ts +2 -0
  58. package/dist/types/tui/code-cell.d.ts +6 -0
  59. package/dist/types/tui/output-block.d.ts +11 -0
  60. package/dist/types/web/scrapers/github.d.ts +22 -0
  61. package/dist/types/web/search/providers/perplexity.d.ts +8 -1
  62. package/dist/types/web/search/types.d.ts +1 -1
  63. package/package.json +9 -9
  64. package/scripts/dev-launch +42 -0
  65. package/scripts/dev-launch-preload.ts +19 -0
  66. package/src/autoresearch/dashboard.ts +11 -21
  67. package/src/cli/args.ts +2 -2
  68. package/src/cli/claude-trace-cli.ts +13 -1
  69. package/src/cli/gallery-cli.ts +223 -0
  70. package/src/cli/gallery-fixtures/agentic.ts +292 -0
  71. package/src/cli/gallery-fixtures/codeintel.ts +188 -0
  72. package/src/cli/gallery-fixtures/edit.ts +194 -0
  73. package/src/cli/gallery-fixtures/fs.ts +153 -0
  74. package/src/cli/gallery-fixtures/index.ts +40 -0
  75. package/src/cli/gallery-fixtures/interaction.ts +49 -0
  76. package/src/cli/gallery-fixtures/memory.ts +81 -0
  77. package/src/cli/gallery-fixtures/misc.ts +221 -0
  78. package/src/cli/gallery-fixtures/search.ts +213 -0
  79. package/src/cli/gallery-fixtures/shell.ts +167 -0
  80. package/src/cli/gallery-fixtures/types.ts +41 -0
  81. package/src/cli/gallery-fixtures/web.ts +158 -0
  82. package/src/cli/gallery-screenshot.ts +279 -0
  83. package/src/cli-commands.ts +1 -0
  84. package/src/commands/gallery.ts +52 -0
  85. package/src/commands/launch.ts +1 -1
  86. package/src/config/keybindings.ts +68 -2
  87. package/src/config/model-equivalence.ts +35 -12
  88. package/src/config/model-id-affixes.ts +39 -22
  89. package/src/config/model-registry.ts +16 -16
  90. package/src/config/settings-schema.ts +29 -6
  91. package/src/config/settings.ts +11 -0
  92. package/src/dap/client.ts +14 -16
  93. package/src/debug/raw-sse.ts +18 -4
  94. package/src/edit/file-snapshot-store.ts +1 -1
  95. package/src/edit/index.ts +1 -1
  96. package/src/edit/renderer.ts +43 -55
  97. package/src/edit/streaming.ts +1 -1
  98. package/src/eval/__tests__/agent-bridge.test.ts +102 -58
  99. package/src/eval/__tests__/bridge-timeout.test.ts +64 -0
  100. package/src/eval/__tests__/idle-timeout.test.ts +26 -12
  101. package/src/eval/__tests__/kernel-spawn.test.ts +103 -0
  102. package/src/eval/__tests__/llm-bridge.test.ts +10 -10
  103. package/src/eval/agent-bridge.ts +38 -12
  104. package/src/eval/backend.ts +6 -6
  105. package/src/eval/bridge-timeout.ts +44 -0
  106. package/src/eval/idle-timeout.ts +33 -15
  107. package/src/eval/js/executor.ts +10 -10
  108. package/src/eval/llm-bridge.ts +4 -5
  109. package/src/eval/py/executor.ts +6 -6
  110. package/src/eval/py/kernel.ts +11 -1
  111. package/src/eval/py/spawn-options.ts +126 -0
  112. package/src/export/ttsr.ts +9 -0
  113. package/src/extensibility/extensions/runner.ts +3 -0
  114. package/src/extensibility/plugins/doctor.ts +0 -1
  115. package/src/extensibility/plugins/marketplace-auto-update.ts +49 -0
  116. package/src/goals/tools/goal-tool.ts +2 -2
  117. package/src/internal-urls/docs-index.generated.ts +7 -6
  118. package/src/lsp/client.ts +179 -52
  119. package/src/lsp/index.ts +38 -4
  120. package/src/lsp/render.ts +3 -3
  121. package/src/lsp/types.ts +10 -0
  122. package/src/main.ts +47 -52
  123. package/src/memory-backend/index.ts +13 -1
  124. package/src/memory-backend/resolve.ts +3 -5
  125. package/src/memory-backend/types.ts +1 -1
  126. package/src/modes/components/agent-dashboard.ts +13 -4
  127. package/src/modes/components/assistant-message.ts +22 -1
  128. package/src/modes/components/copy-selector.ts +249 -0
  129. package/src/modes/components/custom-editor.ts +10 -1
  130. package/src/modes/components/extensions/extension-list.ts +17 -8
  131. package/src/modes/components/history-search.ts +19 -11
  132. package/src/modes/components/model-selector.ts +125 -29
  133. package/src/modes/components/oauth-selector.ts +28 -12
  134. package/src/modes/components/session-observer-overlay.ts +13 -15
  135. package/src/modes/components/session-selector.ts +24 -13
  136. package/src/modes/components/status-line.ts +3 -5
  137. package/src/modes/components/tool-execution.ts +83 -24
  138. package/src/modes/components/tree-selector.ts +19 -7
  139. package/src/modes/components/user-message-selector.ts +25 -14
  140. package/src/modes/controllers/command-controller.ts +13 -118
  141. package/src/modes/controllers/event-controller.ts +26 -10
  142. package/src/modes/controllers/input-controller.ts +11 -3
  143. package/src/modes/controllers/selector-controller.ts +40 -3
  144. package/src/modes/index.ts +5 -4
  145. package/src/modes/interactive-mode.ts +21 -7
  146. package/src/modes/setup-version.ts +11 -0
  147. package/src/modes/setup-wizard/index.ts +3 -2
  148. package/src/modes/setup-wizard/scenes/web-search.ts +3 -2
  149. package/src/modes/theme/theme.ts +46 -10
  150. package/src/modes/types.ts +2 -2
  151. package/src/modes/utils/context-usage.ts +10 -6
  152. package/src/modes/utils/copy-targets.ts +254 -0
  153. package/src/modes/utils/hotkeys-markdown.ts +1 -0
  154. package/src/prompts/tools/ast-edit.md +1 -1
  155. package/src/prompts/tools/ast-grep.md +1 -1
  156. package/src/prompts/tools/read.md +1 -1
  157. package/src/prompts/tools/search.md +1 -1
  158. package/src/sdk.ts +21 -23
  159. package/src/session/agent-session.ts +13 -9
  160. package/src/slash-commands/builtin-registry.ts +4 -12
  161. package/src/slash-commands/helpers/usage-report.ts +2 -0
  162. package/src/task/executor.ts +20 -2
  163. package/src/task/render.ts +37 -11
  164. package/src/telemetry-export.ts +25 -7
  165. package/src/tools/bash.ts +18 -8
  166. package/src/tools/browser/render.ts +5 -4
  167. package/src/tools/debug.ts +3 -3
  168. package/src/tools/eval-backends.ts +6 -17
  169. package/src/tools/eval-render.ts +28 -10
  170. package/src/tools/eval.ts +19 -23
  171. package/src/tools/fetch.ts +99 -89
  172. package/src/tools/read.ts +7 -7
  173. package/src/tools/render-utils.ts +63 -3
  174. package/src/tools/renderers.ts +16 -1
  175. package/src/tools/report-tool-issue.ts +1 -1
  176. package/src/tools/search.ts +173 -81
  177. package/src/tools/ssh.ts +21 -8
  178. package/src/tools/todo.ts +20 -7
  179. package/src/tools/write.ts +39 -9
  180. package/src/tui/code-cell.ts +19 -4
  181. package/src/tui/output-block.ts +14 -0
  182. package/src/web/scrapers/github.ts +255 -3
  183. package/src/web/scrapers/youtube.ts +3 -2
  184. package/src/web/search/providers/perplexity.ts +199 -51
  185. package/src/web/search/render.ts +42 -57
  186. package/src/web/search/types.ts +5 -1
  187. package/dist/types/eval/heartbeat.d.ts +0 -45
  188. package/src/eval/__tests__/heartbeat.test.ts +0 -84
  189. package/src/eval/__tests__/shared-executors.test.ts +0 -609
  190. package/src/eval/heartbeat.ts +0 -74
  191. /package/dist/types/eval/__tests__/{heartbeat.test.d.ts → bridge-timeout.test.d.ts} +0 -0
  192. /package/dist/types/eval/__tests__/{shared-executors.test.d.ts → kernel-spawn.test.d.ts} +0 -0
@@ -31,9 +31,9 @@ import {
31
31
  renderJsonTreeLines,
32
32
  } from "../../tools/json-tree";
33
33
  import { formatExpandHint, replaceTabs, resolveImageOptions, truncateToWidth } from "../../tools/render-utils";
34
- import { toolRenderers } from "../../tools/renderers";
34
+ import { type ToolRenderer, toolRenderers } from "../../tools/renderers";
35
35
  import { TODO_STRIKE_TOTAL_FRAMES } from "../../tools/todo";
36
- import { renderStatusLine } from "../../tui";
36
+ import { isFramedBlockComponent, renderStatusLine } from "../../tui";
37
37
  import { sanitizeWithOptionalSixelPassthrough } from "../../utils/sixel";
38
38
  import { renderDiff } from "./diff";
39
39
 
@@ -45,6 +45,18 @@ function ensureInvalidate(component: unknown): Component {
45
45
  return c as Component;
46
46
  }
47
47
 
48
+ function addBoxChild(box: Box, component: unknown): boolean {
49
+ const child = ensureInvalidate(component);
50
+ box.addChild(child);
51
+ return isFramedBlockComponent(child);
52
+ }
53
+
54
+ function setBoxPaddingForFramedBlock(box: Box, hasFramedBlock: boolean): void {
55
+ const padding = hasFramedBlock ? 0 : 1;
56
+ box.setPaddingX(padding);
57
+ box.setPaddingY(padding);
58
+ }
59
+
48
60
  /**
49
61
  * Drop trailing removal/hunk-header lines that appear in a streaming diff
50
62
  * before the matching `+added` lines have arrived. Without this, a partial
@@ -107,7 +119,7 @@ function rawTextInputFromPartialJson(partialJson: unknown): string | undefined {
107
119
  // Function-tool arguments stream as JSON. Custom/free-form tools stream raw
108
120
  // text in the same transport field; only the raw form is a valid fallback for
109
121
  // the conventional `input` parameter.
110
- if (first === "{" || first === "[" || first === '"') return undefined;
122
+ if (first === "{" || first === '"') return undefined;
111
123
  return partialJson;
112
124
  }
113
125
 
@@ -518,6 +530,39 @@ export class ToolExecutionComponent extends Container {
518
530
  return (this.#result.details as { async?: { state?: string } } | undefined)?.async?.state === "running";
519
531
  }
520
532
 
533
+ /**
534
+ * While a tool's preview is still streaming, a block whose preview is
535
+ * append-only (rows only grow at the bottom, never re-layout) lets the
536
+ * renderer commit the scrolled-off head of an over-tall preview to native
537
+ * scrollback instead of dropping it — the same anti-yank path a streaming
538
+ * assistant reply uses (see {@link TranscriptContainer} +
539
+ * `NativeScrollbackLiveRegion`). Covers both phases: a pre-result call preview
540
+ * (a `write` whose content streams in) and a partial-result preview that
541
+ * streams output below fixed input (an `eval`/`bash` whose stdout grows under
542
+ * its code cell). Gated on {@link isTranscriptBlockFinalized} so the boundary
543
+ * closes the instant the block reaches a terminal state — a final result that
544
+ * may collapse to a compact view, a backgrounded async tool, or a seal — and
545
+ * the renderer decides whether its current preview shape qualifies via
546
+ * `isStreamingPreviewAppendOnly` (typically: only the expanded full view,
547
+ * which is top-anchored; the collapsed tail window re-layouts but is bounded
548
+ * so it never overflows anyway).
549
+ */
550
+ isTranscriptBlockAppendOnly(): boolean {
551
+ // A finalized block's preview can collapse/re-layout; only a live,
552
+ // still-streaming block is a candidate.
553
+ if (this.isTranscriptBlockFinalized()) return false;
554
+ const predicate =
555
+ (this.#tool as { isStreamingPreviewAppendOnly?: ToolRenderer["isStreamingPreviewAppendOnly"] } | undefined)
556
+ ?.isStreamingPreviewAppendOnly ?? toolRenderers[this.#toolName]?.isStreamingPreviewAppendOnly;
557
+ if (!predicate) return false;
558
+ try {
559
+ return predicate(this.#getCallArgsForRender(), this.#renderState, this.#result);
560
+ } catch (err) {
561
+ logger.warn("Tool append-only predicate failed", { tool: this.#toolName, error: String(err) });
562
+ return false;
563
+ }
564
+ }
565
+
521
566
  /**
522
567
  * Mark the tool terminal even though no result arrived (the turn aborted or
523
568
  * abandoned it) and stop animating, so it can freeze and stops pinning the
@@ -582,28 +627,35 @@ export class ToolExecutionComponent extends Container {
582
627
  const inline = Boolean((tool as { inline?: boolean }).inline);
583
628
  this.#contentBox.setBgFn(inline ? undefined : bgFn);
584
629
  this.#contentBox.clear();
630
+ let contentBoxHasFramedBlock = false;
585
631
  // Mirror the built-in renderer branch so custom renderers (notably the
586
632
  // task tool, whose live instance routes through here) receive the same
587
633
  // render context — e.g. the `hasResult` flag that suppresses the task
588
634
  // call preview once result lines exist.
589
635
  this.#renderState.renderContext = this.#buildRenderContext();
590
636
 
591
- // Render call component
637
+ // Render call component. The fallback label only stands in for a
638
+ // missing `renderCall`; when the call is intentionally suppressed
639
+ // (mergeCallAndResult once a result exists) we render nothing here so
640
+ // the result component isn't preceded by a redundant tool-name line.
592
641
  const shouldRenderCall = !this.#result || !mergeCallAndResult;
593
- if (shouldRenderCall && tool.renderCall) {
594
- try {
595
- const callComponent = tool.renderCall(this.#getCallArgsForRender(), this.#renderState, theme);
596
- if (callComponent) {
597
- this.#contentBox.addChild(ensureInvalidate(callComponent));
642
+ if (shouldRenderCall) {
643
+ if (tool.renderCall) {
644
+ try {
645
+ const callComponent = tool.renderCall(this.#getCallArgsForRender(), this.#renderState, theme);
646
+ if (callComponent) {
647
+ contentBoxHasFramedBlock =
648
+ addBoxChild(this.#contentBox, callComponent) || contentBoxHasFramedBlock;
649
+ }
650
+ } catch (err) {
651
+ logger.warn("Tool renderer failed", { tool: this.#toolName, error: String(err) });
652
+ // Fall back to default on error
653
+ addBoxChild(this.#contentBox, new Text(theme.fg("toolTitle", theme.bold(this.#toolLabel)), 0, 0));
598
654
  }
599
- } catch (err) {
600
- logger.warn("Tool renderer failed", { tool: this.#toolName, error: String(err) });
601
- // Fall back to default on error
602
- this.#contentBox.addChild(new Text(theme.fg("toolTitle", theme.bold(this.#toolLabel)), 0, 0));
655
+ } else {
656
+ // No custom renderCall, show tool name
657
+ addBoxChild(this.#contentBox, new Text(theme.fg("toolTitle", theme.bold(this.#toolLabel)), 0, 0));
603
658
  }
604
- } else {
605
- // No custom renderCall, show tool name
606
- this.#contentBox.addChild(new Text(theme.fg("toolTitle", theme.bold(this.#toolLabel)), 0, 0));
607
659
  }
608
660
 
609
661
  // Render result component if we have a result
@@ -626,23 +678,24 @@ export class ToolExecutionComponent extends Container {
626
678
  this.#args,
627
679
  );
628
680
  if (resultComponent) {
629
- this.#contentBox.addChild(ensureInvalidate(resultComponent));
681
+ contentBoxHasFramedBlock = addBoxChild(this.#contentBox, resultComponent) || contentBoxHasFramedBlock;
630
682
  }
631
683
  } catch (err) {
632
684
  logger.warn("Tool renderer failed", { tool: this.#toolName, error: String(err) });
633
685
  // Fall back to showing raw output on error
634
686
  const output = this.#getTextOutput();
635
687
  if (output) {
636
- this.#contentBox.addChild(new Text(theme.fg("toolOutput", replaceTabs(output)), 0, 0));
688
+ addBoxChild(this.#contentBox, new Text(theme.fg("toolOutput", replaceTabs(output)), 0, 0));
637
689
  }
638
690
  }
639
691
  } else if (this.#result) {
640
692
  // Has result but no custom renderResult
641
693
  const output = this.#getTextOutput();
642
694
  if (output) {
643
- this.#contentBox.addChild(new Text(theme.fg("toolOutput", replaceTabs(output)), 0, 0));
695
+ addBoxChild(this.#contentBox, new Text(theme.fg("toolOutput", replaceTabs(output)), 0, 0));
644
696
  }
645
697
  }
698
+ setBoxPaddingForFramedBlock(this.#contentBox, contentBoxHasFramedBlock);
646
699
  } else if (this.#toolName in toolRenderers) {
647
700
  // Built-in tools with renderers
648
701
  const renderer = toolRenderers[this.#toolName];
@@ -661,6 +714,7 @@ export class ToolExecutionComponent extends Container {
661
714
  // Multi-file: render each file as its own Box (identical to separate tool calls)
662
715
  this.#contentBox.setBgFn(undefined);
663
716
  this.#contentBox.clear();
717
+ this.#contentBox.setPaddingX(1);
664
718
 
665
719
  const renderContext = this.#buildRenderContext();
666
720
  this.#renderState.renderContext = renderContext;
@@ -683,7 +737,8 @@ export class ToolExecutionComponent extends Container {
683
737
  theme,
684
738
  );
685
739
  if (resultComponent) {
686
- fileBox.addChild(ensureInvalidate(resultComponent));
740
+ const fileBoxHasFramedBlock = addBoxChild(fileBox, resultComponent);
741
+ setBoxPaddingForFramedBlock(fileBox, fileBoxHasFramedBlock);
687
742
  }
688
743
  } catch (err) {
689
744
  logger.warn("Tool renderer failed", { tool: this.#toolName, error: String(err) });
@@ -719,6 +774,7 @@ export class ToolExecutionComponent extends Container {
719
774
  // Inline renderers skip background styling
720
775
  this.#contentBox.setBgFn(renderer.inline ? undefined : bgFn);
721
776
  this.#contentBox.clear();
777
+ let contentBoxHasFramedBlock = false;
722
778
 
723
779
  const renderContext = this.#buildRenderContext();
724
780
  this.#renderState.renderContext = renderContext;
@@ -729,12 +785,13 @@ export class ToolExecutionComponent extends Container {
729
785
  try {
730
786
  const callComponent = renderer.renderCall(this.#getCallArgsForRender(), this.#renderState, theme);
731
787
  if (callComponent) {
732
- this.#contentBox.addChild(ensureInvalidate(callComponent));
788
+ contentBoxHasFramedBlock =
789
+ addBoxChild(this.#contentBox, callComponent) || contentBoxHasFramedBlock;
733
790
  }
734
791
  } catch (err) {
735
792
  logger.warn("Tool renderer failed", { tool: this.#toolName, error: String(err) });
736
793
  // Fall back to default on error
737
- this.#contentBox.addChild(new Text(theme.fg("toolTitle", theme.bold(this.#toolLabel)), 0, 0));
794
+ addBoxChild(this.#contentBox, new Text(theme.fg("toolTitle", theme.bold(this.#toolLabel)), 0, 0));
738
795
  }
739
796
  }
740
797
 
@@ -752,17 +809,19 @@ export class ToolExecutionComponent extends Container {
752
809
  this.#getCallArgsForRender(),
753
810
  );
754
811
  if (resultComponent) {
755
- this.#contentBox.addChild(ensureInvalidate(resultComponent));
812
+ contentBoxHasFramedBlock =
813
+ addBoxChild(this.#contentBox, resultComponent) || contentBoxHasFramedBlock;
756
814
  }
757
815
  } catch (err) {
758
816
  logger.warn("Tool renderer failed", { tool: this.#toolName, error: String(err) });
759
817
  // Fall back to showing raw output on error
760
818
  const output = this.#getTextOutput();
761
819
  if (output) {
762
- this.#contentBox.addChild(new Text(theme.fg("toolOutput", replaceTabs(output)), 0, 0));
820
+ addBoxChild(this.#contentBox, new Text(theme.fg("toolOutput", replaceTabs(output)), 0, 0));
763
821
  }
764
822
  }
765
823
  }
824
+ setBoxPaddingForFramedBlock(this.#contentBox, contentBoxHasFramedBlock);
766
825
  }
767
826
  } else {
768
827
  // Other built-in tools: use Text directly with caching
@@ -6,6 +6,7 @@ import {
6
6
  fuzzyMatch,
7
7
  Input,
8
8
  matchesKey,
9
+ ScrollView,
9
10
  Spacer,
10
11
  Text,
11
12
  TruncatedText,
@@ -492,6 +493,10 @@ class TreeList implements Component {
492
493
  const contentReserve = Math.max(MIN_CONTENT_COLS, Math.floor(width / 2));
493
494
  const maxIndentLevels = Math.max(1, Math.floor((width - contentReserve - OVERHEAD_COLS) / 3));
494
495
 
496
+ const overflow = this.#filteredNodes.length > this.maxVisibleLines;
497
+ const rowWidth = Math.max(0, width - (overflow ? 1 : 0));
498
+ const rows: string[] = [];
499
+
495
500
  for (let i = startIndex; i < endIndex; i++) {
496
501
  const flatNode = this.#filteredNodes[i];
497
502
  const entry = flatNode.node.entry;
@@ -560,15 +565,22 @@ class TreeList implements Component {
560
565
  if (isSelected) {
561
566
  line = theme.bg("selectedBg", line);
562
567
  }
563
- lines.push(truncateToWidth(line, width));
568
+ rows.push(truncateToWidth(line, rowWidth));
564
569
  }
565
570
 
566
- lines.push(
567
- truncateToWidth(
568
- theme.fg("muted", ` (${this.#selectedIndex + 1}/${this.#filteredNodes.length})${this.#getFilterLabel()}`),
569
- width,
570
- ),
571
- );
571
+ const sv = new ScrollView(rows, {
572
+ height: rows.length,
573
+ scrollbar: "auto",
574
+ totalRows: this.#filteredNodes.length,
575
+ theme: { track: t => theme.fg("muted", t), thumb: t => theme.fg("accent", t) },
576
+ });
577
+ sv.setScrollOffset(startIndex);
578
+ lines.push(...sv.render(width));
579
+
580
+ const filterLabel = this.#getFilterLabel();
581
+ if (filterLabel) {
582
+ lines.push(truncateToWidth(theme.fg("muted", ` ${filterLabel.trim()}`), width));
583
+ }
572
584
 
573
585
  return lines;
574
586
  }
@@ -4,6 +4,7 @@ import {
4
4
  extractPrintableText,
5
5
  fuzzyFilter,
6
6
  matchesKey,
7
+ ScrollView,
7
8
  Spacer,
8
9
  Text,
9
10
  truncateToWidth,
@@ -48,14 +49,10 @@ class UserMessageList implements Component {
48
49
  return this.#isSearchEnabled() || this.#searchQuery.length > 0;
49
50
  }
50
51
 
51
- #renderStatusLine(total: number): string {
52
- const selectedCount = total === 0 ? 0 : this.#selectedIndex + 1;
53
- const count =
54
- this.#searchQuery.trim() && total !== this.messages.length
55
- ? `${selectedCount}/${total} of ${this.messages.length}`
56
- : `${selectedCount}/${total}`;
57
- const suffix = this.#searchQuery.trim() ? ` Search: ${this.#searchQuery}` : " Type to search";
58
- return theme.fg("muted", ` (${count})${suffix}`);
52
+ #renderStatusLine(_total: number): string {
53
+ const query = this.#searchQuery.trim();
54
+ const suffix = query ? `Search: ${this.#searchQuery}` : "Type to search";
55
+ return theme.fg("muted", ` ${suffix}`);
59
56
  }
60
57
 
61
58
  #setSearchQuery(query: string): void {
@@ -103,6 +100,9 @@ class UserMessageList implements Component {
103
100
  const endIndex = Math.min(startIndex + this.#maxVisible, total);
104
101
 
105
102
  // Render visible messages (2 lines per message + blank line)
103
+ const overflow = total > this.#maxVisible;
104
+ const rowWidth = Math.max(0, width - (overflow ? 1 : 0));
105
+ const messageLines: string[] = [];
106
106
  for (let i = startIndex; i < endIndex; i++) {
107
107
  const message = this.#filteredMessages[i];
108
108
  if (!message) continue;
@@ -113,26 +113,37 @@ class UserMessageList implements Component {
113
113
 
114
114
  // First line: cursor + message
115
115
  const cursor = isSelected ? theme.fg("accent", "› ") : " ";
116
- const maxMsgWidth = width - 2; // Account for cursor (2 chars)
116
+ const maxMsgWidth = rowWidth - 2; // Account for cursor (2 chars)
117
117
  const truncatedMsg = truncateToWidth(normalizedMessage, maxMsgWidth);
118
118
  const messageLine = cursor + (isSelected ? theme.bold(truncatedMsg) : truncatedMsg);
119
119
 
120
- lines.push(messageLine);
120
+ messageLines.push(messageLine);
121
121
 
122
122
  // Second line: metadata (position in history)
123
123
  const position = this.messages.indexOf(message) + 1;
124
124
  const metadata = ` Message ${position} of ${this.messages.length}`;
125
125
  const metadataLine = theme.fg("muted", metadata);
126
- lines.push(metadataLine);
127
- lines.push(""); // Blank line between messages
126
+ messageLines.push(metadataLine);
127
+ messageLines.push(""); // Blank line between messages
128
128
  }
129
129
 
130
130
  if (total === 0) {
131
131
  lines.push(theme.fg("muted", " No matching messages"));
132
+ } else {
133
+ const visibleCount = endIndex - startIndex;
134
+ const linesPerItem = visibleCount > 0 ? messageLines.length / visibleCount : 1;
135
+ const sv = new ScrollView(messageLines, {
136
+ height: messageLines.length,
137
+ scrollbar: "auto",
138
+ totalRows: Math.round(total * linesPerItem),
139
+ theme: { track: t => theme.fg("muted", t), thumb: t => theme.fg("accent", t) },
140
+ });
141
+ sv.setScrollOffset(Math.round(startIndex * linesPerItem));
142
+ lines.push(...sv.render(width));
132
143
  }
133
144
 
134
- // Add scroll/search indicator if needed
135
- if (startIndex > 0 || endIndex < total || this.#shouldRenderSearchStatus()) {
145
+ // Add search indicator if needed
146
+ if (this.#shouldRenderSearchStatus()) {
136
147
  lines.push(this.#renderStatusLine(total));
137
148
  }
138
149
 
@@ -6,7 +6,6 @@ import {
6
6
  getEnvApiKey,
7
7
  getProviderDetails,
8
8
  type ProviderDetails,
9
- type ToolCall,
10
9
  type UsageLimit,
11
10
  type UsageReport,
12
11
  } from "@oh-my-pi/pi-ai";
@@ -14,7 +13,6 @@ import { Loader, Markdown, padding, Spacer, Text, visibleWidth } from "@oh-my-pi
14
13
  import { formatDuration, Snowflake } from "@oh-my-pi/pi-utils";
15
14
  import { $ } from "bun";
16
15
  import { shouldEnableAppendOnlyContext } from "../../config/append-only-context-mode";
17
- import { loadCustomShare } from "../../export/custom-share";
18
16
  import type { CompactOptions } from "../../extensibility/extensions/types";
19
17
  import {
20
18
  diffMentalModelContent,
@@ -132,6 +130,7 @@ export class CommandController {
132
130
  }
133
131
 
134
132
  try {
133
+ const { loadCustomShare } = await import("../../export/custom-share");
135
134
  const customShare = await loadCustomShare();
136
135
  if (customShare) {
137
136
  const loader = new BorderedLoader(this.ctx.ui, theme, "Sharing...");
@@ -239,121 +238,6 @@ export class CommandController {
239
238
  }
240
239
  }
241
240
 
242
- handleCopyCommand(sub?: string) {
243
- switch (sub) {
244
- case "code":
245
- return this.#copyCode();
246
- case "all":
247
- return this.#copyAllCode();
248
- case "cmd":
249
- return this.#copyLastCommand();
250
- case "last":
251
- case undefined:
252
- return this.#copyLastMessage();
253
- default:
254
- this.ctx.showError(`Unknown subcommand: ${sub}. Use code, all, cmd, or last.`);
255
- }
256
- }
257
-
258
- #copyLastMessage() {
259
- const assistantText = this.ctx.session.getLastAssistantText();
260
- if (assistantText) {
261
- this.#doCopy(assistantText, "Copied last agent message to clipboard");
262
- return;
263
- }
264
-
265
- if (!this.ctx.session.hasCopyCandidateAssistantMessage()) {
266
- const handoffText = this.ctx.session.getLastVisibleHandoffText();
267
- if (handoffText) {
268
- this.#doCopy(handoffText, "Copied handoff context to clipboard");
269
- return;
270
- }
271
- }
272
-
273
- this.ctx.showError("No agent messages to copy yet.");
274
- }
275
-
276
- #copyCode() {
277
- const text = this.ctx.session.getLastAssistantText();
278
- if (!text) {
279
- this.ctx.showError("No agent messages to copy yet.");
280
- return;
281
- }
282
- const matches = [...text.matchAll(/^```[^\n]*\n([\s\S]*?)^```/gm)];
283
- const lastMatch = matches.at(-1);
284
- if (!lastMatch) {
285
- this.ctx.showWarning("No code block found in the last agent message.");
286
- return;
287
- }
288
- this.#doCopy(lastMatch[1].replace(/\n$/, ""), "Copied last code block to clipboard");
289
- }
290
-
291
- #copyAllCode() {
292
- const text = this.ctx.session.getLastAssistantText();
293
- if (!text) {
294
- this.ctx.showError("No agent messages to copy yet.");
295
- return;
296
- }
297
- const matches = [...text.matchAll(/^```[^\n]*\n([\s\S]*?)^```/gm)];
298
- if (matches.length === 0) {
299
- this.ctx.showWarning("No code blocks found in the last agent message.");
300
- return;
301
- }
302
- const combined = matches.map(m => m[1].replace(/\n$/, "")).join("\n\n");
303
- this.#doCopy(combined, `Copied ${matches.length} code block${matches.length > 1 ? "s" : ""} to clipboard`);
304
- }
305
-
306
- #extractEvalCode(args: unknown): string | undefined {
307
- if (!args || typeof args !== "object") return undefined;
308
- const cells = (args as { cells?: unknown }).cells;
309
- if (!Array.isArray(cells)) return undefined;
310
-
311
- const codeBlocks: string[] = [];
312
- for (const cell of cells) {
313
- if (!cell || typeof cell !== "object") continue;
314
- const code = (cell as { code?: unknown }).code;
315
- if (typeof code === "string" && code.length > 0) {
316
- codeBlocks.push(code);
317
- }
318
- }
319
-
320
- return codeBlocks.length > 0 ? codeBlocks.join("\n\n") : undefined;
321
- }
322
-
323
- #copyLastCommand() {
324
- const messages = this.ctx.session.messages;
325
- // Walk backwards to find the last bash/eval tool call
326
- for (let i = messages.length - 1; i >= 0; i--) {
327
- const msg = messages[i];
328
- if (msg.role !== "assistant") continue;
329
- const toolCalls = msg.content.filter((c): c is ToolCall => c.type === "toolCall");
330
- for (let j = toolCalls.length - 1; j >= 0; j--) {
331
- const tc = toolCalls[j];
332
- if (tc.name === "bash" && typeof tc.arguments.command === "string") {
333
- this.#doCopy(tc.arguments.command, "Copied last bash command to clipboard");
334
- return;
335
- }
336
- if (tc.name === "eval") {
337
- const code = this.#extractEvalCode(tc.arguments);
338
- if (code) {
339
- this.#doCopy(code, "Copied last eval code to clipboard");
340
- return;
341
- }
342
- }
343
- }
344
- }
345
- this.ctx.showWarning("No bash or eval command found in the conversation.");
346
- }
347
-
348
- #doCopy(content: string, label: string) {
349
- try {
350
- copyToClipboard(content);
351
- this.ctx.showStatus(label);
352
- } catch (error) {
353
- this.ctx.showError(error instanceof Error ? error.message : String(error));
354
- }
355
- }
356
-
357
241
  async handleSessionCommand(): Promise<void> {
358
242
  const stats = this.ctx.session.getSessionStats();
359
243
  const premiumRequests =
@@ -581,7 +465,7 @@ export class CommandController {
581
465
  const argumentText = text.slice(7).trim();
582
466
  const action = argumentText.split(/\s+/, 1)[0]?.toLowerCase() || "view";
583
467
  const agentDir = this.ctx.settings.getAgentDir();
584
- const backend = resolveMemoryBackend(this.ctx.settings);
468
+ const backend = await resolveMemoryBackend(this.ctx.settings);
585
469
 
586
470
  if (action === "view") {
587
471
  const payload = await backend.buildDeveloperInstructions(agentDir, this.ctx.settings, this.ctx.session);
@@ -1388,6 +1272,8 @@ function formatAccountLabel(limit: UsageLimit, report: UsageReport, index: numbe
1388
1272
  if (email) return email;
1389
1273
  const accountId = (report.metadata?.accountId as string | undefined) ?? limit.scope.accountId;
1390
1274
  if (accountId) return accountId;
1275
+ const projectId = (report.metadata?.projectId as string | undefined) ?? limit.scope.projectId;
1276
+ if (projectId) return projectId;
1391
1277
  return `account ${index + 1}`;
1392
1278
  }
1393
1279
 
@@ -1396,6 +1282,8 @@ function formatUnlimitedReportLabel(report: UsageReport, index: number): string
1396
1282
  if (email) return email;
1397
1283
  const accountId = report.metadata?.accountId as string | undefined;
1398
1284
  if (accountId) return accountId;
1285
+ const projectId = report.metadata?.projectId as string | undefined;
1286
+ if (projectId) return projectId;
1399
1287
  return `account ${index + 1}`;
1400
1288
  }
1401
1289
 
@@ -1481,6 +1369,13 @@ function formatAggregateAmount(limits: UsageLimit[]): string {
1481
1369
  return `${formatNumber(remainingPct)}% free`;
1482
1370
  }
1483
1371
 
1372
+ // Count unique accounts from limit scopes — not limits.length.
1373
+ const uniqueAccountIds = new Set(
1374
+ limits.map(limit => limit.scope.accountId).filter((id): id is string => typeof id === "string" && id.length > 0),
1375
+ );
1376
+ if (uniqueAccountIds.size > 0) return `${uniqueAccountIds.size} ${uniqueAccountIds.size === 1 ? "acct" : "accts"}`;
1377
+ // No account IDs available — keep the pre-existing fallback so providers
1378
+ // that don't populate scope.accountId still show a summary.
1484
1379
  return `${limits.length} accts`;
1485
1380
  }
1486
1381
 
@@ -49,9 +49,15 @@ export class EventController {
49
49
  #lastIntent: string | undefined = undefined;
50
50
  #backgroundToolCallIds = new Set<string>();
51
51
  #assistantMessageStreaming = false;
52
+ #agentTurnActive = false;
52
53
  #readToolCallArgs = new Map<string, Record<string, unknown>>();
53
54
  #readToolCallAssistantComponents = new Map<string, AssistantMessageComponent>();
54
55
  #lastAssistantComponent: AssistantMessageComponent | undefined = undefined;
56
+ // Assistant component whose turn-ending error is currently mirrored in the
57
+ // pinned banner. Its inline `Error: …` line is suppressed while pinned and
58
+ // restored when the banner clears at the next `agent_start` (see
59
+ // #handleMessageEnd / #handleAgentStart).
60
+ #pinnedErrorComponent: AssistantMessageComponent | undefined = undefined;
55
61
  #idleCompactionTimer?: NodeJS.Timeout;
56
62
  #ircExpiryTimers = new Map<string, NodeJS.Timeout>();
57
63
  #handlers: AgentSessionEventHandlers;
@@ -172,21 +178,21 @@ export class EventController {
172
178
 
173
179
  const run = this.#handlers[event.type] as (e: AgentSessionEvent) => Promise<void>;
174
180
  await run(event);
175
- // While assistant text or a foreground tool is streaming, rows above the
176
- // viewport can re-layout after they have already entered native scrollback
177
- // (Markdown fences, wrapping, previews). Let the TUI rebuild history on
178
- // those offscreen edits instead of deferring, which otherwise leaves stale
179
- // tail rows duplicated above the live viewport.
180
- // Background-running tools are excluded so late async updates outside the
181
- // active foreground stream keep the no-yank deferral; agent_start resets
182
- // the mode at every turn boundary.
181
+ // While an assistant turn is active, visible status chrome and foreground
182
+ // transcript blocks can re-render after rows have entered native scrollback
183
+ // (idle Working loader, Markdown fences, wrapping, tool previews). Let the
184
+ // TUI use its foreground live-region path instead of idle deferral, which
185
+ // can otherwise leave the loader/status frame frozen until the next input.
186
+ // Background-running tools after the turn ends are excluded so late async
187
+ // updates keep the no-yank deferral; agent_start/agent_end bracket the
188
+ // foreground turn.
183
189
  if (STREAM_RENDER_MODE_EVENTS[event.type]) {
184
190
  this.#refreshToolRenderMode();
185
191
  }
186
192
  }
187
193
 
188
194
  #refreshToolRenderMode(): void {
189
- let foregroundToolActive = this.#assistantMessageStreaming;
195
+ let foregroundToolActive = this.#agentTurnActive || this.#assistantMessageStreaming;
190
196
  if (!foregroundToolActive) {
191
197
  for (const toolCallId of this.ctx.pendingTools.keys()) {
192
198
  if (!this.#backgroundToolCallIds.has(toolCallId)) {
@@ -199,11 +205,16 @@ export class EventController {
199
205
  }
200
206
 
201
207
  async #handleAgentStart(_event: Extract<AgentSessionEvent, { type: "agent_start" }>): Promise<void> {
208
+ this.#agentTurnActive = true;
202
209
  this.#lastIntent = undefined;
203
210
  this.#readToolCallArgs.clear();
204
211
  this.#readToolCallAssistantComponents.clear();
205
212
  this.#assistantMessageStreaming = false;
206
213
  this.#lastAssistantComponent = undefined;
214
+ // Restore the previous turn's inline error in the transcript before dropping
215
+ // the banner, so the error stays in history once the banner is gone.
216
+ this.#pinnedErrorComponent?.setErrorPinned(false);
217
+ this.#pinnedErrorComponent = undefined;
207
218
  this.ctx.clearPinnedError();
208
219
  if (this.ctx.retryEscapeHandler) {
209
220
  this.ctx.editor.onEscape = this.ctx.retryEscapeHandler;
@@ -215,6 +226,7 @@ export class EventController {
215
226
  this.ctx.statusContainer.clear();
216
227
  }
217
228
  this.#cancelIdleCompaction();
229
+ this.#refreshToolRenderMode();
218
230
  this.ctx.ensureLoadingAnimation();
219
231
  this.ctx.ui.requestRender();
220
232
  }
@@ -493,12 +505,15 @@ export class EventController {
493
505
  this.ctx.streamingMessage = undefined;
494
506
  // Pin a turn-ending provider error (e.g. Anthropic content-filter block)
495
507
  // above the editor so it survives transcript scroll. Cleared at the next
496
- // turn's agent_start.
508
+ // turn's agent_start. Suppress the transcript's inline `Error: …` line for
509
+ // the same message while pinned so the error isn't rendered twice.
497
510
  if (
498
511
  event.message.stopReason === "error" &&
499
512
  event.message.errorMessage &&
500
513
  !isSilentAbort(event.message.errorMessage)
501
514
  ) {
515
+ this.#lastAssistantComponent?.setErrorPinned(true);
516
+ this.#pinnedErrorComponent = this.#lastAssistantComponent;
502
517
  this.ctx.showPinnedError(event.message.errorMessage);
503
518
  }
504
519
  this.ctx.statusLine.invalidate();
@@ -646,6 +661,7 @@ export class EventController {
646
661
  }
647
662
  }
648
663
  async #handleAgentEnd(_event: Extract<AgentSessionEvent, { type: "agent_end" }>): Promise<void> {
664
+ this.#agentTurnActive = false;
649
665
  this.#assistantMessageStreaming = false;
650
666
  if (this.ctx.loadingAnimation) {
651
667
  this.ctx.loadingAnimation.stop();
@@ -144,6 +144,8 @@ export class InputController {
144
144
  this.ctx.editor.setActionKeys("app.clear", this.ctx.keybindings.getKeys("app.clear"));
145
145
  this.ctx.editor.onClear = () => this.handleCtrlC();
146
146
  this.ctx.editor.setActionKeys("app.exit", this.ctx.keybindings.getKeys("app.exit"));
147
+ this.ctx.editor.setActionKeys("app.display.reset", this.ctx.keybindings.getKeys("app.display.reset"));
148
+ this.ctx.editor.onDisplayReset = () => this.ctx.ui.resetDisplay();
147
149
  this.ctx.editor.onExit = () => this.handleCtrlD();
148
150
  this.ctx.editor.setActionKeys("app.suspend", this.ctx.keybindings.getKeys("app.suspend"));
149
151
  this.ctx.editor.onSuspend = () => this.handleCtrlZ();
@@ -188,11 +190,9 @@ export class InputController {
188
190
  this.ctx.editor.onExpandTools = () => this.toggleToolOutputExpansion();
189
191
  this.ctx.editor.setActionKeys("app.message.dequeue", this.ctx.keybindings.getKeys("app.message.dequeue"));
190
192
  this.ctx.editor.onDequeue = () => this.handleDequeue();
191
-
192
193
  this.ctx.editor.clearCustomKeyHandlers();
193
194
  // Wire up extension shortcuts
194
195
  this.registerExtensionShortcuts();
195
-
196
196
  const planModeKeys = this.ctx.keybindings.getKeys("app.plan.toggle");
197
197
  for (const key of planModeKeys) {
198
198
  this.ctx.editor.setCustomKeyHandler(key, () => void this.ctx.handlePlanModeCommand());
@@ -846,7 +846,15 @@ export class InputController {
846
846
  child.setExpanded(expanded);
847
847
  }
848
848
  }
849
- this.ctx.ui.requestRender(false, { allowUnknownViewportMutation: true });
849
+ // Toggling expansion mutates every block, but on ED3-risk terminals the
850
+ // transcript freezes a snapshot of each block once it scrolls past the live
851
+ // region (committed native scrollback is immutable there). A plain repaint
852
+ // replays those stale snapshots, so the toggle appears to do nothing above
853
+ // the live block. resetDisplay() invalidates the snapshots and forces a
854
+ // full clear + replay — the keyboard-accessible resize-reset equivalent —
855
+ // which is the only path that re-emits the whole transcript at its new
856
+ // heights.
857
+ this.ctx.ui.resetDisplay();
850
858
  }
851
859
 
852
860
  toggleThinkingBlockVisibility(): void {