@oh-my-pi/pi-coding-agent 16.2.2 → 16.2.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 (150) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/dist/cli.js +3624 -3568
  3. package/dist/types/advisor/__tests__/config.test.d.ts +1 -0
  4. package/dist/types/advisor/advise-tool.d.ts +8 -4
  5. package/dist/types/advisor/config.d.ts +88 -0
  6. package/dist/types/advisor/index.d.ts +1 -0
  7. package/dist/types/advisor/transcript-recorder.d.ts +13 -2
  8. package/dist/types/advisor/watchdog.d.ts +20 -0
  9. package/dist/types/collab/guest.d.ts +29 -0
  10. package/dist/types/config/settings-schema.d.ts +81 -0
  11. package/dist/types/debug/log-viewer.d.ts +1 -0
  12. package/dist/types/debug/raw-sse.d.ts +1 -0
  13. package/dist/types/edit/hashline/diff.d.ts +0 -11
  14. package/dist/types/extensibility/tool-event-input.d.ts +7 -0
  15. package/dist/types/extensibility/utils.d.ts +12 -0
  16. package/dist/types/mcp/transports/index.d.ts +1 -0
  17. package/dist/types/mcp/transports/sse.d.ts +20 -0
  18. package/dist/types/modes/components/advisor-config.d.ts +59 -0
  19. package/dist/types/modes/components/index.d.ts +1 -0
  20. package/dist/types/modes/components/model-selector.d.ts +9 -1
  21. package/dist/types/modes/components/settings-selector.d.ts +1 -0
  22. package/dist/types/modes/components/status-line/component.d.ts +30 -1
  23. package/dist/types/modes/components/status-line/types.d.ts +13 -1
  24. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  25. package/dist/types/modes/interactive-mode.d.ts +10 -4
  26. package/dist/types/modes/skill-command.d.ts +32 -0
  27. package/dist/types/modes/types.d.ts +7 -2
  28. package/dist/types/session/agent-session.d.ts +58 -10
  29. package/dist/types/session/indexed-session-storage.d.ts +7 -1
  30. package/dist/types/session/messages.d.ts +26 -0
  31. package/dist/types/session/messages.test.d.ts +1 -0
  32. package/dist/types/session/session-entries.d.ts +31 -3
  33. package/dist/types/session/session-history-format.d.ts +6 -0
  34. package/dist/types/session/session-loader.d.ts +9 -1
  35. package/dist/types/session/session-manager.d.ts +6 -4
  36. package/dist/types/session/session-storage.d.ts +11 -0
  37. package/dist/types/session/session-title-slot.d.ts +19 -0
  38. package/dist/types/ssh/connection-manager.d.ts +47 -0
  39. package/dist/types/ssh/utils.d.ts +16 -0
  40. package/dist/types/task/executor.d.ts +3 -16
  41. package/dist/types/task/render.d.ts +0 -5
  42. package/dist/types/task/renderer.d.ts +13 -0
  43. package/dist/types/task/types.d.ts +16 -0
  44. package/dist/types/task/yield-assembly.d.ts +28 -0
  45. package/dist/types/tiny/text.d.ts +8 -0
  46. package/dist/types/tools/render-utils.d.ts +2 -0
  47. package/dist/types/tools/review.d.ts +6 -4
  48. package/dist/types/tools/ssh.d.ts +1 -1
  49. package/dist/types/tools/todo.d.ts +6 -0
  50. package/dist/types/tools/yield.d.ts +8 -3
  51. package/dist/types/utils/thinking-display.d.ts +4 -0
  52. package/package.json +12 -12
  53. package/src/advisor/__tests__/advisor.test.ts +242 -10
  54. package/src/advisor/__tests__/config.test.ts +173 -0
  55. package/src/advisor/advise-tool.ts +11 -6
  56. package/src/advisor/config.ts +256 -0
  57. package/src/advisor/index.ts +1 -0
  58. package/src/advisor/runtime.ts +12 -2
  59. package/src/advisor/transcript-recorder.ts +25 -2
  60. package/src/advisor/watchdog.ts +57 -31
  61. package/src/autoresearch/index.ts +7 -2
  62. package/src/cli/gc-cli.ts +17 -10
  63. package/src/collab/guest.ts +43 -7
  64. package/src/config/model-registry.ts +80 -18
  65. package/src/config/settings-schema.ts +77 -0
  66. package/src/debug/index.ts +32 -7
  67. package/src/debug/log-viewer.ts +111 -53
  68. package/src/debug/raw-sse.ts +68 -48
  69. package/src/discovery/codex.ts +13 -5
  70. package/src/edit/hashline/diff.ts +57 -4
  71. package/src/eval/js/shared/local-module-loader.ts +23 -1
  72. package/src/export/html/template.js +13 -7
  73. package/src/extensibility/extensions/loader.ts +5 -3
  74. package/src/extensibility/extensions/wrapper.ts +9 -3
  75. package/src/extensibility/hooks/loader.ts +3 -3
  76. package/src/extensibility/hooks/tool-wrapper.ts +13 -4
  77. package/src/extensibility/plugins/manager.ts +2 -1
  78. package/src/extensibility/tool-event-input.ts +23 -0
  79. package/src/extensibility/utils.ts +74 -0
  80. package/src/internal-urls/docs-index.generated.txt +1 -1
  81. package/src/mcp/client.ts +3 -1
  82. package/src/mcp/manager.ts +12 -5
  83. package/src/mcp/transports/index.ts +1 -0
  84. package/src/mcp/transports/sse.ts +377 -0
  85. package/src/memories/index.ts +15 -6
  86. package/src/modes/components/advisor-config.ts +555 -0
  87. package/src/modes/components/advisor-message.ts +9 -2
  88. package/src/modes/components/agent-hub.ts +9 -4
  89. package/src/modes/components/index.ts +2 -0
  90. package/src/modes/components/model-selector.ts +79 -48
  91. package/src/modes/components/settings-selector.ts +1 -0
  92. package/src/modes/components/status-line/component.ts +144 -5
  93. package/src/modes/components/status-line/segments.ts +46 -21
  94. package/src/modes/components/status-line/types.ts +13 -1
  95. package/src/modes/components/tool-execution.ts +47 -6
  96. package/src/modes/controllers/command-controller.ts +23 -2
  97. package/src/modes/controllers/event-controller.ts +106 -0
  98. package/src/modes/controllers/extension-ui-controller.ts +1 -1
  99. package/src/modes/controllers/input-controller.ts +61 -61
  100. package/src/modes/controllers/selector-controller.ts +100 -9
  101. package/src/modes/interactive-mode.ts +65 -5
  102. package/src/modes/skill-command.ts +116 -0
  103. package/src/modes/types.ts +7 -2
  104. package/src/modes/utils/ui-helpers.ts +41 -23
  105. package/src/prompts/agents/reviewer.md +11 -10
  106. package/src/prompts/review-custom-request.md +1 -2
  107. package/src/prompts/review-request.md +1 -2
  108. package/src/prompts/system/interrupted-thinking.md +7 -0
  109. package/src/prompts/system/recap-user.md +9 -0
  110. package/src/prompts/system/subagent-system-prompt.md +8 -5
  111. package/src/prompts/system/subagent-yield-reminder.md +6 -5
  112. package/src/prompts/system/system-prompt.md +0 -1
  113. package/src/prompts/tools/irc.md +2 -2
  114. package/src/prompts/tools/read.md +2 -2
  115. package/src/sdk.ts +28 -24
  116. package/src/session/agent-session.ts +867 -428
  117. package/src/session/indexed-session-storage.ts +86 -13
  118. package/src/session/messages.test.ts +125 -0
  119. package/src/session/messages.ts +172 -9
  120. package/src/session/redis-session-storage.ts +49 -2
  121. package/src/session/session-entries.ts +39 -2
  122. package/src/session/session-history-format.ts +29 -2
  123. package/src/session/session-listing.ts +54 -24
  124. package/src/session/session-loader.ts +66 -3
  125. package/src/session/session-manager.ts +113 -19
  126. package/src/session/session-persistence.ts +95 -1
  127. package/src/session/session-storage.ts +36 -0
  128. package/src/session/session-title-slot.ts +141 -0
  129. package/src/session/sql-session-storage.ts +71 -11
  130. package/src/slash-commands/builtin-registry.ts +16 -3
  131. package/src/ssh/__tests__/connection-manager-args.test.ts +123 -1
  132. package/src/ssh/__tests__/file-transfer-posix-guard.test.ts +55 -18
  133. package/src/ssh/connection-manager.ts +139 -12
  134. package/src/ssh/file-transfer.ts +23 -18
  135. package/src/ssh/ssh-executor.ts +2 -13
  136. package/src/ssh/utils.ts +19 -0
  137. package/src/task/executor.ts +21 -23
  138. package/src/task/render.ts +162 -20
  139. package/src/task/renderer.ts +14 -0
  140. package/src/task/types.ts +17 -0
  141. package/src/task/yield-assembly.ts +207 -0
  142. package/src/tiny/text.ts +23 -0
  143. package/src/tools/ask.ts +55 -4
  144. package/src/tools/render-utils.ts +2 -0
  145. package/src/tools/renderers.ts +8 -2
  146. package/src/tools/review.ts +17 -7
  147. package/src/tools/ssh.ts +8 -4
  148. package/src/tools/todo.ts +17 -1
  149. package/src/tools/yield.ts +140 -31
  150. package/src/utils/thinking-display.ts +15 -0
