@xynogen/pix-ask 0.2.14 → 0.2.15

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-ask",
3
- "version": "0.2.14",
3
+ "version": "0.2.15",
4
4
  "description": "Pi tool — structured questionnaire UI (ask_user)",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -0,0 +1,271 @@
1
+ /**
2
+ * chip-editor — a freeform Editor for the questionnaire that renders paste
3
+ * chips like the main Pi prompt does.
4
+ *
5
+ * long pasted text → buffer: [paste #1 +42 lines]
6
+ * display: 󰉿 text 42 lines
7
+ * pasted image path → buffer: [paste #2 58 chars]
8
+ * display: 󰋩 image #2
9
+ *
10
+ * The base `pi-tui` Editor already collapses large text pastes (>10 lines or
11
+ * >1000 chars) into `[paste #N …]` markers and expands them back to their full
12
+ * content on submit. This subclass adds two things on top:
13
+ *
14
+ * 1. Image-path detection — a pasted/typed image path becomes its own paste
15
+ * marker so it deletes atomically and shows an image chip. On submit it
16
+ * expands back to the raw path (a reference the model can read), matching
17
+ * pix-display's clipboard-image behavior.
18
+ * 2. Display restyle — every `[paste #N …]` marker is re-rendered as a
19
+ * colored icon chip. Purely visual; the buffer is untouched.
20
+ *
21
+ * This duplicates the small helpers from pix-display's ChipEditor rather than
22
+ * adding a cross-package dependency (repo policy prefers duplication).
23
+ */
24
+
25
+ import {
26
+ Editor,
27
+ type EditorTheme,
28
+ type KeybindingsManager,
29
+ type TUI,
30
+ truncateToWidth,
31
+ visibleWidth,
32
+ } from "@earendil-works/pi-tui";
33
+ import { BOLD, FG_BLUE, FG_DIM, FG_GREEN, RST } from "@xynogen/pix-pretty/ansi";
34
+ import { icon } from "@xynogen/pix-pretty/icon-catalog";
35
+ import { readClipboardImageToFile } from "./clipboard-image.js";
36
+
37
+ // ─── Constants ──────────────────────────────────────────────────────────────
38
+
39
+ // Boundary wrapper injected around each expanded paste so the model sees an
40
+ // explicit start/end per blob instead of adjacent pastes merged into one wall.
41
+ // Applies to text and image pastes alike.
42
+ const PASTE_OPEN = "<paste>";
43
+ const PASTE_CLOSE = "</paste>";
44
+
45
+ const IMAGE_EXTS = new Set([
46
+ ".png",
47
+ ".jpg",
48
+ ".jpeg",
49
+ ".gif",
50
+ ".webp",
51
+ ".bmp",
52
+ ".tif",
53
+ ".tiff",
54
+ ".heic",
55
+ ".heif",
56
+ ]);
57
+
58
+ // Group 1 = prefix char (or empty at start), Group 2 = path.
59
+ const PATH_RE = /(^|[^\w/])((?:~|\/)[^\s,;'"(){}[\]]+)/g;
60
+
61
+ // Pi's marker grammar — must match exactly for atomic segmentation.
62
+ const MARKER_RE = /\[paste #(\d+)( (\+(\d+) lines|(\d+) chars))?\]/g;
63
+ // Cursor inversion codes the Editor embeds when the cursor intersects a marker.
64
+ const CURSOR_RE = /\x1b\[[0-9;]*m/g;
65
+ // A `[…]` span that starts with `paste #` and may carry interleaved cursor SGR.
66
+ const MARKER_SPAN_RE =
67
+ /(?:\x1b\[[0-9;]*m)*\[(?:\x1b\[[0-9;]*m)*paste #(?:[^\]]|\x1b\[[0-9;]*m)*\]/g;
68
+
69
+ // ─── Helpers (duplicated from pix-display/paste-chips) ───────────────────────
70
+
71
+ function extOf(p: string): string {
72
+ const dot = p.lastIndexOf(".");
73
+ return dot >= 0 ? p.slice(dot).toLowerCase() : "";
74
+ }
75
+
76
+ function isImagePath(p: string): boolean {
77
+ return IMAGE_EXTS.has(extOf(p));
78
+ }
79
+
80
+ function makeMarker(id: number, charCount: number): string {
81
+ return `[paste #${id} ${charCount} chars]`;
82
+ }
83
+
84
+ /** True when `text` ends with a Pi paste marker (chip). */
85
+ export function endsWithMarker(text: string): boolean {
86
+ return /\[paste #\d+[^\]]*\]$/.test(text);
87
+ }
88
+
89
+ /** Mirror of the base Editor's paste-marker grammar, scoped to one id. */
90
+ function markerReFor(pasteId: number): RegExp {
91
+ // pasteId is a numeric Map key; coerce to an integer literal so the pattern
92
+ // is provably digits-only (no injection / ReDoS surface).
93
+ const id = Math.trunc(pasteId);
94
+ return new RegExp(`\\[paste #${id}( (\\+\\d+ lines|\\d+ chars))?\\]`, "g");
95
+ }
96
+
97
+ /**
98
+ * Expand every paste marker in `text` to its content wrapped in
99
+ * `<paste>…</paste>`. Mirrors the base Editor's expansion loop but adds a
100
+ * boundary per blob (text and image alike) so adjacent pastes can't merge into
101
+ * one indistinguishable wall in the model-facing text.
102
+ */
103
+ export function expandPasteMarkers(text: string, pastes: Map<number, string>): string {
104
+ let result = text;
105
+ for (const [pasteId, pasteContent] of pastes) {
106
+ result = result.replace(
107
+ markerReFor(pasteId),
108
+ () => `${PASTE_OPEN}${pasteContent}${PASTE_CLOSE}`,
109
+ );
110
+ }
111
+ return result;
112
+ }
113
+
114
+ function compactNumber(raw: string): string {
115
+ const n = Number.parseInt(raw, 10);
116
+ if (!Number.isFinite(n)) return raw;
117
+ if (n < 1_000) return `${n}`;
118
+ if (n < 1_000_000) return `${(n / 1_000).toFixed(1).replace(/\.0$/, "")}k`;
119
+ return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}m`;
120
+ }
121
+
122
+ type EditorInternals = {
123
+ pastes: Map<number, string>;
124
+ pasteCounter: number;
125
+ };
126
+
127
+ /**
128
+ * Walk `text`; for each image path allocate a new paste ID, register the real
129
+ * path in editor.pastes, remember the ID as an image, and emit a Pi-format
130
+ * marker so deletion is atomic and the display shows an image chip.
131
+ */
132
+ export function replaceImagePaths(
133
+ text: string,
134
+ internals: EditorInternals,
135
+ imageIds: Set<number>,
136
+ ): string {
137
+ return text.replace(PATH_RE, (_, prefix: string, rawPath: string) => {
138
+ if (!isImagePath(rawPath)) return prefix + rawPath;
139
+ internals.pasteCounter += 1;
140
+ const id = internals.pasteCounter;
141
+ internals.pastes.set(id, rawPath);
142
+ imageIds.add(id);
143
+ return prefix + makeMarker(id, rawPath.length);
144
+ });
145
+ }
146
+
147
+ /**
148
+ * Re-style every paste marker in a rendered line:
149
+ * image → `󰋩 image #N` (blue), text → `󰉿 text N lines/chars` (green).
150
+ * Width-preserving is not required — the Editor re-wraps each render call.
151
+ */
152
+ export function restyleMarkers(line: string, imageIds: Set<number>): string {
153
+ return line.replace(MARKER_SPAN_RE, (span) => {
154
+ const clean = span.includes("\x1b") ? span.replace(CURSOR_RE, "") : span;
155
+ MARKER_RE.lastIndex = 0;
156
+ const m = MARKER_RE.exec(clean);
157
+ if (!m?.[1]) return span;
158
+ const [, idStr, , , linesStr, charsStr] = m;
159
+ const id = Number.parseInt(idStr, 10);
160
+ if (imageIds.has(id)) {
161
+ return chip(FG_BLUE, icon("paste.image"), "image", `#${id}`);
162
+ }
163
+ if (linesStr) {
164
+ return chip(FG_GREEN, icon("paste.text"), "text", `${linesStr} lines`);
165
+ }
166
+ if (charsStr) {
167
+ return chip(FG_GREEN, icon("paste.text"), "text", `${compactNumber(charsStr)} chars`);
168
+ }
169
+ return chip(FG_GREEN, icon("paste.text"), "text", `#${id}`);
170
+ });
171
+ }
172
+
173
+ function chip(color: string, glyph: string, label: string, meta: string): string {
174
+ return `${color}${BOLD}${glyph} ${label}${RST}${FG_DIM} ${meta}${RST}`;
175
+ }
176
+
177
+ // ─── ChipEditor ──────────────────────────────────────────────────────────────
178
+
179
+ /**
180
+ * Editor subclass for the questionnaire freeform row. Text pastes are collapsed
181
+ * to badges by the base class; image paths become image chips here. Both expand
182
+ * to their real content/path on submit via the base `submitValue`.
183
+ */
184
+ export class ChipEditor extends Editor {
185
+ private readonly imageIds = new Set<number>();
186
+ private readonly keybindings?: KeybindingsManager;
187
+
188
+ constructor(tui: TUI, theme: EditorTheme, keybindings?: KeybindingsManager) {
189
+ super(tui, theme);
190
+ this.keybindings = keybindings;
191
+ this.patchHandlePaste();
192
+ this.patchExpandPasteMarkers();
193
+ this.patchSubmitValue();
194
+ }
195
+
196
+ /**
197
+ * Patch `expandPasteMarkers` (TS-private on the base Editor, runtime-public
198
+ * JS) so every paste expands to its content wrapped in `<paste>…</paste>`.
199
+ * The base inlines content raw, letting adjacent pastes merge into one
200
+ * indistinguishable wall; the boundary gives the model an explicit start/end
201
+ * per blob — text and image path alike.
202
+ */
203
+ private patchExpandPasteMarkers(): void {
204
+ const internals = this as unknown as EditorInternals;
205
+ const self = this as unknown as { expandPasteMarkers(text: string): string };
206
+ self.expandPasteMarkers = (text: string): string => expandPasteMarkers(text, internals.pastes);
207
+ }
208
+
209
+ override insertTextAtCursor(text: string): void {
210
+ const internals = this as unknown as EditorInternals;
211
+ const replaced = replaceImagePaths(text, internals, this.imageIds);
212
+ // Land the cursor after the chip, not inside it.
213
+ super.insertTextAtCursor(endsWithMarker(replaced) ? `${replaced} ` : replaced);
214
+ }
215
+
216
+ private patchHandlePaste(): void {
217
+ const self = this as unknown as { handlePaste(text: string): void };
218
+ const base = self.handlePaste.bind(self);
219
+ self.handlePaste = (pastedText: string) => {
220
+ const before = this.getText();
221
+ base(pastedText);
222
+ const after = this.getText();
223
+ if (endsWithMarker(after) && after !== before) {
224
+ super.insertTextAtCursor(" ");
225
+ }
226
+ };
227
+ }
228
+
229
+ private patchSubmitValue(): void {
230
+ const self = this as unknown as { submitValue(): void };
231
+ const base = self.submitValue.bind(self);
232
+ self.submitValue = () => {
233
+ // The base resets pasteCounter to zero in submitValue; clear the
234
+ // parallel image-ID registry in the same operation before reuse.
235
+ this.imageIds.clear();
236
+ base();
237
+ };
238
+ }
239
+
240
+ /**
241
+ * Intercept the paste-image key for clipboard-image capture before the base
242
+ * Editor sees it. The questionnaire runs as an overlay, so Pi's app-level
243
+ * paste-image handler never fires here; we replicate it. The key is resolved
244
+ * through the app KeybindingsManager (honoring user remaps and Kitty
245
+ * encoding) rather than a raw byte compare. On an image hit we spill the
246
+ * bytes to a temp file and insert its path, which `insertTextAtCursor` turns
247
+ * into an image chip. No image (or no keybindings/tool) → fall through so the
248
+ * base Editor handles the key as ordinary input / text paste.
249
+ */
250
+ override handleInput(data: string): void {
251
+ if (this.keybindings?.matches(data, "app.clipboard.pasteImage")) {
252
+ const filePath = readClipboardImageToFile();
253
+ if (filePath) {
254
+ this.insertTextAtCursor(filePath);
255
+ return;
256
+ }
257
+ }
258
+ super.handleInput(data);
259
+ }
260
+
261
+ override render(width: number): string[] {
262
+ const raw = super.render(width);
263
+ return raw.map((line) => {
264
+ const restyled = restyleMarkers(line, this.imageIds);
265
+ if (visibleWidth(restyled) > width) {
266
+ return truncateToWidth(restyled, width, "");
267
+ }
268
+ return restyled;
269
+ });
270
+ }
271
+ }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * clipboard-image.ts — read an image off the system clipboard and spill it to a
3
+ * temp file, returning the path.
4
+ *
5
+ * The questionnaire runs as an overlay, so Pi's app-level "paste image" handler
6
+ * (Ctrl+V) never reaches it, and Pi's own `readClipboardImage` is not importable
7
+ * (its package exports map exposes only `.` and `./rpc-entry`). This is a small,
8
+ * provider-neutral reimplementation of the same probe order Pi uses:
9
+ *
10
+ * Wayland / WSL → wl-paste, then xclip
11
+ * WSL (fallback) → powershell.exe (Windows clipboard)
12
+ * X11 → xclip
13
+ *
14
+ * Only the reachable, dependency-free path is implemented — spawning the same
15
+ * CLI tools Pi relies on. No clipboard, no image → null (caller falls back to
16
+ * text paste).
17
+ */
18
+
19
+ import { spawnSync } from "node:child_process";
20
+ import { randomUUID } from "node:crypto";
21
+ import { readFileSync, unlinkSync, writeFileSync } from "node:fs";
22
+ import { tmpdir } from "node:os";
23
+ import { join } from "node:path";
24
+
25
+ const LIST_TIMEOUT_MS = 1000;
26
+ const READ_TIMEOUT_MS = 3000;
27
+ const POWERSHELL_TIMEOUT_MS = 5000;
28
+ const MAX_BUFFER_BYTES = 50 * 1024 * 1024;
29
+
30
+ // Preference order mirrors Pi: PNG first, then other lossless/animated formats.
31
+ const SUPPORTED_MIME = ["image/png", "image/jpeg", "image/webp", "image/gif"];
32
+
33
+ type ClipImage = { bytes: Buffer; mimeType: string };
34
+
35
+ function baseMime(mimeType: string): string {
36
+ return mimeType.split(";")[0]?.trim().toLowerCase() ?? mimeType.toLowerCase();
37
+ }
38
+
39
+ export function extForMime(mimeType: string): string {
40
+ switch (baseMime(mimeType)) {
41
+ case "image/png":
42
+ return "png";
43
+ case "image/jpeg":
44
+ return "jpg";
45
+ case "image/webp":
46
+ return "webp";
47
+ case "image/gif":
48
+ return "gif";
49
+ default:
50
+ return "png";
51
+ }
52
+ }
53
+
54
+ function pickPreferredMime(types: string[]): string | null {
55
+ const normalized = types
56
+ .map((t) => t.trim())
57
+ .filter(Boolean)
58
+ .map((t) => ({ raw: t, base: baseMime(t) }));
59
+ for (const preferred of SUPPORTED_MIME) {
60
+ const match = normalized.find((t) => t.base === preferred);
61
+ if (match) return match.raw;
62
+ }
63
+ return normalized.find((t) => t.base.startsWith("image/"))?.raw ?? null;
64
+ }
65
+
66
+ function run(command: string, args: string[], timeoutMs = READ_TIMEOUT_MS): Buffer | null {
67
+ const result = spawnSync(command, args, { timeout: timeoutMs, maxBuffer: MAX_BUFFER_BYTES });
68
+ if (result.error || result.status !== 0) return null;
69
+ const out = result.stdout;
70
+ return Buffer.isBuffer(out) ? out : Buffer.from(out ?? "");
71
+ }
72
+
73
+ export function isWaylandSession(env: NodeJS.ProcessEnv = process.env): boolean {
74
+ return Boolean(env.WAYLAND_DISPLAY) || env.XDG_SESSION_TYPE === "wayland";
75
+ }
76
+
77
+ function isWSL(env: NodeJS.ProcessEnv = process.env): boolean {
78
+ if (env.WSL_DISTRO_NAME || env.WSLENV) return true;
79
+ try {
80
+ return /microsoft|wsl/i.test(readFileSync("/proc/version", "utf-8"));
81
+ } catch {
82
+ return false;
83
+ }
84
+ }
85
+
86
+ function readViaWlPaste(): ClipImage | null {
87
+ const list = run("wl-paste", ["--list-types"], LIST_TIMEOUT_MS);
88
+ if (!list) return null;
89
+ const types = list
90
+ .toString("utf-8")
91
+ .split(/\r?\n/)
92
+ .map((t) => t.trim())
93
+ .filter(Boolean);
94
+ const selected = pickPreferredMime(types);
95
+ if (!selected) return null;
96
+ const data = run("wl-paste", ["--type", selected, "--no-newline"]);
97
+ if (!data || data.length === 0) return null;
98
+ return { bytes: data, mimeType: baseMime(selected) };
99
+ }
100
+
101
+ function readViaXclip(): ClipImage | null {
102
+ const targets = run("xclip", ["-selection", "clipboard", "-t", "TARGETS", "-o"], LIST_TIMEOUT_MS);
103
+ const candidates = targets
104
+ ? targets
105
+ .toString("utf-8")
106
+ .split(/\r?\n/)
107
+ .map((t) => t.trim())
108
+ .filter(Boolean)
109
+ : [];
110
+ const preferred = candidates.length > 0 ? pickPreferredMime(candidates) : null;
111
+ const tryTypes = preferred ? [preferred, ...SUPPORTED_MIME] : [...SUPPORTED_MIME];
112
+ for (const mimeType of tryTypes) {
113
+ const data = run("xclip", ["-selection", "clipboard", "-t", mimeType, "-o"]);
114
+ if (data && data.length > 0) return { bytes: data, mimeType: baseMime(mimeType) };
115
+ }
116
+ return null;
117
+ }
118
+
119
+ function readViaPowerShell(): ClipImage | null {
120
+ const tmpFile = join(tmpdir(), `pix-wsl-clip-${randomUUID()}.png`);
121
+ try {
122
+ const winPathBuf = run("wslpath", ["-w", tmpFile], LIST_TIMEOUT_MS);
123
+ const winPath = winPathBuf?.toString("utf-8").trim();
124
+ if (!winPath) return null;
125
+ const psQuoted = winPath.split("'").join("''");
126
+ const psScript = [
127
+ "Add-Type -AssemblyName System.Windows.Forms",
128
+ "Add-Type -AssemblyName System.Drawing",
129
+ `$path = '${psQuoted}'`,
130
+ "$img = [System.Windows.Forms.Clipboard]::GetImage()",
131
+ "if ($img) { $img.Save($path, [System.Drawing.Imaging.ImageFormat]::Png); Write-Output 'ok' } else { Write-Output 'empty' }",
132
+ ].join("; ");
133
+ const out = run("powershell.exe", ["-NoProfile", "-Command", psScript], POWERSHELL_TIMEOUT_MS);
134
+ if (out?.toString("utf-8").trim() !== "ok") return null;
135
+ const bytes = readFileSync(tmpFile);
136
+ if (bytes.length === 0) return null;
137
+ return { bytes, mimeType: "image/png" };
138
+ } catch {
139
+ return null;
140
+ } finally {
141
+ try {
142
+ unlinkSync(tmpFile);
143
+ } catch {
144
+ // best-effort cleanup
145
+ }
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Read a clipboard image and spill it to a temp file. Returns the file path, or
151
+ * null when there is no image (or no clipboard tool). Linux-only probing; other
152
+ * platforms return null (overlay can't reach a native clipboard bridge).
153
+ */
154
+ export function readClipboardImageToFile(
155
+ env: NodeJS.ProcessEnv = process.env,
156
+ platform: NodeJS.Platform = process.platform,
157
+ ): string | null {
158
+ if (env.TERMUX_VERSION || platform !== "linux") return null;
159
+
160
+ const wsl = isWSL(env);
161
+ const wayland = isWaylandSession(env);
162
+
163
+ let image: ClipImage | null = null;
164
+ if (wayland || wsl) image = readViaWlPaste() ?? readViaXclip();
165
+ if (!image && wsl) image = readViaPowerShell();
166
+ if (!image && !wayland) image = readViaXclip();
167
+ if (!image) return null;
168
+
169
+ const ext = extForMime(image.mimeType);
170
+ const filePath = join(tmpdir(), `pix-clipboard-${randomUUID()}.${ext}`);
171
+ try {
172
+ writeFileSync(filePath, image.bytes);
173
+ } catch {
174
+ return null;
175
+ }
176
+ return filePath;
177
+ }
package/src/helpers.ts CHANGED
@@ -23,13 +23,18 @@ export function hasAnyPreview(q: QuestionData): boolean {
23
23
  return q.options.some((o) => typeof o.preview === "string" && o.preview.length > 0);
24
24
  }
25
25
 
26
- /** Which sentinel rows are auto-appended for a question. */
26
+ /**
27
+ * Which sentinel rows are auto-appended for a question.
28
+ *
29
+ * Freeform ("Type something.") is ALWAYS offered so the user can reject every
30
+ * option and answer in their own words — regardless of preview or multi-select.
31
+ * Multi-select additionally gets a "Confirm" row to commit the checked options.
32
+ */
27
33
  export function sentinelsFor(q: QuestionData): Array<{ kind: string; label: string }> {
28
34
  const out: Array<{ kind: string; label: string }> = [];
35
+ out.push({ kind: "other", label: SENTINEL_FREEFORM });
29
36
  if (q.multiSelect) {
30
37
  out.push({ kind: "next", label: SENTINEL_NEXT });
31
- } else if (!hasAnyPreview(q)) {
32
- out.push({ kind: "other", label: SENTINEL_FREEFORM });
33
38
  }
34
39
  return out;
35
40
  }
@@ -2,7 +2,6 @@ import type { Theme } from "@earendil-works/pi-coding-agent";
2
2
  import {
3
3
  Container,
4
4
  decodeKittyPrintable,
5
- Editor,
6
5
  fuzzyFilter,
7
6
  Key,
8
7
  type KeybindingsManager,
@@ -19,6 +18,7 @@ import {
19
18
  modalWidth,
20
19
  terminalModalHeight,
21
20
  } from "@xynogen/pix-pretty/modal-frame";
21
+ import { ChipEditor } from "./chip-editor.js";
22
22
  import { dim } from "./components.js";
23
23
  import { checkboxGlyphs, selectionGlyph } from "./glyphs.js";
24
24
  import { safeMarkdownTheme, sentinelsFor } from "./helpers.js";
@@ -57,7 +57,7 @@ export class AskQuestionnaire extends Container {
57
57
  private selectedOptionIndex = 0;
58
58
  private multiChecked = new Set<number>();
59
59
  private inputMode = false;
60
- private editor?: Editor;
60
+ private editor?: ChipEditor;
61
61
  private mdTheme = safeMarkdownTheme();
62
62
  private pager = new ModalPager();
63
63
  private selectedOptionRows: { start: number; end: number } | undefined;
@@ -119,18 +119,22 @@ export class AskQuestionnaire extends Container {
119
119
 
120
120
  // ── Layout ─────────────────────────────────────────────────────────
121
121
 
122
- private ensureEditor(): Editor {
122
+ private ensureEditor(): ChipEditor {
123
123
  if (this.editor) return this.editor;
124
- const editor = new Editor(this.tui, {
125
- borderColor: (s: string) => this.theme.fg("accent", s),
126
- selectList: {
127
- selectedPrefix: (s: string) => this.theme.fg("accent", s),
128
- selectedText: (s: string) => this.theme.fg("accent", s),
129
- description: (s: string) => this.theme.fg("muted", s),
130
- scrollInfo: (s: string) => this.theme.fg("dim", s),
131
- noMatch: (s: string) => this.theme.fg("warning", s),
124
+ const editor = new ChipEditor(
125
+ this.tui,
126
+ {
127
+ borderColor: (s: string) => this.theme.fg("accent", s),
128
+ selectList: {
129
+ selectedPrefix: (s: string) => this.theme.fg("accent", s),
130
+ selectedText: (s: string) => this.theme.fg("accent", s),
131
+ description: (s: string) => this.theme.fg("muted", s),
132
+ scrollInfo: (s: string) => this.theme.fg("dim", s),
133
+ noMatch: (s: string) => this.theme.fg("warning", s),
134
+ },
132
135
  },
133
- });
136
+ this.keybindings,
137
+ );
134
138
  editor.disableSubmit = false;
135
139
  editor.onSubmit = (text: string) => this.handleFreeformSubmit(text);
136
140
  editor.focused = true;
@@ -450,6 +454,9 @@ export class AskQuestionnaire extends Container {
450
454
  const sel = i === this.selectedOptionIndex;
451
455
  const ptr = sel ? t.fg("accent", "→") : " ";
452
456
 
457
+ // Visually separate the "Confirm" commit row from the choices above it.
458
+ if (item.kind === "next" && lines.length > 0) lines.push("");
459
+
453
460
  const itemStart = lines.length;
454
461
  if (item.kind === "option" && item.option) {
455
462
  const optIdx = this.filteredOptions.indexOf(item.option);
package/src/schema.ts CHANGED
@@ -10,7 +10,7 @@ export const MAX_LABEL_LENGTH = 60;
10
10
 
11
11
  export const SENTINEL_FREEFORM = "Type something.";
12
12
  export const SENTINEL_CHAT = "Chat about this";
13
- export const SENTINEL_NEXT = "Next";
13
+ export const SENTINEL_NEXT = "Confirm";
14
14
 
15
15
  export const SPLIT_PANE_MIN_WIDTH = 84;
16
16
  export const SEPARATOR = " │ ";