@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 +25 -0
- package/README.md +73 -2
- package/package.json +49 -4
- package/src/choice.ts +153 -0
- package/src/frame.ts +86 -0
- package/src/index.ts +27 -0
- package/src/input.ts +107 -0
- package/src/menu.ts +282 -0
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
|
-
#
|
|
1
|
+
# @estebanforge/pi-tui-dialogs
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|