@estebanforge/pi-tui-dialogs 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +25 -0
- package/README.md +53 -2
- package/package.json +49 -4
- package/src/frame.ts +86 -0
- package/src/index.ts +19 -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,54 @@
|
|
|
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
|
+
- `canOpenDialog` — headless guard for non-TUI modes.
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
import { canOpenDialog, dialogInput, dialogMenu, DialogMenu } from "@estebanforge/pi-tui-dialogs";
|
|
29
|
+
|
|
30
|
+
await ctx.ui.custom(
|
|
31
|
+
(host, theme, _keys, done) =>
|
|
32
|
+
new DialogMenu(
|
|
33
|
+
host,
|
|
34
|
+
theme,
|
|
35
|
+
{
|
|
36
|
+
title: "My extension",
|
|
37
|
+
subtitle: "Project file: .pi/my-extension.json",
|
|
38
|
+
tabs: [
|
|
39
|
+
{ label: "General", rows: [{ id: "enabled", label: "Enabled", value: "on", values: ["on", "off"], help: "Master switch." }] },
|
|
40
|
+
{ 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") }) }] },
|
|
41
|
+
],
|
|
42
|
+
onChange: (id, value) => save(id, value),
|
|
43
|
+
},
|
|
44
|
+
() => done(),
|
|
45
|
+
),
|
|
46
|
+
// DIALOG_OPTIONS centers the dialog near fullscreen
|
|
47
|
+
);
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Plain library, no Pi entrypoint: depend on it and Pi installs it transitively.
|
|
51
|
+
|
|
52
|
+
## License
|
|
53
|
+
|
|
54
|
+
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.1",
|
|
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/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,19 @@
|
|
|
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";
|
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
|
+
}
|