@xynogen/pix-pretty 1.6.3 → 1.7.0

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
@@ -21,6 +21,10 @@ Complete rendering and formatting solution for Pi Coding Agent with syntax highl
21
21
  - **Atomic deletion** - Delete entire paste markers as single units
22
22
  - **Type-aware labels** - Visual distinction between image and text pastes
23
23
 
24
+ ### Permission Dialog Overlay
25
+
26
+ - **Shared gate overlay** - `showOverlay(ui, config)` (export `./gate-overlay`) is the one permission-dialog component used by both `pix-gate` and `pix-sudo`. Two modes: `mode:"confirm"` shows a SelectList only; `mode:"sudo"` shows a SelectList then a masked password stage. Returns `{ action: "approved" | "denied" | "timeout", password? }`. All dialogs are padded (`Box` `paddingX=2`, `paddingY=1`). The simpler `./confirm` export stays for plain boolean Yes/No prompts — `gate-overlay` is its richer multi-choice, password-capable sibling.
27
+
24
28
  ### Reasoning Tag Rendering
25
29
 
26
30
  - **Live streaming** - Splits `<think>`/`<thinking>` regions into native Pi `thinking` content blocks token-by-token during streaming
package/package.json CHANGED
@@ -1,12 +1,14 @@
1
1
  {
2
2
  "name": "@xynogen/pix-pretty",
3
- "version": "1.6.3",
3
+ "version": "1.7.0",
4
4
  "description": "Enhanced tool output rendering with syntax highlighting, file icons, tree views, FFF search, and paste chip formatting",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
7
7
  "exports": {
8
8
  ".": "./src/index.ts",
9
9
  "./ansi": "./src/ansi.ts",
10
+ "./confirm": "./src/confirm.ts",
11
+ "./progress": "./src/progress.ts",
10
12
  "./config": "./src/config.ts",
11
13
  "./diff": "./src/diff.ts",
12
14
  "./diff-render": "./src/diff-render.ts",
@@ -18,7 +20,8 @@
18
20
  "./types": "./src/types.ts",
19
21
  "./utils": "./src/utils.ts",
20
22
  "./resize": "./src/resize.ts",
21
- "./context": "./src/tools/context.ts"
23
+ "./context": "./src/tools/context.ts",
24
+ "./gate-overlay": "./src/gate-overlay.ts"
22
25
  },
23
26
  "scripts": {
24
27
  "test": "bun test"
@@ -28,13 +31,6 @@
28
31
  "README.md",
29
32
  "LICENSE"
30
33
  ],
31
- "pi": {
32
- "extensions": [
33
- "./src/index.ts",
34
- "./src/paste-chips.ts",
35
- "./src/thinking.ts"
36
- ]
37
- },
38
34
  "keywords": [
39
35
  "pi",
40
36
  "pi-package",
package/src/confirm.ts ADDED
@@ -0,0 +1,174 @@
1
+ /**
2
+ * pix-pretty/confirm — reusable Yes/No confirmation overlay.
3
+ *
4
+ * A single coloured SelectList overlay (Yes / No) with optional title, body
5
+ * lines and an auto-cancel countdown. Mirrors the confirm stage of pix-sudo
6
+ * so any extension can get the same look without re-implementing it.
7
+ *
8
+ * Returns true on confirm, false on deny / cancel / timeout.
9
+ */
10
+
11
+ import { DynamicBorder } from "@earendil-works/pi-coding-agent";
12
+ import { Box, type SelectItem, SelectList, Text } from "@earendil-works/pi-tui";
13
+
14
+ // Minimal structural type for the `ctx.ui.custom` host call. Kept local so the
15
+ // component has no hard dependency on a specific Pi context shape.
16
+ interface CustomTheme {
17
+ fg(color: string, text: string): string;
18
+ bg(color: string, text: string): string;
19
+ bold(text: string): string;
20
+ }
21
+
22
+ interface CustomComponent {
23
+ render(width: number): string[];
24
+ invalidate(): void;
25
+ handleInput(data: string): void;
26
+ focused?: boolean;
27
+ }
28
+
29
+ export interface ConfirmUI {
30
+ custom<T>(
31
+ cb: (
32
+ tui: { requestRender(): void },
33
+ theme: CustomTheme,
34
+ kb: unknown,
35
+ done: (v: T) => void,
36
+ ) => CustomComponent,
37
+ opts?: { overlay?: boolean },
38
+ ): Promise<T | undefined>;
39
+ }
40
+
41
+ export interface ConfirmOptions {
42
+ /** Title shown bold at the top (e.g. "Update Pi & extensions?"). */
43
+ title: string;
44
+ /** Optional body lines rendered under the title. */
45
+ body?: string[];
46
+ /** Label for the confirm choice. Default "Yes". */
47
+ confirmLabel?: string;
48
+ /** Label for the deny choice. Default "No". */
49
+ denyLabel?: string;
50
+ /** Accent colour for border + selection. Default "accent". */
51
+ accent?: string;
52
+ /** Auto-cancel after this many ms (0 disables). Default 0. */
53
+ timeoutMs?: number;
54
+ }
55
+
56
+ const SECOND_MS = 1000;
57
+ const COUNTDOWN_WARN_S = 5;
58
+
59
+ /**
60
+ * Show a Yes/No overlay. Resolves true on confirm, false otherwise.
61
+ */
62
+ export function confirmOverlay(
63
+ ui: ConfirmUI,
64
+ opts: ConfirmOptions,
65
+ ): Promise<boolean> {
66
+ const accent = opts.accent ?? "accent";
67
+ const timeoutMs = opts.timeoutMs ?? 0;
68
+
69
+ return new Promise((resolve) => {
70
+ const controller = new AbortController();
71
+ const timer =
72
+ timeoutMs > 0
73
+ ? setTimeout(() => controller.abort(), timeoutMs)
74
+ : undefined;
75
+
76
+ ui.custom<boolean>(
77
+ (tui, theme, _kb, done) => {
78
+ let ticker: ReturnType<typeof setInterval> | undefined;
79
+
80
+ const choices: SelectItem[] = [
81
+ {
82
+ value: "yes",
83
+ label: opts.confirmLabel ?? "Yes",
84
+ description: "Proceed",
85
+ },
86
+ {
87
+ value: "no",
88
+ label: opts.denyLabel ?? "No",
89
+ description: "Cancel",
90
+ },
91
+ ];
92
+
93
+ const selectList = new SelectList(choices, choices.length, {
94
+ selectedPrefix: (t) => theme.fg(accent, t),
95
+ selectedText: (t) => theme.fg(accent, t),
96
+ description: (t) => theme.fg("muted", t),
97
+ scrollInfo: (t) => theme.fg("dim", t),
98
+ noMatch: (t) => theme.fg("warning", t),
99
+ });
100
+
101
+ const container = new Box(0, 0, (s) => theme.bg("customMessageBg", s));
102
+ container.addChild(
103
+ new DynamicBorder((s: string) => theme.fg(accent, s)),
104
+ );
105
+ container.addChild(
106
+ new Text(theme.fg(accent, theme.bold(opts.title)), 1, 0),
107
+ );
108
+
109
+ for (const line of opts.body ?? []) {
110
+ container.addChild(new Text(theme.fg("text", line), 1, 0));
111
+ }
112
+
113
+ if (timeoutMs > 0) {
114
+ const deadlineMs = Date.now() + timeoutMs;
115
+ const countdownText = new Text("", 1, 0);
116
+ const updateCountdown = () => {
117
+ const remaining = Math.max(
118
+ 0,
119
+ Math.ceil((deadlineMs - Date.now()) / SECOND_MS),
120
+ );
121
+ countdownText.setText(
122
+ theme.fg("dim", "Auto-cancel in ") +
123
+ theme.fg(
124
+ remaining <= COUNTDOWN_WARN_S ? accent : "muted",
125
+ `${remaining}s`,
126
+ ),
127
+ );
128
+ };
129
+ updateCountdown();
130
+ ticker = setInterval(() => {
131
+ updateCountdown();
132
+ tui.requestRender();
133
+ }, SECOND_MS);
134
+ container.addChild(countdownText);
135
+ }
136
+
137
+ container.addChild(selectList);
138
+ container.addChild(
139
+ new Text(
140
+ theme.fg("dim", "↑↓ navigate • enter select • esc cancel"),
141
+ 1,
142
+ 0,
143
+ ),
144
+ );
145
+ container.addChild(
146
+ new DynamicBorder((s: string) => theme.fg(accent, s)),
147
+ );
148
+
149
+ const finish = (value: boolean) => {
150
+ if (timer !== undefined) clearTimeout(timer);
151
+ if (ticker !== undefined) clearInterval(ticker);
152
+ done(value);
153
+ };
154
+
155
+ selectList.onSelect = (item) => finish(item.value === "yes");
156
+ selectList.onCancel = () => finish(false);
157
+ controller.signal.addEventListener("abort", () => finish(false));
158
+
159
+ return {
160
+ render: (w: number) => container.render(w),
161
+ invalidate: () => container.invalidate(),
162
+ handleInput: (data: string) => {
163
+ selectList.handleInput(data);
164
+ tui.requestRender();
165
+ },
166
+ };
167
+ },
168
+ { overlay: true },
169
+ ).then((result) => {
170
+ if (timer !== undefined) clearTimeout(timer);
171
+ resolve(result ?? false);
172
+ });
173
+ });
174
+ }
@@ -0,0 +1,163 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { type OverlayUI, showOverlay } from "./gate-overlay.ts";
3
+
4
+ // ── Mock host ─────────────────────────────────────────────────────────────────
5
+ //
6
+ // Drive showOverlay deterministically without a real TUI. The mock invokes the
7
+ // builder callback (so components initialise + wire their handlers), captures
8
+ // the rendered lines, then hands a `drive(comp, done)` hook to the test which
9
+ // triggers selectList.onSelect / maskedInput.onSubmit / etc via the real
10
+ // component instances — exactly what real keyboard input would do.
11
+
12
+ const theme = {
13
+ fg: (_c: string, t: string) => t,
14
+ bg: (_c: string, t: string) => t,
15
+ bold: (t: string) => t,
16
+ };
17
+
18
+ interface Wired {
19
+ render(w: number): string[];
20
+ invalidate(): void;
21
+ handleInput(d: string): void;
22
+ }
23
+
24
+ /**
25
+ * Build a mock UI. `drive` receives the rendered lines and a `feed` fn that
26
+ * pushes raw input strings into the component. To trigger a selection we feed
27
+ * the SelectList keys; simpler: we expose the live component so the test can
28
+ * call its handlers. We do the latter via the captured component ref.
29
+ */
30
+ function makeUI(
31
+ onReady: (comp: Wired, finish: (v: unknown) => void) => void,
32
+ ): OverlayUI {
33
+ return {
34
+ custom: async <T>(
35
+ cb: (
36
+ tui: { requestRender(): void },
37
+ th: typeof theme,
38
+ kb: unknown,
39
+ done: (v: T) => void,
40
+ ) => Wired,
41
+ ): Promise<T | undefined> => {
42
+ let resolved: T | undefined;
43
+ const done = (v: T) => {
44
+ resolved = v;
45
+ };
46
+ const comp = cb({ requestRender: () => {} }, theme, undefined, done);
47
+ comp.render(80); // initialise render path
48
+ onReady(comp, done as (v: unknown) => void);
49
+ return resolved;
50
+ },
51
+ };
52
+ }
53
+
54
+ // SelectList handles "\r" (enter) to select the highlighted item, and arrow
55
+ // keys to move. The first item is highlighted by default.
56
+ const ENTER = "\r";
57
+ const DOWN = "\x1b[B";
58
+
59
+ describe("showOverlay — confirm mode", () => {
60
+ test("selecting the approve choice (first) returns approved", async () => {
61
+ const result = await showOverlay(
62
+ makeUI((comp) => {
63
+ comp.handleInput(ENTER); // select item 0 = "yes"
64
+ }),
65
+ { mode: "confirm", title: "T", timeoutMs: 0 },
66
+ );
67
+ expect(result.action).toBe("approved");
68
+ expect(result.password).toBeUndefined();
69
+ });
70
+
71
+ test("selecting the deny choice (second) returns denied", async () => {
72
+ const result = await showOverlay(
73
+ makeUI((comp) => {
74
+ comp.handleInput(DOWN); // move to item 1 = "no"
75
+ comp.handleInput(ENTER);
76
+ }),
77
+ { mode: "confirm", title: "T", timeoutMs: 0 },
78
+ );
79
+ expect(result.action).toBe("denied");
80
+ });
81
+
82
+ test("deny-first ordering: item 0 is the deny choice when configured so", async () => {
83
+ const result = await showOverlay(
84
+ makeUI((comp) => {
85
+ comp.handleInput(ENTER); // select item 0
86
+ }),
87
+ {
88
+ mode: "confirm",
89
+ title: "Critical",
90
+ timeoutMs: 0,
91
+ approveValue: "yes",
92
+ choices: [
93
+ { value: "no", label: "Block", description: "deny" },
94
+ { value: "yes", label: "Allow", description: "approve" },
95
+ ],
96
+ },
97
+ );
98
+ // item 0 = "no" => not the approveValue => denied
99
+ expect(result.action).toBe("denied");
100
+ });
101
+
102
+ test("renders title and body lines", async () => {
103
+ let captured: string[] = [];
104
+ await showOverlay(
105
+ makeUI((comp) => {
106
+ captured = comp.render(80);
107
+ comp.handleInput(ENTER);
108
+ }),
109
+ {
110
+ mode: "confirm",
111
+ title: "MY TITLE",
112
+ body: ["body-line-x"],
113
+ timeoutMs: 0,
114
+ },
115
+ );
116
+ const joined = captured.join("\n");
117
+ expect(joined).toContain("MY TITLE");
118
+ expect(joined).toContain("body-line-x");
119
+ });
120
+ });
121
+
122
+ describe("showOverlay — sudo mode", () => {
123
+ test("approve then submit password returns approved + real password", async () => {
124
+ const result = await showOverlay(
125
+ makeUI((comp) => {
126
+ comp.handleInput(ENTER); // select item 0 = "yes" => switch to password stage
127
+ comp.handleInput("s3cret"); // type into MaskedInput
128
+ comp.handleInput(ENTER); // submit
129
+ }),
130
+ { mode: "sudo", title: "ROOT", timeoutMs: 0 },
131
+ );
132
+ expect(result.action).toBe("approved");
133
+ expect(result.password).toBe("s3cret");
134
+ });
135
+
136
+ test("deny at select stage returns denied, never reaches password", async () => {
137
+ const result = await showOverlay(
138
+ makeUI((comp) => {
139
+ comp.handleInput(DOWN); // item 1 = "no"
140
+ comp.handleInput(ENTER);
141
+ }),
142
+ { mode: "sudo", title: "ROOT", timeoutMs: 0 },
143
+ );
144
+ expect(result.action).toBe("denied");
145
+ expect(result.password).toBeUndefined();
146
+ });
147
+
148
+ test("password is masked in render (● not plaintext)", async () => {
149
+ let pwFrame: string[] = [];
150
+ await showOverlay(
151
+ makeUI((comp) => {
152
+ comp.handleInput(ENTER); // to password stage
153
+ comp.handleInput("abc");
154
+ pwFrame = comp.render(80);
155
+ comp.handleInput(ENTER); // submit so the promise resolves
156
+ }),
157
+ { mode: "sudo", title: "ROOT", timeoutMs: 0 },
158
+ );
159
+ const joined = pwFrame.join("\n");
160
+ expect(joined).not.toContain("abc");
161
+ expect(joined).toContain("●");
162
+ });
163
+ });
@@ -0,0 +1,310 @@
1
+ /**
2
+ * pix-pretty/gate-overlay — shared permission dialog component.
3
+ *
4
+ * One component, two modes:
5
+ * "confirm" — SelectList only. Used by pix-gate for command gating.
6
+ * "sudo" — SelectList → masked password input. Used by pix-sudo.
7
+ *
8
+ * Both modes share: colored border, title, body lines, countdown.
9
+ *
10
+ * Design goals:
11
+ * - Pure function — no side effects, no global state.
12
+ * - Fully unit-testable: inject a mock `ui` to drive inputs deterministically.
13
+ * - Single source of truth for the overlay look across pix-gate and pix-sudo.
14
+ */
15
+
16
+ import { DynamicBorder } from "@earendil-works/pi-coding-agent";
17
+ import {
18
+ Box,
19
+ Input,
20
+ type SelectItem,
21
+ SelectList,
22
+ Text,
23
+ } from "@earendil-works/pi-tui";
24
+
25
+ // ── Types ─────────────────────────────────────────────────────────────────────
26
+
27
+ export type OverlayAction = "approved" | "denied" | "timeout";
28
+
29
+ export interface OverlayResult {
30
+ action: OverlayAction;
31
+ /** Only present when action === "approved" and mode === "sudo". */
32
+ password?: string;
33
+ }
34
+
35
+ export interface OverlayChoice {
36
+ value: string;
37
+ label: string;
38
+ description: string;
39
+ }
40
+
41
+ interface BaseConfig {
42
+ /** Accent colour token (e.g. "error", "warning", "accent"). Default "accent". */
43
+ accent?: string;
44
+ /** Title shown bold at the top. */
45
+ title: string;
46
+ /** Optional body lines under the title. */
47
+ body?: string[];
48
+ /** Auto-deny after this many ms. 0 = no timeout. Default 30_000. */
49
+ timeoutMs?: number;
50
+ /**
51
+ * Choices shown in the SelectList.
52
+ * The choice whose value === approveValue counts as approval.
53
+ * Default: [{ value:"yes", label:"Allow" }, { value:"no", label:"Deny" }]
54
+ */
55
+ choices?: OverlayChoice[];
56
+ /** Which choice value means "approved". Default "yes". */
57
+ approveValue?: string;
58
+ }
59
+
60
+ export interface ConfirmConfig extends BaseConfig {
61
+ mode: "confirm";
62
+ }
63
+
64
+ export interface SudoConfig extends BaseConfig {
65
+ mode: "sudo";
66
+ /** Label for the password input hint. Default "Sudo password:" */
67
+ passwordLabel?: string;
68
+ }
69
+
70
+ export type OverlayConfig = ConfirmConfig | SudoConfig;
71
+
72
+ // Minimal structural types — no hard dep on a specific Pi context shape.
73
+ interface OverlayTheme {
74
+ fg(color: string, text: string): string;
75
+ bg(color: string, text: string): string;
76
+ bold(text: string): string;
77
+ }
78
+
79
+ interface OverlayTui {
80
+ requestRender(): void;
81
+ }
82
+
83
+ interface OverlayComponent {
84
+ render(width: number): string[];
85
+ invalidate(): void;
86
+ handleInput(data: string): void;
87
+ focused?: boolean;
88
+ }
89
+
90
+ export interface OverlayUI {
91
+ custom<T>(
92
+ cb: (
93
+ tui: OverlayTui,
94
+ theme: OverlayTheme,
95
+ kb: unknown,
96
+ done: (v: T) => void,
97
+ ) => OverlayComponent,
98
+ opts?: { overlay?: boolean },
99
+ ): Promise<T | undefined>;
100
+ }
101
+
102
+ // ── Constants ─────────────────────────────────────────────────────────────────
103
+
104
+ const SECOND_MS = 1_000;
105
+ const COUNTDOWN_WARN_S = 5;
106
+ const DEFAULT_TIMEOUT_MS = 30_000;
107
+
108
+ const DEFAULT_CHOICES: OverlayChoice[] = [
109
+ { value: "yes", label: "Allow", description: "Proceed" },
110
+ { value: "no", label: "Deny", description: "Block" },
111
+ ];
112
+
113
+ // ── Masked input (● per char) ─────────────────────────────────────────────────
114
+
115
+ class MaskedInput extends Input {
116
+ override render(width: number): string[] {
117
+ const real = this.getValue();
118
+ this.setValue("●".repeat(real.length));
119
+ const lines = super.render(width);
120
+ this.setValue(real);
121
+ return lines;
122
+ }
123
+ }
124
+
125
+ // ── Main ──────────────────────────────────────────────────────────────────────
126
+
127
+ /**
128
+ * Show a permission overlay and resolve the user's decision.
129
+ *
130
+ * @example — gate confirm
131
+ * ```ts
132
+ * const result = await showOverlay(ui, {
133
+ * mode: "confirm",
134
+ * title: "⚠️ DANGEROUS",
135
+ * body: ["rm -rf /tmp/work"],
136
+ * accent: "warning",
137
+ * timeoutMs: 30_000,
138
+ * choices: [
139
+ * { value: "yes", label: "Allow", description: "Run the command" },
140
+ * { value: "no", label: "Deny", description: "Block it" },
141
+ * ],
142
+ * });
143
+ * ```
144
+ *
145
+ * @example — sudo prompt
146
+ * ```ts
147
+ * const result = await showOverlay(ui, {
148
+ * mode: "sudo",
149
+ * title: "🔐 ROOT COMMAND REQUEST",
150
+ * body: ["Intent: install package", "Command: apt install foo"],
151
+ * accent: "error",
152
+ * });
153
+ * if (result.action === "approved") runWithSudo(cmd, result.password!);
154
+ * ```
155
+ */
156
+ export function showOverlay(
157
+ ui: OverlayUI,
158
+ config: OverlayConfig,
159
+ ): Promise<OverlayResult> {
160
+ const accent = config.accent ?? "accent";
161
+ const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
162
+ const choices = config.choices ?? DEFAULT_CHOICES;
163
+ const approveVal = config.approveValue ?? "yes";
164
+
165
+ return new Promise((resolve) => {
166
+ const controller = new AbortController();
167
+ const timer =
168
+ timeoutMs > 0
169
+ ? setTimeout(() => controller.abort(), timeoutMs)
170
+ : undefined;
171
+
172
+ ui.custom<OverlayResult>(
173
+ (tui, theme, _kb, done) => {
174
+ // ── state ───────────────────────────────────────────────────────
175
+ type Stage = "select" | "password";
176
+ let stage: Stage = "select";
177
+
178
+ // ── components ──────────────────────────────────────────────────
179
+ const selectItems: SelectItem[] = choices.map((c) => ({
180
+ value: c.value,
181
+ label: c.label,
182
+ description: c.description,
183
+ }));
184
+
185
+ const selectList = new SelectList(selectItems, selectItems.length, {
186
+ selectedPrefix: (t) => theme.fg(accent, t),
187
+ selectedText: (t) => theme.fg(accent, t),
188
+ description: (t) => theme.fg("muted", t),
189
+ scrollInfo: (t) => theme.fg("dim", t),
190
+ noMatch: (t) => theme.fg("warning", t),
191
+ });
192
+
193
+ const maskedInput = new MaskedInput();
194
+ const passwordHint = new Text("", 1, 0);
195
+ const helpText = new Text("", 1, 0);
196
+ const countdownText = new Text("", 1, 0);
197
+
198
+ // ── container ───────────────────────────────────────────────────
199
+ // padding: 2 cols horizontal, 1 row vertical around all dialog content
200
+ const container = new Box(2, 1, (s) => theme.bg("customMessageBg", s));
201
+ container.addChild(
202
+ new DynamicBorder((s: string) => theme.fg(accent, s)),
203
+ );
204
+ container.addChild(
205
+ new Text(theme.fg(accent, theme.bold(config.title)), 1, 0),
206
+ );
207
+
208
+ for (const line of config.body ?? []) {
209
+ container.addChild(new Text(theme.fg("text", line), 1, 0));
210
+ }
211
+
212
+ // ── countdown ───────────────────────────────────────────────────
213
+ let ticker: ReturnType<typeof setInterval> | undefined;
214
+ if (timeoutMs > 0) {
215
+ const deadlineMs = Date.now() + timeoutMs;
216
+ const updateCountdown = () => {
217
+ const remaining = Math.max(
218
+ 0,
219
+ Math.ceil((deadlineMs - Date.now()) / SECOND_MS),
220
+ );
221
+ countdownText.setText(
222
+ theme.fg("dim", "Auto-deny in ") +
223
+ theme.fg(
224
+ remaining <= COUNTDOWN_WARN_S ? accent : "muted",
225
+ `${remaining}s`,
226
+ ),
227
+ );
228
+ };
229
+ updateCountdown();
230
+ ticker = setInterval(() => {
231
+ updateCountdown();
232
+ tui.requestRender();
233
+ }, SECOND_MS);
234
+ container.addChild(countdownText);
235
+ }
236
+
237
+ // ── select stage (always starts here) ───────────────────────────
238
+ helpText.setText(
239
+ theme.fg("dim", "↑↓ navigate • enter select • esc deny"),
240
+ );
241
+ container.addChild(selectList);
242
+ container.addChild(helpText);
243
+ container.addChild(
244
+ new DynamicBorder((s: string) => theme.fg(accent, s)),
245
+ );
246
+
247
+ // ── finish helper ────────────────────────────────────────────────
248
+ const finish = (result: OverlayResult) => {
249
+ if (timer !== undefined) clearTimeout(timer);
250
+ if (ticker !== undefined) clearInterval(ticker);
251
+ done(result);
252
+ };
253
+
254
+ // ── stage: password (sudo mode only) ────────────────────────────
255
+ const switchToPassword = () => {
256
+ stage = "password";
257
+ container.removeChild(selectList);
258
+ container.removeChild(helpText);
259
+
260
+ const label =
261
+ config.mode === "sudo"
262
+ ? (config.passwordLabel ?? "Sudo password:")
263
+ : "Password:";
264
+ passwordHint.setText(theme.fg("muted", label));
265
+ container.addChild(passwordHint);
266
+ container.addChild(maskedInput);
267
+ container.addChild(
268
+ new Text(theme.fg("dim", "enter confirm • esc cancel"), 1, 0),
269
+ );
270
+ tui.requestRender();
271
+ };
272
+
273
+ // ── event wiring ─────────────────────────────────────────────────
274
+ selectList.onSelect = (item) => {
275
+ if (item.value !== approveVal) {
276
+ finish({ action: "denied" });
277
+ } else if (config.mode === "sudo") {
278
+ switchToPassword();
279
+ } else {
280
+ finish({ action: "approved" });
281
+ }
282
+ };
283
+ selectList.onCancel = () => finish({ action: "denied" });
284
+
285
+ maskedInput.onSubmit = (pw) =>
286
+ finish({ action: "approved", password: pw });
287
+ maskedInput.onEscape = () => finish({ action: "denied" });
288
+
289
+ controller.signal.addEventListener("abort", () =>
290
+ finish({ action: "timeout" }),
291
+ );
292
+
293
+ // ── component interface ──────────────────────────────────────────
294
+ return {
295
+ render: (w) => container.render(w),
296
+ invalidate: () => container.invalidate(),
297
+ handleInput: (data) => {
298
+ if (stage === "select") selectList.handleInput(data);
299
+ else maskedInput.handleInput(data);
300
+ tui.requestRender();
301
+ },
302
+ };
303
+ },
304
+ { overlay: true },
305
+ ).then((result) => {
306
+ if (timer !== undefined) clearTimeout(timer);
307
+ resolve(result ?? { action: "timeout" });
308
+ });
309
+ });
310
+ }