@xprnvd/pi-context-view 0.0.0-stage → 0.7.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 (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +112 -2
  3. package/package.json +55 -5
  4. package/src/capture/adjustments.ts +649 -0
  5. package/src/capture/attribution.ts +25 -0
  6. package/src/capture/builder.ts +128 -0
  7. package/src/capture/diff.ts +455 -0
  8. package/src/capture/dispatch.ts +36 -0
  9. package/src/capture/guard.ts +287 -0
  10. package/src/capture/messages.ts +246 -0
  11. package/src/capture/payload.ts +584 -0
  12. package/src/capture/redact.ts +58 -0
  13. package/src/capture/register.ts +129 -0
  14. package/src/capture/request.ts +218 -0
  15. package/src/capture/tools.ts +69 -0
  16. package/src/capture/tracker.ts +71 -0
  17. package/src/command.ts +164 -0
  18. package/src/compaction.ts +57 -0
  19. package/src/config.ts +408 -0
  20. package/src/index.ts +191 -0
  21. package/src/injections.ts +670 -0
  22. package/src/measure.ts +887 -0
  23. package/src/message-preview.ts +72 -0
  24. package/src/model-identity.ts +48 -0
  25. package/src/model.ts +548 -0
  26. package/src/pi-version.ts +29 -0
  27. package/src/probe/failure.ts +34 -0
  28. package/src/probe/filter.ts +126 -0
  29. package/src/probe/silent-probe.ts +349 -0
  30. package/src/probe/token.ts +28 -0
  31. package/src/probe/trigger.ts +178 -0
  32. package/src/probe/view.ts +26 -0
  33. package/src/projection.ts +122 -0
  34. package/src/prompt-additions.ts +226 -0
  35. package/src/prompt-blocks.ts +80 -0
  36. package/src/replay.ts +234 -0
  37. package/src/settings.ts +62 -0
  38. package/src/snapshot.ts +188 -0
  39. package/src/system-prompt-rows.ts +311 -0
  40. package/src/text.ts +46 -0
  41. package/src/token-diff.ts +211 -0
  42. package/src/transcript.ts +19 -0
  43. package/src/ui/color.ts +52 -0
  44. package/src/ui/diff-preview.ts +181 -0
  45. package/src/ui/injections-model.ts +378 -0
  46. package/src/ui/injections-view.ts +729 -0
  47. package/src/ui/json-preview.ts +41 -0
  48. package/src/ui/layout.ts +148 -0
  49. package/src/ui/markers.ts +253 -0
  50. package/src/ui/notes.ts +117 -0
  51. package/src/ui/section-preview.ts +267 -0
  52. package/src/ui/skill-preview.ts +83 -0
  53. package/src/ui/status-bar.ts +94 -0
  54. package/src/ui/usage-map.ts +157 -0
  55. package/src/ui/usage-preview.ts +194 -0
  56. package/src/ui/usage-view.ts +1255 -0
  57. package/src/ui/wheel.ts +65 -0
  58. package/src/usage.ts +461 -0
@@ -0,0 +1,267 @@
1
+ /**
2
+ * Shared preview body rendering for content that carries labeled parts, e.g. a
3
+ * tool's dropped prompt lines and definition. Both the Injections item preview
4
+ * and the Usage block stream present those parts the same way: a bold
5
+ * subheader with its token share above each part. Pure string logic.
6
+ */
7
+ import type { Theme } from "@earendil-works/pi-coding-agent";
8
+ import { wrapTextWithAnsi } from "@earendil-works/pi-tui";
9
+
10
+ import type {
11
+ ForcedEdit, InjectedReference, InjectionSection, JsonSpan, RequestChange, TextDiff, TokenDiff,
12
+ } from "../model.ts";
13
+ import { normalizeInlineText, normalizePreviewText } from "../text.ts";
14
+ import { forcedEditDiffLines, messageDiffLines, payloadDiffLines, textDiffLines } from "./diff-preview.ts";
15
+ import { shiftJsonSpan } from "./json-preview.ts";
16
+ import { BODY_INDENT, calculateViewport, descriptionBlockRows } from "./layout.ts";
17
+ import {
18
+ type ContextMarker,
19
+ type DiffBase,
20
+ diffMarker,
21
+ guessMarker,
22
+ markerLegendLines,
23
+ noteBulletLines,
24
+ partMarkers,
25
+ stateMarkers,
26
+ } from "./markers.ts";
27
+
28
+ /**
29
+ * Arrow introducing an extension line's source label. Non-breaking spaces bind
30
+ * the preceding word, arrow, and label into one wrapping unit. Units wider
31
+ * than the content width still hard-wrap, possibly just after the arrow.
32
+ */
33
+ const SOURCE_ARROW = "\u00A0<-\u00A0";
34
+ /** Keep a normal block's worth of content visible before making room for its explanation. */
35
+ const DESCRIPTION_MIN_CONTENT_ROWS = 22;
36
+ /** Dim prefix of a tool declaration part, so it reads apart from a `tool call` part. */
37
+ const TOOL_LABEL_PREFIX = "tool:";
38
+
39
+ /** Raw preview content plus the labeled parts it decomposes into, when known. */
40
+ export interface SectionedContent {
41
+ readonly text: string;
42
+ readonly jsonSpan?: JsonSpan;
43
+ readonly sections?: readonly InjectionSection[];
44
+ readonly injectedReferences?: readonly InjectedReference[];
45
+ /** True when a `--system-prompt` replacement dropped this content; it reads 0 tokens. */
46
+ readonly dropped?: boolean;
47
+ /** True when an extension moved this content out of the region pi renders it into. */
48
+ readonly moved?: boolean;
49
+ /** Request-only change of this content; only `forced` has a marker. */
50
+ readonly change?: RequestChange;
51
+ /** Request edit a forced prompt replaced; the body shows it as a diff from the sent text. */
52
+ readonly forcedEdit?: ForcedEdit;
53
+ /** Texts behind a System Prompt part's diff; the body shows them as a diff. */
54
+ readonly textDiff?: TextDiff;
55
+ /** Tokens this content gained and lost against its base; only Injections measures them. */
56
+ readonly diff?: TokenDiff;
57
+ /** True when the request sent other content than the session keeps; only Injections measures it. */
58
+ readonly requestOnly?: boolean;
59
+ }
60
+
61
+ /** Space shared by uncapped preview content, its counter, and the marker legend. */
62
+ export interface PreviewDescriptionLayout {
63
+ readonly width: number;
64
+ /** Rows left after the view's fixed frame, before the description and counter. */
65
+ readonly availableRows: number;
66
+ /** Wrapped content rows, including entry headers/separators but never the footer. */
67
+ readonly contentLineCount: number;
68
+ }
69
+
70
+ /**
71
+ * One fixed legend for the markers a preview shows, never part of its raw
72
+ * content, followed by `notes` that explain the shown item further. `diffBase`
73
+ * names what the shown token diffs compare with. Collapse it whole when fewer
74
+ * than `DESCRIPTION_MIN_CONTENT_ROWS` content rows would remain, or when a
75
+ * shorter preview would no longer fit in full. Uncapped line counts keep the
76
+ * collapse decision independent of the Usage cap it helps determine.
77
+ */
78
+ export function previewLegendLines(
79
+ theme: Theme,
80
+ contents: readonly SectionedContent[],
81
+ layout: PreviewDescriptionLayout,
82
+ notes: readonly string[] = [],
83
+ diffBase?: DiffBase,
84
+ ): string[] {
85
+ const markers = previewMarkers(contents);
86
+ if (markers.length === 0 && notes.length === 0) return [];
87
+ const lines = [
88
+ ...markerLegendLines(theme, markers, layout.width, diffBase),
89
+ ...notes.flatMap((note) => noteBulletLines(theme, note, layout.width)),
90
+ ];
91
+ const availableRows = layout.availableRows - descriptionBlockRows(lines);
92
+ const viewport = calculateViewport(layout.contentLineCount, availableRows, 0);
93
+ const floor = Math.min(DESCRIPTION_MIN_CONTENT_ROWS, layout.contentLineCount);
94
+ return availableRows >= floor && viewport.visibleCount >= floor ? lines : [];
95
+ }
96
+
97
+ /**
98
+ * Preview body lines for one item or entry: labeled parts under their
99
+ * subheaders, or the raw text when no breakdown exists. A payload change's
100
+ * part renders its changed lines instead of its text, a part with a replaced
101
+ * edit renders that edit as a diff, and a changed System Prompt part with a
102
+ * base renders its diff from that base. The caller supplies
103
+ * `wrapText` so each view keeps its own sanitizing, wrapping, indentation, and
104
+ * choice of whether the marked JSON run is expanded at this level, and
105
+ * `heading` so an undivided body can drop a line the heading already shows.
106
+ */
107
+ export function previewBodyLines(
108
+ theme: Theme,
109
+ content: SectionedContent,
110
+ wrapWidth: number,
111
+ wrapText: (text: string, jsonSpan: JsonSpan | undefined) => string[],
112
+ heading?: string,
113
+ ): string[] {
114
+ const sections = content.sections ?? [];
115
+ if (sections.length === 0) {
116
+ if (content.forcedEdit !== undefined) return forcedEditDiffLines(theme, content.text, content.forcedEdit, wrapWidth);
117
+ if (hasDiffBase(content.textDiff)) return textDiffLines(theme, content.textDiff, wrapWidth);
118
+ return contentBodyLines(theme, content, wrapWidth, wrapText, heading);
119
+ }
120
+ const lines: string[] = [];
121
+ for (const section of sections) {
122
+ if (lines.length > 0) {
123
+ // Captured trailing whitespace must not add to the subsection separator
124
+ while (lines.length > 0 && normalizeInlineText(lines[lines.length - 1] ?? "") === "") lines.pop();
125
+ lines.push("", "");
126
+ }
127
+ lines.push(...sectionHeaderLines(theme, section, wrapWidth));
128
+ if (section.message !== undefined) lines.push(...messageDiffLines(theme, section, wrapWidth));
129
+ else if (section.changedLines !== undefined) lines.push(...payloadDiffLines(theme, section.changedLines, wrapWidth));
130
+ else if (section.forcedEdit !== undefined) {
131
+ lines.push(...forcedEditDiffLines(theme, section.text, section.forcedEdit, wrapWidth));
132
+ } else if (hasDiffBase(section.textDiff)) lines.push(...textDiffLines(theme, section.textDiff, wrapWidth));
133
+ else lines.push(...contentBodyLines(theme, section, wrapWidth, wrapText, section.label));
134
+ }
135
+ return lines;
136
+ }
137
+
138
+ /**
139
+ * Whether a part's text diff has a base to compare with. Without one, the
140
+ * whole part is added: its `+N` already says so, and plain text reads better.
141
+ */
142
+ function hasDiffBase(diff: TextDiff | undefined): diff is TextDiff {
143
+ return diff !== undefined && diff.base.length > 0;
144
+ }
145
+
146
+ /**
147
+ * Markers one preview renders: extension lines and their inferred owners come
148
+ * from reference metadata, while state markers follow the subheaders and
149
+ * metadata rows that show them, whether or not that part carries references.
150
+ */
151
+ function previewMarkers(contents: readonly SectionedContent[]): ContextMarker[] {
152
+ const referenced = contents.flatMap((content) => referenceParts(content)).filter(hasInjectedReferences);
153
+ const marked = contents.flatMap((content) => [content, ...(content.sections ?? [])]);
154
+ const markers: ContextMarker[] = [];
155
+ if (referenced.length > 0) markers.push("highlighted");
156
+ if (referenced.some(hasGuessedReferences) || marked.some(isGuessedPart)) markers.push("guess");
157
+ // The legend orders and deduplicates markers itself
158
+ markers.push(...marked.flatMap(partMarkers));
159
+ return markers;
160
+ }
161
+
162
+ /** Only metadata on rendered body parts triggers the footer, never a text or label match. */
163
+ function hasInjectedReferences(part: SectionedContent): boolean {
164
+ return (part.injectedReferences?.length ?? 0) > 0;
165
+ }
166
+
167
+ /** Whether a rendered reference names an inferred owner needing the extra caveat. */
168
+ function hasGuessedReferences(part: SectionedContent): boolean {
169
+ return part.injectedReferences?.some((reference) => reference.attribution === "guess") === true;
170
+ }
171
+
172
+ /** Whether a part's diff belongs to an owner inferred from the text itself, marked on its subheader. */
173
+ function isGuessedPart(part: SectionedContent | InjectionSection): boolean {
174
+ return "attribution" in part && part.attribution === "guess";
175
+ }
176
+
177
+ /** Body parts carrying reference metadata: the item's sections, or the item itself. */
178
+ function referenceParts(content: SectionedContent): readonly SectionedContent[] {
179
+ return content.sections?.length ? content.sections : [content];
180
+ }
181
+
182
+ /** Render annotated extension lines locally; other content keeps the caller's JSON/skill transformations. */
183
+ function contentBodyLines(
184
+ theme: Theme,
185
+ content: SectionedContent,
186
+ wrapWidth: number,
187
+ wrapText: (text: string, jsonSpan: JsonSpan | undefined) => string[],
188
+ heading: string | undefined,
189
+ ): string[] {
190
+ const references = content.injectedReferences ?? [];
191
+ if (references.length === 0) return bodyLines(content.text, content.jsonSpan, heading, wrapText);
192
+ let text = "";
193
+ let offset = 0;
194
+ for (const reference of references) {
195
+ text += normalizePreviewText(content.text.slice(offset, reference.start));
196
+ const captured = content.text.slice(reference.start, reference.end);
197
+ // A part that opens with an extension line drops its captured lead, as plain text does.
198
+ const line = text.trim().length === 0 ? captured.replace(/^\n+/, "") : captured;
199
+ text += theme.fg("syntaxNumber", normalizePreviewText(line));
200
+ text += theme.fg("borderMuted", SOURCE_ARROW);
201
+ text += theme.fg("mdLink", normalizeInlineText(reference.source.label));
202
+ if (reference.tool !== undefined) {
203
+ text += theme.fg("mdLinkUrl", `:${normalizeInlineText(reference.tool)}`);
204
+ }
205
+ if (reference.attribution === "guess") text += guessMarker(theme);
206
+ offset = reference.end;
207
+ }
208
+ text += normalizePreviewText(content.text.slice(offset));
209
+ const body = withoutRepeatedHeading(text.replace(/^\n+/, ""), heading);
210
+ return wrapTextWithAnsi(body, wrapWidth)
211
+ .map((line) => line === "" ? "" : `${BODY_INDENT}${line}`);
212
+ }
213
+
214
+ /**
215
+ * One body part, wrapped after dropping the lead its heading already carries:
216
+ * the newline that separated a prompt line, and a first line repeating
217
+ * the heading itself, as a skill block opens with its own name.
218
+ */
219
+ function bodyLines(
220
+ text: string,
221
+ jsonSpan: JsonSpan | undefined,
222
+ heading: string | undefined,
223
+ wrapText: (text: string, jsonSpan: JsonSpan | undefined) => string[],
224
+ ): string[] {
225
+ const body = withoutRepeatedHeading(text.replace(/^\n+/, ""), heading);
226
+ return wrapText(body, shiftJsonSpan(jsonSpan, text.length - body.length));
227
+ }
228
+
229
+ /**
230
+ * Text without a first line that only repeats the heading rendered above it.
231
+ * The dropped line stays part of the estimate; it is redundant on screen only.
232
+ */
233
+ function withoutRepeatedHeading(text: string, heading: string | undefined): string {
234
+ if (heading === undefined) return text;
235
+ const lineEnd = text.indexOf("\n");
236
+ if (lineEnd === -1 || headingKey(text.slice(0, lineEnd)) !== headingKey(heading)) return text;
237
+ return text.slice(lineEnd + 1);
238
+ }
239
+
240
+ /**
241
+ * Comparable form of a heading or a first content line: case and a trailing
242
+ * colon carry no information here, so pi's own block headers ("Available
243
+ * tools:", "Guidelines:") read as repeats of the part labels above them.
244
+ */
245
+ function headingKey(text: string): string {
246
+ return text.trim().replace(/:$/, "").toLowerCase();
247
+ }
248
+
249
+ /**
250
+ * Bold subheader naming one part, its share of the parent estimate, its token
251
+ * diff, a guessed owner, and its markers. A diff-only part has no share, and a
252
+ * tool declaration's name follows a dim `tool:` prefix.
253
+ * Parts use `syntaxKeyword` rather than the usual `mdHeading` subheader color,
254
+ * because they nest under item and entry headings that already carry `mdHeading`.
255
+ */
256
+ function sectionHeaderLines(theme: Theme, section: InjectionSection, wrapWidth: number): string[] {
257
+ const prefix = section.toolDeclaration === true ? theme.fg("dim", TOOL_LABEL_PREFIX) : "";
258
+ const label = prefix + theme.fg("syntaxKeyword", theme.bold(normalizeInlineText(section.label)));
259
+ const prompt = section.message?.userPrompt;
260
+ const number = prompt === undefined ? "" : theme.fg("muted", ` · user prompt #${prompt}`);
261
+ const tokens = section.diffOnly === true
262
+ ? ""
263
+ : theme.fg("muted", ` · ${section.tokens.toLocaleString("en-US")} tokens`);
264
+ const guess = section.attribution === "guess" ? guessMarker(theme) : "";
265
+ const header = `${label}${number}${tokens}${diffMarker(theme, section.diff)}${guess}${stateMarkers(theme, section)}`;
266
+ return wrapTextWithAnsi(header, wrapWidth).map((line) => `${BODY_INDENT}${line}`);
267
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Preview-only recognition of complete pi skill-expansion wrappers. The
3
+ * returned segments retain ordinary and malformed text verbatim; callers are
4
+ * responsible for terminal sanitization before parsing or rendering.
5
+ */
6
+
7
+ const SKILL_OPEN_TAG = /^<skill name="([^"\r\n]+)"(?: location="[^"\r\n]*")?>\r?$/gm;
8
+ const ANY_SKILL_OPEN_TAG = /^<skill(?:\s|>)[^\r\n]*\r?$/gm;
9
+ const SKILL_CLOSE_TAG = /^<\/skill>\r?$/gm;
10
+
11
+ /** One display segment from a user message containing attached skills. */
12
+ export type SkillPreviewSegment =
13
+ | { readonly type: "text"; readonly text: string }
14
+ | { readonly type: "skill"; readonly name: string };
15
+
16
+ interface TagSpan {
17
+ readonly start: number;
18
+ readonly end: number;
19
+ }
20
+
21
+ interface SkillOpenTag extends TagSpan {
22
+ readonly name: string;
23
+ }
24
+
25
+ /**
26
+ * Replace no content directly: split complete skill wrappers into named
27
+ * segments so the UI can render compact badges while retaining unmatched
28
+ * wrappers as text. A later complete wrapper is still recognized when an
29
+ * earlier opening tag is malformed or unclosed.
30
+ */
31
+ export function splitSkillPreview(text: string): SkillPreviewSegment[] {
32
+ const openings = collectOpenTags(text);
33
+ const openingBoundaries = collectTagSpans(text, ANY_SKILL_OPEN_TAG);
34
+ const closings = collectTagSpans(text, SKILL_CLOSE_TAG);
35
+ const segments: SkillPreviewSegment[] = [];
36
+ let textStart = 0;
37
+ let openingBoundaryIndex = 0;
38
+ let closingIndex = 0;
39
+
40
+ for (let openingIndex = 0; openingIndex < openings.length; openingIndex++) {
41
+ const opening = openings[openingIndex];
42
+ if (opening === undefined || opening.start < textStart) continue;
43
+ while ((closings[closingIndex]?.start ?? text.length) < opening.end) closingIndex++;
44
+ const closing = closings[closingIndex];
45
+ if (closing === undefined) continue;
46
+
47
+ while ((openingBoundaries[openingBoundaryIndex]?.start ?? text.length) <= opening.start) {
48
+ openingBoundaryIndex++;
49
+ }
50
+ const nextOpening = openingBoundaries[openingBoundaryIndex];
51
+ if (nextOpening !== undefined && nextOpening.start < closing.start) continue;
52
+
53
+ pushTextSegment(segments, text.slice(textStart, opening.start));
54
+ segments.push({ type: "skill", name: opening.name });
55
+ textStart = closing.end;
56
+ closingIndex++;
57
+ }
58
+
59
+ pushTextSegment(segments, text.slice(textStart));
60
+ return segments;
61
+ }
62
+
63
+ /** Collect structurally valid opening tags and their skill names. */
64
+ function collectOpenTags(text: string): SkillOpenTag[] {
65
+ return [...text.matchAll(SKILL_OPEN_TAG)].map((match) => ({
66
+ start: match.index,
67
+ end: match.index + match[0].length,
68
+ name: match[1] ?? "",
69
+ }));
70
+ }
71
+
72
+ /** Collect complete line-delimited tag spans for one global expression. */
73
+ function collectTagSpans(text: string, expression: RegExp): TagSpan[] {
74
+ return [...text.matchAll(expression)].map((match) => ({
75
+ start: match.index,
76
+ end: match.index + match[0].length,
77
+ }));
78
+ }
79
+
80
+ /** Append non-empty ordinary text without merging it into skill segments. */
81
+ function pushTextSegment(segments: SkillPreviewSegment[], text: string): void {
82
+ if (text.length > 0) segments.push({ type: "text", text });
83
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Status bar of the Injections list: what the selected row's changes are,
3
+ * where they live, who made them, and what its markers mean, one field per
4
+ * line. It never shows raw text or repeats the row's source or location.
5
+ * Pure string logic.
6
+ */
7
+ import type { Theme } from "@earendil-works/pi-coding-agent";
8
+ import { wrapTextWithAnsi } from "@earendil-works/pi-tui";
9
+
10
+ import type { ChangeEditor, TokenDiff } from "../model.ts";
11
+ import { normalizeInlineText } from "../text.ts";
12
+ import type { ChangeScope, InjectionRow } from "./injections-model.ts";
13
+ import { fitLine } from "./layout.ts";
14
+ import { formatDiffAdded, formatDiffRemoved, guessMarker, markerStatusField } from "./markers.ts";
15
+
16
+ /** Indent of each field, two columns deeper than the description, so the bar reads apart from it. */
17
+ const FIELD_INDENT = " ";
18
+ /** Indent of a field's wrapped continuation lines. */
19
+ const CONTINUATION_INDENT = " ";
20
+
21
+ /** Where a row's changes live, after the diff in its Change field. */
22
+ const SCOPE_PHRASES: Record<ChangeScope, string> = {
23
+ saved: "saved in the session",
24
+ "saved-and-edit": "saved in the session, with an edit only in this request",
25
+ partly: "some saved in the session, some only in this request",
26
+ request: "only in this request",
27
+ "request-edit": "only in this request; the session keeps the original",
28
+ "request-new": "only in this request; not in the session",
29
+ payload: "only in the provider payload",
30
+ };
31
+
32
+ /** Why a `system prompt` row shows no value. */
33
+ const DIFF_ONLY_NOTE = "Already counted in System Prompt";
34
+
35
+ /**
36
+ * Wrapped status lines of one list row: Change, then Changed By or the note
37
+ * of a `system prompt` row, then one explanation per marker. Empty for a row
38
+ * without a change or marker, and for the separator and TOTAL.
39
+ */
40
+ export function statusBarLines(theme: Theme, row: InjectionRow, width: number): string[] {
41
+ return statusFields(theme, row).flatMap((field) => wrapField(field, width));
42
+ }
43
+
44
+ /** Unwrapped fields of one row, in display order. */
45
+ function statusFields(theme: Theme, row: InjectionRow): string[] {
46
+ if (row.kind !== "group" && row.kind !== "item") return [];
47
+ const fields: string[] = [];
48
+ if (row.scope !== undefined) fields.push(field(theme, "Change", changeText(theme, row.diff, row.scope)));
49
+ if (row.kind !== "item") return fields;
50
+ if (row.diffOnly === true) fields.push(theme.fg("dim", DIFF_ONLY_NOTE));
51
+ else if (row.editors !== undefined) fields.push(field(theme, "Changed By", editorsText(theme, row.editors)));
52
+ // A row shows one of Dropped and Moved, like its markers
53
+ if (row.dropped === true) fields.push(markerStatusField(theme, "dropped"));
54
+ else if (row.moved === true) fields.push(markerStatusField(theme, "moved"));
55
+ if (row.change === "forced") fields.push(markerStatusField(theme, "forced"));
56
+ if (row.forcedEdit !== undefined) {
57
+ fields.push(markerStatusField(theme, row.forcedEdit.sent ? "forced-edit-sent" : "forced-edit-unsent"));
58
+ }
59
+ return fields;
60
+ }
61
+
62
+ /** One field: its `muted` name, then a dim colon and the value. */
63
+ function field(theme: Theme, name: string, value: string): string {
64
+ return `${theme.fg("muted", name)}${theme.fg("dim", ": ")}${value}`;
65
+ }
66
+
67
+ /** `+N -N` in the diff colors, then where the changes live; only the phrase without a diff. */
68
+ function changeText(theme: Theme, diff: TokenDiff | undefined, scope: ChangeScope): string {
69
+ const sides: string[] = [];
70
+ if (diff !== undefined && diff.added > 0) sides.push(theme.fg("toolDiffAdded", formatDiffAdded(diff.added)));
71
+ if (diff !== undefined && diff.removed > 0) sides.push(theme.fg("toolDiffRemoved", formatDiffRemoved(diff.removed)));
72
+ const phrase = SCOPE_PHRASES[scope];
73
+ if (sides.length === 0) return theme.fg("dim", phrase);
74
+ return `${sides.join(" ")}${theme.fg("dim", `${scope === "partly" ? "," : ""} ${phrase}`)}`;
75
+ }
76
+
77
+ /** Comma-separated editors: sanitized extension labels with a dim `(guess)` where inferred, then `Unknown`. */
78
+ function editorsText(theme: Theme, editors: readonly ChangeEditor[]): string {
79
+ return editors.map((editor) => {
80
+ if (editor.type === "unknown") return theme.fg("dim", "Unknown");
81
+ const label = theme.fg("dim", normalizeInlineText(editor.source.label));
82
+ return editor.attribution === "guess" ? `${label}${guessMarker(theme)}` : label;
83
+ }).join(theme.fg("dim", ", "));
84
+ }
85
+
86
+ /** Wrap one field under its indent, hanging continuation lines two columns deeper. */
87
+ function wrapField(text: string, width: number): string[] {
88
+ const indentWidth = Math.min(CONTINUATION_INDENT.length, Math.max(0, width - 1));
89
+ const wrapped = wrapTextWithAnsi(text, Math.max(1, width - indentWidth));
90
+ return wrapped.map((line, index) => {
91
+ const indent = (index === 0 ? FIELD_INDENT : CONTINUATION_INDENT).slice(0, indentWidth);
92
+ return fitLine(`${indent}${line}`, width);
93
+ });
94
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Pure proportional-cell model for the Usage view's context map. Geometry is
3
+ * an input: the caller clamps the configured cell counts to what the viewport
4
+ * can render. The map uses estimated category totals against the selected
5
+ * Window/Fit scale. Pi's separately reported occupied tokens may differ
6
+ * because of tokenizer, serialization, caching, and last-response timing.
7
+ */
8
+ import type { ContextUsageSnapshot } from "../model.ts";
9
+
10
+ const FIT_SCALE_PERCENT = 115;
11
+ const PERCENT_DENOMINATOR = 100;
12
+ const MINIMUM_FIT_SCALE_TOKENS = 10_000;
13
+ const FIT_SCALE_SIGNIFICANT_DIGITS = 2;
14
+
15
+ /** One visual map cell assigned to a category, the auto-compact buffer, or remaining free space. */
16
+ export interface UsageMapCell {
17
+ readonly categoryId?: string;
18
+ readonly fill: "full" | "partial" | "buffer" | "free";
19
+ }
20
+
21
+ /** Rectangular context-usage map in row-major order. */
22
+ export interface UsageMap {
23
+ readonly columns: number;
24
+ readonly rows: number;
25
+ /** Tokens one cell represents at the active scale; shrinks when Fit narrows the denominator. */
26
+ readonly blockTokens: number;
27
+ readonly cells: readonly UsageMapCell[];
28
+ }
29
+
30
+ interface MapSegment {
31
+ readonly categoryId: string;
32
+ readonly start: number;
33
+ readonly end: number;
34
+ }
35
+
36
+ /**
37
+ * Calculate a Fit denominator from estimated occupancy: 15% headroom,
38
+ * rounded upward to two significant digits, with the documented floor/cap.
39
+ */
40
+ export function calculateFitMapScale(usage: ContextUsageSnapshot): number | undefined {
41
+ const contextWindow = usage.reported?.contextWindow;
42
+ if (contextWindow === undefined || !Number.isFinite(contextWindow) || contextWindow <= 0) return undefined;
43
+
44
+ const estimatedTotal = usage.categories.reduce((sum, category) => sum + category.tokens, 0);
45
+ const withHeadroom = Math.max(0, estimatedTotal) * FIT_SCALE_PERCENT / PERCENT_DENOMINATOR;
46
+ const rounded = roundUpToSignificantDigits(withHeadroom, FIT_SCALE_SIGNIFICANT_DIGITS);
47
+ return Math.min(contextWindow, Math.max(MINIMUM_FIT_SCALE_TOKENS, rounded));
48
+ }
49
+
50
+ /**
51
+ * Build a proportional map from estimated categories. `scaleTokens` changes
52
+ * only the mapped denominator; buffer placement remains anchored to the true
53
+ * context window. Returns undefined without a usable denominator.
54
+ */
55
+ export function buildUsageMap(
56
+ usage: ContextUsageSnapshot,
57
+ columns: number,
58
+ rows: number,
59
+ scaleTokens?: number,
60
+ ): UsageMap | undefined {
61
+ const contextWindow = usage.reported?.contextWindow;
62
+ if (
63
+ contextWindow === undefined ||
64
+ !Number.isFinite(contextWindow) ||
65
+ contextWindow <= 0 ||
66
+ columns <= 0 ||
67
+ rows <= 0
68
+ ) return undefined;
69
+
70
+ const requestedScale = scaleTokens ?? contextWindow;
71
+ if (!Number.isFinite(requestedScale) || requestedScale <= 0) return undefined;
72
+ const mapScale = Math.min(contextWindow, requestedScale);
73
+ const cellCount = Math.floor(columns) * Math.floor(rows);
74
+ const estimatedTotal = usage.categories.reduce((sum, category) => sum + category.tokens, 0);
75
+ const occupiedTokens = clamp(estimatedTotal, 0, mapScale);
76
+ const occupiedCells = occupiedTokens / mapScale * cellCount;
77
+ // Fit reserves its headroom as visible free space; the true-window buffer remains outside the mapped range.
78
+ const windowOccupancy = clamp(estimatedTotal, 0, contextWindow);
79
+ const bufferTokens = mapScale < contextWindow
80
+ ? 0
81
+ : clamp(usage.autoCompactReserveTokens ?? 0, 0, contextWindow - windowOccupancy);
82
+ const bufferStart = (contextWindow - bufferTokens) / mapScale * cellCount;
83
+ const segments = createSegments(usage, estimatedTotal, occupiedCells);
84
+ const cells = Array.from(
85
+ { length: cellCount },
86
+ (_, index) => createCell(index, occupiedCells, bufferStart, segments),
87
+ );
88
+ return {
89
+ columns: Math.floor(columns),
90
+ rows: Math.floor(rows),
91
+ blockTokens: mapScale / cellCount,
92
+ cells,
93
+ };
94
+ }
95
+
96
+ /** Scale estimated category shares into the occupied map range. */
97
+ function createSegments(
98
+ usage: ContextUsageSnapshot,
99
+ estimatedTotal: number,
100
+ occupiedCells: number,
101
+ ): MapSegment[] {
102
+ if (estimatedTotal <= 0 || occupiedCells <= 0) return [];
103
+ const segments: MapSegment[] = [];
104
+ let cursor = 0;
105
+ for (const category of usage.categories) {
106
+ const size = category.tokens / estimatedTotal * occupiedCells;
107
+ segments.push({ categoryId: category.id, start: cursor, end: cursor + size });
108
+ cursor += size;
109
+ }
110
+ return segments;
111
+ }
112
+
113
+ /** Assign one map cell to its largest category overlap and classify its fill. */
114
+ function createCell(
115
+ index: number,
116
+ occupiedCells: number,
117
+ bufferStart: number,
118
+ segments: readonly MapSegment[],
119
+ ): UsageMapCell {
120
+ const occupiedOverlap = overlap(index, index + 1, 0, occupiedCells);
121
+ if (occupiedOverlap <= 0) {
122
+ // An unoccupied cell belongs to the buffer when at least half of it lies past the trigger point.
123
+ return overlap(index, index + 1, bufferStart, index + 1) >= 0.5 ? { fill: "buffer" } : { fill: "free" };
124
+ }
125
+
126
+ let categoryId: string | undefined;
127
+ let categoryOverlap = 0;
128
+ for (const segment of segments) {
129
+ const currentOverlap = overlap(index, index + 1, segment.start, segment.end);
130
+ if (currentOverlap > categoryOverlap) {
131
+ categoryId = segment.categoryId;
132
+ categoryOverlap = currentOverlap;
133
+ }
134
+ }
135
+ return {
136
+ categoryId,
137
+ fill: categoryOverlap >= 0.7 ? "full" : "partial",
138
+ };
139
+ }
140
+
141
+ /** Length shared by two half-open numeric ranges. */
142
+ function overlap(aStart: number, aEnd: number, bStart: number, bEnd: number): number {
143
+ return Math.max(0, Math.min(aEnd, bEnd) - Math.max(aStart, bStart));
144
+ }
145
+
146
+ /** Round a positive value upward at the requested significant-digit boundary. */
147
+ function roundUpToSignificantDigits(value: number, significantDigits: number): number {
148
+ if (!Number.isFinite(value) || value <= 0) return 0;
149
+ const boundary = 10 ** (Math.floor(Math.log10(value)) - significantDigits + 1);
150
+ return Math.ceil(value / boundary) * boundary;
151
+ }
152
+
153
+ /** Restrict a finite value to an inclusive range. */
154
+ function clamp(value: number, minimum: number, maximum: number): number {
155
+ if (!Number.isFinite(value)) return minimum;
156
+ return Math.min(maximum, Math.max(minimum, value));
157
+ }