@oh-my-pi/pi-tui 18.4.6 → 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 (147) hide show
  1. package/CHANGELOG.md +30 -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 +11 -2
  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/theme/defaults/index.d.ts +700 -0
  15. package/dist/types/theme/schema.d.ts +18 -0
  16. package/dist/types/tools/bash-interactive.d.ts +15 -0
  17. package/dist/types/tools/output-meta.d.ts +8 -2
  18. package/dist/types/tools/streaming-output.d.ts +15 -10
  19. package/dist/types/tui.d.ts +5 -0
  20. package/dist/types/windows-input-mode.d.ts +26 -0
  21. package/package.json +9 -9
  22. package/src/apps/debug/log-viewer.ts +236 -2
  23. package/src/apps/debug/protocol-probe.ts +10 -1
  24. package/src/apps/debug/raw-sse.ts +210 -28
  25. package/src/native/apply.ts +2 -1
  26. package/src/native/backend.ts +66 -7
  27. package/src/native/encode.ts +7 -2
  28. package/src/native/node.ts +15 -1
  29. package/src/native/reconcile.ts +28 -1
  30. package/src/overlays/extensions/extension-dashboard.ts +275 -23
  31. package/src/overlays/extensions/extension-list.ts +274 -14
  32. package/src/overlays/extensions/inspector-panel.ts +368 -0
  33. package/src/overlays/hook-selector.ts +1 -1
  34. package/src/overlays/jobs-panel.ts +222 -49
  35. package/src/prompt/composer-cache.ts +26 -3
  36. package/src/prompt/word-completion.ts +31 -13
  37. package/src/terminal.ts +37 -1
  38. package/src/theme/dark.json +10 -0
  39. package/src/theme/defaults/alabaster.json +10 -0
  40. package/src/theme/defaults/amethyst.json +10 -0
  41. package/src/theme/defaults/anthracite.json +10 -0
  42. package/src/theme/defaults/basalt.json +10 -0
  43. package/src/theme/defaults/birch.json +10 -0
  44. package/src/theme/defaults/dark-abyss.json +10 -0
  45. package/src/theme/defaults/dark-arctic.json +10 -0
  46. package/src/theme/defaults/dark-aurora.json +10 -0
  47. package/src/theme/defaults/dark-catppuccin.json +10 -0
  48. package/src/theme/defaults/dark-cavern.json +10 -0
  49. package/src/theme/defaults/dark-celestial.json +10 -0
  50. package/src/theme/defaults/dark-copper.json +10 -0
  51. package/src/theme/defaults/dark-cosmos.json +10 -0
  52. package/src/theme/defaults/dark-cyberpunk.json +10 -0
  53. package/src/theme/defaults/dark-dracula.json +10 -0
  54. package/src/theme/defaults/dark-eclipse.json +10 -0
  55. package/src/theme/defaults/dark-ember.json +10 -0
  56. package/src/theme/defaults/dark-equinox.json +10 -0
  57. package/src/theme/defaults/dark-forest.json +10 -0
  58. package/src/theme/defaults/dark-github.json +10 -0
  59. package/src/theme/defaults/dark-gruvbox.json +10 -0
  60. package/src/theme/defaults/dark-lavender.json +10 -0
  61. package/src/theme/defaults/dark-lunar.json +10 -0
  62. package/src/theme/defaults/dark-midnight.json +10 -0
  63. package/src/theme/defaults/dark-monochrome.json +10 -0
  64. package/src/theme/defaults/dark-monokai.json +10 -0
  65. package/src/theme/defaults/dark-nebula.json +10 -0
  66. package/src/theme/defaults/dark-neon-noir.json +10 -0
  67. package/src/theme/defaults/dark-nord.json +10 -0
  68. package/src/theme/defaults/dark-ocean.json +10 -0
  69. package/src/theme/defaults/dark-one.json +10 -0
  70. package/src/theme/defaults/dark-poimandres.json +10 -0
  71. package/src/theme/defaults/dark-rainforest.json +10 -0
  72. package/src/theme/defaults/dark-reef.json +10 -0
  73. package/src/theme/defaults/dark-retro.json +10 -0
  74. package/src/theme/defaults/dark-rose-pine.json +10 -0
  75. package/src/theme/defaults/dark-sakura.json +10 -0
  76. package/src/theme/defaults/dark-slate.json +10 -0
  77. package/src/theme/defaults/dark-solarized.json +10 -0
  78. package/src/theme/defaults/dark-solstice.json +10 -0
  79. package/src/theme/defaults/dark-starfall.json +10 -0
  80. package/src/theme/defaults/dark-sunset.json +10 -0
  81. package/src/theme/defaults/dark-swamp.json +10 -0
  82. package/src/theme/defaults/dark-synthwave.json +10 -0
  83. package/src/theme/defaults/dark-taiga.json +10 -0
  84. package/src/theme/defaults/dark-terminal.json +10 -0
  85. package/src/theme/defaults/dark-tokyo-night.json +10 -0
  86. package/src/theme/defaults/dark-tundra.json +10 -0
  87. package/src/theme/defaults/dark-twilight.json +10 -0
  88. package/src/theme/defaults/dark-volcanic.json +10 -0
  89. package/src/theme/defaults/graphite.json +10 -0
  90. package/src/theme/defaults/light-arctic.json +10 -0
  91. package/src/theme/defaults/light-aurora-day.json +10 -0
  92. package/src/theme/defaults/light-canyon.json +10 -0
  93. package/src/theme/defaults/light-catppuccin.json +10 -0
  94. package/src/theme/defaults/light-cirrus.json +10 -0
  95. package/src/theme/defaults/light-coral.json +10 -0
  96. package/src/theme/defaults/light-cyberpunk.json +10 -0
  97. package/src/theme/defaults/light-dawn.json +10 -0
  98. package/src/theme/defaults/light-dunes.json +10 -0
  99. package/src/theme/defaults/light-eucalyptus.json +10 -0
  100. package/src/theme/defaults/light-forest.json +10 -0
  101. package/src/theme/defaults/light-frost.json +10 -0
  102. package/src/theme/defaults/light-github.json +10 -0
  103. package/src/theme/defaults/light-glacier.json +10 -0
  104. package/src/theme/defaults/light-gruvbox.json +10 -0
  105. package/src/theme/defaults/light-haze.json +10 -0
  106. package/src/theme/defaults/light-honeycomb.json +10 -0
  107. package/src/theme/defaults/light-lagoon.json +10 -0
  108. package/src/theme/defaults/light-lavender.json +10 -0
  109. package/src/theme/defaults/light-meadow.json +10 -0
  110. package/src/theme/defaults/light-mint.json +10 -0
  111. package/src/theme/defaults/light-monochrome.json +10 -0
  112. package/src/theme/defaults/light-ocean.json +10 -0
  113. package/src/theme/defaults/light-one.json +10 -0
  114. package/src/theme/defaults/light-opal.json +10 -0
  115. package/src/theme/defaults/light-orchard.json +10 -0
  116. package/src/theme/defaults/light-paper.json +10 -0
  117. package/src/theme/defaults/light-poimandres.json +10 -0
  118. package/src/theme/defaults/light-prism.json +10 -0
  119. package/src/theme/defaults/light-retro.json +10 -0
  120. package/src/theme/defaults/light-sand.json +10 -0
  121. package/src/theme/defaults/light-savanna.json +10 -0
  122. package/src/theme/defaults/light-solarized.json +10 -0
  123. package/src/theme/defaults/light-soleil.json +10 -0
  124. package/src/theme/defaults/light-sunset.json +10 -0
  125. package/src/theme/defaults/light-synthwave.json +10 -0
  126. package/src/theme/defaults/light-tokyo-night.json +10 -0
  127. package/src/theme/defaults/light-wetland.json +10 -0
  128. package/src/theme/defaults/light-zenith.json +10 -0
  129. package/src/theme/defaults/limestone.json +10 -0
  130. package/src/theme/defaults/mahogany.json +10 -0
  131. package/src/theme/defaults/marble.json +10 -0
  132. package/src/theme/defaults/obsidian.json +10 -0
  133. package/src/theme/defaults/onyx.json +10 -0
  134. package/src/theme/defaults/pearl.json +10 -0
  135. package/src/theme/defaults/porcelain.json +10 -0
  136. package/src/theme/defaults/quartz.json +10 -0
  137. package/src/theme/defaults/sandstone.json +10 -0
  138. package/src/theme/defaults/titanium.json +10 -0
  139. package/src/theme/light.json +10 -0
  140. package/src/theme/schema-validation.ts +7 -0
  141. package/src/theme/schema.ts +18 -0
  142. package/src/theme/theme-schema.json +30 -0
  143. package/src/tools/bash-interactive.ts +85 -16
  144. package/src/tools/output-meta.ts +13 -5
  145. package/src/tools/streaming-output.ts +48 -23
  146. package/src/tui.ts +56 -15
  147. package/src/windows-input-mode.ts +206 -0
