@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.
- package/CHANGELOG.md +30 -0
- package/dist/types/apps/debug/log-viewer.d.ts +17 -0
- package/dist/types/apps/debug/raw-sse.d.ts +14 -0
- package/dist/types/native/backend.d.ts +11 -2
- package/dist/types/native/encode.d.ts +5 -1
- package/dist/types/native/node.d.ts +14 -1
- package/dist/types/native/reconcile.d.ts +5 -0
- package/dist/types/overlays/extensions/extension-dashboard.d.ts +31 -0
- package/dist/types/overlays/extensions/extension-list.d.ts +37 -0
- package/dist/types/overlays/extensions/inspector-panel.d.ts +8 -0
- package/dist/types/overlays/jobs-panel.d.ts +35 -9
- package/dist/types/prompt/word-completion.d.ts +12 -0
- package/dist/types/terminal.d.ts +7 -0
- package/dist/types/theme/defaults/index.d.ts +700 -0
- package/dist/types/theme/schema.d.ts +18 -0
- package/dist/types/tools/bash-interactive.d.ts +15 -0
- package/dist/types/tools/output-meta.d.ts +8 -2
- package/dist/types/tools/streaming-output.d.ts +15 -10
- package/dist/types/tui.d.ts +5 -0
- package/dist/types/windows-input-mode.d.ts +26 -0
- package/package.json +9 -9
- package/src/apps/debug/log-viewer.ts +236 -2
- package/src/apps/debug/protocol-probe.ts +10 -1
- package/src/apps/debug/raw-sse.ts +210 -28
- package/src/native/apply.ts +2 -1
- package/src/native/backend.ts +66 -7
- package/src/native/encode.ts +7 -2
- package/src/native/node.ts +15 -1
- package/src/native/reconcile.ts +28 -1
- package/src/overlays/extensions/extension-dashboard.ts +275 -23
- package/src/overlays/extensions/extension-list.ts +274 -14
- package/src/overlays/extensions/inspector-panel.ts +368 -0
- package/src/overlays/hook-selector.ts +1 -1
- package/src/overlays/jobs-panel.ts +222 -49
- package/src/prompt/composer-cache.ts +26 -3
- package/src/prompt/word-completion.ts +31 -13
- package/src/terminal.ts +37 -1
- package/src/theme/dark.json +10 -0
- package/src/theme/defaults/alabaster.json +10 -0
- package/src/theme/defaults/amethyst.json +10 -0
- package/src/theme/defaults/anthracite.json +10 -0
- package/src/theme/defaults/basalt.json +10 -0
- package/src/theme/defaults/birch.json +10 -0
- package/src/theme/defaults/dark-abyss.json +10 -0
- package/src/theme/defaults/dark-arctic.json +10 -0
- package/src/theme/defaults/dark-aurora.json +10 -0
- package/src/theme/defaults/dark-catppuccin.json +10 -0
- package/src/theme/defaults/dark-cavern.json +10 -0
- package/src/theme/defaults/dark-celestial.json +10 -0
- package/src/theme/defaults/dark-copper.json +10 -0
- package/src/theme/defaults/dark-cosmos.json +10 -0
- package/src/theme/defaults/dark-cyberpunk.json +10 -0
- package/src/theme/defaults/dark-dracula.json +10 -0
- package/src/theme/defaults/dark-eclipse.json +10 -0
- package/src/theme/defaults/dark-ember.json +10 -0
- package/src/theme/defaults/dark-equinox.json +10 -0
- package/src/theme/defaults/dark-forest.json +10 -0
- package/src/theme/defaults/dark-github.json +10 -0
- package/src/theme/defaults/dark-gruvbox.json +10 -0
- package/src/theme/defaults/dark-lavender.json +10 -0
- package/src/theme/defaults/dark-lunar.json +10 -0
- package/src/theme/defaults/dark-midnight.json +10 -0
- package/src/theme/defaults/dark-monochrome.json +10 -0
- package/src/theme/defaults/dark-monokai.json +10 -0
- package/src/theme/defaults/dark-nebula.json +10 -0
- package/src/theme/defaults/dark-neon-noir.json +10 -0
- package/src/theme/defaults/dark-nord.json +10 -0
- package/src/theme/defaults/dark-ocean.json +10 -0
- package/src/theme/defaults/dark-one.json +10 -0
- package/src/theme/defaults/dark-poimandres.json +10 -0
- package/src/theme/defaults/dark-rainforest.json +10 -0
- package/src/theme/defaults/dark-reef.json +10 -0
- package/src/theme/defaults/dark-retro.json +10 -0
- package/src/theme/defaults/dark-rose-pine.json +10 -0
- package/src/theme/defaults/dark-sakura.json +10 -0
- package/src/theme/defaults/dark-slate.json +10 -0
- package/src/theme/defaults/dark-solarized.json +10 -0
- package/src/theme/defaults/dark-solstice.json +10 -0
- package/src/theme/defaults/dark-starfall.json +10 -0
- package/src/theme/defaults/dark-sunset.json +10 -0
- package/src/theme/defaults/dark-swamp.json +10 -0
- package/src/theme/defaults/dark-synthwave.json +10 -0
- package/src/theme/defaults/dark-taiga.json +10 -0
- package/src/theme/defaults/dark-terminal.json +10 -0
- package/src/theme/defaults/dark-tokyo-night.json +10 -0
- package/src/theme/defaults/dark-tundra.json +10 -0
- package/src/theme/defaults/dark-twilight.json +10 -0
- package/src/theme/defaults/dark-volcanic.json +10 -0
- package/src/theme/defaults/graphite.json +10 -0
- package/src/theme/defaults/light-arctic.json +10 -0
- package/src/theme/defaults/light-aurora-day.json +10 -0
- package/src/theme/defaults/light-canyon.json +10 -0
- package/src/theme/defaults/light-catppuccin.json +10 -0
- package/src/theme/defaults/light-cirrus.json +10 -0
- package/src/theme/defaults/light-coral.json +10 -0
- package/src/theme/defaults/light-cyberpunk.json +10 -0
- package/src/theme/defaults/light-dawn.json +10 -0
- package/src/theme/defaults/light-dunes.json +10 -0
- package/src/theme/defaults/light-eucalyptus.json +10 -0
- package/src/theme/defaults/light-forest.json +10 -0
- package/src/theme/defaults/light-frost.json +10 -0
- package/src/theme/defaults/light-github.json +10 -0
- package/src/theme/defaults/light-glacier.json +10 -0
- package/src/theme/defaults/light-gruvbox.json +10 -0
- package/src/theme/defaults/light-haze.json +10 -0
- package/src/theme/defaults/light-honeycomb.json +10 -0
- package/src/theme/defaults/light-lagoon.json +10 -0
- package/src/theme/defaults/light-lavender.json +10 -0
- package/src/theme/defaults/light-meadow.json +10 -0
- package/src/theme/defaults/light-mint.json +10 -0
- package/src/theme/defaults/light-monochrome.json +10 -0
- package/src/theme/defaults/light-ocean.json +10 -0
- package/src/theme/defaults/light-one.json +10 -0
- package/src/theme/defaults/light-opal.json +10 -0
- package/src/theme/defaults/light-orchard.json +10 -0
- package/src/theme/defaults/light-paper.json +10 -0
- package/src/theme/defaults/light-poimandres.json +10 -0
- package/src/theme/defaults/light-prism.json +10 -0
- package/src/theme/defaults/light-retro.json +10 -0
- package/src/theme/defaults/light-sand.json +10 -0
- package/src/theme/defaults/light-savanna.json +10 -0
- package/src/theme/defaults/light-solarized.json +10 -0
- package/src/theme/defaults/light-soleil.json +10 -0
- package/src/theme/defaults/light-sunset.json +10 -0
- package/src/theme/defaults/light-synthwave.json +10 -0
- package/src/theme/defaults/light-tokyo-night.json +10 -0
- package/src/theme/defaults/light-wetland.json +10 -0
- package/src/theme/defaults/light-zenith.json +10 -0
- package/src/theme/defaults/limestone.json +10 -0
- package/src/theme/defaults/mahogany.json +10 -0
- package/src/theme/defaults/marble.json +10 -0
- package/src/theme/defaults/obsidian.json +10 -0
- package/src/theme/defaults/onyx.json +10 -0
- package/src/theme/defaults/pearl.json +10 -0
- package/src/theme/defaults/porcelain.json +10 -0
- package/src/theme/defaults/quartz.json +10 -0
- package/src/theme/defaults/sandstone.json +10 -0
- package/src/theme/defaults/titanium.json +10 -0
- package/src/theme/light.json +10 -0
- package/src/theme/schema-validation.ts +7 -0
- package/src/theme/schema.ts +18 -0
- package/src/theme/theme-schema.json +30 -0
- package/src/tools/bash-interactive.ts +85 -16
- package/src/tools/output-meta.ts +13 -5
- package/src/tools/streaming-output.ts +48 -23
- package/src/tui.ts +56 -15
- 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
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
}
|
package/src/native/apply.ts
CHANGED
package/src/native/backend.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
#
|
|
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.#
|
|
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
|
|
package/src/native/encode.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
};
|
package/src/native/node.ts
CHANGED
|
@@ -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
|
|
package/src/native/reconcile.ts
CHANGED
|
@@ -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(
|