@xprnvd/pi-context-view 0.0.0-stage → 0.8.0

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 (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +114 -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 +514 -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 +251 -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 +487 -0
  46. package/src/ui/injections-view.ts +1023 -0
  47. package/src/ui/json-preview.ts +41 -0
  48. package/src/ui/layout.ts +161 -0
  49. package/src/ui/markers.ts +253 -0
  50. package/src/ui/notes.ts +117 -0
  51. package/src/ui/search.ts +427 -0
  52. package/src/ui/section-preview.ts +267 -0
  53. package/src/ui/skill-preview.ts +83 -0
  54. package/src/ui/status-bar.ts +94 -0
  55. package/src/ui/usage-map.ts +157 -0
  56. package/src/ui/usage-preview.ts +202 -0
  57. package/src/ui/usage-view.ts +1428 -0
  58. package/src/ui/wheel.ts +65 -0
  59. package/src/usage.ts +461 -0
@@ -0,0 +1,427 @@
1
+ /**
2
+ * Shared find-as-you-type pieces for both views: the one-line query prompt,
3
+ * case-insensitive matching, and match highlighting inside rendered lines.
4
+ * Matching follows pi's fullscreen transcript search: every whitespace run,
5
+ * line breaks included, matches a single space, so a phrase that wraps across
6
+ * two preview lines is still found. No pi access beyond the theme.
7
+ */
8
+ import type { Theme } from "@earendil-works/pi-coding-agent";
9
+ import {
10
+ Input,
11
+ sliceByColumn,
12
+ stripTerminalSequences,
13
+ truncateToWidth,
14
+ visibleWidth,
15
+ } from "@earendil-works/pi-tui";
16
+
17
+ import { normalizeInlineText, normalizePreviewText } from "../text.ts";
18
+ import { BODY_INDENT, fitLine, spreadLine } from "./layout.ts";
19
+
20
+ /** Hint-row key label that opens the search prompt in either view. */
21
+ export const SEARCH_KEY = "/";
22
+ /** Hint-row key label for stepping through preview matches. */
23
+ export const MATCH_STEP_KEY_HINT = "n/N";
24
+
25
+ const ANSI_SEQUENCE = /\u001B\[[\u0030-\u003F]*[\u0020-\u002F]*[\u0040-\u007E]|\u001B[\]_P^X][^\u0007\u001B]*(?:\u0007|\u001B\\)/g;
26
+ const TRAILING_ANSI = new RegExp(`(?:${ANSI_SEQUENCE.source})+$`);
27
+ const WORD = /\S+/gu;
28
+ const PRINTABLE_ASCII = /^[\u0020-\u007E]*$/;
29
+ const segmenter = new Intl.Segmenter();
30
+
31
+ /** Visible-column span of one match on one rendered line, end exclusive. */
32
+ export interface MatchSegment {
33
+ readonly row: number;
34
+ readonly start: number;
35
+ readonly end: number;
36
+ }
37
+
38
+ /** One query occurrence; several segments when it wraps across rows. */
39
+ export interface SearchMatch {
40
+ readonly segments: readonly MatchSegment[];
41
+ }
42
+
43
+ /** One highlight range on a rendered line. */
44
+ export interface HighlightRange {
45
+ readonly start: number;
46
+ readonly end: number;
47
+ /** The match the user is on; emphasized over the others. */
48
+ readonly current: boolean;
49
+ }
50
+
51
+ /** Corpus character range mapped back to a run of visible columns on one row. */
52
+ interface CorpusSpan {
53
+ readonly textStart: number;
54
+ readonly textEnd: number;
55
+ readonly row: number;
56
+ readonly start: number;
57
+ readonly end: number;
58
+ /** One column per character, so inner offsets map linearly. */
59
+ readonly linear: boolean;
60
+ }
61
+
62
+ /** Whitespace-collapsed text of every line plus its mapping to rows and columns. */
63
+ interface SearchCorpus {
64
+ readonly text: string;
65
+ readonly spans: readonly CorpusSpan[];
66
+ }
67
+
68
+ /** Case-insensitive pattern for a query, or undefined when it is blank. */
69
+ export function searchPattern(query: string): RegExp | undefined {
70
+ const normalized = query.replace(/\s+/gu, " ").trim();
71
+ if (normalized === "") return undefined;
72
+ return new RegExp(normalized.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "iu");
73
+ }
74
+
75
+ /** Whether a row label contains the query, compared as it renders on one line. */
76
+ export function labelMatches(label: string, pattern: RegExp): boolean {
77
+ return pattern.test(normalizeInlineText(label));
78
+ }
79
+
80
+ /**
81
+ * Searchable form of raw content: sanitized like a preview, with whitespace
82
+ * collapsed so a phrase matches however it wraps. Callers cache the result.
83
+ */
84
+ export function searchableContent(text: string): string {
85
+ return normalizePreviewText(text).replace(/\s+/gu, " ");
86
+ }
87
+
88
+ /** Every occurrence of the query across rendered lines, in reading order. */
89
+ export function findMatches(lines: readonly string[], pattern: RegExp): SearchMatch[] {
90
+ const corpus = buildCorpus(lines);
91
+ const global = new RegExp(pattern.source, "giu");
92
+ const matches: SearchMatch[] = [];
93
+ let spanIndex = 0;
94
+ for (const found of corpus.text.matchAll(global)) {
95
+ const start = found.index;
96
+ const end = start + found[0].length;
97
+ if (end === start) continue;
98
+ while (spanIndex < corpus.spans.length && (corpus.spans[spanIndex]?.textEnd ?? 0) <= start) spanIndex++;
99
+ const segments: MatchSegment[] = [];
100
+ for (let index = spanIndex; index < corpus.spans.length; index++) {
101
+ const span = corpus.spans[index];
102
+ if (span === undefined || span.textStart >= end) break;
103
+ const segmentStart = span.linear ? span.start + Math.max(start, span.textStart) - span.textStart : span.start;
104
+ const segmentEnd = span.linear ? span.start + Math.min(end, span.textEnd) - span.textStart : span.end;
105
+ const previous = segments.at(-1);
106
+ // Consecutive spans on one row, including the space between words, join into one segment.
107
+ if (previous !== undefined && previous.row === span.row) {
108
+ segments[segments.length - 1] = { ...previous, end: Math.max(previous.end, segmentEnd) };
109
+ } else {
110
+ segments.push({ row: span.row, start: segmentStart, end: segmentEnd });
111
+ }
112
+ }
113
+ if (segments.length > 0) matches.push({ segments });
114
+ }
115
+ return matches;
116
+ }
117
+
118
+ /**
119
+ * Highlight ranges on one rendered line with pi's own transcript-search styles.
120
+ * Styling is reapplied to every plain run, so the line's own color resets
121
+ * cannot cut a highlight short.
122
+ */
123
+ export function highlightLine(theme: Theme, line: string, ranges: readonly HighlightRange[]): string {
124
+ const width = visibleWidth(line);
125
+ let result = "";
126
+ let column = 0;
127
+ // Every slice comes from the original line, which carries the styles active at its start.
128
+ for (const range of [...ranges].sort((a, b) => a.start - b.start)) {
129
+ const start = Math.max(column, Math.min(range.start, width));
130
+ const end = Math.min(range.end, width);
131
+ if (end <= start) continue;
132
+ result += sliceByColumn(line, column, start - column, true);
133
+ result += styleRuns(sliceByColumn(line, start, end - start, true), (text) => matchStyle(theme, text, range.current));
134
+ column = end;
135
+ }
136
+ if (column === 0) return line;
137
+ // Slicing stops at the last visible column, so restore the resets that close the line.
138
+ return `${result}${sliceByColumn(line, column, width - column, true)}${line.match(TRAILING_ANSI)?.[0] ?? ""}`;
139
+ }
140
+
141
+ /** One match segment on a row, tagged with the match it belongs to. */
142
+ interface RowSegment {
143
+ readonly match: number;
144
+ readonly start: number;
145
+ readonly end: number;
146
+ }
147
+
148
+ /** Match segments grouped by row, so rendering a row never scans every match. */
149
+ function segmentsByRow(matches: readonly SearchMatch[]): Map<number, RowSegment[]> {
150
+ const rows = new Map<number, RowSegment[]>();
151
+ for (const [match, { segments }] of matches.entries()) {
152
+ for (const segment of segments) {
153
+ const row = rows.get(segment.row);
154
+ const entry = { match, start: segment.start, end: segment.end };
155
+ if (row === undefined) rows.set(segment.row, [entry]);
156
+ else row.push(entry);
157
+ }
158
+ }
159
+ return rows;
160
+ }
161
+
162
+ /** Status text for a query: blank, `No matches`, a `current/total` ordinal, or a counted noun. */
163
+ export function matchStatus(
164
+ active: boolean,
165
+ count: number,
166
+ current: number | undefined,
167
+ noun: readonly [singular: string, plural: string] = ["match", "matches"],
168
+ ): string {
169
+ if (!active) return "";
170
+ if (count === 0) return "No matches";
171
+ if (current !== undefined) return `${current + 1}/${count}`;
172
+ return `${count} ${count === 1 ? noun[0] : noun[1]}`;
173
+ }
174
+
175
+ /** Pi's transcript-search look: underlined match colors, or bold inverse for the current match. */
176
+ function matchStyle(theme: Theme, text: string, current: boolean): string {
177
+ const colored = theme.bg("searchMatchBg", theme.fg("searchMatchText", text));
178
+ return current ? theme.bold(theme.inverse(colored)) : theme.underline(colored);
179
+ }
180
+
181
+ /** Apply a style to each plain-text run while passing escape sequences through unchanged. */
182
+ function styleRuns(text: string, style: (text: string) => string): string {
183
+ let result = "";
184
+ let offset = 0;
185
+ for (const sequence of text.matchAll(ANSI_SEQUENCE)) {
186
+ if (sequence.index > offset) result += style(text.slice(offset, sequence.index));
187
+ result += sequence[0];
188
+ offset = sequence.index + sequence[0].length;
189
+ }
190
+ if (offset < text.length) result += style(text.slice(offset));
191
+ return result;
192
+ }
193
+
194
+ /** Join every word on every line with single spaces, remembering where each word renders. */
195
+ function buildCorpus(lines: readonly string[]): SearchCorpus {
196
+ let text = "";
197
+ const spans: CorpusSpan[] = [];
198
+ for (const [row, line] of lines.entries()) {
199
+ const plain = stripTerminalSequences(line);
200
+ for (const word of plain.matchAll(WORD)) {
201
+ if (text.length > 0) text += " ";
202
+ const column = visibleWidth(plain.slice(0, word.index));
203
+ if (PRINTABLE_ASCII.test(word[0])) {
204
+ spans.push({
205
+ textStart: text.length,
206
+ textEnd: text.length + word[0].length,
207
+ row,
208
+ start: column,
209
+ end: column + word[0].length,
210
+ linear: true,
211
+ });
212
+ text += word[0];
213
+ continue;
214
+ }
215
+ let graphemeColumn = column;
216
+ for (const { segment } of segmenter.segment(word[0])) {
217
+ const width = visibleWidth(segment);
218
+ spans.push({
219
+ textStart: text.length,
220
+ textEnd: text.length + segment.length,
221
+ row,
222
+ start: graphemeColumn,
223
+ end: graphemeColumn + width,
224
+ linear: false,
225
+ });
226
+ text += segment;
227
+ graphemeColumn += width;
228
+ }
229
+ }
230
+ }
231
+ return { text, spans };
232
+ }
233
+
234
+ /** Selection change waiting for the next render, which knows the wrapped lines. */
235
+ type PendingMove = "first" | -1 | 1;
236
+
237
+ /**
238
+ * Find-in-preview state over rendered lines: the query prompt, its matches,
239
+ * and the current match. Wrapping depends on width, so the view hands over
240
+ * freshly rendered lines every frame and moves are resolved against them.
241
+ */
242
+ export class PreviewSearch {
243
+ public readonly prompt = new SearchPrompt();
244
+ private matchList: readonly SearchMatch[] = [];
245
+ private matchRows = new Map<number, RowSegment[]>();
246
+ private source: readonly string[] | undefined;
247
+ private sourceQuery = "";
248
+ private currentIndex: number | undefined;
249
+ private pending: PendingMove | undefined;
250
+ private anchor = 0;
251
+
252
+ /** Matches in the lines last passed to `sync`. */
253
+ public get matches(): readonly SearchMatch[] {
254
+ return this.matchList;
255
+ }
256
+
257
+ /** Index of the current match, if any. */
258
+ public get current(): number | undefined {
259
+ return this.currentIndex;
260
+ }
261
+
262
+ /** Status for the prompt row. */
263
+ public get status(): string {
264
+ return matchStatus(this.prompt.active, this.matchList.length, this.currentIndex);
265
+ }
266
+
267
+ /** Open the prompt; typing selects the first match at or after `anchorRow`. */
268
+ public edit(anchorRow: number): void {
269
+ this.anchor = anchorRow;
270
+ this.prompt.edit();
271
+ }
272
+
273
+ /** Feed one key to the open prompt; returns whether the query changed. */
274
+ public type(data: string): boolean {
275
+ if (!this.prompt.type(data)) return false;
276
+ this.pending = "first";
277
+ return true;
278
+ }
279
+
280
+ /** Select the first match at or after `anchorRow` once the next frame's lines are known. */
281
+ public restart(anchorRow = 0): void {
282
+ this.anchor = anchorRow;
283
+ this.currentIndex = undefined;
284
+ this.pending = this.prompt.active ? "first" : undefined;
285
+ }
286
+
287
+ /** Move to the next or previous match, wrapping around; false without a query. */
288
+ public step(direction: -1 | 1): boolean {
289
+ if (!this.prompt.active) return false;
290
+ this.pending = direction;
291
+ return true;
292
+ }
293
+
294
+ /** Drop the query, its matches, and any pending move. */
295
+ public clear(): void {
296
+ this.prompt.clear();
297
+ this.matchList = [];
298
+ this.matchRows.clear();
299
+ this.source = undefined;
300
+ this.sourceQuery = "";
301
+ this.currentIndex = undefined;
302
+ this.pending = undefined;
303
+ }
304
+
305
+ /**
306
+ * Match the query against this frame's lines and resolve any pending move.
307
+ * Returns the row the view should reveal, or undefined when nothing moved.
308
+ */
309
+ public sync(lines: readonly string[]): number | undefined {
310
+ if (lines !== this.source || this.prompt.query !== this.sourceQuery) {
311
+ this.source = lines;
312
+ this.sourceQuery = this.prompt.query;
313
+ const pattern = this.prompt.pattern;
314
+ this.matchList = pattern === undefined ? [] : findMatches(lines, pattern);
315
+ this.matchRows = segmentsByRow(this.matchList);
316
+ if (this.currentIndex !== undefined && this.currentIndex >= this.matchList.length) {
317
+ this.currentIndex = this.matchList.length === 0 ? undefined : this.matchList.length - 1;
318
+ }
319
+ }
320
+ const pending = this.pending;
321
+ this.pending = undefined;
322
+ if (pending === undefined || this.matchList.length === 0) {
323
+ if (pending !== undefined) this.currentIndex = undefined;
324
+ return undefined;
325
+ }
326
+ this.currentIndex = this.resolve(pending);
327
+ return this.matchList[this.currentIndex]?.segments[0]?.row;
328
+ }
329
+
330
+ /** Highlight every match segment on one rendered row. */
331
+ public highlight(theme: Theme, line: string, row: number): string {
332
+ const segments = this.matchRows.get(row);
333
+ if (segments === undefined) return line;
334
+ const ranges = segments.map(({ match, start, end }) => ({ start, end, current: match === this.currentIndex }));
335
+ return highlightLine(theme, line, ranges);
336
+ }
337
+
338
+ /** Index a pending move lands on; the match list is non-empty. */
339
+ private resolve(pending: PendingMove): number {
340
+ const count = this.matchList.length;
341
+ if (pending === "first") {
342
+ const index = this.matchList.findIndex((match) => (match.segments[0]?.row ?? 0) >= this.anchor);
343
+ return index === -1 ? 0 : index;
344
+ }
345
+ if (this.currentIndex === undefined) return pending === 1 ? 0 : count - 1;
346
+ return (this.currentIndex + pending + count) % count;
347
+ }
348
+ }
349
+
350
+ /**
351
+ * One-line query prompt. While open it edits through pi's own `Input`, so
352
+ * word deletion, kill/yank, and undo behave as everywhere else in pi; once
353
+ * submitted it keeps showing the query as read-only text.
354
+ */
355
+ export class SearchPrompt {
356
+ private input = new Input({ prompt: "" });
357
+ private open = false;
358
+
359
+ /** Whether keys currently edit the query. */
360
+ public get editing(): boolean {
361
+ return this.open;
362
+ }
363
+
364
+ /** The query as typed. */
365
+ public get query(): string {
366
+ return this.input.getValue();
367
+ }
368
+
369
+ /** Whether a non-blank query is set, typed or submitted. */
370
+ public get active(): boolean {
371
+ return searchPattern(this.query) !== undefined;
372
+ }
373
+
374
+ /** Pattern for the current query, or undefined when it is blank. */
375
+ public get pattern(): RegExp | undefined {
376
+ return searchPattern(this.query);
377
+ }
378
+
379
+ /** Start editing, keeping the current query so it can be refined. */
380
+ public edit(): void {
381
+ this.open = true;
382
+ }
383
+
384
+ /** Stop editing and keep the query. */
385
+ public submit(): void {
386
+ this.open = false;
387
+ }
388
+
389
+ /** Drop the query and stop editing. */
390
+ public clear(): void {
391
+ this.input = new Input({ prompt: "" });
392
+ this.open = false;
393
+ }
394
+
395
+ /** Replace the query with the cursor at its end, without editing. */
396
+ public set(query: string): void {
397
+ this.input = new Input({ prompt: "" });
398
+ this.input.handleInput(query);
399
+ this.open = false;
400
+ }
401
+
402
+ /** Feed one key to the open prompt; returns whether the query changed. */
403
+ public type(data: string): boolean {
404
+ const previous = this.query;
405
+ this.input.handleInput(data);
406
+ return this.query !== previous;
407
+ }
408
+
409
+ /**
410
+ * Prompt row: dim label, the query (with pi's fake cursor while editing),
411
+ * and a right-aligned status such as the match count.
412
+ */
413
+ public render(theme: Theme, width: number, label: string, status: string): string {
414
+ return this.renderAfter(theme, width, theme.fg("dim", `${label}: `), status);
415
+ }
416
+
417
+ /** Prompt row after an already styled `lead`, such as a mode switcher, instead of a plain label. */
418
+ public renderAfter(theme: Theme, width: number, lead: string, status: string): string {
419
+ const prefix = `${BODY_INDENT}${lead}`;
420
+ const right = status === "" ? "" : `${theme.fg("dim", status)} `;
421
+ const queryWidth = Math.max(1, width - visibleWidth(prefix) - visibleWidth(right) - 1);
422
+ const query = this.open
423
+ ? truncateToWidth(this.input.render(queryWidth)[0] ?? "", queryWidth, "").trimEnd()
424
+ : theme.fg("text", truncateToWidth(normalizeInlineText(this.query), queryWidth, "…"));
425
+ return right === "" ? fitLine(`${prefix}${query}`, width) : spreadLine(`${prefix}${query}`, right, width);
426
+ }
427
+ }
@@ -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
+ }