@estebanforge/pi-tui-dialogs 0.0.0-stage → 0.1.2

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/LICENSE ADDED
@@ -0,0 +1,25 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 EstebanForge
4
+
5
+ Portions of the dialog pattern (frame, menu, and input dialog structure) are
6
+ adapted from pi-subagent-manager, MIT License, Copyright (c) 2026
7
+ pi-subagent contributors (https://github.com/championswimmer/pi-subagent-manager).
8
+
9
+ Permission is hereby granted, free of charge, to any person obtaining a copy
10
+ of this software and associated documentation files (the "Software"), to deal
11
+ in the Software without restriction, including without limitation the rights
12
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13
+ copies of the Software, and to permit persons to whom the Software is
14
+ furnished to do so, subject to the following conditions:
15
+
16
+ The above copyright notice and this permission notice shall be included in all
17
+ copies or substantial portions of the Software.
18
+
19
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,74 @@
1
- # Temporary Holding Version
1
+ # @estebanforge/pi-tui-dialogs
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Framed TUI dialog kit for `@estebanforge` Pi Coding Agent extensions. One
4
+ settings/picker pattern for every extension instead of one per package.
5
+
6
+ Dialog pattern adapted from
7
+ [pi-subagent-manager](https://github.com/championswimmer/pi-subagent-manager)
8
+ (MIT); tabs and type-to-search follow the Zellij modal they replace.
9
+
10
+ ## What it ships
11
+
12
+ - `frameDialog` / `dialogHeight` / `DIALOG_OPTIONS` — shared rounded frame:
13
+ title in the top border, body, footer line above the bottom border, padded to
14
+ a stable height so centered overlays do not jump between views.
15
+ - `DialogMenu` / `dialogMenu` — framed menu with `label │ value` rows, a
16
+ counter line, a muted help panel for the selected row, optional tab groups
17
+ (left/right to switch) and type-to-search within the active tab. Rows with
18
+ `values` cycle in place on Enter; rows with `onEdit` run a custom action and
19
+ keep the menu open; plain rows resolve the menu with their id.
20
+ - `dialogInput` — framed single-line input with placeholder, help lines, and
21
+ optional `validate`: return an error string to keep the dialog open with the
22
+ message inline, return `undefined` to accept.
23
+ - `dialogChoice` — framed single-choice modal: one decision statement (wraps),
24
+ a closed option list of 1 to `MAX_CHOICE_OPTIONS` (10) options with unique
25
+ values and an optional preselect. Enter resolves the chosen option's value;
26
+ esc resolves `undefined` (the escape hatch). Caller-contract violations (0
27
+ or >10 options, duplicate values, unknown preselect) throw before any
28
+ overlay opens.
29
+ - `canOpenDialog` — headless guard for non-TUI modes.
30
+
31
+ ## Usage
32
+
33
+ ```ts
34
+ import { canOpenDialog, dialogChoice, dialogInput, dialogMenu, DialogMenu } from "@estebanforge/pi-tui-dialogs";
35
+
36
+ await ctx.ui.custom(
37
+ (host, theme, _keys, done) =>
38
+ new DialogMenu(
39
+ host,
40
+ theme,
41
+ {
42
+ title: "My extension",
43
+ subtitle: "Project file: .pi/my-extension.json",
44
+ tabs: [
45
+ { label: "General", rows: [{ id: "enabled", label: "Enabled", value: "on", values: ["on", "off"], help: "Master switch." }] },
46
+ { label: "Limits", rows: [{ id: "cap", label: "Cap", value: "100", onEdit: () => void dialogInput(ctx, "Cap", { initial: "100", validate: (raw) => (/^\d+$/.test(raw) ? undefined : "must be a number") }) }] },
47
+ ],
48
+ onChange: (id, value) => save(id, value),
49
+ },
50
+ () => done(),
51
+ ),
52
+ // DIALOG_OPTIONS centers the dialog near fullscreen
53
+ );
54
+ ```
55
+
56
+ A closed choice reads the same way:
57
+
58
+ ```ts
59
+ const engine = await dialogChoice(ctx, "Choose your engine", {
60
+ description: "Which engine runs your turns? Switch anytime with /engine.",
61
+ options: [
62
+ { value: "fast", label: "Fast", description: "default · lighter" },
63
+ { value: "deep", label: "Deep", description: "downloads a big binary" },
64
+ ],
65
+ selected: "fast",
66
+ });
67
+ // engine === undefined means esc: decide later, persist nothing.
68
+ ```
69
+
70
+ Plain library, no Pi entrypoint: depend on it and Pi installs it transitively.
71
+
72
+ ## License
73
+
74
+ MIT. See LICENSE for the pi-subagent-manager attribution.
package/package.json CHANGED
@@ -1,6 +1,51 @@
1
1
  {
2
2
  "name": "@estebanforge/pi-tui-dialogs",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.2",
4
+ "description": "Framed TUI dialog kit for @estebanforge Pi extensions: bordered settings menus with label/value columns, help panel, tabs, type-to-search, and validated input dialogs. Dialog pattern adapted from pi-subagent-manager (MIT). Plain library, no Pi entrypoint: consumers depend on it and Pi installs it transitively.",
5
+ "keywords": [
6
+ "pi",
7
+ "tui",
8
+ "dialog",
9
+ "settings"
10
+ ],
11
+ "license": "MIT",
12
+ "author": {
13
+ "name": "EstebanForge",
14
+ "email": "esteban@attitude.cl",
15
+ "url": "https://actitud.xyz"
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/EstebanForge/pi-extensions.git",
20
+ "directory": "packages/pi-tui-dialogs"
21
+ },
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "type": "module",
26
+ "files": [
27
+ "src",
28
+ "README.md",
29
+ "LICENSE"
30
+ ],
31
+ "main": "./src/index.ts",
32
+ "exports": {
33
+ ".": "./src/index.ts"
34
+ },
35
+ "peerDependencies": {
36
+ "@earendil-works/pi-coding-agent": "^1.0.0",
37
+ "@earendil-works/pi-tui": "^1.0.0"
38
+ },
39
+ "peerDependenciesMeta": {
40
+ "@earendil-works/pi-coding-agent": {
41
+ "optional": true
42
+ },
43
+ "@earendil-works/pi-tui": {
44
+ "optional": true
45
+ }
46
+ },
47
+ "engines": {
48
+ "node": ">=22.0.0"
49
+ },
50
+ "sideEffects": "false"
51
+ }
package/src/choice.ts ADDED
@@ -0,0 +1,153 @@
1
+ // Framed single-choice modal: one decision statement plus a closed list of
2
+ // options, esc cancels. SelectList inside the shared frame. Pattern extracted
3
+ // from pi-antigravity-bridge's first-run engine picker.
4
+ import type { Theme } from "@earendil-works/pi-coding-agent";
5
+ import { wrapTextWithAnsi, type SelectItem, SelectList } from "@earendil-works/pi-tui";
6
+ import { DIALOG_OPTIONS, dialogHeight, dialogText, frameDialog, type DialogHost } from "./frame.js";
7
+ import type { DialogContext } from "./menu.js";
8
+
9
+ /** Hard cap on the option list. A closed choice stays scannable; past ten
10
+ * options the caller should filter or paginate before asking. */
11
+ export const MAX_CHOICE_OPTIONS = 10;
12
+
13
+ /** One closed choice. Values are the resolved contract: they must be unique
14
+ * and stable across releases (callers persist them). */
15
+ export interface ChoiceOption {
16
+ value: string;
17
+ label: string;
18
+ /** One-line hint under the label; the list truncates long lines. */
19
+ description?: string;
20
+ }
21
+
22
+ export interface ChoiceDialogOptions {
23
+ /** Options in answer order; the first is preselected unless `selected`. */
24
+ options: ChoiceOption[];
25
+ /** What the user is deciding; wraps to the frame width. */
26
+ description?: string;
27
+ /** Preselected option value; must match one option. */
28
+ selected?: string;
29
+ /** Footer hint; default "↑↓ navigate · enter select · esc cancel". */
30
+ footer?: string | readonly string[];
31
+ }
32
+
33
+ /** Caller-contract validation. Throws before any overlay opens: a choice
34
+ * modal rendered with 0 options, duplicate values, or a bogus preselect is
35
+ * a programming error, not a user-facing state. Used directly by ChoiceView
36
+ * (sync throw) and by dialogChoice (which rejects). */
37
+ export function assertChoiceOptions(options: ChoiceOption[], selected?: string): void {
38
+ if (options.length < 1) {
39
+ throw new RangeError(`dialogChoice needs at least 1 option, got ${options.length}`);
40
+ }
41
+ if (options.length > MAX_CHOICE_OPTIONS) {
42
+ throw new RangeError(`dialogChoice takes at most ${MAX_CHOICE_OPTIONS} options, got ${options.length}`);
43
+ }
44
+ const seen = new Set<string>();
45
+ for (const option of options) {
46
+ if (seen.has(option.value)) {
47
+ throw new RangeError(`dialogChoice options have duplicate value "${option.value}"`);
48
+ }
49
+ seen.add(option.value);
50
+ }
51
+ if (selected !== undefined && !seen.has(selected)) {
52
+ throw new RangeError(`dialogChoice selected "${selected}" matches no option`);
53
+ }
54
+ }
55
+
56
+ /** Hostable framed choice component. `done` resolves the decision: the
57
+ * chosen value, or undefined on esc. Render it inside your own overlay and
58
+ * route input to it while it is active. */
59
+ export class ChoiceView {
60
+ private readonly list: SelectList;
61
+ private settled = false;
62
+
63
+ constructor(
64
+ private host: DialogHost,
65
+ private theme: Theme,
66
+ readonly title: string,
67
+ readonly options: ChoiceDialogOptions,
68
+ private readonly done: (value: string | undefined) => void,
69
+ ) {
70
+ assertChoiceOptions(options.options, options.selected);
71
+ const items: SelectItem[] = options.options.map((option) => ({
72
+ value: option.value,
73
+ label: dialogText(option.label),
74
+ description: option.description === undefined ? undefined : dialogText(option.description),
75
+ }));
76
+ this.list = new SelectList(items, items.length, {
77
+ selectedPrefix: (t: string) => theme.fg("accent", t),
78
+ selectedText: (t: string) => theme.fg("accent", t),
79
+ description: (t: string) => theme.fg("muted", t),
80
+ scrollInfo: (t: string) => theme.fg("dim", t),
81
+ noMatch: (t: string) => theme.fg("warning", t),
82
+ });
83
+ if (options.selected !== undefined) {
84
+ this.list.setSelectedIndex(items.findIndex((item) => item.value === options.selected));
85
+ }
86
+ this.list.onSelect = (item) => this.finish(item.value);
87
+ this.list.onCancel = () => this.finish(undefined);
88
+ }
89
+
90
+ private finish(value: string | undefined): void {
91
+ if (this.settled) return;
92
+ this.settled = true;
93
+ this.done(value);
94
+ }
95
+
96
+ handleInput(data: string): void {
97
+ this.list.handleInput(data);
98
+ this.host.requestRender();
99
+ }
100
+
101
+ invalidate(): void {}
102
+
103
+ render(width: number): string[] {
104
+ const inner = Math.max(1, width - 4);
105
+ const height = dialogHeight(this.host);
106
+ const descriptionLines = this.options.description
107
+ ? wrapTextWithAnsi(dialogText(this.options.description), inner).map((line) =>
108
+ this.theme.fg("muted", ` ${line}`),
109
+ )
110
+ : [];
111
+ // Window the list to what the frame can actually show (DialogMenu's
112
+ // viewport rule). frameDialog clips the body to height - 4; without
113
+ // this the SelectList would render all options and let the selection
114
+ // move into clipped, invisible lines on short terminals or with long
115
+ // descriptions. The - 3 covers the two blank separators plus the
116
+ // "(n/m)" scroll line SelectList appends whenever the list is windowed
117
+ // (clipping that line hides the position hint). maxVisible is
118
+ // compile-time private on SelectList but a plain field at runtime;
119
+ // re-assigning it per render is the intended windowing knob (the
120
+ // constructor value only sized the initial view).
121
+ const listBudget = Math.max(1, height - 4 - descriptionLines.length - 3);
122
+ (this.list as unknown as { maxVisible: number }).maxVisible = listBudget;
123
+ return frameDialog(
124
+ this.theme,
125
+ width,
126
+ dialogHeight(this.host),
127
+ this.title,
128
+ [
129
+ "",
130
+ ...descriptionLines,
131
+ "",
132
+ ...this.list.render(inner).map((line) => ` ${line}`),
133
+ ],
134
+ this.options.footer ?? "↑↓ navigate · enter select · esc cancel",
135
+ );
136
+ }
137
+ }
138
+
139
+ /** Run one framed choice overlay. Resolves with the chosen option value, or
140
+ * undefined when cancelled (esc). Contract violations reject the promise
141
+ * before the overlay opens. */
142
+ export async function dialogChoice(
143
+ ctx: DialogContext,
144
+ title: string,
145
+ options: ChoiceDialogOptions,
146
+ ): Promise<string | undefined> {
147
+ // Fail before the overlay opens so a bad caller contract cannot flash UI.
148
+ assertChoiceOptions(options.options, options.selected);
149
+ return ctx.ui.custom<string | undefined>(
150
+ (host, theme, _keys, done) => new ChoiceView(host, theme, title, options, done),
151
+ DIALOG_OPTIONS,
152
+ );
153
+ }
package/src/frame.ts ADDED
@@ -0,0 +1,86 @@
1
+ // Framed dialog primitives. Dialog pattern adapted from pi-subagent-manager
2
+ // (MIT, Copyright (c) 2026 pi-subagent contributors); see LICENSE.
3
+ import {
4
+ stripTerminalSequences,
5
+ truncateToWidth,
6
+ visibleWidth,
7
+ } from "@earendil-works/pi-tui";
8
+
9
+ /** Minimal host a dialog needs: re-render requests plus terminal rows. */
10
+ export interface DialogHost {
11
+ requestRender(force?: boolean): void;
12
+ terminal?: { rows: number };
13
+ }
14
+
15
+ /** Overlay options shared by every dialog: centered, near-fullscreen. */
16
+ export const DIALOG_OPTIONS = {
17
+ overlay: true,
18
+ overlayOptions: {
19
+ anchor: "center",
20
+ width: "90%",
21
+ maxHeight: "90%",
22
+ margin: 1,
23
+ },
24
+ } as const;
25
+
26
+ /** Strip ANSI and control characters from untrusted row text. */
27
+ export function dialogText(text: string): string {
28
+ return stripTerminalSequences(text).replace(/[\x00-\x1f\x7f-\x9f]/g, " ");
29
+ }
30
+
31
+ /** Match Pi's overlay height calculation; maxHeight alone would clip the bottom border. */
32
+ export function dialogHeight(host: { terminal?: { rows: number } }): number {
33
+ const rows = host.terminal?.rows ?? 24;
34
+ return Math.max(1, Math.min(rows - 2, Math.floor(rows * 0.9)));
35
+ }
36
+
37
+ /** Render the shared rounded frame: title in the top border, body lines, footer above the bottom border.
38
+ * A string-array footer picks the first variant that fits the width (RTK undertitle behavior). */
39
+ export function frameDialog(
40
+ theme: FrameTheme,
41
+ width: number,
42
+ height: number,
43
+ title: string,
44
+ body: string[],
45
+ footer: string | readonly string[],
46
+ ): string[] {
47
+ if (width <= 0 || height <= 0) return [];
48
+ const fit = (text: string, columns: number) => {
49
+ const clipped = truncateToWidth(text, Math.max(0, columns), "", true);
50
+ return clipped + " ".repeat(Math.max(0, columns - visibleWidth(clipped)));
51
+ };
52
+ if (width < 4 || height < 4) {
53
+ return [fit(theme.fg("muted", dialogText(title)), width)];
54
+ }
55
+ const inner = width - 2;
56
+ const line = (text: string) => theme.fg("border", "│") + fit(text, inner) + theme.fg("border", "│");
57
+ const footerText = pickFooterVariant(footer, inner - 3);
58
+ const heading = truncateToWidth(` ${dialogText(title)} `, inner, "");
59
+ const top = theme.fg("border", "╭" + heading + "─".repeat(Math.max(0, inner - visibleWidth(heading))) + "╮");
60
+ // Pad to the full frame so centered overlays keep the same bounds between views.
61
+ const budget = height - 4;
62
+ const padded = body.slice(0, budget);
63
+ while (padded.length < budget) padded.push("");
64
+ const content = padded.map(line);
65
+ return [
66
+ top,
67
+ ...content,
68
+ theme.fg("border", "├" + "─".repeat(inner) + "┤"),
69
+ line(theme.fg("dim", dialogText(footerText))),
70
+ theme.fg("border", "╰" + "─".repeat(inner) + "╯"),
71
+ ];
72
+ }
73
+
74
+ /** The theme color slots dialogs use, a subset of Pi's Theme fg names. */
75
+ export type FrameTheme = {
76
+ fg(color: "border" | "muted" | "dim", text: string): string;
77
+ };
78
+
79
+ function pickFooterVariant(footer: string | readonly string[], maxWidth: number): string {
80
+ if (typeof footer === "string") return footer;
81
+ if (footer.length === 0) return "";
82
+ for (const variant of footer) {
83
+ if (visibleWidth(variant) <= maxWidth) return variant;
84
+ }
85
+ return footer[footer.length - 1]!;
86
+ }
package/src/index.ts ADDED
@@ -0,0 +1,27 @@
1
+ // Framed TUI dialog kit for @estebanforge Pi extensions. Dialog pattern
2
+ // adapted from pi-subagent-manager (MIT); see LICENSE for attribution.
3
+ export {
4
+ DIALOG_OPTIONS,
5
+ dialogHeight,
6
+ dialogText,
7
+ frameDialog,
8
+ type DialogHost,
9
+ type FrameTheme,
10
+ } from "./frame.js";
11
+ export {
12
+ canOpenDialog,
13
+ dialogMenu,
14
+ DialogMenu,
15
+ type DialogContext,
16
+ type DialogRow,
17
+ type DialogTab,
18
+ } from "./menu.js";
19
+ export { dialogInput, InputView, type DialogInputOptions } from "./input.js";
20
+ export {
21
+ assertChoiceOptions,
22
+ ChoiceView,
23
+ dialogChoice,
24
+ MAX_CHOICE_OPTIONS,
25
+ type ChoiceDialogOptions,
26
+ type ChoiceOption,
27
+ } from "./choice.js";
package/src/input.ts ADDED
@@ -0,0 +1,107 @@
1
+ // Framed single-line input with optional inline validation. Hostable view for
2
+ // composing inside an existing overlay (settings menu editor slot) plus a
3
+ // ctx-level dialogInput wrapper. Pattern adapted from pi-subagent-manager
4
+ // (MIT, Copyright (c) 2026 pi-subagent contributors).
5
+ import type { Theme } from "@earendil-works/pi-coding-agent";
6
+ import { Input, wrapTextWithAnsi } from "@earendil-works/pi-tui";
7
+ import { DIALOG_OPTIONS, dialogHeight, dialogText, frameDialog, type DialogHost } from "./frame.js";
8
+ import type { DialogContext } from "./menu.js";
9
+
10
+ export interface DialogInputOptions {
11
+ /** Starting value (cursor lands at its end). */
12
+ initial?: string;
13
+ /** Muted help lines under the field. */
14
+ help?: string;
15
+ /** Placeholder while the field is empty. */
16
+ placeholder?: string;
17
+ /** Return an error string to keep the editor open with the message inline;
18
+ * return undefined to accept. */
19
+ validate?: (raw: string) => string | undefined;
20
+ }
21
+
22
+ /** Hostable framed input component. `done` resolves the editing session:
23
+ * accepted string, or undefined on esc. Render it inside your own overlay
24
+ * and route input to it while it is active. */
25
+ export class InputView {
26
+ private readonly input: Input;
27
+ private errorText = "";
28
+ private settled = false;
29
+
30
+ constructor(
31
+ private host: DialogHost,
32
+ private theme: Theme,
33
+ readonly title: string,
34
+ readonly options: DialogInputOptions,
35
+ private readonly done: (value: string | undefined) => void,
36
+ ) {
37
+ this.input = new Input(options.placeholder ? { placeholder: options.placeholder } : undefined);
38
+ this.input.setValue(dialogText(options.initial ?? ""));
39
+ // pi-tui Input starts the cursor at column 0; place it at the end of the
40
+ // prefilled value (ctrl+e is the cursor-end binding) so editing behaves
41
+ // the way the prefilled field looks.
42
+ this.input.handleInput("\x05");
43
+ this.input.focused = true;
44
+ this.input.onSubmit = (value) => {
45
+ const message = this.options.validate?.(value);
46
+ if (message) {
47
+ this.errorText = message;
48
+ this.host.requestRender();
49
+ return;
50
+ }
51
+ this.finish(value);
52
+ };
53
+ this.input.onEscape = () => this.finish(undefined);
54
+ }
55
+
56
+ private finish(value: string | undefined): void {
57
+ if (this.settled) return;
58
+ this.settled = true;
59
+ this.done(value);
60
+ }
61
+
62
+ handleInput(data: string): void {
63
+ this.input.handleInput(data);
64
+ this.host.requestRender();
65
+ }
66
+
67
+ invalidate(): void {
68
+ this.input.invalidate();
69
+ }
70
+
71
+ render(width: number): string[] {
72
+ const helpLines = this.options.help
73
+ ? wrapTextWithAnsi(dialogText(this.options.help), Math.max(1, width - 5))
74
+ .map((line) => this.theme.fg("muted", ` ${line}`))
75
+ : [];
76
+ const errorLines = this.errorText
77
+ ? [this.theme.fg("error", ` ${dialogText(this.errorText)}`)]
78
+ : [];
79
+ return frameDialog(
80
+ this.theme,
81
+ width,
82
+ dialogHeight(this.host),
83
+ this.title,
84
+ [
85
+ "",
86
+ ...this.input.render(Math.max(1, width - 4)).map((line) => ` ${line}`),
87
+ ...errorLines,
88
+ "",
89
+ ...helpLines,
90
+ ],
91
+ "Enter apply · Esc cancel",
92
+ );
93
+ }
94
+ }
95
+
96
+ /** Run one framed input overlay. Resolves with the accepted value, or
97
+ * undefined when cancelled. */
98
+ export function dialogInput(
99
+ ctx: DialogContext,
100
+ title: string,
101
+ options: DialogInputOptions = {},
102
+ ): Promise<string | undefined> {
103
+ return ctx.ui.custom<string | undefined>(
104
+ (host, theme, _keys, done) => new InputView(host, theme, title, options, done),
105
+ DIALOG_OPTIONS,
106
+ );
107
+ }
package/src/menu.ts ADDED
@@ -0,0 +1,282 @@
1
+ // Framed settings/picker menu: RTK optimizer's modal contract (SettingItem
2
+ // rows, tabs, type-to-search, Enter/Space cycle, live onChange + updateValue
3
+ // sync) rendered with the pi-subagent-manager dialog pattern (framed rows,
4
+ // help panel). Subagent-manager portions MIT, Copyright (c) 2026 pi-subagent
5
+ // contributors.
6
+ import type { Theme } from "@earendil-works/pi-coding-agent";
7
+ import { getKeybindings, matchesKey, Key, truncateToWidth, visibleWidth, wrapTextWithAnsi, type SettingItem } from "@earendil-works/pi-tui";
8
+ import { DIALOG_OPTIONS, dialogHeight, dialogText, frameDialog, type DialogHost } from "./frame.js";
9
+
10
+ /** A menu row is a pi-tui SettingItem; `onEdit` additionally opens a custom
11
+ * editor (input dialog, submenu) on Enter and keeps the menu open. Rows with
12
+ * `values` cycle in place on Enter/Space; rows with neither resolve the menu
13
+ * with their id. */
14
+ export interface DialogRow extends SettingItem {
15
+ onEdit?: () => void;
16
+ }
17
+
18
+ export interface DialogTab {
19
+ label: string;
20
+ rows: DialogRow[];
21
+ }
22
+
23
+ export interface DialogMenuOptions {
24
+ title: string;
25
+ /** Muted line under the tab bar / counter (e.g. the settings file path). */
26
+ subtitle?: string;
27
+ /** Flat rows; ignored when `tabs` is provided. */
28
+ rows?: DialogRow[];
29
+ /** Row groups; left/right switch the active tab. */
30
+ tabs?: DialogTab[];
31
+ selectedId?: string;
32
+ /** Footer hint; a string array picks the first variant that fits the width. */
33
+ footer?: string | readonly string[];
34
+ /** Type-to-filter within the active tab. Default on. */
35
+ search?: boolean;
36
+ /** Called after an in-place cycle commits a new value. */
37
+ onChange?: (id: string, value: string) => void;
38
+ }
39
+
40
+ const DEFAULT_FOOTER = "↑↓ navigate · Enter select · Esc close";
41
+
42
+ export class DialogMenu {
43
+ private selected = 0;
44
+ private viewport = 1;
45
+ private query = "";
46
+ private activeTab = 0;
47
+
48
+ constructor(
49
+ private host: DialogHost,
50
+ private theme: Theme,
51
+ readonly options: DialogMenuOptions,
52
+ private done: (selectedId: string | undefined) => void,
53
+ ) {}
54
+
55
+ private get tabs(): DialogTab[] {
56
+ return this.options.tabs ?? (this.options.rows ? [{ label: "", rows: this.options.rows }] : []);
57
+ }
58
+
59
+ private get rows(): DialogRow[] {
60
+ const tab = this.tabs[this.activeTab];
61
+ return tab ? tab.rows : [];
62
+ }
63
+
64
+ private get filtered(): DialogRow[] {
65
+ const rows = this.rows;
66
+ const q = this.query.trim().toLowerCase();
67
+ if (!q) return rows;
68
+ return rows.filter((row) =>
69
+ `${row.label} ${row.currentValue} ${row.id}`.toLowerCase().includes(q),
70
+ );
71
+ }
72
+
73
+ getSelectedId(): string | undefined {
74
+ return this.filtered[this.selected]?.id;
75
+ }
76
+
77
+ /** External value sync: patch one row's displayed value by id, all tabs. */
78
+ updateValue(id: string, value: string): void {
79
+ for (const tab of this.tabs) {
80
+ const row = tab.rows.find((r) => r.id === id);
81
+ if (row) row.currentValue = value;
82
+ }
83
+ }
84
+
85
+ private clampSelection(): void {
86
+ const count = this.filtered.length;
87
+ this.selected = Math.max(0, Math.min(count - 1, this.selected));
88
+ }
89
+
90
+ private move(offset: number): void {
91
+ this.clampSelection();
92
+ const count = this.filtered.length;
93
+ this.selected = Math.max(0, Math.min(count - 1, this.selected + offset));
94
+ this.host.requestRender();
95
+ }
96
+
97
+ private cycle(): void {
98
+ const row = this.filtered[this.selected];
99
+ if (!row?.values?.length) return;
100
+ const current = row.currentValue;
101
+ const at = row.values.indexOf(current);
102
+ const next = row.values[(at + 1) % row.values.length] ?? row.values[0] ?? current;
103
+ row.currentValue = next;
104
+ this.options.onChange?.(row.id, next);
105
+ this.host.requestRender();
106
+ }
107
+
108
+ private switchTab(direction: number): void {
109
+ if (this.tabs.length < 2) return;
110
+ const count = this.tabs.length;
111
+ this.activeTab = ((this.activeTab + direction) % count + count) % count;
112
+ this.query = "";
113
+ this.selected = 0;
114
+ this.host.requestRender();
115
+ }
116
+
117
+ private activate(): void {
118
+ const row = this.filtered[this.selected];
119
+ if (!row) return this.done(undefined);
120
+ if (row.values?.length) return this.cycle();
121
+ if (row.onEdit) {
122
+ row.onEdit();
123
+ return;
124
+ }
125
+ this.done(this.getSelectedId());
126
+ }
127
+
128
+ handleInput(data: string): void {
129
+ const keys = getKeybindings();
130
+ if (keys.matches(data, "tui.select.cancel")) {
131
+ if (this.query) {
132
+ this.query = "";
133
+ this.clampSelection();
134
+ this.host.requestRender();
135
+ return;
136
+ }
137
+ return this.done(undefined);
138
+ }
139
+ if (this.query && matchesKey(data, Key.backspace)) {
140
+ this.query = this.query.slice(0, -1);
141
+ this.clampSelection();
142
+ this.host.requestRender();
143
+ return;
144
+ }
145
+ if (keys.matches(data, "tui.select.confirm") || (!this.query && data === " ")) {
146
+ return this.activate();
147
+ }
148
+ if (matchesKey(data, "left")) return this.switchTab(-1);
149
+ if (matchesKey(data, "right")) return this.switchTab(1);
150
+ let offset = 0;
151
+ if (keys.matches(data, "tui.select.up")) offset = -1;
152
+ else if (keys.matches(data, "tui.select.down") || matchesKey(data, Key.tab)) offset = 1;
153
+ else if (matchesKey(data, Key.pageUp)) offset = -this.viewport;
154
+ else if (matchesKey(data, Key.pageDown)) offset = this.viewport;
155
+ else if (matchesKey(data, Key.home)) offset = -this.filtered.length;
156
+ else if (matchesKey(data, Key.end)) offset = this.filtered.length;
157
+ if (offset !== 0) return this.move(offset);
158
+ if (this.options.search !== false && data.length === 1 && data >= " ") {
159
+ this.query += data;
160
+ this.clampSelection();
161
+ this.host.requestRender();
162
+ }
163
+ }
164
+
165
+ invalidate(): void {}
166
+
167
+ render(width: number): string[] {
168
+ const height = dialogHeight(this.host);
169
+ const inner = Math.max(0, width - 4);
170
+ const rows = this.filtered;
171
+ this.clampSelection();
172
+
173
+ const tabCount = this.tabs.length;
174
+ const body: string[] = [];
175
+ if (tabCount > 1) body.push(this.renderTabBar(width));
176
+ body.push(
177
+ this.theme.fg(
178
+ "dim",
179
+ ` ${rows.length ? this.selected + 1 : 0}/${rows.length} · Field / Value${this.query ? ` · /${this.query}` : ""}`,
180
+ ),
181
+ );
182
+ if (this.options.subtitle) {
183
+ body.push(this.theme.fg("muted", ` ${truncateToWidth(dialogText(this.options.subtitle), Math.max(1, inner), "…")}`));
184
+ }
185
+
186
+ const helpLines = wrapHelp(this.theme, rows[this.selected]?.description ?? "", inner, height);
187
+ const reserved = helpLines.length + (this.options.subtitle ? 1 : 0) + (tabCount > 1 ? 1 : 0);
188
+ this.viewport = Math.max(1, height - 6 - reserved);
189
+ const start = Math.max(0, Math.min(this.selected - this.viewport + 1, rows.length - this.viewport));
190
+
191
+ const hasValueColumn = rows.some((row) => row.currentValue !== undefined);
192
+ const labelWidth = Math.min(
193
+ 28,
194
+ Math.max(8, Math.floor(inner * 0.35)),
195
+ hasValueColumn ? Math.max(0, inner - 2 - 3 - 1) : Infinity,
196
+ );
197
+ for (let i = start; i < Math.min(rows.length, start + this.viewport); i++) {
198
+ body.push(this.renderRow(rows[i]!, start + i === this.selected, labelWidth));
199
+ }
200
+ body.push("");
201
+ body.push(...helpLines);
202
+
203
+ return frameDialog(
204
+ this.theme,
205
+ width,
206
+ height,
207
+ this.options.title + (this.query ? ` · /${this.query}` : ""),
208
+ body,
209
+ this.options.footer ?? DEFAULT_FOOTER,
210
+ );
211
+ }
212
+
213
+ private renderRow(row: DialogRow, isSelected: boolean, labelWidth: number): string {
214
+ const color = (text: string) => (isSelected ? this.theme.fg("accent", text) : text);
215
+ const label = truncateToWidth(dialogText(row.label), labelWidth, "", true);
216
+ const padding = " ".repeat(Math.max(0, labelWidth - visibleWidth(label)));
217
+ return (
218
+ color(`${isSelected ? "›" : " "} `) +
219
+ color(label) +
220
+ color(`${padding} │ `) +
221
+ color(dialogText(row.currentValue))
222
+ );
223
+ }
224
+
225
+ private renderTabBar(width: number): string {
226
+ const parts = this.tabs.map((tab, i) =>
227
+ i === this.activeTab
228
+ ? this.theme.fg("accent", `[ ${tab.label} ]`)
229
+ : this.theme.fg("muted", ` ${tab.label} `),
230
+ );
231
+ return truncateToWidth(parts.join(""), Math.max(1, width), "…");
232
+ }
233
+ }
234
+
235
+ function wrapHelp(theme: Theme, help: string, inner: number, height: number): string[] {
236
+ if (!help) return [];
237
+ return wrapTextWithAnsi(dialogText(help), Math.max(1, inner - 1))
238
+ .slice(0, Math.max(1, height - 8))
239
+ .map((line) => theme.fg("muted", ` ${line}`));
240
+ }
241
+
242
+ /** Run one framed menu overlay. Resolves with the accepted row id, or
243
+ * undefined when closed. */
244
+ export function dialogMenu(
245
+ ctx: DialogContext,
246
+ title: string,
247
+ rows: DialogRow[],
248
+ options: Partial<Pick<DialogMenuOptions, "subtitle" | "tabs" | "selectedId" | "footer" | "search" | "onChange">> = {},
249
+ ): Promise<string | undefined> {
250
+ return ctx.ui.custom(
251
+ (host, theme, _keys, done) =>
252
+ new DialogMenu(host, theme, { title, rows, ...options }, done),
253
+ DIALOG_OPTIONS,
254
+ );
255
+ }
256
+
257
+ /** Structural subset of an extension context: what dialogs actually touch. */
258
+ export interface DialogContext {
259
+ hasUI: boolean;
260
+ mode?: string;
261
+ ui: {
262
+ notify(message: string, level?: "info" | "warning" | "error"): void;
263
+ custom<T>(factory: CustomFactory<T>, options?: unknown): Promise<T>;
264
+ };
265
+ }
266
+
267
+ type CustomFactory<T> = (
268
+ host: DialogHost & Record<string, unknown>,
269
+ theme: Theme,
270
+ keys: unknown,
271
+ done: (value: T) => void,
272
+ ) => { render(width: number): string[]; invalidate(): void; handleInput?(data: string): void };
273
+
274
+ /** Headless guard: dialogs need the interactive TUI. */
275
+ export function canOpenDialog(ctx: DialogContext): boolean {
276
+ if (!ctx.hasUI) return false;
277
+ if (ctx.mode && ctx.mode !== "tui") {
278
+ ctx.ui.notify("Dialogs require interactive TUI mode.", "warning");
279
+ return false;
280
+ }
281
+ return true;
282
+ }