pi-extension-utils 0.3.0 → 0.3.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/README.md CHANGED
@@ -16,7 +16,7 @@ The host owns one real widget per placement and composes registered sub-widgets
16
16
  ## API sketch
17
17
 
18
18
  ```ts
19
- import { connect, createLogger } from "pi-extension-utils";
19
+ import { connect, createLogger, paneOverlay } from "pi-extension-utils";
20
20
 
21
21
  const client = connect(pi, { ctx, clientId: "my-extension" });
22
22
  client.widgets.set("belowEditor", "status", (tui, theme) => component, { order: 10 });
@@ -28,6 +28,23 @@ const result = await client.ui.fullscreen<string | null>(
28
28
  (tui, theme, keybindings, done) => new MyOverlayComponent(tui, theme, done),
29
29
  );
30
30
 
31
+ // Opinionated master/detail pane overlay helper with built-in key handling.
32
+ const result = await client.ui.fullscreen(
33
+ paneOverlay<string, string>({
34
+ primary: {
35
+ mode: "cursor",
36
+ rows: ["one", "two", "three"],
37
+ renderRow: (row) => row,
38
+ onSelectionChange: (row) => console.log("selected", row),
39
+ },
40
+ detail: {
41
+ title: "Details",
42
+ rows: (ctx) => [`Selected: ${ctx.selectedRow}`],
43
+ },
44
+ legendPlacement: "footer",
45
+ }),
46
+ );
47
+
31
48
  // Lower-level lease, for manual control over the blank/restore window.
32
49
  const lease = client.fullscreen.acquire();
33
50
  lease.release();
@@ -5,3 +5,4 @@ export * from "./pane-state.ts";
5
5
  export * from "./protocol.ts";
6
6
  export * from "./reminders/types.ts";
7
7
  export * from "./tui-chrome.ts";
8
+ export * from "./pane-overlay.ts";
package/dist/src/index.js CHANGED
@@ -5,3 +5,4 @@ export * from "./pane-state.js";
5
5
  export * from "./protocol.js";
6
6
  export * from "./reminders/types.js";
7
7
  export * from "./tui-chrome.js";
8
+ export * from "./pane-overlay.js";
@@ -0,0 +1,75 @@
1
+ import type { FullscreenComponentFactory } from "./client.ts";
2
+ export interface PaneOverlayContext<T = unknown, Row = unknown> {
3
+ tui: unknown;
4
+ primaryFocus: boolean;
5
+ detailFocus: boolean;
6
+ selectedRow: Row | undefined;
7
+ selectedIndex: number;
8
+ selectedKey: string;
9
+ primary: {
10
+ mode: "cursor" | "scroll";
11
+ cursor: number;
12
+ scrollOffset: number;
13
+ };
14
+ detail: {
15
+ scrollOffset: number;
16
+ };
17
+ close(result?: T): void;
18
+ requestRender(): void;
19
+ }
20
+ export interface PaneOverlayCustomAction<T = unknown, Row = unknown> {
21
+ keys: string | readonly string[];
22
+ label: string;
23
+ run(ctx: PaneOverlayContext<T, Row>): void;
24
+ when?(ctx: PaneOverlayContext<T, Row>): boolean;
25
+ showInLegend?: boolean;
26
+ }
27
+ export interface PrimaryPaneOptions<Row = unknown> {
28
+ mode?: "cursor" | "scroll";
29
+ rows: Row[] | ((ctx: PaneOverlayContext<unknown, Row>) => Row[]);
30
+ renderRow?(row: Row, ctx: PaneOverlayContext<unknown, Row>): string;
31
+ selectionKey?(row: Row, index: number): string;
32
+ onSelectionChange?(row: Row | undefined, index: number, key: string, ctx: PaneOverlayContext<unknown, Row>): void;
33
+ title?: string | ((ctx: PaneOverlayContext<unknown, Row>) => string);
34
+ footer?: string | ((ctx: PaneOverlayContext<unknown, Row>) => string);
35
+ }
36
+ export interface DetailPaneOptions<Row = unknown> {
37
+ rows(ctx: PaneOverlayContext<unknown, Row>): string[];
38
+ title?: string | ((ctx: PaneOverlayContext<unknown, Row>) => string);
39
+ footer?: string | ((ctx: PaneOverlayContext<unknown, Row>) => string);
40
+ }
41
+ export interface PaneOverlaySplitOptions {
42
+ initialFraction?: number;
43
+ minPrimaryWidth?: number;
44
+ minDetailWidth?: number;
45
+ maxPrimaryWidth?: number;
46
+ stepCols?: number;
47
+ minFraction?: number;
48
+ maxFraction?: number;
49
+ fractionBasis?: "total" | "interior";
50
+ }
51
+ export interface PaneCollapseOptions {
52
+ key?: string;
53
+ collapsedWidth?: number;
54
+ label?: string;
55
+ }
56
+ export interface PaneOverlayOptions<T = unknown, Row = unknown> {
57
+ primary: PrimaryPaneOptions<Row>;
58
+ detail: DetailPaneOptions<Row>;
59
+ height?: number | ((tui: unknown) => number);
60
+ closeKeys?: readonly string[];
61
+ closeResult?: T;
62
+ customActions?: readonly PaneOverlayCustomAction<T, Row>[];
63
+ bannedKeys?: readonly string[];
64
+ split?: PaneOverlaySplitOptions;
65
+ legendPlacement?: "footer" | "primary";
66
+ collapse?: PaneCollapseOptions;
67
+ perSelectionScroll?: boolean;
68
+ stickyBottom?: boolean;
69
+ }
70
+ export interface PaneOverlayComponent {
71
+ render(width: number): string[];
72
+ handleInput(data: string): void;
73
+ dispose(): void;
74
+ }
75
+ export declare function paneOverlay<T = undefined, Row = unknown>(options: PaneOverlayOptions<T, Row>): FullscreenComponentFactory<T>;
@@ -0,0 +1,492 @@
1
+ import { dispatchNavKeys } from "./key-dispatch.js";
2
+ import { computeFixedSidebarLayout, computeSplitPaneLayout, endCursor, endScrollOffset, ensureCursorVisible, homeCursor, homeScrollOffset, moveCursor, moveScrollOffset, pageCursor, pageScrollOffset, resizeSplitPane, } from "./pane-state.js";
3
+ import { boxRow, clipStyled, flatRule, formatScrollInfo, padRight, renderKeyRow, titledBottomSegment, titledTopSegment, } from "./tui-chrome.js";
4
+ import { matchesKey } from "@earendil-works/pi-tui";
5
+ const DEFAULT_CLOSE_KEYS = ["escape", "ctrl+c", "q"];
6
+ function resolveValue(value, ctx) {
7
+ return typeof value === "function" ? value(ctx) : value;
8
+ }
9
+ function computeBodyHeight(tui, height) {
10
+ if (typeof height === "number")
11
+ return Math.max(1, height);
12
+ if (typeof height === "function")
13
+ return Math.max(1, height(tui));
14
+ const rows = tui?.terminal?.rows;
15
+ if (typeof rows === "number")
16
+ return Math.max(8, rows - 2);
17
+ return 16;
18
+ }
19
+ function matchesAny(data, binding) {
20
+ if (!binding)
21
+ return false;
22
+ const keys = typeof binding === "string" ? [binding] : binding;
23
+ return keys.some((key) => data === key || matchesKey(data, key));
24
+ }
25
+ function defaultRenderRow(row) {
26
+ return typeof row === "string" ? row : String(row);
27
+ }
28
+ function decorateRow(text, theme) {
29
+ return theme.bold ? theme.bold(theme.fg("accent", text)) : theme.fg("accent", text);
30
+ }
31
+ export function paneOverlay(options) {
32
+ const split = options.split ?? {};
33
+ const closeKeys = options.closeKeys ?? DEFAULT_CLOSE_KEYS;
34
+ const customActions = options.customActions ?? [];
35
+ const bannedKeys = options.bannedKeys ?? [];
36
+ const legendPlacement = options.legendPlacement ?? "footer";
37
+ const perSelectionScroll = options.perSelectionScroll ?? false;
38
+ const stickyBottom = options.stickyBottom ?? false;
39
+ const collapse = options.collapse;
40
+ const collapseKey = collapse?.key ?? "c";
41
+ const collapsedWidth = collapse?.collapsedWidth ?? 1;
42
+ return (tui, theme, _keybindings, done) => {
43
+ const chromeTheme = theme;
44
+ let finished = false;
45
+ let focus = "primary";
46
+ let collapsed = false;
47
+ let leftFraction = split.initialFraction ?? 0.5;
48
+ let lastRenderWidth = 80;
49
+ let lastSelectedKey;
50
+ const primaryState = { cursor: 0, scrollOffset: 0 };
51
+ const detailState = { scrollOffset: 0, sticky: stickyBottom };
52
+ const perSelectionOffsets = new Map();
53
+ const requestRender = () => {
54
+ tui?.requestRender?.();
55
+ };
56
+ const finish = (result) => {
57
+ if (finished)
58
+ return;
59
+ finished = true;
60
+ done(result);
61
+ };
62
+ const makeContext = (selectedIndex, selectedRow, selectedKey, primaryRows, detailRows) => ({
63
+ tui,
64
+ primaryFocus: focus === "primary",
65
+ detailFocus: focus === "detail",
66
+ selectedRow,
67
+ selectedIndex,
68
+ selectedKey,
69
+ primary: {
70
+ mode: options.primary.mode ?? "scroll",
71
+ cursor: primaryState.cursor,
72
+ scrollOffset: primaryState.scrollOffset,
73
+ },
74
+ detail: { scrollOffset: detailState.scrollOffset },
75
+ close: (result) => finish(result),
76
+ requestRender,
77
+ });
78
+ const getLayout = (totalWidth) => {
79
+ if (collapsed) {
80
+ return computeFixedSidebarLayout({
81
+ totalWidth,
82
+ collapsed: true,
83
+ leftWidth: collapsedWidth,
84
+ collapsedLeftWidth: collapsedWidth,
85
+ minLeftWidth: split.minPrimaryWidth ?? 0,
86
+ minRightWidth: split.minDetailWidth ?? 8,
87
+ });
88
+ }
89
+ return computeSplitPaneLayout({
90
+ totalWidth,
91
+ leftFraction,
92
+ minLeftWidth: split.minPrimaryWidth ?? 8,
93
+ minRightWidth: split.minDetailWidth ?? 8,
94
+ leftMaxWidth: split.maxPrimaryWidth,
95
+ minFraction: split.minFraction,
96
+ maxFraction: split.maxFraction,
97
+ fractionBasis: split.fractionBasis,
98
+ });
99
+ };
100
+ const resize = (direction) => {
101
+ if (collapsed)
102
+ return;
103
+ const next = resizeSplitPane({
104
+ totalWidth: lastRenderWidth,
105
+ leftFraction,
106
+ direction,
107
+ stepCols: split.stepCols ?? 4,
108
+ minLeftWidth: split.minPrimaryWidth ?? 8,
109
+ minRightWidth: split.minDetailWidth ?? 8,
110
+ leftMaxWidth: split.maxPrimaryWidth,
111
+ minFraction: split.minFraction,
112
+ maxFraction: split.maxFraction,
113
+ fractionBasis: split.fractionBasis,
114
+ });
115
+ leftFraction = next.leftFraction;
116
+ };
117
+ const toggleFocus = () => {
118
+ focus = focus === "primary" ? "detail" : "primary";
119
+ };
120
+ const ensureFocusDetailWhenCollapsed = () => {
121
+ if (collapsed)
122
+ focus = "detail";
123
+ };
124
+ const getDetailStateForKey = (key) => {
125
+ let state = perSelectionOffsets.get(key);
126
+ if (!state) {
127
+ state = { offset: 0, sticky: stickyBottom };
128
+ perSelectionOffsets.set(key, state);
129
+ }
130
+ return state;
131
+ };
132
+ const listViewport = (bodyHeight, legendLineCount) => {
133
+ const primaryHeight = legendPlacement === "primary"
134
+ ? Math.max(0, bodyHeight - legendLineCount - 1)
135
+ : bodyHeight;
136
+ return { primaryHeight, detailHeight: bodyHeight };
137
+ };
138
+ const pageSizeHalf = (height) => Math.max(1, Math.floor(height / 2));
139
+ const withPrimaryViewport = (bodyHeight, legendLineCount) => {
140
+ const primaryRows = resolveValue(options.primary.rows, makeContext(0, undefined, "", [], []));
141
+ const viewportHeight = listViewport(bodyHeight, legendLineCount).primaryHeight;
142
+ return { primaryRows, viewportHeight: Math.max(1, viewportHeight) };
143
+ };
144
+ const applyPrimaryNav = (bodyHeight, legendLineCount, mutator) => {
145
+ const { primaryRows, viewportHeight } = withPrimaryViewport(bodyHeight, legendLineCount);
146
+ mutator(primaryState, primaryRows, viewportHeight);
147
+ };
148
+ const movePrimary = (delta, bodyHeight, legendLineCount) => {
149
+ applyPrimaryNav(bodyHeight, legendLineCount, (state, rows, viewportHeight) => {
150
+ 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;
153
+ state.scrollOffset = next.scroll;
154
+ }
155
+ else {
156
+ state.scrollOffset = moveScrollOffset({ offset: state.scrollOffset, contentLength: rows.length, viewportHeight }, delta);
157
+ }
158
+ });
159
+ };
160
+ const halfPagePrimary = (delta, bodyHeight, legendLineCount) => {
161
+ applyPrimaryNav(bodyHeight, legendLineCount, (state, rows, viewportHeight) => {
162
+ const pageSize = pageSizeHalf(viewportHeight);
163
+ 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;
166
+ state.scrollOffset = next.scroll;
167
+ }
168
+ else {
169
+ state.scrollOffset = pageScrollOffset({ offset: state.scrollOffset, contentLength: rows.length, viewportHeight }, delta, pageSize);
170
+ }
171
+ });
172
+ };
173
+ const homePrimary = (bodyHeight, legendLineCount) => {
174
+ applyPrimaryNav(bodyHeight, legendLineCount, (state, rows, viewportHeight) => {
175
+ if ((options.primary.mode ?? "scroll") === "cursor") {
176
+ const next = homeCursor({ cursor: state.cursor, scroll: state.scrollOffset, itemCount: rows.length, viewportHeight });
177
+ state.cursor = next.cursor;
178
+ state.scrollOffset = next.scroll;
179
+ }
180
+ else {
181
+ state.scrollOffset = homeScrollOffset();
182
+ }
183
+ });
184
+ };
185
+ const endPrimary = (bodyHeight, legendLineCount) => {
186
+ applyPrimaryNav(bodyHeight, legendLineCount, (state, rows, viewportHeight) => {
187
+ if ((options.primary.mode ?? "scroll") === "cursor") {
188
+ const next = endCursor({ cursor: state.cursor, scroll: state.scrollOffset, itemCount: rows.length, viewportHeight });
189
+ state.cursor = next.cursor;
190
+ state.scrollOffset = next.scroll;
191
+ }
192
+ else {
193
+ state.scrollOffset = endScrollOffset(rows.length, viewportHeight);
194
+ }
195
+ });
196
+ };
197
+ const markDetailManual = () => {
198
+ detailState.sticky = false;
199
+ if (perSelectionScroll && lastSelectedKey !== undefined) {
200
+ getDetailStateForKey(lastSelectedKey).sticky = false;
201
+ }
202
+ };
203
+ const legendEntries = (ctx) => {
204
+ const entries = [];
205
+ entries.push({ key: "tab/←/→", label: "focus" });
206
+ const primaryMode = options.primary.mode ?? "scroll";
207
+ const moveLabel = ctx.detailFocus || primaryMode === "scroll" ? "scroll" : "select";
208
+ entries.push({ key: "j/k", label: moveLabel });
209
+ entries.push({ key: "u/d", label: "half-page" });
210
+ entries.push({ key: "g/G", label: "top/bottom" });
211
+ if (!collapsed)
212
+ entries.push({ key: "[/]", label: "resize" });
213
+ if (collapse)
214
+ entries.push({ key: collapseKey, label: collapse.label ?? "collapse" });
215
+ for (const action of customActions) {
216
+ if (action.showInLegend === false)
217
+ continue;
218
+ if (action.when && !action.when(ctx))
219
+ continue;
220
+ entries.push({ key: typeof action.keys === "string" ? action.keys : action.keys.join("/"), label: action.label });
221
+ }
222
+ const qCloses = closeKeys.includes("q");
223
+ entries.push({ key: qCloses ? "q/esc" : "esc", label: "close" });
224
+ return entries;
225
+ };
226
+ const renderLegendLines = (entries, totalWidth) => {
227
+ if (entries.length === 0 || totalWidth <= 0)
228
+ return [];
229
+ const keyWidth = Math.min(11, Math.max(3, Math.floor(totalWidth / 4)));
230
+ return entries.map((entry) => chromeTheme.fg("dim", renderKeyRow(entry.key, entry.label, totalWidth, keyWidth)));
231
+ };
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
+ const component = {
256
+ dispose() {
257
+ // no-op is acceptable
258
+ },
259
+ render(width) {
260
+ const totalWidth = Math.max(10, Math.floor(width));
261
+ lastRenderWidth = totalWidth;
262
+ const bodyHeight = computeBodyHeight(tui, options.height);
263
+ const layout = getLayout(totalWidth);
264
+ const primaryWidth = layout.leftWidth;
265
+ 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);
269
+ if (selectedKey !== lastSelectedKey) {
270
+ lastSelectedKey = selectedKey;
271
+ if (options.primary.onSelectionChange) {
272
+ options.primary.onSelectionChange(selectedRow, selectedIndex, selectedKey, ctx);
273
+ }
274
+ if (perSelectionScroll) {
275
+ const state = getDetailStateForKey(selectedKey);
276
+ detailState.scrollOffset = state.offset;
277
+ detailState.sticky = state.sticky;
278
+ }
279
+ else if (stickyBottom) {
280
+ detailState.sticky = true;
281
+ }
282
+ }
283
+ ensureFocusDetailWhenCollapsed();
284
+ const entries = legendEntries(ctx);
285
+ const legendLines = renderLegendLines(entries, totalWidth);
286
+ const { primaryHeight, detailHeight } = listViewport(bodyHeight, legendLines.length);
287
+ // Align primary viewport.
288
+ if ((options.primary.mode ?? "scroll") === "cursor") {
289
+ const viewport = ensureCursorVisible({
290
+ cursor: primaryState.cursor,
291
+ scroll: primaryState.scrollOffset,
292
+ itemCount: primaryRows.length,
293
+ viewportHeight: Math.max(1, primaryHeight),
294
+ });
295
+ primaryState.cursor = viewport.cursor;
296
+ primaryState.scrollOffset = viewport.scroll;
297
+ }
298
+ else {
299
+ primaryState.scrollOffset = Math.max(0, Math.min(primaryState.scrollOffset, Math.max(0, primaryRows.length - primaryHeight)));
300
+ }
301
+ // Align detail viewport.
302
+ const detailMaxOffset = Math.max(0, detailRows.length - detailHeight);
303
+ if (stickyBottom && detailState.sticky) {
304
+ detailState.scrollOffset = detailMaxOffset;
305
+ }
306
+ else {
307
+ detailState.scrollOffset = Math.max(0, Math.min(detailState.scrollOffset, detailMaxOffset));
308
+ }
309
+ if (perSelectionScroll) {
310
+ const state = getDetailStateForKey(selectedKey);
311
+ state.offset = detailState.scrollOffset;
312
+ state.sticky = detailState.sticky;
313
+ }
314
+ const primaryTitle = resolveValue(options.primary.title, ctx);
315
+ const detailTitle = resolveValue(options.detail.title, ctx);
316
+ const topPrimary = titledTopSegment(chromeTheme, {
317
+ width: primaryWidth,
318
+ label: primaryTitle ?? "",
319
+ labelColor: focus === "primary" ? "accent" : "text",
320
+ labelBold: focus === "primary",
321
+ });
322
+ const topDetail = titledTopSegment(chromeTheme, {
323
+ width: detailWidth,
324
+ label: detailTitle ?? "",
325
+ labelColor: focus === "detail" ? "accent" : "text",
326
+ labelBold: focus === "detail",
327
+ });
328
+ const corner = (glyph) => chromeTheme.fg("dim", glyph);
329
+ const topBorder = primaryWidth === 0
330
+ ? corner("╭") + topDetail + corner("╮")
331
+ : corner("╭") + topPrimary + corner("┬") + topDetail + corner("╮");
332
+ const renderFn = options.primary.renderRow ?? defaultRenderRow;
333
+ const primaryVisible = primaryRows
334
+ .slice(primaryState.scrollOffset, primaryState.scrollOffset + primaryHeight)
335
+ .map((row, index) => {
336
+ const absoluteIndex = primaryState.scrollOffset + index;
337
+ const text = renderFn(row, ctx);
338
+ const selected = (options.primary.mode ?? "scroll") === "cursor" && absoluteIndex === primaryState.cursor && focus === "primary";
339
+ return selected ? decorateRow(text, chromeTheme) : text;
340
+ });
341
+ const detailVisible = detailRows.slice(detailState.scrollOffset, detailState.scrollOffset + detailHeight);
342
+ const primaryFooterText = resolveValue(options.primary.footer, ctx)
343
+ ?? ((options.primary.mode ?? "scroll") === "cursor" && primaryRows.length > 0
344
+ ? `${primaryState.cursor + 1}/${primaryRows.length}`
345
+ : primaryRows.length > primaryHeight
346
+ ? formatScrollInfo(primaryState.scrollOffset, Math.max(0, primaryRows.length - primaryHeight), { style: "position" })
347
+ : "");
348
+ const detailFooterText = resolveValue(options.detail.footer, ctx)
349
+ ?? (detailRows.length > detailHeight
350
+ ? formatScrollInfo(detailState.scrollOffset, Math.max(0, detailRows.length - detailHeight), { style: "position" })
351
+ : "");
352
+ const bottomPrimary = titledBottomSegment(chromeTheme, primaryWidth, primaryFooterText, focus === "primary");
353
+ const bottomDetail = titledBottomSegment(chromeTheme, detailWidth, detailFooterText, focus === "detail");
354
+ const bottomBorder = primaryWidth === 0
355
+ ? corner("╰") + bottomDetail + corner("╯")
356
+ : corner("╰") + bottomPrimary + corner("┴") + bottomDetail + corner("╯");
357
+ const bodyLines = [];
358
+ for (let row = 0; row < bodyHeight; row++) {
359
+ let primaryCell = primaryVisible[row] ?? "";
360
+ if (legendPlacement === "primary" && row === primaryHeight && legendLines.length > 0) {
361
+ primaryCell = flatRule(chromeTheme, collapse?.label ?? "actions", primaryWidth);
362
+ }
363
+ if (legendPlacement === "primary" && row > primaryHeight) {
364
+ const legendIndex = row - primaryHeight - 1;
365
+ primaryCell = legendIndex < legendLines.length ? padRight(legendLines[legendIndex], primaryWidth) : "";
366
+ }
367
+ const detailCell = detailVisible[row] ?? "";
368
+ if (primaryWidth === 0) {
369
+ const border = chromeTheme.fg("dim", "│");
370
+ bodyLines.push(border + padRight(clipStyled(detailCell, detailWidth), detailWidth) + border);
371
+ }
372
+ else {
373
+ bodyLines.push(boxRow(chromeTheme, primaryCell, detailCell, primaryWidth, detailWidth));
374
+ }
375
+ }
376
+ const lines = [topBorder, ...bodyLines, bottomBorder];
377
+ if (legendPlacement === "footer") {
378
+ lines.push(...legendLines);
379
+ }
380
+ return lines.map((line) => padRight(line, totalWidth));
381
+ },
382
+ handleInput(data) {
383
+ if (finished)
384
+ return;
385
+ const bodyHeight = computeBodyHeight(tui, options.height);
386
+ if (matchesAny(data, closeKeys)) {
387
+ finish(options.closeResult);
388
+ return;
389
+ }
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);
393
+ for (const action of customActions) {
394
+ if (action.when && !action.when(ctx))
395
+ continue;
396
+ if (matchesAny(data, action.keys)) {
397
+ action.run(ctx);
398
+ requestRender();
399
+ return;
400
+ }
401
+ }
402
+ if (matchesAny(data, bannedKeys)) {
403
+ requestRender();
404
+ return;
405
+ }
406
+ if (matchesAny(data, ["left", "right", "tab"])) {
407
+ toggleFocus();
408
+ ensureFocusDetailWhenCollapsed();
409
+ requestRender();
410
+ return;
411
+ }
412
+ const legendLineCount = legendPlacement === "primary" ? renderLegendLines(legendEntries(ctx), lastRenderWidth).length : 0;
413
+ if (matchesAny(data, ["pageUp", "pageDown"]))
414
+ return;
415
+ const commonConsumed = dispatchNavKeys(data, {
416
+ close: () => undefined,
417
+ closeKeys: [],
418
+ move: (delta) => {
419
+ if (focus === "primary") {
420
+ movePrimary(delta, bodyHeight, legendLineCount);
421
+ }
422
+ else {
423
+ markDetailManual();
424
+ detailState.scrollOffset = moveScrollOffset({ offset: detailState.scrollOffset, contentLength: detailRows.length, viewportHeight: Math.max(1, bodyHeight) }, delta);
425
+ }
426
+ },
427
+ home: () => {
428
+ if (focus === "primary") {
429
+ homePrimary(bodyHeight, legendLineCount);
430
+ }
431
+ else {
432
+ markDetailManual();
433
+ detailState.scrollOffset = homeScrollOffset();
434
+ }
435
+ },
436
+ end: () => {
437
+ if (focus === "primary") {
438
+ endPrimary(bodyHeight, legendLineCount);
439
+ }
440
+ else {
441
+ markDetailManual();
442
+ detailState.scrollOffset = endScrollOffset(detailRows.length, Math.max(1, bodyHeight));
443
+ }
444
+ },
445
+ });
446
+ if (commonConsumed) {
447
+ requestRender();
448
+ return;
449
+ }
450
+ if (matchesAny(data, ["u", "U"])) {
451
+ if (focus === "primary") {
452
+ halfPagePrimary(-1, bodyHeight, legendLineCount);
453
+ }
454
+ else {
455
+ markDetailManual();
456
+ detailState.scrollOffset = pageScrollOffset({ offset: detailState.scrollOffset, contentLength: detailRows.length, viewportHeight: Math.max(1, bodyHeight) }, -1, pageSizeHalf(bodyHeight));
457
+ }
458
+ requestRender();
459
+ return;
460
+ }
461
+ if (matchesAny(data, ["d", "D"])) {
462
+ if (focus === "primary") {
463
+ halfPagePrimary(1, bodyHeight, legendLineCount);
464
+ }
465
+ else {
466
+ markDetailManual();
467
+ detailState.scrollOffset = pageScrollOffset({ offset: detailState.scrollOffset, contentLength: detailRows.length, viewportHeight: Math.max(1, bodyHeight) }, 1, pageSizeHalf(bodyHeight));
468
+ }
469
+ requestRender();
470
+ return;
471
+ }
472
+ if (collapse && matchesAny(data, [collapseKey])) {
473
+ collapsed = !collapsed;
474
+ ensureFocusDetailWhenCollapsed();
475
+ requestRender();
476
+ return;
477
+ }
478
+ if (!collapsed && matchesAny(data, ["["])) {
479
+ resize(-1);
480
+ requestRender();
481
+ return;
482
+ }
483
+ if (!collapsed && matchesAny(data, ["]"])) {
484
+ resize(1);
485
+ requestRender();
486
+ return;
487
+ }
488
+ },
489
+ };
490
+ return component;
491
+ };
492
+ }
@@ -113,28 +113,37 @@ export function titledTopSegment(theme, opts) {
113
113
  const fillDashes = Math.max(1, width - labelLen - 3);
114
114
  return clipStyled(`${dash(1)} ${labelStyled} ${dash(fillDashes)}`, width);
115
115
  }
116
+ const width = Math.max(0, opts.width);
117
+ if (width <= 0)
118
+ return "";
116
119
  const labelColor = opts.labelColor ?? "text";
117
120
  const tailColor = opts.tailColor ?? "dim";
118
- // Reserve at least `─ ` + label + ` ─` (4 chars) and 1 dash on each side of the tail when present.
119
- const labelBudget = opts.width <= 4 ? 0 : opts.width - 4;
121
+ const tailPlain = opts.tailPlain ?? opts.tail ?? "";
122
+ const rawTailRendered = opts.tailRendered ?? (opts.tail !== undefined ? theme.fg(tailColor, opts.tail) : "");
123
+ const tailLen = visibleWidth(tailPlain);
124
+ // With a tail, the minimum chrome is:
125
+ // `─ ` + label + ` ` + `─` + ` ` + tail + ` ` + `─` = label + tail + 7.
126
+ // The previous math reserved only 6 columns, so long label+tail combinations
127
+ // could render one column too wide and crash Pi's TUI at terminal width.
128
+ const tailFits = tailLen > 0 && width >= tailLen + 7;
129
+ const labelBudget = tailFits ? Math.max(0, width - tailLen - 7) : Math.max(0, width - 3);
120
130
  // Defensive: callers occasionally pass undefined labels for transient/legacy
121
131
  // runs that lack agent+mode+label; treat as empty rather than crashing pi.
122
- const labelText = truncateToWidth(opts.label ?? "", labelBudget);
132
+ const rawLabel = opts.label ?? "";
133
+ const labelText = truncateToWidth(rawLabel, labelBudget, "").replace(/\u001b\[[0-9;]*m/g, "");
123
134
  const labelStyled = opts.labelBold && theme.bold
124
135
  ? theme.bold(theme.fg(labelColor, labelText))
125
136
  : theme.fg(labelColor, labelText);
126
- const tailPlain = opts.tailPlain ?? opts.tail ?? "";
127
- const tailRendered = opts.tailRendered ?? (opts.tail !== undefined ? theme.fg(tailColor, opts.tail) : "");
128
137
  const labelLen = visibleWidth(labelText);
129
- const tailLen = visibleWidth(tailPlain);
130
- // Layout with tail: `─ <label> ──…── <tail> ─` => fixed = 6 + labelLen + tailLen.
131
- // Layout without tail: `─ <label> ──…────` => fixed = 3 + labelLen (one space + label + space).
132
- if (tailLen > 0) {
133
- const fillDashes = Math.max(1, opts.width - (labelLen + tailLen + 6));
134
- return `${dash(1)} ${labelStyled} ${dash(fillDashes)} ${tailRendered} ${dash(1)}`;
138
+ // Layout with tail: `─ <label> ──…── <tail> ─`.
139
+ // Layout without tail: `─ <label> ──…────`.
140
+ if (tailFits) {
141
+ const tailRendered = visibleWidth(rawTailRendered) > tailLen ? clipStyled(rawTailRendered, tailLen) : rawTailRendered;
142
+ const fillDashes = Math.max(1, width - (labelLen + tailLen + 6));
143
+ return clipStyled(`${dash(1)} ${labelStyled} ${dash(fillDashes)} ${tailRendered} ${dash(1)}`, width);
135
144
  }
136
- const fillDashes = Math.max(1, opts.width - (labelLen + 3));
137
- return `${dash(1)} ${labelStyled} ${dash(fillDashes)}`;
145
+ const fillDashes = Math.max(0, width - (labelLen + 3));
146
+ return clipStyled(`${dash(1)} ${labelStyled} ${dash(fillDashes)}`, width);
138
147
  }
139
148
  /**
140
149
  * Build one half of a bottom border with an embedded hint string.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-extension-utils",
3
- "version": "0.3.0",
3
+ "version": "0.3.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",