@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
@@ -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;
@@ -38,7 +38,7 @@ import {
38
38
  type NativeThemePalette,
39
39
  setNativeSymbolPreset,
40
40
  } from "../theme/theme";
41
- import type { Component, OverlayOptions } from "../tui";
41
+ import type { Component, OverlayOptions, RenderScheduler, RenderTimer } from "../tui";
42
42
  import { TspDocument } from "./apply";
43
43
  import { getNativeBlob } from "./blobs";
44
44
  import { node } from "./describe";
@@ -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;
@@ -80,9 +92,23 @@ export interface NativeBackendOptions {
80
92
  readonly recordPath?: string;
81
93
  /** Log the `rows` fallback count per frame. Defaults to `PI_TUI_NATIVE_STATS=1`. */
82
94
  readonly stats?: boolean;
83
- readonly now?: () => number;
95
+ /** Clock and timers (stall wake-up); defaults to `Date.now` and unref'd `setTimeout`. */
96
+ readonly scheduler?: RenderScheduler;
84
97
  }
85
98
 
99
+ /** Real clock and timers that never keep the process alive on their own. */
100
+ const DEFAULT_SCHEDULER: RenderScheduler = {
101
+ now: () => Date.now(),
102
+ scheduleImmediate: callback => {
103
+ setImmediate(callback);
104
+ },
105
+ scheduleRender: (callback, delayMs) => {
106
+ const timer = setTimeout(callback, delayMs);
107
+ timer.unref();
108
+ return { cancel: () => clearTimeout(timer) };
109
+ },
110
+ };
111
+
86
112
  /** Frames kept for the debug `tsp` op. */
87
113
  const RECENT_FRAMES = 64;
88
114
  /** An unanswered frame older than this no longer holds rendering back. */
@@ -216,7 +242,9 @@ export class NativeBackend {
216
242
  #mirror: boolean;
217
243
  #recordPath: string | undefined;
218
244
  #stats: boolean;
219
- #now: () => number;
245
+ #scheduler: RenderScheduler;
246
+ /** Wakes a render when the oldest unacked frame of a credit-blocked surface turns stalled. */
247
+ #stallTimer: RenderTimer | undefined;
220
248
  #recent: TspFrame[] = [];
221
249
  #sawResize = false;
222
250
  #live = false;
@@ -237,7 +265,7 @@ export class NativeBackend {
237
265
  this.#mirror = options.mirror === true;
238
266
  this.#recordPath = options.recordPath ?? (Bun.env.PI_TUI_TSP_RECORD || undefined);
239
267
  this.#stats = options.stats ?? Bun.env.PI_TUI_NATIVE_STATS === "1";
240
- this.#now = options.now ?? Date.now;
268
+ this.#scheduler = options.scheduler ?? DEFAULT_SCHEDULER;
241
269
  this.#inline = this.#newSurface("inline");
242
270
  }
243
271
 
@@ -338,6 +366,7 @@ export class NativeBackend {
338
366
  this.#unbindTheme?.();
339
367
  this.#unbindTheme = undefined;
340
368
  this.#useNerdSymbols(false);
369
+ this.#clearStallTimer();
341
370
  }
342
371
 
343
372
  /**
@@ -440,6 +469,7 @@ export class NativeBackend {
440
469
  this.#pruneOverlayNodes(overlays);
441
470
  if (!this.#hasCredit(surface)) {
442
471
  surface.dirty = true;
472
+ this.#armStallTimer(surface);
443
473
  return;
444
474
  }
445
475
  surface.dirty = false;
@@ -504,7 +534,7 @@ export class NativeBackend {
504
534
  #hasCredit(surface: Surface): boolean {
505
535
  if (surface.unacked.length < this.#credits) return true;
506
536
  const oldest = surface.unacked[0]!;
507
- if (this.#now() - oldest < STALLED_ACK_MS) return false;
537
+ if (this.#scheduler.now() - oldest < STALLED_ACK_MS) return false;
508
538
  logger.warn("TSP: terminal stopped acknowledging frames; resuming without credits", {
509
539
  sf: surface.id,
510
540
  s: surface.seq,
@@ -515,9 +545,27 @@ export class NativeBackend {
515
545
  return true;
516
546
  }
517
547
 
548
+ /** Render again once `surface`'s oldest unacked frame counts as stalled, in case no ack ever arrives. */
549
+ #armStallTimer(surface: Surface): void {
550
+ if (this.#stallTimer) return;
551
+ const delay = surface.unacked[0]! + STALLED_ACK_MS - this.#scheduler.now();
552
+ this.#stallTimer = this.#scheduler.scheduleRender(
553
+ () => {
554
+ this.#stallTimer = undefined;
555
+ this.#host.requestRender();
556
+ },
557
+ Math.max(0, delay),
558
+ );
559
+ }
560
+
561
+ #clearStallTimer(): void {
562
+ this.#stallTimer?.cancel();
563
+ this.#stallTimer = undefined;
564
+ }
565
+
518
566
  #sendFrame(surface: Surface, ops: readonly TspOp[]): void {
