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.
@@ -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
- return text
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
- if (!hint)
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(hint, hintBudget);
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
- if (!title)
204
+ const safeTitle = normalizeRenderableText(title);
205
+ if (!safeTitle)
200
206
  return dash(width);
201
207
  if (opts?.leadingDashes !== undefined) {
202
- const label = ` ${title} `;
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(title, Math.max(0, width - 4));
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>): string;
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?: string | ((ctx: PaneOverlayContext<unknown, Row>) => string);
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?: string | ((ctx: PaneOverlayContext<unknown, Row>) => string);
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 {};
@@ -1,5 +1,5 @@
1
1
  import { dispatchNavKeys } from "./key-dispatch.js";
2
- import { computeFixedSidebarLayout, computeSplitPaneLayout, endCursor, endScrollOffset, ensureCursorVisible, homeCursor, homeScrollOffset, moveCursor, moveScrollOffset, pageCursor, pageScrollOffset, resizeSplitPane, } from "./state.js";
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, primaryRows, detailRows) => ({
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 listViewport = (bodyHeight, legendLineCount) => {
133
- const primaryHeight = legendPlacement === "primary"
134
- ? Math.max(0, bodyHeight - legendLineCount - 1)
135
- : bodyHeight;
136
- return { primaryHeight, detailHeight: bodyHeight };
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 primaryRows = resolveValue(options.primary.rows, makeContext(0, undefined, "", [], []));
141
- const viewportHeight = listViewport(bodyHeight, legendLineCount).primaryHeight;
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 next = moveCursor({ cursor: state.cursor, scroll: state.scrollOffset, itemCount: rows.length, viewportHeight }, delta);
152
- state.cursor = next.cursor;
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 next = pageCursor({ cursor: state.cursor, scroll: state.scrollOffset, itemCount: rows.length, viewportHeight }, delta, pageSize);
165
- state.cursor = next.cursor;
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 next = homeCursor({ cursor: state.cursor, scroll: state.scrollOffset, itemCount: rows.length, viewportHeight });
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 next = endCursor({ cursor: state.cursor, scroll: state.scrollOffset, itemCount: rows.length, viewportHeight });
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 { primaryRows, selectedIndex, selectedRow, selectedKey } = computeSelection(bodyHeight);
267
- const detailRows = resolveValue(options.detail.rows, makeContext(selectedIndex, selectedRow, selectedKey, primaryRows, []));
268
- const ctx = makeContext(selectedIndex, selectedRow, selectedKey, primaryRows, detailRows);
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 { primaryHeight, detailHeight } = listViewport(bodyHeight, legendLines.length);
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
- label: primaryTitle ?? "",
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
- label: detailTitle ?? "",
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
- const text = renderFn(row, ctx);
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" && primaryRows.length > 0
344
- ? `${primaryState.cursor + 1}/${primaryRows.length}`
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 (legendPlacement === "primary" && row === primaryHeight && legendLines.length > 0) {
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 > primaryHeight) {
364
- const legendIndex = row - primaryHeight - 1;
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 { primaryRows, selectedIndex, selectedRow, selectedKey } = computeSelection(bodyHeight);
391
- const detailRows = resolveValue(options.detail.rows, makeContext(selectedIndex, selectedRow, selectedKey, primaryRows, []));
392
- const ctx = makeContext(selectedIndex, selectedRow, selectedKey, primaryRows, detailRows);
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;
@@ -19,12 +19,16 @@ await client.ui.fullscreen(
19
19
  mode: "cursor",
20
20
  rows: () => runs,
21
21
  selectionKey: (run) => run.id,
22
- renderRow: (run) => run.label,
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
 
@@ -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
- renderRow: (run) => run.label,
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 },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-extension-utils",
3
- "version": "0.4.1",
3
+ "version": "0.5.1",
4
4
  "description": "Shared Pi extension utilities for coordinated widgets, fullscreen leases, and logging.",
5
5
  "type": "module",
6
6
  "main": "./dist/src/index.js",