@oh-my-pi/pi-tui 18.13.0 → 18.13.2

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 (46) hide show
  1. package/CHANGELOG.md +36 -32
  2. package/dist/types/chrome/context-map.d.ts +15 -0
  3. package/dist/types/chrome/context-presentation.d.ts +48 -0
  4. package/dist/types/chrome/context-view-model.d.ts +77 -0
  5. package/dist/types/overlays/advisor-config.d.ts +6 -0
  6. package/dist/types/overlays/ask-dialog.d.ts +12 -3
  7. package/dist/types/overlays/context-viewer-format.d.ts +42 -0
  8. package/dist/types/overlays/context-viewer-overview.d.ts +70 -0
  9. package/dist/types/overlays/context-viewer.d.ts +25 -0
  10. package/dist/types/overlays/hook-input.d.ts +1 -0
  11. package/dist/types/overlays/pause-screen.d.ts +13 -3
  12. package/dist/types/overlays/running-subagent-badge.d.ts +7 -1
  13. package/dist/types/status-line/component.d.ts +2 -2
  14. package/dist/types/status-line/context-usage.d.ts +15 -30
  15. package/dist/types/theme/symbols.d.ts +1 -1
  16. package/dist/types/tools/ask.d.ts +4 -0
  17. package/dist/types/tools/fetch.d.ts +1 -0
  18. package/dist/types/tools/github.d.ts +3 -0
  19. package/dist/types/tools/read.d.ts +3 -0
  20. package/dist/types/tools/wait.d.ts +6 -0
  21. package/dist/types/tui.d.ts +2 -0
  22. package/package.json +9 -9
  23. package/src/chrome/context-map.ts +62 -0
  24. package/src/chrome/context-presentation.ts +26 -0
  25. package/src/chrome/context-view-model.ts +73 -0
  26. package/src/components/editor.ts +23 -0
  27. package/src/components/markdown.ts +14 -2
  28. package/src/overlays/advisor-config.ts +9 -2
  29. package/src/overlays/ask-dialog.ts +122 -35
  30. package/src/overlays/context-viewer-format.ts +145 -0
  31. package/src/overlays/context-viewer-overview.ts +381 -0
  32. package/src/overlays/context-viewer.ts +728 -0
  33. package/src/overlays/hook-input.ts +2 -0
  34. package/src/overlays/pause-screen.ts +166 -37
  35. package/src/overlays/running-subagent-badge.ts +12 -1
  36. package/src/render/output-pane.ts +3 -3
  37. package/src/status-line/component.ts +6 -4
  38. package/src/status-line/context-usage.ts +56 -580
  39. package/src/theme/symbols.ts +16 -0
  40. package/src/tools/ask.ts +4 -0
  41. package/src/tools/bash.ts +5 -23
  42. package/src/tools/fetch.ts +1 -0
  43. package/src/tools/github.ts +23 -3
  44. package/src/tools/read.ts +3 -1
  45. package/src/tools/wait.ts +6 -0
  46. package/src/tui.ts +23 -3
