@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,41 @@
1
+ /**
2
+ * Preview-only expansion of the JSON runs the model marks structurally: a
3
+ * tool's parameter schema, tool-call arguments, and serialized message
4
+ * content. Every preview level expands them; the compact provider-bound form
5
+ * still backs every token estimate. Pure string logic — no pi or TUI access.
6
+ */
7
+ import type { JsonSpan } from "../model.ts";
8
+
9
+ /** Spaces per nesting level, matching how tool schemas are usually authored. */
10
+ const JSON_INDENT = 2;
11
+
12
+ /**
13
+ * Text with its marked JSON run re-serialized across indented lines. Text
14
+ * without a span, and a span whose slice no longer parses, are returned
15
+ * unchanged, so a stale marker degrades to the captured form.
16
+ */
17
+ export function expandJsonSpan(text: string, span: JsonSpan | undefined): string {
18
+ if (span === undefined) return text;
19
+ const expanded = prettifyJson(text.slice(span.start, span.end));
20
+ if (expanded === undefined) return text;
21
+ return `${text.slice(0, span.start)}${expanded}${text.slice(span.end)}`;
22
+ }
23
+
24
+ /**
25
+ * Re-anchor a span after `removed` characters were dropped from the front of
26
+ * its text; undefined once the removal reaches into the JSON run itself.
27
+ */
28
+ export function shiftJsonSpan(span: JsonSpan | undefined, removed: number): JsonSpan | undefined {
29
+ if (span === undefined || removed === 0) return span;
30
+ if (removed > span.start) return undefined;
31
+ return { start: span.start - removed, end: span.end - removed };
32
+ }
33
+
34
+ /** Indented re-serialization of one JSON document, or undefined when the run does not parse. */
35
+ function prettifyJson(source: string): string | undefined {
36
+ try {
37
+ return JSON.stringify(JSON.parse(source), undefined, JSON_INDENT);
38
+ } catch {
39
+ return undefined;
40
+ }
41
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Shared pi-native fullscreen-view helpers: indentation constants,
3
+ * terminal-height viewport math, width fitting, hint-row formatting, and
4
+ * step-navigation key matching used by the Usage and Injections views. Pure
5
+ * string/number logic — no pi access.
6
+ */
7
+ import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
8
+ import { Key, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
9
+
10
+ /** Hint-row key label for the single-step navigation keys both views accept. */
11
+ export const STEP_KEY_HINT = "↑↓/jk";
12
+
13
+ /** Two-space indent for descriptions, counters, hints, and body content. */
14
+ export const BODY_INDENT = " ";
15
+ export const DEFAULT_TERMINAL_ROWS = 24;
16
+
17
+ /** How many content rows fit and whether an overflow indicator is needed. */
18
+ export interface Viewport {
19
+ visibleCount: number;
20
+ showScroll: boolean;
21
+ }
22
+
23
+ /** Divide terminal rows between content and an overflow indicator. */
24
+ export function calculateViewport(
25
+ itemCount: number,
26
+ terminalRows: number,
27
+ fixedLineCount: number,
28
+ extraLineCount = 0,
29
+ ): Viewport {
30
+ const available = Math.max(1, terminalRows - fixedLineCount - extraLineCount);
31
+ const showScroll = itemCount > available && available > 1;
32
+ return {
33
+ visibleCount: Math.max(1, available - (showScroll ? 1 : 0)),
34
+ showScroll,
35
+ };
36
+ }
37
+
38
+ /** Keep emergency short-terminal output bounded while preserving both borders. */
39
+ export function fitToTerminalHeight(lines: string[], terminalRows: number, border: string): string[] {
40
+ if (lines.length <= terminalRows) return lines;
41
+ if (terminalRows === 1) return [border];
42
+ return [...lines.slice(0, terminalRows - 1), border];
43
+ }
44
+
45
+ /** Normalize an injected terminal-height reading to a usable positive integer. */
46
+ export function normalizeTerminalRows(rows: number): number {
47
+ return Number.isFinite(rows) ? Math.max(1, Math.floor(rows)) : DEFAULT_TERMINAL_ROWS;
48
+ }
49
+
50
+ /** Whether input requests one step backwards: Up or vim-style `k`. */
51
+ export function isStepBackKey(data: string): boolean {
52
+ return matchesKey(data, Key.up) || data === "k";
53
+ }
54
+
55
+ /** Whether input requests one step forwards: Down or vim-style `j`. */
56
+ export function isStepForwardKey(data: string): boolean {
57
+ return matchesKey(data, Key.down) || data === "j";
58
+ }
59
+
60
+ /** Whether input requests one page backwards: PgUp or `Ctrl+U`. */
61
+ export function isPageBackKey(data: string): boolean {
62
+ return matchesKey(data, Key.pageUp) || matchesKey(data, Key.ctrl("u"));
63
+ }
64
+
65
+ /** Whether input requests one page forwards: PgDn or `Ctrl+D`. */
66
+ export function isPageForwardKey(data: string): boolean {
67
+ return matchesKey(data, Key.pageDown) || matchesKey(data, Key.ctrl("d"));
68
+ }
69
+
70
+ /** Truncate one rendered line to the supplied width. */
71
+ export function fitLine(line: string, width: number): string {
72
+ return truncateToWidth(line, width, "…");
73
+ }
74
+
75
+ /**
76
+ * Rows a description block occupies, including the blank row above it; zero
77
+ * when collapsed. A description is the least important block on a frame, so
78
+ * each view drops the whole block once its own content threshold is reached.
79
+ */
80
+ export function descriptionBlockRows(lines: readonly string[]): number {
81
+ return lines.length === 0 ? 0 : lines.length + 1;
82
+ }
83
+
84
+ /** Wrap plain dialog-description text with semantic color and an indented continuation column. */
85
+ export function wrapDescriptionLines(
86
+ theme: Theme,
87
+ text: string,
88
+ color: ThemeColor,
89
+ width: number,
90
+ ): string[] {
91
+ const indentWidth = Math.min(BODY_INDENT.length, Math.max(0, width - 1));
92
+ const indent = BODY_INDENT.slice(0, indentWidth);
93
+ const contentWidth = Math.max(1, width - indentWidth);
94
+ const wrapped = wrapTextWithAnsi(text, contentWidth);
95
+ return (wrapped.length === 0 ? [""] : wrapped).map((line) =>
96
+ truncateToWidth(theme.fg(color, `${indent}${line}`), width, "")
97
+ );
98
+ }
99
+
100
+ /**
101
+ * Keep wrapped notice blocks within `maxLines` rows, so a broken configuration
102
+ * file cannot push a view's content off the frame. On overflow, the last kept
103
+ * row becomes a `… +N more` notice, wrapped by `wrapNotice`, counting every
104
+ * block not shown whole.
105
+ */
106
+ export function capNoticeLines(
107
+ blocks: readonly (readonly string[])[],
108
+ maxLines: number,
109
+ wrapNotice: (notice: string) => string[],
110
+ ): string[] {
111
+ const lines = blocks.flat();
112
+ if (lines.length <= maxLines) return lines;
113
+ const kept = lines.slice(0, maxLines - 1);
114
+ const hidden = blocks.length - countWholeBlocks(blocks, kept.length);
115
+ return [...kept, ...wrapNotice(`… +${hidden} more`)];
116
+ }
117
+
118
+ /** Notices whose wrapped lines fit entirely into the first `keptLines` rows. */
119
+ function countWholeBlocks(blocks: readonly (readonly string[])[], keptLines: number): number {
120
+ let used = 0;
121
+ let whole = 0;
122
+ for (const block of blocks) {
123
+ if (used + block.length > keptLines) break;
124
+ used += block.length;
125
+ whole += 1;
126
+ }
127
+ return whole;
128
+ }
129
+
130
+ /** Spread left and right content across the width, truncating the left side on overlap. */
131
+ export function spreadLine(left: string, right: string, width: number): string {
132
+ const gap = width - visibleWidth(left) - visibleWidth(right);
133
+ if (gap < 1) {
134
+ return fitLine(`${truncateToWidth(left, Math.max(1, width - visibleWidth(right) - 2), "…")} ${right}`, width);
135
+ }
136
+ return `${left}${" ".repeat(gap)}${right}`;
137
+ }
138
+
139
+ /** Pi-style hint row: two-space indent, `key description` pairs joined by ` · `. */
140
+ export function hintRow(theme: Theme, hints: ReadonlyArray<readonly [string, string]>): string {
141
+ const separator = theme.fg("dim", " · ");
142
+ return `${BODY_INDENT}${hints.map(([key, description]) => hint(theme, key, description)).join(separator)}`;
143
+ }
144
+
145
+ /** Pi-style hint: dim key, slightly brighter (muted) description. */
146
+ function hint(theme: Theme, key: string, description: string): string {
147
+ return theme.fg("dim", key) + theme.fg("muted", ` ${description}`);
148
+ }
@@ -0,0 +1,253 @@
1
+ /**
2
+ * State markers rendered after a token estimate, token diffs, and the bullet
3
+ * preview legend and selected-row status fields that explain them. Markers
4
+ * name how pi accounts for a part rather than a usage category, so their colors are fixed instead
5
+ * of configurable. Pure string logic shared by both views.
6
+ */
7
+ import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
8
+ import { wrapTextWithAnsi } from "@earendil-works/pi-tui";
9
+
10
+ import type { ForcedEdit, RequestChange, TokenDiff } from "../model.ts";
11
+ import { BODY_INDENT, fitLine } from "./layout.ts";
12
+
13
+ /** Dim separator introducing a state marker after the estimate it explains. */
14
+ const MARKER_SEPARATOR = " · ";
15
+
16
+ /** Markers a frame can show, in the fixed order their legend bullets render. */
17
+ const MARKER_ORDER = [
18
+ "highlighted", "guess", "dropped", "moved", "forced", "forced-edit", "diff", "request-only",
19
+ ] as const;
20
+
21
+ /** One marked state a description bullet explains; `diff` stands for `+N`/`-N` token diffs. */
22
+ export type ContextMarker = (typeof MARKER_ORDER)[number];
23
+
24
+ /**
25
+ * The state of one part or row that markers show; only a `forced` change has a
26
+ * marker of its own. A replaced edit shows the same `Forced` keyword.
27
+ */
28
+ export interface MarkedState {
29
+ readonly dropped?: boolean;
30
+ readonly moved?: boolean;
31
+ readonly change?: RequestChange;
32
+ readonly forcedEdit?: Pick<ForcedEdit, "sent">;
33
+ readonly requestOnly?: boolean;
34
+ }
35
+
36
+ /**
37
+ * What the token diffs of a preview compare with:
38
+ * default-prompt pi's default prompt, for contributions under pi
39
+ * session the session, for extension tools and messages
40
+ * captured-request the captured request, for payload changes
41
+ */
42
+ export type DiffBase = "default-prompt" | "session" | "captured-request";
43
+
44
+ /** Preview diff-legend sentences by base; the fallback sentence names none. */
45
+ const DIFF_EXPLANATIONS: Record<DiffBase | "list", string> = {
46
+ list: " Tokens extensions added or removed.",
47
+ "default-prompt": " Tokens extensions added or removed, compared with pi’s default prompt.",
48
+ session: " Tokens extensions added or removed, compared with the session.",
49
+ "captured-request": " Tokens added or removed, compared with the captured request.",
50
+ };
51
+
52
+ /** Bullet marker and hanging indent of a legend entry, both two columns wide. */
53
+ const BULLET_MARKER = "- ";
54
+ const BULLET_INDENT = `${BODY_INDENT} `;
55
+
56
+ /** A legend sentence: dim text, with runs that name a colored mark shown in that mark's color. */
57
+ type LegendText = string | ReadonlyArray<string | { readonly text: string; readonly color: ThemeColor }>;
58
+
59
+ /** Keyword opening a legend bullet, its fixed color, and the sentence it heads. */
60
+ interface MarkerLegend {
61
+ readonly keyword: string;
62
+ readonly color: ThemeColor;
63
+ readonly explanation: LegendText;
64
+ }
65
+
66
+ /** One fixed sentence per marker, independent of the others. */
67
+ const MARKER_LEGENDS: Record<Exclude<ContextMarker, "diff">, MarkerLegend> = {
68
+ highlighted: {
69
+ keyword: "Highlighted",
70
+ // Extension lines carry this color, so the bullet opens in it too.
71
+ color: "syntaxNumber",
72
+ explanation: " parts are injected by extensions into pi’s system prompt.",
73
+ },
74
+ guess: {
75
+ keyword: "(guess)",
76
+ color: "dim",
77
+ explanation: " sources are inferred from the injected text itself.",
78
+ },
79
+ dropped: {
80
+ keyword: "Dropped",
81
+ color: "toolDiffRemoved",
82
+ explanation: " parts were replaced by a custom system prompt and are counted nowhere.",
83
+ },
84
+ moved: {
85
+ keyword: "Moved",
86
+ color: "warning",
87
+ explanation: " blocks appear in a different position in the system prompt than usual.",
88
+ },
89
+ forced: {
90
+ keyword: "Forced",
91
+ color: "warning",
92
+ explanation: " prompts replace pi’s system prompt for this request.",
93
+ },
94
+ "forced-edit": {
95
+ keyword: "Forced",
96
+ color: "warning",
97
+ // The signs open diff lines in these colors, so the sentence names them in the same colors
98
+ explanation: [
99
+ " parts were edited for this request, but the forced prompt discarded the edit. Lines marked ",
100
+ { text: "+", color: "toolDiffAdded" },
101
+ " were sent to the model; lines marked ",
102
+ { text: "-", color: "toolDiffRemoved" },
103
+ " were never sent.",
104
+ ],
105
+ },
106
+ "request-only": {
107
+ keyword: "Request-Only",
108
+ color: "dim",
109
+ explanation: " Includes changes made only in this request, not saved in the session.",
110
+ },
111
+ };
112
+
113
+ /**
114
+ * Markers the Injections status bar explains on the selected row; its Change
115
+ * field covers diffs and Request-Only. A replaced edit is explained by whether
116
+ * the forced prompt still sent its part.
117
+ */
118
+ export type StatusMarker = "dropped" | "moved" | "forced" | "forced-edit-sent" | "forced-edit-unsent";
119
+
120
+ /** Status-bar explanation of each marker, about the one selected row, and the legend naming its keyword. */
121
+ const MARKER_STATUS: Record<StatusMarker, { readonly legend: keyof typeof MARKER_LEGENDS; readonly text: string }> = {
122
+ dropped: { legend: "dropped", text: "a custom system prompt replaced this part; counted nowhere" },
123
+ moved: { legend: "moved", text: "in a different position in the system prompt than usual" },
124
+ forced: { legend: "forced", text: "an extension’s prompt replaced pi’s system prompt for this request" },
125
+ "forced-edit-sent": {
126
+ legend: "forced-edit",
127
+ text: "a forced prompt replaced this part’s request edit; Pi sent the forced prompt’s version",
128
+ },
129
+ "forced-edit-unsent": {
130
+ legend: "forced-edit",
131
+ text: "a forced prompt replaced this part’s request edit; Pi sent nothing",
132
+ },
133
+ };
134
+
135
+ /** Themed marker naming content pi never sent, for a preview subheader or a hierarchy row. */
136
+ export function droppedMarker(theme: Theme): string {
137
+ return stateMarker(theme, MARKER_LEGENDS.dropped);
138
+ }
139
+
140
+ /** Themed marker naming content pi sends from elsewhere in the prompt than it wrote it. */
141
+ export function movedMarker(theme: Theme): string {
142
+ return stateMarker(theme, MARKER_LEGENDS.moved);
143
+ }
144
+
145
+ /** `+N` with thousands separators; the plain text of a diff column. */
146
+ export function formatDiffAdded(tokens: number): string {
147
+ return `+${tokens.toLocaleString("en-US")}`;
148
+ }
149
+
150
+ /** `-N` with thousands separators; the plain text of a diff column. */
151
+ export function formatDiffRemoved(tokens: number): string {
152
+ return `-${tokens.toLocaleString("en-US")}`;
153
+ }
154
+
155
+ /**
156
+ * A token diff after an estimate in preview metadata, behind a dim separator:
157
+ * `+N` in `toolDiffAdded` and `-N` in `toolDiffRemoved`, leaving out a zero
158
+ * side. Empty without a diff.
159
+ */
160
+ export function diffMarker(theme: Theme, diff: TokenDiff | undefined): string {
161
+ const sides: string[] = [];
162
+ if (diff !== undefined && diff.added > 0) sides.push(theme.fg("toolDiffAdded", formatDiffAdded(diff.added)));
163
+ if (diff !== undefined && diff.removed > 0) sides.push(theme.fg("toolDiffRemoved", formatDiffRemoved(diff.removed)));
164
+ return sides.length === 0 ? "" : `${theme.fg("dim", MARKER_SEPARATOR)}${sides.join(" ")}`;
165
+ }
166
+
167
+ /** Themed marker naming a request that carried an extension's forced prompt instead of pi's. */
168
+ export function forcedMarker(theme: Theme): string {
169
+ return stateMarker(theme, MARKER_LEGENDS.forced);
170
+ }
171
+
172
+ /**
173
+ * Every state marker of one part, in legend order: dropped, moved, forced,
174
+ * then request-only. Empty when the part carries none. Token diffs, not
175
+ * markers, show what an added, modified, or deleted part changed.
176
+ */
177
+ export function stateMarkers(theme: Theme, part: MarkedState): string {
178
+ let markers = part.dropped === true ? droppedMarker(theme) : part.moved === true ? movedMarker(theme) : "";
179
+ if (part.change === "forced" || part.forcedEdit !== undefined) markers += forcedMarker(theme);
180
+ if (part.requestOnly === true) markers += stateMarker(theme, MARKER_LEGENDS["request-only"]);
181
+ return markers;
182
+ }
183
+
184
+ /** Markers one part carries, including its token diff, for the legend of the frame showing it. */
185
+ export function partMarkers(part: MarkedState & { readonly diff?: TokenDiff }): ContextMarker[] {
186
+ const markers: ContextMarker[] = [];
187
+ if (part.dropped === true) markers.push("dropped");
188
+ if (part.moved === true) markers.push("moved");
189
+ if (part.change === "forced") markers.push("forced");
190
+ if (part.forcedEdit !== undefined) markers.push("forced-edit");
191
+ if (part.diff !== undefined && (part.diff.added > 0 || part.diff.removed > 0)) markers.push("diff");
192
+ if (part.requestOnly === true) markers.push("request-only");
193
+ return markers;
194
+ }
195
+
196
+ /** Themed suffix naming an owner this extension inferred rather than one pi reported. */
197
+ export function guessMarker(theme: Theme): string {
198
+ const { color, keyword } = MARKER_LEGENDS.guess;
199
+ return theme.fg(color, ` ${keyword}`);
200
+ }
201
+
202
+ /**
203
+ * Description bullets explaining the markers one frame shows, in fixed order
204
+ * and without duplicates. Callers pass only the markers actually visible there,
205
+ * so a view never explains a state it does not render. `diffBase` names what
206
+ * the preview's token diffs compare with; omitted, the bullet names no base.
207
+ */
208
+ export function markerLegendLines(
209
+ theme: Theme,
210
+ markers: Iterable<ContextMarker>,
211
+ width: number,
212
+ diffBase?: DiffBase,
213
+ ): string[] {
214
+ const shown = new Set(markers);
215
+ return MARKER_ORDER.filter((marker) => shown.has(marker)).flatMap((marker) => {
216
+ if (marker === "diff") {
217
+ const keyword = `${theme.fg("toolDiffAdded", "+N")} ${theme.fg("toolDiffRemoved", "-N")}`;
218
+ return bulletLines(theme, keyword, DIFF_EXPLANATIONS[diffBase ?? "list"], width);
219
+ }
220
+ const legend = MARKER_LEGENDS[marker];
221
+ return bulletLines(theme, theme.fg(legend.color, legend.keyword), legend.explanation, width);
222
+ });
223
+ }
224
+
225
+ /**
226
+ * Status-bar field explaining one marker of the selected row: the keyword in
227
+ * the marker's fixed color, then a dim colon and explanation.
228
+ */
229
+ export function markerStatusField(theme: Theme, marker: StatusMarker): string {
230
+ const status = MARKER_STATUS[marker];
231
+ const { color, keyword } = MARKER_LEGENDS[status.legend];
232
+ return `${theme.fg(color, keyword)}${theme.fg("dim", `: ${status.text}`)}`;
233
+ }
234
+
235
+ /** One dim description bullet with a hanging indent, for a note that is not a marker legend. */
236
+ export function noteBulletLines(theme: Theme, text: string, width: number): string[] {
237
+ return bulletLines(theme, "", text, width);
238
+ }
239
+
240
+ /** Dim separator plus the keyword in the one fixed color its legend bullet also opens with. */
241
+ function stateMarker(theme: Theme, legend: MarkerLegend): string {
242
+ return `${theme.fg("dim", MARKER_SEPARATOR)}${theme.fg(legend.color, legend.keyword)}`;
243
+ }
244
+
245
+ /** One bullet: dim marker, styled keyword in the colors it explains, dim sentence, hanging indent. */
246
+ function bulletLines(theme: Theme, keyword: string, explanation: LegendText, width: number): string[] {
247
+ const runs = typeof explanation === "string" ? [explanation] : explanation;
248
+ const sentence = runs.map((run) => typeof run === "string" ? theme.fg("dim", run) : theme.fg(run.color, run.text));
249
+ const text = `${keyword}${sentence.join("")}`;
250
+ const wrapped = wrapTextWithAnsi(text, Math.max(1, width - BULLET_INDENT.length));
251
+ const lead = `${BODY_INDENT}${theme.fg("dim", BULLET_MARKER)}`;
252
+ return wrapped.map((line, index) => fitLine(`${index === 0 ? lead : BULLET_INDENT}${line}`, width));
253
+ }
@@ -0,0 +1,117 @@
1
+ /** Presentation notes, kept separate from layout and from the states they explain. */
2
+ import type { Theme } from "@earendil-works/pi-coding-agent";
3
+
4
+ import type { InjectionItem } from "../model.ts";
5
+ import type { GuardResult } from "../snapshot.ts";
6
+ import { normalizeInlineText } from "../text.ts";
7
+ import { wrapDescriptionLines } from "./layout.ts";
8
+ import { noteBulletLines } from "./markers.ts";
9
+
10
+ /** Payload attention is independent of its compact label or longer explanation. */
11
+ export type PayloadCheck = "pending" | "unchecked" | "partial";
12
+
13
+ /** A note's identity survives copy changes; text may contain theme styling, never raw content. */
14
+ export interface DescriptionNote {
15
+ readonly kind: "overview" | "degraded" | "probe" | "probing-disabled" | "payload-check" | "hidden-tools" | "payload-changes" | "thinking" | "empty";
16
+ readonly text: string;
17
+ }
18
+
19
+ /** Capture metadata that can be explained without opening raw content. */
20
+ export interface InjectionsNotesInput {
21
+ readonly degradedReason?: string;
22
+ readonly probe?: boolean;
23
+ /** True when no request was captured yet and configuration disables the silent probe. */
24
+ readonly probingDisabled?: boolean;
25
+ readonly guard?: GuardResult;
26
+ readonly hiddenTools?: readonly string[];
27
+ readonly items: readonly InjectionItem[];
28
+ }
29
+
30
+ /** A complete comparison and a fallback without a request need no attention state. */
31
+ export function payloadCheck(guard: GuardResult | undefined): PayloadCheck | undefined {
32
+ if (guard?.status === "pending") return "pending";
33
+ if (guard?.status === "incomplete") return guard.findings === undefined ? "unchecked" : "partial";
34
+ return undefined;
35
+ }
36
+
37
+ /** Compact wording for an independently classified payload check. */
38
+ export function payloadCheckLabel(check: PayloadCheck): string {
39
+ const labels: Record<PayloadCheck, string> = {
40
+ pending: "pending", unchecked: "not checked", partial: "partly checked",
41
+ };
42
+ return labels[check];
43
+ }
44
+
45
+ /** Origin stays visible even when the optional notes collapse. */
46
+ export function requestOriginLabel(probe = false): string {
47
+ return probe ? "[Request Probe]" : "[Latest Request]";
48
+ }
49
+
50
+ /** Build optional notes in presentation order, with dynamic metadata sanitized before styling. */
51
+ export function injectionsNotes(theme: Theme, input: InjectionsNotesInput): DescriptionNote[] {
52
+ const notes: DescriptionNote[] = [{ kind: "overview", text: "Injections into the model context, with token estimates." }];
53
+ if (input.degradedReason !== undefined) {
54
+ notes.push({ kind: "degraded", text: theme.fg("warning", "Degraded") + theme.fg("dim", ": pi-native fallback used") });
55
+ }
56
+ if (input.probingDisabled === true) notes.push(probingDisabledNote(theme));
57
+ if (input.probe === true) {
58
+ notes.push({ kind: "probe", text: `Captured by a ${theme.fg("warning", "request probe")}` + theme.fg(
59
+ "dim", " with an empty prompt. Prompt-dependent injections may be missing.",
60
+ ) });
61
+ }
62
+ const check = payloadCheck(input.guard);
63
+ if (check === "pending") {
64
+ notes.push({ kind: "payload-check", text: "Payload changes are not checked yet: the comparison is pending." });
65
+ } else if (input.guard?.status === "incomplete") {
66
+ const reason = normalizeInlineText(input.guard.reason);
67
+ notes.push({ kind: "payload-check", text: check === "unchecked"
68
+ ? `Payload changes were not checked: ${reason}`
69
+ : `Payload changes were checked only in part: ${reason}` });
70
+ }
71
+ const tools = input.hiddenTools ?? [];
72
+ if (tools.length > 0) {
73
+ const names = [...tools].sort().map(normalizeInlineText).join(", ");
74
+ notes.push({ kind: "hidden-tools", text: `${tools.length} ${tools.length === 1 ? "tool" : "tools"} hidden by pi: ${names}.` });
75
+ }
76
+ return [...notes, ...findingNotes(input.items)];
77
+ }
78
+
79
+ /** Payload findings explain a separate observation layer in the list and preview. */
80
+ export function findingNotes(items: readonly InjectionItem[]): DescriptionNote[] {
81
+ return items.some((item) => item.kind === "payload-change")
82
+ ? [{ kind: "payload-changes", text: "Payload changes appear only in the provider payload. Their sources are unknown." }]
83
+ : [];
84
+ }
85
+
86
+ /** Explain what a view opened before the first request misses while the silent probe is disabled. */
87
+ export function probingDisabledNote(theme: Theme): DescriptionNote {
88
+ return { kind: "probing-disabled", text: `${theme.fg("warning", "Request probing")}` + theme.fg(
89
+ "dim", " is disabled. Request-only injections may be missing before the first prompt.",
90
+ ) };
91
+ }
92
+
93
+ /** Explain why category estimates need not reconcile with reported usage. */
94
+ export function usageNote(): DescriptionNote {
95
+ return { kind: "overview", text: "Estimated context for the next model request. " +
96
+ "Token counts are approximate and may differ from the provider's estimate." };
97
+ }
98
+
99
+ /** Explain the different bases of thinking estimates, without retaining signatures. */
100
+ export function thinkingNote(): DescriptionNote {
101
+ return { kind: "thinking", text: "Entry headers read: [DD-MM-YYYY] [assistant] visible + Reasoning ≈invisible (≈total). " +
102
+ "≈ is a provider-reported count; ~ is a rough approximation when no breakdown " +
103
+ "is reported and excluded from category totals. " +
104
+ "Encoded replaces Reasoning when the provider replays encrypted reasoning with its message." };
105
+ }
106
+
107
+ /** Empty content is explicit, rather than an unexplained blank preview. */
108
+ export function emptyPreviewNote(): DescriptionNote {
109
+ return { kind: "empty", text: "No content captured for this category." };
110
+ }
111
+
112
+ /** Wrap a complete note; the caller decides whether the whole block fits. */
113
+ export function descriptionNoteLines(theme: Theme, note: DescriptionNote, width: number): string[] {
114
+ return note.kind === "overview" || note.kind === "degraded" || note.kind === "thinking" || note.kind === "empty"
115
+ ? wrapDescriptionLines(theme, note.text, "dim", width)
116
+ : noteBulletLines(theme, note.text, width);
117
+ }