pi-context-view 0.2.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 +47 -0
- package/doc/images/context-injections.png +0 -0
- package/doc/images/context-usage.png +0 -0
- package/doc/images/pi-context-view.png +0 -0
- package/package.json +53 -0
- package/src/capture.ts +396 -0
- package/src/command.ts +134 -0
- package/src/index.ts +119 -0
- package/src/measure.ts +356 -0
- package/src/model.ts +201 -0
- package/src/ui/injections-model.ts +239 -0
- package/src/ui/injections-view.ts +369 -0
- package/src/ui/layout.ts +69 -0
- package/src/ui/usage-map.ts +97 -0
- package/src/ui/usage-view.ts +603 -0
- package/src/usage.ts +318 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure presentation model for the Injections view: flattened rows and
|
|
3
|
+
* list navigation/scrolling state. No pi or TUI access — unit-testable.
|
|
4
|
+
*/
|
|
5
|
+
import type { InitialSnapshot, InjectionItem } from "../model.ts";
|
|
6
|
+
|
|
7
|
+
const TERMINAL_STRING_SEQUENCE =
|
|
8
|
+
/(?:\u001B[\]PX^_]|[\u0090\u0098\u009D\u009E\u009F])[\s\S]*?(?:\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
|
+
/** One flattened list row derived from the snapshot hierarchy. */
|
|
14
|
+
export type InjectionRow =
|
|
15
|
+
| {
|
|
16
|
+
readonly kind: "group";
|
|
17
|
+
readonly label: string;
|
|
18
|
+
readonly tokens: number;
|
|
19
|
+
readonly depth: 0;
|
|
20
|
+
}
|
|
21
|
+
| {
|
|
22
|
+
readonly kind: "item";
|
|
23
|
+
readonly label: string;
|
|
24
|
+
readonly tokens: number;
|
|
25
|
+
/** One for items and two for constituent sub-items. */
|
|
26
|
+
readonly depth: 1 | 2;
|
|
27
|
+
/** Stable preview target id from the snapshot. */
|
|
28
|
+
readonly itemId: string;
|
|
29
|
+
}
|
|
30
|
+
| {
|
|
31
|
+
readonly kind: "separator";
|
|
32
|
+
readonly label: "";
|
|
33
|
+
readonly tokens: 0;
|
|
34
|
+
readonly depth: 0;
|
|
35
|
+
}
|
|
36
|
+
| {
|
|
37
|
+
readonly kind: "total";
|
|
38
|
+
readonly label: "TOTAL";
|
|
39
|
+
readonly tokens: number;
|
|
40
|
+
readonly depth: 0;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** Index snapshot items (including sub-items) by id for preview lookup. */
|
|
44
|
+
export function collectItemsById(snapshot: InitialSnapshot): Map<string, InjectionItem> {
|
|
45
|
+
const items = new Map<string, InjectionItem>();
|
|
46
|
+
for (const group of snapshot.groups) {
|
|
47
|
+
for (const item of group.items) {
|
|
48
|
+
items.set(item.id, item);
|
|
49
|
+
for (const child of item.children ?? []) items.set(child.id, child);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return items;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Normalize whitespace and remove terminal control sequences from raw preview text. */
|
|
56
|
+
export function normalizePreviewText(text: string): string {
|
|
57
|
+
return text
|
|
58
|
+
.replaceAll("\r\n", "\n")
|
|
59
|
+
.replaceAll("\r", "\n")
|
|
60
|
+
.replaceAll("\t", " ")
|
|
61
|
+
.replace(TERMINAL_STRING_SEQUENCE, "")
|
|
62
|
+
.replace(TERMINAL_CSI_SEQUENCE, "")
|
|
63
|
+
.replace(TERMINAL_ESCAPE_SEQUENCE, "")
|
|
64
|
+
.replace(TERMINAL_CONTROL_CHARACTER, "");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Sanitize dynamic text for one terminal line and collapse embedded whitespace. */
|
|
68
|
+
export function normalizeInlineText(text: string): string {
|
|
69
|
+
return normalizePreviewText(text).replace(/\s+/g, " ").trim();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Flatten snapshot groups into rows separated from the non-selectable Initial total. */
|
|
73
|
+
export function buildInjectionRows(snapshot: InitialSnapshot): InjectionRow[] {
|
|
74
|
+
const rows: InjectionRow[] = [];
|
|
75
|
+
for (const group of snapshot.groups) {
|
|
76
|
+
rows.push({
|
|
77
|
+
kind: "group",
|
|
78
|
+
label: group.source.label,
|
|
79
|
+
tokens: group.totalTokens,
|
|
80
|
+
depth: 0,
|
|
81
|
+
});
|
|
82
|
+
for (const item of group.items) {
|
|
83
|
+
rows.push({
|
|
84
|
+
kind: "item",
|
|
85
|
+
label: item.label,
|
|
86
|
+
tokens: item.tokens,
|
|
87
|
+
depth: 1,
|
|
88
|
+
itemId: item.id,
|
|
89
|
+
});
|
|
90
|
+
for (const child of item.children ?? []) {
|
|
91
|
+
rows.push({
|
|
92
|
+
kind: "item",
|
|
93
|
+
label: child.label,
|
|
94
|
+
tokens: child.tokens,
|
|
95
|
+
depth: 2,
|
|
96
|
+
itemId: child.id,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
rows.push({ kind: "separator", label: "", tokens: 0, depth: 0 });
|
|
102
|
+
rows.push({ kind: "total", label: "TOTAL", tokens: snapshot.totalTokens, depth: 0 });
|
|
103
|
+
return rows;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Selection and scroll-window state over fixed rows. A trailing summary can
|
|
108
|
+
* participate in scrolling without being included in selection navigation.
|
|
109
|
+
*/
|
|
110
|
+
export class ListNavigator {
|
|
111
|
+
private readonly rowCount: number;
|
|
112
|
+
private readonly selectableRowCount: number;
|
|
113
|
+
private visibleCount: number;
|
|
114
|
+
private selectedIndex = 0;
|
|
115
|
+
private scrollOffset = 0;
|
|
116
|
+
|
|
117
|
+
public constructor(rowCount: number, visibleCount: number, selectableRowCount = rowCount) {
|
|
118
|
+
this.rowCount = Math.max(0, rowCount);
|
|
119
|
+
this.selectableRowCount = Math.min(this.rowCount, Math.max(0, selectableRowCount));
|
|
120
|
+
this.visibleCount = Math.max(1, visibleCount);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
public get selected(): number {
|
|
124
|
+
return this.selectedIndex;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
public get selectedOrdinal(): number {
|
|
128
|
+
return this.selectedIndex;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
public get selectableCount(): number {
|
|
132
|
+
return this.selectableRowCount;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
public get offset(): number {
|
|
136
|
+
return this.scrollOffset;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
public get windowSize(): number {
|
|
140
|
+
return Math.min(this.visibleCount, this.rowCount);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
public get hasOverflow(): boolean {
|
|
144
|
+
return this.rowCount > this.visibleCount;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
public setVisibleCount(count: number): void {
|
|
148
|
+
this.visibleCount = Math.max(1, count);
|
|
149
|
+
this.ensureVisible();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
public moveBy(delta: number): boolean {
|
|
153
|
+
return this.moveTo(this.selectedIndex + delta);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
public moveTo(index: number): boolean {
|
|
157
|
+
if (this.selectableRowCount === 0) return false;
|
|
158
|
+
const next = Math.min(this.selectableRowCount - 1, Math.max(0, index));
|
|
159
|
+
if (next === this.selectedIndex) return false;
|
|
160
|
+
this.selectedIndex = next;
|
|
161
|
+
this.ensureVisible();
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
public page(direction: -1 | 1): boolean {
|
|
166
|
+
return this.moveBy(direction * Math.max(1, this.visibleCount - 1));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private ensureVisible(): void {
|
|
170
|
+
const maxOffset = Math.max(0, this.rowCount - this.visibleCount);
|
|
171
|
+
if (this.selectedIndex < this.scrollOffset) {
|
|
172
|
+
this.scrollOffset = this.selectedIndex;
|
|
173
|
+
} else if (this.selectedIndex >= this.scrollOffset + this.visibleCount) {
|
|
174
|
+
this.scrollOffset = this.selectedIndex - this.visibleCount + 1;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const trailingRows = this.rowCount - this.selectedIndex - 1;
|
|
178
|
+
if (this.selectedIndex === this.selectableRowCount - 1 && trailingRows < this.visibleCount) {
|
|
179
|
+
this.scrollOffset = maxOffset;
|
|
180
|
+
}
|
|
181
|
+
this.scrollOffset = Math.min(maxOffset, Math.max(0, this.scrollOffset));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Scroll-only window over wrapped preview lines. Extent is re-declared each
|
|
187
|
+
* render (wrapping depends on width); the offset is clamped to stay valid.
|
|
188
|
+
*/
|
|
189
|
+
export class PreviewScroller {
|
|
190
|
+
private lineCount = 0;
|
|
191
|
+
private visibleCount = 1;
|
|
192
|
+
private offsetValue = 0;
|
|
193
|
+
|
|
194
|
+
public get offset(): number {
|
|
195
|
+
return this.offsetValue;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
public get windowSize(): number {
|
|
199
|
+
return Math.min(this.visibleCount, this.lineCount);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** One-based final line currently visible, suitable for a progress counter. */
|
|
203
|
+
public get visibleEnd(): number {
|
|
204
|
+
return Math.min(this.lineCount, this.offsetValue + this.windowSize);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
public get hasOverflow(): boolean {
|
|
208
|
+
return this.lineCount > this.visibleCount;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
public get maxOffset(): number {
|
|
212
|
+
return Math.max(0, this.lineCount - this.visibleCount);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
public setExtent(lineCount: number, visibleCount: number): void {
|
|
216
|
+
this.lineCount = Math.max(0, lineCount);
|
|
217
|
+
this.visibleCount = Math.max(1, visibleCount);
|
|
218
|
+
this.offsetValue = Math.min(this.maxOffset, this.offsetValue);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
public scrollBy(delta: number): boolean {
|
|
222
|
+
return this.scrollTo(this.offsetValue + delta);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
public scrollTo(offset: number): boolean {
|
|
226
|
+
const next = Math.min(this.maxOffset, Math.max(0, offset));
|
|
227
|
+
if (next === this.offsetValue) return false;
|
|
228
|
+
this.offsetValue = next;
|
|
229
|
+
return true;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
public page(direction: -1 | 1): boolean {
|
|
233
|
+
return this.scrollBy(direction * Math.max(1, this.visibleCount - 1));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
public reset(): void {
|
|
237
|
+
this.offsetValue = 0;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Focused `/context injections` view: hierarchical Initial snapshot rows and
|
|
3
|
+
* a disabled Runtime roadmap label.
|
|
4
|
+
*/
|
|
5
|
+
import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { Key, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
7
|
+
|
|
8
|
+
import type { InitialSnapshot, InjectionItem } from "../model.ts";
|
|
9
|
+
import {
|
|
10
|
+
buildInjectionRows,
|
|
11
|
+
collectItemsById,
|
|
12
|
+
type InjectionRow,
|
|
13
|
+
ListNavigator,
|
|
14
|
+
normalizeInlineText,
|
|
15
|
+
normalizePreviewText,
|
|
16
|
+
PreviewScroller,
|
|
17
|
+
} from "./injections-model.ts";
|
|
18
|
+
import {
|
|
19
|
+
BODY_INDENT,
|
|
20
|
+
calculateViewport,
|
|
21
|
+
DEFAULT_TERMINAL_ROWS,
|
|
22
|
+
fitLine,
|
|
23
|
+
fitToTerminalHeight,
|
|
24
|
+
hintRow,
|
|
25
|
+
normalizeTerminalRows,
|
|
26
|
+
spreadLine,
|
|
27
|
+
} from "./layout.ts";
|
|
28
|
+
|
|
29
|
+
const LIST_FIXED_LINE_COUNT = 10;
|
|
30
|
+
const PREVIEW_FIXED_LINE_COUNT = 8;
|
|
31
|
+
const LIST_DESCRIPTION = "Injections into the model context for the first turn, with token estimates.";
|
|
32
|
+
|
|
33
|
+
/** Everything the Injections view renders. */
|
|
34
|
+
export interface InjectionsViewInput {
|
|
35
|
+
readonly snapshot: InitialSnapshot;
|
|
36
|
+
readonly degradedReason?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Open the Injections view as a fullscreen overlay. */
|
|
40
|
+
export async function showInjectionsView(
|
|
41
|
+
context: ExtensionCommandContext,
|
|
42
|
+
input: InjectionsViewInput,
|
|
43
|
+
): Promise<void> {
|
|
44
|
+
await context.ui.custom<void>(
|
|
45
|
+
(tui, theme, _keybindings, done) => {
|
|
46
|
+
const view = new InjectionsView(theme, input, done, () => tui.terminal.rows);
|
|
47
|
+
return {
|
|
48
|
+
render: (width: number) => view.render(width),
|
|
49
|
+
invalidate: () => view.invalidate(),
|
|
50
|
+
handleInput: (data: string) => {
|
|
51
|
+
view.handleInput(data);
|
|
52
|
+
tui.requestRender();
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
overlay: true,
|
|
58
|
+
overlayOptions: { width: "100%", maxHeight: "100%", margin: 0 },
|
|
59
|
+
},
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Exported for direct render/input tests; use showInjectionsView from pi code. */
|
|
64
|
+
export class InjectionsView {
|
|
65
|
+
private readonly theme: Theme;
|
|
66
|
+
private readonly input: InjectionsViewInput;
|
|
67
|
+
private readonly done: (result: undefined) => void;
|
|
68
|
+
private readonly getTerminalRows: () => number;
|
|
69
|
+
private readonly rows: InjectionRow[];
|
|
70
|
+
private readonly navigator: ListNavigator;
|
|
71
|
+
private readonly itemsById: Map<string, InjectionItem>;
|
|
72
|
+
private readonly previewScroller = new PreviewScroller();
|
|
73
|
+
private previewItem: InjectionItem | undefined;
|
|
74
|
+
private previewLines: string[] | undefined;
|
|
75
|
+
private previewWrapWidth: number | undefined;
|
|
76
|
+
private cachedWidth: number | undefined;
|
|
77
|
+
private cachedTerminalRows: number | undefined;
|
|
78
|
+
private cachedLines: string[] | undefined;
|
|
79
|
+
|
|
80
|
+
public constructor(
|
|
81
|
+
theme: Theme,
|
|
82
|
+
input: InjectionsViewInput,
|
|
83
|
+
done: (result: undefined) => void,
|
|
84
|
+
getTerminalRows: () => number = () => process.stdout.rows ?? DEFAULT_TERMINAL_ROWS,
|
|
85
|
+
) {
|
|
86
|
+
this.theme = theme;
|
|
87
|
+
this.input = input;
|
|
88
|
+
this.done = done;
|
|
89
|
+
this.getTerminalRows = getTerminalRows;
|
|
90
|
+
this.rows = buildInjectionRows(input.snapshot);
|
|
91
|
+
this.navigator = new ListNavigator(this.rows.length, 1, this.rows.length - 2);
|
|
92
|
+
this.itemsById = collectItemsById(input.snapshot);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
public handleInput(data: string): void {
|
|
96
|
+
if (this.previewItem !== undefined) {
|
|
97
|
+
this.handlePreviewInput(data);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (matchesKey(data, Key.escape) || data === "q") {
|
|
101
|
+
this.done(undefined);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (matchesKey(data, Key.enter)) {
|
|
105
|
+
this.openPreview();
|
|
106
|
+
} else if (matchesKey(data, Key.up)) {
|
|
107
|
+
if (this.navigator.moveBy(-1)) this.clearCache();
|
|
108
|
+
} else if (matchesKey(data, Key.down)) {
|
|
109
|
+
if (this.navigator.moveBy(1)) this.clearCache();
|
|
110
|
+
} else if (matchesKey(data, Key.pageUp)) {
|
|
111
|
+
if (this.navigator.page(-1)) this.clearCache();
|
|
112
|
+
} else if (matchesKey(data, Key.pageDown)) {
|
|
113
|
+
if (this.navigator.page(1)) this.clearCache();
|
|
114
|
+
} else if (matchesKey(data, Key.home)) {
|
|
115
|
+
if (this.navigator.moveTo(0)) this.clearCache();
|
|
116
|
+
} else if (matchesKey(data, Key.end)) {
|
|
117
|
+
if (this.navigator.moveTo(this.rows.length - 1)) this.clearCache();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
public render(width: number): string[] {
|
|
122
|
+
const terminalRows = normalizeTerminalRows(this.getTerminalRows());
|
|
123
|
+
if (
|
|
124
|
+
this.cachedLines !== undefined &&
|
|
125
|
+
this.cachedWidth === width &&
|
|
126
|
+
this.cachedTerminalRows === terminalRows
|
|
127
|
+
) {
|
|
128
|
+
return this.cachedLines;
|
|
129
|
+
}
|
|
130
|
+
if (this.previewItem !== undefined) {
|
|
131
|
+
const lines = this.renderPreview(width, terminalRows, this.previewItem);
|
|
132
|
+
this.cachedWidth = width;
|
|
133
|
+
this.cachedTerminalRows = terminalRows;
|
|
134
|
+
this.cachedLines = lines;
|
|
135
|
+
return lines;
|
|
136
|
+
}
|
|
137
|
+
const theme = this.theme;
|
|
138
|
+
const border = theme.fg("border", "─".repeat(Math.max(1, width)));
|
|
139
|
+
const warningLines = this.degradedWarningLines(width);
|
|
140
|
+
const degradedDescriptionLine = this.degradedDescriptionLine(width);
|
|
141
|
+
const extraLineCount = warningLines.length + (degradedDescriptionLine === undefined ? 0 : 1);
|
|
142
|
+
const viewport = calculateViewport(this.rows.length, terminalRows, LIST_FIXED_LINE_COUNT, extraLineCount);
|
|
143
|
+
this.navigator.setVisibleCount(viewport.visibleCount);
|
|
144
|
+
const lines: string[] = [border, ""];
|
|
145
|
+
|
|
146
|
+
lines.push(this.headerLine(width));
|
|
147
|
+
lines.push("");
|
|
148
|
+
lines.push(...warningLines);
|
|
149
|
+
const listLines = this.listLines(width);
|
|
150
|
+
lines.push(...listLines);
|
|
151
|
+
if (viewport.showScroll) lines.push(this.scrollLine(width));
|
|
152
|
+
const paddingCount = viewport.visibleCount - listLines.length;
|
|
153
|
+
for (let pad = 0; pad < paddingCount; pad++) lines.push("");
|
|
154
|
+
lines.push("");
|
|
155
|
+
lines.push(this.fit(theme.fg("muted", `${BODY_INDENT}${LIST_DESCRIPTION}`), width));
|
|
156
|
+
if (degradedDescriptionLine !== undefined) lines.push(degradedDescriptionLine);
|
|
157
|
+
lines.push("");
|
|
158
|
+
lines.push(
|
|
159
|
+
this.fit(
|
|
160
|
+
hintRow(this.theme, [
|
|
161
|
+
["↑↓", "Navigate"],
|
|
162
|
+
["Enter", "Preview"],
|
|
163
|
+
["Esc", "Close"],
|
|
164
|
+
]),
|
|
165
|
+
width,
|
|
166
|
+
),
|
|
167
|
+
);
|
|
168
|
+
lines.push("", border);
|
|
169
|
+
|
|
170
|
+
const fittedLines = fitToTerminalHeight(lines, terminalRows, border);
|
|
171
|
+
this.cachedWidth = width;
|
|
172
|
+
this.cachedTerminalRows = terminalRows;
|
|
173
|
+
this.cachedLines = fittedLines;
|
|
174
|
+
return fittedLines;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
public invalidate(): void {
|
|
178
|
+
this.clearCache();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// === Preview mode ===
|
|
182
|
+
|
|
183
|
+
private handlePreviewInput(data: string): void {
|
|
184
|
+
if (matchesKey(data, Key.escape) || data === "q") {
|
|
185
|
+
this.closePreview();
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (matchesKey(data, Key.up)) {
|
|
189
|
+
if (this.previewScroller.scrollBy(-1)) this.clearCache();
|
|
190
|
+
} else if (matchesKey(data, Key.down)) {
|
|
191
|
+
if (this.previewScroller.scrollBy(1)) this.clearCache();
|
|
192
|
+
} else if (matchesKey(data, Key.pageUp)) {
|
|
193
|
+
if (this.previewScroller.page(-1)) this.clearCache();
|
|
194
|
+
} else if (matchesKey(data, Key.pageDown)) {
|
|
195
|
+
if (this.previewScroller.page(1)) this.clearCache();
|
|
196
|
+
} else if (matchesKey(data, Key.home)) {
|
|
197
|
+
if (this.previewScroller.scrollTo(0)) this.clearCache();
|
|
198
|
+
} else if (matchesKey(data, Key.end)) {
|
|
199
|
+
if (this.previewScroller.scrollTo(this.previewScroller.maxOffset)) this.clearCache();
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
private openPreview(): void {
|
|
204
|
+
const row = this.rows[this.navigator.selected];
|
|
205
|
+
if (row?.kind !== "item") return;
|
|
206
|
+
const item = this.itemsById.get(row.itemId);
|
|
207
|
+
if (item === undefined) return;
|
|
208
|
+
this.previewItem = item;
|
|
209
|
+
this.previewLines = undefined;
|
|
210
|
+
this.previewWrapWidth = undefined;
|
|
211
|
+
this.previewScroller.reset();
|
|
212
|
+
this.clearCache();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private closePreview(): void {
|
|
216
|
+
this.previewItem = undefined;
|
|
217
|
+
this.previewLines = undefined;
|
|
218
|
+
this.previewWrapWidth = undefined;
|
|
219
|
+
this.clearCache();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
private renderPreview(width: number, terminalRows: number, item: InjectionItem): string[] {
|
|
223
|
+
const theme = this.theme;
|
|
224
|
+
const border = theme.fg("border", "─".repeat(Math.max(1, width)));
|
|
225
|
+
const wrapped = this.getPreviewLines(width, item);
|
|
226
|
+
const viewport = calculateViewport(wrapped.length, terminalRows, PREVIEW_FIXED_LINE_COUNT);
|
|
227
|
+
this.previewScroller.setExtent(wrapped.length, viewport.visibleCount);
|
|
228
|
+
|
|
229
|
+
const lines: string[] = [border, ""];
|
|
230
|
+
const title = theme.fg("accent", theme.bold(normalizeInlineText(item.label)));
|
|
231
|
+
const source = normalizeInlineText(item.source.label);
|
|
232
|
+
const meta = theme.fg("muted", `${source} · ${item.tokens.toLocaleString("en-US")} tokens `);
|
|
233
|
+
lines.push(this.spread(title, meta, width));
|
|
234
|
+
lines.push("");
|
|
235
|
+
|
|
236
|
+
const start = this.previewScroller.offset;
|
|
237
|
+
for (let index = start; index < start + viewport.visibleCount; index++) {
|
|
238
|
+
lines.push(wrapped[index] ?? "");
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (viewport.showScroll) lines.push(this.previewScrollLine(width, wrapped.length));
|
|
242
|
+
lines.push("");
|
|
243
|
+
lines.push(
|
|
244
|
+
this.fit(
|
|
245
|
+
hintRow(this.theme, [
|
|
246
|
+
["↑↓", "Scroll"],
|
|
247
|
+
["PgUp/PgDn", "Page"],
|
|
248
|
+
["Esc", "Back"],
|
|
249
|
+
]),
|
|
250
|
+
width,
|
|
251
|
+
),
|
|
252
|
+
);
|
|
253
|
+
lines.push("", border);
|
|
254
|
+
return fitToTerminalHeight(lines, terminalRows, border);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
private getPreviewLines(width: number, item: InjectionItem): string[] {
|
|
258
|
+
const wrapWidth = Math.max(10, width - BODY_INDENT.length - 1);
|
|
259
|
+
if (this.previewLines !== undefined && this.previewWrapWidth === wrapWidth) return this.previewLines;
|
|
260
|
+
const text = normalizePreviewText(item.text);
|
|
261
|
+
const lines: string[] = [];
|
|
262
|
+
for (const paragraph of text.split("\n")) {
|
|
263
|
+
const wrapped = wrapTextWithAnsi(paragraph, wrapWidth);
|
|
264
|
+
if (wrapped.length === 0) {
|
|
265
|
+
lines.push("");
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
for (const line of wrapped) lines.push(`${BODY_INDENT}${line}`);
|
|
269
|
+
}
|
|
270
|
+
this.previewLines = lines;
|
|
271
|
+
this.previewWrapWidth = wrapWidth;
|
|
272
|
+
return lines;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
private previewScrollLine(width: number, totalLines: number): string {
|
|
276
|
+
if (!this.previewScroller.hasOverflow) return this.fit("", width);
|
|
277
|
+
return this.fit(
|
|
278
|
+
this.theme.fg("dim", `${BODY_INDENT}(${this.previewScroller.visibleEnd}/${totalLines})`),
|
|
279
|
+
width,
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
private headerLine(width: number): string {
|
|
284
|
+
const theme = this.theme;
|
|
285
|
+
const title = theme.fg("accent", theme.bold("Context Injections"));
|
|
286
|
+
const separator = theme.fg("dim", " · ");
|
|
287
|
+
const initial = theme.fg("mdHeading", theme.bold("[INITIAL]"));
|
|
288
|
+
const runtime = theme.fg("dim", "RUNTIME");
|
|
289
|
+
return this.fit(`${title}${separator}${initial} ${runtime}`, width);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
private listLines(width: number): string[] {
|
|
293
|
+
const theme = this.theme;
|
|
294
|
+
const lines: string[] = [];
|
|
295
|
+
const start = this.navigator.offset;
|
|
296
|
+
const end = start + this.navigator.windowSize;
|
|
297
|
+
for (let index = start; index < end; index++) {
|
|
298
|
+
const row = this.rows[index];
|
|
299
|
+
if (row === undefined) break;
|
|
300
|
+
if (row.kind === "separator") {
|
|
301
|
+
lines.push("");
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
const selected = row.kind !== "total" && index === this.navigator.selected;
|
|
305
|
+
// The cursor stays in one fixed column; hierarchy indents after it.
|
|
306
|
+
const marker = selected ? theme.fg("accent", "→ ") : BODY_INDENT;
|
|
307
|
+
const indent = BODY_INDENT.repeat(row.depth);
|
|
308
|
+
const value = row.tokens.toLocaleString("en-US");
|
|
309
|
+
const tokens = row.kind === "total"
|
|
310
|
+
? theme.bold(theme.fg("text", value))
|
|
311
|
+
: theme.fg(selected ? "accent" : "muted", value);
|
|
312
|
+
const labelWidth = Math.max(8, width - indent.length - visibleWidth(tokens) - 6);
|
|
313
|
+
const label = truncateToWidth(normalizeInlineText(row.label), labelWidth, "…");
|
|
314
|
+
lines.push(this.spread(`${marker}${indent}${this.rowLabel(row, label, selected)}`, `${tokens} `, width));
|
|
315
|
+
}
|
|
316
|
+
return lines;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
private rowLabel(row: InjectionRow, label: string, selected: boolean): string {
|
|
320
|
+
const theme = this.theme;
|
|
321
|
+
if (selected) return theme.fg("accent", label);
|
|
322
|
+
if (row.kind === "group" || row.kind === "total") return theme.bold(theme.fg("text", label));
|
|
323
|
+
return theme.fg(row.depth > 1 ? "dim" : "muted", label);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
private scrollLine(width: number): string {
|
|
327
|
+
if (!this.navigator.hasOverflow) return this.fit("", width);
|
|
328
|
+
return this.fit(
|
|
329
|
+
this.theme.fg(
|
|
330
|
+
"dim",
|
|
331
|
+
`${BODY_INDENT}(${this.navigator.selectedOrdinal + 1}/${this.navigator.selectableCount})`,
|
|
332
|
+
),
|
|
333
|
+
width,
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Wrapped degraded-capture reason placed below the dialog header. */
|
|
338
|
+
private degradedWarningLines(width: number): string[] {
|
|
339
|
+
if (this.input.degradedReason === undefined) return [];
|
|
340
|
+
const reason = this.theme.fg(
|
|
341
|
+
"warning",
|
|
342
|
+
`${BODY_INDENT}${normalizeInlineText(this.input.degradedReason)}`,
|
|
343
|
+
);
|
|
344
|
+
return wrapTextWithAnsi(reason, width);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Degraded-capture indicator shown as part of the dialog description. */
|
|
348
|
+
private degradedDescriptionLine(width: number): string | undefined {
|
|
349
|
+
if (this.input.degradedReason === undefined) return undefined;
|
|
350
|
+
return this.fit(
|
|
351
|
+
this.theme.fg("warning", `${BODY_INDENT}[Degraded: pi-native fallback used]`),
|
|
352
|
+
width,
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
private spread(left: string, right: string, width: number): string {
|
|
357
|
+
return spreadLine(left, right, width);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
private fit(line: string, width: number): string {
|
|
361
|
+
return fitLine(line, width);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
private clearCache(): void {
|
|
365
|
+
this.cachedWidth = undefined;
|
|
366
|
+
this.cachedTerminalRows = undefined;
|
|
367
|
+
this.cachedLines = undefined;
|
|
368
|
+
}
|
|
369
|
+
}
|
package/src/ui/layout.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared pi-native fullscreen-view layout helpers: indentation constants,
|
|
3
|
+
* terminal-height viewport math, width fitting, and hint-row formatting used
|
|
4
|
+
* by the Usage and Injections views. Pure string/number logic — no pi access.
|
|
5
|
+
*/
|
|
6
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
8
|
+
|
|
9
|
+
/** Two-space indent for descriptions, counters, hints, and body content. */
|
|
10
|
+
export const BODY_INDENT = " ";
|
|
11
|
+
export const DEFAULT_TERMINAL_ROWS = 24;
|
|
12
|
+
|
|
13
|
+
/** How many content rows fit and whether an overflow indicator is needed. */
|
|
14
|
+
export interface Viewport {
|
|
15
|
+
visibleCount: number;
|
|
16
|
+
showScroll: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Divide terminal rows between content and an overflow indicator. */
|
|
20
|
+
export function calculateViewport(
|
|
21
|
+
itemCount: number,
|
|
22
|
+
terminalRows: number,
|
|
23
|
+
fixedLineCount: number,
|
|
24
|
+
extraLineCount = 0,
|
|
25
|
+
): Viewport {
|
|
26
|
+
const available = Math.max(1, terminalRows - fixedLineCount - extraLineCount);
|
|
27
|
+
const showScroll = itemCount > available && available > 1;
|
|
28
|
+
return {
|
|
29
|
+
visibleCount: Math.max(1, available - (showScroll ? 1 : 0)),
|
|
30
|
+
showScroll,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Keep emergency short-terminal output bounded while preserving both borders. */
|
|
35
|
+
export function fitToTerminalHeight(lines: string[], terminalRows: number, border: string): string[] {
|
|
36
|
+
if (lines.length <= terminalRows) return lines;
|
|
37
|
+
if (terminalRows === 1) return [border];
|
|
38
|
+
return [...lines.slice(0, terminalRows - 1), border];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Normalize an injected terminal-height reading to a usable positive integer. */
|
|
42
|
+
export function normalizeTerminalRows(rows: number): number {
|
|
43
|
+
return Number.isFinite(rows) ? Math.max(1, Math.floor(rows)) : DEFAULT_TERMINAL_ROWS;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Truncate one rendered line to the supplied width. */
|
|
47
|
+
export function fitLine(line: string, width: number): string {
|
|
48
|
+
return truncateToWidth(line, width, "…");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Spread left and right content across the width, truncating the left side on overlap. */
|
|
52
|
+
export function spreadLine(left: string, right: string, width: number): string {
|
|
53
|
+
const gap = width - visibleWidth(left) - visibleWidth(right);
|
|
54
|
+
if (gap < 1) {
|
|
55
|
+
return fitLine(`${truncateToWidth(left, Math.max(1, width - visibleWidth(right) - 2), "…")} ${right}`, width);
|
|
56
|
+
}
|
|
57
|
+
return `${left}${" ".repeat(gap)}${right}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Pi-style hint row: two-space indent, `key description` pairs joined by ` · `. */
|
|
61
|
+
export function hintRow(theme: Theme, hints: ReadonlyArray<readonly [string, string]>): string {
|
|
62
|
+
const separator = theme.fg("dim", " · ");
|
|
63
|
+
return `${BODY_INDENT}${hints.map(([key, description]) => hint(theme, key, description)).join(separator)}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Pi-style hint: dim key, slightly brighter (muted) description. */
|
|
67
|
+
function hint(theme: Theme, key: string, description: string): string {
|
|
68
|
+
return theme.fg("dim", key) + theme.fg("muted", ` ${description}`);
|
|
69
|
+
}
|