@xprnvd/pi-context-view 0.0.0-stage → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +114 -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 +514 -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 +251 -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 +487 -0
- package/src/ui/injections-view.ts +1023 -0
- package/src/ui/json-preview.ts +41 -0
- package/src/ui/layout.ts +161 -0
- package/src/ui/markers.ts +253 -0
- package/src/ui/notes.ts +117 -0
- package/src/ui/search.ts +427 -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 +202 -0
- package/src/ui/usage-view.ts +1428 -0
- package/src/ui/wheel.ts +65 -0
- package/src/usage.ts +461 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Preview-only recognition of complete pi skill-expansion wrappers. The
|
|
3
|
+
* returned segments retain ordinary and malformed text verbatim; callers are
|
|
4
|
+
* responsible for terminal sanitization before parsing or rendering.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const SKILL_OPEN_TAG = /^<skill name="([^"\r\n]+)"(?: location="[^"\r\n]*")?>\r?$/gm;
|
|
8
|
+
const ANY_SKILL_OPEN_TAG = /^<skill(?:\s|>)[^\r\n]*\r?$/gm;
|
|
9
|
+
const SKILL_CLOSE_TAG = /^<\/skill>\r?$/gm;
|
|
10
|
+
|
|
11
|
+
/** One display segment from a user message containing attached skills. */
|
|
12
|
+
export type SkillPreviewSegment =
|
|
13
|
+
| { readonly type: "text"; readonly text: string }
|
|
14
|
+
| { readonly type: "skill"; readonly name: string };
|
|
15
|
+
|
|
16
|
+
interface TagSpan {
|
|
17
|
+
readonly start: number;
|
|
18
|
+
readonly end: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface SkillOpenTag extends TagSpan {
|
|
22
|
+
readonly name: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Replace no content directly: split complete skill wrappers into named
|
|
27
|
+
* segments so the UI can render compact badges while retaining unmatched
|
|
28
|
+
* wrappers as text. A later complete wrapper is still recognized when an
|
|
29
|
+
* earlier opening tag is malformed or unclosed.
|
|
30
|
+
*/
|
|
31
|
+
export function splitSkillPreview(text: string): SkillPreviewSegment[] {
|
|
32
|
+
const openings = collectOpenTags(text);
|
|
33
|
+
const openingBoundaries = collectTagSpans(text, ANY_SKILL_OPEN_TAG);
|
|
34
|
+
const closings = collectTagSpans(text, SKILL_CLOSE_TAG);
|
|
35
|
+
const segments: SkillPreviewSegment[] = [];
|
|
36
|
+
let textStart = 0;
|
|
37
|
+
let openingBoundaryIndex = 0;
|
|
38
|
+
let closingIndex = 0;
|
|
39
|
+
|
|
40
|
+
for (let openingIndex = 0; openingIndex < openings.length; openingIndex++) {
|
|
41
|
+
const opening = openings[openingIndex];
|
|
42
|
+
if (opening === undefined || opening.start < textStart) continue;
|
|
43
|
+
while ((closings[closingIndex]?.start ?? text.length) < opening.end) closingIndex++;
|
|
44
|
+
const closing = closings[closingIndex];
|
|
45
|
+
if (closing === undefined) continue;
|
|
46
|
+
|
|
47
|
+
while ((openingBoundaries[openingBoundaryIndex]?.start ?? text.length) <= opening.start) {
|
|
48
|
+
openingBoundaryIndex++;
|
|
49
|
+
}
|
|
50
|
+
const nextOpening = openingBoundaries[openingBoundaryIndex];
|
|
51
|
+
if (nextOpening !== undefined && nextOpening.start < closing.start) continue;
|
|
52
|
+
|
|
53
|
+
pushTextSegment(segments, text.slice(textStart, opening.start));
|
|
54
|
+
segments.push({ type: "skill", name: opening.name });
|
|
55
|
+
textStart = closing.end;
|
|
56
|
+
closingIndex++;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
pushTextSegment(segments, text.slice(textStart));
|
|
60
|
+
return segments;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Collect structurally valid opening tags and their skill names. */
|
|
64
|
+
function collectOpenTags(text: string): SkillOpenTag[] {
|
|
65
|
+
return [...text.matchAll(SKILL_OPEN_TAG)].map((match) => ({
|
|
66
|
+
start: match.index,
|
|
67
|
+
end: match.index + match[0].length,
|
|
68
|
+
name: match[1] ?? "",
|
|
69
|
+
}));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Collect complete line-delimited tag spans for one global expression. */
|
|
73
|
+
function collectTagSpans(text: string, expression: RegExp): TagSpan[] {
|
|
74
|
+
return [...text.matchAll(expression)].map((match) => ({
|
|
75
|
+
start: match.index,
|
|
76
|
+
end: match.index + match[0].length,
|
|
77
|
+
}));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Append non-empty ordinary text without merging it into skill segments. */
|
|
81
|
+
function pushTextSegment(segments: SkillPreviewSegment[], text: string): void {
|
|
82
|
+
if (text.length > 0) segments.push({ type: "text", text });
|
|
83
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Status bar of the Injections list: what the selected row's changes are,
|
|
3
|
+
* where they live, who made them, and what its markers mean, one field per
|
|
4
|
+
* line. It never shows raw text or repeats the row's source or location.
|
|
5
|
+
* Pure string logic.
|
|
6
|
+
*/
|
|
7
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
9
|
+
|
|
10
|
+
import type { ChangeEditor, TokenDiff } from "../model.ts";
|
|
11
|
+
import { normalizeInlineText } from "../text.ts";
|
|
12
|
+
import type { ChangeScope, InjectionRow } from "./injections-model.ts";
|
|
13
|
+
import { fitLine } from "./layout.ts";
|
|
14
|
+
import { formatDiffAdded, formatDiffRemoved, guessMarker, markerStatusField } from "./markers.ts";
|
|
15
|
+
|
|
16
|
+
/** Indent of each field, two columns deeper than the description, so the bar reads apart from it. */
|
|
17
|
+
const FIELD_INDENT = " ";
|
|
18
|
+
/** Indent of a field's wrapped continuation lines. */
|
|
19
|
+
const CONTINUATION_INDENT = " ";
|
|
20
|
+
|
|
21
|
+
/** Where a row's changes live, after the diff in its Change field. */
|
|
22
|
+
const SCOPE_PHRASES: Record<ChangeScope, string> = {
|
|
23
|
+
saved: "saved in the session",
|
|
24
|
+
"saved-and-edit": "saved in the session, with an edit only in this request",
|
|
25
|
+
partly: "some saved in the session, some only in this request",
|
|
26
|
+
request: "only in this request",
|
|
27
|
+
"request-edit": "only in this request; the session keeps the original",
|
|
28
|
+
"request-new": "only in this request; not in the session",
|
|
29
|
+
payload: "only in the provider payload",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Why a `system prompt` row shows no value. */
|
|
33
|
+
const DIFF_ONLY_NOTE = "Already counted in System Prompt";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Wrapped status lines of one list row: Change, then Changed By or the note
|
|
37
|
+
* of a `system prompt` row, then one explanation per marker. Empty for a row
|
|
38
|
+
* without a change or marker, and for the separator and TOTAL.
|
|
39
|
+
*/
|
|
40
|
+
export function statusBarLines(theme: Theme, row: InjectionRow, width: number): string[] {
|
|
41
|
+
return statusFields(theme, row).flatMap((field) => wrapField(field, width));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Unwrapped fields of one row, in display order. */
|
|
45
|
+
function statusFields(theme: Theme, row: InjectionRow): string[] {
|
|
46
|
+
if (row.kind !== "group" && row.kind !== "item") return [];
|
|
47
|
+
const fields: string[] = [];
|
|
48
|
+
if (row.scope !== undefined) fields.push(field(theme, "Change", changeText(theme, row.diff, row.scope)));
|
|
49
|
+
if (row.kind !== "item") return fields;
|
|
50
|
+
if (row.diffOnly === true) fields.push(theme.fg("dim", DIFF_ONLY_NOTE));
|
|
51
|
+
else if (row.editors !== undefined) fields.push(field(theme, "Changed By", editorsText(theme, row.editors)));
|
|
52
|
+
// A row shows one of Dropped and Moved, like its markers
|
|
53
|
+
if (row.dropped === true) fields.push(markerStatusField(theme, "dropped"));
|
|
54
|
+
else if (row.moved === true) fields.push(markerStatusField(theme, "moved"));
|
|
55
|
+
if (row.change === "forced") fields.push(markerStatusField(theme, "forced"));
|
|
56
|
+
if (row.forcedEdit !== undefined) {
|
|
57
|
+
fields.push(markerStatusField(theme, row.forcedEdit.sent ? "forced-edit-sent" : "forced-edit-unsent"));
|
|
58
|
+
}
|
|
59
|
+
return fields;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** One field: its `muted` name, then a dim colon and the value. */
|
|
63
|
+
function field(theme: Theme, name: string, value: string): string {
|
|
64
|
+
return `${theme.fg("muted", name)}${theme.fg("dim", ": ")}${value}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** `+N -N` in the diff colors, then where the changes live; only the phrase without a diff. */
|
|
68
|
+
function changeText(theme: Theme, diff: TokenDiff | undefined, scope: ChangeScope): string {
|
|
69
|
+
const sides: string[] = [];
|
|
70
|
+
if (diff !== undefined && diff.added > 0) sides.push(theme.fg("toolDiffAdded", formatDiffAdded(diff.added)));
|
|
71
|
+
if (diff !== undefined && diff.removed > 0) sides.push(theme.fg("toolDiffRemoved", formatDiffRemoved(diff.removed)));
|
|
72
|
+
const phrase = SCOPE_PHRASES[scope];
|
|
73
|
+
if (sides.length === 0) return theme.fg("dim", phrase);
|
|
74
|
+
return `${sides.join(" ")}${theme.fg("dim", `${scope === "partly" ? "," : ""} ${phrase}`)}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Comma-separated editors: sanitized extension labels with a dim `(guess)` where inferred, then `Unknown`. */
|
|
78
|
+
function editorsText(theme: Theme, editors: readonly ChangeEditor[]): string {
|
|
79
|
+
return editors.map((editor) => {
|
|
80
|
+
if (editor.type === "unknown") return theme.fg("dim", "Unknown");
|
|
81
|
+
const label = theme.fg("dim", normalizeInlineText(editor.source.label));
|
|
82
|
+
return editor.attribution === "guess" ? `${label}${guessMarker(theme)}` : label;
|
|
83
|
+
}).join(theme.fg("dim", ", "));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Wrap one field under its indent, hanging continuation lines two columns deeper. */
|
|
87
|
+
function wrapField(text: string, width: number): string[] {
|
|
88
|
+
const indentWidth = Math.min(CONTINUATION_INDENT.length, Math.max(0, width - 1));
|
|
89
|
+
const wrapped = wrapTextWithAnsi(text, Math.max(1, width - indentWidth));
|
|
90
|
+
return wrapped.map((line, index) => {
|
|
91
|
+
const indent = (index === 0 ? FIELD_INDENT : CONTINUATION_INDENT).slice(0, indentWidth);
|
|
92
|
+
return fitLine(`${indent}${line}`, width);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure proportional-cell model for the Usage view's context map. Geometry is
|
|
3
|
+
* an input: the caller clamps the configured cell counts to what the viewport
|
|
4
|
+
* can render. The map uses estimated category totals against the selected
|
|
5
|
+
* Window/Fit scale. Pi's separately reported occupied tokens may differ
|
|
6
|
+
* because of tokenizer, serialization, caching, and last-response timing.
|
|
7
|
+
*/
|
|
8
|
+
import type { ContextUsageSnapshot } from "../model.ts";
|
|
9
|
+
|
|
10
|
+
const FIT_SCALE_PERCENT = 115;
|
|
11
|
+
const PERCENT_DENOMINATOR = 100;
|
|
12
|
+
const MINIMUM_FIT_SCALE_TOKENS = 10_000;
|
|
13
|
+
const FIT_SCALE_SIGNIFICANT_DIGITS = 2;
|
|
14
|
+
|
|
15
|
+
/** One visual map cell assigned to a category, the auto-compact buffer, or remaining free space. */
|
|
16
|
+
export interface UsageMapCell {
|
|
17
|
+
readonly categoryId?: string;
|
|
18
|
+
readonly fill: "full" | "partial" | "buffer" | "free";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Rectangular context-usage map in row-major order. */
|
|
22
|
+
export interface UsageMap {
|
|
23
|
+
readonly columns: number;
|
|
24
|
+
readonly rows: number;
|
|
25
|
+
/** Tokens one cell represents at the active scale; shrinks when Fit narrows the denominator. */
|
|
26
|
+
readonly blockTokens: number;
|
|
27
|
+
readonly cells: readonly UsageMapCell[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface MapSegment {
|
|
31
|
+
readonly categoryId: string;
|
|
32
|
+
readonly start: number;
|
|
33
|
+
readonly end: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Calculate a Fit denominator from estimated occupancy: 15% headroom,
|
|
38
|
+
* rounded upward to two significant digits, with the documented floor/cap.
|
|
39
|
+
*/
|
|
40
|
+
export function calculateFitMapScale(usage: ContextUsageSnapshot): number | undefined {
|
|
41
|
+
const contextWindow = usage.reported?.contextWindow;
|
|
42
|
+
if (contextWindow === undefined || !Number.isFinite(contextWindow) || contextWindow <= 0) return undefined;
|
|
43
|
+
|
|
44
|
+
const estimatedTotal = usage.categories.reduce((sum, category) => sum + category.tokens, 0);
|
|
45
|
+
const withHeadroom = Math.max(0, estimatedTotal) * FIT_SCALE_PERCENT / PERCENT_DENOMINATOR;
|
|
46
|
+
const rounded = roundUpToSignificantDigits(withHeadroom, FIT_SCALE_SIGNIFICANT_DIGITS);
|
|
47
|
+
return Math.min(contextWindow, Math.max(MINIMUM_FIT_SCALE_TOKENS, rounded));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Build a proportional map from estimated categories. `scaleTokens` changes
|
|
52
|
+
* only the mapped denominator; buffer placement remains anchored to the true
|
|
53
|
+
* context window. Returns undefined without a usable denominator.
|
|
54
|
+
*/
|
|
55
|
+
export function buildUsageMap(
|
|
56
|
+
usage: ContextUsageSnapshot,
|
|
57
|
+
columns: number,
|
|
58
|
+
rows: number,
|
|
59
|
+
scaleTokens?: number,
|
|
60
|
+
): UsageMap | undefined {
|
|
61
|
+
const contextWindow = usage.reported?.contextWindow;
|
|
62
|
+
if (
|
|
63
|
+
contextWindow === undefined ||
|
|
64
|
+
!Number.isFinite(contextWindow) ||
|
|
65
|
+
contextWindow <= 0 ||
|
|
66
|
+
columns <= 0 ||
|
|
67
|
+
rows <= 0
|
|
68
|
+
) return undefined;
|
|
69
|
+
|
|
70
|
+
const requestedScale = scaleTokens ?? contextWindow;
|
|
71
|
+
if (!Number.isFinite(requestedScale) || requestedScale <= 0) return undefined;
|
|
72
|
+
const mapScale = Math.min(contextWindow, requestedScale);
|
|
73
|
+
const cellCount = Math.floor(columns) * Math.floor(rows);
|
|
74
|
+
const estimatedTotal = usage.categories.reduce((sum, category) => sum + category.tokens, 0);
|
|
75
|
+
const occupiedTokens = clamp(estimatedTotal, 0, mapScale);
|
|
76
|
+
const occupiedCells = occupiedTokens / mapScale * cellCount;
|
|
77
|
+
// Fit reserves its headroom as visible free space; the true-window buffer remains outside the mapped range.
|
|
78
|
+
const windowOccupancy = clamp(estimatedTotal, 0, contextWindow);
|
|
79
|
+
const bufferTokens = mapScale < contextWindow
|
|
80
|
+
? 0
|
|
81
|
+
: clamp(usage.autoCompactReserveTokens ?? 0, 0, contextWindow - windowOccupancy);
|
|
82
|
+
const bufferStart = (contextWindow - bufferTokens) / mapScale * cellCount;
|
|
83
|
+
const segments = createSegments(usage, estimatedTotal, occupiedCells);
|
|
84
|
+
const cells = Array.from(
|
|
85
|
+
{ length: cellCount },
|
|
86
|
+
(_, index) => createCell(index, occupiedCells, bufferStart, segments),
|
|
87
|
+
);
|
|
88
|
+
return {
|
|
89
|
+
columns: Math.floor(columns),
|
|
90
|
+
rows: Math.floor(rows),
|
|
91
|
+
blockTokens: mapScale / cellCount,
|
|
92
|
+
cells,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Scale estimated category shares into the occupied map range. */
|
|
97
|
+
function createSegments(
|
|
98
|
+
usage: ContextUsageSnapshot,
|
|
99
|
+
estimatedTotal: number,
|
|
100
|
+
occupiedCells: number,
|
|
101
|
+
): MapSegment[] {
|
|
102
|
+
if (estimatedTotal <= 0 || occupiedCells <= 0) return [];
|
|
103
|
+
const segments: MapSegment[] = [];
|
|
104
|
+
let cursor = 0;
|
|
105
|
+
for (const category of usage.categories) {
|
|
106
|
+
const size = category.tokens / estimatedTotal * occupiedCells;
|
|
107
|
+
segments.push({ categoryId: category.id, start: cursor, end: cursor + size });
|
|
108
|
+
cursor += size;
|
|
109
|
+
}
|
|
110
|
+
return segments;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Assign one map cell to its largest category overlap and classify its fill. */
|
|
114
|
+
function createCell(
|
|
115
|
+
index: number,
|
|
116
|
+
occupiedCells: number,
|
|
117
|
+
bufferStart: number,
|
|
118
|
+
segments: readonly MapSegment[],
|
|
119
|
+
): UsageMapCell {
|
|
120
|
+
const occupiedOverlap = overlap(index, index + 1, 0, occupiedCells);
|
|
121
|
+
if (occupiedOverlap <= 0) {
|
|
122
|
+
// An unoccupied cell belongs to the buffer when at least half of it lies past the trigger point.
|
|
123
|
+
return overlap(index, index + 1, bufferStart, index + 1) >= 0.5 ? { fill: "buffer" } : { fill: "free" };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let categoryId: string | undefined;
|
|
127
|
+
let categoryOverlap = 0;
|
|
128
|
+
for (const segment of segments) {
|
|
129
|
+
const currentOverlap = overlap(index, index + 1, segment.start, segment.end);
|
|
130
|
+
if (currentOverlap > categoryOverlap) {
|
|
131
|
+
categoryId = segment.categoryId;
|
|
132
|
+
categoryOverlap = currentOverlap;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
categoryId,
|
|
137
|
+
fill: categoryOverlap >= 0.7 ? "full" : "partial",
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Length shared by two half-open numeric ranges. */
|
|
142
|
+
function overlap(aStart: number, aEnd: number, bStart: number, bEnd: number): number {
|
|
143
|
+
return Math.max(0, Math.min(aEnd, bEnd) - Math.max(aStart, bStart));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Round a positive value upward at the requested significant-digit boundary. */
|
|
147
|
+
function roundUpToSignificantDigits(value: number, significantDigits: number): number {
|
|
148
|
+
if (!Number.isFinite(value) || value <= 0) return 0;
|
|
149
|
+
const boundary = 10 ** (Math.floor(Math.log10(value)) - significantDigits + 1);
|
|
150
|
+
return Math.ceil(value / boundary) * boundary;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Restrict a finite value to an inclusive range. */
|
|
154
|
+
function clamp(value: number, minimum: number, maximum: number): number {
|
|
155
|
+
if (!Number.isFinite(value)) return minimum;
|
|
156
|
+
return Math.min(maximum, Math.max(minimum, value));
|
|
157
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure block model for the Usage category preview: geometry of the flattened
|
|
3
|
+
* line stream plus the selection, paging, and in-block scrolling state shared
|
|
4
|
+
* by every preview width. No pi or TUI access — unit-testable.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Absolute line span of one block inside the flattened preview stream. */
|
|
8
|
+
export interface PreviewBlockExtent {
|
|
9
|
+
readonly start: number;
|
|
10
|
+
readonly height: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Owner of one flattened preview line; blank separator rows have none. */
|
|
14
|
+
export interface PreviewLineRef {
|
|
15
|
+
readonly blockIndex: number;
|
|
16
|
+
readonly lineIndex: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Flattened preview stream: block spans plus per-line ownership. */
|
|
20
|
+
export interface PreviewLayout {
|
|
21
|
+
readonly extents: readonly PreviewBlockExtent[];
|
|
22
|
+
readonly lines: ReadonlyArray<PreviewLineRef | undefined>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Stack block heights into one line stream separated by a blank row. */
|
|
26
|
+
export function layoutPreviewBlocks(heights: readonly number[]): PreviewLayout {
|
|
27
|
+
const extents: PreviewBlockExtent[] = [];
|
|
28
|
+
const lines: Array<PreviewLineRef | undefined> = [];
|
|
29
|
+
for (const [blockIndex, height] of heights.entries()) {
|
|
30
|
+
if (blockIndex > 0) lines.push(undefined);
|
|
31
|
+
extents.push({ start: lines.length, height });
|
|
32
|
+
for (let lineIndex = 0; lineIndex < height; lineIndex++) lines.push({ blockIndex, lineIndex });
|
|
33
|
+
}
|
|
34
|
+
return { extents, lines };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Selection and scroll state over stacked preview blocks. Steps move block to
|
|
39
|
+
* block, except that a block taller than the viewport scrolls line by line
|
|
40
|
+
* until its far edge is reached. Extent is re-declared each render because
|
|
41
|
+
* wrapping and terminal height both change it.
|
|
42
|
+
*/
|
|
43
|
+
export class BlockNavigator {
|
|
44
|
+
private extents: readonly PreviewBlockExtent[] = [];
|
|
45
|
+
private lineCount = 0;
|
|
46
|
+
private visibleCount = 1;
|
|
47
|
+
private selectedIndex = 0;
|
|
48
|
+
private offsetValue = 0;
|
|
49
|
+
|
|
50
|
+
/** Index of the selected block. */
|
|
51
|
+
public get selected(): number {
|
|
52
|
+
return this.selectedIndex;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Number of navigable blocks in the current stream. */
|
|
56
|
+
public get blockCount(): number {
|
|
57
|
+
return this.extents.length;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** First stream line currently visible. */
|
|
61
|
+
public get offset(): number {
|
|
62
|
+
return this.offsetValue;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Highest offset that still fills the viewport. */
|
|
66
|
+
public get maxOffset(): number {
|
|
67
|
+
return Math.max(0, this.lineCount - this.visibleCount);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Adopt new geometry, keeping the scroll position unless it hides the selected block. */
|
|
71
|
+
public setExtent(layout: PreviewLayout, visibleCount: number): void {
|
|
72
|
+
this.extents = layout.extents;
|
|
73
|
+
this.lineCount = layout.lines.length;
|
|
74
|
+
this.visibleCount = Math.max(1, visibleCount);
|
|
75
|
+
this.selectedIndex = clamp(this.selectedIndex, this.extents.length - 1);
|
|
76
|
+
this.offsetValue = clamp(this.offsetValue, this.maxOffset);
|
|
77
|
+
if (!this.isSelectionAnchored()) this.revealSelected();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Step back one block, or one line while the selected block extends above the viewport. */
|
|
81
|
+
public stepBack(): boolean {
|
|
82
|
+
const block = this.extents[this.selectedIndex];
|
|
83
|
+
if (block === undefined) return false;
|
|
84
|
+
if (block.start < this.offsetValue) return this.scrollTo(this.offsetValue - 1);
|
|
85
|
+
return this.selectBlock(this.selectedIndex - 1, -1);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Step forward one block, or one line while the selected block extends below the viewport. */
|
|
89
|
+
public stepForward(): boolean {
|
|
90
|
+
const block = this.extents[this.selectedIndex];
|
|
91
|
+
if (block === undefined) return false;
|
|
92
|
+
if (block.start + block.height > this.offsetValue + this.visibleCount) {
|
|
93
|
+
return this.scrollTo(this.offsetValue + 1);
|
|
94
|
+
}
|
|
95
|
+
return this.selectBlock(this.selectedIndex + 1, 1);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Page by the viewport height, then finish at the stream boundary from its first or last page. */
|
|
99
|
+
public page(direction: -1 | 1): boolean {
|
|
100
|
+
if (this.extents.length === 0) return false;
|
|
101
|
+
const offset = clamp(this.offsetValue + direction * this.visibleCount, this.maxOffset);
|
|
102
|
+
if (offset === this.offsetValue) {
|
|
103
|
+
const boundaryIndex = direction < 0 ? 0 : this.extents.length - 1;
|
|
104
|
+
return this.moveTo(boundaryIndex, offset);
|
|
105
|
+
}
|
|
106
|
+
return this.moveTo(this.firstVisibleBlock(offset), offset);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Select one block, e.g. the one holding a search match, scrolling minimally to show it. */
|
|
110
|
+
public select(index: number): boolean {
|
|
111
|
+
if (index < 0 || index >= this.extents.length || index === this.selectedIndex) return false;
|
|
112
|
+
this.selectedIndex = index;
|
|
113
|
+
this.revealSelected();
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Select the first block at the top of the stream. */
|
|
118
|
+
public moveToFirst(): boolean {
|
|
119
|
+
return this.moveTo(0, 0);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Select the last block at the end of the stream. */
|
|
123
|
+
public moveToLast(): boolean {
|
|
124
|
+
return this.moveTo(this.extents.length - 1, this.maxOffset);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Start over at the first block after the preview target changes. */
|
|
128
|
+
public reset(): void {
|
|
129
|
+
this.selectedIndex = 0;
|
|
130
|
+
this.offsetValue = 0;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Move selection to an existing block and reveal the edge reached in the navigation direction. */
|
|
134
|
+
private selectBlock(index: number, direction: -1 | 1): boolean {
|
|
135
|
+
if (index < 0 || index >= this.extents.length || index === this.selectedIndex) return false;
|
|
136
|
+
this.selectedIndex = index;
|
|
137
|
+
this.revealSelected(direction);
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Scroll the stream to a clamped offset. */
|
|
142
|
+
private scrollTo(offset: number): boolean {
|
|
143
|
+
const next = clamp(offset, this.maxOffset);
|
|
144
|
+
if (next === this.offsetValue) return false;
|
|
145
|
+
this.offsetValue = next;
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Apply a selection and offset pair, reporting whether either changed. */
|
|
150
|
+
private moveTo(index: number, offset: number): boolean {
|
|
151
|
+
const nextIndex = clamp(index, this.extents.length - 1);
|
|
152
|
+
const nextOffset = clamp(offset, this.maxOffset);
|
|
153
|
+
if (nextIndex === this.selectedIndex && nextOffset === this.offsetValue) return false;
|
|
154
|
+
this.selectedIndex = nextIndex;
|
|
155
|
+
this.offsetValue = nextOffset;
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Whether the selected block is fully shown, or intersects the viewport when too tall to fit. */
|
|
160
|
+
private isSelectionAnchored(): boolean {
|
|
161
|
+
const block = this.extents[this.selectedIndex];
|
|
162
|
+
if (block === undefined) return true;
|
|
163
|
+
const end = block.start + block.height;
|
|
164
|
+
if (block.height <= this.visibleCount) {
|
|
165
|
+
return block.start >= this.offsetValue && end <= this.offsetValue + this.visibleCount;
|
|
166
|
+
}
|
|
167
|
+
return block.start < this.offsetValue + this.visibleCount && end > this.offsetValue;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Scroll minimally to show the selected block; oversized blocks expose the approached edge. */
|
|
171
|
+
private revealSelected(direction?: -1 | 1): void {
|
|
172
|
+
const block = this.extents[this.selectedIndex];
|
|
173
|
+
if (block === undefined) return;
|
|
174
|
+
const end = block.start + block.height;
|
|
175
|
+
if (block.height > this.visibleCount) {
|
|
176
|
+
if (direction === -1) this.scrollTo(end - this.visibleCount);
|
|
177
|
+
else if (direction === 1 || !this.isSelectionAnchored()) this.scrollTo(block.start);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (block.start < this.offsetValue) this.scrollTo(block.start);
|
|
181
|
+
else if (end > this.offsetValue + this.visibleCount) this.scrollTo(end - this.visibleCount);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** First block fully inside the viewport, falling back to the first one it intersects. */
|
|
185
|
+
private firstVisibleBlock(offset: number): number {
|
|
186
|
+
const end = offset + this.visibleCount;
|
|
187
|
+
let firstIntersecting: number | undefined;
|
|
188
|
+
for (const [index, block] of this.extents.entries()) {
|
|
189
|
+
const blockEnd = block.start + block.height;
|
|
190
|
+
if (block.start >= offset && blockEnd <= end) return index;
|
|
191
|
+
if (firstIntersecting === undefined && block.start < end && blockEnd > offset) {
|
|
192
|
+
firstIntersecting = index;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return firstIntersecting ?? this.selectedIndex;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Restrict a candidate index or offset to a non-negative range. */
|
|
200
|
+
function clamp(value: number, max: number): number {
|
|
201
|
+
return Math.max(0, Math.min(max, value));
|
|
202
|
+
}
|