@estebanforge/pi-tui-dialogs 0.1.1 → 0.1.3
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 +27 -1
- package/package.json +2 -2
- package/src/choice.ts +153 -0
- package/src/frame.ts +8 -0
- package/src/index.ts +9 -0
- package/src/input.ts +2 -2
- package/src/menu.ts +2 -2
package/README.md
CHANGED
|
@@ -12,6 +12,12 @@ Dialog pattern adapted from
|
|
|
12
12
|
- `frameDialog` / `dialogHeight` / `DIALOG_OPTIONS` — shared rounded frame:
|
|
13
13
|
title in the top border, body, footer line above the bottom border, padded to
|
|
14
14
|
a stable height so centered overlays do not jump between views.
|
|
15
|
+
- `dialogBlocked(title)` — the pi 1.5.1 overlay `blocked` option payload
|
|
16
|
+
(`{ kind: "question", message: title }`): pi reports the overlay as waiting
|
|
17
|
+
on user input (OSC 7501) until `done()`, also while hidden. `dialogMenu`,
|
|
18
|
+
`dialogInput`, and `dialogChoice` pass it for you; hand-rolled overlays can
|
|
19
|
+
spread it into their own `ctx.ui.custom` options. Hosts older than pi 1.5.1
|
|
20
|
+
ignore the field.
|
|
15
21
|
- `DialogMenu` / `dialogMenu` — framed menu with `label │ value` rows, a
|
|
16
22
|
counter line, a muted help panel for the selected row, optional tab groups
|
|
17
23
|
(left/right to switch) and type-to-search within the active tab. Rows with
|
|
@@ -20,12 +26,18 @@ Dialog pattern adapted from
|
|
|
20
26
|
- `dialogInput` — framed single-line input with placeholder, help lines, and
|
|
21
27
|
optional `validate`: return an error string to keep the dialog open with the
|
|
22
28
|
message inline, return `undefined` to accept.
|
|
29
|
+
- `dialogChoice` — framed single-choice modal: one decision statement (wraps),
|
|
30
|
+
a closed option list of 1 to `MAX_CHOICE_OPTIONS` (10) options with unique
|
|
31
|
+
values and an optional preselect. Enter resolves the chosen option's value;
|
|
32
|
+
esc resolves `undefined` (the escape hatch). Caller-contract violations (0
|
|
33
|
+
or >10 options, duplicate values, unknown preselect) throw before any
|
|
34
|
+
overlay opens.
|
|
23
35
|
- `canOpenDialog` — headless guard for non-TUI modes.
|
|
24
36
|
|
|
25
37
|
## Usage
|
|
26
38
|
|
|
27
39
|
```ts
|
|
28
|
-
import { canOpenDialog, dialogInput, dialogMenu, DialogMenu } from "@estebanforge/pi-tui-dialogs";
|
|
40
|
+
import { canOpenDialog, dialogChoice, dialogInput, dialogMenu, DialogMenu } from "@estebanforge/pi-tui-dialogs";
|
|
29
41
|
|
|
30
42
|
await ctx.ui.custom(
|
|
31
43
|
(host, theme, _keys, done) =>
|
|
@@ -47,6 +59,20 @@ await ctx.ui.custom(
|
|
|
47
59
|
);
|
|
48
60
|
```
|
|
49
61
|
|
|
62
|
+
A closed choice reads the same way:
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
const engine = await dialogChoice(ctx, "Choose your engine", {
|
|
66
|
+
description: "Which engine runs your turns? Switch anytime with /engine.",
|
|
67
|
+
options: [
|
|
68
|
+
{ value: "fast", label: "Fast", description: "default · lighter" },
|
|
69
|
+
{ value: "deep", label: "Deep", description: "downloads a big binary" },
|
|
70
|
+
],
|
|
71
|
+
selected: "fast",
|
|
72
|
+
});
|
|
73
|
+
// engine === undefined means esc: decide later, persist nothing.
|
|
74
|
+
```
|
|
75
|
+
|
|
50
76
|
Plain library, no Pi entrypoint: depend on it and Pi installs it transitively.
|
|
51
77
|
|
|
52
78
|
## License
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@estebanforge/pi-tui-dialogs",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
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
5
|
"keywords": [
|
|
6
6
|
"pi",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
".": "./src/index.ts"
|
|
34
34
|
},
|
|
35
35
|
"peerDependencies": {
|
|
36
|
-
"@earendil-works/pi-coding-agent": "^1.
|
|
36
|
+
"@earendil-works/pi-coding-agent": "^1.5.1",
|
|
37
37
|
"@earendil-works/pi-tui": "^1.0.0"
|
|
38
38
|
},
|
|
39
39
|
"peerDependenciesMeta": {
|
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, dialogBlocked, 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, blocked: dialogBlocked(title) },
|
|
152
|
+
);
|
|
153
|
+
}
|
package/src/frame.ts
CHANGED
|
@@ -23,6 +23,14 @@ export const DIALOG_OPTIONS = {
|
|
|
23
23
|
},
|
|
24
24
|
} as const;
|
|
25
25
|
|
|
26
|
+
/** Blocked program status (OSC 7501) for a dialog overlay: pi reports the
|
|
27
|
+
* overlay as waiting on user input until done(), also while hidden. Hosts
|
|
28
|
+
* older than pi 1.5.1 ignore the field. Pi's own selectors use the same
|
|
29
|
+
* convention: kind "question", message = dialog title. */
|
|
30
|
+
export function dialogBlocked(title: string): { kind: "question"; message: string } {
|
|
31
|
+
return { kind: "question", message: title };
|
|
32
|
+
}
|
|
33
|
+
|
|
26
34
|
/** Strip ANSI and control characters from untrusted row text. */
|
|
27
35
|
export function dialogText(text: string): string {
|
|
28
36
|
return stripTerminalSequences(text).replace(/[\x00-\x1f\x7f-\x9f]/g, " ");
|
package/src/index.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// adapted from pi-subagent-manager (MIT); see LICENSE for attribution.
|
|
3
3
|
export {
|
|
4
4
|
DIALOG_OPTIONS,
|
|
5
|
+
dialogBlocked,
|
|
5
6
|
dialogHeight,
|
|
6
7
|
dialogText,
|
|
7
8
|
frameDialog,
|
|
@@ -17,3 +18,11 @@ export {
|
|
|
17
18
|
type DialogTab,
|
|
18
19
|
} from "./menu.js";
|
|
19
20
|
export { dialogInput, InputView, type DialogInputOptions } from "./input.js";
|
|
21
|
+
export {
|
|
22
|
+
assertChoiceOptions,
|
|
23
|
+
ChoiceView,
|
|
24
|
+
dialogChoice,
|
|
25
|
+
MAX_CHOICE_OPTIONS,
|
|
26
|
+
type ChoiceDialogOptions,
|
|
27
|
+
type ChoiceOption,
|
|
28
|
+
} from "./choice.js";
|
package/src/input.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// (MIT, Copyright (c) 2026 pi-subagent contributors).
|
|
5
5
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
6
6
|
import { Input, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
7
|
-
import { DIALOG_OPTIONS, dialogHeight, dialogText, frameDialog, type DialogHost } from "./frame.js";
|
|
7
|
+
import { DIALOG_OPTIONS, dialogBlocked, dialogHeight, dialogText, frameDialog, type DialogHost } from "./frame.js";
|
|
8
8
|
import type { DialogContext } from "./menu.js";
|
|
9
9
|
|
|
10
10
|
export interface DialogInputOptions {
|
|
@@ -102,6 +102,6 @@ export function dialogInput(
|
|
|
102
102
|
): Promise<string | undefined> {
|
|
103
103
|
return ctx.ui.custom<string | undefined>(
|
|
104
104
|
(host, theme, _keys, done) => new InputView(host, theme, title, options, done),
|
|
105
|
-
DIALOG_OPTIONS,
|
|
105
|
+
{ ...DIALOG_OPTIONS, blocked: dialogBlocked(title) },
|
|
106
106
|
);
|
|
107
107
|
}
|
package/src/menu.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// contributors.
|
|
6
6
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
7
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";
|
|
8
|
+
import { DIALOG_OPTIONS, dialogBlocked, dialogHeight, dialogText, frameDialog, type DialogHost } from "./frame.js";
|
|
9
9
|
|
|
10
10
|
/** A menu row is a pi-tui SettingItem; `onEdit` additionally opens a custom
|
|
11
11
|
* editor (input dialog, submenu) on Enter and keeps the menu open. Rows with
|
|
@@ -250,7 +250,7 @@ export function dialogMenu(
|
|
|
250
250
|
return ctx.ui.custom(
|
|
251
251
|
(host, theme, _keys, done) =>
|
|
252
252
|
new DialogMenu(host, theme, { title, rows, ...options }, done),
|
|
253
|
-
DIALOG_OPTIONS,
|
|
253
|
+
{ ...DIALOG_OPTIONS, blocked: dialogBlocked(title) },
|
|
254
254
|
);
|
|
255
255
|
}
|
|
256
256
|
|