@@ -3,11 +3,13 @@ import {
3
3
  extractPrintableText,
4
4
  matchesKey,
5
5
  padding,
6
+ parseSgrMouse,
6
7
  replaceTabs,
7
8
  truncateToWidth,
8
9
  visibleWidth,
9
10
  } from "@oh-my-pi/pi-tui";
10
11
  import { sanitizeText } from "@oh-my-pi/pi-utils";
12
+ import { bottomBorder, divider, row, topBorder } from "../modes/components/overlay-box";
11
13
  import { theme } from "../modes/theme/theme";
12
14
  import { copyToClipboard } from "../utils/clipboard";
13
15
  import {
@@ -23,6 +25,8 @@ export const LOAD_OLDER_LABEL = "### MOVE UP TO LOAD MORE...";
23
25
 
24
26
  const INITIAL_LOG_CHUNK = 50;
25
27
  const LOAD_OLDER_CHUNK = 50;
28
+ const MIN_LOG_VIEWER_WIDTH = 48;
29
+ const LOG_VIEWER_CHROME_LINES = 8;
26
30
 
27
31
  type LogEntry = {
28
32
  rawLine: string;
@@ -184,6 +188,31 @@ export class DebugLogViewerModel {
184
188
  }
185
189
  }
186
190
 
191
+ moveCursorToRow(rowIndex: number, extendSelection: boolean): boolean {
192
+ const selectableIndex = this.#selectableRowIndices.indexOf(rowIndex);
193
+ if (selectableIndex < 0) {
194
+ return false;
195
+ }
196
+
197
+ if (extendSelection && this.#selectionAnchorSelectableIndex === undefined) {
198
+ const row = this.#getCursorRow();
199
+ if (row?.kind === "log") {
200
+ this.#selectionAnchorSelectableIndex = this.#cursorSelectableIndex;
201
+ }
202
+ }
203
+
204
+ this.#cursorSelectableIndex = selectableIndex;
205
+
206
+ if (!extendSelection) {
207
+ this.#selectionAnchorSelectableIndex = undefined;
208
+ }
209
+
210
+ if (this.#getCursorRow()?.kind !== "log" && !extendSelection) {
211
+ this.#selectionAnchorSelectableIndex = undefined;
212
+ }
213
+ return true;
214
+ }
215
+
187
216
  getSelectedLogIndices(): number[] {
188
217
  if (this.#selectableRowIndices.length === 0) {
189
218
  return [];
@@ -484,6 +513,9 @@ export class DebugLogViewerComponent implements Component {
484
513
  #scrollRowOffset = 0;
485
514
  #statusMessage: string | undefined;
486
515
  #loadingOlder = false;
516
+ #bodyRowStart = 0;
517
+ #bodyRowCount = 0;
518
+ #bodyLineToRowIndex: Array<number | undefined> = [];
487
519
 
488
520
  constructor(options: DebugLogViewerComponentOptions) {
489
521
  this.#logSource = options.logSource;
@@ -501,6 +533,10 @@ export class DebugLogViewerComponent implements Component {
501
533
  }
502
534
 
503
535
  handleInput(keyData: string): void {
536
+ if (keyData.startsWith("\x1b[<") && this.#handleMouse(keyData)) {
537
+ return;
538
+ }
539
+
504
540
  if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc")) {
505
541
  this.#onExit();
506
542
  return;
@@ -598,59 +634,99 @@ export class DebugLogViewerComponent implements Component {
598
634
  }
599
635
  }
600
636
 
637
+ #handleMouse(keyData: string): boolean {
638
+ const event = parseSgrMouse(keyData);
639
+ if (!event) return false;
640
+
641
+ const overBody = event.row >= this.#bodyRowStart && event.row < this.#bodyRowStart + this.#bodyRowCount;
642
+ if (event.wheel !== null && overBody) {
643
+ this.#statusMessage = undefined;
644
+ const maxOffset = Math.max(0, this.#model.rows.length - this.#bodyHeight());
645
+ this.#scrollRowOffset = Math.max(0, Math.min(maxOffset, this.#scrollRowOffset + event.wheel * 3));
646
+ this.#onUpdate?.();
647
+ return true;
648
+ }
649
+
650
+ if (!event.leftClick || !overBody) return false;
651
+ const rowIndex = this.#bodyLineToRowIndex[event.row - this.#bodyRowStart];
652
+ if (rowIndex === undefined) return false;
653
+
654
+ const target = this.#model.rows[rowIndex];
655
+ if (!target || target.kind === "warning") return false;
656
+ this.#statusMessage = undefined;
657
+ this.#model.moveCursorToRow(rowIndex, false);
658
+ if (target.kind === "load-older") {
659
+ void this.#handleLoadOlder();
660
+ return true;
661
+ }
662
+
663
+ if (this.#model.isExpanded(target.logIndex)) {
664
+ this.#model.collapseSelected();
665
+ } else {
666
+ this.#model.expandSelected();
667
+ }
668
+ this.#ensureCursorVisible();
669
+ this.#onUpdate?.();
670
+ return true;
671
+ }
672
+
601
673
  invalidate(): void {
602
674
  // no cached child state
603
675
  }
604
676
 
605
677
  render(width: number): readonly string[] {
606
- this.#lastRenderWidth = Math.max(20, width);
678
+ this.#lastRenderWidth = Math.max(MIN_LOG_VIEWER_WIDTH, width);
607
679
  this.#ensureCursorVisible();
608
680
 
609
- const innerWidth = Math.max(1, this.#lastRenderWidth - 2);
681
+ const contentWidth = Math.max(1, this.#lastRenderWidth - 4);
610
682
  const bodyHeight = this.#bodyHeight();
611
683
 
612
- const rows = this.#renderRows(innerWidth);
613
- const visibleBodyLines = this.#renderVisibleBodyLines(rows, innerWidth, bodyHeight);
684
+ const rows = this.#renderRows(contentWidth);
685
+ this.#bodyRowStart = 4;
686
+ this.#bodyRowCount = bodyHeight;
687
+ const visibleBodyLines = this.#renderVisibleBodyLines(rows, bodyHeight);
614
688
 
615
689
  return [
616
- this.#frameTop(innerWidth),
617
- this.#frameLine(this.#summaryText(), innerWidth),
618
- this.#frameSeparator(innerWidth),
619
- this.#frameLine(this.#filterText(), innerWidth),
620
- this.#frameSeparator(innerWidth),
690
+ topBorder(this.#lastRenderWidth, "Recent Logs"),
691
+ row(this.#summaryText(), this.#lastRenderWidth),
692
+ row(this.#filterText(), this.#lastRenderWidth),
693
+ divider(this.#lastRenderWidth),
621
694
  ...visibleBodyLines,
622
- this.#frameLine(this.#statusText(), innerWidth),
623
- this.#frameBottom(innerWidth),
695
+ divider(this.#lastRenderWidth),
696
+ row(this.#statusText(), this.#lastRenderWidth),
697
+ row(theme.fg("dim", this.#controlsText()), this.#lastRenderWidth),
698
+ bottomBorder(this.#lastRenderWidth),
624
699
  ];
625
700
  }
626
701
 
627
702
  #summaryText(): string {
628
- return ` # ${this.#model.visibleLogCount}/${this.#model.logCount} logs | ${this.#controlsText()}`;
703
+ const selected = this.#model.getSelectedCount();
704
+ const expanded = this.#model.expandedCount;
705
+ return `${theme.fg("muted", "showing")} ${theme.fg("accent", `${this.#model.visibleLogCount}/${this.#model.logCount}`)} ${theme.fg("muted", "selected")} ${theme.fg(selected > 0 ? "accent" : "muted", String(selected))} ${theme.fg("muted", "expanded")} ${theme.fg(expanded > 0 ? "accent" : "muted", String(expanded))}`;
629
706
  }
630
707
 
631
708
  #controlsText(): string {
632
- return "Esc: back Ctrl+C: copy Up/Down: move Shift+Up/Down: select range Left/Right: collapse/expand Ctrl+A: select all Ctrl+O: load older Ctrl+P: pid filter";
709
+ return "Esc close · Ctrl+C copy · ↑/↓/wheel move · click toggle · Shift+↑/↓ select · ←/→ collapse/expand · Ctrl+A all · Ctrl+O older · Ctrl+P pid";
633
710
  }
634
711
 
635
712
  #filterText(): string {
636
713
  const sanitized = replaceTabs(sanitizeText(this.#model.filterQuery));
637
- const query = sanitized.length === 0 ? "" : theme.fg("accent", sanitized);
714
+ const query = sanitized.length === 0 ? theme.fg("muted", "type to filter") : theme.fg("accent", sanitized);
638
715
  const pidStatus = this.#model.isProcessFilterEnabled()
639
- ? theme.fg("success", "pid:on")
640
- : theme.fg("muted", "pid:off");
641
- return ` filter: ${query} ${pidStatus}`;
716
+ ? theme.fg("success", "pid on")
717
+ : theme.fg("muted", "pid off");
718
+ const loading = this.#loadingOlder ? ` ${theme.fg("warning", "loading older…")}` : "";
719
+ return `${theme.fg("muted", "filter")} ${query} ${pidStatus}${loading}`;
642
720
  }
643
721
 
644
722
  #statusText(): string {
645
- const base = ` Selected: ${this.#model.getSelectedCount()} Expanded: ${this.#model.expandedCount}`;
646
- if (this.#statusMessage) {
647
- return `${base} ${this.#statusMessage}`;
648
- }
649
- return base;
723
+ return this.#statusMessage
724
+ ? theme.fg("success", this.#statusMessage)
725
+ : theme.fg("dim", "Enter loads older when highlighted; printable keys update filter");
650
726
  }
651
727
 
652
728
  #bodyHeight(): number {
653
- return Math.max(3, this.#terminalRows - 8);
729
+ return Math.max(3, (process.stdout.rows || this.#terminalRows || 24) - LOG_VIEWER_CHROME_LINES);
654
730
  }
655
731
 
656
732
  async #handleLoadOlder(additionalCount: number = LOAD_OLDER_CHUNK): Promise<void> {
@@ -780,26 +856,25 @@ export class DebugLogViewerComponent implements Component {
780
856
  return rendered;
781
857
  }
782
858
 
783
- #renderVisibleBodyLines(
784
- rows: Array<{ lines: string[]; rowIndex: number }>,
785
- innerWidth: number,
786
- bodyHeight: number,
787
- ): string[] {
859
+ #renderVisibleBodyLines(rows: Array<{ lines: string[]; rowIndex: number }>, bodyHeight: number): string[] {
860
+ this.#bodyLineToRowIndex = [];
788
861
  const lines: string[] = [];
789
862
  if (rows.length === 0) {
790
- lines.push(this.#frameLine(theme.fg("muted", "no matches"), innerWidth));
863
+ this.#bodyLineToRowIndex.push(undefined);
864
+ lines.push(row(theme.fg("muted", "no matches"), this.#lastRenderWidth));
791
865
  }
792
866
  for (let i = this.#scrollRowOffset; i < rows.length; i++) {
793
- const row = rows[i];
794
- if (!row) {
867
+ const renderedRow = rows[i];
868
+ if (!renderedRow) {
795
869
  continue;
796
870
  }
797
871
 
798
- for (const line of row.lines) {
872
+ for (const line of renderedRow.lines) {
799
873
  if (lines.length >= bodyHeight) {
800
874
  break;
801
875
  }
802
- lines.push(this.#frameLine(line, innerWidth));
876
+ this.#bodyLineToRowIndex.push(renderedRow.rowIndex);
877
+ lines.push(row(line, this.#lastRenderWidth));
803
878
  }
804
879
 
805
880
  if (lines.length >= bodyHeight) {
@@ -808,7 +883,8 @@ export class DebugLogViewerComponent implements Component {
808
883
  }
809
884
 
810
885
  while (lines.length < bodyHeight) {
811
- lines.push(this.#frameLine("", innerWidth));
886
+ this.#bodyLineToRowIndex.push(undefined);
887
+ lines.push(row("", this.#lastRenderWidth));
812
888
  }
813
889
 
814
890
  return lines;
@@ -840,7 +916,7 @@ export class DebugLogViewerComponent implements Component {
840
916
  return;
841
917
  }
842
918
  const bodyHeight = Math.max(1, this.#bodyHeight());
843
- const innerWidth = Math.max(1, this.#lastRenderWidth - 2);
919
+ const innerWidth = Math.max(1, this.#lastRenderWidth - 4);
844
920
 
845
921
  // Scroll up: cursor is above viewport
846
922
  if (cursorRowIndex < this.#scrollRowOffset) {
@@ -865,24 +941,6 @@ export class DebugLogViewerComponent implements Component {
865
941
  }
866
942
  }
867
943
 
868
- #frameTop(innerWidth: number): string {
869
- return `${theme.boxRound.topLeft}${theme.boxRound.horizontal.repeat(innerWidth)}${theme.boxRound.topRight}`;
870
- }
871
-
872
- #frameSeparator(innerWidth: number): string {
873
- return `${theme.boxRound.teeRight}${theme.boxRound.horizontal.repeat(innerWidth)}${theme.boxRound.teeLeft}`;
874
- }
875
-
876
- #frameBottom(innerWidth: number): string {
877
- return `${theme.boxRound.bottomLeft}${theme.boxRound.horizontal.repeat(innerWidth)}${theme.boxRound.bottomRight}`;
878
- }
879
-
880
- #frameLine(content: string, innerWidth: number): string {
881
- const truncated = truncateToWidth(content, innerWidth);
882
- const remaining = Math.max(0, innerWidth - visibleWidth(truncated));
883
- return `${theme.boxRound.vertical}${truncated}${padding(remaining)}${theme.boxRound.vertical}`;
884
- }
885
-
886
944
  #copySelected() {
887
945
  const selectedPayload = buildLogCopyPayload(this.#model.getSelectedRawLines());
888
946
  const selected = selectedPayload.length === 0 ? [] : selectedPayload.split("\n");
@@ -1,13 +1,6 @@
1
- import {
2
- type Component,
3
- matchesKey,
4
- padding,
5
- replaceTabs,
6
- ScrollView,
7
- truncateToWidth,
8
- visibleWidth,
9
- } from "@oh-my-pi/pi-tui";
1
+ import { type Component, matchesKey, parseSgrMouse, replaceTabs, ScrollView, truncateToWidth } from "@oh-my-pi/pi-tui";
10
2
  import { sanitizeText } from "@oh-my-pi/pi-utils";
3
+ import { bottomBorder, divider, row, topBorder } from "../modes/components/overlay-box";
11
4
  import { theme } from "../modes/theme/theme";
12
5
  import { copyToClipboard } from "../utils/clipboard";
13
6
  import {
@@ -17,8 +10,8 @@ import {
17
10
  rawSseRecordLines,
18
11
  } from "./raw-sse-buffer";
19
12
 
20
- const MIN_VIEWER_WIDTH = 20;
21
- const VIEWER_FRAME_LINES = 5;
13
+ const MIN_VIEWER_WIDTH = 40;
14
+ const VIEWER_CHROME_LINES = 6;
22
15
  // `data:` lines below this width render fine on a single row; anything wider gets pretty-printed
23
16
  // across multiple `data:` lines so streamed JSON blobs stop getting clipped by `truncateToWidth`.
24
17
  const PRETTY_PRINT_DATA_THRESHOLD = 100;
@@ -79,6 +72,8 @@ export class RawSseViewerComponent implements Component {
79
72
  #followTail = true;
80
73
  #lastRenderWidth = MIN_VIEWER_WIDTH;
81
74
  #statusMessage: string | undefined;
75
+ #bodyRowStart = 0;
76
+ #bodyRowCount = 0;
82
77
  // Pretty-printed wire lines keyed by `record.sequence`. Pretty-printing is
83
78
  // the JSON.parse + JSON.stringify per `data:` line, so we cache the result —
84
79
  // the render path runs on every keypress and from `#maxScrollOffset()`.
@@ -98,9 +93,17 @@ export class RawSseViewerComponent implements Component {
98
93
  });
99
94
  }
100
95
 
96
+ dispose(): void {
97
+ this.#unsubscribe();
98
+ }
99
+
101
100
  handleInput(keyData: string): void {
101
+ if (keyData.startsWith("\x1b[<") && this.#handleMouse(keyData)) {
102
+ return;
103
+ }
104
+
102
105
  if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc")) {
103
- this.#unsubscribe();
106
+ this.dispose();
104
107
  this.#onExit();
105
108
  return;
106
109
  }
@@ -145,15 +148,44 @@ export class RawSseViewerComponent implements Component {
145
148
  }
146
149
  }
147
150
 
151
+ #handleMouse(keyData: string): boolean {
152
+ const event = parseSgrMouse(keyData);
153
+ if (!event) return false;
154
+
155
+ const overBody = event.row >= this.#bodyRowStart && event.row < this.#bodyRowStart + this.#bodyRowCount;
156
+ if (event.wheel !== null && overBody) {
157
+ this.#followTail = false;
158
+ this.#scrollOffset = Math.max(0, Math.min(this.#maxScrollOffset(), this.#scrollOffset + event.wheel * 3));
159
+ this.#onUpdate?.();
160
+ return true;
161
+ }
162
+
163
+ if (!event.leftClick) return false;
164
+ if (event.row === 1) {
165
+ this.#followTail = !this.#followTail;
166
+ this.#followIfNeeded();
167
+ this.#onUpdate?.();
168
+ return true;
169
+ }
170
+ if (overBody) {
171
+ this.#followTail = false;
172
+ const clickedOffset = this.#scrollOffset + event.row - this.#bodyRowStart;
173
+ this.#scrollOffset = Math.max(0, Math.min(this.#maxScrollOffset(), clickedOffset));
174
+ this.#onUpdate?.();
175
+ return true;
176
+ }
177
+ return false;
178
+ }
179
+
148
180
  invalidate(): void {}
149
181
 
150
182
  render(width: number): readonly string[] {
151
183
  this.#lastRenderWidth = Math.max(MIN_VIEWER_WIDTH, width);
152
184
  this.#followIfNeeded();
153
185
 
154
- const innerWidth = Math.max(1, this.#lastRenderWidth - 2);
186
+ const contentWidth = Math.max(1, this.#lastRenderWidth - 4);
155
187
  const bodyHeight = this.#bodyHeight();
156
- const rawLines = this.#renderRawLines(innerWidth);
188
+ const rawLines = this.#renderRawLines(contentWidth);
157
189
  const sv = new ScrollView(rawLines.slice(this.#scrollOffset, this.#scrollOffset + bodyHeight), {
158
190
  height: bodyHeight,
159
191
  scrollbar: "auto",
@@ -161,15 +193,18 @@ export class RawSseViewerComponent implements Component {
161
193
  theme: { track: t => theme.fg("muted", t), thumb: t => theme.fg("accent", t) },
162
194
  });
163
195
  sv.setScrollOffset(this.#scrollOffset);
164
- const bodyRows = sv.render(innerWidth);
196
+ const bodyRows = sv.render(contentWidth);
197
+ this.#bodyRowStart = 3;
198
+ this.#bodyRowCount = bodyHeight;
165
199
 
166
200
  return [
167
- this.#frameTop(innerWidth),
168
- this.#frameLine(this.#summaryText(), innerWidth),
169
- this.#frameSeparator(innerWidth),
170
- ...bodyRows.map(line => this.#frameLine(line, innerWidth)),
171
- this.#frameLine(this.#statusText(), innerWidth),
172
- this.#frameBottom(innerWidth),
201
+ topBorder(this.#lastRenderWidth, "Raw Provider Stream"),
202
+ row(this.#summaryText(), this.#lastRenderWidth),
203
+ divider(this.#lastRenderWidth),
204
+ ...bodyRows.map(line => row(line, this.#lastRenderWidth)),
205
+ divider(this.#lastRenderWidth),
206
+ row(this.#statusText(), this.#lastRenderWidth),
207
+ bottomBorder(this.#lastRenderWidth),
173
208
  ];
174
209
  }
175
210
 
@@ -221,22 +256,25 @@ export class RawSseViewerComponent implements Component {
221
256
  if (key < firstSequence) this.#prettyLinesCache.delete(key);
222
257
  }
223
258
  }
224
-
225
259
  #summaryText(): string {
226
260
  const snapshot = this.#buffer.snapshot();
227
- const last = snapshot.lastUpdatedAt ? ` last=${formatRawSseIsoTime(snapshot.lastUpdatedAt)}` : "";
228
- const follow = this.#followTail ? "follow:on" : "follow:off";
229
- return ` # raw provider stream (SSE + WS) | events=${snapshot.totalEvents} records=${snapshot.records.length}${last} | ${follow} | Esc back Ctrl+C copy End follow`;
261
+ const last = snapshot.lastUpdatedAt
262
+ ? `${theme.fg("muted", "last")} ${theme.fg("accent", formatRawSseIsoTime(snapshot.lastUpdatedAt))}`
263
+ : theme.fg("muted", "waiting for first frame");
264
+ const follow = this.#followTail ? theme.fg("success", "follow on") : theme.fg("warning", "follow off");
265
+ return `${theme.fg("muted", "events")} ${theme.fg("accent", String(snapshot.totalEvents))} ${theme.fg("muted", "records")} ${theme.fg("accent", String(snapshot.records.length))} ${last} ${follow}`;
230
266
  }
231
267
 
232
268
  #statusText(): string {
233
- return this.#statusMessage ?? " Up/Down scroll PgUp/PgDn page";
269
+ const help = "Esc close · Ctrl+C copy raw · End follow tail · wheel scroll · click summary toggles follow";
270
+ return this.#statusMessage
271
+ ? `${theme.fg("success", this.#statusMessage)} ${theme.fg("dim", help)}`
272
+ : theme.fg("dim", help);
234
273
  }
235
274
 
236
275
  #bodyHeight(): number {
237
- return Math.max(3, this.#terminalRows - VIEWER_FRAME_LINES);
276
+ return Math.max(3, (process.stdout.rows || this.#terminalRows || 24) - VIEWER_CHROME_LINES);
238
277
  }
239
-
240
278
  #followIfNeeded(): void {
241
279
  if (this.#followTail) this.#scrollToTail();
242
280
  }
@@ -246,8 +284,8 @@ export class RawSseViewerComponent implements Component {
246
284
  }
247
285
 
248
286
  #maxScrollOffset(): number {
249
- const innerWidth = Math.max(1, this.#lastRenderWidth - 2);
250
- return Math.max(0, this.#renderRawLines(innerWidth).length - this.#bodyHeight());
287
+ const contentWidth = Math.max(1, this.#lastRenderWidth - 4);
288
+ return Math.max(0, this.#renderRawLines(contentWidth).length - this.#bodyHeight());
251
289
  }
252
290
 
253
291
  #copyAll(): void {
@@ -271,22 +309,4 @@ export class RawSseViewerComponent implements Component {
271
309
  }
272
310
  this.#onUpdate?.();
273
311
  }
274
-
275
- #frameTop(innerWidth: number): string {
276
- return `${theme.boxRound.topLeft}${theme.boxRound.horizontal.repeat(innerWidth)}${theme.boxRound.topRight}`;
277
- }
278
-
279
- #frameSeparator(innerWidth: number): string {
280
- return `${theme.boxRound.teeRight}${theme.boxRound.horizontal.repeat(innerWidth)}${theme.boxRound.teeLeft}`;
281
- }
282
-
283
- #frameBottom(innerWidth: number): string {
284
- return `${theme.boxRound.bottomLeft}${theme.boxRound.horizontal.repeat(innerWidth)}${theme.boxRound.bottomRight}`;
285
- }
286
-
287
- #frameLine(content: string, innerWidth: number): string {
288
- const truncated = truncateToWidth(content, innerWidth);
289
- const remaining = Math.max(0, innerWidth - visibleWidth(truncated));
290
- return `${theme.boxRound.vertical}${truncated}${padding(remaining)}${theme.boxRound.vertical}`;
291
- }
292
312
  }
@@ -342,12 +342,20 @@ async function loadHooks(ctx: LoadContext): Promise<LoadResult<Hook>> {
342
342
  const codexDir = getProjectCodexDir(ctx);
343
343
  const projectHooksDir = path.join(codexDir, "hooks");
344
344
 
345
+ // OMP hooks must be named `pre-<tool>.<ts|js>` or `post-<tool>.<ts|js>`.
346
+ // Files without that prefix are not OMP hooks (e.g. the standalone Codex
347
+ // hook scripts users keep alongside) — silently dropping the prefix and
348
+ // defaulting to `pre:<basename>` caused those scripts to be imported as
349
+ // extension factories and any top-level `process.exit()` killed startup
350
+ // (#3680).
345
351
  const transformHook =
346
- (level: "user" | "project") => (name: string, _content: string, path: string, source: SourceMeta) => {
352
+ (level: "user" | "project") =>
353
+ (name: string, _content: string, path: string, source: SourceMeta): Hook | null => {
347
354
  const baseName = name.replace(/\.(ts|js)$/, "");
348
355
  const match = baseName.match(/^(pre|post)-(.+)$/);
349
- const hookType = (match?.[1] as "pre" | "post") || "pre";
350
- const toolName = match?.[2] || baseName;
356
+ if (!match) return null;
357
+ const hookType = match[1] as "pre" | "post";
358
+ const toolName = match[2];
351
359
  return {
352
360
  name,
353
361
  path,
@@ -359,11 +367,11 @@ async function loadHooks(ctx: LoadContext): Promise<LoadResult<Hook>> {
359
367
  };
360
368
 
361
369
  const results = await Promise.all([
362
- loadFilesFromDir(ctx, userHooksDir, PROVIDER_ID, "user", {
370
+ loadFilesFromDir<Hook>(ctx, userHooksDir, PROVIDER_ID, "user", {
363
371
  extensions: ["ts", "js"],
364
372
  transform: transformHook("user"),
365
373
  }),
366
- loadFilesFromDir(ctx, projectHooksDir, PROVIDER_ID, "project", {
374
+ loadFilesFromDir<Hook>(ctx, projectHooksDir, PROVIDER_ID, "project", {
367
375
  extensions: ["ts", "js"],
368
376
  transform: transformHook("project"),
369
377
  }),
@@ -9,6 +9,7 @@
9
9
  * match is accepted even when the tag was minted by a source that did not keep
10
10
  * history, and stale tags recover through the session snapshot store when possible.
11
11
  */
12
+ import * as path from "node:path";
12
13
  import {
13
14
  type ApplyResult,
14
15
  applyEdits,
@@ -30,6 +31,7 @@ import {
30
31
  } from "@oh-my-pi/hashline";
31
32
  import { resolveToCwd } from "../../tools/path-utils";
32
33
  import { generateDiffString } from "../diff";
34
+ import { canonicalSnapshotKey } from "../file-snapshot-store";
33
35
  import { readEditFileText } from "../read-file";
34
36
  import { nativeBlockResolver } from "./block-resolver";
35
37
 
@@ -90,6 +92,54 @@ async function readSectionTextCached(absolutePath: string, sectionPath: string):
90
92
  return rawContent;
91
93
  }
92
94
 
95
+ /**
96
+ * Resolve a missing authored path to a file read this session by matching its
97
+ * basename and snapshot tag, mirroring {@link Patcher}'s apply-time recovery so
98
+ * a bare/wrong-directory `[basename#tag]` header previews against the same file
99
+ * the edit will land on. Returns `undefined` when no unique basename+tag match
100
+ * exists, leaving the caller to surface the original read error.
101
+ */
102
+ function recoverSectionPathFromTag(
103
+ section: PatchSection,
104
+ authoredAbsolutePath: string,
105
+ snapshots: SnapshotStore,
106
+ ): string | undefined {
107
+ if (section.fileHash === undefined) return undefined;
108
+ const authoredName = path.basename(section.path);
109
+ const authoredKey = canonicalSnapshotKey(authoredAbsolutePath);
110
+ const candidates = [
111
+ ...new Set(
112
+ snapshots
113
+ .findByHash(section.fileHash)
114
+ .filter(snapshot => path.basename(snapshot.path) === authoredName)
115
+ .map(snapshot => snapshot.path),
116
+ ),
117
+ ].filter(candidate => candidate !== authoredKey);
118
+ return candidates.length === 1 ? candidates[0] : undefined;
119
+ }
120
+
121
+ /**
122
+ * Read the section's target file for a preview, recovering a bare/mis-typed
123
+ * `[basename#tag]` path onto the file its tag uniquely names. Recovery fires
124
+ * only when the authored path is absent — matching {@link Patcher}'s apply-time
125
+ * order — so a permission/parse error on an existing file surfaces against the
126
+ * authored path instead of silently previewing a different tagged file. Returns
127
+ * the path actually read so callers key snapshot lookups off the same file.
128
+ */
129
+ async function readSectionForPreview(
130
+ section: PatchSection,
131
+ authoredAbsolutePath: string,
132
+ snapshots: SnapshotStore,
133
+ streaming: boolean | undefined,
134
+ ): Promise<{ absolutePath: string; rawContent: string }> {
135
+ const read = streaming ? readSectionTextCached : readSectionText;
136
+ const recovered = (await Bun.file(authoredAbsolutePath).exists())
137
+ ? undefined
138
+ : recoverSectionPathFromTag(section, authoredAbsolutePath, snapshots);
139
+ const target = recovered ?? authoredAbsolutePath;
140
+ return { absolutePath: target, rawContent: await read(target, section.path) };
141
+ }
142
+
93
143
  function hasAnchorScopedEdit(edits: readonly Edit[]): boolean {
94
144
  return edits.some(edit => {
95
145
  if (edit.kind === "delete") return true;
@@ -258,10 +308,13 @@ export async function computeHashlineSectionDiff(
258
308
  options: HashlineDiffOptions = {},
259
309
  ): Promise<{ diff: string; firstChangedLine: number | undefined } | { error: string }> {
260
310
  try {
261
- const absolutePath = resolveToCwd(section.path, cwd);
262
- const rawContent = options.streaming
263
- ? await readSectionTextCached(absolutePath, section.path)
264
- : await readSectionText(absolutePath, section.path);
311
+ const authoredPath = resolveToCwd(section.path, cwd);
312
+ const { absolutePath, rawContent } = await readSectionForPreview(
313
+ section,
314
+ authoredPath,
315
+ snapshots,
316
+ options.streaming,
317
+ );
265
318
  const { text: content } = stripBom(rawContent);
266
319
  const normalized = normalizeToLF(content);
267
320
  // Streaming favors a stable, monotonic preview over an exact unified
@@ -29,6 +29,7 @@ export class LocalModuleLoader {
29
29
  #externalModules = new Map<string, Promise<vm.Module>>();
30
30
  #requireCache = new Map<string, NodeJS.Require>();
31
31
  #modulePaths = new WeakMap<vm.Module, string>();
32
+ #linkChain: Promise<void> = Promise.resolve();
32
33
 
33
34
  constructor(sessionId: string) {
34
35
  this.#context = vm.createContext(globalThis);
@@ -147,7 +148,17 @@ export class LocalModuleLoader {
147
148
  async #linkAndEvaluate(entry: LocalModuleEntry, modulePath: string): Promise<void> {
148
149
  const { module } = entry;
149
150
  try {
150
- if (module.status === "unlinked") await module.link(this.#linkResolve);
151
+ // Serialize the link phase across every graph root. Bun's node:vm linker
152
+ // segfaults (getImportedModule on a null record) when two link passes
153
+ // instantiate overlapping module instances concurrently — e.g.
154
+ // Promise.all([import("./a"), import("./b")]) over a graph that shares
155
+ // dependencies. Holding the lock for the whole module.link() (including its
156
+ // async resolver callbacks) guarantees the linker is never re-entered
157
+ // mid-instantiation. The lock is released before evaluate(), so a dynamic
158
+ // import during evaluation can re-acquire it without deadlock.
159
+ await this.#serializeLink(async () => {
160
+ if (module.status === "unlinked") await module.link(this.#linkResolve);
161
+ });
151
162
  if (module.status === "linked") await module.evaluate();
152
163
  } catch (error) {
153
164
  this.#invalidateFailedLoad(modulePath);
@@ -159,6 +170,17 @@ export class LocalModuleLoader {
159
170
  }
160
171
  }
161
172
 
173
+ // Promise-chain mutex serializing node:vm link passes (see #linkAndEvaluate).
174
+ // #linkChain is kept non-rejecting so a failed link never wedges the queue.
175
+ #serializeLink<T>(run: () => Promise<T>): Promise<T> {
176
+ const result = this.#linkChain.then(run);
177
+ this.#linkChain = result.then(
178
+ () => undefined,
179
+ () => undefined,
180
+ );
181
+ return result;
182
+ }
183
+
162
184
  // Shared static-link resolver for `module.link()`. node:vm passes the referencing
163
185
  // module and reuses this one resolver for the entire graph, so the referrer path is
164
186
  // recovered from `#modulePaths`. Local dependencies are constructed but NOT linked or
@@ -1236,7 +1236,7 @@
1236
1236
  let html = `
1237
1237
  <div class="header">
1238
1238
  <h1>Session: ${escapeHtml(header?.id || 'unknown')}</h1>
1239
- <div class="help-bar">Ctrl+T toggle thinking · Ctrl+O toggle tools</div>
1239
+ <div class="help-bar">T toggle thinking · O toggle tools</div>
1240
1240
  <div class="header-info">
1241
1241
  <div class="info-item"><span class="info-label">Date:</span><span class="info-value">${header?.timestamp ? new Date(header.timestamp).toLocaleString() : 'unknown'}</span></div>
1242
1242
  <div class="info-item"><span class="info-label">Models:</span><span class="info-value">${globalStats.models.join(', ') || 'unknown'}</span></div>
@@ -1580,13 +1580,19 @@
1580
1580
  searchQuery = '';
1581
1581
  navigateTo(leafId, 'bottom');
1582
1582
  }
1583
- if (e.ctrlKey && e.key === 't') {
1583
+ if (e.key === 't' || e.key === 'T' || e.key === 'o' || e.key === 'O') {
1584
+ // Skip when typing in the sidebar search (or any other editable target)
1585
+ // so the chord can't fire on a user's letter input. Avoid Ctrl/Cmd-based
1586
+ // chords entirely — every major browser reserves Ctrl+T (new tab) and
1587
+ // Ctrl+O (open file), so the shortcut would never reach the page.
1588
+ const t = e.target;
1589
+ const editable =
1590
+ t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable);
1591
+ if (editable) return;
1592
+ if (e.ctrlKey || e.metaKey || e.altKey) return;
1584
1593
  e.preventDefault();
1585
- toggleThinking();
1586
- }
1587
- if (e.ctrlKey && e.key === 'o') {
1588
- e.preventDefault();
1589
- toggleToolOutputs();
1594
+ if (e.key === 't' || e.key === 'T') toggleThinking();
1595
+ else toggleToolOutputs();
1590
1596
  }
1591
1597
  });
1592
1598