@oh-my-pi/pi-coding-agent 15.9.3 → 15.9.67

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 (142) hide show
  1. package/CHANGELOG.md +74 -1
  2. package/dist/types/cli/classify-install-target.d.ts +5 -1
  3. package/dist/types/config/keybindings.d.ts +4 -1
  4. package/dist/types/config/settings-schema.d.ts +24 -5
  5. package/dist/types/edit/file-snapshot-store.d.ts +1 -1
  6. package/dist/types/eval/__tests__/kernel-spawn.test.d.ts +1 -0
  7. package/dist/types/eval/backend.d.ts +6 -6
  8. package/dist/types/eval/bridge-timeout.d.ts +27 -0
  9. package/dist/types/eval/idle-timeout.d.ts +16 -14
  10. package/dist/types/eval/js/executor.d.ts +3 -3
  11. package/dist/types/eval/py/executor.d.ts +2 -2
  12. package/dist/types/eval/py/spawn-options.d.ts +58 -0
  13. package/dist/types/modes/components/assistant-message.d.ts +16 -0
  14. package/dist/types/modes/components/copy-selector.d.ts +22 -0
  15. package/dist/types/modes/components/custom-editor.d.ts +3 -1
  16. package/dist/types/modes/components/error-banner.d.ts +11 -0
  17. package/dist/types/modes/components/model-selector.d.ts +1 -0
  18. package/dist/types/modes/components/tool-execution.d.ts +15 -0
  19. package/dist/types/modes/components/transcript-container.d.ts +1 -0
  20. package/dist/types/modes/components/user-message.d.ts +1 -1
  21. package/dist/types/modes/controllers/command-controller.d.ts +0 -1
  22. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  23. package/dist/types/modes/image-references.d.ts +17 -0
  24. package/dist/types/modes/interactive-mode.d.ts +8 -1
  25. package/dist/types/modes/types.d.ts +8 -1
  26. package/dist/types/modes/utils/copy-targets.d.ts +53 -0
  27. package/dist/types/modes/utils/ui-helpers.d.ts +1 -0
  28. package/dist/types/session/blob-store.d.ts +12 -11
  29. package/dist/types/session/session-manager.d.ts +5 -3
  30. package/dist/types/system-prompt.d.ts +2 -0
  31. package/dist/types/tiny/title-client.d.ts +16 -1
  32. package/dist/types/tool-discovery/mode.d.ts +8 -0
  33. package/dist/types/tools/archive-reader.d.ts +5 -1
  34. package/dist/types/tools/eval-render.d.ts +8 -0
  35. package/dist/types/tools/render-utils.d.ts +25 -0
  36. package/dist/types/tui/code-cell.d.ts +6 -0
  37. package/dist/types/tui/hyperlink.d.ts +12 -0
  38. package/dist/types/tui/output-block.d.ts +11 -0
  39. package/dist/types/web/search/render.d.ts +1 -2
  40. package/package.json +9 -9
  41. package/src/autoresearch/dashboard.ts +11 -21
  42. package/src/cli/classify-install-target.ts +31 -5
  43. package/src/cli/claude-trace-cli.ts +13 -1
  44. package/src/cli/plugin-cli.ts +45 -0
  45. package/src/cli/web-search-cli.ts +0 -1
  46. package/src/config/keybindings.ts +58 -1
  47. package/src/config/model-registry.ts +54 -4
  48. package/src/config/settings-schema.ts +25 -5
  49. package/src/debug/raw-sse.ts +18 -4
  50. package/src/edit/file-snapshot-store.ts +1 -1
  51. package/src/edit/index.ts +1 -1
  52. package/src/edit/renderer.ts +7 -7
  53. package/src/edit/streaming.ts +1 -1
  54. package/src/eval/__tests__/agent-bridge.test.ts +100 -27
  55. package/src/eval/__tests__/bridge-timeout.test.ts +64 -0
  56. package/src/eval/__tests__/idle-timeout.test.ts +26 -12
  57. package/src/eval/__tests__/kernel-spawn.test.ts +103 -0
  58. package/src/eval/__tests__/llm-bridge.test.ts +10 -10
  59. package/src/eval/__tests__/shared-executors.test.ts +2 -2
  60. package/src/eval/agent-bridge.ts +4 -5
  61. package/src/eval/backend.ts +6 -6
  62. package/src/eval/bridge-timeout.ts +44 -0
  63. package/src/eval/idle-timeout.ts +33 -15
  64. package/src/eval/js/executor.ts +10 -10
  65. package/src/eval/llm-bridge.ts +4 -5
  66. package/src/eval/py/executor.ts +6 -6
  67. package/src/eval/py/kernel.ts +11 -1
  68. package/src/eval/py/spawn-options.ts +126 -0
  69. package/src/eval/py/tool-bridge.ts +43 -5
  70. package/src/export/ttsr.ts +9 -0
  71. package/src/extensibility/custom-commands/bundled/ci-green/index.ts +31 -2
  72. package/src/extensibility/extensions/runner.ts +2 -0
  73. package/src/internal-urls/docs-index.generated.ts +9 -8
  74. package/src/lsp/client.ts +80 -2
  75. package/src/lsp/index.ts +38 -4
  76. package/src/lsp/render.ts +3 -3
  77. package/src/main.ts +8 -2
  78. package/src/modes/components/agent-dashboard.ts +13 -4
  79. package/src/modes/components/assistant-message.ts +44 -1
  80. package/src/modes/components/copy-selector.ts +249 -0
  81. package/src/modes/components/custom-editor.ts +14 -2
  82. package/src/modes/components/error-banner.ts +33 -0
  83. package/src/modes/components/extensions/extension-list.ts +17 -8
  84. package/src/modes/components/history-search.ts +19 -11
  85. package/src/modes/components/model-selector.ts +125 -29
  86. package/src/modes/components/oauth-selector.ts +28 -12
  87. package/src/modes/components/session-observer-overlay.ts +13 -15
  88. package/src/modes/components/session-selector.ts +24 -13
  89. package/src/modes/components/tool-execution.ts +71 -13
  90. package/src/modes/components/transcript-container.ts +93 -32
  91. package/src/modes/components/tree-selector.ts +19 -7
  92. package/src/modes/components/user-message-selector.ts +25 -14
  93. package/src/modes/components/user-message.ts +9 -2
  94. package/src/modes/controllers/command-controller.ts +0 -116
  95. package/src/modes/controllers/event-controller.ts +67 -12
  96. package/src/modes/controllers/input-controller.ts +33 -1
  97. package/src/modes/controllers/selector-controller.ts +38 -1
  98. package/src/modes/image-references.ts +111 -0
  99. package/src/modes/interactive-mode.ts +52 -17
  100. package/src/modes/theme/theme.ts +46 -10
  101. package/src/modes/types.ts +11 -2
  102. package/src/modes/utils/copy-targets.ts +254 -0
  103. package/src/modes/utils/ui-helpers.ts +23 -2
  104. package/src/prompts/ci-green-request.md +5 -3
  105. package/src/prompts/system/project-prompt.md +1 -0
  106. package/src/prompts/tools/ast-edit.md +1 -1
  107. package/src/prompts/tools/ast-grep.md +1 -1
  108. package/src/prompts/tools/read.md +1 -1
  109. package/src/prompts/tools/search.md +1 -1
  110. package/src/sdk.ts +17 -9
  111. package/src/session/agent-session.ts +43 -14
  112. package/src/session/blob-store.ts +96 -9
  113. package/src/session/session-manager.ts +19 -10
  114. package/src/slash-commands/builtin-registry.ts +3 -11
  115. package/src/system-prompt.ts +4 -0
  116. package/src/task/render.ts +38 -11
  117. package/src/tiny/title-client.ts +7 -1
  118. package/src/tool-discovery/mode.ts +24 -0
  119. package/src/tools/archive-reader.ts +339 -31
  120. package/src/tools/bash.ts +18 -8
  121. package/src/tools/browser/render.ts +5 -4
  122. package/src/tools/debug.ts +3 -3
  123. package/src/tools/eval-render.ts +24 -9
  124. package/src/tools/eval.ts +14 -19
  125. package/src/tools/fetch.ts +34 -14
  126. package/src/tools/gh.ts +65 -11
  127. package/src/tools/index.ts +6 -8
  128. package/src/tools/read.ts +65 -19
  129. package/src/tools/render-utils.ts +46 -0
  130. package/src/tools/search-tool-bm25.ts +4 -6
  131. package/src/tools/search.ts +60 -11
  132. package/src/tools/ssh.ts +21 -8
  133. package/src/tools/write.ts +17 -8
  134. package/src/tui/code-cell.ts +19 -4
  135. package/src/tui/hyperlink.ts +42 -7
  136. package/src/tui/output-block.ts +14 -0
  137. package/src/web/search/index.ts +2 -2
  138. package/src/web/search/render.ts +23 -55
  139. package/dist/types/eval/heartbeat.d.ts +0 -45
  140. package/src/eval/__tests__/heartbeat.test.ts +0 -84
  141. package/src/eval/heartbeat.ts +0 -74
  142. /package/dist/types/eval/__tests__/{heartbeat.test.d.ts → bridge-timeout.test.d.ts} +0 -0
