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

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 (64) hide show
  1. package/CHANGELOG.md +41 -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/chat/assistant-message.d.ts +4 -3
  5. package/dist/types/chat/messages.d.ts +4 -0
  6. package/dist/types/chrome/transcript-container.d.ts +7 -0
  7. package/dist/types/components/markdown.d.ts +0 -4
  8. package/dist/types/native/backend.d.ts +8 -0
  9. package/dist/types/native/encode.d.ts +5 -1
  10. package/dist/types/native/node.d.ts +14 -1
  11. package/dist/types/native/reconcile.d.ts +5 -0
  12. package/dist/types/overlays/advisor-config.d.ts +8 -0
  13. package/dist/types/overlays/ask-dialog.d.ts +1 -0
  14. package/dist/types/overlays/extensions/extension-dashboard.d.ts +31 -0
  15. package/dist/types/overlays/extensions/extension-list.d.ts +37 -0
  16. package/dist/types/overlays/extensions/inspector-panel.d.ts +8 -0
  17. package/dist/types/overlays/jobs-panel.d.ts +35 -9
  18. package/dist/types/prompt/composer.d.ts +3 -1
  19. package/dist/types/prompt/word-completion.d.ts +12 -0
  20. package/dist/types/status-line/context-usage.d.ts +2 -0
  21. package/dist/types/terminal-capabilities.d.ts +5 -0
  22. package/dist/types/terminal.d.ts +7 -0
  23. package/dist/types/tools/bash-interactive.d.ts +15 -0
  24. package/dist/types/tools/output-meta.d.ts +8 -2
  25. package/dist/types/tools/streaming-output.d.ts +15 -10
  26. package/dist/types/tui.d.ts +8 -0
  27. package/dist/types/windows-input-mode.d.ts +26 -0
  28. package/package.json +9 -9
  29. package/src/apps/debug/log-viewer.ts +236 -2
  30. package/src/apps/debug/protocol-probe.ts +10 -1
  31. package/src/apps/debug/raw-sse.ts +210 -28
  32. package/src/chat/advisor-message.ts +4 -2
  33. package/src/chat/assistant-message.ts +7 -9
  34. package/src/chat/chat-transcript-builder.ts +1 -1
  35. package/src/chat/messages.ts +4 -0
  36. package/src/chrome/transcript-container.ts +13 -0
  37. package/src/components/markdown.ts +90 -130
  38. package/src/keys.ts +2 -4
  39. package/src/native/apply.ts +2 -1
  40. package/src/native/backend.ts +22 -0
  41. package/src/native/encode.ts +9 -2
  42. package/src/native/node.ts +15 -1
  43. package/src/native/reconcile.ts +28 -1
  44. package/src/overlays/advisor-config.ts +228 -2
  45. package/src/overlays/ask-dialog.ts +1 -0
  46. package/src/overlays/extensions/extension-dashboard.ts +275 -23
  47. package/src/overlays/extensions/extension-list.ts +274 -14
  48. package/src/overlays/extensions/inspector-panel.ts +368 -0
  49. package/src/overlays/hook-selector.ts +1 -1
  50. package/src/overlays/jobs-panel.ts +222 -49
  51. package/src/overlays/model-hub.ts +25 -4
  52. package/src/overlays/session-info-overlay.ts +55 -11
  53. package/src/prompt/composer-cache.ts +26 -3
  54. package/src/prompt/composer.ts +32 -7
  55. package/src/prompt/word-completion.ts +31 -13
  56. package/src/status-line/context-usage.ts +18 -2
  57. package/src/terminal-capabilities.ts +8 -0
  58. package/src/terminal.ts +43 -7
  59. package/src/tools/bash-interactive.ts +85 -16
  60. package/src/tools/output-meta.ts +13 -5
  61. package/src/tools/streaming-output.ts +48 -23
  62. package/src/tools/wait.ts +7 -19
  63. package/src/tui.ts +56 -14
  64. package/src/windows-input-mode.ts +206 -0
@@ -6,12 +6,11 @@ export declare const DEFAULT_MAX_BYTES: number;
6
6
  /** Default UTF-8 byte budget per match line. */
7
7
  export declare const DEFAULT_MAX_COLUMN = 512;
8
8
  /**
9
- * Default artifact-on-disk cap for {@link OutputSink}.
10
- *
11
- * `0` means unbounded: by default, `artifact://<id>` references preserve the
12
- * complete raw stream instead of a capped head/tail sample.
9
+ * Default artifact-on-disk cap for {@link OutputSink}: 16 MiB, split into a
10
+ * {@link ARTIFACT_DEFAULT_HEAD_BYTES} head and a rolling tail. `0` means
11
+ * unbounded (the complete raw stream is preserved).
13
12
  */
14
- export declare const ARTIFACT_DEFAULT_MAX_BYTES = 0;
13
+ export declare const ARTIFACT_DEFAULT_MAX_BYTES: number;
15
14
  /** Default head budget; the remainder becomes the rolling tail window. */
16
15
  export declare const ARTIFACT_DEFAULT_HEAD_BYTES: number;
17
16
  /** First failed artifact I/O operation; safe to persist without exposing filesystem errors. */
@@ -36,6 +35,11 @@ export interface OutputSummary {
36
35
  columnMax?: number;
37
36
  /** Artifact ID for internal URL access (artifact://<id>) when truncated */
38
37
  artifactId?: string;
38
+ /**
39
+ * Bytes the artifact cap dropped from the middle of the saved file. When
40
+ * set, `artifact://<id>` holds a head/tail sample, not the full output.
41
+ */
42
+ artifactElidedBytes?: number;
39
43
  /** Full raw output was not completely saved; artifactId is unavailable. */
40
44
  artifactError?: OutputArtifactError;
41
45
  }