@@ -0,0 +1,728 @@
1
+ import type { TspSpan } from "@oh-my-pi/pi-wire";
2
+ import { CONTEXT_PRESENTATION } from "../chrome/context-presentation";
3
+ import type { ContextViewCategory, ContextViewData, ContextViewEntry } from "../chrome/context-view-model";
4
+ import { editorKey } from "../chrome/keybinding-hints";
5
+ import { OverlayPanel, PanelDivider, PanelRows } from "../chrome/overlay-box";
6
+ import { nativeSlotProps } from "../components/layout/geometry";
7
+ import { MenuSelection } from "../components/menu-selection";
8
+ import { ScrollView } from "../components/scroll-view";
9
+ import {
10
+ matchesSelectCancel,
11
+ matchesSelectDown,
12
+ matchesSelectPageDown,
13
+ matchesSelectPageUp,
14
+ matchesSelectUp,
15
+ } from "../keybinding-matchers";
16
+ import { getKeybindings } from "../keybindings";
17
+ import { col, node, row, span, text } from "../native/describe";
18
+ import type {
19
+ DescribeContext,
20
+ NativeNode,
21
+ NativeReveal,
22
+ NativeRevealAt,
23
+ NativeScroll,
24
+ NativeUiEvent,
25
+ } from "../native/node";
26
+ import { actionBar, actionButton, selectList } from "../native/overlay";
27
+ import { styledSpans } from "../native/spans";
28
+ import { ensureThemeSync, theme } from "../theme/theme";
29
+ import type { Component } from "../tui";
30
+ import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "../utils";
31
+ import {
32
+ entryHeaderLine,
33
+ entryHeaderSpans,
34
+ entryMetadata,
35
+ entryPreview,
36
+ groupHeaderLine,
37
+ previewHint,
38
+ previewRows,
39
+ safeContent,
40
+ safeText,
41
+ } from "./context-viewer-format";
42
+ import {
43
+ allocateUsage,
44
+ buildOverviewRows,
45
+ contextSnapcompactLines,
46
+ contextUsageSummary,
47
+ describeContextMeter,
48
+ legendItem,
49
+ legendLine,
50
+ MAP_LEGEND_GAP,
51
+ type MapGeometry,
52
+ mapGeometry,
53
+ mapRowLine,
54
+ mapRowSpans,
55
+ type OverviewRow,
56
+ overviewLabel,
57
+ planMapRows,
58
+ } from "./context-viewer-overview";
59
+
60
+ type ViewState = "overview" | "category" | "entry";
61
+
62
+ /** Rows of one entry inside the ANSI category preview. */
63
+ interface EntryRange {
64
+ start: number;
65
+ end: number;
66
+ complete: boolean;
67
+ }
68
+
69
+ const NATIVE_ENTRY_GUTTER_COLS = 2;
70
+ // Wrapper keys around native entry nodes; entry actions arrive at `<list>/<row>/<column>/<entry id>`.
71
+ const NATIVE_ENTRY_KEYS = { list: "entries", row: "content", column: "list" } as const;
72
+ const NATIVE_ENTRY_KEYPATH = `${NATIVE_ENTRY_KEYS.list}/${NATIVE_ENTRY_KEYS.row}/${NATIVE_ENTRY_KEYS.column}/`;
73
+
74
+ /**
75
+ * Read-only context snapshot with three states: Overview → category previews → full entry content.
76
+ * Navigation state is shared; `render` (ANSI) and `describe` (native) each draw every state,
77
+ * via `#render<State>` and `#describe<State>` respectively.
78
+ */
79
+ export class ContextViewer implements Component {
80
+ readonly #data: ContextViewData;
81
+ readonly #close: () => void;
82
+ readonly #requestRender: () => void;
83
+ readonly #getRows: () => number;
84
+ /** Attributed usage when it can be drawn against the window (meter and map). */
85
+ readonly #drawableAllocation: ReadonlyMap<ContextViewCategory, number> | undefined;
86
+ readonly #meter: readonly NativeNode[] | undefined;
87
+ /** Dim notes under the legend: the scaling note when rows are attributed, then Snapcompact diagnostics. */
88
+ readonly #overviewNotes: readonly string[];
89
+ readonly #modelLabel: string;
90
+
91
+ // Navigation.
92
+ #state: ViewState = "overview";
93
+ readonly #overviewSelection: MenuSelection<OverviewRow>;
94
+ #category: ContextViewCategory | undefined;
95
+ readonly #entrySelection = new MenuSelection<ContextViewEntry>([], {
96
+ getKey: entry => entry.id,
97
+ getSearchText: entry => entry.label,
98
+ });
99
+ #entry: ContextViewEntry | undefined;
100
+ /** Back target of the entry state; single-entry categories open straight from Overview. */
101
+ #entryParent: "overview" | "category" = "overview";
102
+ #disposed = false;
103
+
104
+ // Layout shared by both backends.
105
+ #bodyWidth = 0;
106
+ #bodyRows = 1;
107
+ #hour12: boolean | undefined;
108
+ /** Only ANSI input needs local full-content scroll geometry. */
109
+ #lastRenderMode: "ansi" | "native" = "ansi";
110
+ #entryBodyCache: { entry: ContextViewEntry; text: string } | undefined;
111
+
112
+ // ANSI backend.
113
+ readonly #panel = new OverlayPanel("Context");
114
+ readonly #header = new PanelRows();
115
+ readonly #body = new PanelRows();
116
+ readonly #footer = new PanelRows();
117
+ readonly #overviewScroll = new ScrollView([], { height: 1 });
118
+ readonly #ansiPreviewScroll = new ScrollView([], { height: 1 });
119
+ readonly #ansiEntryScroll = new ScrollView([], { height: 1 });
120
+ /** Previews of the open category only. */
121
+ #ansiPreviewCache:
122
+ | { category: ContextViewCategory; width: number; height: number; lines: string[]; ranges: EntryRange[] }
123
+ | undefined;
124
+ #ansiEntryLayoutWidth: number | undefined;
125
+
126
+ // Native backend.
127
+ #nativeOverlay:
128
+ | { category: ContextViewCategory | undefined; props: NonNullable<Component["nativeOverlay"]> }
129
+ | undefined;
130
+ /** Previews of the open category only. */
131
+ #nativePreviewCache:
132
+ | {
133
+ category: ContextViewCategory;
134
+ width: number;
135
+ height: number;
136
+ previews: Map<ContextViewEntry, { spans: TspSpan[] | undefined; complete: boolean }>;
137
+ }
138
+ | undefined;
139
+ #nativePreviewRevealAt: NativeRevealAt = "nearest";
140
+ // Native scroll requests, not a viewport offset. The reconciler repeats each
141
+ // line step by the increment in `n`.
142
+ #nativeScroll: NativeScroll = { by: "start", n: 0 };
143
+ /** Native reveal of the legend's first or last row when keyboard navigation hits the list edge. */
144
+ #overviewEdgeReveal: { key: string; reveal: NativeReveal } | undefined;
145
+
146
+ constructor(
147
+ data: ContextViewData,
148
+ options: { onClose: () => void; requestRender: () => void; getRows: () => number },
149
+ ) {
150
+ ensureThemeSync();
151
+ this.#data = data;
152
+ this.#close = options.onClose;
153
+ this.#requestRender = options.requestRender;
154
+ this.#getRows = options.getRows;
155
+ const { breakdown } = data;
156
+ this.#modelLabel = safeText(breakdown.model?.name ?? breakdown.model?.id ?? "no model");
157
+ const window = breakdown.contextWindow;
158
+ const allocation = allocateUsage(data.categories, breakdown.usedTokens);
159
+ this.#drawableAllocation = window > 0 && Number.isFinite(window) ? allocation : undefined;
160
+ this.#overviewNotes = [
161
+ ...(allocation
162
+ ? ["Category totals split Used by estimated share; the entries inside may not add up to them."]
163
+ : []),
164
+ ...contextSnapcompactLines(breakdown),
165
+ ];
166
+ const drawable = this.#drawableAllocation;
167
+ this.#meter = drawable
168
+ ? describeContextMeter({
169
+ ...breakdown,
170
+ categories: data.categories.map(category => ({
171
+ label: safeText(overviewLabel(category)),
172
+ tokens: drawable.get(category)!,
173
+ color: CONTEXT_PRESENTATION[category.id].color,
174
+ })),
175
+ })
176
+ : undefined;
177
+ this.#overviewSelection = new MenuSelection(buildOverviewRows(data, allocation), {
178
+ getKey: item => item.key,
179
+ getSearchText: item => item.label,
180
+ isDisabled: item => !item.category,
181
+ });
182
+ this.#footer.setHeight(1);
183
+ this.#panel.addChild(this.#header);
184
+ this.#panel.addChild(this.#body);
185
+ this.#panel.addChild(new PanelDivider());
186
+ this.#panel.addChild(this.#footer);
187
+ }
188
+
189
+ invalidate(): void {
190
+ this.#ansiPreviewCache = undefined;
191
+ this.#nativePreviewCache = undefined;
192
+ this.#resetEntryLayout();
193
+ this.#panel.invalidate();
194
+ }
195
+
196
+ dispose(): void {
197
+ this.#disposed = true;
198
+ this.#nativePreviewCache = undefined;
199
+ this.#resetEntryLayout();
200
+ this.#overviewScroll.dispose();
201
+ this.#ansiPreviewScroll.dispose();
202
+ this.#ansiEntryScroll.dispose();
203
+ this.#panel.dispose();
204
+ }
205
+
206
+ get nativeOverlay(): NonNullable<Component["nativeOverlay"]> {
207
+ const category = this.#state === "overview" ? undefined : this.#category;
208
+ if (!this.#nativeOverlay || this.#nativeOverlay.category !== category) {
209
+ this.#nativeOverlay = {
210
+ category,
211
+ props: {
212
+ role: "omp.overlay.report",
213
+ size: "lg",
214
+ anchor: "center",
215
+ head: [
216
+ span("Context", "strong"),
217
+ span(` · ${category ? safeText(category.label) : this.#modelLabel}`, "muted"),
218
+ ],
219
+ },
220
+ };
221
+ }
222
+ return this.#nativeOverlay.props;
223
+ }
224
+
225
+ // ── Navigation ──────────────────────────────────────────────────────────
226
+
227
+ /** Capture only handled navigation keys; the focus path consumes other modal input. */
228
+ capturesInput(data: string): boolean {
229
+ return (
230
+ matchesSelectCancel(data) ||
231
+ matchesSelectUp(data) ||
232
+ matchesSelectDown(data) ||
233
+ (this.#state === "entry" && (matchesSelectPageUp(data) || matchesSelectPageDown(data))) ||
234
+ getKeybindings().matches(data, "tui.select.confirm")
235
+ );
236
+ }
237
+
238
+ handleInput(data: string): void {
239
+ if (this.#disposed) return;
240
+ if (matchesSelectCancel(data)) this.#back();
241
+ else if (getKeybindings().matches(data, "tui.select.confirm")) this.#activate();
242
+ else if (matchesSelectUp(data)) this.#navigate(-1);
243
+ else if (matchesSelectDown(data)) this.#navigate(1);
244
+ else if (matchesSelectPageUp(data)) this.#page(-1);
245
+ else if (matchesSelectPageDown(data)) this.#page(1);
246
+ this.#requestRender();
247
+ }
248
+
249
+ handleNativeEvent(event: NativeUiEvent): void {
250
+ if (this.#disposed) return;
251
+ if (event.type === "action") {
252
+ if (event.act === "close") this.#close();
253
+ else if (event.act === "back") this.#back();
254
+ else if (this.#state === "category" && (event.act === "select-entry" || event.act === "open-entry")) {
255
+ const id = event.key.startsWith(NATIVE_ENTRY_KEYPATH) ? event.key.slice(NATIVE_ENTRY_KEYPATH.length) : "";
256
+ const entry = this.#entrySelection.items.find(entry => entry.id === id);
257
+ if (!entry) return;
258
+ this.#entrySelection.setSelectedKey(entry.id);
259
+ if (event.act === "open-entry") this.#activate();
260
+ }
261
+ } else if (this.#state === "overview" && (event.type === "select" || event.type === "activate")) {
262
+ const item = this.#overviewSelection.items.find(item => item.key === event.item);
263
+ if (!item?.category) return;
264
+ this.#overviewSelection.setSelectedKey(event.item);
265
+ if (event.type === "activate") this.#activate();
266
+ }
267
+ this.#requestRender();
268
+ }
269
+
270
+ #activate(): void {
271
+ if (this.#state === "overview") {
272
+ const activation = this.#overviewSelection.requestActivation();
273
+ if (activation.kind !== "confirmed" || !activation.item.category) return;
274
+ this.#category = activation.item.category;
275
+ this.#entrySelection.setItems(this.#category.entries, this.#category.entries[0]?.id);
276
+ this.#nativePreviewRevealAt = "nearest";
277
+ this.#ansiPreviewCache = undefined;
278
+ this.#ansiPreviewScroll.scrollToTop();
279
+ if (this.#category.entries.length === 1) this.#openEntry(this.#category.entries[0]!, "overview");
280
+ else this.#state = "category";
281
+ } else if (this.#state === "category") {
282
+ const entry = this.#entrySelection.selectedItem;
283
+ if (!entry) return;
284
+ this.#openEntry(entry, "category");
285
+ }
286
+ }
287
+
288
+ #openEntry(entry: ContextViewEntry, parent: "overview" | "category"): void {
289
+ this.#entry = entry;
290
+ this.#entryParent = parent;
291
+ this.#resetEntryLayout();
292
+ this.#ansiEntryScroll.scrollToTop();
293
+ this.#state = "entry";
294
+ if (this.#lastRenderMode === "ansi" && this.#bodyWidth > 0) this.#ensureAnsiEntryLayout();
295
+ }
296
+
297
+ #back(): void {
298
+ if (this.#state === "entry") {
299
+ this.#state = this.#entryParent;
300
+ if (this.#entryParent === "category") this.#nativePreviewRevealAt = "start";
301
+ this.#entry = undefined;
302
+ this.#resetEntryLayout();
303
+ this.#ansiEntryScroll.setLines([]);
304
+ } else if (this.#state === "category") this.#state = "overview";
305
+ else this.#close();
306
+ }
307
+
308
+ #navigate(delta: -1 | 1): void {
309
+ if (this.#state === "entry") {
310
+ if (this.#lastRenderMode === "ansi") {
311
+ if (this.#bodyWidth > 0) this.#ensureAnsiEntryLayout();
312
+ this.#ansiEntryScroll.scroll(delta);
313
+ }
314
+ } else if (this.#state === "overview") {
315
+ // Native reveals the moved selection itself; act only at the list edge, matching ANSI.
316
+ if (this.#overviewSelection.move(delta)) return;
317
+ this.#overviewScroll.scroll(delta);
318
+ // Past the edge selectable row only unselectable rows (Buffer, Free Space) remain: bring the
319
+ // edge row of the legend into view in one press.
320
+ const rows = this.#overviewSelection.items;
321
+ const target = delta > 0 ? rows.at(-1) : rows[0];
322
+ if (target) {
323
+ this.#overviewEdgeReveal = {
324
+ key: target.key,
325
+ reveal: { at: delta > 0 ? "end" : "start", n: (this.#overviewEdgeReveal?.reveal.n ?? 0) + 1 },
326
+ };
327
+ }
328
+ return;
329
+ } else {
330
+ this.#entrySelection.move(delta);
331
+ return;
332
+ }
333
+ this.#nativeScroll = {
334
+ by: delta > 0 ? "line-down" : "line-up",
335
+ n: this.#nativeScroll.n + 1,
336
+ };
337
+ }
338
+
339
+ /** Page the full-content view; lists keep single-step selection. */
340
+ #page(direction: -1 | 1): void {
341
+ if (this.#state !== "entry") return;
342
+ if (this.#lastRenderMode === "ansi") {
343
+ if (this.#bodyWidth > 0) this.#ensureAnsiEntryLayout();
344
+ // Keep one line of overlap so the reading position stays visible.
345
+ this.#ansiEntryScroll.scroll(direction * Math.max(1, this.#bodyRows - 1));
346
+ }
347
+ this.#nativeScroll = {
348
+ by: direction > 0 ? "page-down" : "page-up",
349
+ n: this.#nativeScroll.n + 1,
350
+ };
351
+ }
352
+
353
+ // ── Shared layout and content ───────────────────────────────────────────
354
+
355
+ /** Body height for the current terminal rows; returns the header summary that fits. */
356
+ #layout(): string[] {
357
+ const rows = Math.max(1, this.#getRows());
358
+ const summary = this.#summary().slice(0, Math.max(0, rows - 5));
359
+ this.#bodyRows = Math.max(0, rows - summary.length - 4);
360
+ return summary;
361
+ }
362
+
363
+ #summary(): string[] {
364
+ if (this.#state !== "overview") return [];
365
+ const labels = contextUsageSummary(this.#data.breakdown);
366
+ return [`Used ${labels.used}${labels.detail}`, labels.room];
367
+ }
368
+
369
+ /** Both backends wrap previews to `width` with the same row and source bounds. */
370
+ #preview(entry: ContextViewEntry, width: number): { lines: string[]; complete: boolean } {
371
+ return entryPreview(entry, {
372
+ width,
373
+ rows: previewRows(entry, this.#bodyRows),
374
+ sourceWidth: this.#bodyWidth,
375
+ hour12: this.#hour12,
376
+ });
377
+ }
378
+
379
+ #entryBodyText(): string {
380
+ const entry = this.#entry!;
381
+ if (this.#entryBodyCache?.entry !== entry) {
382
+ this.#entryBodyCache = { entry, text: safeContent(entry.text.trimEnd()) };
383
+ this.#ansiEntryLayoutWidth = undefined;
384
+ }
385
+ return this.#entryBodyCache.text;
386
+ }
387
+
388
+ #resetEntryLayout(): void {
389
+ this.#entryBodyCache = undefined;
390
+ this.#ansiEntryLayoutWidth = undefined;
391
+ }
392
+
393
+ // ── ANSI backend ────────────────────────────────────────────────────────
394
+
395
+ render(width: number): readonly string[] {
396
+ this.#lastRenderMode = "ansi";
397
+ this.#bodyWidth = Math.max(1, width - 4);
398
+ const terminalRows = Math.max(1, this.#getRows());
399
+ const summary = this.#layout();
400
+ this.#panel.title =
401
+ this.#state === "overview" ? `Context · ${this.#modelLabel}` : safeText(this.#category!.label);
402
+ this.#header.setHeight(summary.length);
403
+ this.#header.setLines(summary.map(line => truncateToWidth(theme.fg("dim", line), this.#bodyWidth)));
404
+ this.#body.setLines(
405
+ this.#state === "overview"
406
+ ? this.#renderOverview()
407
+ : this.#state === "category"
408
+ ? this.#renderCategory()
409
+ : this.#renderEntry(),
410
+ );
411
+ this.#body.setHeight(this.#bodyRows);
412
+ this.#footer.setLines([truncateToWidth(theme.fg("dim", this.#footerHints()), this.#bodyWidth)]);
413
+ const rendered = this.#panel.render(width);
414
+ // Short terminals keep the top border, footer, and bottom border, in that priority.
415
+ if (terminalRows >= 4) return rendered;
416
+ if (terminalRows === 3) return [rendered[0]!, rendered[rendered.length - 2]!, rendered[rendered.length - 1]!];
417
+ return terminalRows === 2 ? [rendered[0]!, rendered[rendered.length - 1]!] : [rendered[rendered.length - 1]!];
418
+ }
419
+
420
+ #footerHints(): string {
421
+ const navigation = [editorKey("tui.select.up"), editorKey("tui.select.down")].filter(Boolean).join("/");
422
+ const canOpen =
423
+ this.#state === "overview"
424
+ ? !!this.#overviewSelection.selectedItem?.category
425
+ : this.#state === "category" && !!this.#entrySelection.selectedItem;
426
+ const confirm = canOpen ? editorKey("tui.select.confirm") : "";
427
+ const cancel = editorKey("tui.select.cancel");
428
+ const movement = this.#state === "overview" ? "select/scroll" : this.#state === "category" ? "select" : "scroll";
429
+ const paging =
430
+ this.#state === "entry"
431
+ ? [editorKey("tui.select.pageUp"), editorKey("tui.select.pageDown")].filter(Boolean).join("/")
432
+ : "";
433
+ return [
434
+ navigation && `${navigation} ${movement}`,
435
+ paging && `${paging} page`,
436
+ confirm && `${confirm} open`,
437
+ cancel && `${cancel} ${this.#state === "overview" ? "close" : "back"}`,
438
+ ]
439
+ .filter(Boolean)
440
+ .join(" · ");
441
+ }
442
+
443
+ /** Map on the left when it fits; scrollable legend and Snapcompact diagnostics on the right. */
444
+ #renderOverview(): string[] {
445
+ const width = this.#bodyWidth;
446
+ const height = this.#bodyRows;
447
+ const allocation = this.#drawableAllocation;
448
+ const map = allocation ? mapGeometry(width, height) : undefined;
449
+ const listWidth = map ? width - map.width - MAP_LEGEND_GAP : width;
450
+ const legendLines = this.#overviewSelection.items.map(item =>
451
+ legendLine(item, this.#overviewSelection.selectedKey === item.key, Math.max(0, listWidth - 1)),
452
+ );
453
+ for (const line of this.#overviewNotes)
454
+ legendLines.push(...wrapTextWithAnsi(theme.fg("dim", line), Math.max(1, listWidth - 1)));
455
+ this.#overviewScroll.setHeight(height);
456
+ this.#overviewScroll.setLines(legendLines);
457
+ this.#overviewScroll.revealRange(
458
+ {
459
+ id: this.#overviewSelection.selectedKey ?? "",
460
+ start: this.#overviewSelection.selectedIndex,
461
+ end: this.#overviewSelection.selectedIndex + 1,
462
+ margin: 0,
463
+ },
464
+ listWidth,
465
+ );
466
+ const legend = this.#overviewScroll.render(listWidth);
467
+ if (!allocation || !map) return [...legend];
468
+ const mapRows = planMapRows(this.#data, allocation, map);
469
+ const lines: string[] = [];
470
+ for (let index = 0; index < height; index++) {
471
+ const cells = mapRows[index];
472
+ const grid = cells ? mapRowLine(cells, map) : "";
473
+ const padding = " ".repeat(Math.max(0, map.width - visibleWidth(grid)) + MAP_LEGEND_GAP);
474
+ lines.push(`${grid}${padding}${legend[index] ?? ""}`);
475
+ }
476
+ return lines;
477
+ }
478
+
479
+ /** Entry previews with the selected one marked by a gutter bar. */
480
+ #renderCategory(): string[] {
481
+ const contentWidth = Math.max(1, this.#bodyWidth - 2);
482
+ this.#prepareAnsiPreview(contentWidth, this.#bodyRows);
483
+ const range = this.#ansiPreviewCache!.ranges[this.#entrySelection.selectedIndex];
484
+ if (range)
485
+ this.#ansiPreviewScroll.revealRange(
486
+ {
487
+ id: this.#entrySelection.selectedKey!,
488
+ start: range.start,
489
+ end: range.end,
490
+ margin: 0,
491
+ oversized: "start",
492
+ },
493
+ contentWidth,
494
+ );
495
+ const offset = this.#ansiPreviewScroll.getScrollOffset();
496
+ return this.#ansiPreviewScroll.render(contentWidth).map((line, index) => {
497
+ const selected = range && offset + index >= range.start && offset + index < range.end;
498
+ if (selected && !range.complete && offset + index === range.end - 1)
499
+ line = truncateToWidth(previewHint(true), contentWidth - 1);
500
+ return `${selected ? theme.fg("accent", "┃ ") : " "}${line}`;
501
+ });
502
+ }
503
+
504
+ #prepareAnsiPreview(width: number, height: number): void {
505
+ const category = this.#category!;
506
+ const cached = this.#ansiPreviewCache;
507
+ this.#ansiPreviewScroll.setHeight(height);
508
+ if (cached?.category === category && cached.width === width && cached.height === height) return;
509
+ const lines: string[] = [];
510
+ const ranges: EntryRange[] = [];
511
+ const groups = new Set<string>();
512
+ for (const entry of category.entries) {
513
+ const start = lines.length;
514
+ const group = entry.thinkingGroup;
515
+ const indent = group ? " " : "";
516
+ if (group && !groups.has(group.id)) {
517
+ groups.add(group.id);
518
+ lines.push(truncateToWidth(groupHeaderLine(group, category.id), Math.max(1, width - 1)));
519
+ }
520
+ const entryWidth = Math.max(1, width - 1 - indent.length);
521
+ lines.push(`${indent}${truncateToWidth(entryHeaderLine(entry, category.id), entryWidth)}`);
522
+ const content = this.#preview(entry, entryWidth);
523
+ lines.push(...content.lines.map(line => `${indent}${line}`));
524
+ if (!content.complete) lines.push(previewHint(false));
525
+ ranges.push({ start, end: lines.length, complete: content.complete });
526
+ lines.push("");
527
+ }
528
+ this.#ansiPreviewCache = { category, width, height, lines, ranges };
529
+ this.#ansiPreviewScroll.setLines(lines);
530
+ }
531
+
532
+ #renderEntry(): string[] {
533
+ this.#ensureAnsiEntryLayout();
534
+ return [...this.#ansiEntryScroll.render(this.#bodyWidth)];
535
+ }
536
+
537
+ #ensureAnsiEntryLayout(): void {
538
+ const width = this.#bodyWidth;
539
+ const content = this.#entryBodyText();
540
+ this.#ansiEntryScroll.setHeight(this.#bodyRows);
541
+ if (this.#ansiEntryLayoutWidth !== width) {
542
+ const entry = this.#entry!;
543
+ const contentWidth = Math.max(1, width - 1);
544
+ const lines = [
545
+ truncateToWidth(entryHeaderLine(entry, this.#category!.id), contentWidth),
546
+ ...entryMetadata(entry, { hour12: this.#hour12, withGroup: true }).flatMap(line =>
547
+ wrapTextWithAnsi(theme.fg("dim", line), contentWidth),
548
+ ),
549
+ ...content.split("\n").flatMap(line => (line ? wrapTextWithAnsi(line, contentWidth) : [""])),
550
+ ];
551
+ this.#ansiEntryLayoutWidth = width;
552
+ this.#ansiEntryScroll.setLines(lines);
553
+ }
554
+ }
555
+
556
+ // ── Native backend ──────────────────────────────────────────────────────
557
+
558
+ describe(cx: DescribeContext): NativeNode {
559
+ this.#lastRenderMode = "native";
560
+ this.#bodyWidth = Math.max(1, cx.cols - 4);
561
+ if (this.#hour12 !== cx.hour12) {
562
+ this.#hour12 = cx.hour12;
563
+ this.invalidate();
564
+ }
565
+ this.#layout();
566
+ const children =
567
+ this.#state === "overview"
568
+ ? this.#describeOverview(cx)
569
+ : this.#state === "category"
570
+ ? [this.#describeCategory()]
571
+ : this.#describeEntry();
572
+ children.push(
573
+ actionBar([
574
+ null,
575
+ ...(this.#state !== "overview" ? [actionButton("Back", "back")] : []),
576
+ actionButton("Close", "close"),
577
+ ]),
578
+ );
579
+ return col(children, {
580
+ role: "omp.context",
581
+ // Overview and category lists own their scrollers, so the pinned actions never cover a revealed row.
582
+ ...(this.#state !== "entry" ? { grow: 1, min: { h: 0 } } : {}),
583
+ });
584
+ }
585
+
586
+ /** Meter (or summary text) above a scrollable legend, with the map beside it when there is no meter. */
587
+ #describeOverview(cx: DescribeContext): NativeNode[] {
588
+ const meter = cx.supports("meter") ? this.#meter : undefined;
589
+ const items = this.#overviewSelection.items.map(item => {
590
+ const legend = legendItem(
591
+ item,
592
+ this.#overviewSelection.selectedKey === item.key,
593
+ !!meter,
594
+ this.#data.breakdown.contextWindow,
595
+ );
596
+ const edge = this.#overviewEdgeReveal;
597
+ return edge?.key === item.key ? { ...legend, reveal: edge.reveal } : legend;
598
+ });
599
+ const list = selectList("categories", items, {
600
+ selected: this.#overviewSelection.selectedKey,
601
+ grow: 1,
602
+ min: { h: 0 },
603
+ max: { lines: Math.max(1, this.#bodyRows) },
604
+ });
605
+ // The map stands in for the meter on terminals without one.
606
+ const mapAllocation = meter ? undefined : this.#drawableAllocation;
607
+ const map = mapAllocation ? mapGeometry(this.#bodyWidth, this.#bodyRows) : undefined;
608
+ const body: NativeNode[] = [
609
+ mapAllocation && map ? row([this.#describeMap(mapAllocation, map), list], { gap: "lg" }) : list,
610
+ ...this.#overviewNotes.map(line => text([span(line, "dim")], { wrap: "word" })),
611
+ ];
612
+ const head = meter ?? this.#summary().map(line => text([span(line, "dim")], { wrap: "word" }));
613
+ return [...head, node("col", { grow: 1, min: { h: 0 } }, body, "overview")];
614
+ }
615
+
616
+ #describeMap(allocation: ReadonlyMap<ContextViewCategory, number>, map: MapGeometry): NativeNode {
617
+ return col(
618
+ planMapRows(this.#data, allocation, map).map(cells => text(mapRowSpans(cells, map), { wrap: "none" })),
619
+ );
620
+ }
621
+
622
+ /** Entry previews in a viewport-bounded list; the list owns wheel scrolling. */
623
+ #describeCategory(): NativeNode {
624
+ const groups = new Set<string>();
625
+ const items = this.#entrySelection.items.flatMap((entry, index) => [
626
+ this.#describePreview(entry, groups),
627
+ { ...text(" ", { wrap: "none" }), key: `separator-${index}` },
628
+ ]);
629
+ // list.max owns a wheel scroller; common max.h would only clip.
630
+ return node(
631
+ "list",
632
+ {
633
+ grow: 1,
634
+ min: { h: 0 },
635
+ max: { lines: Math.max(1, this.#bodyRows) },
636
+ hidden: this.#bodyRows === 0 ? true : undefined,
637
+ },
638
+ [
639
+ node(
640
+ "row",
641
+ { gap: "none", align: "start" },
642
+ [
643
+ // Native pick bars paint outside entry boxes, but must stay inside the viewport.
644
+ node("spacer", nativeSlotProps("w", { fixed: NATIVE_ENTRY_GUTTER_COLS }), undefined, "gutter"),
645
+ node("col", { grow: 1, min: { w: 0 }, gap: "none" }, items, NATIVE_ENTRY_KEYS.column),
646
+ ],
647
+ NATIVE_ENTRY_KEYS.row,
648
+ ),
649
+ ],
650
+ NATIVE_ENTRY_KEYS.list,
651
+ );
652
+ }
653
+
654
+ /** One entry: group heading on its first member, entry heading, preview, and truncation hint. */
655
+ #describePreview(entry: ContextViewEntry, groups: Set<string>): NativeNode {
656
+ const category = this.#category!.id;
657
+ const selected = entry.id === this.#entrySelection.selectedKey;
658
+ const group = entry.thinkingGroup;
659
+ const parts: NativeNode[] = [];
660
+ if (group && !groups.has(group.id)) {
661
+ groups.add(group.id);
662
+ parts.push({
663
+ ...text(styledSpans(groupHeaderLine(group, category)), { wrap: "word", lines: 1 }),
664
+ key: "group",
665
+ });
666
+ }
667
+ const header = entryHeaderSpans(entry, category);
668
+ parts.push({
669
+ ...text(group ? [span(" "), ...header] : header, { wrap: "word", lines: 1 }),
670
+ key: "head",
671
+ ...(selected ? { reveal: this.#nativePreviewRevealAt } : {}),
672
+ });
673
+ const content = this.#nativePreview(entry);
674
+ if (content.spans) parts.push({ ...text(content.spans, { wrap: "word" }), key: "preview" });
675
+ if (!content.complete)
676
+ parts.push({
677
+ ...text(styledSpans(previewHint(selected)), { wrap: "word", lines: 1 }),
678
+ key: "hint",
679
+ });
680
+ return {
681
+ ...node(
682
+ "col",
683
+ {
684
+ gap: "none",
685
+ mark: selected ? "pick" : undefined,
686
+ actions: { click: "select-entry", dblclick: "open-entry" },
687
+ },
688
+ parts,
689
+ entry.id,
690
+ ),
691
+ ...(selected ? { reveal: { at: "nearest" as const, n: 0 } } : {}),
692
+ };
693
+ }
694
+
695
+ #nativePreview(entry: ContextViewEntry): { spans: TspSpan[] | undefined; complete: boolean } {
696
+ const category = this.#category!;
697
+ let cache = this.#nativePreviewCache;
698
+ if (cache?.category !== category || cache.width !== this.#bodyWidth || cache.height !== this.#bodyRows) {
699
+ cache = { category, width: this.#bodyWidth, height: this.#bodyRows, previews: new Map() };
700
+ this.#nativePreviewCache = cache;
701
+ }
702
+ let preview = cache.previews.get(entry);
703
+ if (!preview) {
704
+ const indent = entry.thinkingGroup ? 2 : 0;
705
+ const content = this.#preview(entry, Math.max(1, this.#bodyWidth - 1 - NATIVE_ENTRY_GUTTER_COLS - indent));
706
+ // Tern owns final wrapping; only omitted source creates a more row.
707
+ const spans = content.lines.length ? styledSpans(content.lines.join("\n")) : undefined;
708
+ preview = { spans, complete: content.complete };
709
+ cache.previews.set(entry, preview);
710
+ }
711
+ return preview;
712
+ }
713
+
714
+ /** Entry heading above scrollable metadata and full content. */
715
+ #describeEntry(): NativeNode[] {
716
+ const entry = this.#entry!;
717
+ const body = node(
718
+ "col",
719
+ {},
720
+ [
721
+ ...entryMetadata(entry, { hour12: this.#hour12, withGroup: true }).map(line => text([span(line, "dim")])),
722
+ text(styledSpans(this.#entryBodyText()), { wrap: "word" }),
723
+ ],
724
+ `entry-${entry.id}`,
725
+ );
726
+ return [text(entryHeaderSpans(entry, this.#category!.id)), { ...body, scroll: this.#nativeScroll }];
727
+ }
728
+ }