@oh-my-pi/pi-tui 18.4.8 → 18.4.9

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 (40) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/types/apps/debug/log-viewer.d.ts +17 -0
  3. package/dist/types/apps/debug/raw-sse.d.ts +14 -0
  4. package/dist/types/native/backend.d.ts +8 -0
  5. package/dist/types/native/encode.d.ts +5 -1
  6. package/dist/types/native/node.d.ts +14 -1
  7. package/dist/types/native/reconcile.d.ts +5 -0
  8. package/dist/types/overlays/extensions/extension-dashboard.d.ts +31 -0
  9. package/dist/types/overlays/extensions/extension-list.d.ts +37 -0
  10. package/dist/types/overlays/extensions/inspector-panel.d.ts +8 -0
  11. package/dist/types/overlays/jobs-panel.d.ts +35 -9
  12. package/dist/types/prompt/word-completion.d.ts +12 -0
  13. package/dist/types/terminal.d.ts +7 -0
  14. package/dist/types/tools/bash-interactive.d.ts +15 -0
  15. package/dist/types/tools/output-meta.d.ts +8 -2
  16. package/dist/types/tools/streaming-output.d.ts +15 -10
  17. package/dist/types/tui.d.ts +5 -0
  18. package/dist/types/windows-input-mode.d.ts +26 -0
  19. package/package.json +9 -9
  20. package/src/apps/debug/log-viewer.ts +236 -2
  21. package/src/apps/debug/protocol-probe.ts +10 -1
  22. package/src/apps/debug/raw-sse.ts +210 -28
  23. package/src/native/apply.ts +2 -1
  24. package/src/native/backend.ts +22 -0
  25. package/src/native/encode.ts +7 -2
  26. package/src/native/node.ts +15 -1
  27. package/src/native/reconcile.ts +28 -1
  28. package/src/overlays/extensions/extension-dashboard.ts +275 -23
  29. package/src/overlays/extensions/extension-list.ts +274 -14
  30. package/src/overlays/extensions/inspector-panel.ts +368 -0
  31. package/src/overlays/hook-selector.ts +1 -1
  32. package/src/overlays/jobs-panel.ts +222 -49
  33. package/src/prompt/composer-cache.ts +26 -3
  34. package/src/prompt/word-completion.ts +31 -13
  35. package/src/terminal.ts +37 -1
  36. package/src/tools/bash-interactive.ts +85 -16
  37. package/src/tools/output-meta.ts +13 -5
  38. package/src/tools/streaming-output.ts +48 -23
  39. package/src/tui.ts +52 -14
  40. package/src/windows-input-mode.ts +206 -0
@@ -1,10 +1,15 @@
1
+ import type { TspPickerGroup, TspPickerItem, TspTone } from "@oh-my-pi/pi-wire";
1
2
  import { formatKeyHint, formatKeyHints } from "../../app-keybindings";
2
3
  import type { Component } from "../../tui";
4
+ import { code } from "../../native/describe";
5
+ import { Memo } from "../../native/memo";
6
+ import type { DescribeContext, NativeNode, NativeUiEvent } from "../../native/node";
7
+ import { CLOSE_ACTION, picker, pickerAction, pickerEvent, pickerHits, pickerQuery } from "../../native/picker";
3
8
  import { matchesKey } from "../../keys";
4
9
  import { Input } from "../../components/input";
5
10
  import { routeSgrMouseInput, type SgrMouseEvent } from "../../mouse";
6
11
  import { padding, truncateToWidth, visibleWidth } from "../../utils";
7
- import { sanitizeText } from "@oh-my-pi/pi-utils";
12
+ import { isRecord, sanitizeText } from "@oh-my-pi/pi-utils";
8
13
  import { theme } from "../../theme/theme";
9
14
  import { sanitizeDisplayText } from "../../overlays/extensions/display-text";
10
15
  import { DebugViewerFrame, type DebugViewerFrameContent, type DebugViewerFrameContext } from "./viewer-frame";
@@ -24,6 +29,15 @@ export const LOAD_OLDER_LABEL = "### MOVE UP TO LOAD MORE...";
24
29
  const INITIAL_LOG_CHUNK = 50;
25
30
  const LOAD_OLDER_CHUNK = 50;
26
31
  const MIN_LOG_VIEWER_WIDTH = 48;
