pi-extension-utils 0.4.1 → 0.5.1
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/dist/src/pane/chrome.js +16 -10
- package/dist/src/pane/overlay.d.ts +27 -4
- package/dist/src/pane/overlay.js +176 -58
- package/docs/pane-overlay.md +29 -2
- package/examples/pane-overlay.ts +8 -4
- package/package.json +1 -1
package/dist/src/pane/chrome.js
CHANGED
|
@@ -34,7 +34,11 @@ export function pad(s, len) {
|
|
|
34
34
|
return s + " ".repeat(Math.max(0, len - vis));
|
|
35
35
|
}
|
|
36
36
|
function normalizeRenderableText(text) {
|
|
37
|
-
|
|
37
|
+
// Coerce defensively: a non-string here reaches truncateToWidth/text.slice
|
|
38
|
+
// and hard-crashes pi ("text.slice is not a function"). Callers pass values
|
|
39
|
+
// derived from on-disk/agent state that may be out-of-contract.
|
|
40
|
+
const s = typeof text === "string" ? text : text == null ? "" : String(text);
|
|
41
|
+
return s
|
|
38
42
|
.replaceAll("\t", " ")
|
|
39
43
|
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001A\u001C-\u001F\u007F]/g, "");
|
|
40
44
|
}
|
|
@@ -94,12 +98,12 @@ export function titledTopSegment(theme, opts) {
|
|
|
94
98
|
return dash(width);
|
|
95
99
|
const labelColor = opts.labelColor ?? "text";
|
|
96
100
|
const tailColor = opts.tailColor ?? "dim";
|
|
97
|
-
const tailPlain = opts.tailPlain ?? opts.tail ?? "";
|
|
101
|
+
const tailPlain = normalizeRenderableText(opts.tailPlain ?? opts.tail ?? "");
|
|
98
102
|
const tailRendered = opts.tailRendered ?? (opts.tail !== undefined ? theme.fg(tailColor, opts.tail) : "");
|
|
99
103
|
const tailLen = visibleWidth(tailPlain);
|
|
100
104
|
const canShowTail = tailLen > 0 && width - tailLen >= 14;
|
|
101
105
|
const labelBudget = Math.max(0, width - (canShowTail ? tailLen + 6 : 4));
|
|
102
|
-
const labelText = clipText(opts.label ?? "", labelBudget);
|
|
106
|
+
const labelText = clipText(normalizeRenderableText(opts.label ?? ""), labelBudget);
|
|
103
107
|
const labelLen = visibleWidth(labelText);
|
|
104
108
|
if (labelLen === 0)
|
|
105
109
|
return dash(width);
|
|
@@ -118,7 +122,7 @@ export function titledTopSegment(theme, opts) {
|
|
|
118
122
|
return "";
|
|
119
123
|
const labelColor = opts.labelColor ?? "text";
|
|
120
124
|
const tailColor = opts.tailColor ?? "dim";
|
|
121
|
-
const tailPlain = opts.tailPlain ?? opts.tail ?? "";
|
|
125
|
+
const tailPlain = normalizeRenderableText(opts.tailPlain ?? opts.tail ?? "");
|
|
122
126
|
const rawTailRendered = opts.tailRendered ?? (opts.tail !== undefined ? theme.fg(tailColor, opts.tail) : "");
|
|
123
127
|
const tailLen = visibleWidth(tailPlain);
|
|
124
128
|
// With a tail, the minimum chrome is:
|
|
@@ -129,7 +133,7 @@ export function titledTopSegment(theme, opts) {
|
|
|
129
133
|
const labelBudget = tailFits ? Math.max(0, width - tailLen - 7) : Math.max(0, width - 3);
|
|
130
134
|
// Defensive: callers occasionally pass undefined labels for transient/legacy
|
|
131
135
|
// runs that lack agent+mode+label; treat as empty rather than crashing pi.
|
|
132
|
-
const rawLabel = opts.label ?? "";
|
|
136
|
+
const rawLabel = normalizeRenderableText(opts.label ?? "");
|
|
133
137
|
const labelText = truncateToWidth(rawLabel, labelBudget, "").replace(/\u001b\[[0-9;]*m/g, "");
|
|
134
138
|
const labelStyled = opts.labelBold && theme.bold
|
|
135
139
|
? theme.bold(theme.fg(labelColor, labelText))
|
|
@@ -153,11 +157,12 @@ export function titledBottomSegment(theme, width, hint, focused) {
|
|
|
153
157
|
const dash = (n) => theme.fg("dim", "─".repeat(Math.max(0, n)));
|
|
154
158
|
if (width <= 0)
|
|
155
159
|
return "";
|
|
156
|
-
|
|
160
|
+
const safeHint = normalizeRenderableText(hint);
|
|
161
|
+
if (!safeHint)
|
|
157
162
|
return dash(width);
|
|
158
163
|
// Reserve at minimum `─ ` + hint + ` `; truncate hint to fit when narrow so we never overflow.
|
|
159
164
|
const hintBudget = Math.max(0, width - 3);
|
|
160
|
-
const clipped = truncateToWidth(
|
|
165
|
+
const clipped = truncateToWidth(safeHint, hintBudget);
|
|
161
166
|
const clippedLen = visibleWidth(clipped);
|
|
162
167
|
if (clippedLen === 0)
|
|
163
168
|
return dash(width);
|
|
@@ -196,10 +201,11 @@ export function flatRule(theme, title, width, opts) {
|
|
|
196
201
|
if (width <= 0)
|
|
197
202
|
return "";
|
|
198
203
|
const dash = (n) => theme.fg("dim", "─".repeat(Math.max(0, n)));
|
|
199
|
-
|
|
204
|
+
const safeTitle = normalizeRenderableText(title);
|
|
205
|
+
if (!safeTitle)
|
|
200
206
|
return dash(width);
|
|
201
207
|
if (opts?.leadingDashes !== undefined) {
|
|
202
|
-
const label = ` ${
|
|
208
|
+
const label = ` ${safeTitle} `;
|
|
203
209
|
const labelW = visibleWidth(label);
|
|
204
210
|
if (labelW + 4 >= width)
|
|
205
211
|
return dash(width);
|
|
@@ -207,7 +213,7 @@ export function flatRule(theme, title, width, opts) {
|
|
|
207
213
|
const trailing = Math.max(0, width - labelW - leading);
|
|
208
214
|
return `${dash(leading)}${theme.fg("dim", label)}${dash(trailing)}`;
|
|
209
215
|
}
|
|
210
|
-
const clipped = truncateToWidth(
|
|
216
|
+
const clipped = truncateToWidth(safeTitle, Math.max(0, width - 4));
|
|
211
217
|
const clippedLen = visibleWidth(clipped);
|
|
212
218
|
const styled = theme.fg("dim", clipped);
|
|
213
219
|
const trailing = Math.max(0, width - (clippedLen + 3));
|
|
@@ -10,13 +10,30 @@ export interface PaneOverlayContext<T = unknown, Row = unknown> {
|
|
|
10
10
|
mode: "cursor" | "scroll";
|
|
11
11
|
cursor: number;
|
|
12
12
|
scrollOffset: number;
|
|
13
|
+
width: number;
|
|
13
14
|
};
|
|
14
15
|
detail: {
|
|
15
16
|
scrollOffset: number;
|
|
17
|
+
width: number;
|
|
16
18
|
};
|
|
17
19
|
close(result?: T): void;
|
|
18
20
|
requestRender(): void;
|
|
19
21
|
}
|
|
22
|
+
export interface PaneOverlaySeparatorRow {
|
|
23
|
+
kind: "separator";
|
|
24
|
+
label?: string;
|
|
25
|
+
}
|
|
26
|
+
export type PaneOverlayPrimaryRow<Row = unknown> = Row | PaneOverlaySeparatorRow;
|
|
27
|
+
export interface PaneOverlayTitle {
|
|
28
|
+
label: string;
|
|
29
|
+
tail?: string;
|
|
30
|
+
tailRendered?: string;
|
|
31
|
+
tailPlain?: string;
|
|
32
|
+
labelColor?: string;
|
|
33
|
+
tailColor?: string;
|
|
34
|
+
labelBold?: boolean;
|
|
35
|
+
}
|
|
36
|
+
type PaneOverlayTitleValue<Row> = string | PaneOverlayTitle | ((ctx: PaneOverlayContext<unknown, Row>) => string | PaneOverlayTitle);
|
|
20
37
|
export interface PaneOverlayCustomAction<T = unknown, Row = unknown> {
|
|
21
38
|
keys: string | readonly string[];
|
|
22
39
|
label: string;
|
|
@@ -26,16 +43,20 @@ export interface PaneOverlayCustomAction<T = unknown, Row = unknown> {
|
|
|
26
43
|
}
|
|
27
44
|
export interface PrimaryPaneOptions<Row = unknown> {
|
|
28
45
|
mode?: "cursor" | "scroll";
|
|
29
|
-
rows: Row[] | ((ctx: PaneOverlayContext<unknown, Row>) => Row[]);
|
|
30
|
-
renderRow?(row: Row, ctx: PaneOverlayContext<unknown, Row
|
|
46
|
+
rows: PaneOverlayPrimaryRow<Row>[] | ((ctx: PaneOverlayContext<unknown, Row>) => PaneOverlayPrimaryRow<Row>[]);
|
|
47
|
+
renderRow?(row: Row, ctx: PaneOverlayContext<unknown, Row>, width: number): string;
|
|
31
48
|
selectionKey?(row: Row, index: number): string;
|
|
49
|
+
initialSelectionKey?: string;
|
|
50
|
+
initialIndex?: number;
|
|
32
51
|
onSelectionChange?(row: Row | undefined, index: number, key: string, ctx: PaneOverlayContext<unknown, Row>): void;
|
|
33
|
-
title?:
|
|
52
|
+
title?: PaneOverlayTitleValue<Row>;
|
|
53
|
+
info?: string[] | ((ctx: PaneOverlayContext<unknown, Row>) => string[]);
|
|
54
|
+
infoTitle?: string | ((ctx: PaneOverlayContext<unknown, Row>) => string);
|
|
34
55
|
footer?: string | ((ctx: PaneOverlayContext<unknown, Row>) => string);
|
|
35
56
|
}
|
|
36
57
|
export interface DetailPaneOptions<Row = unknown> {
|
|
37
58
|
rows(ctx: PaneOverlayContext<unknown, Row>): string[];
|
|
38
|
-
title?:
|
|
59
|
+
title?: PaneOverlayTitleValue<Row>;
|
|
39
60
|
footer?: string | ((ctx: PaneOverlayContext<unknown, Row>) => string);
|
|
40
61
|
}
|
|
41
62
|
export interface PaneOverlaySplitOptions {
|
|
@@ -66,6 +87,7 @@ export interface PaneOverlayOptions<T = unknown, Row = unknown> {
|
|
|
66
87
|
collapse?: PaneCollapseOptions;
|
|
67
88
|
perSelectionScroll?: boolean;
|
|
68
89
|
stickyBottom?: boolean;
|
|
90
|
+
onRender?(ctx: PaneOverlayContext<T, Row>): void;
|
|
69
91
|
}
|
|
70
92
|
export interface PaneOverlayComponent {
|
|
71
93
|
render(width: number): string[];
|
|
@@ -73,3 +95,4 @@ export interface PaneOverlayComponent {
|
|
|
73
95
|
dispose(): void;
|
|
74
96
|
}
|
|
75
97
|
export declare function paneOverlay<T = undefined, Row = unknown>(options: PaneOverlayOptions<T, Row>): FullscreenComponentFactory<T>;
|
|
98
|
+
export {};
|
package/dist/src/pane/overlay.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { dispatchNavKeys } from "./key-dispatch.js";
|
|
2
|
-
import { computeFixedSidebarLayout, computeSplitPaneLayout,
|
|
2
|
+
import { computeFixedSidebarLayout, computeSplitPaneLayout, endScrollOffset, ensureCursorVisible, homeScrollOffset, moveScrollOffset, pageScrollOffset, resizeSplitPane, } from "./state.js";
|
|
3
3
|
import { boxRow, clipStyled, flatRule, formatScrollInfo, padRight, renderKeyRow, titledBottomSegment, titledTopSegment, } from "./chrome.js";
|
|
4
4
|
import { matchesKey } from "@earendil-works/pi-tui";
|
|
5
5
|
const DEFAULT_CLOSE_KEYS = ["escape", "ctrl+c", "q"];
|
|
@@ -28,6 +28,51 @@ function defaultRenderRow(row) {
|
|
|
28
28
|
function decorateRow(text, theme) {
|
|
29
29
|
return theme.bold ? theme.bold(theme.fg("accent", text)) : theme.fg("accent", text);
|
|
30
30
|
}
|
|
31
|
+
function isSeparatorRow(row) {
|
|
32
|
+
return typeof row === "object" && row !== null && row.kind === "separator";
|
|
33
|
+
}
|
|
34
|
+
function selectableIndexes(rows) {
|
|
35
|
+
const indexes = [];
|
|
36
|
+
for (let index = 0; index < rows.length; index++) {
|
|
37
|
+
if (!isSeparatorRow(rows[index]))
|
|
38
|
+
indexes.push(index);
|
|
39
|
+
}
|
|
40
|
+
return indexes;
|
|
41
|
+
}
|
|
42
|
+
function nearestSelectableIndex(rows, start) {
|
|
43
|
+
if (rows.length === 0)
|
|
44
|
+
return 0;
|
|
45
|
+
const clamped = Math.max(0, Math.min(start, rows.length - 1));
|
|
46
|
+
if (!isSeparatorRow(rows[clamped]))
|
|
47
|
+
return clamped;
|
|
48
|
+
for (let offset = 1; offset < rows.length; offset++) {
|
|
49
|
+
const after = clamped + offset;
|
|
50
|
+
if (after < rows.length && !isSeparatorRow(rows[after]))
|
|
51
|
+
return after;
|
|
52
|
+
const before = clamped - offset;
|
|
53
|
+
if (before >= 0 && !isSeparatorRow(rows[before]))
|
|
54
|
+
return before;
|
|
55
|
+
}
|
|
56
|
+
return 0;
|
|
57
|
+
}
|
|
58
|
+
function titleOptions(title, focused) {
|
|
59
|
+
if (typeof title === "object" && title !== null) {
|
|
60
|
+
return {
|
|
61
|
+
label: title.label,
|
|
62
|
+
tail: title.tail,
|
|
63
|
+
tailRendered: title.tailRendered,
|
|
64
|
+
tailPlain: title.tailPlain,
|
|
65
|
+
labelColor: title.labelColor ?? (focused ? "accent" : "text"),
|
|
66
|
+
tailColor: title.tailColor,
|
|
67
|
+
labelBold: title.labelBold ?? focused,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
label: title ?? "",
|
|
72
|
+
labelColor: focused ? "accent" : "text",
|
|
73
|
+
labelBold: focused,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
31
76
|
export function paneOverlay(options) {
|
|
32
77
|
const split = options.split ?? {};
|
|
33
78
|
const closeKeys = options.closeKeys ?? DEFAULT_CLOSE_KEYS;
|
|
@@ -47,6 +92,7 @@ export function paneOverlay(options) {
|
|
|
47
92
|
let leftFraction = split.initialFraction ?? 0.5;
|
|
48
93
|
let lastRenderWidth = 80;
|
|
49
94
|
let lastSelectedKey;
|
|
95
|
+
let initialSelectionApplied = false;
|
|
50
96
|
const primaryState = { cursor: 0, scrollOffset: 0 };
|
|
51
97
|
const detailState = { scrollOffset: 0, sticky: stickyBottom };
|
|
52
98
|
const perSelectionOffsets = new Map();
|
|
@@ -59,7 +105,7 @@ export function paneOverlay(options) {
|
|
|
59
105
|
finished = true;
|
|
60
106
|
done(result);
|
|
61
107
|
};
|
|
62
|
-
const makeContext = (selectedIndex, selectedRow, selectedKey,
|
|
108
|
+
const makeContext = (selectedIndex, selectedRow, selectedKey, primaryWidth = 0, detailWidth = 0) => ({
|
|
63
109
|
tui,
|
|
64
110
|
primaryFocus: focus === "primary",
|
|
65
111
|
detailFocus: focus === "detail",
|
|
@@ -70,8 +116,9 @@ export function paneOverlay(options) {
|
|
|
70
116
|
mode: options.primary.mode ?? "scroll",
|
|
71
117
|
cursor: primaryState.cursor,
|
|
72
118
|
scrollOffset: primaryState.scrollOffset,
|
|
119
|
+
width: primaryWidth,
|
|
73
120
|
},
|
|
74
|
-
detail: { scrollOffset: detailState.scrollOffset },
|
|
121
|
+
detail: { scrollOffset: detailState.scrollOffset, width: detailWidth },
|
|
75
122
|
close: (result) => finish(result),
|
|
76
123
|
requestRender,
|
|
77
124
|
});
|
|
@@ -97,6 +144,60 @@ export function paneOverlay(options) {
|
|
|
97
144
|
fractionBasis: split.fractionBasis,
|
|
98
145
|
});
|
|
99
146
|
};
|
|
147
|
+
const currentWidths = () => {
|
|
148
|
+
const layout = getLayout(lastRenderWidth);
|
|
149
|
+
return { primaryWidth: layout.leftWidth, detailWidth: layout.rightWidth };
|
|
150
|
+
};
|
|
151
|
+
const getPrimaryRows = (ctx) => {
|
|
152
|
+
return resolveValue(options.primary.rows, ctx) ?? [];
|
|
153
|
+
};
|
|
154
|
+
const selectionKeyFor = (row, index) => {
|
|
155
|
+
return (options.primary.selectionKey ?? ((_row, rowIndex) => String(rowIndex)))(row, index);
|
|
156
|
+
};
|
|
157
|
+
const applyInitialSelection = (rows) => {
|
|
158
|
+
if (initialSelectionApplied || rows.length === 0)
|
|
159
|
+
return;
|
|
160
|
+
let targetIndex = -1;
|
|
161
|
+
if (options.primary.initialSelectionKey !== undefined) {
|
|
162
|
+
targetIndex = rows.findIndex((row, index) => !isSeparatorRow(row) && selectionKeyFor(row, index) === options.primary.initialSelectionKey);
|
|
163
|
+
}
|
|
164
|
+
if (targetIndex < 0 && options.primary.initialIndex !== undefined) {
|
|
165
|
+
targetIndex = options.primary.initialIndex;
|
|
166
|
+
}
|
|
167
|
+
if (targetIndex < 0)
|
|
168
|
+
return;
|
|
169
|
+
initialSelectionApplied = true;
|
|
170
|
+
const selectableIndex = nearestSelectableIndex(rows, targetIndex);
|
|
171
|
+
primaryState.cursor = selectableIndex;
|
|
172
|
+
primaryState.scrollOffset = selectableIndex;
|
|
173
|
+
};
|
|
174
|
+
const computeSelectionFromRows = (primaryRows, bodyHeight) => {
|
|
175
|
+
applyInitialSelection(primaryRows);
|
|
176
|
+
const primaryMode = options.primary.mode ?? "scroll";
|
|
177
|
+
let selectedIndex = 0;
|
|
178
|
+
if (primaryMode === "cursor") {
|
|
179
|
+
const viewport = ensureCursorVisible({
|
|
180
|
+
cursor: nearestSelectableIndex(primaryRows, primaryState.cursor),
|
|
181
|
+
scroll: primaryState.scrollOffset,
|
|
182
|
+
itemCount: primaryRows.length,
|
|
183
|
+
viewportHeight: Math.max(1, bodyHeight),
|
|
184
|
+
});
|
|
185
|
+
primaryState.cursor = nearestSelectableIndex(primaryRows, viewport.cursor);
|
|
186
|
+
primaryState.scrollOffset = viewport.scroll;
|
|
187
|
+
selectedIndex = primaryState.cursor;
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
primaryState.scrollOffset = Math.min(primaryState.scrollOffset, Math.max(0, primaryRows.length - 1));
|
|
191
|
+
selectedIndex = nearestSelectableIndex(primaryRows, primaryState.scrollOffset);
|
|
192
|
+
primaryState.scrollOffset = selectedIndex;
|
|
193
|
+
}
|
|
194
|
+
if (primaryRows.length === 0 || selectableIndexes(primaryRows).length === 0)
|
|
195
|
+
selectedIndex = 0;
|
|
196
|
+
const selectedCandidate = primaryRows[selectedIndex];
|
|
197
|
+
const selectedRow = !isSeparatorRow(selectedCandidate) ? selectedCandidate : undefined;
|
|
198
|
+
const selectedKey = selectedRow !== undefined ? selectionKeyFor(selectedRow, selectedIndex) : String(selectedIndex);
|
|
199
|
+
return { primaryRows, selectedIndex, selectedRow, selectedKey };
|
|
200
|
+
};
|
|
100
201
|
const resize = (direction) => {
|
|
101
202
|
if (collapsed)
|
|
102
203
|
return;
|
|
@@ -129,16 +230,27 @@ export function paneOverlay(options) {
|
|
|
129
230
|
}
|
|
130
231
|
return state;
|
|
131
232
|
};
|
|
132
|
-
const
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
233
|
+
const primarySections = (bodyHeight, legendLineCount, infoLineCount) => {
|
|
234
|
+
const legendBlockHeight = legendPlacement === "primary" && legendLineCount > 0 ? legendLineCount + 1 : 0;
|
|
235
|
+
const availableBeforeLegend = Math.max(0, bodyHeight - legendBlockHeight);
|
|
236
|
+
const infoVisibleCount = infoLineCount > 0
|
|
237
|
+
? Math.min(infoLineCount, Math.max(0, availableBeforeLegend - 2))
|
|
238
|
+
: 0;
|
|
239
|
+
const infoBlockHeight = infoVisibleCount > 0 ? infoVisibleCount + 1 : 0;
|
|
240
|
+
return {
|
|
241
|
+
primaryHeight: Math.max(0, availableBeforeLegend - infoBlockHeight),
|
|
242
|
+
detailHeight: bodyHeight,
|
|
243
|
+
infoVisibleCount,
|
|
244
|
+
};
|
|
137
245
|
};
|
|
138
246
|
const pageSizeHalf = (height) => Math.max(1, Math.floor(height / 2));
|
|
139
247
|
const withPrimaryViewport = (bodyHeight, legendLineCount) => {
|
|
140
|
-
const
|
|
141
|
-
const
|
|
248
|
+
const { primaryWidth, detailWidth } = currentWidths();
|
|
249
|
+
const primaryRows = getPrimaryRows(makeContext(0, undefined, "", primaryWidth, detailWidth));
|
|
250
|
+
const selection = computeSelectionFromRows(primaryRows, bodyHeight);
|
|
251
|
+
const ctx = makeContext(selection.selectedIndex, selection.selectedRow, selection.selectedKey, primaryWidth, detailWidth);
|
|
252
|
+
const infoLines = resolveValue(options.primary.info, ctx) ?? [];
|
|
253
|
+
const viewportHeight = primarySections(bodyHeight, legendLineCount, infoLines.length).primaryHeight;
|
|
142
254
|
return { primaryRows, viewportHeight: Math.max(1, viewportHeight) };
|
|
143
255
|
};
|
|
144
256
|
const applyPrimaryNav = (bodyHeight, legendLineCount, mutator) => {
|
|
@@ -148,8 +260,13 @@ export function paneOverlay(options) {
|
|
|
148
260
|
const movePrimary = (delta, bodyHeight, legendLineCount) => {
|
|
149
261
|
applyPrimaryNav(bodyHeight, legendLineCount, (state, rows, viewportHeight) => {
|
|
150
262
|
if ((options.primary.mode ?? "scroll") === "cursor") {
|
|
151
|
-
const
|
|
152
|
-
|
|
263
|
+
const indexes = selectableIndexes(rows);
|
|
264
|
+
if (indexes.length === 0)
|
|
265
|
+
return;
|
|
266
|
+
const currentIndex = nearestSelectableIndex(rows, state.cursor);
|
|
267
|
+
const currentOrdinal = Math.max(0, indexes.indexOf(currentIndex));
|
|
268
|
+
state.cursor = indexes[Math.max(0, Math.min(indexes.length - 1, currentOrdinal + delta))] ?? currentIndex;
|
|
269
|
+
const next = ensureCursorVisible({ cursor: state.cursor, scroll: state.scrollOffset, itemCount: rows.length, viewportHeight });
|
|
153
270
|
state.scrollOffset = next.scroll;
|
|
154
271
|
}
|
|
155
272
|
else {
|
|
@@ -161,8 +278,13 @@ export function paneOverlay(options) {
|
|
|
161
278
|
applyPrimaryNav(bodyHeight, legendLineCount, (state, rows, viewportHeight) => {
|
|
162
279
|
const pageSize = pageSizeHalf(viewportHeight);
|
|
163
280
|
if ((options.primary.mode ?? "scroll") === "cursor") {
|
|
164
|
-
const
|
|
165
|
-
|
|
281
|
+
const indexes = selectableIndexes(rows);
|
|
282
|
+
if (indexes.length === 0)
|
|
283
|
+
return;
|
|
284
|
+
const currentIndex = nearestSelectableIndex(rows, state.cursor);
|
|
285
|
+
const currentOrdinal = Math.max(0, indexes.indexOf(currentIndex));
|
|
286
|
+
state.cursor = indexes[Math.max(0, Math.min(indexes.length - 1, currentOrdinal + delta * pageSize))] ?? currentIndex;
|
|
287
|
+
const next = ensureCursorVisible({ cursor: state.cursor, scroll: state.scrollOffset, itemCount: rows.length, viewportHeight });
|
|
166
288
|
state.scrollOffset = next.scroll;
|
|
167
289
|
}
|
|
168
290
|
else {
|
|
@@ -173,7 +295,10 @@ export function paneOverlay(options) {
|
|
|
173
295
|
const homePrimary = (bodyHeight, legendLineCount) => {
|
|
174
296
|
applyPrimaryNav(bodyHeight, legendLineCount, (state, rows, viewportHeight) => {
|
|
175
297
|
if ((options.primary.mode ?? "scroll") === "cursor") {
|
|
176
|
-
const
|
|
298
|
+
const indexes = selectableIndexes(rows);
|
|
299
|
+
if (indexes.length === 0)
|
|
300
|
+
return;
|
|
301
|
+
const next = ensureCursorVisible({ cursor: indexes[0] ?? 0, scroll: state.scrollOffset, itemCount: rows.length, viewportHeight });
|
|
177
302
|
state.cursor = next.cursor;
|
|
178
303
|
state.scrollOffset = next.scroll;
|
|
179
304
|
}
|
|
@@ -185,7 +310,10 @@ export function paneOverlay(options) {
|
|
|
185
310
|
const endPrimary = (bodyHeight, legendLineCount) => {
|
|
186
311
|
applyPrimaryNav(bodyHeight, legendLineCount, (state, rows, viewportHeight) => {
|
|
187
312
|
if ((options.primary.mode ?? "scroll") === "cursor") {
|
|
188
|
-
const
|
|
313
|
+
const indexes = selectableIndexes(rows);
|
|
314
|
+
if (indexes.length === 0)
|
|
315
|
+
return;
|
|
316
|
+
const next = ensureCursorVisible({ cursor: indexes[indexes.length - 1] ?? 0, scroll: state.scrollOffset, itemCount: rows.length, viewportHeight });
|
|
189
317
|
state.cursor = next.cursor;
|
|
190
318
|
state.scrollOffset = next.scroll;
|
|
191
319
|
}
|
|
@@ -229,29 +357,6 @@ export function paneOverlay(options) {
|
|
|
229
357
|
const keyWidth = Math.min(11, Math.max(3, Math.floor(totalWidth / 4)));
|
|
230
358
|
return entries.map((entry) => chromeTheme.fg("dim", renderKeyRow(entry.key, entry.label, totalWidth, keyWidth)));
|
|
231
359
|
};
|
|
232
|
-
const computeSelection = (bodyHeight) => {
|
|
233
|
-
const provisionalCtx = makeContext(0, undefined, "", [], []);
|
|
234
|
-
const primaryRows = resolveValue(options.primary.rows, provisionalCtx);
|
|
235
|
-
const primaryMode = options.primary.mode ?? "scroll";
|
|
236
|
-
let selectedIndex = 0;
|
|
237
|
-
if (primaryMode === "cursor") {
|
|
238
|
-
selectedIndex = ensureCursorVisible({
|
|
239
|
-
cursor: primaryState.cursor,
|
|
240
|
-
scroll: primaryState.scrollOffset,
|
|
241
|
-
itemCount: primaryRows.length,
|
|
242
|
-
viewportHeight: Math.max(1, bodyHeight),
|
|
243
|
-
}).cursor;
|
|
244
|
-
}
|
|
245
|
-
else {
|
|
246
|
-
selectedIndex = Math.min(primaryState.scrollOffset, Math.max(0, primaryRows.length - 1));
|
|
247
|
-
}
|
|
248
|
-
if (primaryRows.length === 0)
|
|
249
|
-
selectedIndex = 0;
|
|
250
|
-
const selectedRow = primaryRows[selectedIndex];
|
|
251
|
-
const selectionKeyFn = options.primary.selectionKey ?? ((_row, index) => String(index));
|
|
252
|
-
const selectedKey = selectedRow !== undefined ? selectionKeyFn(selectedRow, selectedIndex) : String(selectedIndex);
|
|
253
|
-
return { primaryRows, selectedIndex, selectedRow, selectedKey };
|
|
254
|
-
};
|
|
255
360
|
const component = {
|
|
256
361
|
dispose() {
|
|
257
362
|
// no-op is acceptable
|
|
@@ -263,9 +368,11 @@ export function paneOverlay(options) {
|
|
|
263
368
|
const layout = getLayout(totalWidth);
|
|
264
369
|
const primaryWidth = layout.leftWidth;
|
|
265
370
|
const detailWidth = layout.rightWidth;
|
|
266
|
-
const
|
|
267
|
-
const
|
|
268
|
-
const ctx = makeContext(selectedIndex, selectedRow, selectedKey,
|
|
371
|
+
const primaryRows = getPrimaryRows(makeContext(0, undefined, "", primaryWidth, detailWidth));
|
|
372
|
+
const { selectedIndex, selectedRow, selectedKey } = computeSelectionFromRows(primaryRows, bodyHeight);
|
|
373
|
+
const ctx = makeContext(selectedIndex, selectedRow, selectedKey, primaryWidth, detailWidth);
|
|
374
|
+
options.onRender?.(ctx);
|
|
375
|
+
const detailRows = options.detail.rows(ctx) ?? [];
|
|
269
376
|
if (selectedKey !== lastSelectedKey) {
|
|
270
377
|
lastSelectedKey = selectedKey;
|
|
271
378
|
if (options.primary.onSelectionChange) {
|
|
@@ -283,7 +390,9 @@ export function paneOverlay(options) {
|
|
|
283
390
|
ensureFocusDetailWhenCollapsed();
|
|
284
391
|
const entries = legendEntries(ctx);
|
|
285
392
|
const legendLines = renderLegendLines(entries, totalWidth);
|
|
286
|
-
const
|
|
393
|
+
const infoLines = resolveValue(options.primary.info, ctx) ?? [];
|
|
394
|
+
const infoTitle = resolveValue(options.primary.infoTitle, ctx) ?? "info";
|
|
395
|
+
const { primaryHeight, detailHeight, infoVisibleCount } = primarySections(bodyHeight, legendLines.length, infoLines.length);
|
|
287
396
|
// Align primary viewport.
|
|
288
397
|
if ((options.primary.mode ?? "scroll") === "cursor") {
|
|
289
398
|
const viewport = ensureCursorVisible({
|
|
@@ -315,15 +424,11 @@ export function paneOverlay(options) {
|
|
|
315
424
|
const detailTitle = resolveValue(options.detail.title, ctx);
|
|
316
425
|
const topPrimary = titledTopSegment(chromeTheme, {
|
|
317
426
|
width: primaryWidth,
|
|
318
|
-
|
|
319
|
-
labelColor: focus === "primary" ? "accent" : "text",
|
|
320
|
-
labelBold: focus === "primary",
|
|
427
|
+
...titleOptions(primaryTitle, focus === "primary"),
|
|
321
428
|
});
|
|
322
429
|
const topDetail = titledTopSegment(chromeTheme, {
|
|
323
430
|
width: detailWidth,
|
|
324
|
-
|
|
325
|
-
labelColor: focus === "detail" ? "accent" : "text",
|
|
326
|
-
labelBold: focus === "detail",
|
|
431
|
+
...titleOptions(detailTitle, focus === "detail"),
|
|
327
432
|
});
|
|
328
433
|
const corner = (glyph) => chromeTheme.fg("dim", glyph);
|
|
329
434
|
const topBorder = primaryWidth === 0
|
|
@@ -334,14 +439,17 @@ export function paneOverlay(options) {
|
|
|
334
439
|
.slice(primaryState.scrollOffset, primaryState.scrollOffset + primaryHeight)
|
|
335
440
|
.map((row, index) => {
|
|
336
441
|
const absoluteIndex = primaryState.scrollOffset + index;
|
|
337
|
-
|
|
442
|
+
if (isSeparatorRow(row))
|
|
443
|
+
return flatRule(chromeTheme, row.label ?? "", primaryWidth);
|
|
444
|
+
const text = renderFn(row, ctx, primaryWidth);
|
|
338
445
|
const selected = (options.primary.mode ?? "scroll") === "cursor" && absoluteIndex === primaryState.cursor && focus === "primary";
|
|
339
446
|
return selected ? decorateRow(text, chromeTheme) : text;
|
|
340
447
|
});
|
|
341
448
|
const detailVisible = detailRows.slice(detailState.scrollOffset, detailState.scrollOffset + detailHeight);
|
|
449
|
+
const selectable = selectableIndexes(primaryRows);
|
|
342
450
|
const primaryFooterText = resolveValue(options.primary.footer, ctx)
|
|
343
|
-
?? ((options.primary.mode ?? "scroll") === "cursor" &&
|
|
344
|
-
? `${primaryState.cursor + 1}/${
|
|
451
|
+
?? ((options.primary.mode ?? "scroll") === "cursor" && selectable.length > 0
|
|
452
|
+
? `${Math.max(0, selectable.indexOf(primaryState.cursor)) + 1}/${selectable.length}`
|
|
345
453
|
: primaryRows.length > primaryHeight
|
|
346
454
|
? formatScrollInfo(primaryState.scrollOffset, Math.max(0, primaryRows.length - primaryHeight), { style: "position" })
|
|
347
455
|
: "");
|
|
@@ -355,13 +463,21 @@ export function paneOverlay(options) {
|
|
|
355
463
|
? corner("╰") + bottomDetail + corner("╯")
|
|
356
464
|
: corner("╰") + bottomPrimary + corner("┴") + bottomDetail + corner("╯");
|
|
357
465
|
const bodyLines = [];
|
|
466
|
+
const infoStart = primaryHeight;
|
|
467
|
+
const legendStart = primaryHeight + (infoVisibleCount > 0 ? infoVisibleCount + 1 : 0);
|
|
358
468
|
for (let row = 0; row < bodyHeight; row++) {
|
|
359
469
|
let primaryCell = primaryVisible[row] ?? "";
|
|
360
|
-
if (
|
|
470
|
+
if (infoVisibleCount > 0 && row === infoStart) {
|
|
471
|
+
primaryCell = flatRule(chromeTheme, infoTitle, primaryWidth);
|
|
472
|
+
}
|
|
473
|
+
else if (infoVisibleCount > 0 && row > infoStart && row < legendStart) {
|
|
474
|
+
primaryCell = infoLines[row - infoStart - 1] ?? "";
|
|
475
|
+
}
|
|
476
|
+
else if (legendPlacement === "primary" && row === legendStart && legendLines.length > 0) {
|
|
361
477
|
primaryCell = flatRule(chromeTheme, collapse?.label ?? "actions", primaryWidth);
|
|
362
478
|
}
|
|
363
|
-
if (legendPlacement === "primary" && row >
|
|
364
|
-
const legendIndex = row -
|
|
479
|
+
if (legendPlacement === "primary" && row > legendStart) {
|
|
480
|
+
const legendIndex = row - legendStart - 1;
|
|
365
481
|
primaryCell = legendIndex < legendLines.length ? padRight(legendLines[legendIndex], primaryWidth) : "";
|
|
366
482
|
}
|
|
367
483
|
const detailCell = detailVisible[row] ?? "";
|
|
@@ -387,9 +503,11 @@ export function paneOverlay(options) {
|
|
|
387
503
|
finish(options.closeResult);
|
|
388
504
|
return;
|
|
389
505
|
}
|
|
390
|
-
const {
|
|
391
|
-
const
|
|
392
|
-
const
|
|
506
|
+
const { primaryWidth, detailWidth } = currentWidths();
|
|
507
|
+
const primaryRows = getPrimaryRows(makeContext(0, undefined, "", primaryWidth, detailWidth));
|
|
508
|
+
const { selectedIndex, selectedRow, selectedKey } = computeSelectionFromRows(primaryRows, bodyHeight);
|
|
509
|
+
const ctx = makeContext(selectedIndex, selectedRow, selectedKey, primaryWidth, detailWidth);
|
|
510
|
+
const detailRows = options.detail.rows(ctx) ?? [];
|
|
393
511
|
for (const action of customActions) {
|
|
394
512
|
if (action.when && !action.when(ctx))
|
|
395
513
|
continue;
|
package/docs/pane-overlay.md
CHANGED
|
@@ -19,12 +19,16 @@ await client.ui.fullscreen(
|
|
|
19
19
|
mode: "cursor",
|
|
20
20
|
rows: () => runs,
|
|
21
21
|
selectionKey: (run) => run.id,
|
|
22
|
-
|
|
22
|
+
initialSelectionKey: currentRunId,
|
|
23
|
+
renderRow: (run, ctx, width) => width > 40 ? `${run.label} ${run.status}` : run.label,
|
|
24
|
+
infoTitle: "selected",
|
|
25
|
+
info: (ctx) => ctx.selectedRow ? [`status: ${ctx.selectedRow.status}`] : [],
|
|
23
26
|
},
|
|
24
27
|
detail: {
|
|
25
|
-
title: (ctx) => ctx.selectedRow?.label ?? "Details",
|
|
28
|
+
title: (ctx) => ({ label: ctx.selectedRow?.label ?? "Details", tail: "VAL" }),
|
|
26
29
|
rows: (ctx) => renderDetails(ctx.selectedRow),
|
|
27
30
|
},
|
|
31
|
+
onRender: (ctx) => tickFlashMessage(),
|
|
28
32
|
customActions: [
|
|
29
33
|
{
|
|
30
34
|
keys: ["enter", "o"],
|
|
@@ -62,6 +66,29 @@ await client.ui.fullscreen(
|
|
|
62
66
|
| `collapse` | Optional primary/sidebar collapse key |
|
|
63
67
|
| `perSelectionScroll` | Keep separate detail scroll per selected key |
|
|
64
68
|
| `stickyBottom` | Detail starts/follows at bottom until user scrolls |
|
|
69
|
+
| `onRender` | Per-frame hook for external transient UI state |
|
|
70
|
+
|
|
71
|
+
## Primary pane extras
|
|
72
|
+
|
|
73
|
+
The primary pane can render more than a flat selectable list:
|
|
74
|
+
|
|
75
|
+
- `renderRow(row, ctx, width)` receives the current primary pane width. The same value is also exposed as `ctx.primary.width`; `ctx.detail.width` exposes the detail pane width.
|
|
76
|
+
- `primary.info` renders a selected-row-derived block below the list and above a primary-placed legend. Use `primary.infoTitle` to label its divider.
|
|
77
|
+
- `rows` may include `{ kind: "separator", label: "done" }`. Separator rows render as inline rules and are skipped by cursor movement and selection keys.
|
|
78
|
+
- `initialSelectionKey` or `initialIndex` controls the first selected row when the overlay opens. `initialSelectionKey` applies on the first frame where that key is present, including async-arriving rows.
|
|
79
|
+
|
|
80
|
+
Pane titles may be strings or structured objects forwarded to `titledTopSegment`:
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
detail: {
|
|
84
|
+
title: (ctx) => ({
|
|
85
|
+
label: ctx.selectedRow?.name ?? "Details",
|
|
86
|
+
tailRendered: renderStatusTail(ctx.selectedRow),
|
|
87
|
+
tailPlain: plainStatusTail(ctx.selectedRow),
|
|
88
|
+
}),
|
|
89
|
+
rows: renderDetailRows,
|
|
90
|
+
}
|
|
91
|
+
```
|
|
65
92
|
|
|
66
93
|
## Custom actions
|
|
67
94
|
|
package/examples/pane-overlay.ts
CHANGED
|
@@ -4,12 +4,13 @@ import { connect, paneOverlay } from "pi-extension-utils";
|
|
|
4
4
|
interface RunRow {
|
|
5
5
|
id: string;
|
|
6
6
|
label: string;
|
|
7
|
+
status: string;
|
|
7
8
|
lines: string[];
|
|
8
9
|
}
|
|
9
10
|
|
|
10
11
|
const runs: RunRow[] = [
|
|
11
|
-
{ id: "a", label: "explorer", lines: ["status: running", "task: inspect files"] },
|
|
12
|
-
{ id: "b", label: "qa", lines: ["status: done", "tests: passed"] },
|
|
12
|
+
{ id: "a", label: "explorer", status: "running", lines: ["status: running", "task: inspect files"] },
|
|
13
|
+
{ id: "b", label: "qa", status: "done", lines: ["status: done", "tests: passed"] },
|
|
13
14
|
];
|
|
14
15
|
|
|
15
16
|
export default function (pi: ExtensionAPI) {
|
|
@@ -25,10 +26,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
25
26
|
mode: "cursor",
|
|
26
27
|
rows: runs,
|
|
27
28
|
selectionKey: (run) => run.id,
|
|
28
|
-
|
|
29
|
+
initialSelectionKey: "b",
|
|
30
|
+
renderRow: (run, _overlay, width) => width > 28 ? `${run.label} ${run.status}` : run.label,
|
|
31
|
+
infoTitle: "selected",
|
|
32
|
+
info: (overlay) => overlay.selectedRow ? [`status: ${overlay.selectedRow.status}`] : [],
|
|
29
33
|
},
|
|
30
34
|
detail: {
|
|
31
|
-
title: (overlay) => overlay.selectedRow?.label ?? "Details",
|
|
35
|
+
title: (overlay) => ({ label: overlay.selectedRow?.label ?? "Details", tail: overlay.selectedRow?.status }),
|
|
32
36
|
rows: (overlay) => overlay.selectedRow?.lines ?? ["No selection"],
|
|
33
37
|
},
|
|
34
38
|
split: { initialFraction: 0.35, minPrimaryWidth: 20, minDetailWidth: 30 },
|