@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,381 @@
1
+ /**
2
+ * Overview model: usage attribution, legend rows, and the occupancy map. Legend and
3
+ * map rows have an ANSI form (`render`) and a native form (`describe`); keep the twins visually identical.
4
+ */
5
+ import { formatNumber } from "@oh-my-pi/pi-utils";
6
+ import type { TspSpan } from "@oh-my-pi/pi-wire";
7
+ import type { ContextBreakdown } from "../status-line/context-usage";
8
+ import { type ContextMapCell, planContextMap } from "../chrome/context-map";
9
+ import { CONTEXT_PRESENTATION, type ContextPresentationId } from "../chrome/context-presentation";
10
+ import { item, node, span, text } from "../native/describe";
11
+ import type { NativeNode } from "../native/node";
12
+ import { theme } from "../theme/theme";
13
+ import type { ThemeColor } from "../theme";
14
+ import { truncateToWidth, visibleWidth } from "../utils";
15
+ import {
16
+ type ContextViewCategory,
17
+ type ContextViewData,
18
+ contextCategoryDescriptor,
19
+ } from "../chrome/context-view-model";
20
+ import { safeLine, safeText } from "./context-viewer-format";
21
+
22
+ /** The map is hidden below this body width. */
23
+ const MAP_MIN_WIDTH = 52;
24
+ /** Map cells are separated by a space from this body width. */
25
+ const MAP_GAP_MIN_WIDTH = 72;
26
+ /** Maximum map columns and rows. */
27
+ const MAP_MAX_CELLS = 16;
28
+ /** Width reserved for the legend beside the map. */
29
+ const LEGEND_MIN_WIDTH = 32;
30
+ /** Columns between the map and the legend. */
31
+ export const MAP_LEGEND_GAP = 3;
32
+
33
+ export interface OverviewRow {
34
+ readonly key: string;
35
+ readonly id: ContextPresentationId;
36
+ readonly label: string;
37
+ /** Attributed usage; undefined when the weights cannot scale current usage. */
38
+ readonly tokens: number | undefined;
39
+ /** Category opened on confirm; undefined rows are listed but not selectable. */
40
+ readonly category?: ContextViewCategory;
41
+ readonly child?: boolean;
42
+ /** Right-aligned token column. */
43
+ readonly tokenColumn: string;
44
+ /** Right-aligned share column, including its leading separator. */
45
+ readonly shareColumn: string;
46
+ }
47
+
48
+ export interface MapGeometry {
49
+ readonly columns: number;
50
+ readonly rows: number;
51
+ readonly cellWidth: number;
52
+ readonly gap: string;
53
+ readonly width: number;
54
+ }
55
+
56
+ /** Largest-remainder split of `total` by `weights`; undefined when the weights cannot scale. */
57
+ function allocateTokens(weights: readonly number[], total: number): number[] | undefined {
58
+ const sum = weights.reduce((value, weight) => value + weight, 0);
59
+ if (!(sum > 0) || !Number.isFinite(sum)) return;
60
+ const shares = weights.map((weight, index) => {
61
+ const exact = (total * weight) / sum;
62
+ return { index, tokens: Math.floor(exact), remainder: exact - Math.floor(exact) };
63
+ });
64
+ const remaining = total - shares.reduce((value, share) => value + share.tokens, 0);
65
+ const ranked = [...shares].sort((a, b) => b.remainder - a.remainder || a.index - b.index);
66
+ for (let index = 0; index < remaining; index++) ranked[index]!.tokens++;
67
+ return shares.map(share => share.tokens);
68
+ }
69
+
70
+ /**
71
+ * Attribute current usage to categories by weight, then each category's share to its children.
72
+ * Attributed values are not entry sums: entries keep their unscaled content/replay estimates.
73
+ * Undefined when the top-level weights cannot scale; unscalable children stay unattributed.
74
+ */
75
+ export function allocateUsage(
76
+ categories: readonly ContextViewCategory[],
77
+ usage: number,
78
+ ): Map<ContextViewCategory, number> | undefined {
79
+ const allocation = new Map<ContextViewCategory, number>();
80
+ const allocate = (siblings: readonly ContextViewCategory[], total: number): boolean => {
81
+ const shares = allocateTokens(
82
+ siblings.map(category => category.tokens),
83
+ total,
84
+ );
85
+ if (!shares) return false;
86
+ for (const [index, category] of siblings.entries()) {
87
+ allocation.set(category, shares[index]!);
88
+ if (category.children) allocate(category.children, shares[index]!);
89
+ }
90
+ return true;
91
+ };
92
+ return allocate(categories, usage) ? allocation : undefined;
93
+ }
94
+
95
+ /** Overview row label for a category: its Overview-specific label when the weight differs from its content. */
96
+ export function overviewLabel(category: ContextViewCategory): string {
97
+ return contextCategoryDescriptor(category.id).overviewLabel ?? category.label;
98
+ }
99
+
100
+ function formatShare(tokens: number, window: number): string {
101
+ if (window <= 0) return "—";
102
+ const percent = (tokens / window) * 100;
103
+ return percent > 0 && percent < 0.05 ? "<0.1%" : `${percent.toFixed(1)}%`;
104
+ }
105
+
106
+ /** Category and child rows, then buffer and free space, with aligned token and share columns. */
107
+ export function buildOverviewRows(
108
+ data: ContextViewData,
109
+ allocation: ReadonlyMap<ContextViewCategory, number> | undefined,
110
+ ): OverviewRow[] {
111
+ const rows: Omit<OverviewRow, "tokenColumn" | "shareColumn">[] = [];
112
+ for (const category of data.categories) {
113
+ const openable = contextCategoryDescriptor(category.id).openable !== false;
114
+ rows.push({
115
+ key: category.id,
116
+ id: category.id,
117
+ label: overviewLabel(category),
118
+ tokens: allocation?.get(category),
119
+ // Empty categories have nothing to open.
120
+ category: openable && category.entries.length > 0 ? category : undefined,
121
+ });
122
+ for (const child of category.children ?? [])
123
+ rows.push({
124
+ key: child.key!,
125
+ id: category.id,
126
+ label: child.label,
127
+ tokens: allocation?.get(child),
128
+ category: openable ? child : undefined,
129
+ child: true,
130
+ });
131
+ }
132
+ rows.push(
133
+ { key: "buffer", id: "buffer", label: "Auto-Compact Buffer", tokens: data.breakdown.autoCompactBufferTokens },
134
+ { key: "free", id: "free", label: "Free Space", tokens: data.breakdown.freeTokens },
135
+ );
136
+ const tokenWidth = Math.max(...rows.map(row => (row.tokens === undefined ? 1 : formatNumber(row.tokens).length)));
137
+ const shareWidth = Math.max(
138
+ ...rows.map(row => (row.tokens === undefined ? 1 : formatShare(row.tokens, data.breakdown.contextWindow).length)),
139
+ );
140
+ return rows.map(row => ({
141
+ ...row,
142
+ tokenColumn: row.tokens === undefined ? "—" : formatNumber(row.tokens).padStart(tokenWidth),
143
+ shareColumn:
144
+ row.tokens === undefined
145
+ ? ""
146
+ : ` ${formatShare(row.tokens, data.breakdown.contextWindow).padStart(shareWidth)}`,
147
+ }));
148
+ }
149
+
150
+ /** ANSI legend row: cursor, swatch, label padded to `width`, token and share columns. */
151
+ export function legendLine(row: OverviewRow, selected: boolean, width: number): string {
152
+ const presentation = CONTEXT_PRESENTATION[row.id];
153
+ const glyph = theme.styledSymbol(presentation.symbol, presentation.color);
154
+ const cursor = selected ? theme.fg("accent", theme.nav.cursor) : " ".repeat(visibleWidth(theme.nav.cursor));
155
+ const prefix = `${cursor} ${row.child ? " " : ""}${glyph} `;
156
+ const suffix = ` ${theme.fg(selected ? presentation.color : "text", row.tokenColumn)}${theme.fg(selected ? presentation.color : "dim", row.shareColumn)}`;
157
+ const budget = Math.max(0, width - visibleWidth(prefix) - visibleWidth(suffix));
158
+ const label = truncateToWidth(safeLine(row.label), budget);
159
+ const name = selected ? theme.fg(presentation.color, theme.bold(label)) : label;
160
+ return truncateToWidth(`${prefix}${name}${" ".repeat(Math.max(0, budget - visibleWidth(label)))}${suffix}`, width);
161
+ }
162
+
163
+ /** Native legend item; `meterGlyphs` swaps map glyphs for the meter's bar glyphs. */
164
+ export function legendItem(row: OverviewRow, selected: boolean, meterGlyphs: boolean, window: number): NativeNode {
165
+ const presentation = CONTEXT_PRESENTATION[row.id];
166
+ const glyph = meterGlyphs
167
+ ? row.id === "buffer"
168
+ ? "▨"
169
+ : row.id === "free"
170
+ ? "□"
171
+ : "■"
172
+ : theme.symbol(presentation.symbol);
173
+ const usage = row.tokens === undefined ? "—" : `${formatNumber(row.tokens)} · ${formatShare(row.tokens, window)}`;
174
+ return item(row.key, {
175
+ label: [
176
+ span(`${row.child ? " " : ""}${glyph} `, presentation.color),
177
+ span(safeText(row.label), selected ? `${presentation.color} strong` : undefined),
178
+ ],
179
+ value: selected ? [span(usage, presentation.color)] : usage,
180
+ });
181
+ }
182
+
183
+ /** Map size beside a legend in a `width` × `height` body; undefined when either would not fit. */
184
+ export function mapGeometry(width: number, height: number): MapGeometry | undefined {
185
+ if (width < MAP_MIN_WIDTH || height < 2) return;
186
+ const gap = width >= MAP_GAP_MIN_WIDTH ? " " : "";
187
+ const cellWidth = Math.max(
188
+ 1,
189
+ ...Object.values(CONTEXT_PRESENTATION).map(presentation => visibleWidth(theme.symbol(presentation.symbol))),
190
+ );
191
+ const columns = Math.min(
192
+ MAP_MAX_CELLS,
193
+ Math.floor((width - LEGEND_MIN_WIDTH - MAP_LEGEND_GAP + gap.length) / (cellWidth + gap.length)),
194
+ );
195
+ if (columns < 1) return;
196
+ const rows = Math.min(MAP_MAX_CELLS, height);
197
+ return { columns, rows, cellWidth, gap, width: columns * (cellWidth + gap.length) - gap.length };
198
+ }
199
+
200
+ /** Map cells per row, in window order: categories, free space, then buffer. */
201
+ export function planMapRows(
202
+ data: ContextViewData,
203
+ allocation: ReadonlyMap<ContextViewCategory, number>,
204
+ geometry: MapGeometry,
205
+ ): ContextMapCell<ContextPresentationId>[][] {
206
+ const cells = planContextMap<ContextPresentationId>(
207
+ [
208
+ ...data.categories.map(category => ({ id: category.id, tokens: allocation.get(category) ?? 0 })),
209
+ { id: "free", tokens: data.breakdown.freeTokens },
210
+ { id: "buffer", tokens: data.breakdown.autoCompactBufferTokens },
211
+ ],
212
+ data.breakdown.contextWindow,
213
+ geometry.columns,
214
+ geometry.rows,
215
+ );
216
+ return Array.from({ length: geometry.rows }, (_, row) =>
217
+ cells.slice(row * geometry.columns, (row + 1) * geometry.columns),
218
+ );
219
+ }
220
+
221
+ /** ANSI map row; partially filled cells are dim. */
222
+ export function mapRowLine(cells: readonly ContextMapCell<ContextPresentationId>[], geometry: MapGeometry): string {
223
+ return cells
224
+ .map(cell => {
225
+ const presentation = CONTEXT_PRESENTATION[cell.id];
226
+ const glyph = theme.symbol(presentation.symbol);
227
+ const painted = theme.fg(presentation.color, glyph);
228
+ return `${cell.full ? painted : `\x1b[2m${painted}\x1b[22m`}${" ".repeat(Math.max(0, geometry.cellWidth - visibleWidth(glyph)))}`;
229
+ })
230
+ .join(geometry.gap);
231
+ }
232
+
233
+ /** Native map row; partially filled cells are dim. */
234
+ export function mapRowSpans(cells: readonly ContextMapCell<ContextPresentationId>[], geometry: MapGeometry): TspSpan[] {
235
+ return cells.map(cell => {
236
+ const presentation = CONTEXT_PRESENTATION[cell.id];
237
+ const glyph = theme.symbol(presentation.symbol);
238
+ return span(
239
+ `${glyph}${" ".repeat(Math.max(0, geometry.cellWidth - visibleWidth(glyph)))}${geometry.gap}`,
240
+ `${presentation.color}${cell.full ? "" : " dim"}`,
241
+ );
242
+ });
243
+ }
244
+
245
+ function percentString(part: number, whole: number, fractionDigits = 1): string {
246
+ if (whole <= 0) return "0%";
247
+ const pct = (part / whole) * 100;
248
+ if (pct > 0 && pct < 0.05) return "<0.1%";
249
+ return `${pct.toFixed(fractionDigits)}%`;
250
+ }
251
+
252
+ /** Snapcompact savings lines of the Overview; empty when no snapcompact setting is on. */
253
+ export function contextSnapcompactLines(breakdown: ContextBreakdown): string[] {
254
+ const snap = breakdown.snapcompact;
255
+ if (!snap) return [];
256
+ if (!snap.visionCapable) return ["Snapcompact: inactive (model has no image input)"];
257
+ const lines = ["Snapcompact (estimated wire savings)"];
258
+ if (snap.systemPrompt) {
259
+ const sp = snap.systemPrompt;
260
+ const scope = sp.scope === "agents-md" ? "AGENTS.md" : "all";
261
+ if (sp.applied) {
262
+ const frames = `${sp.frames} frame${sp.frames === 1 ? "" : "s"}`;
263
+ lines.push(
264
+ ` System prompt (${scope}): saves ~${formatNumber(sp.savedTokens)} (${formatNumber(sp.textTokens)} text → ${frames} ≈ ${formatNumber(sp.imageTokens)})`,
265
+ );
266
+ } else {
267
+ const reason =
268
+ sp.reason === "budget"
269
+ ? "image budget exhausted"
270
+ : sp.reason === "empty"
271
+ ? "nothing to image"
272
+ : "frames would not save tokens";
273
+ lines.push(` System prompt (${scope}): stays text (${reason})`);
274
+ }
275
+ }
276
+ if (snap.toolResults) {
277
+ const tr = snap.toolResults;
278
+ lines.push(
279
+ tr.swapped > 0
280
+ ? ` Tool results: saves ~${formatNumber(tr.savedTokens)} (${tr.swapped}/${tr.total} imaged, ${formatNumber(tr.textTokens)} text → ${tr.frames} frames ≈ ${formatNumber(tr.imageTokens)})`
281
+ : ` Tool results: none imaged (${tr.total} in history)`,
282
+ );
283
+ }
284
+ if (snap.savedTokens > 0) {
285
+ lines.push(
286
+ ` Next request: ~${formatNumber(Math.max(0, breakdown.usedTokens - snap.savedTokens))} tokens on the wire`,
287
+ );
288
+ }
289
+ return lines;
290
+ }
291
+
292
+ interface ContextMeterData extends Pick<
293
+ ContextBreakdown,
294
+ "contextWindow" | "usedTokens" | "freeTokens" | "autoCompactBufferTokens" | "thresholdTokens"
295
+ > {
296
+ categories: readonly { label: string; tokens: number; color: ThemeColor }[];
297
+ }
298
+
299
+ /** Stacked meter parts in grid order: the categories, free space as the empty track, then the hatched buffer. */
300
+ function contextMeterParts(
301
+ breakdown: ContextMeterData,
302
+ ): { value: number; token?: string; label?: string; hatch?: boolean }[] {
303
+ const window = breakdown.contextWindow;
304
+ const parts: { value: number; token?: string; label?: string; hatch?: boolean }[] = breakdown.categories
305
+ .filter(category => category.tokens > 0)
306
+ .map(category => ({
307
+ value: category.tokens / window,
308
+ token: category.color,
309
+ label: `${category.label} · ${formatNumber(category.tokens)} tokens (${percentString(category.tokens, window)})`,
310
+ }));
311
+ parts.push({
312
+ value: breakdown.freeTokens / window,
313
+ token: "track",
314
+ label: `Free space · ${formatNumber(breakdown.freeTokens)} tokens (${percentString(breakdown.freeTokens, window)})`,
315
+ });
316
+ if (breakdown.autoCompactBufferTokens > 0) {
317
+ parts.push({
318
+ value: breakdown.autoCompactBufferTokens / window,
319
+ token: "warning",
320
+ hatch: true,
321
+ label: `Autocompact buffer · ${formatNumber(breakdown.autoCompactBufferTokens)} tokens (${percentString(breakdown.autoCompactBufferTokens, window)})`,
322
+ });
323
+ }
324
+ return parts;
325
+ }
326
+
327
+ /** Shared usage and compaction labels for ANSI and native context views. */
328
+ export function contextUsageSummary(
329
+ breakdown: Pick<ContextBreakdown, "contextWindow" | "usedTokens" | "freeTokens" | "thresholdTokens">,
330
+ ) {
331
+ const { contextWindow: window, usedTokens, freeTokens, thresholdTokens } = breakdown;
332
+ const room =
333
+ thresholdTokens === undefined
334
+ ? `${formatNumber(freeTokens)} free`
335
+ : usedTokens >= thresholdTokens
336
+ ? `Past auto-compaction at ${percentString(thresholdTokens, window, 0)}`
337
+ : `${formatNumber(thresholdTokens - usedTokens)} left before auto-compaction at ${percentString(thresholdTokens, window, 0)}`;
338
+ return {
339
+ used: formatNumber(usedTokens),
340
+ detail: ` of ${formatNumber(window)} tokens · ${percentString(usedTokens, window, 0)}`,
341
+ room,
342
+ };
343
+ }
344
+
345
+ /** Native usage summary and stacked bar; callers provide the category list. */
346
+ export function describeContextMeter(breakdown: ContextMeterData): [NativeNode, NativeNode] {
347
+ const { contextWindow: window, usedTokens, thresholdTokens } = breakdown;
348
+ const parts = contextMeterParts(breakdown);
349
+ const used = Math.min(1, usedTokens / window);
350
+ const labels = contextUsageSummary(breakdown);
351
+ const summary = node(
352
+ "row",
353
+ { role: "omp.context.summary", align: "baseline", wrap: true },
354
+ [
355
+ text([span(labels.used, "strong"), span(labels.detail, "dim")], {
356
+ role: "omp.context.used",
357
+ grow: 1,
358
+ wrap: "none",
359
+ }),
360
+ text(labels.room, { role: "omp.context.room", wrap: "none" }),
361
+ ],
362
+ "summary",
363
+ );
364
+ const marks =
365
+ thresholdTokens !== undefined
366
+ ? [
367
+ {
368
+ at: thresholdTokens / window,
369
+ tone: "warning" as const,
370
+ title: `Auto-compaction at ${percentString(thresholdTokens, window, 0)} · ${formatNumber(thresholdTokens)} tokens`,
371
+ },
372
+ ]
373
+ : undefined;
374
+ const bar = node(
375
+ "meter",
376
+ { value: used, style: "bar", size: "lg", parts, ...(marks ? { marks } : {}), aria: "Context usage by category" },
377
+ undefined,
378
+ "bar",
379
+ );
380
+ return [summary, bar];
381
+ }