32
+ /** Picker item id of the "load older entries" row. */
33
+ const OLDER_ITEM = "older";
34
+ /** Status-dot tone of a log level. */
35
+ const LEVEL_TONES: Readonly<Record<string, TspTone>> = {
36
+ error: "error",
37
+ warn: "warning",
38
+ info: "info",
39
+ debug: "muted",
40
+ };
27
41
 
28
42
  type LogEntry = {
29
43
  rawLine: string;
@@ -40,6 +54,47 @@ type DebugLogViewerModelOptions = {
40
54
  loadOlderLogs?: (limitDays?: number) => Promise<string>;
41
55
  };
42
56
 
57
+ /**
58
+ * One log entry as a picker row: the message as the label, the remaining
59
+ * fields as `key=value` detail, the local time and pid as facts and the
60
+ * level as the status dot. Lines that aren't JSON objects show as they are.
61
+ */
62
+ function logPickerItem(id: string, rawLine: string): TspPickerItem {
63
+ let parsed: unknown;
64
+ try {
65
+ parsed = JSON.parse(rawLine);
66
+ } catch {}
67
+ if (!isRecord(parsed)) {
68
+ return { id, label: sanitizeDisplayText(rawLine).replace(/\s+/g, " "), mono: true };
69
+ }
70
+ const { timestamp, level, pid, message, ...rest } = parsed;
71
+ let detail = "";
72
+ for (const key in rest) {
73
+ const value = rest[key];
74
+ detail += `${detail ? " " : ""}${key}=${typeof value === "string" ? value : JSON.stringify(value)}`;
75
+ }
76
+ const time = typeof timestamp === "string" ? new Date(timestamp) : undefined;
77
+ return {
78
+ id,
79
+ label: sanitizeDisplayText(typeof message === "string" ? message : rawLine).replace(/\s+/g, " "),
80
+ detail: detail ? sanitizeDisplayText(detail).replace(/\s+/g, " ") : undefined,
81
+ dot: typeof level === "string" ? LEVEL_TONES[level] : undefined,
82
+ facts: {
83
+ time: time && Number.isFinite(time.getTime()) ? time.toLocaleTimeString(undefined, { hour12: false }) : "",
84
+ pid: typeof pid === "number" ? String(pid) : "",
85
+ },
86
+ };
87
+ }
88
+
89
+ /** A log line pretty-printed for the preview pane: indented JSON when it parses, else the sanitized line. */
90
+ function logPreviewText(rawLine: string): { text: string; lang?: string } {
91
+ try {
92
+ return { text: JSON.stringify(JSON.parse(rawLine), null, 2), lang: "json" };
93
+ } catch {
94
+ return { text: sanitizeDisplayText(rawLine) };
95
+ }
96
+ }
97
+
43
98
  type ViewerRow =
44
99
  | {
45
100
  kind: "warning";
@@ -516,6 +571,10 @@ export class DebugLogViewerComponent implements Component {
516
571
  #statusMessage: string | undefined;
517
572
  #loadingOlder = false;
518
573
  #bodyLineToRowIndex: Array<number | undefined> = [];
574
+ readonly #native = new Memo();
575
+ /** Picker rows by log index, valid while {@link #nativeItemsCount} entries are loaded (a prepend shifts indices). */
576
+ #nativeItems = new Map<number, TspPickerItem>();
577
+ #nativeItemsCount = 0;
519
578
 
520
579
  constructor(options: DebugLogViewerComponentOptions) {
521
580
  this.#deps = options.deps;
@@ -606,6 +665,24 @@ export class DebugLogViewerComponent implements Component {
606
665
  return;
607
666
  }
608
667
 
668
+ // Page and end keys jump the cursor; the frame (or the native list)
669
+ // keeps it in view.
670
+ const jump = matchesKey(keyData, "pageUp")
671
+ ? -this.#frame.getBodyHeight()
672
+ : matchesKey(keyData, "pageDown")
673
+ ? this.#frame.getBodyHeight()
674
+ : matchesKey(keyData, "home")
675
+ ? -this.#model.rows.length
676
+ : matchesKey(keyData, "end")
677
+ ? this.#model.rows.length
678
+ : 0;
679
+ if (jump !== 0) {
680
+ this.#statusMessage = undefined;
681
+ this.#model.moveCursor(jump, false);
682
+ this.#onUpdate?.();
683
+ return;
684
+ }
685
+
609
686
  if (matchesKey(keyData, "right")) {
610
687
  this.#statusMessage = undefined;
611
688
  if (this.#model.cursorRowKind === "load-older") {
@@ -666,12 +743,169 @@ export class DebugLogViewerComponent implements Component {
666
743
 
667
744
  invalidate(): void {
668
745
  this.#frame.invalidate();
746
+ this.#native.clear();
669
747
  }
670
748
 
671
749
  dispose(): void {
672
750
  this.#frame.dispose();
673
751
  }
674
752
 
753
+ /** A `picker` sheet floats over the transcript instead of taking the screen. */
754
+ nativeSheet(cx: DescribeContext): boolean {
755
+ return cx.supports("picker");
756
+ }
757
+
758
+ /**
759
+ * The logs as a picker sheet: the filter as its query, one row per entry
760
+ * (message, fields, time, pid, level dot) with the session boundary as a
761
+ * group head, the range selection checked, the entry under the cursor
762
+ * pretty-printed below, and the keys' actions in the bar. Without the
763
+ * `picker` kind it falls back to rows.
764
+ */
765
+ describe(cx: DescribeContext): NativeNode | null {
766
+ if (!cx.supports("picker")) return null;
767
+ const model = this.#model;
768
+ const query = this.#filter.getValue();
769
+ const cursor = this.#filter.getCursor();
770
+ const selected = model.getSelectedLogIndices();
771
+ return this.#native.get(
772
+ [
773
+ model.rows,
774
+ model.cursorRowIndex,
775
+ selected.join(","),
776
+ model.logCount,
777
+ model.isProcessFilterEnabled(),
778
+ model.canLoadOlder(),
779
+ query,
780
+ cursor,
781
+ this.#loadingOlder,
782
+ this.#statusMessage,
783
+ ],
784
+ () => {
785
+ if (this.#nativeItemsCount !== model.logCount) {
786
+ this.#nativeItems = new Map();
787
+ this.#nativeItemsCount = model.logCount;
788
+ }
789
+ const items: TspPickerItem[] = [];
790
+ const order: (string | TspPickerGroup)[] = [];
791
+ const hits: Record<string, [number, number][]> = {};
792
+ const needle = query.toLowerCase();
793
+ for (const viewerRow of model.rows) {
794
+ if (viewerRow.kind === "warning") {
795
+ order.push({ group: "session", label: "This session" });
796
+ continue;
797
+ }
798
+ if (viewerRow.kind === "load-older") {
799
+ items.push({ id: OLDER_ITEM, label: "Load older entries", tone: "muted" });
800
+ order.push(OLDER_ITEM);
801
+ continue;
802
+ }
803
+ let item = this.#nativeItems.get(viewerRow.logIndex);
804
+ if (!item) {
805
+ item = logPickerItem(`l${viewerRow.logIndex}`, model.getRawLine(viewerRow.logIndex));
806
+ this.#nativeItems.set(viewerRow.logIndex, item);
807
+ }
808
+ items.push(item);
809
+ order.push(item.id);
810
+ if (needle && typeof item.label === "string") {
811
+ const found = pickerHits(item.label, [needle]);
812
+ if (found.length > 0) hits[item.id] = found;
813
+ }
814
+ }
815
+ const cursorLog = model.cursorLogIndex;
816
+ const preview = cursorLog === undefined ? undefined : logPreviewText(model.getRawLine(cursorLog));
817
+ const subtitle = [
818
+ `${model.visibleLogCount}/${model.logCount} entries`,
819
+ selected.length > 1 ? `${selected.length} selected` : undefined,
820
+ this.#loadingOlder ? "loading older…" : undefined,
821
+ ]
822
+ .filter(part => part !== undefined)
823
+ .join(" · ");
824
+ return picker(
825
+ {
826
+ title: "Recent logs",
827
+ subtitle,
828
+ noun: "entries",
829
+ size: "lg",
830
+ layout: "rows",
831
+ preview: preview ? "below" : "none",
832
+ ...pickerQuery(this.#filter),
833
+ placeholder: "Filter logs…",
834
+ columns: [
835
+ { id: "pid", format: "dim", priority: 0 },
836
+ { id: "time", format: "dim", priority: 1 },
837
+ ],
838
+ items,
839
+ order,
840
+ hits,
841
+ selected:
842
+ model.cursorRowKind === "load-older"
843
+ ? OLDER_ITEM
844
+ : cursorLog === undefined
845
+ ? null
846
+ : `l${cursorLog}`,
847
+ current: selected.length > 1 ? selected.map(index => `l${index}`) : [],
848
+ total: model.logCount,
849
+ empty: query ? "No matching log entries" : "No log entries",
850
+ message: this.#statusMessage,
851
+ actions: [
852
+ pickerAction("copy", "Copy", "ctrl+c", { primary: true }),
853
+ pickerAction("all", "Select all", "ctrl+a"),
854
+ pickerAction("pid", "This process", "ctrl+p", { on: model.isProcessFilterEnabled() }),
855
+ pickerAction("older", "Load older", "ctrl+o", {
856
+ disabled: model.canLoadOlder() ? undefined : "No older log entries",
857
+ }),
858
+ CLOSE_ACTION,
859
+ ],
860
+ },
861
+ preview && cursorLog !== undefined
862
+ ? [{ ...code(preview.text, { lang: preview.lang, wrap: true }), key: `entry:${cursorLog}` }]
863
+ : undefined,
864
+ );
865
+ },
866
+ );
867
+ }
868
+
869
+ /**
870
+ * Pointer actions run the keys' code: a row click moves the cursor there,
871
+ * a second click copies it (or loads older entries from that row), and the
872
+ * bar's buttons mirror their keys.
873
+ */
874
+ handleNativeEvent(event: NativeUiEvent): void {
875
+ const ev = pickerEvent(event);
876
+ if (!ev) return;
877
+ this.#statusMessage = undefined;
878
+ if (ev.kind === "action") {
879
+ switch (ev.act) {
880
+ case "copy":
881
+ this.#copySelected();
882
+ return;
883
+ case "all":
884
+ this.#model.selectAllVisible();
885
+ return;
886
+ case "pid":
887
+ this.#model.toggleProcessFilter();
888
+ return;
889
+ case "older":
890
+ void this.#handleLoadOlder(this.#frame.getBodyHeight() + 1);
891
+ return;
892
+ case "close":
893
+ this.#onExit();
894
+ return;
895
+ }
896
+ return;
897
+ }
898
+ const rowIndex = this.#model.rows.findIndex(viewerRow =>
899
+ ev.item === OLDER_ITEM
900
+ ? viewerRow.kind === "load-older"
901
+ : viewerRow.kind === "log" && `l${viewerRow.logIndex}` === ev.item,
902
+ );
903
+ if (rowIndex < 0 || !this.#model.moveCursorToRow(rowIndex, false)) return;
904
+ if (ev.kind !== "activate") return;
905
+ if (ev.item === OLDER_ITEM) void this.#handleLoadOlder();
906
+ else this.#copySelected();
907
+ }
908
+
675
909
  render(width: number): readonly string[] {
676
910
  return this.#frame.render(Math.max(MIN_LOG_VIEWER_WIDTH, width));
677
911
  }
@@ -715,7 +949,7 @@ export class DebugLogViewerComponent implements Component {
715
949
  }
716
950
 
717
951
  #controlsText(): string {
718
- return `${formatKeyHint("escape")} close · ${formatKeyHint("ctrl+c")} copy · ${formatKeyHints(["up", "down"])}/wheel move · click toggle · ${formatKeyHints(["shift+up", "shift+down"])} select · ${formatKeyHints(["left", "right"])} collapse/expand · ${formatKeyHint("ctrl+a")} all · ${formatKeyHint("ctrl+o")} older · ${formatKeyHint("ctrl+p")} pid`;
952
+ return `${formatKeyHint("escape")} close · ${formatKeyHint("ctrl+c")} copy · ${formatKeyHints(["up", "down"])}/wheel move · ${formatKeyHints(["pageUp", "pageDown"])} page · ${formatKeyHints(["home", "end"])} first/last · click toggle · ${formatKeyHints(["shift+up", "shift+down"])} select · ${formatKeyHints(["left", "right"])} collapse/expand · ${formatKeyHint("ctrl+a")} all · ${formatKeyHint("ctrl+o")} older · ${formatKeyHint("ctrl+p")} pid`;
719
953
  }
720
954
 
721
955
  #filterText(): string {
@@ -23,6 +23,8 @@ import { Text } from "../../components/text";
23
23
  import { ImageProtocol, NotifyProtocol, TERMINAL } from "../../terminal-capabilities";
24
24
  import { DynamicBorder } from "../../chrome/dynamic-border";
25
25
  import { theme } from "../../theme/theme";
26
+ import { ansi } from "../../native/describe";
27
+ import type { NativeNode } from "../../native/node";
26
28
 
27
29
  const PNG_SIGNATURE = Uint8Array.of(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a);
28
30
 
@@ -148,13 +150,20 @@ function imageProtocolLabel(): string {
148
150
  /**
149
151
  * Deliberate exception to the normal text/data components: OSC 66 probes must
150
152
  * reach the terminal byte-for-byte, without sanitization, wrapping, clipping,
151
- * or padding that could invalidate the protocol sample.
153
+ * or padding that could invalidate the protocol sample. A native terminal
154
+ * gets the same bytes as one `ansi` block.
152
155
  */
153
156
  class RawLines implements Component {
154
157
  readonly #lines: readonly string[];
158
+ readonly #native: NativeNode;
155
159
 
156
160
  constructor(lines: readonly string[]) {
157
161
  this.#lines = lines.slice();
162
+ this.#native = ansi(this.#lines.join("\n"));
163
+ }
164
+
165
+ describe(): NativeNode {
166
+ return this.#native;
158
167
  }
159
168
 
160
169
  render(): readonly string[] {
@@ -1,6 +1,11 @@
1
+ import type { TspScrollBy } from "@oh-my-pi/pi-wire";
1
2
  import { formatKeyHint } from "../../app-keybindings";
2
3
  import type { Component } from "../../tui";
3
4
  import { matchesKey } from "../../keys";
5
+ import { col, compact, keyed, node, row, span, text } from "../../native/describe";
6
+ import { Memo } from "../../native/memo";
7
+ import type { NativeNode, NativeScreen, NativeScroll, NativeUiEvent } from "../../native/node";
8
+ import { actionBar, actionButton } from "../../native/overlay";
4
9
  import { routeSgrMouseInput, type SgrMouseEvent } from "../../mouse";
5
10
  import { truncateToWidth } from "../../utils";
6
11
  import { sanitizeDisplayText } from "../../overlays/extensions/display-text";
@@ -10,6 +15,7 @@ import {
10
15
  formatRawSseIsoTime,
11
16
  type RawSseDebugBuffer,
12
17
  type RawSseDebugRecord,
18
+ type RawSseDebugSnapshot,
13
19
  rawSseRecordLines,
14
20
  } from "./raw-sse-buffer";
15
21
 
@@ -17,6 +23,9 @@ const MIN_VIEWER_WIDTH = 40;
17
23
  // `data:` lines below this width render fine on a single row; anything wider gets pretty-printed
18
24
  // across multiple `data:` lines so streamed JSON blobs stop getting clipped by `truncateToWidth`.
19
25
  const PRETTY_PRINT_DATA_THRESHOLD = 100;
26
+ // The native body is fed append-only (a `text append` op per new record);
27
+ // past this many characters it starts over from the live window.
28
+ const NATIVE_STREAM_CHAR_CAP = 2_000_000;
20
29
 
21
30
  // Walks the SSE wire lines and replaces single-line `data: <json>` payloads with
22
31
  // multi-line `data: <indented-json>` entries when the JSON is wide enough to clip.
@@ -77,7 +86,20 @@ export class RawSseViewerComponent implements Component {
77
86
  readonly #unsubscribe: () => void;
78
87
  readonly #frame: DebugViewerFrame;
79
88
  #statusMessage: string | undefined;
89
+ #statusFailed = false;
80
90
  #disposed = false;
91
+ readonly #native = new Memo();
92
+ readonly #nativeHead = new Memo();
93
+ readonly #nativeBody = new Memo();
94
+ /** The last scroll key, forwarded to the stream block (see {@link NativeNode.scroll}). */
95
+ #nativeScroll: NativeScroll | undefined;
96
+ /** What the native `ansi` body was fed: every record after a rebase, in order, through {@link #nativeLast}. */
97
+ #nativeText = "";
98
+ /** Sequence of the last record in {@link #nativeText}. */
99
+ #nativeLast = 0;
100
+ /** Buffer counters at the last feed; a drop means the buffer was cleared. */
101
+ #nativeEvents = 0;
102
+ #nativeDropped = 0;
81
103
  // Pretty-printed wire lines keyed by `record.sequence`. Pretty-printing is
82
104
  // the JSON.parse + JSON.stringify per `data:` line, so we cache the result —
83
105
  // the render path runs on every keypress and scroll update.
@@ -123,8 +145,7 @@ export class RawSseViewerComponent implements Component {
123
145
  }
124
146
 
125
147
  if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc")) {
126
- this.dispose();
127
- this.#onExit();
148
+ this.#close();
128
149
  return;
129
150
  }
130
151
 
@@ -133,34 +154,50 @@ export class RawSseViewerComponent implements Component {
133
154
  return;
134
155
  }
135
156
 
136
- if (matchesKey(keyData, "up")) {
137
- this.#frame.scroll(-1);
138
- this.#onUpdate?.();
139
- return;
140
- }
141
-
142
- if (matchesKey(keyData, "down")) {
143
- this.#frame.scroll(1);
144
- this.#onUpdate?.();
145
- return;
146
- }
147
-
148
- if (matchesKey(keyData, "pageUp")) {
149
- this.#frame.scroll(-this.#frame.getBodyHeight());
150
- this.#onUpdate?.();
151
- return;
152
- }
153
-
154
- if (matchesKey(keyData, "pageDown")) {
155
- this.#frame.scroll(this.#frame.getBodyHeight());
156
- this.#onUpdate?.();
157
- return;
158
- }
157
+ const by: TspScrollBy | undefined = matchesKey(keyData, "up")
158
+ ? "line-up"
159
+ : matchesKey(keyData, "down")
160
+ ? "line-down"
161
+ : matchesKey(keyData, "pageUp")
162
+ ? "page-up"
163
+ : matchesKey(keyData, "pageDown")
164
+ ? "page-down"
165
+ : matchesKey(keyData, "home")
166
+ ? "start"
167
+ : matchesKey(keyData, "end")
168
+ ? "end"
169
+ : undefined;
170
+ if (by) this.#scroll(by);
171
+ }
159
172
 
160
- if (matchesKey(keyData, "end")) {
161
- this.#frame.setFollowTail(true);
162
- this.#onUpdate?.();
173
+ /**
174
+ * Scroll keys move the row frame and, natively, ask the terminal to move
175
+ * the stream block the same way (`end` follows the tail again).
176
+ */
177
+ #scroll(by: TspScrollBy): void {
178
+ const page = this.#frame.getBodyHeight();
179
+ switch (by) {
180
+ case "line-up":
181
+ this.#frame.scroll(-1);
182
+ break;
183
+ case "line-down":
184
+ this.#frame.scroll(1);
185
+ break;
186
+ case "page-up":
187
+ this.#frame.scroll(-page);
188
+ break;
189
+ case "page-down":
190
+ this.#frame.scroll(page);
191
+ break;
192
+ case "start":
193
+ this.#frame.setScrollOffset(0);
194
+ break;
195
+ case "end":
196
+ this.#frame.setFollowTail(true);
197
+ break;
163
198
  }
199
+ this.#nativeScroll = { by, n: (this.#nativeScroll?.n ?? 0) + 1 };
200
+ this.#onUpdate?.();
164
201
  }
165
202
 
166
203
  #handleMouse(event: SgrMouseEvent): boolean {
@@ -187,12 +224,154 @@ export class RawSseViewerComponent implements Component {
187
224
 
188
225
  invalidate(): void {
189
226
  this.#frame.invalidate();
227
+ this.#native.clear();
228
+ this.#nativeHead.clear();
229
+ this.#nativeBody.clear();
190
230
  }
191
231
 
192
232
  render(width: number): readonly string[] {
193
233
  return this.#frame.render(Math.max(MIN_VIEWER_WIDTH, width));
194
234
  }
195
235
 
236
+ /** The action bar's buttons run the same code as their keys. */
237
+ handleNativeEvent(event: NativeUiEvent): void {
238
+ if (event.type !== "action") return;
239
+ if (event.act === "copy") this.#copyAll();
240
+ else if (event.act === "close") this.#close();
241
+ }
242
+
243
+ /**
244
+ * The stream as a page of its own: the capture counters over the wire
245
+ * text, one `ansi` block that fills the page, scrolls itself and follows
246
+ * its tail until the user scrolls away (Tern-local, so no follow state
247
+ * here; the scroll keys reach it as `scroll` requests). The copy status
248
+ * and the buttons are this component's own node, docked under the page
249
+ * so they never scroll away.
250
+ */
251
+ describeScreen(): NativeScreen {
252
+ const snapshot = this.#buffer.snapshot();
253
+ const stream = this.#feedNative(snapshot);
254
+ const head = this.#nativeHead.get(
255
+ [snapshot.totalEvents, snapshot.records.length, snapshot.droppedRecords, snapshot.lastUpdatedAt],
256
+ () => this.#describeHead(snapshot),
257
+ );
258
+ const body = this.#nativeBody.get([stream, this.#nativeScroll], () =>
259
+ stream.length > 0
260
+ ? {
261
+ ...node(
262
+ "ansi",
263
+ { text: stream, follow: true, role: "omp.debug.stream", max: { h: 1 } },
264
+ undefined,
265
+ "stream",
266
+ ),
267
+ scroll: this.#nativeScroll,
268
+ }
269
+ : keyed(
270
+ col(
271
+ [
272
+ text("No raw SSE frames captured yet", { role: "omp.app.empty-title" }),
273
+ text([
274
+ span("HTTP SSE providers populate this view while a model response is streaming.", "muted"),
275
+ ]),
276
+ ],
277
+ { gap: "xs", align: "center", role: "omp.app.empty" },
278
+ ),
279
+ "empty",
280
+ ),
281
+ );
282
+ return { role: "omp.debug", main: [head, body], dock: [this] };
283
+ }
284
+
285
+ /** The docked bar under the stream page: the copy status and the buttons. */
286
+ describe(): NativeNode {
287
+ return this.#native.get([this.#statusMessage, this.#statusFailed], () =>
288
+ col(
289
+ compact([
290
+ this.#statusMessage !== undefined &&
291
+ keyed(
292
+ text([span(this.#statusMessage, this.#statusFailed ? "error" : "success")], {
293
+ wrap: "word",
294
+ role: "omp.app.status",
295
+ }),
296
+ "status",
297
+ ),
298
+ actionBar([
299
+ actionButton("Copy raw", "copy", { keys: "ctrl+c", tone: "accent" }),
300
+ null,
301
+ actionButton("Close", "close", { keys: "escape" }),
302
+ ]),
303
+ ]),
304
+ { role: "omp.debug.bar", gap: "sm" },
305
+ ),
306
+ );
307
+ }
308
+
309
+ #describeHead(snapshot: RawSseDebugSnapshot): NativeNode {
310
+ const stats = [
311
+ span(`${snapshot.totalEvents} events`, "num"),
312
+ span(" · ", "dim"),
313
+ span(`${snapshot.records.length} records`, "num"),
314
+ ];
315
+ if (snapshot.droppedRecords > 0) {
316
+ stats.push(span(" · ", "dim"), span(`${snapshot.droppedRecords} dropped`, "warning"));
317
+ }
318
+ stats.push(
319
+ span(" · ", "dim"),
320
+ snapshot.lastUpdatedAt
321
+ ? span(`last ${formatRawSseIsoTime(snapshot.lastUpdatedAt)}`, "muted")
322
+ : span("waiting for first frame", "muted"),
323
+ );
324
+ return keyed(
325
+ row([text("Raw provider stream", { role: "omp.app.title" }), text(stats, { truncate: "end" })], {
326
+ gap: "sm",
327
+ align: "center",
328
+ role: "omp.app.head",
329
+ }),
330
+ "head",
331
+ );
332
+ }
333
+
334
+ /**
335
+ * Brings {@link #nativeText} up to the live window: appends the records
336
+ * after {@link #nativeLast}, so evictions never rewrite what the terminal
337
+ * already holds. Starts over once the text outgrows its cap or the buffer
338
+ * was cleared.
339
+ */
340
+ #feedNative(snapshot: RawSseDebugSnapshot): string {
341
+ const records = snapshot.records;
342
+ const last = records.at(-1)?.sequence ?? 0;
343
+ if (
344
+ this.#nativeText.length > NATIVE_STREAM_CHAR_CAP ||
345
+ last < this.#nativeLast ||
346
+ snapshot.totalEvents < this.#nativeEvents ||
347
+ snapshot.droppedRecords < this.#nativeDropped
348
+ ) {
349
+ this.#nativeText = "";
350
+ this.#nativeLast = 0;
351
+ }
352
+ this.#nativeEvents = snapshot.totalEvents;
353
+ this.#nativeDropped = snapshot.droppedRecords;
354
+ if (last === this.#nativeLast) return this.#nativeText;
355
+ let fed = this.#nativeText;
356
+ for (const record of records) {
357
+ if (record.sequence <= this.#nativeLast) continue;
358
+ for (const line of this.#prettyLinesFor(record)) fed += `${sanitizeDisplayText(line)}\n`;
359
+ if (record.kind === "event" && record.truncated) {
360
+ fed += `${theme.fg("warning", `: omp-debug-event-truncated originalChars=${record.originalChars}`)}\n`;
361
+ }
362
+ fed += "\n";
363
+ }
364
+ this.#nativeText = fed;
365
+ this.#nativeLast = last;
366
+ this.#pruneCache(records[0]!.sequence);
367
+ return fed;
368
+ }
369
+
370
+ #close(): void {
371
+ this.dispose();
372
+ this.#onExit();
373
+ }
374
+
196
375
  #renderRawLines(innerWidth: number): string[] {
197
376
  const snapshot = this.#buffer.snapshot();
198
377
  if (snapshot.records.length === 0) {
@@ -264,6 +443,7 @@ export class RawSseViewerComponent implements Component {
264
443
  if (payload.trim().length === 0) {
265
444
  const message = "No raw SSE frames to copy";
266
445
  this.#statusMessage = message;
446
+ this.#statusFailed = true;
267
447
  this.#onStatus?.(message);
268
448
  this.#onUpdate?.();
269
449
  return;
@@ -273,10 +453,12 @@ export class RawSseViewerComponent implements Component {
273
453
  this.#deps.copyToClipboard(payload);
274
454
  const message = "Copied raw SSE stream";
275
455
  this.#statusMessage = message;
456
+ this.#statusFailed = false;
276
457
  this.#onStatus?.(message);
277
458
  } catch (error) {
278
459
  const message = error instanceof Error ? error.message : String(error);
279
460
  this.#statusMessage = `Copy failed: ${message}`;
461
+ this.#statusFailed = true;
280
462
  }
281
463
  this.#onUpdate?.();
282
464
  }
@@ -212,7 +212,8 @@ export class TspDocument {
212
212
  this.#focus = id;
213
213
  return;
214
214
  }
215
- case "reveal": {
215
+ case "reveal":
216
+ case "scroll": {
216
217
  const [, id] = op;
217
218
  this.#node(id);
218
219
  return;
@@ -64,6 +64,18 @@ export interface NativeHost {
64
64
  overlays(): readonly NativeOverlay[];
65
65
  /** Component receiving keyboard input. */
66
66
  focused(): Component | null;
67
+ /**
68
+ * The user clicked into a node described by `owners[0]` (then the
69
+ * components containing it, innermost first): move keyboard focus there.
70
+ * `field` is the outermost owner that takes keys and whose focus target is
71
+ * the clicked `editor`/`input`, if any; `sheet` tells the overlays that
72
+ * don't hold the keys while the user works beside them.
73
+ */
74
+ focusFromPointer(
75
+ owners: readonly Component[],
76
+ field: Component | null,
77
+ sheet: (overlay: Component) => boolean,
78
+ ): void;
67
79
  requestRender(): void;
68
80
  /** The terminal switched appearance. */
69
81
  appearanceChanged(dark: boolean): void;
@@ -661,6 +673,16 @@ export class NativeBackend {
661
673
  case "edit":
662
674
  this.#routeUiEvent(event);
663
675
  return;
676
+ case "focus": {
677
+ const reconciler = this.#surfaceFor(event.sf)?.reconciler;
678
+ const owners = reconciler?.owners(event.id) ?? [];
679
+ if (!reconciler || owners.length === 0) return;
680
+ const field =
681
+ owners.findLast(owner => owner.handleInput && reconciler.focusTarget(owner) === event.id) ?? null;
682
+ this.#host.focusFromPointer(owners, field, overlay => overlay.nativeSheet?.(this.#cx) === true);
683
+ this.#host.requestRender();
684
+ return;
685
+ }
664
686
  }
665
687
  }
666
688