@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 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.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.0.0",
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