@@ -12,6 +12,24 @@ export interface ThemeJson {
12
12
  cardBg?: ColorValue;
13
13
  infoBg?: ColorValue;
14
14
  };
15
+ /**
16
+ * The terminal this theme was made for, for hosts that paint the terminal
17
+ * themselves (Tern). omp never sets the terminal's colors: its TUI draws on
18
+ * whatever background the terminal has. A host derives absent fields from
19
+ * `colors`.
20
+ */
21
+ terminal?: {
22
+ /** Terminal background. */
23
+ background?: ColorValue;
24
+ /** Default text. */
25
+ foreground?: ColorValue;
26
+ /** The window around the terminal: sidebar, tab bar, title bar. */
27
+ chrome?: ColorValue;
28
+ /** Lifted surfaces: popovers, menus, buttons. */
29
+ widget?: ColorValue;
30
+ /** ANSI colors 0–15: exactly 16. */
31
+ ansi?: ColorValue[];
32
+ };
15
33
  symbols?: {
16
34
  preset?: "unicode" | "nerd" | "ascii";
17
35
  overrides?: Record<string, string>;
@@ -1,5 +1,6 @@
1
1
  import type * as XtermModule from "@oh-my-pi/pi-utils/vterm";
2
2
  import type { Component } from "../tui.js";
3
+ import type { DescribeContext, NativeNode, NativeUiEvent } from "../native/node.js";
3
4
  import type { Theme } from "../theme/theme.js";
4
5
  /** Resize-only backend exposed by an interactive PTY controller. */
5
6
  export interface BashInteractiveTerminalBackend {
@@ -8,6 +9,12 @@ export interface BashInteractiveTerminalBackend {
8
9
  /** Interactive terminal overlay driven by an external PTY controller. */
9
10
  export declare class BashInteractiveOverlayComponent implements Component {
10
11
  #private;
12
+ /** Native sheet: a full-pane glass overlay titled Console. */
13
+ readonly nativeOverlay: {
14
+ readonly role: "omp.overlay.console";
15
+ readonly size: "full";
16
+ readonly head: "Console";
17
+ };
11
18
  constructor(command: string, uiTheme: Theme, getTerminalRows: () => number, terminalCtor: typeof XtermModule.Terminal, backend: BashInteractiveTerminalBackend);
12
19
  /** Connects normalized keyboard input and overlay lifecycle events to the controller. */
13
20
  setHandlers(onInput: (data: string) => void, onDismiss: () => void, onDispose: () => void): void;
@@ -22,6 +29,14 @@ export declare class BashInteractiveOverlayComponent implements Component {
22
29
  timedOut: boolean;
23
30
  }): void;
24
31
  handleInput(data: string): void;
32
+ /** The Force kill button runs the Esc path. */
33
+ handleNativeEvent(event: NativeUiEvent): void;
34
+ /**
35
+ * Native view: the command with its state badge, the PTY screen as an
36
+ * `ansi` node (SGR kept, fixed to the PTY grid), and a footer with the
37
+ * Force kill action while running.
38
+ */
39
+ describe(cx: DescribeContext): NativeNode;
25
40
  render(width: number): readonly string[];
26
41
  invalidate(): void;
27
42
  dispose(): void;
@@ -31,6 +31,8 @@ export interface TruncationMeta {
31
31
  elidedLines?: number;
32
32
  /** Artifact ID if full output was saved */
33
33
  artifactId?: string;
34
+ /** Bytes the artifact cap dropped from the saved file's middle; the artifact is then a head/tail sample. */
35
+ artifactElidedBytes?: number;
34
36
  /** Next offset for pagination (head truncation only) */
35
37
  nextOffset?: number;
36
38
  /**
@@ -87,6 +89,7 @@ export interface LimitsMeta {
87
89
  maxColumn: number;
88
90
  unit?: "bytes" | "chars";
89
91
  artifactId?: string;
92
+ artifactElidedBytes?: number;
90
93
  };
91
94
  }
92
95
  /**
@@ -111,8 +114,11 @@ export interface OutputMeta {
111
114
  * Messages that don't match the expected format are appended ungrouped at the end.
112
115
  */
113
116
  export declare function formatGroupedDiagnosticMessages(messages: string[]): string;
114
- /** Format a recoverable output artifact link. */
115
- export declare function formatFullOutputReference(artifactId: string): string;
117
+ /**
118
+ * Format a recoverable output artifact link. An artifact the size cap cut
119
+ * (`artifactElidedBytes > 0`) is labeled as the head/tail sample it holds.
120
+ */
121
+ export declare function formatFullOutputReference(artifactId: string, artifactElidedBytes?: number): string;
116
122
  /** Strip the last literal notice or a matching final line; optionally preserve surrounding whitespace. */
117
123
  export declare function stripTrailingNotice(text: string, notice: string | ((line: string) => boolean), trimResult?: boolean): string;
118
124
  /** Remove the trailing bash raw-output artifact footer while preserving its artifact id. */
@@ -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
  }
@@ -330,6 +330,11 @@ export declare class TUI extends Container {
330
330
  options?: OverlayOptions;
331
331
  preFocus: Component | null;
332
332
  hidden: boolean;
333
+ /**
334
+ * A sheet the user clicked away from (see {@link TUI.#focusFromPointer}):
335
+ * it stays up but no longer holds the keys, until focus moves back into it.
336
+ */
337
+ released: boolean;
333
338
  }[];
334
339
  constructor(terminal: Terminal, showHardwareCursor?: boolean, options?: TUIOptions);
335
340
  /**
@@ -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.6",
4
+ "version": "18.4.9",
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.6",
45
- "@oh-my-pi/pi-agent-core": "18.4.6",
46
- "@oh-my-pi/pi-ai": "18.4.6",
47
- "@oh-my-pi/pi-catalog": "18.4.6",
48
- "@oh-my-pi/pi-natives": "18.4.6",
49
- "@oh-my-pi/pi-utils": "18.4.6",
50
- "@oh-my-pi/pi-wire": "18.4.6",
51
- "@oh-my-pi/snapcompact": "18.4.6"
44
+ "@oh-my-pi/omptype": "18.4.9",
45
+ "@oh-my-pi/pi-agent-core": "18.4.9",
46
+ "@oh-my-pi/pi-ai": "18.4.9",
47
+ "@oh-my-pi/pi-catalog": "18.4.9",
48
+ "@oh-my-pi/pi-natives": "18.4.9",
49
+ "@oh-my-pi/pi-utils": "18.4.9",
50
+ "@oh-my-pi/pi-wire": "18.4.9",
51
+ "@oh-my-pi/snapcompact": "18.4.9"
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[] {