519
567
  surface.seq++;
520
- surface.unacked.push(this.#now());
568
+ surface.unacked.push(this.#scheduler.now());
521
569
  const frame: TspFrame = { sf: surface.id, s: surface.seq, ops };
522
570
  if (surface.doc) {
523
571
  const errors = surface.doc.applyFrame(frame);
@@ -563,7 +611,7 @@ export class NativeBackend {
563
611
  }
564
612
  }
565
613
  try {
566
- fs.appendFileSync(path, `${JSON.stringify({ t: this.#now(), dir, verb, params, body: payload })}\n`);
614
+ fs.appendFileSync(path, `${JSON.stringify({ t: this.#scheduler.now(), dir, verb, params, body: payload })}\n`);
567
615
  } catch (error) {
568
616
  logger.warn("TSP: recording failed; disabling", { path, error: String(error) });
569
617
  this.#recordPath = undefined;
@@ -583,6 +631,7 @@ export class NativeBackend {
583
631
  const newly = Math.min(event.s, surface.seq) - surface.acked;
584
632
  surface.acked = Math.min(event.s, surface.seq);
585
633
  surface.unacked.splice(0, newly);
634
+ this.#clearStallTimer();
586
635
  if (surface.dirty) this.#host.requestRender();
587
636
  return;
588
637
  }
@@ -624,6 +673,16 @@ export class NativeBackend {
624
673
  case "edit":
625
674
  this.#routeUiEvent(event);
626
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
+ }
627
686
  }
628
687
  }
629
688
 
@@ -106,9 +106,13 @@ export function encodeTspJson(verb: TspVerb, value: unknown, params?: TspParams,
106
106
  return encodeTspMessage(verb, JSON.stringify(value), params, limit);
107
107
  }
108
108
 
109
- /** The `hello` query; callers follow it with a DA1 sentinel. */
109
+ /**
110
+ * The `hello` query; callers follow it with a DA1 sentinel. `features: ["edit"]`
111
+ * tells the terminal that omp applies its `edit` events (TSP §8.5), so it may keep a
112
+ * native selection in omp's editors; without it, every key stays omp's.
113
+ */
110
114
  export function encodeTspHelloQuery(version?: string): string {
111
- return encodeTspJson("q", { q: "hello", v: [TSP_VERSION], app: "omp", ver: version });
115
+ return encodeTspJson("q", { q: "hello", v: [TSP_VERSION], app: "omp", features: ["edit"], ver: version });
112
116
  }
113
117
 
114
118
  /** One decoded APC message: verb, parameters and raw body. */
@@ -166,6 +170,7 @@ const EVENT_REQUIRED: Readonly<Record<string, Readonly<Record<string, "string" |
166
170
  activate: { id: "string", item: "string" },
167
171
  action: { id: "string", act: "string" },
168
172
  edit: { id: "string", from: "number", to: "number", text: "string", cursor: "number", len: "number" },
173
+ focus: { id: "string" },
169
174
  error: { msg: "string" },
170
175
  gone: { ids: "array" },
171
176
  };
@@ -13,7 +13,7 @@
13
13
  * See `packages/wire/src/tsp.ts` for the wire vocabulary and
14
14
  * `crates/tern/SURFACE_PROTOCOL.md` (Stencil repository) for the spec.
15
15
  */
16
- import type { TspEvent, TspKind, TspProps, TspSpan } from "@oh-my-pi/pi-wire";
16
+ import type { TspEvent, TspKind, TspProps, TspScrollBy, TspSpan } from "@oh-my-pi/pi-wire";
17
17
  import type { Component } from "../tui";
18
18
 
19
19
  /** A described node: a wire node minus its id, with components allowed as children. */
@@ -33,9 +33,23 @@ export type NativeNode = {
33
33
  * added. Key a node by what it points at to reveal it again on a move.
34
34
  */
35
35
  readonly reveal?: "start" | "end" | "nearest";
36
+ /**
37
+ * Keyboard scrolling forwarded to the terminal (PgUp/PgDn/End reach the
38
+ * program): moves the scroller at or above the node by `by` whenever
39
+ * `n` differs from the previous description of the same node. Bump `n`
40
+ * per key press; presses between two frames repeat the latest `by` once
41
+ * each (`start`/`end` once). A freshly added node never scrolls.
42
+ */
43
+ readonly scroll?: NativeScroll;
36
44
  };
37
45
  }[TspKind];
38
46
 
47
+ /** A {@link NativeNode.scroll} request. */
48
+ export interface NativeScroll {
49
+ readonly by: TspScrollBy;
50
+ readonly n: number;
51
+ }
52
+
39
53
  /** A child slot: a described node, or a component that describes itself. */
