@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,181 @@
|
|
|
1
|
+
/** Sanitized message and payload diffs, sharing the word alignment used by token estimates. */
|
|
2
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
4
|
+
|
|
5
|
+
import type { ForcedEdit, InjectionSection, TextDiff, TextSpan } from "../model.ts";
|
|
6
|
+
import type { PayloadChangeLine } from "../snapshot.ts";
|
|
7
|
+
import { normalizePreviewText } from "../text.ts";
|
|
8
|
+
import { diffWords, type WordDiffPart } from "../token-diff.ts";
|
|
9
|
+
import { expandJsonSpan } from "./json-preview.ts";
|
|
10
|
+
import { BODY_INDENT } from "./layout.ts";
|
|
11
|
+
|
|
12
|
+
/** Diff gutter shared by changed lines, context, and wrapped continuations. */
|
|
13
|
+
const DIFF_INDENT = " ";
|
|
14
|
+
|
|
15
|
+
/** One sanitized physical line and its changed character ranges. */
|
|
16
|
+
interface DiffLine {
|
|
17
|
+
text: string;
|
|
18
|
+
changes: TextSpan[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A line's side and whether its changed words belong to a replacement. */
|
|
22
|
+
interface DiffRow extends DiffLine {
|
|
23
|
+
readonly type: WordDiffPart["type"];
|
|
24
|
+
readonly highlight: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* One message's full diff body. Expand marked JSON on each side before
|
|
29
|
+
* comparing, never affecting estimates. Unchanged and added messages keep the
|
|
30
|
+
* gutter without signs: an addition has nothing to compare with, and its `+N`
|
|
31
|
+
* already says so. Deletions use only their side's color, without inverse words.
|
|
32
|
+
*/
|
|
33
|
+
export function messageDiffLines(theme: Theme, section: InjectionSection, width: number): string[] {
|
|
34
|
+
const text = normalizePreviewText(expandJsonSpan(section.text, section.jsonSpan));
|
|
35
|
+
const original = section.message?.original;
|
|
36
|
+
const before = section.change === "added" || original === undefined
|
|
37
|
+
? text : normalizePreviewText(expandJsonSpan(original.text, original.jsonSpan));
|
|
38
|
+
const after = section.change === "deleted" ? "" : text;
|
|
39
|
+
return messageRows(diffWords(before, after)).flatMap((row) => renderRow(theme, row, width));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Word diff from the request edit a forced prompt replaced to the text Pi
|
|
44
|
+
* sent instead, laid out like a message diff. Added lines are what Pi sent,
|
|
45
|
+
* as in every other diff; an unsent part compares with no text.
|
|
46
|
+
*/
|
|
47
|
+
export function forcedEditDiffLines(theme: Theme, sent: string, edit: ForcedEdit, width: number): string[] {
|
|
48
|
+
return textDiffLines(theme, { base: edit.text, request: edit.sent ? sent : "" }, width);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Word diff of a System Prompt part from its diff base to what the request
|
|
53
|
+
* sent, laid out like a message diff, so the body matches its token diff.
|
|
54
|
+
*/
|
|
55
|
+
export function textDiffLines(theme: Theme, diff: TextDiff, width: number): string[] {
|
|
56
|
+
return messageRows(diffWords(normalizePreviewText(diff.base), normalizePreviewText(diff.request)))
|
|
57
|
+
.flatMap((row) => renderRow(theme, row, width));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Highlight a finding's retained lines without changing their guard order or
|
|
62
|
+
* inventing missing context. All removed and added lines share one word
|
|
63
|
+
* comparison, exactly as their token diff does.
|
|
64
|
+
*/
|
|
65
|
+
export function payloadDiffLines(theme: Theme, lines: readonly PayloadChangeLine[], width: number): string[] {
|
|
66
|
+
// A lone carriage return inside a retained line must not create another finding line
|
|
67
|
+
const safe = lines.map((line) => ({ ...line, text: normalizePreviewText(line.text).replaceAll("\n", " ") }));
|
|
68
|
+
const before = safe.filter((line) => line.type === "removed");
|
|
69
|
+
const after = safe.filter((line) => line.type === "added");
|
|
70
|
+
const parts = diffWords(before.map((line) => line.text).join("\n"), after.map((line) => line.text).join("\n"));
|
|
71
|
+
const removed = sideLines(parts, "removed");
|
|
72
|
+
const added = sideLines(parts, "added");
|
|
73
|
+
const highlight = before.length > 0 && after.length > 0;
|
|
74
|
+
let removedIndex = 0;
|
|
75
|
+
let addedIndex = 0;
|
|
76
|
+
return safe.flatMap((line) => {
|
|
77
|
+
const body = line.type === "removed" ? removed[removedIndex++] : added[addedIndex++];
|
|
78
|
+
return renderRow(theme, { ...body, type: line.type, highlight }, width);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Flush message lines at shared newline boundaries, keeping complete unchanged
|
|
84
|
+
* lines as context and changed blocks as removals followed by additions.
|
|
85
|
+
*/
|
|
86
|
+
function messageRows(parts: readonly WordDiffPart[]): DiffRow[] {
|
|
87
|
+
const rows: DiffRow[] = [];
|
|
88
|
+
let before = emptyLines();
|
|
89
|
+
let after = emptyLines();
|
|
90
|
+
for (const part of parts) {
|
|
91
|
+
if (part.type !== "unchanged") {
|
|
92
|
+
appendText(part.type === "removed" ? before : after, part.text, true);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const lines = part.text.split("\n");
|
|
96
|
+
for (const [index, text] of lines.entries()) {
|
|
97
|
+
appendText(before, text, false);
|
|
98
|
+
appendText(after, text, false);
|
|
99
|
+
if (index === lines.length - 1) continue;
|
|
100
|
+
rows.push(...blockRows(before, after));
|
|
101
|
+
before = emptyLines();
|
|
102
|
+
after = emptyLines();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
// An absent side or a final newline does not create a phantom empty line
|
|
106
|
+
if (before.at(-1)?.text === "") before.pop();
|
|
107
|
+
if (after.at(-1)?.text === "") after.pop();
|
|
108
|
+
return [...rows, ...blockRows(before, after)];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Context at either end stays once; the middle shows each side and inverse changed words for replacements. */
|
|
112
|
+
function blockRows(before: readonly DiffLine[], after: readonly DiffLine[]): DiffRow[] {
|
|
113
|
+
let start = 0;
|
|
114
|
+
while (start < before.length && start < after.length && sameLine(before[start], after[start])) start++;
|
|
115
|
+
let beforeEnd = before.length;
|
|
116
|
+
let afterEnd = after.length;
|
|
117
|
+
while (beforeEnd > start && afterEnd > start && sameLine(before[beforeEnd - 1], after[afterEnd - 1])) {
|
|
118
|
+
beforeEnd--;
|
|
119
|
+
afterEnd--;
|
|
120
|
+
}
|
|
121
|
+
const highlight = beforeEnd > start && afterEnd > start;
|
|
122
|
+
return [
|
|
123
|
+
...before.slice(0, start).map((line): DiffRow => ({ ...line, type: "unchanged", highlight: false })),
|
|
124
|
+
...before.slice(start, beforeEnd).map((line): DiffRow => ({ ...line, type: "removed", highlight })),
|
|
125
|
+
...after.slice(start, afterEnd).map((line): DiffRow => ({ ...line, type: "added", highlight })),
|
|
126
|
+
...before.slice(beforeEnd).map((line): DiffRow => ({ ...line, type: "unchanged", highlight: false })),
|
|
127
|
+
];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Equal text is context only when the word alignment left both lines untouched. */
|
|
131
|
+
function sameLine(before: DiffLine, after: DiffLine): boolean {
|
|
132
|
+
return before.text === after.text && before.changes.length === 0 && after.changes.length === 0;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Lines of just one side, with word changes anchored before wrapping. */
|
|
136
|
+
function sideLines(parts: readonly WordDiffPart[], side: "added" | "removed"): DiffLine[] {
|
|
137
|
+
const lines = emptyLines();
|
|
138
|
+
for (const part of parts) {
|
|
139
|
+
if (part.type === "unchanged" || part.type === side) appendText(lines, part.text, part.type === side);
|
|
140
|
+
}
|
|
141
|
+
return lines;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Start a physical-line buffer for a side of a comparison. */
|
|
145
|
+
function emptyLines(): DiffLine[] {
|
|
146
|
+
return [{ text: "", changes: [] }];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Append a word run to physical lines, keeping changed ranges local to each line. */
|
|
150
|
+
function appendText(lines: DiffLine[], text: string, changed: boolean): void {
|
|
151
|
+
for (const [index, part] of text.split("\n").entries()) {
|
|
152
|
+
if (index > 0) lines.push({ text: "", changes: [] });
|
|
153
|
+
const line = lines[lines.length - 1];
|
|
154
|
+
if (changed && part.length > 0) line.changes.push({ start: line.text.length, end: line.text.length + part.length });
|
|
155
|
+
line.text += part;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Apply inverse to changed words, not leading whitespace, as Pi's diffs do; colors and wrapping come afterward. */
|
|
160
|
+
function highlightLine(theme: Theme, line: DiffRow): string {
|
|
161
|
+
if (!line.highlight) return line.text;
|
|
162
|
+
let text = "";
|
|
163
|
+
let offset = 0;
|
|
164
|
+
for (const span of line.changes) {
|
|
165
|
+
const start = span.start + (/^\s*/u.exec(line.text.slice(span.start, span.end))?.[0].length ?? 0);
|
|
166
|
+
if (start >= span.end) continue;
|
|
167
|
+
text += line.text.slice(offset, start) + theme.inverse(line.text.slice(start, span.end));
|
|
168
|
+
offset = span.end;
|
|
169
|
+
}
|
|
170
|
+
return text + line.text.slice(offset);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Keep diff colors and inverse words through wrapping, with continuations under the text, not its sign. */
|
|
174
|
+
function renderRow(theme: Theme, row: DiffRow, width: number): string[] {
|
|
175
|
+
const prefix = row.type === "added" ? "+ " : row.type === "removed" ? "- " : DIFF_INDENT;
|
|
176
|
+
const color = row.type === "added" ? "toolDiffAdded" : row.type === "removed" ? "toolDiffRemoved" : "toolDiffContext";
|
|
177
|
+
const text = theme.fg(color, highlightLine(theme, row));
|
|
178
|
+
const wrapped = wrapTextWithAnsi(text, Math.max(1, width - DIFF_INDENT.length));
|
|
179
|
+
return (wrapped.length === 0 ? [""] : wrapped).map((line, index) =>
|
|
180
|
+
`${BODY_INDENT}${index === 0 ? theme.fg(color, prefix) : DIFF_INDENT}${line}`);
|
|
181
|
+
}
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure presentation model for the Injections view: flattened rows, what the
|
|
3
|
+
* status bar tells about each, and list navigation/scrolling state. No pi or
|
|
4
|
+
* TUI access — unit-testable.
|
|
5
|
+
*/
|
|
6
|
+
import {
|
|
7
|
+
type ChangeEditor,
|
|
8
|
+
type ForcedEdit,
|
|
9
|
+
type InjectionItem,
|
|
10
|
+
type InjectionSnapshot,
|
|
11
|
+
type RequestChange,
|
|
12
|
+
sumTokenDiffs,
|
|
13
|
+
type TokenDiff,
|
|
14
|
+
} from "../model.ts";
|
|
15
|
+
|
|
16
|
+
/** Editor of every change no extension line explains. */
|
|
17
|
+
const UNKNOWN_EDITOR: ChangeEditor = { type: "unknown" };
|
|
18
|
+
|
|
19
|
+
/** Prefix of a tool row outside `Built-in Tools (N)`, whose group does not name it a tool. */
|
|
20
|
+
const TOOL_LABEL_PREFIX = "tool:";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Where a row's changes live, one status-bar phrase each:
|
|
24
|
+
* saved the session keeps every change
|
|
25
|
+
* saved-and-edit a leaf with saved changes and an edit only in this request
|
|
26
|
+
* partly an aggregate whose changes are partly only in this request
|
|
27
|
+
* request an aggregate whose changes are all only in this request
|
|
28
|
+
* request-edit a leaf changed only in this request; the session keeps the original
|
|
29
|
+
* request-new a leaf only this request has
|
|
30
|
+
* payload only in the provider payload
|
|
31
|
+
* Aggregates are parents, groups, and the `system prompt`, `messages (N)`,
|
|
32
|
+
* and `payload changes (N)` rows.
|
|
33
|
+
*/
|
|
34
|
+
export type ChangeScope =
|
|
35
|
+
| "saved"
|
|
36
|
+
| "saved-and-edit"
|
|
37
|
+
| "partly"
|
|
38
|
+
| "request"
|
|
39
|
+
| "request-edit"
|
|
40
|
+
| "request-new"
|
|
41
|
+
| "payload";
|
|
42
|
+
|
|
43
|
+
/** One flattened list row derived from the snapshot hierarchy. */
|
|
44
|
+
export type InjectionRow =
|
|
45
|
+
| {
|
|
46
|
+
readonly kind: "group";
|
|
47
|
+
readonly label: string;
|
|
48
|
+
readonly tokens: number;
|
|
49
|
+
readonly depth: 0;
|
|
50
|
+
/** Sum of the group's token diffs. */
|
|
51
|
+
readonly diff?: TokenDiff;
|
|
52
|
+
/** Where the changes of the rows it counts live; absent when none changed. */
|
|
53
|
+
readonly scope?: ChangeScope;
|
|
54
|
+
}
|
|
55
|
+
| {
|
|
56
|
+
readonly kind: "item";
|
|
57
|
+
readonly label: string;
|
|
58
|
+
/** Dim prefix before the label: `tool:` on a tool row outside `Built-in Tools (N)`. */
|
|
59
|
+
readonly labelPrefix?: string;
|
|
60
|
+
readonly tokens: number;
|
|
61
|
+
/** One for items and two for constituent sub-items. */
|
|
62
|
+
readonly depth: 1 | 2;
|
|
63
|
+
/** Whether this row is the final sibling at its depth. */
|
|
64
|
+
readonly isLast: boolean;
|
|
65
|
+
/** Whether a depth-two row's parent has a following sibling. */
|
|
66
|
+
readonly parentContinues?: boolean;
|
|
67
|
+
/** Whether a `--system-prompt` replacement dropped this contribution. */
|
|
68
|
+
readonly dropped?: boolean;
|
|
69
|
+
/** Whether an extension moved this part out of the region pi renders it into. */
|
|
70
|
+
readonly moved?: boolean;
|
|
71
|
+
/** Request-only change of this contribution. */
|
|
72
|
+
readonly change?: RequestChange;
|
|
73
|
+
/** Whether a forced prompt replaced this part's request edit, and still sent the part; no edit text. */
|
|
74
|
+
readonly forcedEdit?: Pick<ForcedEdit, "sent">;
|
|
75
|
+
/** Tokens this contribution gained and lost against its base. */
|
|
76
|
+
readonly diff?: TokenDiff;
|
|
77
|
+
/** Whether the request sent other content than the session keeps. */
|
|
78
|
+
readonly requestOnly?: boolean;
|
|
79
|
+
/** Whether the row shows only a diff of text other rows count, without a value. */
|
|
80
|
+
readonly diffOnly?: boolean;
|
|
81
|
+
/** Where the row's changes live; absent when it has none. */
|
|
82
|
+
readonly scope?: ChangeScope;
|
|
83
|
+
/** Who made a changed leaf row's change; absent on parents, groups, diff-only rows, and unchanged rows. */
|
|
84
|
+
readonly editors?: readonly ChangeEditor[];
|
|
85
|
+
/** Stable preview target id from the snapshot. */
|
|
86
|
+
readonly itemId: string;
|
|
87
|
+
}
|
|
88
|
+
| {
|
|
89
|
+
readonly kind: "separator";
|
|
90
|
+
readonly label: "";
|
|
91
|
+
readonly tokens: 0;
|
|
92
|
+
readonly depth: 0;
|
|
93
|
+
}
|
|
94
|
+
| {
|
|
95
|
+
readonly kind: "total";
|
|
96
|
+
readonly label: "TOTAL (Prompt, Tools, Injections)";
|
|
97
|
+
readonly tokens: number;
|
|
98
|
+
readonly depth: 0;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/** Index snapshot items (including sub-items) by id for preview lookup. */
|
|
102
|
+
export function collectItemsById(snapshot: InjectionSnapshot): Map<string, InjectionItem> {
|
|
103
|
+
const items = new Map<string, InjectionItem>();
|
|
104
|
+
for (const group of snapshot.groups) {
|
|
105
|
+
for (const item of group.items) {
|
|
106
|
+
items.set(item.id, item);
|
|
107
|
+
for (const child of item.children ?? []) items.set(child.id, child);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return items;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Flatten snapshot groups into rows separated from the non-selectable snapshot total. */
|
|
114
|
+
export function buildInjectionRows(snapshot: InjectionSnapshot): InjectionRow[] {
|
|
115
|
+
const rows: InjectionRow[] = [];
|
|
116
|
+
for (const group of snapshot.groups) {
|
|
117
|
+
rows.push({
|
|
118
|
+
kind: "group",
|
|
119
|
+
label: group.source.label,
|
|
120
|
+
tokens: group.totalTokens,
|
|
121
|
+
depth: 0,
|
|
122
|
+
diff: group.diff,
|
|
123
|
+
scope: combineScopes(group.items.filter((item) => item.diffOnly !== true).map(itemScope)),
|
|
124
|
+
});
|
|
125
|
+
group.items.forEach((item, itemIndex) => {
|
|
126
|
+
const isLastItem = itemIndex === group.items.length - 1;
|
|
127
|
+
rows.push({
|
|
128
|
+
kind: "item",
|
|
129
|
+
label: item.label,
|
|
130
|
+
// Built-in tools are pi's children of Built-in Tools (N)
|
|
131
|
+
...(item.kind === "tool" && !item.source.native ? { labelPrefix: TOOL_LABEL_PREFIX } : {}),
|
|
132
|
+
tokens: item.tokens,
|
|
133
|
+
depth: 1,
|
|
134
|
+
isLast: isLastItem,
|
|
135
|
+
dropped: item.dropped,
|
|
136
|
+
moved: item.moved,
|
|
137
|
+
change: item.change,
|
|
138
|
+
...forcedEditField(item),
|
|
139
|
+
diff: item.diff,
|
|
140
|
+
requestOnly: item.requestOnly,
|
|
141
|
+
diffOnly: item.diffOnly,
|
|
142
|
+
...rowStatus(item),
|
|
143
|
+
itemId: item.id,
|
|
144
|
+
});
|
|
145
|
+
const children = item.children ?? [];
|
|
146
|
+
children.forEach((child, childIndex) => {
|
|
147
|
+
rows.push({
|
|
148
|
+
kind: "item",
|
|
149
|
+
label: child.label,
|
|
150
|
+
tokens: child.tokens,
|
|
151
|
+
depth: 2,
|
|
152
|
+
isLast: childIndex === children.length - 1,
|
|
153
|
+
parentContinues: !isLastItem,
|
|
154
|
+
dropped: child.dropped,
|
|
155
|
+
moved: child.moved,
|
|
156
|
+
change: child.change,
|
|
157
|
+
...forcedEditField(child),
|
|
158
|
+
diff: child.diff,
|
|
159
|
+
requestOnly: child.requestOnly,
|
|
160
|
+
...rowStatus(child),
|
|
161
|
+
itemId: child.id,
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
rows.push({ kind: "separator", label: "", tokens: 0, depth: 0 });
|
|
167
|
+
rows.push({ kind: "total", label: "TOTAL (Prompt, Tools, Injections)", tokens: snapshot.totalTokens, depth: 0 });
|
|
168
|
+
return rows;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** A row's replaced-edit state, without the edit text the list never shows. */
|
|
172
|
+
function forcedEditField(item: InjectionItem): { forcedEdit?: Pick<ForcedEdit, "sent"> } {
|
|
173
|
+
return item.forcedEdit === undefined ? {} : { forcedEdit: { sent: item.forcedEdit.sent } };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Status-bar fields of one item row: where its changes live and, for a
|
|
178
|
+
* changed leaf other than a `system prompt` row, who made them. An editor pi
|
|
179
|
+
* does not report is unknown, never the row's source group.
|
|
180
|
+
*/
|
|
181
|
+
function rowStatus(item: InjectionItem): { scope?: ChangeScope; editors?: readonly ChangeEditor[] } {
|
|
182
|
+
const scope = itemScope(item);
|
|
183
|
+
if (scope === undefined) return {};
|
|
184
|
+
const leaf = (item.children ?? []).length === 0 && item.diffOnly !== true;
|
|
185
|
+
return leaf ? { scope, editors: item.editors ?? [UNKNOWN_EDITOR] } : { scope };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Where one item's changes live. A parent combines its children and the
|
|
190
|
+
* removal of children only the session has, which is request-only. A
|
|
191
|
+
* `system prompt` row combines its saved and request-only changes like a
|
|
192
|
+
* parent. Every message change is only in this request, so the messages row
|
|
193
|
+
* has no saved ones. A forced prompt without parts exists only in this request.
|
|
194
|
+
*/
|
|
195
|
+
function itemScope(item: InjectionItem): ChangeScope | undefined {
|
|
196
|
+
const children = item.children ?? [];
|
|
197
|
+
if (children.length > 0) return combineScopes(children.map(itemScope), removesChildren(item, children));
|
|
198
|
+
if (item.kind === "payload-change") return "payload";
|
|
199
|
+
if (item.kind === "message") return item.requestOnly === true ? "request" : undefined;
|
|
200
|
+
if (item.diffOnly === true) {
|
|
201
|
+
if (item.requestOnly !== true) return item.diff === undefined ? undefined : "saved";
|
|
202
|
+
return item.savedChange === true ? "partly" : "request";
|
|
203
|
+
}
|
|
204
|
+
if (item.requestOnly === true) {
|
|
205
|
+
if (item.savedChange === true) return "saved-and-edit";
|
|
206
|
+
return item.change === "added" || item.sessionOriginal === false ? "request-new" : "request-edit";
|
|
207
|
+
}
|
|
208
|
+
if (item.change === "forced") return "request-edit";
|
|
209
|
+
return item.diff === undefined ? undefined : "saved";
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Whether a parent's diff removes more than its children's: children only the session has. */
|
|
213
|
+
function removesChildren(parent: InjectionItem, children: readonly InjectionItem[]): boolean {
|
|
214
|
+
const shown = sumTokenDiffs(children.map((child) => child.diff));
|
|
215
|
+
return (parent.diff?.added ?? 0) > (shown?.added ?? 0) || (parent.diff?.removed ?? 0) > (shown?.removed ?? 0);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* One aggregate scope: saved, request-only, or partly both. Payload changes
|
|
220
|
+
* mixed with other changes count as request-only, since the session keeps
|
|
221
|
+
* neither.
|
|
222
|
+
*/
|
|
223
|
+
function combineScopes(scopes: ReadonlyArray<ChangeScope | undefined>, request = false): ChangeScope | undefined {
|
|
224
|
+
let saved = false;
|
|
225
|
+
let payload = false;
|
|
226
|
+
for (const scope of scopes) {
|
|
227
|
+
if (scope === undefined) continue;
|
|
228
|
+
if (scope === "payload") {
|
|
229
|
+
payload = true;
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
if (scope === "saved" || scope === "saved-and-edit" || scope === "partly") saved = true;
|
|
233
|
+
if (scope !== "saved") request = true;
|
|
234
|
+
}
|
|
235
|
+
if (saved) return request || payload ? "partly" : "saved";
|
|
236
|
+
if (request) return "request";
|
|
237
|
+
return payload ? "payload" : undefined;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Selection and scroll-window state over fixed rows. A trailing summary can
|
|
242
|
+
* participate in scrolling without being included in selection navigation.
|
|
243
|
+
*/
|
|
244
|
+
export class ListNavigator {
|
|
245
|
+
private readonly rowCount: number;
|
|
246
|
+
private readonly selectableRowCount: number;
|
|
247
|
+
private visibleCount: number;
|
|
248
|
+
private selectedIndex = 0;
|
|
249
|
+
private scrollOffset = 0;
|
|
250
|
+
|
|
251
|
+
public constructor(rowCount: number, visibleCount: number, selectableRowCount = rowCount) {
|
|
252
|
+
this.rowCount = Math.max(0, rowCount);
|
|
253
|
+
this.selectableRowCount = Math.min(this.rowCount, Math.max(0, selectableRowCount));
|
|
254
|
+
this.visibleCount = Math.max(1, visibleCount);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
public get selected(): number {
|
|
258
|
+
return this.selectedIndex;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
public get selectedOrdinal(): number {
|
|
262
|
+
return this.selectedIndex;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
public get selectableCount(): number {
|
|
266
|
+
return this.selectableRowCount;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
public get offset(): number {
|
|
270
|
+
return this.scrollOffset;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
public get windowSize(): number {
|
|
274
|
+
return Math.min(this.visibleCount, this.rowCount);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** One-based final row currently visible, suitable for a scroll counter. */
|
|
278
|
+
public get visibleEnd(): number {
|
|
279
|
+
return Math.min(this.rowCount, this.scrollOffset + this.windowSize);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
public get hasOverflow(): boolean {
|
|
283
|
+
return this.rowCount > this.visibleCount;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
public setVisibleCount(count: number): void {
|
|
287
|
+
this.visibleCount = Math.max(1, count);
|
|
288
|
+
this.ensureVisible();
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
public moveBy(delta: number): boolean {
|
|
292
|
+
return this.moveTo(this.selectedIndex + delta);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
public moveTo(index: number): boolean {
|
|
296
|
+
if (this.selectableRowCount === 0) return false;
|
|
297
|
+
const next = Math.min(this.selectableRowCount - 1, Math.max(0, index));
|
|
298
|
+
if (next === this.selectedIndex) return false;
|
|
299
|
+
this.selectedIndex = next;
|
|
300
|
+
this.ensureVisible();
|
|
301
|
+
return true;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
public page(direction: -1 | 1): boolean {
|
|
305
|
+
return this.moveBy(direction * Math.max(1, this.visibleCount - 1));
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
private ensureVisible(): void {
|
|
309
|
+
const maxOffset = Math.max(0, this.rowCount - this.visibleCount);
|
|
310
|
+
if (this.selectedIndex < this.scrollOffset) {
|
|
311
|
+
this.scrollOffset = this.selectedIndex;
|
|
312
|
+
} else if (this.selectedIndex >= this.scrollOffset + this.visibleCount) {
|
|
313
|
+
this.scrollOffset = this.selectedIndex - this.visibleCount + 1;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const trailingRows = this.rowCount - this.selectedIndex - 1;
|
|
317
|
+
if (this.selectedIndex === this.selectableRowCount - 1 && trailingRows < this.visibleCount) {
|
|
318
|
+
this.scrollOffset = maxOffset;
|
|
319
|
+
}
|
|
320
|
+
this.scrollOffset = Math.min(maxOffset, Math.max(0, this.scrollOffset));
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Scroll-only window over wrapped preview lines. Extent is re-declared each
|
|
326
|
+
* render (wrapping depends on width); the offset is clamped to stay valid.
|
|
327
|
+
*/
|
|
328
|
+
export class PreviewScroller {
|
|
329
|
+
private lineCount = 0;
|
|
330
|
+
private visibleCount = 1;
|
|
331
|
+
private offsetValue = 0;
|
|
332
|
+
|
|
333
|
+
public get offset(): number {
|
|
334
|
+
return this.offsetValue;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
public get windowSize(): number {
|
|
338
|
+
return Math.min(this.visibleCount, this.lineCount);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** One-based final line currently visible, suitable for a progress counter. */
|
|
342
|
+
public get visibleEnd(): number {
|
|
343
|
+
return Math.min(this.lineCount, this.offsetValue + this.windowSize);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
public get hasOverflow(): boolean {
|
|
347
|
+
return this.lineCount > this.visibleCount;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
public get maxOffset(): number {
|
|
351
|
+
return Math.max(0, this.lineCount - this.visibleCount);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
public setExtent(lineCount: number, visibleCount: number): void {
|
|
355
|
+
this.lineCount = Math.max(0, lineCount);
|
|
356
|
+
this.visibleCount = Math.max(1, visibleCount);
|
|
357
|
+
this.offsetValue = Math.min(this.maxOffset, this.offsetValue);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
public scrollBy(delta: number): boolean {
|
|
361
|
+
return this.scrollTo(this.offsetValue + delta);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
public scrollTo(offset: number): boolean {
|
|
365
|
+
const next = Math.min(this.maxOffset, Math.max(0, offset));
|
|
366
|
+
if (next === this.offsetValue) return false;
|
|
367
|
+
this.offsetValue = next;
|
|
368
|
+
return true;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
public page(direction: -1 | 1): boolean {
|
|
372
|
+
return this.scrollBy(direction * Math.max(1, this.visibleCount - 1));
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
public reset(): void {
|
|
376
|
+
this.offsetValue = 0;
|
|
377
|
+
}
|
|
378
|
+
}
|