@@ -67,17 +71,18 @@ export interface OutputSinkOptions {
67
71
  /** Minimum ms between onChunk calls. 0 = every chunk (default). */
68
72
  chunkThrottleMs?: number;
69
73
  /**
70
- * Optional cap on bytes written to the artifact-on-disk file. When the cap
71
- * is hit, the head window is preserved verbatim and subsequent output feeds
72
- * a rolling tail window; on close, the sink writes a single
74
+ * Cap on bytes written to the artifact-on-disk file. When the cap is hit,
75
+ * the head window is preserved verbatim and subsequent output feeds a
76
+ * rolling tail window; on close, the sink writes a single
73
77
  * `[ARTIFACT TRUNCATED: …]` notice between them. Default
74
- * {@link ARTIFACT_DEFAULT_MAX_BYTES} (unbounded).
78
+ * {@link ARTIFACT_DEFAULT_MAX_BYTES} (16 MiB); `0` = unbounded.
75
79
  */
76
80
  artifactMaxBytes?: number;
77
81
  /**
78
82
  * Bytes reserved for the head window of the capped artifact file. The
79
83
  * tail window receives `artifactMaxBytes - artifactHeadBytes`. Default
80
- * {@link ARTIFACT_DEFAULT_HEAD_BYTES}; clamped to `[0, artifactMaxBytes]`.
84
+ * {@link ARTIFACT_DEFAULT_HEAD_BYTES}, but at most half of
85
+ * `artifactMaxBytes`; clamped to `[0, artifactMaxBytes]`.
81
86
  */
82
87
  artifactHeadBytes?: number;
83
88
  }
@@ -111,6 +111,9 @@ export interface Component {
111
111
  * last time.
112
112
  */
113
113
  render(width: number): readonly string[];
114
+ /** Inline decision panels must keep displaced settled transcript rows in
115
+ * native scrollback even when mounted inside a transient editor container. */
116
+ readonly retireDisplacedTranscript?: boolean;
114
117
  /**
115
118
  * Describe the component semantically for a Tern Surface Protocol
116
119
  * terminal (see `native/node.ts`). Called instead of `render()` when the
@@ -330,6 +333,11 @@ export declare class TUI extends Container {
330
333
  options?: OverlayOptions;
331
334
  preFocus: Component | null;
332
335
  hidden: boolean;
336
+ /**
337
+ * A sheet the user clicked away from (see {@link TUI.#focusFromPointer}):
338
+ * it stays up but no longer holds the keys, until focus moves back into it.
339
+ */
340
+ released: boolean;
333
341
  }[];
334
342
  constructor(terminal: Terminal, showHardwareCursor?: boolean, options?: TUIOptions);
335
343
  /**
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Decode Windows console win32-input-mode key records into the key sequences
3
+ * the rest of the TUI already understands.
4
+ *
5
+ * Hosts without the kitty keyboard protocol (Windows Terminal before 1.25)
6
+ * deliver Shift+Enter as a bare `\r` (indistinguishable from Enter) and
7
+ * Ctrl+Enter as `\n`. Enabling `CSI ? 9001 h` makes the console host serving
8
+ * this process report every key as `CSI Vk ; Sc ; Uc ; Kd ; Cs ; Rc _`, which
9
+ * carries the full modifier state. Paste text, mouse reports and terminal
10
+ * replies keep arriving as plain VT, so only key records need translating.
11
+ *
12
+ * Unmodified and legacy-expressible keys translate to the bytes a legacy
13
+ * terminal sends; chords legacy encoding loses (modified Enter/Tab/Escape,
14
+ * Ctrl+Shift+letter, …) translate to kitty CSI-u, which key matching already
15
+ * parses.
16
+ */
17
+ /** Stateful decoder: joins UTF-16 surrogate halves that arrive as separate records. */
18
+ export declare class Win32InputModeDecoder {
19
+ #private;
20
+ /**
21
+ * Translate one stdin sequence. Returns `undefined` when `data` is not a
22
+ * win32-input-mode record (pass it through unchanged), otherwise the key
23
+ * events it produces — empty for key releases and bare modifier presses.
24
+ */
25
+ decode(data: string): string[] | undefined;
26
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-tui",
4
- "version": "18.4.8",
4
+ "version": "18.4.10",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": {
@@ -41,14 +41,14 @@
41
41
  "gen:glyphs": "bun scripts/gen-glyphs.ts"
42
42
  },
43
43
  "dependencies": {
44
- "@oh-my-pi/omptype": "18.4.8",
45
- "@oh-my-pi/pi-agent-core": "18.4.8",
46
- "@oh-my-pi/pi-ai": "18.4.8",
47
- "@oh-my-pi/pi-catalog": "18.4.8",
48
- "@oh-my-pi/pi-natives": "18.4.8",
49
- "@oh-my-pi/pi-utils": "18.4.8",
50
- "@oh-my-pi/pi-wire": "18.4.8",
51
- "@oh-my-pi/snapcompact": "18.4.8"
44
+ "@oh-my-pi/omptype": "18.4.10",
45
+ "@oh-my-pi/pi-agent-core": "18.4.10",
46
+ "@oh-my-pi/pi-ai": "18.4.10",
47
+ "@oh-my-pi/pi-catalog": "18.4.10",
48
+ "@oh-my-pi/pi-natives": "18.4.10",
49
+ "@oh-my-pi/pi-utils": "18.4.10",
50
+ "@oh-my-pi/pi-wire": "18.4.10",
51
+ "@oh-my-pi/snapcompact": "18.4.10"
52
52
  },
53
53
  "devDependencies": {
54
54
  "kitty-vt-wasm": "^0.2.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[] {