40
54
  export type NativeChild = NativeNode | Component;
41
55
 
@@ -28,7 +28,7 @@
28
28
  * into `layer`; their anchor keypaths are rewritten to wire ids.
29
29
  */
30
30
  import * as logger from "@oh-my-pi/pi-utils/logger";
31
- import { TSP_TEXT_KINDS, type TspKind, type TspNode, type TspOp } from "@oh-my-pi/pi-wire";
31
+ import { TSP_TEXT_KINDS, type TspKind, type TspNode, type TspOp, type TspScrollBy } from "@oh-my-pi/pi-wire";
32
32
  import { type Component, Container, CURSOR_MARKER } from "../tui";
33
33
  import { normalizeIconProps } from "./icons";
34
34
  import type { DescribeContext, NativeChild, NativeNode } from "./node";
@@ -58,6 +58,8 @@ const REGION_IDS = ["main", "dock", "layer"] as const;
58
58
  type RegionId = (typeof REGION_IDS)[number];
59
59
  const RESERVED_IDS: ReadonlySet<string> = new Set(REGION_IDS);
60
60
  const TEXT_KINDS: ReadonlySet<string> = new Set(TSP_TEXT_KINDS);
61
+ /** Most `scroll` ops one node's coalesced key presses send in a frame. */
62
+ const MAX_SCROLL_REPEAT = 32;
61
63
  let nextComponentId = 0;
62
64
 
63
65
  /** Stable base-36 wire id of a component instance (its root node's id). */
@@ -213,6 +215,8 @@ export class Reconciler {
213
215
  #settles: string[] = [];
214
216
  /** Selected list items and added `reveal` nodes sent this frame, revealed once the frame's adds have landed. */
215
217
  #reveals: [id: string, at: "start" | "end" | "nearest"][] = [];
218
+ /** `scroll` requests whose `n` moved this frame, sent after the reveals. */
219
+ #scrolls: [id: string, by: TspScrollBy][] = [];
216
220
  /** Wire ids `list.selected` keys resolved to (an unresolved key names no node to reveal). */
217
221
  #selectedIds = new Set<string>();
218
222
  #moves: PendingMove[] = [];
@@ -250,6 +254,7 @@ export class Reconciler {
250
254
  this.#dels = [];
251
255
  this.#settles = [];
252
256
  this.#reveals = [];
257
+ this.#scrolls = [];
253
258
  this.#selectedIds.clear();
254
259
  this.#framePortals = [];
255
260
  this.#rows = 0;
@@ -293,6 +298,7 @@ export class Reconciler {
293
298
  this.#parked = [];
294
299
  for (const id of this.#settles) this.#ops.push(["settle", id]);
295
300
  for (const [id, at] of this.#reveals) this.#ops.push(["reveal", id, at]);
301
+ if (cx.feature("scroll")) for (const [id, by] of this.#scrolls) this.#ops.push(["scroll", id, by]);
296
302
  const ops = this.#ops;
297
303
  this.#ops = [];
298
304
  return this.#gone.size === 0 ? ops : ops.filter(op => !this.#touchesGone(op));
@@ -339,6 +345,19 @@ export class Reconciler {
339
345
  return { component: state.comp, keypath };
340
346
  }
341
347
 
348
+ /**
349
+ * The component that described wire node `id`, then each component whose
350
+ * node contains it, innermost first (empty for an unknown id).
351
+ */
352
+ owners(id: string): Component[] {
353
+ const dot = id.indexOf(".");
354
+ const out: Component[] = [];
355
+ for (let state = this.#byId.get(dot === -1 ? id : id.slice(0, dot)) ?? null; state; state = state.container) {
356
+ out.push(state.comp);
357
+ }
358
+ return out;
359
+ }
360
+
342
361
  /** The key a list item node was described with (its key, else its child index). */
343
362
  itemKey(id: string): string {
344
363
  const dot = id.indexOf(".");
@@ -583,6 +602,14 @@ export class Reconciler {
583
602
  this.#flushMoves();
584
603
  return;
585
604
  }
605
+ const scroll = next.node.scroll;
606
+ if (scroll && scroll.n !== old.node.scroll?.n) {
607
+ // Presses described in one frame coalesce: repeat the latest step once per
608
+ // press (an end once), so key repeat keeps its distance.
609
+ const jump = scroll.by === "start" || scroll.by === "end";
610
+ const presses = jump ? 1 : Math.min(Math.max(scroll.n - (old.node.scroll?.n ?? 0), 1), MAX_SCROLL_REPEAT);
611
+ for (let i = 0; i < presses; i++) this.#scrolls.push([next.id, scroll.by]);
612
+ }
586
613
  const oldEntries = this.#entries(old.node.c, old.keypath, old.owner, old.hoist, null);
587
614
  const newEntries = this.#entries(next.node.c, next.keypath, next.owner, next.hoist, inner);
588
615
  this.#diffProps(