pi-extension-utils 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,7 @@
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";
package/dist/src/index.js CHANGED
@@ -1,4 +1,7 @@
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";
@@ -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,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,218 @@
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 labelColor = opts.labelColor ?? "text";
117
+ 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;
120
+ // Defensive: callers occasionally pass undefined labels for transient/legacy
121
+ // runs that lack agent+mode+label; treat as empty rather than crashing pi.
122
+ const labelText = truncateToWidth(opts.label ?? "", labelBudget);
123
+ const labelStyled = opts.labelBold && theme.bold
124
+ ? theme.bold(theme.fg(labelColor, labelText))
125
+ : 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
+ 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)}`;
135
+ }
136
+ const fillDashes = Math.max(1, opts.width - (labelLen + 3));
137
+ return `${dash(1)} ${labelStyled} ${dash(fillDashes)}`;
138
+ }
139
+ /**
140
+ * Build one half of a bottom border with an embedded hint string.
141
+ * Layout: `─ <hint> ───…─` (no tail). Empty hint renders as a solid dash run.
142
+ */
143
+ export function titledBottomSegment(theme, width, hint, focused) {
144
+ const dash = (n) => theme.fg("dim", "─".repeat(Math.max(0, n)));
145
+ if (width <= 0)
146
+ return "";
147
+ if (!hint)
148
+ return dash(width);
149
+ // Reserve at minimum `─ ` + hint + ` `; truncate hint to fit when narrow so we never overflow.
150
+ const hintBudget = Math.max(0, width - 3);
151
+ const clipped = truncateToWidth(hint, hintBudget);
152
+ const clippedLen = visibleWidth(clipped);
153
+ if (clippedLen === 0)
154
+ return dash(width);
155
+ const hintStyled = focused && theme.bold
156
+ ? theme.bold(theme.fg("accent", clipped))
157
+ : theme.fg("dim", clipped);
158
+ const fillDashes = Math.max(0, width - (clippedLen + 3));
159
+ return `${dash(1)} ${hintStyled} ${dash(fillDashes)}`;
160
+ }
161
+ /** Pad-right to a visible width using `visibleWidth` (ANSI-aware). */
162
+ export function padRight(text, width) {
163
+ const clipped = visibleWidth(text) > width ? clipStyled(text, width) : text;
164
+ const pad = Math.max(0, width - visibleWidth(clipped));
165
+ return clipped + " ".repeat(pad);
166
+ }
167
+ /** Slice text by visible width and preserve ANSI color resets when present. */
168
+ export function clipStyled(text, width) {
169
+ if (width <= 0)
170
+ return "";
171
+ if (!text.includes("\u001b["))
172
+ return clipText(text, width);
173
+ return truncateToWidth(text, width, "");
174
+ }
175
+ /** Slice text by character count (NOT visible width). For label text after `clipText` we then re-measure with visibleWidth. */
176
+ export function clipText(text, width) {
177
+ if (width <= 0)
178
+ return "";
179
+ return Array.from(text).slice(0, width).join("");
180
+ }
181
+ /**
182
+ * Inline horizontal rule with an embedded title, NO corner/tee glyphs.
183
+ * Used inside a pane to subdivide sections (e.g. list / legend) without
184
+ * faking a second box border. Layout: `─ <title> ──…──`.
185
+ */
186
+ export function flatRule(theme, title, width, opts) {
187
+ if (width <= 0)
188
+ return "";
189
+ const dash = (n) => theme.fg("dim", "─".repeat(Math.max(0, n)));
190
+ if (!title)
191
+ return dash(width);
192
+ if (opts?.leadingDashes !== undefined) {
193
+ const label = ` ${title} `;
194
+ const labelW = visibleWidth(label);
195
+ if (labelW + 4 >= width)
196
+ return dash(width);
197
+ const leading = Math.max(0, opts.leadingDashes);
198
+ const trailing = Math.max(0, width - labelW - leading);
199
+ return `${dash(leading)}${theme.fg("dim", label)}${dash(trailing)}`;
200
+ }
201
+ const clipped = truncateToWidth(title, Math.max(0, width - 4));
202
+ const clippedLen = visibleWidth(clipped);
203
+ const styled = theme.fg("dim", clipped);
204
+ const trailing = Math.max(0, width - (clippedLen + 3));
205
+ return `${dash(1)} ${styled} ${dash(trailing)}`;
206
+ }
207
+ /** Body row for a two-pane box. Caller supplies the left and right interior widths. */
208
+ export function boxRow(theme, left, right, leftWidth, rightWidth) {
209
+ const border = theme.fg("dim", "│");
210
+ const leftCell = padRight(truncateToWidth(left, leftWidth), leftWidth);
211
+ const rightCell = padRight(truncateToWidth(right, rightWidth), rightWidth);
212
+ return border + leftCell + border + rightCell + border;
213
+ }
214
+ /** Aligned key legend row, dimmed by the caller when desired. */
215
+ export function renderKeyRow(key, desc, width, keyWidth) {
216
+ const keyCell = padRight(key, keyWidth);
217
+ return clipStyled(`${keyCell} ${desc}`, width);
218
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-extension-utils",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
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",