@itc-steve/pi-ask-complete 0.1.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.
@@ -0,0 +1,394 @@
1
+ /**
2
+ * Heuristic bash decomposition for the permission gate.
3
+ *
4
+ * Two jobs:
5
+ * - splitUnits: break a command line into individual command "units" so a rule
6
+ * lookup can require EVERY unit to be allowed (closes `ls; rm -rf x` bypass).
7
+ * Units include segments split on ; | && || & \n AND the contents of
8
+ * command substitution `$(...)` / backticks (closes `echo $(rm x)`).
9
+ * - pathArgs: pull path-like arguments (incl. redirect targets) out of a
10
+ * command so they can be checked against path deny rules
11
+ * (closes `cat .env` / `echo x > .env`).
12
+ *
13
+ * ponytail: regex heuristic, not a real shell parser. Misses full $VAR resolution
14
+ * and nested quote mazes. Unresolved globs/braces/$'' force ask at the store.
15
+ * Upgrade to a shell AST (mvdan-style) only if those vectors matter.
16
+ */
17
+
18
+ const MAX_DEPTH = 6;
19
+
20
+ /**
21
+ * Blank out <<EOF … EOF heredoc bodies so their lines are not treated as units
22
+ * (node <<'EOF' / const / for / console.log were landing in permission.json).
23
+ */
24
+ export function stripHeredocs(command: string): string {
25
+ // <<[-]? optional quotes WORD then body until a line that is exactly WORD
26
+ return command.replace(
27
+ /<<(-)?\s*(['"]?)(\w+)\2\r?\n[\s\S]*?\r?\n\3(?=\r?\n|$)/g,
28
+ " ",
29
+ );
30
+ }
31
+
32
+ /**
33
+ * Drop `# …` comments outside quotes so agent annotations don't become units
34
+ * (`# setup; then rm x` → no `#`/`then` bases from prose).
35
+ * ponytail: not a full lexer; # inside $'…' / unclosed quotes is best-effort.
36
+ */
37
+ export function stripComments(command: string): string {
38
+ let out = "";
39
+ let quote: "'" | '"' | null = null;
40
+ for (let i = 0; i < command.length; i++) {
41
+ const c = command[i]!;
42
+ if (quote) {
43
+ out += c;
44
+ if (c === "\\" && quote === '"' && i + 1 < command.length) {
45
+ out += command[++i];
46
+ } else if (c === quote) {
47
+ quote = null;
48
+ }
49
+ continue;
50
+ }
51
+ if (c === "'" || c === '"') {
52
+ quote = c;
53
+ out += c;
54
+ continue;
55
+ }
56
+ // Bash: # starts a comment only at a word boundary (not $# / ${#x} / foo#bar).
57
+ const prev = i === 0 ? "" : command[i - 1]!;
58
+ if (c === "#" && (i === 0 || /[\s;|&()]/.test(prev))) {
59
+ while (i + 1 < command.length && command[i + 1] !== "\n") i++;
60
+ continue;
61
+ }
62
+ out += c;
63
+ }
64
+ return out;
65
+ }
66
+
67
+ /**
68
+ * Split on shell control operators, but NOT inside quotes.
69
+ * Fixes `python3 -c "…\n…"` being shredded into fake units (and crashing the
70
+ * permission panel with a 20-base label).
71
+ */
72
+ function splitSegments(command: string): string[] {
73
+ const parts: string[] = [];
74
+ let cur = "";
75
+ let quote: "'" | '"' | null = null;
76
+ for (let i = 0; i < command.length; i++) {
77
+ const c = command[i]!;
78
+ if (quote) {
79
+ cur += c;
80
+ if (c === "\\" && quote === '"' && i + 1 < command.length) {
81
+ cur += command[++i];
82
+ } else if (c === quote) {
83
+ quote = null;
84
+ }
85
+ continue;
86
+ }
87
+ if (c === "'" || c === '"') {
88
+ quote = c;
89
+ cur += c;
90
+ continue;
91
+ }
92
+ // && or ||
93
+ if ((c === "&" || c === "|") && command[i + 1] === c) {
94
+ if (cur.trim()) parts.push(cur.trim());
95
+ cur = "";
96
+ i++;
97
+ continue;
98
+ }
99
+ // Redirects that contain & must not become split points:
100
+ // 2>&1 >&2 &>file &>>file
101
+ // (bare `cmd &` / `cmd1 & cmd2` still split — that's intentional).
102
+ if (c === "&") {
103
+ const prev = cur.length ? cur[cur.length - 1]! : "";
104
+ const next = command[i + 1] ?? "";
105
+ if (prev === ">" || next === ">") {
106
+ cur += c;
107
+ continue;
108
+ }
109
+ }
110
+ if (c === ";" || c === "|" || c === "&" || c === "\n") {
111
+ if (cur.trim()) parts.push(cur.trim());
112
+ cur = "";
113
+ continue;
114
+ }
115
+ cur += c;
116
+ }
117
+ if (cur.trim()) parts.push(cur.trim());
118
+ return parts;
119
+ }
120
+
121
+ /** Extract `$(...)`, backtick, and process substitution <(...)/>(...) bodies.
122
+ * (one level; recursion + segment split handles nesting). */
123
+ function extractSubstitutions(command: string): string[] {
124
+ const out: string[] = [];
125
+ // $( ... )
126
+ const dollar = /\$\(([^()]*(?:\([^()]*\)[^()]*)*)\)/g;
127
+ let m: RegExpExecArray | null;
128
+ while ((m = dollar.exec(command))) {
129
+ if (m[1]?.trim()) out.push(m[1].trim());
130
+ }
131
+ // backticks
132
+ const back = /`([^`]*)`/g;
133
+ while ((m = back.exec(command))) {
134
+ if (m[1]?.trim()) out.push(m[1].trim());
135
+ }
136
+ // process substitution <(cmd) and >(cmd) — C1 fix
137
+ const proc = /[<>]\(\s*([^()]*?(?:\([^()]*\)[^()]*?)*)\s*\)/g;
138
+ while ((m = proc.exec(command))) {
139
+ if (m[1]?.trim()) out.push(m[1].trim());
140
+ }
141
+ return out;
142
+ }
143
+
144
+ /**
145
+ * All command units in a line: each segment, plus every substitution body
146
+ * (recursively), each itself segment-split.
147
+ */
148
+ export function splitUnits(command: string, depth = 0): string[] {
149
+ // Top-level only: drop comments + heredoc bodies before segmenting.
150
+ const cleaned =
151
+ depth === 0 ? stripComments(stripHeredocs(command)) : command;
152
+ const trimmed = cleaned.trim();
153
+ if (!trimmed || depth > MAX_DEPTH) return trimmed ? [trimmed] : [];
154
+
155
+ const units: string[] = [];
156
+ for (const seg of splitSegments(trimmed)) {
157
+ const subs = extractSubstitutions(seg);
158
+ // The segment with substitutions blanked out is still a real command to check.
159
+ const bare = seg
160
+ .replace(/\$\([^)]*\)/g, " ")
161
+ .replace(/`[^`]*`/g, " ")
162
+ .replace(/[<>]\([^)]*\)/g, " ")
163
+ .trim();
164
+ if (bare) units.push(bare);
165
+ for (const sub of subs) units.push(...splitUnits(sub, depth + 1));
166
+ }
167
+ return units.length ? units : [];
168
+ }
169
+
170
+ /**
171
+ * Collapse shell quoting/escapes to the path value the shell would open.
172
+ * - unquoted \x → x; drop unescaped ' and " anywhere (not just edges)
173
+ * - single-quoted text is literal (including $ and \) — closes C3 while
174
+ * keeping `'$HOME'/.env` as the literal path $HOME/.env
175
+ * - double-quoted: \ only escapes $ ` " \ newline; $ residue stays so
176
+ * isUnresolvedPath still fires for "$HOME"/.env
177
+ * Unclosed quote → return s unchanged (fail closed: ambiguous).
178
+ */
179
+ function collapseShellToken(s: string): string {
180
+ let out = "";
181
+ let i = 0;
182
+ while (i < s.length) {
183
+ const c = s[i]!;
184
+ if (c === "'") {
185
+ i++;
186
+ let closed = false;
187
+ while (i < s.length) {
188
+ if (s[i] === "'") {
189
+ closed = true;
190
+ i++;
191
+ break;
192
+ }
193
+ out += s[i++];
194
+ }
195
+ if (!closed) return s;
196
+ continue;
197
+ }
198
+ if (c === '"') {
199
+ i++;
200
+ let closed = false;
201
+ while (i < s.length) {
202
+ if (s[i] === '"') {
203
+ closed = true;
204
+ i++;
205
+ break;
206
+ }
207
+ if (s[i] === "\\" && i + 1 < s.length) {
208
+ const n = s[i + 1]!;
209
+ // bash: inside double quotes only $ ` " \ and newline are special
210
+ if (n === "$" || n === "`" || n === '"' || n === "\\" || n === "\n") {
211
+ out += n;
212
+ i += 2;
213
+ } else {
214
+ out += s[i++]; // keep the backslash
215
+ }
216
+ } else {
217
+ out += s[i++];
218
+ }
219
+ }
220
+ if (!closed) return s;
221
+ continue;
222
+ }
223
+ if (c === "\\" && i + 1 < s.length) {
224
+ out += s[i + 1];
225
+ i += 2;
226
+ continue;
227
+ }
228
+ out += c;
229
+ i++;
230
+ }
231
+ return out;
232
+ }
233
+
234
+ function unquote(s: string): string {
235
+ return collapseShellToken(s).trim();
236
+ }
237
+
238
+ /**
239
+ * Normalize a raw argv token into a path candidate:
240
+ * - strip @file upload prefix, trailing ) from subs
241
+ * - strip trailing shell operators glued on (`cat .env;true` → `.env`)
242
+ * - peel git pathspecs (`HEAD:.env` → `.env`)
243
+ * - peel dd-style if=/of= assignments
244
+ * - collapse intra-word quotes/escapes (`.en''v` → `.env`) so path globs match
245
+ */
246
+ export function cleanPathToken(token: string): string {
247
+ let t = token.trim();
248
+ if (!t) return "";
249
+ t = t.replace(/^@/, "");
250
+ // Operators glued to the path without whitespace (bypass vector).
251
+ t = t.replace(/[;|&`].*$/, "");
252
+ t = t.replace(/\)+$/, "").trim();
253
+ if (!t) return "";
254
+
255
+ // dd/install style: if=.env of=/tmp/x
256
+ const assign = t.match(
257
+ /^(?:if|of|in|out|file|path|filename|dest|source)=(.+)$/i,
258
+ );
259
+ if (assign?.[1]) t = assign[1];
260
+
261
+ // git pathspec rev:path / :path — not Windows drive (C:\… / C:/…)
262
+ if (
263
+ t.includes(":") &&
264
+ !/^[A-Za-z]:[\\/]/.test(t) &&
265
+ !/^[A-Za-z]:$/.test(t)
266
+ ) {
267
+ const pathPart = t.slice(t.lastIndexOf(":") + 1);
268
+ if (pathPart) t = pathPart;
269
+ }
270
+
271
+ // After operator/assign/pathspec peel: collapse quotes so **/.env matches.
272
+ return collapseShellToken(t).trim();
273
+ }
274
+
275
+ /**
276
+ * True when the token still needs shell expansion before path policy can allow.
277
+ * Quote-aware: single-quoted text is literal (`'$HOME'` is not an expansion);
278
+ * `$VAR` / `${…}` / `$(…)` / `$ '…'` / globs / braces outside single quotes
279
+ * (and `$` inside double quotes) still force unresolved.
280
+ */
281
+ export function isUnresolvedPath(token: string): boolean {
282
+ if (!token) return false;
283
+ let i = 0;
284
+ while (i < token.length) {
285
+ const c = token[i]!;
286
+ if (c === "'") {
287
+ const end = token.indexOf("'", i + 1);
288
+ if (end < 0) return true; // unclosed — fail closed
289
+ i = end + 1;
290
+ continue;
291
+ }
292
+ if (c === '"') {
293
+ i++;
294
+ while (i < token.length && token[i] !== '"') {
295
+ if (token[i] === "\\" && i + 1 < token.length) {
296
+ i += 2;
297
+ continue;
298
+ }
299
+ // $ expands inside double quotes; globs do not
300
+ if (token[i] === "$" && i + 1 < token.length) {
301
+ const n = token[i + 1]!;
302
+ if (n === "'" || n === "{" || n === "(" || /[A-Za-z_]/.test(n)) return true;
303
+ }
304
+ i++;
305
+ }
306
+ if (i >= token.length) return true; // unclosed
307
+ i++;
308
+ continue;
309
+ }
310
+ if (c === "\\" && i + 1 < token.length) {
311
+ i += 2;
312
+ continue;
313
+ }
314
+ // unquoted glob / brace / $
315
+ if (c === "*" || c === "?" || c === "[" || c === "{") return true;
316
+ if (c === "$" && i + 1 < token.length) {
317
+ const n = token[i + 1]!;
318
+ if (n === "'" || n === "{" || n === "(" || /[A-Za-z_]/.test(n)) return true;
319
+ }
320
+ i++;
321
+ }
322
+ return false;
323
+ }
324
+
325
+ // Looks like a path argument (has a slash or a dotfile/ext), not a flag.
326
+ function looksLikePath(token: string): boolean {
327
+ if (!token || token.startsWith("-")) return false;
328
+ if (token.includes("=")) return false; // env assignment / --opt=val left after clean
329
+ return (
330
+ token.includes("/") ||
331
+ /^\.?[\w.-]+\.\w+$/.test(token) ||
332
+ token.startsWith(".") ||
333
+ // git pathspec residue or plain secret basenames without a dot (id_rsa)
334
+ /^(id_rsa|id_ed25519|id_ecdsa|id_dsa|shadow|gshadow|sudoers|kubeconfig)$/i.test(
335
+ token,
336
+ )
337
+ );
338
+ }
339
+
340
+ /**
341
+ * Path-like arguments across the whole command line, including redirect targets
342
+ * (`> file`, `>> file`, `2> file`). Quotes stripped; returned cleaned.
343
+ */
344
+ export function pathArgs(command: string): string[] {
345
+ const src = stripComments(stripHeredocs(command));
346
+ const out: string[] = [];
347
+
348
+ const push = (raw: string) => {
349
+ const t = cleanPathToken(raw);
350
+ if (t && (looksLikePath(t) || isUnresolvedPath(t))) out.push(t);
351
+ };
352
+
353
+ // redirect targets: >, >>, <, 2>, &> followed by a filename (but NOT <( > ( process subs)
354
+ const redir = /(?:\d*&?>{1,2}|<)\s*("[^"]+"|'[^']+'|(?!\()\S+)/g;
355
+ let m: RegExpExecArray | null;
356
+ while ((m = redir.exec(src))) {
357
+ const t = unquote(m[1]!);
358
+ if (t && !t.startsWith("&")) push(t);
359
+ }
360
+
361
+ // bare tokens (and if=/.git pathspec forms via cleanPathToken)
362
+ for (const rawTok of src.split(/\s+/)) {
363
+ if (!rawTok) continue;
364
+ // Keep if=.env visible to cleanPathToken (looksLikePath alone would skip `=`).
365
+ if (/^(?:if|of|in|out|file|path|filename|dest|source)=/i.test(rawTok)) {
366
+ push(rawTok);
367
+ continue;
368
+ }
369
+ push(rawTok.replace(/^[<>]+\(?/, ""));
370
+ }
371
+
372
+ return [...new Set(out)];
373
+ }
374
+
375
+ /**
376
+ * True when the line has unquoted glob/brace/ANSI-C tokens so path policy
377
+ * cannot prove the real path — caller should force ask (never silent allow).
378
+ */
379
+ export function hasUnresolvedExpansion(command: string): boolean {
380
+ const src = stripComments(stripHeredocs(command));
381
+ for (const rawTok of src.split(/\s+/)) {
382
+ if (!rawTok || rawTok.startsWith("-")) continue;
383
+ // Quote-aware on the raw token first: '$HOME' is literal, "$HOME" is not.
384
+ // Do not re-scan the collapsed form for $ — that would turn single-quoted
385
+ // literal $ into a false expansion after cleanPathToken strips the quotes.
386
+ if (isUnresolvedPath(rawTok)) return true;
387
+ const t = cleanPathToken(rawTok);
388
+ // Globs/braces that survive collapse (unquoted) still force ask.
389
+ if (t && /[*?[{]/.test(t)) return true;
390
+ }
391
+ // Whole-line $'…' even when glued
392
+ if (/\$'[^']*'/.test(src)) return true;
393
+ return false;
394
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Build a human-readable permission prompt from a tool call.
3
+ */
4
+
5
+ import { resolve } from "node:path";
6
+ import { baseCommand } from "./base-command.ts";
7
+ import { normalizePath } from "./wildcard.ts";
8
+
9
+ export type PermissionPrompt = {
10
+ /** Short subject shown in header + used as permanent-allow key. */
11
+ base: string;
12
+ /** Full description of what will run (command line / tool action). */
13
+ display: string;
14
+ /** Optional extra context under the display. */
15
+ detail?: string;
16
+ /** Human blast-radius label for the session-allow option. */
17
+ session?: string;
18
+ /** Human blast-radius label for the permanent-allow option. */
19
+ permanent?: string;
20
+ };
21
+
22
+ export type ToolCallDescription = {
23
+ toolName: string;
24
+ /** bash command string, or tool name for tools */
25
+ subject: string;
26
+ /** Absolute-ish path for write/edit/read when present */
27
+ filePath?: string;
28
+ prompt: PermissionPrompt;
29
+ };
30
+
31
+ function summarize(text: string, max = 200): string {
32
+ const one = text.replace(/\s+/g, " ").trim();
33
+ if (one.length <= max) return one;
34
+ return `${one.slice(0, max - 1)}…`;
35
+ }
36
+
37
+ function resolvePath(p: string, cwd?: string): string {
38
+ const raw = p.trim();
39
+ if (!raw) return raw;
40
+ try {
41
+ return normalizePath(cwd ? resolve(cwd, raw) : resolve(raw));
42
+ } catch {
43
+ return normalizePath(raw);
44
+ }
45
+ }
46
+
47
+ export function describeToolCall(
48
+ toolName: string,
49
+ input: Record<string, unknown>,
50
+ cwd?: string,
51
+ ): ToolCallDescription {
52
+ if (toolName === "bash" || toolName === "sudo_run") {
53
+ const command = String(input.command ?? "");
54
+ const base = baseCommand(command) || "(unknown)";
55
+ const reason =
56
+ toolName === "sudo_run" && typeof input.reason === "string"
57
+ ? input.reason.trim()
58
+ : "";
59
+ const details: string[] = [];
60
+ if (toolName === "sudo_run") details.push("via sudo_run (root)");
61
+ if (reason) details.push(reason);
62
+ if (input.cwd) details.push(`cwd: ${String(input.cwd)}`);
63
+ return {
64
+ toolName,
65
+ subject: command,
66
+ prompt: {
67
+ base,
68
+ display: toolName === "sudo_run" ? `sudo ${command}` : command,
69
+ detail: details.length ? details.join(" · ") : undefined,
70
+ session: "Allow for this session",
71
+ permanent: "Allow permanently",
72
+ },
73
+ };
74
+ }
75
+
76
+ if (toolName === "write") {
77
+ const rawPath = String(input.path ?? "(no path)");
78
+ const filePath = rawPath === "(no path)" ? undefined : resolvePath(rawPath, cwd);
79
+ const content = typeof input.content === "string" ? input.content : "";
80
+ const lines = content ? content.split("\n").length : 0;
81
+ return {
82
+ toolName: "write",
83
+ subject: "write",
84
+ filePath,
85
+ prompt: {
86
+ base: filePath ?? "write",
87
+ display: `write ${filePath ?? rawPath}`,
88
+ session: "Allow writes to this file this session",
89
+ permanent: "Allow writes to this file permanently",
90
+ detail: content
91
+ ? `${lines} line(s), ${content.length} chars · preview: ${summarize(content, 160)}`
92
+ : "empty content",
93
+ },
94
+ };
95
+ }
96
+
97
+ if (toolName === "edit") {
98
+ const rawPath = String(input.path ?? "(no path)");
99
+ const filePath = rawPath === "(no path)" ? undefined : resolvePath(rawPath, cwd);
100
+ const edits = Array.isArray(input.edits) ? input.edits : [];
101
+ const first = edits[0] as { oldText?: string; newText?: string } | undefined;
102
+ const detailParts = [`${edits.length} edit(s)`];
103
+ if (first?.oldText) detailParts.push(`old: ${summarize(first.oldText, 80)}`);
104
+ if (first?.newText) detailParts.push(`new: ${summarize(first.newText, 80)}`);
105
+ return {
106
+ toolName: "edit",
107
+ subject: "edit",
108
+ filePath,
109
+ prompt: {
110
+ base: filePath ?? "edit",
111
+ display: `edit ${filePath ?? rawPath}`,
112
+ session: "Allow edits to this file this session",
113
+ permanent: "Allow edits to this file permanently",
114
+ detail: detailParts.join(" · "),
115
+ },
116
+ };
117
+ }
118
+
119
+ if (toolName === "read") {
120
+ const rawPath = String(input.path ?? "(no path)");
121
+ const filePath = rawPath === "(no path)" ? undefined : resolvePath(rawPath, cwd);
122
+ return {
123
+ toolName: "read",
124
+ subject: "read",
125
+ filePath,
126
+ prompt: {
127
+ base: filePath ?? "read",
128
+ display: `read ${filePath ?? rawPath}`,
129
+ },
130
+ };
131
+ }
132
+
133
+ return {
134
+ toolName,
135
+ subject: toolName,
136
+ prompt: {
137
+ base: toolName,
138
+ display: `${toolName} ${summarize(JSON.stringify(input), 240)}`,
139
+ },
140
+ };
141
+ }
package/src/helpers.ts ADDED
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Pure helpers for pi-ask-user: text sanitization, width-safe display
3
+ * primitives, question introspection, and per-tab state construction.
4
+ */
5
+
6
+ import type { ThemeColor } from "@earendil-works/pi-coding-agent";
7
+ import type { EditorTheme } from "@earendil-works/pi-tui";
8
+ import { Editor, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
9
+ import {
10
+ type Answer,
11
+ type AskUserResult,
12
+ ICON_OTHER,
13
+ type Question,
14
+ type RenderOption,
15
+ type TabState,
16
+ type TuiLike,
17
+ } from "./types.ts";
18
+
19
+ export function wrapTab(index: number, total: number): number {
20
+ if (total <= 0) return 0;
21
+ return ((index % total) + total) % total;
22
+ }
23
+
24
+ /**
25
+ * Normalize externally-supplied text for TUI rendering. Fold CR/CRLF into real
26
+ * newlines, convert tabs to a single space, and strip remaining C0 control
27
+ * chars (keeping only \n). Rationale: a raw \r returns the cursor to column 0
28
+ * mid-row and clobbers leading indent; a raw \t advances to the next terminal
29
+ * tab stop (which the panel's width math counts as 1 col, so rows overflow
30
+ * their declared width and redraws accumulate stale copies); other C0 bytes
31
+ * corrupt layout too. Callers then treat \n as the only meaningful break.
32
+ */
33
+ export function sanitizeMultiline(text: string): string {
34
+ // Build the control-char class from codepoints so no literal control bytes
35
+ // appear in source (keeps biome's noControlCharactersInRegex happy). \x09
36
+ // (tab) is handled separately below; \x0a (\n) is the one char we keep.
37
+ const controls = new RegExp("[\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f]", "g");
38
+ return text
39
+ .replace(/\r\n?/g, "\n")
40
+ .replace(/\t/g, " ")
41
+ .replace(controls, "");
42
+ }
43
+
44
+ export function sanitizeTabDisplay(tab: string): string {
45
+ return sanitizeMultiline(tab).replace(/\n/g, " ").trim() || "(unnamed)";
46
+ }
47
+
48
+ /** Build the full option list for a question, always appending the "Type something." custom-input row. */
49
+ export function buildOptions(q: Question): RenderOption[] {
50
+ const opts: RenderOption[] = [...q.options];
51
+ opts.push({ label: "Type something.", isOther: true });
52
+ return opts;
53
+ }
54
+
55
+ export function isMulti(q: Question | undefined): boolean {
56
+ return !!q?.multiSelect;
57
+ }
58
+
59
+ /** Whether the user is allowed to skip this question (default true). */
60
+ export function canSkip(q: Question | undefined): boolean {
61
+ return q?.allowSkip !== false;
62
+ }
63
+
64
+ /** Does this question use the two-column (options | preview) layout? */
65
+ export function isDualColumn(q: Question | undefined): boolean {
66
+ if (!q) return false;
67
+ return q.options.some((o) => o.preview);
68
+ }
69
+
70
+ export function newTabState(
71
+ tui: TuiLike,
72
+ theme: EditorTheme,
73
+ tabIndex: number,
74
+ onSubmit: (tabIndex: number, value: string) => void,
75
+ ): TabState {
76
+ const editor = new Editor(tui as never, theme);
77
+ editor.onSubmit = (value) => onSubmit(tabIndex, value);
78
+ return {
79
+ cursor: 0,
80
+ scrollOffset: 0,
81
+ inputMode: false,
82
+ editor,
83
+ multiChecked: new Set(),
84
+ customText: null,
85
+ selectedSingle: -1,
86
+ };
87
+ }
88
+
89
+ export function errorResult(
90
+ message: string,
91
+ questions: Question[] = [],
92
+ ): {
93
+ content: { type: "text"; text: string }[];
94
+ details: AskUserResult;
95
+ } {
96
+ return {
97
+ content: [{ type: "text", text: message }],
98
+ details: { questions, answers: [], cancelled: true },
99
+ };
100
+ }
101
+
102
+ /** Pad a string with trailing spaces to a visible width (left-justified). */
103
+ export function padRight(s: string, width: number): string {
104
+ const v = visibleWidth(s);
105
+ return v >= width ? s : s + " ".repeat(width - v);
106
+ }
107
+
108
+ /** Truncate to a visible width, appending “…” only when the text actually
109
+ * overflows. (truncateToWidth's third arg is a fill, not a suffix, so we
110
+ * reserve one column and append the ellipsis ourselves when needed.) */
111
+ export function truncForDisplay(text: string, maxW: number): string {
112
+ if (maxW <= 0) return "";
113
+ if (maxW === 1) return "…";
114
+ const vw = visibleWidth(text);
115
+ if (vw <= maxW) return text;
116
+ return truncateToWidth(text, maxW - 1, "") + "…";
117
+ }
118
+
119
+ /** Structured interpretation of an Answer for display/serialization.
120
+ * Single source of truth — all three consumers (review screen's
121
+ * formatAnswerText, result card's formatAnswer, execute's JSON payload)
122
+ * derive from this, so they can never drift apart. */
123
+ export interface AnswerView {
124
+ /** Human-readable text WITHOUT ANSI — e.g. "Sidebar" / "甲, 乙" /
125
+ * "✎ 自定义文本" / "(none)" / "(skipped)". Consumers wrap it in color. */
126
+ text: string;
127
+ /** Theme color name for the whole text. */
128
+ color: ThemeColor;
129
+ }
130
+
131
+ /** Interpret an Answer into display form. `customGlyph` (default "✎") prefixes
132
+ * any custom text. Returns `(no answer)` / dim for an absent answer. */
133
+ export function describeAnswer(ans: Answer | undefined, customGlyph = ICON_OTHER): AnswerView {
134
+ if (!ans) return { text: "(no answer)", color: "dim" };
135
+ switch (ans.kind) {
136
+ case "skipped":
137
+ return { text: "(skipped)", color: "warning" };
138
+ case "multi": {
139
+ if (ans.options.length === 0 && !ans.custom) return { text: "(none)", color: "dim" };
140
+ const parts = [...ans.options];
141
+ if (ans.custom) parts.push(`${customGlyph} ${ans.custom}`);
142
+ return { text: parts.join(", "), color: "text" };
143
+ }
144
+ case "custom":
145
+ return { text: `${customGlyph} ${ans.text}`, color: "text" };
146
+ case "single":
147
+ return { text: ans.option, color: "text" };
148
+ }
149
+ }