@oh-my-pi/pi-tui 18.4.8 → 18.4.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -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 +8 -0
- 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/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 +22 -0
- 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/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 +52 -14
- package/src/windows-input-mode.ts +206 -0
|
@@ -1,10 +1,15 @@
|
|
|
1
|
+
import type { TspPickerGroup, TspPickerItem, TspTone } from "@oh-my-pi/pi-wire";
|
|
1
2
|
import { formatKeyHint, formatKeyHints } from "../../app-keybindings";
|
|
2
3
|
import type { Component } from "../../tui";
|
|
4
|
+
import { code } from "../../native/describe";
|
|
5
|
+
import { Memo } from "../../native/memo";
|
|
6
|
+
import type { DescribeContext, NativeNode, NativeUiEvent } from "../../native/node";
|
|
7
|
+
import { CLOSE_ACTION, picker, pickerAction, pickerEvent, pickerHits, pickerQuery } from "../../native/picker";
|
|
3
8
|
import { matchesKey } from "../../keys";
|
|
4
9
|
import { Input } from "../../components/input";
|
|
5
10
|
import { routeSgrMouseInput, type SgrMouseEvent } from "../../mouse";
|
|
6
11
|
import { padding, truncateToWidth, visibleWidth } from "../../utils";
|
|
7
|
-
import { sanitizeText } from "@oh-my-pi/pi-utils";
|
|
12
|
+
import { isRecord, sanitizeText } from "@oh-my-pi/pi-utils";
|
|
8
13
|
import { theme } from "../../theme/theme";
|
|
9
14
|
import { sanitizeDisplayText } from "../../overlays/extensions/display-text";
|
|
10
15
|
import { DebugViewerFrame, type DebugViewerFrameContent, type DebugViewerFrameContext } from "./viewer-frame";
|
|
@@ -24,6 +29,15 @@ export const LOAD_OLDER_LABEL = "### MOVE UP TO LOAD MORE...";
|
|
|
24
29
|
const INITIAL_LOG_CHUNK = 50;
|
|
25
30
|
const LOAD_OLDER_CHUNK = 50;
|
|
26
31
|
const MIN_LOG_VIEWER_WIDTH = 48;
|
|
32
|
+
/** Picker item id of the "load older entries" row. */
|
|
33
|
+
const OLDER_ITEM = "older";
|
|
34
|
+
/** Status-dot tone of a log level. */
|
|
35
|
+
const LEVEL_TONES: Readonly<Record<string, TspTone>> = {
|
|
36
|
+
error: "error",
|
|
37
|
+
warn: "warning",
|
|
38
|
+
info: "info",
|
|
39
|
+
debug: "muted",
|
|
40
|
+
};
|
|
27
41
|
|
|
28
42
|
type LogEntry = {
|
|
29
43
|
rawLine: string;
|
|
@@ -40,6 +54,47 @@ type DebugLogViewerModelOptions = {
|
|
|
40
54
|
loadOlderLogs?: (limitDays?: number) => Promise<string>;
|
|
41
55
|
};
|
|
42
56
|
|
|
57
|
+
/**
|
|
58
|
+
* One log entry as a picker row: the message as the label, the remaining
|
|
59
|
+
* fields as `key=value` detail, the local time and pid as facts and the
|
|
60
|
+
* level as the status dot. Lines that aren't JSON objects show as they are.
|
|
61
|
+
*/
|
|
62
|
+
function logPickerItem(id: string, rawLine: string): TspPickerItem {
|
|
63
|
+
let parsed: unknown;
|
|
64
|
+
try {
|
|
65
|
+
parsed = JSON.parse(rawLine);
|
|
66
|
+
} catch {}
|
|
67
|
+
if (!isRecord(parsed)) {
|
|
68
|
+
return { id, label: sanitizeDisplayText(rawLine).replace(/\s+/g, " "), mono: true };
|
|
69
|
+
}
|
|
70
|
+
const { timestamp, level, pid, message, ...rest } = parsed;
|
|
71
|
+
let detail = "";
|
|
72
|
+
for (const key in rest) {
|
|
73
|
+
const value = rest[key];
|
|
74
|
+
detail += `${detail ? " " : ""}${key}=${typeof value === "string" ? value : JSON.stringify(value)}`;
|
|
75
|
+
}
|
|
76
|
+
const time = typeof timestamp === "string" ? new Date(timestamp) : undefined;
|
|
77
|
+
return {
|
|
78
|
+
id,
|
|
79
|
+
label: sanitizeDisplayText(typeof message === "string" ? message : rawLine).replace(/\s+/g, " "),
|
|
80
|
+
detail: detail ? sanitizeDisplayText(detail).replace(/\s+/g, " ") : undefined,
|
|
81
|
+
dot: typeof level === "string" ? LEVEL_TONES[level] : undefined,
|
|
82
|
+
facts: {
|
|
83
|
+
time: time && Number.isFinite(time.getTime()) ? time.toLocaleTimeString(undefined, { hour12: false }) : "",
|
|
84
|
+
pid: typeof pid === "number" ? String(pid) : "",
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A log line pretty-printed for the preview pane: indented JSON when it parses, else the sanitized line. */
|
|
90
|
+
function logPreviewText(rawLine: string): { text: string; lang?: string } {
|
|
91
|
+
try {
|
|
92
|
+
return { text: JSON.stringify(JSON.parse(rawLine), null, 2), lang: "json" };
|
|
93
|
+
} catch {
|
|
94
|
+
return { text: sanitizeDisplayText(rawLine) };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
43
98
|
type ViewerRow =
|
|
44
99
|
| {
|
|
45
100
|
kind: "warning";
|
|
@@ -516,6 +571,10 @@ export class DebugLogViewerComponent implements Component {
|
|
|
516
571
|
#statusMessage: string | undefined;
|
|
517
572
|
#loadingOlder = false;
|
|
518
573
|
#bodyLineToRowIndex: Array<number | undefined> = [];
|
|
574
|
+
readonly #native = new Memo();
|
|
575
|
+
/** Picker rows by log index, valid while {@link #nativeItemsCount} entries are loaded (a prepend shifts indices). */
|
|
576
|
+
#nativeItems = new Map<number, TspPickerItem>();
|
|
577
|
+
#nativeItemsCount = 0;
|
|
519
578
|
|
|
520
579
|
constructor(options: DebugLogViewerComponentOptions) {
|
|
521
580
|
this.#deps = options.deps;
|
|
@@ -606,6 +665,24 @@ export class DebugLogViewerComponent implements Component {
|
|
|
606
665
|
return;
|
|
607
666
|
}
|
|
608
667
|
|
|
668
|
+
// Page and end keys jump the cursor; the frame (or the native list)
|
|
669
|
+
// keeps it in view.
|
|
670
|
+
const jump = matchesKey(keyData, "pageUp")
|
|
671
|
+
? -this.#frame.getBodyHeight()
|
|
672
|
+
: matchesKey(keyData, "pageDown")
|
|
673
|
+
? this.#frame.getBodyHeight()
|
|
674
|
+
: matchesKey(keyData, "home")
|
|
675
|
+
? -this.#model.rows.length
|
|
676
|
+
: matchesKey(keyData, "end")
|
|
677
|
+
? this.#model.rows.length
|
|
678
|
+
: 0;
|
|
679
|
+
if (jump !== 0) {
|
|
680
|
+
this.#statusMessage = undefined;
|
|
681
|
+
this.#model.moveCursor(jump, false);
|
|
682
|
+
this.#onUpdate?.();
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
|
|
609
686
|
if (matchesKey(keyData, "right")) {
|
|
610
687
|
this.#statusMessage = undefined;
|
|
611
688
|
if (this.#model.cursorRowKind === "load-older") {
|
|
@@ -666,12 +743,169 @@ export class DebugLogViewerComponent implements Component {
|
|
|
666
743
|
|
|
667
744
|
invalidate(): void {
|
|
668
745
|
this.#frame.invalidate();
|
|
746
|
+
this.#native.clear();
|
|
669
747
|
}
|
|
670
748
|
|
|
671
749
|
dispose(): void {
|
|
672
750
|
this.#frame.dispose();
|
|
673
751
|
}
|
|
674
752
|
|
|
753
|
+
/** A `picker` sheet floats over the transcript instead of taking the screen. */
|
|
754
|
+
nativeSheet(cx: DescribeContext): boolean {
|
|
755
|
+
return cx.supports("picker");
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/**
|
|
759
|
+
* The logs as a picker sheet: the filter as its query, one row per entry
|
|
760
|
+
* (message, fields, time, pid, level dot) with the session boundary as a
|
|
761
|
+
* group head, the range selection checked, the entry under the cursor
|
|
762
|
+
* pretty-printed below, and the keys' actions in the bar. Without the
|
|
763
|
+
* `picker` kind it falls back to rows.
|
|
764
|
+
*/
|
|
765
|
+
describe(cx: DescribeContext): NativeNode | null {
|
|
766
|
+
if (!cx.supports("picker")) return null;
|
|
767
|
+
const model = this.#model;
|
|
768
|
+
const query = this.#filter.getValue();
|
|
769
|
+
const cursor = this.#filter.getCursor();
|
|
770
|
+
const selected = model.getSelectedLogIndices();
|
|
771
|
+
return this.#native.get(
|
|
772
|
+
[
|
|
773
|
+
model.rows,
|
|
774
|
+
model.cursorRowIndex,
|
|
775
|
+
selected.join(","),
|
|
776
|
+
model.logCount,
|
|
777
|
+
model.isProcessFilterEnabled(),
|
|
778
|
+
model.canLoadOlder(),
|
|
779
|
+
query,
|
|
780
|
+
cursor,
|
|
781
|
+
this.#loadingOlder,
|
|
782
|
+
this.#statusMessage,
|
|
783
|
+
],
|
|
784
|
+
() => {
|
|
785
|
+
if (this.#nativeItemsCount !== model.logCount) {
|
|
786
|
+
this.#nativeItems = new Map();
|
|
787
|
+
this.#nativeItemsCount = model.logCount;
|
|
788
|
+
}
|
|
789
|
+
const items: TspPickerItem[] = [];
|
|
790
|
+
const order: (string | TspPickerGroup)[] = [];
|
|
791
|
+
const hits: Record<string, [number, number][]> = {};
|
|
792
|
+
const needle = query.toLowerCase();
|
|
793
|
+
for (const viewerRow of model.rows) {
|
|
794
|
+
if (viewerRow.kind === "warning") {
|
|
795
|
+
order.push({ group: "session", label: "This session" });
|
|
796
|
+
continue;
|
|
797
|
+
}
|
|
798
|
+
if (viewerRow.kind === "load-older") {
|
|
799
|
+
items.push({ id: OLDER_ITEM, label: "Load older entries", tone: "muted" });
|
|
800
|
+
order.push(OLDER_ITEM);
|
|
801
|
+
continue;
|
|
802
|
+
}
|
|
803
|
+
let item = this.#nativeItems.get(viewerRow.logIndex);
|
|
804
|
+
if (!item) {
|
|
805
|
+
item = logPickerItem(`l${viewerRow.logIndex}`, model.getRawLine(viewerRow.logIndex));
|
|
806
|
+
this.#nativeItems.set(viewerRow.logIndex, item);
|
|
807
|
+
}
|
|
808
|
+
items.push(item);
|
|
809
|
+
order.push(item.id);
|
|
810
|
+
if (needle && typeof item.label === "string") {
|
|
811
|
+
const found = pickerHits(item.label, [needle]);
|
|
812
|
+
if (found.length > 0) hits[item.id] = found;
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
const cursorLog = model.cursorLogIndex;
|
|
816
|
+
const preview = cursorLog === undefined ? undefined : logPreviewText(model.getRawLine(cursorLog));
|
|
817
|
+
const subtitle = [
|
|
818
|
+
`${model.visibleLogCount}/${model.logCount} entries`,
|
|
819
|
+
selected.length > 1 ? `${selected.length} selected` : undefined,
|
|
820
|
+
this.#loadingOlder ? "loading older…" : undefined,
|
|
821
|
+
]
|
|
822
|
+
.filter(part => part !== undefined)
|
|
823
|
+
.join(" · ");
|
|
824
|
+
return picker(
|
|
825
|
+
{
|
|
826
|
+
title: "Recent logs",
|
|
827
|
+
subtitle,
|
|
828
|
+
noun: "entries",
|
|
829
|
+
size: "lg",
|
|
830
|
+
layout: "rows",
|
|
831
|
+
preview: preview ? "below" : "none",
|
|
832
|
+
...pickerQuery(this.#filter),
|
|
833
|
+
placeholder: "Filter logs…",
|
|
834
|
+
columns: [
|
|
835
|
+
{ id: "pid", format: "dim", priority: 0 },
|
|
836
|
+
{ id: "time", format: "dim", priority: 1 },
|
|
837
|
+
],
|
|
838
|
+
items,
|
|
839
|
+
order,
|
|
840
|
+
hits,
|
|
841
|
+
selected:
|
|
842
|
+
model.cursorRowKind === "load-older"
|
|
843
|
+
? OLDER_ITEM
|
|
844
|
+
: cursorLog === undefined
|
|
845
|
+
? null
|
|
846
|
+
: `l${cursorLog}`,
|
|
847
|
+
current: selected.length > 1 ? selected.map(index => `l${index}`) : [],
|
|
848
|
+
total: model.logCount,
|
|
849
|
+
empty: query ? "No matching log entries" : "No log entries",
|
|
850
|
+
message: this.#statusMessage,
|
|
851
|
+
actions: [
|
|
852
|
+
pickerAction("copy", "Copy", "ctrl+c", { primary: true }),
|
|
853
|
+
pickerAction("all", "Select all", "ctrl+a"),
|
|
854
|
+
pickerAction("pid", "This process", "ctrl+p", { on: model.isProcessFilterEnabled() }),
|
|
855
|
+
pickerAction("older", "Load older", "ctrl+o", {
|
|
856
|
+
disabled: model.canLoadOlder() ? undefined : "No older log entries",
|
|
857
|
+
}),
|
|
858
|
+
CLOSE_ACTION,
|
|
859
|
+
],
|
|
860
|
+
},
|
|
861
|
+
preview && cursorLog !== undefined
|
|
862
|
+
? [{ ...code(preview.text, { lang: preview.lang, wrap: true }), key: `entry:${cursorLog}` }]
|
|
863
|
+
: undefined,
|
|
864
|
+
);
|
|
865
|
+
},
|
|
866
|
+
);
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
/**
|
|
870
|
+
* Pointer actions run the keys' code: a row click moves the cursor there,
|
|
871
|
+
* a second click copies it (or loads older entries from that row), and the
|
|
872
|
+
* bar's buttons mirror their keys.
|
|
873
|
+
*/
|
|
874
|
+
handleNativeEvent(event: NativeUiEvent): void {
|
|
875
|
+
const ev = pickerEvent(event);
|
|
876
|
+
if (!ev) return;
|
|
877
|
+
this.#statusMessage = undefined;
|
|
878
|
+
if (ev.kind === "action") {
|
|
879
|
+
switch (ev.act) {
|
|
880
|
+
case "copy":
|
|
881
|
+
this.#copySelected();
|
|
882
|
+
return;
|
|
883
|
+
case "all":
|
|
884
|
+
this.#model.selectAllVisible();
|
|
885
|
+
return;
|
|
886
|
+
case "pid":
|
|
887
|
+
this.#model.toggleProcessFilter();
|
|
888
|
+
return;
|
|
889
|
+
case "older":
|
|
890
|
+
void this.#handleLoadOlder(this.#frame.getBodyHeight() + 1);
|
|
891
|
+
return;
|
|
892
|
+
case "close":
|
|
893
|
+
this.#onExit();
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
return;
|
|
897
|
+
}
|
|
898
|
+
const rowIndex = this.#model.rows.findIndex(viewerRow =>
|
|
899
|
+
ev.item === OLDER_ITEM
|
|
900
|
+
? viewerRow.kind === "load-older"
|
|
901
|
+
: viewerRow.kind === "log" && `l${viewerRow.logIndex}` === ev.item,
|
|
902
|
+
);
|
|
903
|
+
if (rowIndex < 0 || !this.#model.moveCursorToRow(rowIndex, false)) return;
|
|
904
|
+
if (ev.kind !== "activate") return;
|
|
905
|
+
if (ev.item === OLDER_ITEM) void this.#handleLoadOlder();
|
|
906
|
+
else this.#copySelected();
|
|
907
|
+
}
|
|
908
|
+
|
|
675
909
|
render(width: number): readonly string[] {
|
|
676
910
|
return this.#frame.render(Math.max(MIN_LOG_VIEWER_WIDTH, width));
|
|
677
911
|
}
|
|
@@ -715,7 +949,7 @@ export class DebugLogViewerComponent implements Component {
|
|
|
715
949
|
}
|
|
716
950
|
|
|
717
951
|
#controlsText(): string {
|
|
718
|
-
return `${formatKeyHint("escape")} close · ${formatKeyHint("ctrl+c")} copy · ${formatKeyHints(["up", "down"])}/wheel move · click toggle · ${formatKeyHints(["shift+up", "shift+down"])} select · ${formatKeyHints(["left", "right"])} collapse/expand · ${formatKeyHint("ctrl+a")} all · ${formatKeyHint("ctrl+o")} older · ${formatKeyHint("ctrl+p")} pid`;
|
|
952
|
+
return `${formatKeyHint("escape")} close · ${formatKeyHint("ctrl+c")} copy · ${formatKeyHints(["up", "down"])}/wheel move · ${formatKeyHints(["pageUp", "pageDown"])} page · ${formatKeyHints(["home", "end"])} first/last · click toggle · ${formatKeyHints(["shift+up", "shift+down"])} select · ${formatKeyHints(["left", "right"])} collapse/expand · ${formatKeyHint("ctrl+a")} all · ${formatKeyHint("ctrl+o")} older · ${formatKeyHint("ctrl+p")} pid`;
|
|
719
953
|
}
|
|
720
954
|
|
|
721
955
|
#filterText(): string {
|
|
@@ -23,6 +23,8 @@ import { Text } from "../../components/text";
|
|
|
23
23
|
import { ImageProtocol, NotifyProtocol, TERMINAL } from "../../terminal-capabilities";
|
|
24
24
|
import { DynamicBorder } from "../../chrome/dynamic-border";
|
|
25
25
|
import { theme } from "../../theme/theme";
|
|
26
|
+
import { ansi } from "../../native/describe";
|
|
27
|
+
import type { NativeNode } from "../../native/node";
|
|
26
28
|
|
|
27
29
|
const PNG_SIGNATURE = Uint8Array.of(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a);
|
|
28
30
|
|
|
@@ -148,13 +150,20 @@ function imageProtocolLabel(): string {
|
|
|
148
150
|
/**
|
|
149
151
|
* Deliberate exception to the normal text/data components: OSC 66 probes must
|
|
150
152
|
* reach the terminal byte-for-byte, without sanitization, wrapping, clipping,
|
|
151
|
-
* or padding that could invalidate the protocol sample.
|
|
153
|
+
* or padding that could invalidate the protocol sample. A native terminal
|
|
154
|
+
* gets the same bytes as one `ansi` block.
|
|
152
155
|
*/
|
|
153
156
|
class RawLines implements Component {
|
|
154
157
|
readonly #lines: readonly string[];
|
|
158
|
+
readonly #native: NativeNode;
|
|
155
159
|
|
|
156
160
|
constructor(lines: readonly string[]) {
|
|
157
161
|
this.#lines = lines.slice();
|
|
162
|
+
this.#native = ansi(this.#lines.join("\n"));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
describe(): NativeNode {
|
|
166
|
+
return this.#native;
|
|
158
167
|
}
|
|
159
168
|
|
|
160
169
|
render(): readonly string[] {
|
|
@@ -1,6 +1,11 @@
|
|
|
1
|
+
import type { TspScrollBy } from "@oh-my-pi/pi-wire";
|
|
1
2
|
import { formatKeyHint } from "../../app-keybindings";
|
|
2
3
|
import type { Component } from "../../tui";
|
|
3
4
|
import { matchesKey } from "../../keys";
|
|
5
|
+
import { col, compact, keyed, node, row, span, text } from "../../native/describe";
|
|
6
|
+
import { Memo } from "../../native/memo";
|
|
7
|
+
import type { NativeNode, NativeScreen, NativeScroll, NativeUiEvent } from "../../native/node";
|
|
8
|
+
import { actionBar, actionButton } from "../../native/overlay";
|
|
4
9
|
import { routeSgrMouseInput, type SgrMouseEvent } from "../../mouse";
|
|
5
10
|
import { truncateToWidth } from "../../utils";
|
|
6
11
|
import { sanitizeDisplayText } from "../../overlays/extensions/display-text";
|
|
@@ -10,6 +15,7 @@ import {
|
|
|
10
15
|
formatRawSseIsoTime,
|
|
11
16
|
type RawSseDebugBuffer,
|
|
12
17
|
type RawSseDebugRecord,
|
|
18
|
+
type RawSseDebugSnapshot,
|
|
13
19
|
rawSseRecordLines,
|
|
14
20
|
} from "./raw-sse-buffer";
|
|
15
21
|
|
|
@@ -17,6 +23,9 @@ const MIN_VIEWER_WIDTH = 40;
|
|
|
17
23
|
// `data:` lines below this width render fine on a single row; anything wider gets pretty-printed
|
|
18
24
|
// across multiple `data:` lines so streamed JSON blobs stop getting clipped by `truncateToWidth`.
|
|
19
25
|
const PRETTY_PRINT_DATA_THRESHOLD = 100;
|
|
26
|
+
// The native body is fed append-only (a `text append` op per new record);
|
|
27
|
+
// past this many characters it starts over from the live window.
|
|
28
|
+
const NATIVE_STREAM_CHAR_CAP = 2_000_000;
|
|
20
29
|
|
|
21
30
|
// Walks the SSE wire lines and replaces single-line `data: <json>` payloads with
|
|
22
31
|
// multi-line `data: <indented-json>` entries when the JSON is wide enough to clip.
|
|
@@ -77,7 +86,20 @@ export class RawSseViewerComponent implements Component {
|
|
|
77
86
|
readonly #unsubscribe: () => void;
|
|
78
87
|
readonly #frame: DebugViewerFrame;
|
|
79
88
|
#statusMessage: string | undefined;
|
|
89
|
+
#statusFailed = false;
|
|
80
90
|
#disposed = false;
|
|
91
|
+
readonly #native = new Memo();
|
|
92
|
+
readonly #nativeHead = new Memo();
|
|
93
|
+
readonly #nativeBody = new Memo();
|
|
94
|
+
/** The last scroll key, forwarded to the stream block (see {@link NativeNode.scroll}). */
|
|
95
|
+
#nativeScroll: NativeScroll | undefined;
|
|
96
|
+
/** What the native `ansi` body was fed: every record after a rebase, in order, through {@link #nativeLast}. */
|
|
97
|
+
#nativeText = "";
|
|
98
|
+
/** Sequence of the last record in {@link #nativeText}. */
|
|
99
|
+
#nativeLast = 0;
|
|
100
|
+
/** Buffer counters at the last feed; a drop means the buffer was cleared. */
|
|
101
|
+
#nativeEvents = 0;
|
|
102
|
+
#nativeDropped = 0;
|
|
81
103
|
// Pretty-printed wire lines keyed by `record.sequence`. Pretty-printing is
|
|
82
104
|
// the JSON.parse + JSON.stringify per `data:` line, so we cache the result —
|
|
83
105
|
// the render path runs on every keypress and scroll update.
|
|
@@ -123,8 +145,7 @@ export class RawSseViewerComponent implements Component {
|
|
|
123
145
|
}
|
|
124
146
|
|
|
125
147
|
if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc")) {
|
|
126
|
-
this
|
|
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
|
@@ -64,6 +64,18 @@ export interface NativeHost {
|
|
|
64
64
|
overlays(): readonly NativeOverlay[];
|
|
65
65
|
/** Component receiving keyboard input. */
|
|
66
66
|
focused(): Component | null;
|
|
67
|
+
/**
|
|
68
|
+
* The user clicked into a node described by `owners[0]` (then the
|
|
69
|
+
* components containing it, innermost first): move keyboard focus there.
|
|
70
|
+
* `field` is the outermost owner that takes keys and whose focus target is
|
|
71
|
+
* the clicked `editor`/`input`, if any; `sheet` tells the overlays that
|
|
72
|
+
* don't hold the keys while the user works beside them.
|
|
73
|
+
*/
|
|
74
|
+
focusFromPointer(
|
|
75
|
+
owners: readonly Component[],
|
|
76
|
+
field: Component | null,
|
|
77
|
+
sheet: (overlay: Component) => boolean,
|
|
78
|
+
): void;
|
|
67
79
|
requestRender(): void;
|
|
68
80
|
/** The terminal switched appearance. */
|
|
69
81
|
appearanceChanged(dark: boolean): void;
|
|
@@ -661,6 +673,16 @@ export class NativeBackend {
|
|
|
661
673
|
case "edit":
|
|
662
674
|
this.#routeUiEvent(event);
|
|
663
675
|
return;
|
|
676
|
+
case "focus": {
|
|
677
|
+
const reconciler = this.#surfaceFor(event.sf)?.reconciler;
|
|
678
|
+
const owners = reconciler?.owners(event.id) ?? [];
|
|
679
|
+
if (!reconciler || owners.length === 0) return;
|
|
680
|
+
const field =
|
|
681
|
+
owners.findLast(owner => owner.handleInput && reconciler.focusTarget(owner) === event.id) ?? null;
|
|
682
|
+
this.#host.focusFromPointer(owners, field, overlay => overlay.nativeSheet?.(this.#cx) === true);
|
|
683
|
+
this.#host.requestRender();
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
664
686
|
}
|
|
665
687
|
}
|
|
666
688
|
|