@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.
- package/LICENSE +21 -0
- package/README.md +112 -2
- package/package.json +55 -5
- package/src/capture/adjustments.ts +649 -0
- package/src/capture/attribution.ts +25 -0
- package/src/capture/builder.ts +128 -0
- package/src/capture/diff.ts +455 -0
- package/src/capture/dispatch.ts +36 -0
- package/src/capture/guard.ts +287 -0
- package/src/capture/messages.ts +246 -0
- package/src/capture/payload.ts +584 -0
- package/src/capture/redact.ts +58 -0
- package/src/capture/register.ts +129 -0
- package/src/capture/request.ts +218 -0
- package/src/capture/tools.ts +69 -0
- package/src/capture/tracker.ts +71 -0
- package/src/command.ts +164 -0
- package/src/compaction.ts +57 -0
- package/src/config.ts +408 -0
- package/src/index.ts +191 -0
- package/src/injections.ts +670 -0
- package/src/measure.ts +887 -0
- package/src/message-preview.ts +72 -0
- package/src/model-identity.ts +48 -0
- package/src/model.ts +548 -0
- package/src/pi-version.ts +29 -0
- package/src/probe/failure.ts +34 -0
- package/src/probe/filter.ts +126 -0
- package/src/probe/silent-probe.ts +349 -0
- package/src/probe/token.ts +28 -0
- package/src/probe/trigger.ts +178 -0
- package/src/probe/view.ts +26 -0
- package/src/projection.ts +122 -0
- package/src/prompt-additions.ts +226 -0
- package/src/prompt-blocks.ts +80 -0
- package/src/replay.ts +234 -0
- package/src/settings.ts +62 -0
- package/src/snapshot.ts +188 -0
- package/src/system-prompt-rows.ts +311 -0
- package/src/text.ts +46 -0
- package/src/token-diff.ts +211 -0
- package/src/transcript.ts +19 -0
- package/src/ui/color.ts +52 -0
- package/src/ui/diff-preview.ts +181 -0
- package/src/ui/injections-model.ts +378 -0
- package/src/ui/injections-view.ts +729 -0
- package/src/ui/json-preview.ts +41 -0
- package/src/ui/layout.ts +148 -0
- package/src/ui/markers.ts +253 -0
- package/src/ui/notes.ts +117 -0
- package/src/ui/section-preview.ts +267 -0
- package/src/ui/skill-preview.ts +83 -0
- package/src/ui/status-bar.ts +94 -0
- package/src/ui/usage-map.ts +157 -0
- package/src/ui/usage-preview.ts +194 -0
- package/src/ui/usage-view.ts +1255 -0
- package/src/ui/wheel.ts +65 -0
- package/src/usage.ts +461 -0
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `system prompt` rows of the Injections tree: the changes to pi's prompt
|
|
3
|
+
* text against pi's default prompt, split by available ownership signals. Each
|
|
4
|
+
* extension gets the diff of the lines measured as its own: its tools' prompt
|
|
5
|
+
* lines and the additions guessed to be its own. `unattributed` gets every
|
|
6
|
+
* other change to the System Prompt, instruction files, and skills. The rows
|
|
7
|
+
* repeat diffs that System Prompt and its siblings already count, so they
|
|
8
|
+
* carry no estimate of their own. The same split names the editors of each
|
|
9
|
+
* System Prompt part's diff. Pure; no pi access.
|
|
10
|
+
*/
|
|
11
|
+
import { charTokens, isPiPromptPart, SYSTEM_PROMPT_ITEM_ID } from "./measure.ts";
|
|
12
|
+
import {
|
|
13
|
+
AGGREGATE_SOURCE,
|
|
14
|
+
AGGREGATE_SOURCE_ID,
|
|
15
|
+
type ChangeEditor,
|
|
16
|
+
type InjectionItem,
|
|
17
|
+
type InjectionSection,
|
|
18
|
+
type InjectionSource,
|
|
19
|
+
sumTokenDiffs,
|
|
20
|
+
type TokenDiff,
|
|
21
|
+
} from "./model.ts";
|
|
22
|
+
|
|
23
|
+
/** Label of every `system prompt` row; lower case, like the other rows under a source. */
|
|
24
|
+
const SYSTEM_PROMPT_ROW_LABEL = "system prompt";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* One diff-only `system prompt` row per extension with lines in the System
|
|
28
|
+
* Prompt diff, in prompt order, then one `unattributed` row for every other
|
|
29
|
+
* prompt change. `items` are the request's prompt and tool items with their
|
|
30
|
+
* diffs; `session` holds the session's, which tell whether an extension's
|
|
31
|
+
* lines are only in this request. The extension rows and the unattributed row
|
|
32
|
+
* together add up to the diffs of System Prompt, Instruction Files, and
|
|
33
|
+
* Skills. No row is built without a diff.
|
|
34
|
+
*/
|
|
35
|
+
export function buildSystemPromptRows(
|
|
36
|
+
items: readonly InjectionItem[],
|
|
37
|
+
session: readonly InjectionItem[],
|
|
38
|
+
): InjectionItem[] {
|
|
39
|
+
const owners = new Map<string, RowDraft>();
|
|
40
|
+
const unattributed = createRowDraft(AGGREGATE_SOURCE);
|
|
41
|
+
const sessionById = new Map(session.map((item) => [item.id, item]));
|
|
42
|
+
for (const item of items) {
|
|
43
|
+
if (item.id === SYSTEM_PROMPT_ITEM_ID) splitPromptDiff(item, owners, unattributed, sessionById.get(item.id));
|
|
44
|
+
else if (item.source.native && (item.kind === "context-file" || item.kind === "skills")) {
|
|
45
|
+
collectUnattributedDiffs(item, unattributed, sessionById.get(item.id));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const requestLines = extensionLines(items);
|
|
49
|
+
const sessionLines = extensionLines(session);
|
|
50
|
+
const rows = [...owners.values()].map((owner) => {
|
|
51
|
+
const id = owner.source.id;
|
|
52
|
+
// The session keeps the lines it has, whatever this request changed
|
|
53
|
+
owner.saved = sessionLines.has(id);
|
|
54
|
+
return createRow(owner, requestLines.get(id) !== sessionLines.get(id));
|
|
55
|
+
});
|
|
56
|
+
rows.push(createRow(unattributed, unattributed.sections.some((section) => section.requestOnly === true)));
|
|
57
|
+
return rows.filter((row) => row.diff !== undefined);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Who made a saved System Prompt part's diff, as the `system prompt` rows
|
|
62
|
+
* split it: extensions whose lines take a share of the added tokens, in
|
|
63
|
+
* prompt order, then `unknown` for any other addition and every removal.
|
|
64
|
+
* A request-only part's editor is always unknown. Undefined without a diff.
|
|
65
|
+
*/
|
|
66
|
+
export function partEditors(part: InjectionItem): ChangeEditor[] | undefined {
|
|
67
|
+
if (part.diff === undefined) return undefined;
|
|
68
|
+
// References name contributed lines, never who edited them in this request
|
|
69
|
+
if (part.requestOnly === true) return [{ type: "unknown" }];
|
|
70
|
+
const shares = ownerShares(part, part.diff.added);
|
|
71
|
+
const editors: ChangeEditor[] = shares.map(({ source, guess }) => ({
|
|
72
|
+
type: "extension",
|
|
73
|
+
source,
|
|
74
|
+
...(guess ? { attribution: "guess" as const } : {}),
|
|
75
|
+
}));
|
|
76
|
+
const known = shares.reduce((sum, share) => sum + share.added, 0);
|
|
77
|
+
if (known < part.diff.added || part.diff.removed > 0) editors.push({ type: "unknown" });
|
|
78
|
+
return editors;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Parts and diffs collected for one `system prompt` row before it is built. */
|
|
82
|
+
interface RowDraft {
|
|
83
|
+
readonly source: InjectionSource;
|
|
84
|
+
/** One preview part per changed prompt part, in prompt order. */
|
|
85
|
+
readonly sections: InjectionSection[];
|
|
86
|
+
/** True when the session keeps some of the row's changes. */
|
|
87
|
+
saved: boolean;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** An empty row draft for one source. */
|
|
91
|
+
function createRowDraft(source: InjectionSource): RowDraft {
|
|
92
|
+
return { source, sections: [], saved: false };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Split the System Prompt diff part by part: each extension's lines go to its
|
|
97
|
+
* row, the rest of the part's diff to `unattributed`. A System Prompt without
|
|
98
|
+
* parts, such as a forced prompt without sections, has no extension lines.
|
|
99
|
+
*/
|
|
100
|
+
function splitPromptDiff(
|
|
101
|
+
prompt: InjectionItem,
|
|
102
|
+
owners: Map<string, RowDraft>,
|
|
103
|
+
unattributed: RowDraft,
|
|
104
|
+
session: InjectionItem | undefined,
|
|
105
|
+
): void {
|
|
106
|
+
const parts = prompt.children ?? [];
|
|
107
|
+
if (parts.length === 0) {
|
|
108
|
+
if (prompt.diff !== undefined) addUnattributed(unattributed, prompt, prompt.diff, prompt.change === "forced");
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const sessionParts = new Map(session?.children?.map((part) => [part.id, part]));
|
|
112
|
+
for (const part of parts) {
|
|
113
|
+
if (part.diff === undefined) continue;
|
|
114
|
+
let added = part.diff.added;
|
|
115
|
+
for (const share of ownerShares(part, part.diff.added)) {
|
|
116
|
+
let owner = owners.get(share.source.id);
|
|
117
|
+
if (owner === undefined) {
|
|
118
|
+
owner = createRowDraft(share.source);
|
|
119
|
+
owners.set(share.source.id, owner);
|
|
120
|
+
}
|
|
121
|
+
owner.sections.push({
|
|
122
|
+
label: part.label,
|
|
123
|
+
text: share.text,
|
|
124
|
+
tokens: 0,
|
|
125
|
+
diffOnly: true,
|
|
126
|
+
diff: { added: share.added, removed: 0 },
|
|
127
|
+
...(share.guess ? { attribution: "guess" as const } : {}),
|
|
128
|
+
});
|
|
129
|
+
added -= share.added;
|
|
130
|
+
}
|
|
131
|
+
const rest = { added, removed: part.diff.removed };
|
|
132
|
+
if (rest.added > 0 || rest.removed > 0) {
|
|
133
|
+
addUnattributed(unattributed, part, rest, false, hasSavedUnattributed(part, sessionParts.get(part.id)));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
collectResidual(prompt, parts, unattributed, session);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Changes to instruction files or skills, which `unattributed` holds whole:
|
|
141
|
+
* one part per changed child, or the item itself without children.
|
|
142
|
+
*/
|
|
143
|
+
function collectUnattributedDiffs(
|
|
144
|
+
item: InjectionItem,
|
|
145
|
+
unattributed: RowDraft,
|
|
146
|
+
session: InjectionItem | undefined,
|
|
147
|
+
): void {
|
|
148
|
+
if (item.diff === undefined) return;
|
|
149
|
+
const children = item.children ?? [];
|
|
150
|
+
if (children.length === 0) {
|
|
151
|
+
addUnattributed(unattributed, item, item.diff, false);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
for (const child of children) {
|
|
155
|
+
if (child.diff !== undefined) addUnattributed(unattributed, child, child.diff, false);
|
|
156
|
+
}
|
|
157
|
+
collectResidual(item, children, unattributed, session);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The part of a parent's diff its children do not show: the removal of
|
|
162
|
+
* children only the session has. Preview their session text as one part. The
|
|
163
|
+
* request lacks them, so it is request-only.
|
|
164
|
+
*/
|
|
165
|
+
function collectResidual(
|
|
166
|
+
parent: InjectionItem,
|
|
167
|
+
children: readonly InjectionItem[],
|
|
168
|
+
row: RowDraft,
|
|
169
|
+
session: InjectionItem | undefined,
|
|
170
|
+
): void {
|
|
171
|
+
const shown = sumTokenDiffs(children.map((child) => child.diff));
|
|
172
|
+
const residual = {
|
|
173
|
+
added: (parent.diff?.added ?? 0) - (shown?.added ?? 0),
|
|
174
|
+
removed: (parent.diff?.removed ?? 0) - (shown?.removed ?? 0),
|
|
175
|
+
};
|
|
176
|
+
if (residual.added <= 0 && residual.removed <= 0) return;
|
|
177
|
+
const sent = new Set(children.map((child) => child.id));
|
|
178
|
+
const missing = session?.children?.filter((child) => !sent.has(child.id)) ?? [];
|
|
179
|
+
const reference = missing.length === 1 ? missing[0] : {
|
|
180
|
+
...parent, text: missing.map((child) => child.text).join("\n"), injectedReferences: undefined,
|
|
181
|
+
};
|
|
182
|
+
// The request lacks these children, so their whole text is removed
|
|
183
|
+
if (reference !== undefined) {
|
|
184
|
+
addUnattributed(row, { ...reference, textDiff: { base: reference.text, request: "" } }, residual, true);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Whether the unattributed share includes saved changes: a saved custom
|
|
190
|
+
* section, or unowned extension lines the session has. Saved lines of known
|
|
191
|
+
* owners go to their own rows, not to the unknown request-only edits beside them.
|
|
192
|
+
*/
|
|
193
|
+
function hasSavedUnattributed(part: InjectionItem, session: InjectionItem | undefined): boolean {
|
|
194
|
+
if (part.requestOnly !== true) return true;
|
|
195
|
+
if (part.savedChange !== true || session === undefined) return false;
|
|
196
|
+
const references = session.injectedReferences ?? [];
|
|
197
|
+
if (references.length > 0) return references.some((reference) => isUnattributedSource(reference.source));
|
|
198
|
+
return !isPiPromptPart(part.id);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Add one changed part and its persistence state to the unattributed row. */
|
|
202
|
+
function addUnattributed(
|
|
203
|
+
row: RowDraft,
|
|
204
|
+
part: InjectionItem,
|
|
205
|
+
diff: TokenDiff,
|
|
206
|
+
requestOnly: boolean,
|
|
207
|
+
saved = part.requestOnly !== true && !requestOnly,
|
|
208
|
+
): void {
|
|
209
|
+
row.sections.push(diffSection(part, diff, requestOnly));
|
|
210
|
+
row.saved ||= saved;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* One changed part as an unattributed preview part: its whole text, with the
|
|
215
|
+
* extension lines in it still annotated, or the session text of a deleted part.
|
|
216
|
+
* It keeps the part's text diff, so both previews show the same diff body.
|
|
217
|
+
*/
|
|
218
|
+
function diffSection(part: InjectionItem, diff: TokenDiff, requestOnly: boolean): InjectionSection {
|
|
219
|
+
return {
|
|
220
|
+
label: part.label,
|
|
221
|
+
text: part.text,
|
|
222
|
+
tokens: 0,
|
|
223
|
+
diffOnly: true,
|
|
224
|
+
diff,
|
|
225
|
+
...(part.requestOnly === true || requestOnly ? { requestOnly: true } : {}),
|
|
226
|
+
jsonSpan: part.jsonSpan,
|
|
227
|
+
injectedReferences: part.injectedReferences,
|
|
228
|
+
...(part.textDiff === undefined ? {} : { textDiff: part.textDiff }),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Tokens one extension's lines add to one part, and the lines themselves. */
|
|
233
|
+
interface OwnerShare {
|
|
234
|
+
readonly source: InjectionSource;
|
|
235
|
+
readonly added: number;
|
|
236
|
+
/** The extension's lines in the part, in prompt order. */
|
|
237
|
+
readonly text: string;
|
|
238
|
+
/** True when any of the lines has a guessed owner. */
|
|
239
|
+
readonly guess: boolean;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Each extension's share of the tokens one part added, by the characters of
|
|
244
|
+
* its lines in prompt order. Shares round cumulatively, like section shares,
|
|
245
|
+
* so together they never exceed `added`; unattributed lines take no share.
|
|
246
|
+
*/
|
|
247
|
+
function ownerShares(part: InjectionItem, added: number): OwnerShare[] {
|
|
248
|
+
const references = (part.injectedReferences ?? [])
|
|
249
|
+
.filter((reference) => !isUnattributedSource(reference.source))
|
|
250
|
+
.sort((a, b) => a.start - b.start);
|
|
251
|
+
let chars = 0;
|
|
252
|
+
let allocated = 0;
|
|
253
|
+
const shares: OwnerShare[] = [];
|
|
254
|
+
for (const owned of Map.groupBy(references, (reference) => reference.source.id).values()) {
|
|
255
|
+
const text = owned.map((reference) => part.text.slice(reference.start, reference.end)).join("");
|
|
256
|
+
chars += text.length;
|
|
257
|
+
const tokens = Math.min(added, charTokens(chars)) - allocated;
|
|
258
|
+
allocated += tokens;
|
|
259
|
+
if (tokens > 0) {
|
|
260
|
+
const guess = owned.some((reference) => reference.attribution === "guess");
|
|
261
|
+
shares.push({ source: owned[0].source, added: tokens, text, guess });
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return shares;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Every extension's lines in the System Prompt parts pi sent, keyed by source
|
|
269
|
+
* id, with the part each line is in. Equal keys in the request and the session
|
|
270
|
+
* mean the extension's lines are saved.
|
|
271
|
+
*/
|
|
272
|
+
function extensionLines(items: readonly InjectionItem[]): Map<string, string> {
|
|
273
|
+
const lines = new Map<string, string>();
|
|
274
|
+
const prompt = items.find((item) => item.id === SYSTEM_PROMPT_ITEM_ID);
|
|
275
|
+
for (const part of prompt?.children ?? []) {
|
|
276
|
+
if (part.dropped === true || part.change === "deleted") continue;
|
|
277
|
+
for (const reference of part.injectedReferences ?? []) {
|
|
278
|
+
if (isUnattributedSource(reference.source)) continue;
|
|
279
|
+
const line = `${part.id}\n${part.text.slice(reference.start, reference.end)}\n`;
|
|
280
|
+
lines.set(reference.source.id, (lines.get(reference.source.id) ?? "") + line);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return lines;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Whether a reference has no extension provenance, rather than a known or
|
|
288
|
+
* guessed owner, such as an unregistered recorded tool's snippet.
|
|
289
|
+
*/
|
|
290
|
+
function isUnattributedSource(source: InjectionSource): boolean {
|
|
291
|
+
return source.id === AGGREGATE_SOURCE_ID;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** The diff-only row of one draft: no estimate, the sum of its parts' diffs. */
|
|
295
|
+
function createRow(draft: RowDraft, requestOnly: boolean): InjectionItem {
|
|
296
|
+
const diff = sumTokenDiffs(draft.sections.map((section) => section.diff));
|
|
297
|
+
return {
|
|
298
|
+
id: `system-prompt:${draft.source.id}`,
|
|
299
|
+
kind: "prompt-addition",
|
|
300
|
+
source: draft.source,
|
|
301
|
+
label: SYSTEM_PROMPT_ROW_LABEL,
|
|
302
|
+
chars: 0,
|
|
303
|
+
tokens: 0,
|
|
304
|
+
text: draft.sections.map((section) => section.text).join(""),
|
|
305
|
+
sections: draft.sections,
|
|
306
|
+
diffOnly: true,
|
|
307
|
+
...(diff === undefined ? {} : { diff }),
|
|
308
|
+
...(requestOnly ? { requestOnly: true } : {}),
|
|
309
|
+
...(draft.saved ? { savedChange: true } : {}),
|
|
310
|
+
};
|
|
311
|
+
}
|
package/src/text.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal sanitizers shared by command reporting and the views. Every string
|
|
3
|
+
* that reaches the terminal from outside this extension — captured content,
|
|
4
|
+
* configuration files, error messages — passes through here first.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const TERMINAL_STRING_START = /\u001B[\]PX^_]|[\u0090\u0098\u009D\u009E\u009F]/g;
|
|
8
|
+
const TERMINAL_STRING_END = /\u0007|\u001B\\|\u009C/g;
|
|
9
|
+
const TERMINAL_CSI_SEQUENCE = /(?:\u001B\[|\u009B)[\u0030-\u003F]*[\u0020-\u002F]*[\u0040-\u007E]/g;
|
|
10
|
+
const TERMINAL_ESCAPE_SEQUENCE = /\u001B[\u0020-\u002F]*[\u0030-\u007E]/g;
|
|
11
|
+
const TERMINAL_CONTROL_CHARACTER = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g;
|
|
12
|
+
|
|
13
|
+
/** Normalize whitespace and remove terminal control sequences from raw preview text. */
|
|
14
|
+
export function normalizePreviewText(text: string): string {
|
|
15
|
+
const normalized = text
|
|
16
|
+
.replaceAll("\r\n", "\n")
|
|
17
|
+
.replaceAll("\r", "\n")
|
|
18
|
+
.replaceAll("\t", " ");
|
|
19
|
+
return stripTerminalStrings(normalized)
|
|
20
|
+
.replace(TERMINAL_CSI_SEQUENCE, "")
|
|
21
|
+
.replace(TERMINAL_ESCAPE_SEQUENCE, "")
|
|
22
|
+
.replace(TERMINAL_CONTROL_CHARACTER, "");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Remove complete terminal strings without rescanning an unterminated suffix for every start. */
|
|
26
|
+
function stripTerminalStrings(text: string): string {
|
|
27
|
+
const parts: string[] = [];
|
|
28
|
+
let offset = 0;
|
|
29
|
+
TERMINAL_STRING_START.lastIndex = 0;
|
|
30
|
+
let start: RegExpExecArray | null;
|
|
31
|
+
while ((start = TERMINAL_STRING_START.exec(text)) !== null) {
|
|
32
|
+
TERMINAL_STRING_END.lastIndex = TERMINAL_STRING_START.lastIndex;
|
|
33
|
+
if (TERMINAL_STRING_END.exec(text) === null) break;
|
|
34
|
+
parts.push(text.slice(offset, start.index));
|
|
35
|
+
offset = TERMINAL_STRING_END.lastIndex;
|
|
36
|
+
TERMINAL_STRING_START.lastIndex = offset;
|
|
37
|
+
}
|
|
38
|
+
// Preserve an incomplete string for the existing escape/control sanitizers.
|
|
39
|
+
parts.push(text.slice(offset));
|
|
40
|
+
return parts.join("");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Sanitize dynamic text for one terminal line and collapse embedded whitespace. */
|
|
44
|
+
export function normalizeInlineText(text: string): string {
|
|
45
|
+
return normalizePreviewText(text).replace(/\s+/g, " ").trim();
|
|
46
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Word-level comparison of two texts and the token diff it implies. Pure
|
|
3
|
+
* string logic shared by the Injections composition; no pi access.
|
|
4
|
+
*/
|
|
5
|
+
import { charTokens } from "./measure.ts";
|
|
6
|
+
import type { TokenDiff } from "./model.ts";
|
|
7
|
+
|
|
8
|
+
/** A word, a whitespace run, or one other character; together they cover the whole text. */
|
|
9
|
+
const WORD_PATTERN = /\s+|[\p{L}\p{N}_]+|[^\s\p{L}\p{N}_]/gu;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Edit steps after which the comparison stops aligning and counts the rest as
|
|
13
|
+
* replaced. Unrelated texts, such as a forced prompt against pi's, would
|
|
14
|
+
* otherwise cost time proportional to their length times their edit distance.
|
|
15
|
+
*/
|
|
16
|
+
const MAX_EDIT_STEPS = 2_000;
|
|
17
|
+
|
|
18
|
+
/** Characters a word-level comparison found only in one of two texts. */
|
|
19
|
+
export interface WordChanges {
|
|
20
|
+
/** Characters only in the later text. */
|
|
21
|
+
readonly addedChars: number;
|
|
22
|
+
/** Characters only in the earlier text. */
|
|
23
|
+
readonly removedChars: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** One contiguous run in a word comparison; joining each side reconstructs its exact text. */
|
|
27
|
+
export interface WordDiffPart {
|
|
28
|
+
readonly type: "unchanged" | "added" | "removed";
|
|
29
|
+
readonly text: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Text and token estimate of one side of a comparison. */
|
|
33
|
+
export interface DiffSide {
|
|
34
|
+
readonly text: string;
|
|
35
|
+
readonly tokens: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Tokens `request` gained and lost against `base`, or undefined when neither
|
|
40
|
+
* changed a token. No base means everything was added, and equal texts mean
|
|
41
|
+
* no change, even when a share of a larger estimate rounds differently.
|
|
42
|
+
* Otherwise a word-level comparison supplies the changed characters, rounded
|
|
43
|
+
* up to tokens, and any rounding difference goes to the side with changed
|
|
44
|
+
* characters, so that base tokens plus `added` minus `removed` equals the
|
|
45
|
+
* request tokens. An empty request with no tokens removes the whole base, and
|
|
46
|
+
* no diff removes more than the base or adds more than the request has.
|
|
47
|
+
*/
|
|
48
|
+
export function measureTokenDiff(base: DiffSide | undefined, request: DiffSide): TokenDiff | undefined {
|
|
49
|
+
if (base === undefined) return nonEmptyDiff({ added: request.tokens, removed: 0 });
|
|
50
|
+
if (base.text === request.text) return undefined;
|
|
51
|
+
const changes = countWordChanges(base.text, request.text);
|
|
52
|
+
return nonEmptyDiff(reconcileTokens(base.tokens, request.tokens, changes));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Characters only in `before` or only in `after`, aligning words with Myers'
|
|
57
|
+
* shortest edit script after matching the common ends. A difference too large
|
|
58
|
+
* to align within `MAX_EDIT_STEPS` counts its whole middle as replaced.
|
|
59
|
+
*/
|
|
60
|
+
export function countWordChanges(before: string, after: string): WordChanges {
|
|
61
|
+
if (before === after) return { addedChars: 0, removedChars: 0 };
|
|
62
|
+
const words = comparisonWords(before, after);
|
|
63
|
+
const matchedChars = words.prefix.length + words.suffix.length + matchedMiddleChars(words.before, words.after);
|
|
64
|
+
return { addedChars: after.length - matchedChars, removedChars: before.length - matchedChars };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Word runs on the same path used by token estimates, with a bounded Myers
|
|
69
|
+
* trace retained only for previews. An unalignable middle is a replacement.
|
|
70
|
+
*/
|
|
71
|
+
export function diffWords(before: string, after: string): WordDiffPart[] {
|
|
72
|
+
if (before === after) return before.length === 0 ? [] : [{ type: "unchanged", text: before }];
|
|
73
|
+
const words = comparisonWords(before, after);
|
|
74
|
+
const trace: Int32Array[] = [];
|
|
75
|
+
matchedMiddleChars(words.before, words.after, trace);
|
|
76
|
+
const middle = trace.length === 0
|
|
77
|
+
? [{ type: "removed" as const, text: words.before.join("") },
|
|
78
|
+
{ type: "added" as const, text: words.after.join("") }]
|
|
79
|
+
: traceParts(words.before, words.after, trace);
|
|
80
|
+
const parts: WordDiffPart[] = [];
|
|
81
|
+
for (const part of [{ type: "unchanged" as const, text: words.prefix }, ...middle,
|
|
82
|
+
{ type: "unchanged" as const, text: words.suffix }]) {
|
|
83
|
+
if (part.text.length === 0) continue;
|
|
84
|
+
const previous = parts.at(-1);
|
|
85
|
+
if (previous?.type === part.type) parts[parts.length - 1] = { type: part.type, text: previous.text + part.text };
|
|
86
|
+
else parts.push(part);
|
|
87
|
+
}
|
|
88
|
+
return parts;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Tokenized middle after stripping identical ends, shared by counts and previews. */
|
|
92
|
+
interface ComparisonWords {
|
|
93
|
+
readonly before: readonly string[];
|
|
94
|
+
readonly after: readonly string[];
|
|
95
|
+
readonly prefix: string;
|
|
96
|
+
readonly suffix: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Strip common ends before running the bounded comparison. */
|
|
100
|
+
function comparisonWords(before: string, after: string): ComparisonWords {
|
|
101
|
+
const beforeWords = before.match(WORD_PATTERN) ?? [];
|
|
102
|
+
const afterWords = after.match(WORD_PATTERN) ?? [];
|
|
103
|
+
let start = 0;
|
|
104
|
+
while (start < beforeWords.length && start < afterWords.length && beforeWords[start] === afterWords[start]) start++;
|
|
105
|
+
let beforeEnd = beforeWords.length;
|
|
106
|
+
let afterEnd = afterWords.length;
|
|
107
|
+
while (beforeEnd > start && afterEnd > start && beforeWords[beforeEnd - 1] === afterWords[afterEnd - 1]) {
|
|
108
|
+
beforeEnd--;
|
|
109
|
+
afterEnd--;
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
before: beforeWords.slice(start, beforeEnd), after: afterWords.slice(start, afterEnd),
|
|
113
|
+
prefix: beforeWords.slice(0, start).join(""), suffix: beforeWords.slice(beforeEnd).join(""),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Characters of the words Myers' shortest edit script keeps in both
|
|
119
|
+
* sequences, or 0 when it needs more than `MAX_EDIT_STEPS`. Each diagonal
|
|
120
|
+
* remembers the kept characters of its furthest path. Only previews request
|
|
121
|
+
* a trace; counts keep linear working memory. A failed alignment clears it.
|
|
122
|
+
*/
|
|
123
|
+
function matchedMiddleChars(before: readonly string[], after: readonly string[], trace?: Int32Array[]): number {
|
|
124
|
+
const n = before.length;
|
|
125
|
+
const m = after.length;
|
|
126
|
+
if (n === 0 || m === 0) return 0;
|
|
127
|
+
const maxSteps = Math.min(n + m, MAX_EDIT_STEPS);
|
|
128
|
+
const offset = maxSteps + 1;
|
|
129
|
+
// furthest[offset + k]: the furthest x reached on diagonal k = x - y; kept[offset + k]: its kept characters
|
|
130
|
+
const furthest = new Int32Array(2 * offset + 1);
|
|
131
|
+
const kept = new Float64Array(2 * offset + 1);
|
|
132
|
+
for (let steps = 0; steps <= maxSteps; steps++) {
|
|
133
|
+
for (let k = -steps; k <= steps; k += 2) {
|
|
134
|
+
const insertion = k === -steps || (k !== steps && furthest[offset + k - 1] < furthest[offset + k + 1]);
|
|
135
|
+
const from = offset + (insertion ? k + 1 : k - 1);
|
|
136
|
+
let x = insertion ? furthest[from] : furthest[from] + 1;
|
|
137
|
+
let y = x - k;
|
|
138
|
+
let chars = kept[from];
|
|
139
|
+
while (x < n && y < m && before[x] === after[y]) {
|
|
140
|
+
chars += before[x].length;
|
|
141
|
+
x++;
|
|
142
|
+
y++;
|
|
143
|
+
}
|
|
144
|
+
furthest[offset + k] = x;
|
|
145
|
+
kept[offset + k] = chars;
|
|
146
|
+
if (x >= n && y >= m) {
|
|
147
|
+
trace?.push(furthest.slice(offset - steps, offset + steps + 1));
|
|
148
|
+
return chars;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
trace?.push(furthest.slice(offset - steps, offset + steps + 1));
|
|
152
|
+
}
|
|
153
|
+
if (trace !== undefined) trace.length = 0;
|
|
154
|
+
return 0;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Walk a successful Myers trace backward, then restore word order. */
|
|
158
|
+
function traceParts(before: readonly string[], after: readonly string[], trace: readonly Int32Array[]): WordDiffPart[] {
|
|
159
|
+
const parts: WordDiffPart[] = [];
|
|
160
|
+
let x = before.length;
|
|
161
|
+
let y = after.length;
|
|
162
|
+
for (let steps = trace.length - 1; steps > 0; steps--) {
|
|
163
|
+
const previous = trace[steps - 1];
|
|
164
|
+
const k = x - y;
|
|
165
|
+
const offset = steps - 1;
|
|
166
|
+
const insertion = k === -steps || (k !== steps && previous[offset + k - 1] < previous[offset + k + 1]);
|
|
167
|
+
const previousK = insertion ? k + 1 : k - 1;
|
|
168
|
+
const previousX = previous[offset + previousK];
|
|
169
|
+
const previousY = previousX - previousK;
|
|
170
|
+
while (x > previousX && y > previousY) {
|
|
171
|
+
parts.push({ type: "unchanged", text: before[--x] });
|
|
172
|
+
y--;
|
|
173
|
+
}
|
|
174
|
+
parts.push(insertion ? { type: "added", text: after[--y] } : { type: "removed", text: before[--x] });
|
|
175
|
+
}
|
|
176
|
+
while (x > 0 && y > 0) {
|
|
177
|
+
parts.push({ type: "unchanged", text: before[--x] });
|
|
178
|
+
y--;
|
|
179
|
+
}
|
|
180
|
+
return parts.reverse();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Round changed characters up to tokens, then give the difference to the base
|
|
185
|
+
* estimate to the side with changed characters, so the result reconciles
|
|
186
|
+
* exactly. An image, for example, changes the estimate far more than its text.
|
|
187
|
+
* Text the estimates leave out, such as the JSON around a message's content
|
|
188
|
+
* blocks, can count more changed characters than either side has tokens; the
|
|
189
|
+
* diff then replaces the whole base.
|
|
190
|
+
*/
|
|
191
|
+
function reconcileTokens(baseTokens: number, requestTokens: number, changes: WordChanges): TokenDiff {
|
|
192
|
+
let added = charTokens(changes.addedChars);
|
|
193
|
+
let removed = charTokens(changes.removedChars);
|
|
194
|
+
const error = requestTokens - baseTokens - (added - removed);
|
|
195
|
+
if (error > 0) {
|
|
196
|
+
const cut = changes.addedChars > 0 ? 0 : Math.min(error, removed);
|
|
197
|
+
removed -= cut;
|
|
198
|
+
added += error - cut;
|
|
199
|
+
} else if (error < 0) {
|
|
200
|
+
const cut = changes.removedChars > 0 ? 0 : Math.min(-error, added);
|
|
201
|
+
added -= cut;
|
|
202
|
+
removed += -error - cut;
|
|
203
|
+
}
|
|
204
|
+
if (added > requestTokens || removed > baseTokens) return { added: requestTokens, removed: baseTokens };
|
|
205
|
+
return { added, removed };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** The diff, or undefined when it neither adds nor removes a token. */
|
|
209
|
+
function nonEmptyDiff(diff: TokenDiff): TokenDiff | undefined {
|
|
210
|
+
return diff.added === 0 && diff.removed === 0 ? undefined : diff;
|
|
211
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process-local helpers for Pi's transcript-backed system messages. Replay
|
|
3
|
+
* itself uses Pi's `getCurrentSystemMessage()`.
|
|
4
|
+
*/
|
|
5
|
+
import type { ContextEvent } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
|
|
7
|
+
/** System-message shape supplied by Pi, with content and declaration patches. */
|
|
8
|
+
export type SystemMessage = Extract<ContextEvent["messages"][number], { role: "system" }>;
|
|
9
|
+
|
|
10
|
+
/** Content followed by non-deleted sections, matching Pi's complete-prompt rendering. */
|
|
11
|
+
export function systemMessageText(message: Pick<SystemMessage, "content" | "sections">): string {
|
|
12
|
+
return [systemContentText(message), ...Object.values(message.sections ?? {})]
|
|
13
|
+
.filter((part): part is string => part !== null && part.length > 0).join("\n\n");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Extract plain text without copying opaque text-block signatures. */
|
|
17
|
+
function systemContentText(message: Pick<SystemMessage, "content">): string {
|
|
18
|
+
return typeof message.content === "string" ? message.content : message.content.map((block) => block.text).join("\n");
|
|
19
|
+
}
|
package/src/ui/color.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rendering of configured colors.
|
|
3
|
+
*
|
|
4
|
+
* A theme color key is painted by the active theme, so it keeps following
|
|
5
|
+
* theme changes. A literal hex value is painted through a pi `Theme` built
|
|
6
|
+
* over that single color, which reuses pi's own conversion: truecolor escapes
|
|
7
|
+
* where the terminal supports them, and the closest 256-color index otherwise.
|
|
8
|
+
*/
|
|
9
|
+
import { Theme, type ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
|
|
11
|
+
import { type CategoryColor, type HexColor, isHexColor, THEME_COLOR_NAMES } from "../config.ts";
|
|
12
|
+
|
|
13
|
+
/** Foreground slot read back from a literal theme, whose slots all hold one color. */
|
|
14
|
+
const LITERAL_SLOT: ThemeColor = "text";
|
|
15
|
+
|
|
16
|
+
/** Backgrounds a literal theme never paints; pi maps the empty value to its reset sequence. */
|
|
17
|
+
const LITERAL_BACKGROUNDS = {
|
|
18
|
+
selectedBg: "",
|
|
19
|
+
userMessageBg: "",
|
|
20
|
+
customMessageBg: "",
|
|
21
|
+
toolPendingBg: "",
|
|
22
|
+
toolSuccessBg: "",
|
|
23
|
+
toolErrorBg: "",
|
|
24
|
+
} as const;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Literal themes, keyed by color and color mode. A map render paints hundreds
|
|
28
|
+
* of cells, so the themes are built once instead of per painted cell; a
|
|
29
|
+
* runtime configures at most one per category.
|
|
30
|
+
*/
|
|
31
|
+
const literalThemes = new Map<string, Theme>();
|
|
32
|
+
|
|
33
|
+
/** Paint text with one configured color, whether it names a theme color or a literal value. */
|
|
34
|
+
export function colorize(theme: Theme, color: CategoryColor, text: string): string {
|
|
35
|
+
if (!isHexColor(color)) return theme.fg(color, text);
|
|
36
|
+
return literalTheme(color, theme.getColorMode()).fg(LITERAL_SLOT, text);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Cached single-color theme converting one hex value for the active color mode. */
|
|
40
|
+
function literalTheme(color: HexColor, mode: ReturnType<Theme["getColorMode"]>): Theme {
|
|
41
|
+
const key = `${mode} ${color}`;
|
|
42
|
+
const cached = literalThemes.get(key);
|
|
43
|
+
if (cached !== undefined) return cached;
|
|
44
|
+
const created = new Theme(uniformForeground(color), LITERAL_BACKGROUNDS, mode);
|
|
45
|
+
literalThemes.set(key, created);
|
|
46
|
+
return created;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Every foreground slot set to one color; the cast rests on the compile-checked key list. */
|
|
50
|
+
function uniformForeground(color: HexColor): Record<ThemeColor, HexColor> {
|
|
51
|
+
return Object.fromEntries(THEME_COLOR_NAMES.map((name) => [name, color])) as Record<ThemeColor, HexColor>;
|
|
52
|
+
}
|