@@ -33,7 +33,7 @@ import {
33
33
  import { formatExpandHint, replaceTabs, resolveImageOptions, truncateToWidth } from "../../tools/render-utils";
34
34
  import { 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,12 @@ 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
+
48
54
  /**
49
55
  * Drop trailing removal/hunk-header lines that appear in a streaming diff
50
56
  * before the matching `+added` lines have arrived. Without this, a partial
@@ -107,7 +113,7 @@ function rawTextInputFromPartialJson(partialJson: unknown): string | undefined {
107
113
  // Function-tool arguments stream as JSON. Custom/free-form tools stream raw
108
114
  // text in the same transport field; only the raw form is a valid fallback for
109
115
  // the conventional `input` parameter.
110
- if (first === "{" || first === "[" || first === '"') return undefined;
116
+ if (first === "{" || first === '"') return undefined;
111
117
  return partialJson;
112
118
  }
113
119
 
@@ -197,6 +203,11 @@ export class ToolExecutionComponent extends Container {
197
203
  #todoStrikeInterval?: NodeJS.Timeout;
198
204
  // Track if args are still being streamed (for edit/write spinner)
199
205
  #argsComplete = false;
206
+ // Sealed once the tool reaches a terminal state (result delivered, or the
207
+ // turn abandoned it without one). Drives `isTranscriptBlockFinalized`: until
208
+ // sealed the block stays in the transcript's repaintable live region so a
209
+ // late result still repaints instead of stranding the streaming preview.
210
+ #sealed = false;
200
211
  #renderState: {
201
212
  spinnerFrame?: number;
202
213
  expanded: boolean;
@@ -448,6 +459,13 @@ export class ToolExecutionComponent extends Container {
448
459
  } else if (!needsSpinner && this.#spinnerInterval) {
449
460
  clearInterval(this.#spinnerInterval);
450
461
  this.#spinnerInterval = undefined;
462
+ // Clear the last drawn frame so a non-live renderCall (e.g. a write whose
463
+ // args just completed) stops showing a frozen spinner glyph. Skip when a
464
+ // todo strike owns the frame — it sets its own value right after this.
465
+ if (!this.#todoStrikeInterval) {
466
+ this.#spinnerFrame = undefined;
467
+ this.#renderState.spinnerFrame = undefined;
468
+ }
451
469
  }
452
470
  }
453
471
 
@@ -488,6 +506,37 @@ export class ToolExecutionComponent extends Container {
488
506
  }
489
507
  }
490
508
 
509
+ /**
510
+ * Whether this block has reached a terminal state for transcript freezing.
511
+ * Reports `false` while it can still visually change so the
512
+ * {@link TranscriptContainer} keeps it inside the repaintable live region:
513
+ * a foreground tool awaiting its result, or one streaming partial output.
514
+ * A final (non-partial) result, a background-async tool the agent has moved
515
+ * past, or an explicit {@link seal} flips it to `true`.
516
+ */
517
+ isTranscriptBlockFinalized(): boolean {
518
+ if (this.#sealed) return true;
519
+ if (this.#result === undefined) return false;
520
+ if (!this.#isPartial) return true;
521
+ // Partial result: a background async tool is accepted to freeze (the agent
522
+ // continues while it runs and would otherwise pin an unbounded live region);
523
+ // a foreground tool streaming partial output stays live until it finishes.
524
+ return (this.#result.details as { async?: { state?: string } } | undefined)?.async?.state === "running";
525
+ }
526
+
527
+ /**
528
+ * Mark the tool terminal even though no result arrived (the turn aborted or
529
+ * abandoned it) and stop animating, so it can freeze and stops pinning the
530
+ * transcript live region.
531
+ */
532
+ seal(): void {
533
+ if (this.#sealed) return;
534
+ this.#sealed = true;
535
+ this.stopAnimation();
536
+ this.#updateDisplay();
537
+ this.#ui.requestRender();
538
+ }
539
+
491
540
  /**
492
541
  * Stop spinner animation and cleanup resources.
493
542
  */
@@ -496,6 +545,7 @@ export class ToolExecutionComponent extends Container {
496
545
  clearInterval(this.#spinnerInterval);
497
546
  this.#spinnerInterval = undefined;
498
547
  this.#spinnerFrame = undefined;
548
+ this.#renderState.spinnerFrame = undefined;
499
549
  }
500
550
  this.#stopTodoStrikeAnimation();
501
551
  this.#editDiffAbort?.abort();
@@ -538,6 +588,7 @@ export class ToolExecutionComponent extends Container {
538
588
  const inline = Boolean((tool as { inline?: boolean }).inline);
539
589
  this.#contentBox.setBgFn(inline ? undefined : bgFn);
540
590
  this.#contentBox.clear();
591
+ let contentBoxHasFramedBlock = false;
541
592
  // Mirror the built-in renderer branch so custom renderers (notably the
542
593
  // task tool, whose live instance routes through here) receive the same
543
594
  // render context — e.g. the `hasResult` flag that suppresses the task
@@ -550,16 +601,16 @@ export class ToolExecutionComponent extends Container {
550
601
  try {
551
602
  const callComponent = tool.renderCall(this.#getCallArgsForRender(), this.#renderState, theme);
552
603
  if (callComponent) {
553
- this.#contentBox.addChild(ensureInvalidate(callComponent));
604
+ contentBoxHasFramedBlock = addBoxChild(this.#contentBox, callComponent) || contentBoxHasFramedBlock;
554
605
  }
555
606
  } catch (err) {
556
607
  logger.warn("Tool renderer failed", { tool: this.#toolName, error: String(err) });
557
608
  // Fall back to default on error
558
- this.#contentBox.addChild(new Text(theme.fg("toolTitle", theme.bold(this.#toolLabel)), 0, 0));
609
+ addBoxChild(this.#contentBox, new Text(theme.fg("toolTitle", theme.bold(this.#toolLabel)), 0, 0));
559
610
  }
560
611
  } else {
561
612
  // No custom renderCall, show tool name
562
- this.#contentBox.addChild(new Text(theme.fg("toolTitle", theme.bold(this.#toolLabel)), 0, 0));
613
+ addBoxChild(this.#contentBox, new Text(theme.fg("toolTitle", theme.bold(this.#toolLabel)), 0, 0));
563
614
  }
564
615
 
565
616
  // Render result component if we have a result
@@ -582,23 +633,24 @@ export class ToolExecutionComponent extends Container {
582
633
  this.#args,
583
634
  );
584
635
  if (resultComponent) {
585
- this.#contentBox.addChild(ensureInvalidate(resultComponent));
636
+ contentBoxHasFramedBlock = addBoxChild(this.#contentBox, resultComponent) || contentBoxHasFramedBlock;
586
637
  }
587
638
  } catch (err) {
588
639
  logger.warn("Tool renderer failed", { tool: this.#toolName, error: String(err) });
589
640
  // Fall back to showing raw output on error
590
641
  const output = this.#getTextOutput();
591
642
  if (output) {
592
- this.#contentBox.addChild(new Text(theme.fg("toolOutput", replaceTabs(output)), 0, 0));
643
+ addBoxChild(this.#contentBox, new Text(theme.fg("toolOutput", replaceTabs(output)), 0, 0));
593
644
  }
594
645
  }
595
646
  } else if (this.#result) {
596
647
  // Has result but no custom renderResult
597
648
  const output = this.#getTextOutput();
598
649
  if (output) {
599
- this.#contentBox.addChild(new Text(theme.fg("toolOutput", replaceTabs(output)), 0, 0));
650
+ addBoxChild(this.#contentBox, new Text(theme.fg("toolOutput", replaceTabs(output)), 0, 0));
600
651
  }
601
652
  }
653
+ this.#contentBox.setPaddingX(contentBoxHasFramedBlock ? 0 : 1);
602
654
  } else if (this.#toolName in toolRenderers) {
603
655
  // Built-in tools with renderers
604
656
  const renderer = toolRenderers[this.#toolName];
@@ -617,6 +669,7 @@ export class ToolExecutionComponent extends Container {
617
669
  // Multi-file: render each file as its own Box (identical to separate tool calls)
618
670
  this.#contentBox.setBgFn(undefined);
619
671
  this.#contentBox.clear();
672
+ this.#contentBox.setPaddingX(1);
620
673
 
621
674
  const renderContext = this.#buildRenderContext();
622
675
  this.#renderState.renderContext = renderContext;
@@ -639,7 +692,8 @@ export class ToolExecutionComponent extends Container {
639
692
  theme,
640
693
  );
641
694
  if (resultComponent) {
642
- fileBox.addChild(ensureInvalidate(resultComponent));
695
+ const fileBoxHasFramedBlock = addBoxChild(fileBox, resultComponent);
696
+ fileBox.setPaddingX(fileBoxHasFramedBlock ? 0 : 1);
643
697
  }
644
698
  } catch (err) {
645
699
  logger.warn("Tool renderer failed", { tool: this.#toolName, error: String(err) });
@@ -675,6 +729,7 @@ export class ToolExecutionComponent extends Container {
675
729
  // Inline renderers skip background styling
676
730
  this.#contentBox.setBgFn(renderer.inline ? undefined : bgFn);
677
731
  this.#contentBox.clear();
732
+ let contentBoxHasFramedBlock = false;
678
733
 
679
734
  const renderContext = this.#buildRenderContext();
680
735
  this.#renderState.renderContext = renderContext;
@@ -685,12 +740,13 @@ export class ToolExecutionComponent extends Container {
685
740
  try {
686
741
  const callComponent = renderer.renderCall(this.#getCallArgsForRender(), this.#renderState, theme);
687
742
  if (callComponent) {
688
- this.#contentBox.addChild(ensureInvalidate(callComponent));
743
+ contentBoxHasFramedBlock =
744
+ addBoxChild(this.#contentBox, callComponent) || contentBoxHasFramedBlock;
689
745
  }
690
746
  } catch (err) {
691
747
  logger.warn("Tool renderer failed", { tool: this.#toolName, error: String(err) });
692
748
  // Fall back to default on error
693
- this.#contentBox.addChild(new Text(theme.fg("toolTitle", theme.bold(this.#toolLabel)), 0, 0));
749
+ addBoxChild(this.#contentBox, new Text(theme.fg("toolTitle", theme.bold(this.#toolLabel)), 0, 0));
694
750
  }
695
751
  }
696
752
 
@@ -708,17 +764,19 @@ export class ToolExecutionComponent extends Container {
708
764
  this.#getCallArgsForRender(),
709
765
  );
710
766
  if (resultComponent) {
711
- this.#contentBox.addChild(ensureInvalidate(resultComponent));
767
+ contentBoxHasFramedBlock =
768
+ addBoxChild(this.#contentBox, resultComponent) || contentBoxHasFramedBlock;
712
769
  }
713
770
  } catch (err) {
714
771
  logger.warn("Tool renderer failed", { tool: this.#toolName, error: String(err) });
715
772
  // Fall back to showing raw output on error
716
773
  const output = this.#getTextOutput();
717
774
  if (output) {
718
- this.#contentBox.addChild(new Text(theme.fg("toolOutput", replaceTabs(output)), 0, 0));
775
+ addBoxChild(this.#contentBox, new Text(theme.fg("toolOutput", replaceTabs(output)), 0, 0));
719
776
  }
720
777
  }
721
778
  }
779
+ this.#contentBox.setPaddingX(contentBoxHasFramedBlock ? 0 : 1);
722
780
  }
723
781
  } else {
724
782
  // Other built-in tools: use Text directly with caching
@@ -12,6 +12,33 @@ interface SnapshotCarrier {
12
12
  [kSnapshot]?: FrozenRender;
13
13
  }
14
14
 
15
+ /**
16
+ * A transcript block that is still mutating (a foreground tool awaiting its
17
+ * result, an assistant message mid-stream) reports `false` so the container
18
+ * keeps it inside the live (repaintable) region instead of freezing it. Blocks
19
+ * without the method are treated as finalized — the default, stable behavior.
20
+ *
21
+ * `isTranscriptBlockAppendOnly` marks a still-live block whose rendered rows
22
+ * only grow at the bottom and never re-layout (a streaming assistant reply).
23
+ * Such a block's scrolled-off head is safe to commit to native scrollback even
24
+ * while live; blocks that omit it (tool previews that collapse to a compact
25
+ * result) keep their mutable rows deferred. Default is `false`.
26
+ */
27
+ interface FinalizableBlock {
28
+ isTranscriptBlockFinalized?(): boolean;
29
+ isTranscriptBlockAppendOnly?(): boolean;
30
+ }
31
+
32
+ function isBlockFinalized(child: Component): boolean {
33
+ const fn = (child as Component & FinalizableBlock).isTranscriptBlockFinalized;
34
+ return fn ? fn.call(child) : true;
35
+ }
36
+
37
+ function isBlockAppendOnly(child: Component): boolean {
38
+ const fn = (child as Component & FinalizableBlock).isTranscriptBlockAppendOnly;
39
+ return fn ? fn.call(child) : false;
40
+ }
41
+
15
42
  /**
16
43
  * Transcript container that freezes the rendered output of every block except
17
44
  * the bottom-most (live) one on terminals where committed native scrollback is
@@ -38,15 +65,24 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
38
65
  // Bumped to invalidate every block's snapshot at once; a snapshot is only
39
66
  // honored when its stored generation still matches.
40
67
  #generation = 0;
41
- // The block that was bottom-most (live) on the previous render. When the live
42
- // position moves past it, its snapshot was last refreshed mid-stream and may
43
- // predate content that finalized in the same coalesced frame that appended the
44
- // block now below it so it must recompute once on the live→frozen transition.
45
- #prevLiveChild: Component | undefined;
46
- // Local line index where the current bottom-most block begins in the most
47
- // recent render. TUI extends the native-scrollback pinned region from this
48
- // point through the live block and the root chrome rendered below it.
68
+ // Line index where the live (repaintable) region began on the previous
69
+ // render the start of the earliest still-mutating block, or the bottom
70
+ // block when everything is finalized. A block leaves the live region only
71
+ // once it has finalized AND a finalized block sits below it; the frame it
72
+ // crosses out is recomputed so it freezes at its true final content, not the
73
+ // mid-stream snapshot it last rendered while live (TUI render coalescing can
74
+ // advance a block's content in the very frame it stops being live).
75
+ #prevLiveStartIndex = 0;
76
+ // Local line index where the current live region begins in the most recent
77
+ // render. TUI extends the native-scrollback pinned region from this point
78
+ // through the live blocks and the root chrome rendered below them.
49
79
  #nativeScrollbackLiveRegionStart: number | undefined;
80
+ // Local line index up to which the leading run of live blocks is append-only
81
+ // (a streaming assistant reply): everything in [liveRegionStart,
82
+ // commitSafeEnd) only grows at the bottom and never re-layouts, so its
83
+ // scrolled-off head is safe to commit to native scrollback. `undefined` when
84
+ // the first live block is volatile (a tool preview).
85
+ #nativeScrollbackCommitSafeEnd: number | undefined;
50
86
 
51
87
  override invalidate(): void {
52
88
  // A theme/global invalidation forces a full recompute on the rebuild that
@@ -64,6 +100,10 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
64
100
  return this.#nativeScrollbackLiveRegionStart;
65
101
  }
66
102
 
103
+ getNativeScrollbackCommitSafeEnd(): number | undefined {
104
+ return this.#nativeScrollbackCommitSafeEnd;
105
+ }
106
+
67
107
  /**
68
108
  * Retire all frozen snapshots so the next render reflects each block's current
69
109
  * state. Call at reconciliation checkpoints (prompt submit) where the whole
@@ -77,41 +117,62 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
77
117
  override render(width: number): string[] {
78
118
  width = Math.max(1, width);
79
119
  this.#nativeScrollbackLiveRegionStart = undefined;
120
+ this.#nativeScrollbackCommitSafeEnd = undefined;
80
121
  if (!TERMINAL.eagerEraseScrollbackRisk) return super.render(width);
81
122
 
123
+ const count = this.children.length;
124
+ // The live region spans from the earliest still-mutating block through the
125
+ // bottom. A block that has not finalized must stay repaintable: out-of-band
126
+ // inserts (TTSR/todo cards) can append a finalized block *below* a tool that
127
+ // is still awaiting its result, and freezing the tool there would strand its
128
+ // committed rows on the mid-stream preview the late result never reaches.
129
+ let liveStartIndex = count - 1;
130
+ for (let i = 0; i < count; i++) {
131
+ if (!isBlockFinalized(this.children[i]!)) {
132
+ liveStartIndex = i;
133
+ break;
134
+ }
135
+ }
136
+ // Blocks at [prevLiveStart, liveStart) just crossed out of the live region;
137
+ // recompute them so they freeze at their final content. Everything below
138
+ // the lower of the two cutoffs was already frozen last frame and replays.
139
+ const replayCutoff = Math.min(liveStartIndex, this.#prevLiveStartIndex);
140
+ this.#prevLiveStartIndex = liveStartIndex;
141
+
82
142
  const lines: string[] = [];
83
- const liveIndex = this.children.length - 1;
84
- const liveChild = this.children[liveIndex];
85
- const prevLiveChild = this.#prevLiveChild;
86
- this.#prevLiveChild = liveChild;
87
- for (let i = 0; i < this.children.length; i++) {
143
+ // Tracks whether we are still inside the leading run of append-only live
144
+ // blocks. The first non-append-only live block (or a finalized block below
145
+ // the live region's start, which cannot happen for a leading run) closes it.
146
+ let commitSafeOpen = true;
147
+ for (let i = 0; i < count; i++) {
88
148
  const child = this.children[i]! as Component & SnapshotCarrier;
89
- if (child === liveChild) {
90
- this.#nativeScrollbackLiveRegionStart = lines.length;
149
+ if (i >= liveStartIndex) {
150
+ if (i === liveStartIndex) this.#nativeScrollbackLiveRegionStart = lines.length;
91
151
  } else {
92
152
  const snapshot = child[kSnapshot];
93
- // Replay the block's last render from while it was live. A stale
94
- // generation (post-thaw) or width mismatch (resize in flight, an
95
- // explicit rebuild that reconciles history anyway) recomputes instead.
96
- // The block that was live on the previous render is also recomputed
97
- // here: TUI render coalescing can advance its content (final streamed
98
- // tokens) in the very frame that appends the block now below it, so its
99
- // cached snapshot predates that final content. Recomputing on the
100
- // transition seals the block at its true final state, not a mid-stream one.
101
- if (
102
- child !== prevLiveChild &&
103
- snapshot &&
104
- snapshot.generation === this.#generation &&
105
- snapshot.width === width
106
- ) {
153
+ // Replay a frozen block's last live render. A stale generation
154
+ // (post-thaw) or width mismatch (resize, explicit rebuild) recomputes
155
+ // instead, as does a block that was still live last frame (i >= cutoff).
156
+ if (i < replayCutoff && snapshot && snapshot.generation === this.#generation && snapshot.width === width) {
107
157
  lines.push(...snapshot.lines);
108
158
  continue;
109
159
  }
110
160
  }
111
161
  const rendered = child.render(width);
112
- // Cache every block's latest render. While a block is live this keeps its
113
- // snapshot current; on the frame it stops being live the recompute above
114
- // refreshes it to the final state before it freezes.
162
+ // Extend the commit-safe boundary through each leading append-only live
163
+ // block. `lines.length` here is this block's start offset; the boundary
164
+ // runs to the end of its rendered rows. The first volatile live block
165
+ // closes the run so its mutable rows stay deferred.
166
+ if (i >= liveStartIndex && commitSafeOpen) {
167
+ if (isBlockAppendOnly(child)) {
168
+ this.#nativeScrollbackCommitSafeEnd = lines.length + rendered.length;
169
+ } else {
170
+ commitSafeOpen = false;
171
+ }
172
+ }
173
+ // Cache every block's latest render. While a block is in the live region
174
+ // this keeps its snapshot current; on the frame it crosses out, the
175
+ // recompute above refreshes it to the final state before it freezes.
115
176
  child[kSnapshot] = { width, lines: rendered, generation: this.#generation };
116
177
  lines.push(...rendered);
117
178
  }
@@ -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
 
@@ -1,5 +1,6 @@
1
1
  import { Container, Markdown, Spacer } from "@oh-my-pi/pi-tui";
2
2
  import { getMarkdownTheme, theme } from "../../modes/theme/theme";
3
+ import { imageReferenceHyperlink, renderImageReferences } from "../image-references";
3
4
  import { highlightMagicKeywords } from "../magic-keywords";
4
5
 
5
6
  // OSC 133 shell integration: marks prompt zones for terminal multiplexers
@@ -11,7 +12,7 @@ const OSC133_ZONE_FINAL = "\x1b]133;C\x07";
11
12
  * Component that renders a user message
12
13
  */
13
14
  export class UserMessageComponent extends Container {
14
- constructor(text: string, synthetic = false) {
15
+ constructor(text: string, synthetic = false, imageLinks?: readonly (string | undefined)[]) {
15
16
  super();
16
17
  const bgColor = (value: string) => theme.bg("userMessageBg", value);
17
18
  // Paint the magic keywords ("ultrathink"/"orchestrate"/"workflow") inside the rendered
@@ -20,9 +21,15 @@ export class UserMessageComponent extends Container {
20
21
  // `highlightMagicKeywords` additionally restores the bubble's own foreground after each
21
22
  // painted keyword so the gradient never bleeds into the rest of the line.
22
23
  const keywordReset = theme.getFgAnsi("userMessageText") || "\x1b[39m";
23
- const color = synthetic
24
+ const baseText = synthetic
24
25
  ? (value: string) => theme.fg("dim", value)
25
26
  : (value: string) => theme.fg("userMessageText", highlightMagicKeywords(value, keywordReset));
27
+ const imageLabel = (value: string) => theme.fg("accent", `\x1b[1m\x1b[4m${value}\x1b[24m\x1b[22m`);
28
+ const color = (value: string) =>
29
+ renderImageReferences(value, {
30
+ renderText: baseText,
31
+ renderReference: (label, index) => imageReferenceHyperlink(label, index, imageLinks, imageLabel),
32
+ });
26
33
  this.addChild(new Spacer(1));
27
34
  this.addChild(
28
35
  new Markdown(text, 1, 1, getMarkdownTheme(), {