pi-extension-utils 0.2.1 → 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 +18 -1
- package/dist/src/index.d.ts +4 -0
- package/dist/src/index.js +4 -0
- package/dist/src/key-dispatch.d.ts +21 -0
- package/dist/src/key-dispatch.js +50 -0
- package/dist/src/pane-overlay.d.ts +75 -0
- package/dist/src/pane-overlay.js +492 -0
- package/dist/src/pane-state.d.ts +71 -0
- package/dist/src/pane-state.js +106 -0
- package/dist/src/tui-chrome.d.ts +65 -0
- package/dist/src/tui-chrome.js +227 -0
- package/package.json +1 -1
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();
|
package/dist/src/index.d.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
export { connect, type FullscreenLease, type RemindersClient, type UtilsClient, type UtilsClientOptions, type WidgetSetOptions } from "./client.ts";
|
|
2
2
|
export { createLogger, type Logger, type LoggerOptions } from "./logger.ts";
|
|
3
|
+
export * from "./key-dispatch.ts";
|
|
4
|
+
export * from "./pane-state.ts";
|
|
3
5
|
export * from "./protocol.ts";
|
|
4
6
|
export * from "./reminders/types.ts";
|
|
7
|
+
export * from "./tui-chrome.ts";
|
|
8
|
+
export * from "./pane-overlay.ts";
|
package/dist/src/index.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
export { connect } from "./client.js";
|
|
2
2
|
export { createLogger } from "./logger.js";
|
|
3
|
+
export * from "./key-dispatch.js";
|
|
4
|
+
export * from "./pane-state.js";
|
|
3
5
|
export * from "./protocol.js";
|
|
4
6
|
export * from "./reminders/types.js";
|
|
7
|
+
export * from "./tui-chrome.js";
|
|
8
|
+
export * from "./pane-overlay.js";
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export type KeyBinding = string | readonly string[];
|
|
2
|
+
export interface ExtraKeyBinding {
|
|
3
|
+
keys: KeyBinding;
|
|
4
|
+
handler: () => void;
|
|
5
|
+
}
|
|
6
|
+
export interface NavKeyHandlers {
|
|
7
|
+
close?: () => void;
|
|
8
|
+
focusToggle?: () => void;
|
|
9
|
+
move?: (delta: 1 | -1) => void;
|
|
10
|
+
page?: (delta: 1 | -1) => void;
|
|
11
|
+
home?: () => void;
|
|
12
|
+
end?: () => void;
|
|
13
|
+
/** Defaults to escape + ctrl+c. Add "q" for dashboard-style close. */
|
|
14
|
+
closeKeys?: readonly string[];
|
|
15
|
+
/** Keys consumed without invoking a handler. */
|
|
16
|
+
bannedKeys?: readonly string[];
|
|
17
|
+
/** Consumer-specific bindings checked after close keys and before banned/common nav keys. */
|
|
18
|
+
extraBindings?: readonly ExtraKeyBinding[];
|
|
19
|
+
}
|
|
20
|
+
/** Dispatch common fullscreen navigation keys using pi-tui `matchesKey` semantics. */
|
|
21
|
+
export declare function dispatchNavKeys(data: string, handlers: NavKeyHandlers): boolean;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { matchesKey } from "@earendil-works/pi-tui";
|
|
2
|
+
const DEFAULT_CLOSE_KEYS = ["escape", "ctrl+c"];
|
|
3
|
+
/** Dispatch common fullscreen navigation keys using pi-tui `matchesKey` semantics. */
|
|
4
|
+
export function dispatchNavKeys(data, handlers) {
|
|
5
|
+
if (matchesAny(data, handlers.closeKeys ?? DEFAULT_CLOSE_KEYS)) {
|
|
6
|
+
handlers.close?.();
|
|
7
|
+
return true;
|
|
8
|
+
}
|
|
9
|
+
for (const binding of handlers.extraBindings ?? []) {
|
|
10
|
+
if (matchesAny(data, binding.keys)) {
|
|
11
|
+
binding.handler();
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
if (matchesAny(data, handlers.bannedKeys ?? []))
|
|
16
|
+
return true;
|
|
17
|
+
if (matchesAny(data, ["tab"])) {
|
|
18
|
+
handlers.focusToggle?.();
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
if (matchesAny(data, ["j", "down"])) {
|
|
22
|
+
handlers.move?.(1);
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
if (matchesAny(data, ["k", "up"])) {
|
|
26
|
+
handlers.move?.(-1);
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
if (matchesAny(data, ["pageDown"])) {
|
|
30
|
+
handlers.page?.(1);
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
if (matchesAny(data, ["pageUp"])) {
|
|
34
|
+
handlers.page?.(-1);
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
if (matchesAny(data, ["home", "g"])) {
|
|
38
|
+
handlers.home?.();
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
if (matchesAny(data, ["end", "shift+g"])) {
|
|
42
|
+
handlers.end?.();
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
function matchesAny(data, binding) {
|
|
48
|
+
const keys = typeof binding === "string" ? [binding] : binding;
|
|
49
|
+
return keys.some((key) => data === key || matchesKey(data, key));
|
|
50
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
export type PaneFocus = "left" | "right";
|
|
2
|
+
export type PaneDirection = -1 | 1;
|
|
3
|
+
export interface SplitPaneLayoutOptions {
|
|
4
|
+
totalWidth: number;
|
|
5
|
+
leftFraction: number;
|
|
6
|
+
minLeftWidth: number;
|
|
7
|
+
minRightWidth: number;
|
|
8
|
+
leftMaxWidth?: number;
|
|
9
|
+
separatorWidth?: number;
|
|
10
|
+
minFraction?: number;
|
|
11
|
+
maxFraction?: number;
|
|
12
|
+
/** Use "total" for the subagents dashboard math, "interior" for picker-style split math. */
|
|
13
|
+
fractionBasis?: "total" | "interior";
|
|
14
|
+
}
|
|
15
|
+
export interface SplitPaneLayout {
|
|
16
|
+
leftWidth: number;
|
|
17
|
+
rightWidth: number;
|
|
18
|
+
interiorWidth: number;
|
|
19
|
+
leftFraction: number;
|
|
20
|
+
}
|
|
21
|
+
export interface ResizeSplitPaneOptions extends SplitPaneLayoutOptions {
|
|
22
|
+
direction: PaneDirection;
|
|
23
|
+
stepCols: number;
|
|
24
|
+
}
|
|
25
|
+
export interface FixedSidebarLayoutOptions {
|
|
26
|
+
totalWidth: number;
|
|
27
|
+
collapsed: boolean;
|
|
28
|
+
leftWidth: number;
|
|
29
|
+
collapsedLeftWidth?: number;
|
|
30
|
+
minLeftWidth?: number;
|
|
31
|
+
minRightWidth?: number;
|
|
32
|
+
separatorWidth?: number;
|
|
33
|
+
collapsedChromeWidth?: number;
|
|
34
|
+
}
|
|
35
|
+
export interface FixedSidebarLayout {
|
|
36
|
+
collapsed: boolean;
|
|
37
|
+
leftWidth: number;
|
|
38
|
+
rightWidth: number;
|
|
39
|
+
interiorWidth: number;
|
|
40
|
+
}
|
|
41
|
+
export interface SidebarState {
|
|
42
|
+
collapsed: boolean;
|
|
43
|
+
focus: PaneFocus;
|
|
44
|
+
}
|
|
45
|
+
export interface ScrollViewportState {
|
|
46
|
+
offset: number;
|
|
47
|
+
contentLength: number;
|
|
48
|
+
viewportHeight: number;
|
|
49
|
+
}
|
|
50
|
+
export interface CursorViewportState {
|
|
51
|
+
cursor: number;
|
|
52
|
+
scroll: number;
|
|
53
|
+
itemCount: number;
|
|
54
|
+
viewportHeight: number;
|
|
55
|
+
}
|
|
56
|
+
export declare function clamp(value: number, min: number, max: number): number;
|
|
57
|
+
export declare function togglePaneFocus(focus: PaneFocus): PaneFocus;
|
|
58
|
+
export declare function computeSplitPaneLayout(opts: SplitPaneLayoutOptions): SplitPaneLayout;
|
|
59
|
+
export declare function resizeSplitPane(opts: ResizeSplitPaneOptions): SplitPaneLayout;
|
|
60
|
+
export declare function computeFixedSidebarLayout(opts: FixedSidebarLayoutOptions): FixedSidebarLayout;
|
|
61
|
+
export declare function toggleSidebar(state: SidebarState): SidebarState;
|
|
62
|
+
export declare function clampScrollOffset(offset: number, contentLength: number, viewportHeight: number): number;
|
|
63
|
+
export declare function moveScrollOffset(state: ScrollViewportState, delta: number): number;
|
|
64
|
+
export declare function pageScrollOffset(state: ScrollViewportState, direction: PaneDirection, pageSize?: number): number;
|
|
65
|
+
export declare function homeScrollOffset(): number;
|
|
66
|
+
export declare function endScrollOffset(contentLength: number, viewportHeight: number): number;
|
|
67
|
+
export declare function ensureCursorVisible(state: CursorViewportState): CursorViewportState;
|
|
68
|
+
export declare function moveCursor(state: CursorViewportState, delta: number): CursorViewportState;
|
|
69
|
+
export declare function pageCursor(state: CursorViewportState, direction: PaneDirection, pageSize?: number): CursorViewportState;
|
|
70
|
+
export declare function homeCursor(state: CursorViewportState): CursorViewportState;
|
|
71
|
+
export declare function endCursor(state: CursorViewportState): CursorViewportState;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
export function clamp(value, min, max) {
|
|
2
|
+
return Math.max(min, Math.min(max, value));
|
|
3
|
+
}
|
|
4
|
+
export function togglePaneFocus(focus) {
|
|
5
|
+
return focus === "left" ? "right" : "left";
|
|
6
|
+
}
|
|
7
|
+
export function computeSplitPaneLayout(opts) {
|
|
8
|
+
const separatorWidth = opts.separatorWidth ?? 3;
|
|
9
|
+
const interiorWidth = Math.max(0, opts.totalWidth - separatorWidth);
|
|
10
|
+
const basis = opts.fractionBasis === "interior" ? interiorWidth : opts.totalWidth;
|
|
11
|
+
const rawLeft = Math.round(Math.max(0, basis) * opts.leftFraction);
|
|
12
|
+
const capped = Math.min(opts.leftMaxWidth ?? Number.POSITIVE_INFINITY, Math.max(0, interiorWidth - opts.minRightWidth));
|
|
13
|
+
const maxLeft = capped > 0 ? capped : interiorWidth;
|
|
14
|
+
const minLeft = Math.min(opts.minLeftWidth, maxLeft);
|
|
15
|
+
const leftWidth = clamp(rawLeft, minLeft, maxLeft);
|
|
16
|
+
return {
|
|
17
|
+
leftWidth,
|
|
18
|
+
rightWidth: Math.max(0, interiorWidth - leftWidth),
|
|
19
|
+
interiorWidth,
|
|
20
|
+
leftFraction: opts.leftFraction,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
export function resizeSplitPane(opts) {
|
|
24
|
+
const separatorWidth = opts.separatorWidth ?? 3;
|
|
25
|
+
const interiorWidth = Math.max(1, opts.totalWidth - separatorWidth);
|
|
26
|
+
const current = computeSplitPaneLayout(opts);
|
|
27
|
+
const basis = opts.fractionBasis === "interior" ? interiorWidth : Math.max(1, opts.totalWidth);
|
|
28
|
+
const nextFraction = opts.fractionBasis === "interior"
|
|
29
|
+
? (current.leftWidth + opts.direction * opts.stepCols) / basis
|
|
30
|
+
: opts.leftFraction + (opts.direction * opts.stepCols) / basis;
|
|
31
|
+
const next = computeSplitPaneLayout({
|
|
32
|
+
...opts,
|
|
33
|
+
leftFraction: clamp(nextFraction, opts.minFraction ?? 0.2, opts.maxFraction ?? 0.7),
|
|
34
|
+
});
|
|
35
|
+
return next.leftWidth === current.leftWidth ? current : next;
|
|
36
|
+
}
|
|
37
|
+
export function computeFixedSidebarLayout(opts) {
|
|
38
|
+
const separatorWidth = opts.separatorWidth ?? 3;
|
|
39
|
+
if (opts.collapsed) {
|
|
40
|
+
const leftWidth = Math.max(0, opts.collapsedLeftWidth ?? 0);
|
|
41
|
+
const chromeWidth = opts.collapsedChromeWidth ?? (leftWidth > 0 ? separatorWidth : 2);
|
|
42
|
+
return {
|
|
43
|
+
collapsed: true,
|
|
44
|
+
leftWidth,
|
|
45
|
+
rightWidth: Math.max(0, opts.totalWidth - chromeWidth - leftWidth),
|
|
46
|
+
interiorWidth: Math.max(0, opts.totalWidth - chromeWidth),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
const interiorWidth = Math.max(0, opts.totalWidth - separatorWidth);
|
|
50
|
+
const minRight = opts.minRightWidth ?? 0;
|
|
51
|
+
const maxLeft = Math.max(0, interiorWidth - minRight);
|
|
52
|
+
const minLeft = Math.min(opts.minLeftWidth ?? 0, maxLeft);
|
|
53
|
+
const leftWidth = clamp(opts.leftWidth, minLeft, maxLeft || interiorWidth);
|
|
54
|
+
return {
|
|
55
|
+
collapsed: false,
|
|
56
|
+
leftWidth,
|
|
57
|
+
rightWidth: Math.max(0, interiorWidth - leftWidth),
|
|
58
|
+
interiorWidth,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
export function toggleSidebar(state) {
|
|
62
|
+
const collapsed = !state.collapsed;
|
|
63
|
+
return { collapsed, focus: collapsed ? "right" : state.focus };
|
|
64
|
+
}
|
|
65
|
+
export function clampScrollOffset(offset, contentLength, viewportHeight) {
|
|
66
|
+
const maxOffset = Math.max(0, contentLength - Math.max(0, viewportHeight));
|
|
67
|
+
return clamp(offset, 0, maxOffset);
|
|
68
|
+
}
|
|
69
|
+
export function moveScrollOffset(state, delta) {
|
|
70
|
+
return clampScrollOffset(state.offset + delta, state.contentLength, state.viewportHeight);
|
|
71
|
+
}
|
|
72
|
+
export function pageScrollOffset(state, direction, pageSize = state.viewportHeight) {
|
|
73
|
+
return moveScrollOffset(state, direction * Math.max(1, pageSize));
|
|
74
|
+
}
|
|
75
|
+
export function homeScrollOffset() {
|
|
76
|
+
return 0;
|
|
77
|
+
}
|
|
78
|
+
export function endScrollOffset(contentLength, viewportHeight) {
|
|
79
|
+
return clampScrollOffset(Number.POSITIVE_INFINITY, contentLength, viewportHeight);
|
|
80
|
+
}
|
|
81
|
+
export function ensureCursorVisible(state) {
|
|
82
|
+
const itemCount = Math.max(0, state.itemCount);
|
|
83
|
+
const viewportHeight = Math.max(1, state.viewportHeight);
|
|
84
|
+
const cursor = itemCount === 0 ? 0 : clamp(state.cursor, 0, itemCount - 1);
|
|
85
|
+
let scroll = clampScrollOffset(state.scroll, itemCount, viewportHeight);
|
|
86
|
+
if (itemCount === 0)
|
|
87
|
+
return { cursor, scroll: 0, itemCount, viewportHeight };
|
|
88
|
+
if (cursor < scroll)
|
|
89
|
+
scroll = cursor;
|
|
90
|
+
else if (cursor >= scroll + viewportHeight)
|
|
91
|
+
scroll = cursor - viewportHeight + 1;
|
|
92
|
+
scroll = clampScrollOffset(scroll, itemCount, viewportHeight);
|
|
93
|
+
return { cursor, scroll, itemCount, viewportHeight };
|
|
94
|
+
}
|
|
95
|
+
export function moveCursor(state, delta) {
|
|
96
|
+
return ensureCursorVisible({ ...state, cursor: state.cursor + delta });
|
|
97
|
+
}
|
|
98
|
+
export function pageCursor(state, direction, pageSize = state.viewportHeight) {
|
|
99
|
+
return moveCursor(state, direction * Math.max(1, pageSize));
|
|
100
|
+
}
|
|
101
|
+
export function homeCursor(state) {
|
|
102
|
+
return ensureCursorVisible({ ...state, cursor: 0 });
|
|
103
|
+
}
|
|
104
|
+
export function endCursor(state) {
|
|
105
|
+
return ensureCursorVisible({ ...state, cursor: Math.max(0, state.itemCount - 1) });
|
|
106
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal theme shape for chrome helpers: only `fg(color, text)` is required.
|
|
3
|
+
* Lets the same helpers work for both `@earendil-works/pi-coding-agent` Theme and
|
|
4
|
+
* any pi-tui-compatible theme.
|
|
5
|
+
*/
|
|
6
|
+
export interface ChromeTheme {
|
|
7
|
+
fg(color: string, text: string): string;
|
|
8
|
+
bold?(text: string): string;
|
|
9
|
+
}
|
|
10
|
+
export declare function fuzzyFilter<T extends {
|
|
11
|
+
name: string;
|
|
12
|
+
description: string;
|
|
13
|
+
model?: string;
|
|
14
|
+
}>(items: T[], query: string): T[];
|
|
15
|
+
export declare function pad(s: string, len: number): string;
|
|
16
|
+
export declare function row(content: string, width: number, theme: ChromeTheme): string;
|
|
17
|
+
export declare function renderHeader(text: string, width: number, theme: ChromeTheme): string;
|
|
18
|
+
export declare function formatPath(filePath: string): string;
|
|
19
|
+
export declare function formatScrollInfo(above: number, below: number, opts?: {
|
|
20
|
+
style?: "more" | "position";
|
|
21
|
+
}): string;
|
|
22
|
+
export declare function renderFooter(text: string, width: number, theme: ChromeTheme): string;
|
|
23
|
+
export interface TitledTopSegmentOptions {
|
|
24
|
+
width: number;
|
|
25
|
+
label: string;
|
|
26
|
+
/** Plain-text tail; styled via `tailColor` (default "dim"). */
|
|
27
|
+
tail?: string;
|
|
28
|
+
/** Pre-rendered tail string when caller already applied ANSI; pair with `tailPlain` for length. */
|
|
29
|
+
tailRendered?: string;
|
|
30
|
+
/** Plain visible-width companion for `tailRendered` so dash math stays correct. */
|
|
31
|
+
tailPlain?: string;
|
|
32
|
+
labelColor?: string;
|
|
33
|
+
tailColor?: string;
|
|
34
|
+
labelBold?: boolean;
|
|
35
|
+
style?: "default" | "legacy";
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Build one half of a top border with an embedded title and optional right-aligned tail.
|
|
39
|
+
* Layout: `─ <label> ───…─── <tail> ─` with single dashes for spacing.
|
|
40
|
+
* Returns a fragment WITHOUT corner glyphs; caller composes corners + `┬` divider.
|
|
41
|
+
*/
|
|
42
|
+
export declare function titledTopSegment(theme: ChromeTheme, opts: TitledTopSegmentOptions): string;
|
|
43
|
+
/**
|
|
44
|
+
* Build one half of a bottom border with an embedded hint string.
|
|
45
|
+
* Layout: `─ <hint> ───…─` (no tail). Empty hint renders as a solid dash run.
|
|
46
|
+
*/
|
|
47
|
+
export declare function titledBottomSegment(theme: ChromeTheme, width: number, hint: string, focused: boolean): string;
|
|
48
|
+
/** Pad-right to a visible width using `visibleWidth` (ANSI-aware). */
|
|
49
|
+
export declare function padRight(text: string, width: number): string;
|
|
50
|
+
/** Slice text by visible width and preserve ANSI color resets when present. */
|
|
51
|
+
export declare function clipStyled(text: string, width: number): string;
|
|
52
|
+
/** Slice text by character count (NOT visible width). For label text after `clipText` we then re-measure with visibleWidth. */
|
|
53
|
+
export declare function clipText(text: string, width: number): string;
|
|
54
|
+
/**
|
|
55
|
+
* Inline horizontal rule with an embedded title, NO corner/tee glyphs.
|
|
56
|
+
* Used inside a pane to subdivide sections (e.g. list / legend) without
|
|
57
|
+
* faking a second box border. Layout: `─ <title> ──…──`.
|
|
58
|
+
*/
|
|
59
|
+
export declare function flatRule(theme: ChromeTheme, title: string, width: number, opts?: {
|
|
60
|
+
leadingDashes?: number;
|
|
61
|
+
}): string;
|
|
62
|
+
/** Body row for a two-pane box. Caller supplies the left and right interior widths. */
|
|
63
|
+
export declare function boxRow(theme: ChromeTheme, left: string, right: string, leftWidth: number, rightWidth: number): string;
|
|
64
|
+
/** Aligned key legend row, dimmed by the caller when desired. */
|
|
65
|
+
export declare function renderKeyRow(key: string, desc: string, width: number, keyWidth: number): string;
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
function fuzzyScore(query, text) {
|
|
3
|
+
const lq = query.toLowerCase();
|
|
4
|
+
const lt = text.toLowerCase();
|
|
5
|
+
if (lt.includes(lq))
|
|
6
|
+
return 100 + (lq.length / lt.length) * 50;
|
|
7
|
+
let score = 0;
|
|
8
|
+
let qi = 0;
|
|
9
|
+
let consecutive = 0;
|
|
10
|
+
for (let i = 0; i < lt.length && qi < lq.length; i++) {
|
|
11
|
+
if (lt[i] === lq[qi]) {
|
|
12
|
+
score += 10 + consecutive;
|
|
13
|
+
consecutive += 5;
|
|
14
|
+
qi++;
|
|
15
|
+
}
|
|
16
|
+
else {
|
|
17
|
+
consecutive = 0;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return qi === lq.length ? score : 0;
|
|
21
|
+
}
|
|
22
|
+
export function fuzzyFilter(items, query) {
|
|
23
|
+
const q = query.trim();
|
|
24
|
+
if (!q)
|
|
25
|
+
return items;
|
|
26
|
+
return items
|
|
27
|
+
.map((item) => ({ item, score: Math.max(fuzzyScore(q, item.name), fuzzyScore(q, item.description) * 0.8, fuzzyScore(q, item.model ?? "") * 0.6) }))
|
|
28
|
+
.filter((x) => x.score > 0)
|
|
29
|
+
.sort((a, b) => b.score - a.score)
|
|
30
|
+
.map((x) => x.item);
|
|
31
|
+
}
|
|
32
|
+
export function pad(s, len) {
|
|
33
|
+
const vis = visibleWidth(s);
|
|
34
|
+
return s + " ".repeat(Math.max(0, len - vis));
|
|
35
|
+
}
|
|
36
|
+
function normalizeRenderableText(text) {
|
|
37
|
+
return text
|
|
38
|
+
.replaceAll("\t", " ")
|
|
39
|
+
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001A\u001C-\u001F\u007F]/g, "");
|
|
40
|
+
}
|
|
41
|
+
export function row(content, width, theme) {
|
|
42
|
+
const innerW = Math.max(0, width - 2);
|
|
43
|
+
const safeContent = truncateToWidth(normalizeRenderableText(content), innerW);
|
|
44
|
+
return theme.fg("border", "│") + pad(safeContent, innerW) + theme.fg("border", "│");
|
|
45
|
+
}
|
|
46
|
+
export function renderHeader(text, width, theme) {
|
|
47
|
+
const innerW = Math.max(0, width - 2);
|
|
48
|
+
const safeText = truncateToWidth(normalizeRenderableText(text), innerW);
|
|
49
|
+
const padLen = Math.max(0, innerW - visibleWidth(safeText));
|
|
50
|
+
const padLeft = Math.floor(padLen / 2);
|
|
51
|
+
const padRight = padLen - padLeft;
|
|
52
|
+
return (theme.fg("border", "╭" + "─".repeat(padLeft)) +
|
|
53
|
+
theme.fg("accent", safeText) +
|
|
54
|
+
theme.fg("border", "─".repeat(padRight) + "╮"));
|
|
55
|
+
}
|
|
56
|
+
export function formatPath(filePath) {
|
|
57
|
+
const home = process.env.HOME;
|
|
58
|
+
if (home && filePath.startsWith(home))
|
|
59
|
+
return `~${filePath.slice(home.length)}`;
|
|
60
|
+
return filePath;
|
|
61
|
+
}
|
|
62
|
+
export function formatScrollInfo(above, below, opts) {
|
|
63
|
+
if (opts?.style === "position")
|
|
64
|
+
return `${above}/${below}`;
|
|
65
|
+
let info = "";
|
|
66
|
+
if (above > 0)
|
|
67
|
+
info += `↑ ${above} more`;
|
|
68
|
+
if (below > 0)
|
|
69
|
+
info += `${info ? " " : ""}↓ ${below} more`;
|
|
70
|
+
return info;
|
|
71
|
+
}
|
|
72
|
+
export function renderFooter(text, width, theme) {
|
|
73
|
+
const innerW = Math.max(0, width - 2);
|
|
74
|
+
const safeText = truncateToWidth(normalizeRenderableText(text), innerW);
|
|
75
|
+
const padLen = Math.max(0, innerW - visibleWidth(safeText));
|
|
76
|
+
const padLeft = Math.floor(padLen / 2);
|
|
77
|
+
const padRight = padLen - padLeft;
|
|
78
|
+
return (theme.fg("border", "╰" + "─".repeat(padLeft)) +
|
|
79
|
+
theme.fg("dim", safeText) +
|
|
80
|
+
theme.fg("border", "─".repeat(padRight) + "╯"));
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Build one half of a top border with an embedded title and optional right-aligned tail.
|
|
84
|
+
* Layout: `─ <label> ───…─── <tail> ─` with single dashes for spacing.
|
|
85
|
+
* Returns a fragment WITHOUT corner glyphs; caller composes corners + `┬` divider.
|
|
86
|
+
*/
|
|
87
|
+
export function titledTopSegment(theme, opts) {
|
|
88
|
+
const dash = (n) => theme.fg("dim", "─".repeat(Math.max(0, n)));
|
|
89
|
+
if (opts.style === "legacy") {
|
|
90
|
+
const width = Math.max(0, opts.width);
|
|
91
|
+
if (width <= 0)
|
|
92
|
+
return "";
|
|
93
|
+
if (width <= 2)
|
|
94
|
+
return dash(width);
|
|
95
|
+
const labelColor = opts.labelColor ?? "text";
|
|
96
|
+
const tailColor = opts.tailColor ?? "dim";
|
|
97
|
+
const tailPlain = opts.tailPlain ?? opts.tail ?? "";
|
|
98
|
+
const tailRendered = opts.tailRendered ?? (opts.tail !== undefined ? theme.fg(tailColor, opts.tail) : "");
|
|
99
|
+
const tailLen = visibleWidth(tailPlain);
|
|
100
|
+
const canShowTail = tailLen > 0 && width - tailLen >= 14;
|
|
101
|
+
const labelBudget = Math.max(0, width - (canShowTail ? tailLen + 6 : 4));
|
|
102
|
+
const labelText = clipText(opts.label ?? "", labelBudget);
|
|
103
|
+
const labelLen = visibleWidth(labelText);
|
|
104
|
+
if (labelLen === 0)
|
|
105
|
+
return dash(width);
|
|
106
|
+
const labelStyled = opts.labelBold && theme.bold
|
|
107
|
+
? theme.bold(theme.fg(labelColor, labelText))
|
|
108
|
+
: theme.fg(labelColor, labelText);
|
|
109
|
+
if (canShowTail) {
|
|
110
|
+
const fillDashes = Math.max(1, width - labelLen - tailLen - 6);
|
|
111
|
+
return clipStyled(`${dash(1)} ${labelStyled} ${dash(fillDashes)} ${tailRendered} ${dash(1)}`, width);
|
|
112
|
+
}
|
|
113
|
+
const fillDashes = Math.max(1, width - labelLen - 3);
|
|
114
|
+
return clipStyled(`${dash(1)} ${labelStyled} ${dash(fillDashes)}`, width);
|
|
115
|
+
}
|
|
116
|
+
const width = Math.max(0, opts.width);
|
|
117
|
+
if (width <= 0)
|
|
118
|
+
return "";
|
|
119
|
+
const labelColor = opts.labelColor ?? "text";
|
|
120
|
+
const tailColor = opts.tailColor ?? "dim";
|
|
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);
|
|
130
|
+
// Defensive: callers occasionally pass undefined labels for transient/legacy
|
|
131
|
+
// runs that lack agent+mode+label; treat as empty rather than crashing pi.
|
|
132
|
+
const rawLabel = opts.label ?? "";
|
|
133
|
+
const labelText = truncateToWidth(rawLabel, labelBudget, "").replace(/\u001b\[[0-9;]*m/g, "");
|
|
134
|
+
const labelStyled = opts.labelBold && theme.bold
|
|
135
|
+
? theme.bold(theme.fg(labelColor, labelText))
|
|
136
|
+
: theme.fg(labelColor, labelText);
|
|
137
|
+
const labelLen = visibleWidth(labelText);
|
|
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);
|
|
144
|
+
}
|
|
145
|
+
const fillDashes = Math.max(0, width - (labelLen + 3));
|
|
146
|
+
return clipStyled(`${dash(1)} ${labelStyled} ${dash(fillDashes)}`, width);
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Build one half of a bottom border with an embedded hint string.
|
|
150
|
+
* Layout: `─ <hint> ───…─` (no tail). Empty hint renders as a solid dash run.
|
|
151
|
+
*/
|
|
152
|
+
export function titledBottomSegment(theme, width, hint, focused) {
|
|
153
|
+
const dash = (n) => theme.fg("dim", "─".repeat(Math.max(0, n)));
|
|
154
|
+
if (width <= 0)
|
|
155
|
+
return "";
|
|
156
|
+
if (!hint)
|
|
157
|
+
return dash(width);
|
|
158
|
+
// Reserve at minimum `─ ` + hint + ` `; truncate hint to fit when narrow so we never overflow.
|
|
159
|
+
const hintBudget = Math.max(0, width - 3);
|
|
160
|
+
const clipped = truncateToWidth(hint, hintBudget);
|
|
161
|
+
const clippedLen = visibleWidth(clipped);
|
|
162
|
+
if (clippedLen === 0)
|
|
163
|
+
return dash(width);
|
|
164
|
+
const hintStyled = focused && theme.bold
|
|
165
|
+
? theme.bold(theme.fg("accent", clipped))
|
|
166
|
+
: theme.fg("dim", clipped);
|
|
167
|
+
const fillDashes = Math.max(0, width - (clippedLen + 3));
|
|
168
|
+
return `${dash(1)} ${hintStyled} ${dash(fillDashes)}`;
|
|
169
|
+
}
|
|
170
|
+
/** Pad-right to a visible width using `visibleWidth` (ANSI-aware). */
|
|
171
|
+
export function padRight(text, width) {
|
|
172
|
+
const clipped = visibleWidth(text) > width ? clipStyled(text, width) : text;
|
|
173
|
+
const pad = Math.max(0, width - visibleWidth(clipped));
|
|
174
|
+
return clipped + " ".repeat(pad);
|
|
175
|
+
}
|
|
176
|
+
/** Slice text by visible width and preserve ANSI color resets when present. */
|
|
177
|
+
export function clipStyled(text, width) {
|
|
178
|
+
if (width <= 0)
|
|
179
|
+
return "";
|
|
180
|
+
if (!text.includes("\u001b["))
|
|
181
|
+
return clipText(text, width);
|
|
182
|
+
return truncateToWidth(text, width, "");
|
|
183
|
+
}
|
|
184
|
+
/** Slice text by character count (NOT visible width). For label text after `clipText` we then re-measure with visibleWidth. */
|
|
185
|
+
export function clipText(text, width) {
|
|
186
|
+
if (width <= 0)
|
|
187
|
+
return "";
|
|
188
|
+
return Array.from(text).slice(0, width).join("");
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Inline horizontal rule with an embedded title, NO corner/tee glyphs.
|
|
192
|
+
* Used inside a pane to subdivide sections (e.g. list / legend) without
|
|
193
|
+
* faking a second box border. Layout: `─ <title> ──…──`.
|
|
194
|
+
*/
|
|
195
|
+
export function flatRule(theme, title, width, opts) {
|
|
196
|
+
if (width <= 0)
|
|
197
|
+
return "";
|
|
198
|
+
const dash = (n) => theme.fg("dim", "─".repeat(Math.max(0, n)));
|
|
199
|
+
if (!title)
|
|
200
|
+
return dash(width);
|
|
201
|
+
if (opts?.leadingDashes !== undefined) {
|
|
202
|
+
const label = ` ${title} `;
|
|
203
|
+
const labelW = visibleWidth(label);
|
|
204
|
+
if (labelW + 4 >= width)
|
|
205
|
+
return dash(width);
|
|
206
|
+
const leading = Math.max(0, opts.leadingDashes);
|
|
207
|
+
const trailing = Math.max(0, width - labelW - leading);
|
|
208
|
+
return `${dash(leading)}${theme.fg("dim", label)}${dash(trailing)}`;
|
|
209
|
+
}
|
|
210
|
+
const clipped = truncateToWidth(title, Math.max(0, width - 4));
|
|
211
|
+
const clippedLen = visibleWidth(clipped);
|
|
212
|
+
const styled = theme.fg("dim", clipped);
|
|
213
|
+
const trailing = Math.max(0, width - (clippedLen + 3));
|
|
214
|
+
return `${dash(1)} ${styled} ${dash(trailing)}`;
|
|
215
|
+
}
|
|
216
|
+
/** Body row for a two-pane box. Caller supplies the left and right interior widths. */
|
|
217
|
+
export function boxRow(theme, left, right, leftWidth, rightWidth) {
|
|
218
|
+
const border = theme.fg("dim", "│");
|
|
219
|
+
const leftCell = padRight(truncateToWidth(left, leftWidth), leftWidth);
|
|
220
|
+
const rightCell = padRight(truncateToWidth(right, rightWidth), rightWidth);
|
|
221
|
+
return border + leftCell + border + rightCell + border;
|
|
222
|
+
}
|
|
223
|
+
/** Aligned key legend row, dimmed by the caller when desired. */
|
|
224
|
+
export function renderKeyRow(key, desc, width, keyWidth) {
|
|
225
|
+
const keyCell = padRight(key, keyWidth);
|
|
226
|
+
return clipStyled(`${keyCell} ${desc}`, width);
|
|
227
|
+
}
|