@vitos-pizza/vitos-pizza 0.3.1 → 0.4.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.
Files changed (37) hide show
  1. package/AGENTS.md +2 -2
  2. package/CHANGELOG.md +42 -11
  3. package/README.md +31 -4
  4. package/assets/architecture.png +0 -0
  5. package/assets/architecture.svg +97 -0
  6. package/package.json +22 -12
  7. package/packages/agent-mode/package.json +3 -3
  8. package/packages/agent-mode/src/plan-instructions.ts +19 -7
  9. package/packages/hypa/extensions/index.ts +16 -0
  10. package/packages/hypa/package.json +23 -0
  11. package/packages/hypa/scripts/smoke.mjs +50 -0
  12. package/packages/hypa/src/config.ts +37 -0
  13. package/packages/hypa/src/seed.ts +22 -0
  14. package/packages/hypa/tsconfig.json +13 -0
  15. package/packages/keybindings/package.json +1 -1
  16. package/packages/permission-system/package.json +2 -2
  17. package/packages/permission-system/presets/default.json +8 -0
  18. package/packages/permission-system/presets/plan.json +6 -0
  19. package/packages/permission-system/presets/yolo.json +1 -0
  20. package/packages/question/extensions/index.ts +54 -21
  21. package/packages/question/package.json +1 -1
  22. package/packages/question/src/forwarding/file-watcher.ts +41 -11
  23. package/packages/question/src/forwarding/forwarder.ts +61 -27
  24. package/packages/question/src/question-ui.ts +773 -78
  25. package/packages/question/src/register-tool.ts +351 -40
  26. package/packages/question/src/types.ts +79 -7
  27. package/packages/session-title/package.json +3 -3
  28. package/packages/settings-preset/package.json +1 -1
  29. package/packages/subagents/agents/planner.md +3 -2
  30. package/packages/subagents/agents/scout.md +2 -1
  31. package/packages/subagents/agents/worker.md +3 -2
  32. package/packages/subagents/package.json +1 -1
  33. package/packages/todoist/package.json +1 -1
  34. package/packages/ui-enhancements/package.json +1 -1
  35. package/packages/ui-enhancements/src/chrome/resize-recovery.ts +71 -14
  36. package/packages/websearch/package.json +1 -1
  37. package/scripts/sync-pi-manifest.mjs +18 -12
@@ -8,18 +8,51 @@ import {
8
8
  visibleWidth,
9
9
  wrapTextWithAnsi,
10
10
  } from "@earendil-works/pi-tui";
11
- import type { QuestionParams, QuestionUiResult } from "./types.ts";
11
+ import type {
12
+ MultiQuestionParams,
13
+ MultiQuestionUiResult,
14
+ MultiTabAnswer,
15
+ QuestionParams,
16
+ QuestionUiResult,
17
+ SelectType,
18
+ SingleTabAnswer,
19
+ TabAnswer,
20
+ } from "./types.ts";
12
21
 
13
22
  const CUSTOM_INPUT_PLACEHOLDER = "Type your answer";
23
+ const CHECKED = "☑";
24
+ const UNCHECKED = "☐";
25
+
26
+ // ────────────────────────────────────────────
27
+ // Type helper — embedded selectType
28
+ // ────────────────────────────────────────────
29
+
30
+ interface QuestionParamsWithSelect extends QuestionParams {
31
+ selectType?: SelectType;
32
+ }
33
+
34
+ // ────────────────────────────────────────────
35
+ // Legacy single‑question UI (selectType-aware)
36
+ // ────────────────────────────────────────────
14
37
 
15
38
  export async function runQuestionUi(
16
39
  ui: ExtensionUIContext,
17
- params: QuestionParams,
40
+ params: QuestionParamsWithSelect,
18
41
  ): Promise<QuestionUiResult | null> {
19
- if (params.options.length === 0) {
20
- return null;
42
+ if (params.options.length === 0) return null;
43
+
44
+ if (params.selectType === "multi") {
45
+ return runMultiSelectSingleQuestion(ui, params);
21
46
  }
22
47
 
48
+ return runSingleSelectSingleQuestion(ui, params);
49
+ }
50
+
51
+ /** Single-select, single question (original behavior) */
52
+ async function runSingleSelectSingleQuestion(
53
+ ui: ExtensionUIContext,
54
+ params: QuestionParams,
55
+ ): Promise<QuestionUiResult | null> {
23
56
  const options = params.options;
24
57
  const customInputIndex = options.length;
25
58
 
@@ -29,7 +62,7 @@ export async function runQuestionUi(
29
62
  let cachedWidth: number | undefined;
30
63
  const customInput = new Input();
31
64
 
32
- customInput.onSubmit = (value) => {
65
+ customInput.onSubmit = (value: string) => {
33
66
  const trimmed = value.trim();
34
67
  if (trimmed && optionIndex === customInputIndex) {
35
68
  done({ answer: trimmed, wasCustom: true });
@@ -49,11 +82,7 @@ export async function runQuestionUi(
49
82
  function selectPresetOption(index: number) {
50
83
  const selected = options[index];
51
84
  if (!selected) return;
52
- done({
53
- answer: selected.label,
54
- wasCustom: false,
55
- index: index + 1,
56
- });
85
+ done({ answer: selected.label, wasCustom: false, index: index + 1 });
57
86
  }
58
87
 
59
88
  function handleInput(data: string) {
@@ -75,9 +104,7 @@ export async function runQuestionUi(
75
104
  }
76
105
  if (matchesKey(data, Key.enter)) {
77
106
  const trimmed = customInput.getValue().trim();
78
- if (trimmed) {
79
- done({ answer: trimmed, wasCustom: true });
80
- }
107
+ if (trimmed) done({ answer: trimmed, wasCustom: true });
81
108
  return;
82
109
  }
83
110
  customInput.focused = true;
@@ -114,54 +141,6 @@ export async function runQuestionUi(
114
141
  }
115
142
  }
116
143
 
117
- function renderCustomInputRow(
118
- prefix: string,
119
- numberLabel: string,
120
- availableWidth: number,
121
- selected: boolean,
122
- ): string[] {
123
- const numberPrefix = `${numberLabel} `;
124
- const numberWidth = visibleWidth(prefix + numberPrefix);
125
- const inputWidth = Math.max(1, availableWidth - numberWidth);
126
- const value = customInput.getValue();
127
-
128
- if (selected && value.length > 0) {
129
- customInput.focused = true;
130
- const [inputLine = ""] = customInput.render(inputWidth);
131
- const stripped = inputLine.startsWith("> ")
132
- ? inputLine.slice(2)
133
- : inputLine;
134
- return [`${prefix}${numberPrefix}${stripped}`];
135
- }
136
-
137
- if (selected) {
138
- customInput.focused = true;
139
- const marker = CURSOR_MARKER;
140
- const firstChar = CUSTOM_INPUT_PLACEHOLDER[0] ?? " ";
141
- const rest = CUSTOM_INPUT_PLACEHOLDER.slice(1);
142
- const cursorFirst = `\x1b[7m${firstChar}\x1b[27m`;
143
- const restPlaceholder = theme.fg("dim", rest);
144
- const field = `${marker}${cursorFirst}${restPlaceholder}`;
145
- const padding = " ".repeat(
146
- Math.max(0, inputWidth - visibleWidth(CUSTOM_INPUT_PLACEHOLDER)),
147
- );
148
- return [`${prefix}${numberPrefix}${field}${padding}`];
149
- }
150
-
151
- customInput.focused = false;
152
- const display =
153
- value.length > 0
154
- ? theme.fg("text", value)
155
- : theme.fg("dim", CUSTOM_INPUT_PLACEHOLDER);
156
- const padding =
157
- value.length > 0
158
- ? ""
159
- : " ".repeat(
160
- Math.max(0, inputWidth - visibleWidth(CUSTOM_INPUT_PLACEHOLDER)),
161
- );
162
- return [`${prefix}${numberPrefix}${display}${padding}`];
163
- }
164
-
165
144
  function render(width: number): string[] {
166
145
  if (cachedLines && cachedWidth === width) return cachedLines;
167
146
 
@@ -171,7 +150,6 @@ export async function runQuestionUi(
171
150
  function addWrapped(text: string) {
172
151
  lines.push(...wrapTextWithAnsi(text, renderWidth));
173
152
  }
174
-
175
153
  function addWrappedWithPrefix(prefix: string, text: string) {
176
154
  const prefixWidth = visibleWidth(prefix);
177
155
  if (prefixWidth >= renderWidth) {
@@ -179,9 +157,9 @@ export async function runQuestionUi(
179
157
  return;
180
158
  }
181
159
  const wrapped = wrapTextWithAnsi(text, renderWidth - prefixWidth);
182
- const continuationPrefix = " ".repeat(prefixWidth);
160
+ const cp = " ".repeat(prefixWidth);
183
161
  for (let i = 0; i < wrapped.length; i++) {
184
- lines.push(`${i === 0 ? prefix : continuationPrefix}${wrapped[i]}`);
162
+ lines.push(`${i === 0 ? prefix : cp}${wrapped[i]}`);
185
163
  }
186
164
  }
187
165
 
@@ -191,13 +169,11 @@ export async function runQuestionUi(
191
169
 
192
170
  for (let i = 0; i < options.length; i++) {
193
171
  const opt = options[i];
194
- const selected = !isOnCustomInput() && i === optionIndex;
195
- const prefix = selected ? theme.fg("accent", "> ") : " ";
172
+ const sel = !isOnCustomInput() && i === optionIndex;
173
+ const prefix = sel ? theme.fg("accent", "> ") : " ";
196
174
  const label = `${i + 1}. ${opt.label}`;
197
- const color = selected ? "accent" : "text";
198
-
175
+ const color = sel ? "accent" : "text";
199
176
  addWrappedWithPrefix(prefix, theme.fg(color, label));
200
-
201
177
  if (opt.description) {
202
178
  addWrappedWithPrefix(" ", theme.fg("muted", opt.description));
203
179
  }
@@ -206,11 +182,13 @@ export async function runQuestionUi(
206
182
  const customSelected = isOnCustomInput();
207
183
  const customPrefix = customSelected ? theme.fg("accent", "> ") : " ";
208
184
  const customNumber = `${customInputIndex + 1}.`;
209
- for (const line of renderCustomInputRow(
185
+ for (const line of renderSingleSelectInputRow(
210
186
  customPrefix,
211
187
  customNumber,
212
188
  renderWidth,
213
189
  customSelected,
190
+ customInput,
191
+ theme,
214
192
  )) {
215
193
  addWrapped(line);
216
194
  }
@@ -218,10 +196,7 @@ export async function runQuestionUi(
218
196
  lines.push("");
219
197
  addWrappedWithPrefix(
220
198
  " ",
221
- theme.fg(
222
- "dim",
223
- "↑↓ select • 1-9 quick pick • Enter confirm • Esc cancel",
224
- ),
199
+ theme.fg("dim", "↑↓ select • 1-9 quick pick • Enter confirm • Esc cancel"),
225
200
  );
226
201
  lines.push(theme.fg("accent", "─".repeat(renderWidth)));
227
202
 
@@ -232,11 +207,731 @@ export async function runQuestionUi(
232
207
 
233
208
  return {
234
209
  render,
235
- invalidate: () => {
236
- cachedLines = undefined;
237
- cachedWidth = undefined;
238
- },
210
+ invalidate: () => { cachedLines = undefined; cachedWidth = undefined; },
211
+ handleInput,
212
+ };
213
+ });
214
+ }
215
+
216
+ /** Multi-select, single question */
217
+ async function runMultiSelectSingleQuestion(
218
+ ui: ExtensionUIContext,
219
+ params: QuestionParams,
220
+ ): Promise<QuestionUiResult | null> {
221
+ const options = params.options;
222
+
223
+ return ui.custom<QuestionUiResult | null>((tui, theme, _kb, done) => {
224
+ let cursorIndex = 0;
225
+ const selected = new Set<number>();
226
+ const customInput = new Input();
227
+ let customValue: string | null = null;
228
+
229
+ let cachedLines: string[] | undefined;
230
+ let cachedWidth: number | undefined;
231
+
232
+ const ci = options.length; // custom input index
233
+ const lastRow = ci; // custom input is last row; submit via Enter at any position
234
+
235
+ function refresh() {
236
+ cachedLines = undefined;
237
+ cachedWidth = undefined;
238
+ tui.requestRender();
239
+ }
240
+
241
+ function toggleCurrent() {
242
+ if (cursorIndex < ci) {
243
+ if (selected.has(cursorIndex)) {
244
+ selected.delete(cursorIndex);
245
+ } else {
246
+ selected.add(cursorIndex);
247
+ }
248
+ refresh();
249
+ }
250
+ }
251
+
252
+ function submitSelected() {
253
+ if (selected.size === 0 && !customValue) return; // ignore empty
254
+ const answers: string[] = [];
255
+ const indices: number[] = [];
256
+ for (const idx of [...selected].sort((a, b) => a - b)) {
257
+ answers.push(options[idx]!.label);
258
+ indices.push(idx + 1);
259
+ }
260
+ if (customValue) {
261
+ answers.push(customValue);
262
+ indices.push(-1);
263
+ }
264
+ done({
265
+ answer: answers.join(", "),
266
+ wasCustom: true,
267
+ index: indices[0],
268
+ multiAnswers: answers,
269
+ multiIndices: indices,
270
+ });
271
+ }
272
+
273
+ function handleInput(data: string) {
274
+ if (matchesKey(data, Key.escape)) {
275
+ done(null);
276
+ return;
277
+ }
278
+
279
+ if (cursorIndex === ci) {
280
+ // On custom input row
281
+ if (matchesKey(data, Key.up)) {
282
+ cursorIndex = Math.max(0, cursorIndex - 1);
283
+ customInput.focused = false;
284
+ refresh();
285
+ return;
286
+ }
287
+ if (matchesKey(data, Key.down)) {
288
+ refresh();
289
+ return;
290
+ }
291
+ if (matchesKey(data, Key.space)) {
292
+ refresh();
293
+ return;
294
+ }
295
+ if (matchesKey(data, Key.enter)) {
296
+ const trimmed = customInput.getValue().trim();
297
+ customValue = trimmed || null;
298
+ submitSelected();
299
+ return;
300
+ }
301
+ customInput.focused = true;
302
+ customInput.handleInput(data);
303
+ refresh();
304
+ return;
305
+ }
306
+
307
+ // On a preset option row
308
+ if (matchesKey(data, Key.up)) {
309
+ cursorIndex = Math.max(0, cursorIndex - 1);
310
+ customInput.focused = false;
311
+ refresh();
312
+ return;
313
+ }
314
+ if (matchesKey(data, Key.down)) {
315
+ cursorIndex = Math.min(ci, cursorIndex + 1);
316
+ customInput.focused = cursorIndex === ci;
317
+ refresh();
318
+ return;
319
+ }
320
+ if (matchesKey(data, Key.space)) {
321
+ toggleCurrent();
322
+ return;
323
+ }
324
+ if (matchesKey(data, Key.enter)) {
325
+ submitSelected();
326
+ return;
327
+ }
328
+ }
329
+
330
+ function render(width: number): string[] {
331
+ if (cachedLines && cachedWidth === width) return cachedLines;
332
+
333
+ const lines: string[] = [];
334
+ const renderWidth = Math.max(1, width);
335
+
336
+ function addWrapped(text: string) {
337
+ lines.push(...wrapTextWithAnsi(text, renderWidth));
338
+ }
339
+ function addWrappedWithPrefix(prefix: string, text: string) {
340
+ const prefixWidth = visibleWidth(prefix);
341
+ if (prefixWidth >= renderWidth) { addWrapped(prefix + text); return; }
342
+ const wrapped = wrapTextWithAnsi(text, renderWidth - prefixWidth);
343
+ const cp = " ".repeat(prefixWidth);
344
+ for (let i = 0; i < wrapped.length; i++) {
345
+ lines.push(`${i === 0 ? prefix : cp}${wrapped[i]}`);
346
+ }
347
+ }
348
+
349
+ lines.push(theme.fg("accent", "─".repeat(renderWidth)));
350
+ addWrappedWithPrefix(" ", theme.fg("text", params.question));
351
+ lines.push("");
352
+
353
+ for (let i = 0; i < options.length; i++) {
354
+ const opt = options[i];
355
+ const isFocused = cursorIndex === i;
356
+ const isChecked = selected.has(i);
357
+ const checkMark = isChecked ? CHECKED : UNCHECKED;
358
+ const prefix = isFocused ? theme.fg("accent", "> ") : " ";
359
+ const check = isFocused
360
+ ? theme.fg("accent", checkMark)
361
+ : isChecked
362
+ ? theme.fg("success", checkMark)
363
+ : theme.fg("muted", checkMark);
364
+ const label = `${i + 1}. ${opt.label}`;
365
+ const labelColor = isFocused ? "accent" : "text";
366
+ addWrappedWithPrefix(`${prefix}${check} `, theme.fg(labelColor, label));
367
+ if (opt.description) {
368
+ addWrappedWithPrefix(" ", theme.fg("muted", opt.description));
369
+ }
370
+ }
371
+
372
+ // Custom input row
373
+ const onCustom = cursorIndex === ci;
374
+ const customPrefix = onCustom ? theme.fg("accent", "> ") : " ";
375
+ const customNumber = `${ci + 1}.`;
376
+ for (const line of renderMultiSelectInputRow(
377
+ customPrefix,
378
+ customNumber,
379
+ renderWidth,
380
+ onCustom,
381
+ customInput,
382
+ theme,
383
+ )) {
384
+ addWrapped(line);
385
+ }
386
+
387
+ // Footer
388
+ lines.push("");
389
+ const n = selected.size + (customValue ? 1 : 0);
390
+ const help = n > 0
391
+ ? `↑↓ • Space toggle • Enter submit (${n}) • Esc cancel`
392
+ : "↑↓ • Space toggle • Enter submit • Esc cancel";
393
+ addWrappedWithPrefix(" ", theme.fg("dim", help));
394
+ lines.push(theme.fg("accent", "─".repeat(renderWidth)));
395
+
396
+ cachedLines = lines;
397
+ cachedWidth = width;
398
+ return lines;
399
+ }
400
+
401
+ return {
402
+ render,
403
+ invalidate: () => { cachedLines = undefined; cachedWidth = undefined; },
404
+ handleInput,
405
+ };
406
+ });
407
+ }
408
+
409
+ // ────────────────────────────────────────────
410
+ // Multi‑question tabbed UI (selectType-aware)
411
+ // ────────────────────────────────────────────
412
+
413
+ export async function runMultiQuestionUi(
414
+ ui: ExtensionUIContext,
415
+ params: MultiQuestionParams,
416
+ ): Promise<MultiQuestionUiResult | null> {
417
+ const tabs = params.questions;
418
+ if (tabs.length === 0) return null;
419
+
420
+ return ui.custom<MultiQuestionUiResult | null>((tui, theme, _kb, done) => {
421
+ // ── state ──
422
+ let activeTab = 0;
423
+ const tabInputs: Input[] = tabs.map(() => new Input());
424
+ const tabOptionIndex: number[] = tabs.map(() => 0);
425
+ /** Per-tab selected indices for multi-select */
426
+ const tabSelected: Set<number>[] = tabs.map(() => new Set());
427
+ const tabCustomValues: (string | null)[] = tabs.map(() => null);
428
+ const tabAnswers: (TabAnswer | null)[] = tabs.map(() => null);
429
+
430
+ let cachedLines: string[] | undefined;
431
+ let cachedWidth: number | undefined;
432
+
433
+ for (const inp of tabInputs) {
434
+ inp.onSubmit = (value: string) => {
435
+ const ti = tabInputs.indexOf(inp);
436
+ if (ti === activeTab) handleTabSubmit(ti, value.trim());
437
+ };
438
+ }
439
+
440
+ // ── helpers ──
441
+ function ci(tab: number): number {
442
+ return tabs[tab]?.options.length ?? 0;
443
+ }
444
+ function isSingle(tab: number): boolean {
445
+ return (tabs[tab]?.selectType ?? "single") === "single";
446
+ }
447
+ function isMulti(tab: number): boolean {
448
+ return (tabs[tab]?.selectType ?? "single") === "multi";
449
+ }
450
+ function isOnCustomInput(tab?: number): boolean {
451
+ const t = tab ?? activeTab;
452
+ return tabOptionIndex[t] === ci(t);
453
+ }
454
+
455
+ function setAnswer(tab: number, ans: TabAnswer) {
456
+ tabAnswers[tab] = ans;
457
+ if (allAnswered()) {
458
+ done(buildResult());
459
+ } else {
460
+ moveToNextUnanswered();
461
+ refresh();
462
+ }
463
+ }
464
+
465
+ function allAnswered(): boolean {
466
+ return tabAnswers.every((a) => a !== null);
467
+ }
468
+
469
+ function buildResult(): MultiQuestionUiResult {
470
+ const answers: Record<string, TabAnswer> = {};
471
+ for (let i = 0; i < tabs.length; i++) {
472
+ const tab = tabs[i];
473
+ const key = tab.id ?? `q${i}`;
474
+ const ans = tabAnswers[i];
475
+ if (ans) answers[key] = ans;
476
+ }
477
+ return { answers };
478
+ }
479
+
480
+ function moveToNextUnanswered() {
481
+ for (let i = 0; i < tabs.length; i++) {
482
+ const next = (activeTab + 1 + i) % tabs.length;
483
+ if (tabAnswers[next] === null) {
484
+ activeTab = next;
485
+ tabInputs[next]!.focused = isOnCustomInput(next);
486
+ return;
487
+ }
488
+ }
489
+ }
490
+
491
+ function refresh() {
492
+ cachedLines = undefined;
493
+ cachedWidth = undefined;
494
+ tui.requestRender();
495
+ }
496
+
497
+ // ── tab submit ──
498
+ function handleTabSubmit(tab: number, customVal: string) {
499
+ if (isSingle(tab)) {
500
+ const opt = tabs[tab]?.options[tabOptionIndex[tab]];
501
+ if (tabOptionIndex[tab] < ci(tab) && opt) {
502
+ setAnswer(tab, { answer: opt.label, wasCustom: false, index: tabOptionIndex[tab] + 1 });
503
+ } else if (customVal) {
504
+ setAnswer(tab, { answer: customVal, wasCustom: true });
505
+ }
506
+ } else {
507
+ // multi
508
+ const set = tabSelected[tab]!;
509
+ if (customVal) tabCustomValues[tab] = customVal;
510
+ const n = set.size + (tabCustomValues[tab] ? 1 : 0);
511
+ if (n === 0) return; // ignore empty
512
+ const ansLabels: string[] = [];
513
+ const ansIndices: number[] = [];
514
+ for (const idx of [...set].sort((a, b) => a - b)) {
515
+ ansLabels.push(tabs[tab]!.options[idx]!.label);
516
+ ansIndices.push(idx + 1);
517
+ }
518
+ if (tabCustomValues[tab]) {
519
+ ansLabels.push(tabCustomValues[tab]!);
520
+ ansIndices.push(-1);
521
+ }
522
+ setAnswer(tab, { answers: ansLabels, indices: ansIndices });
523
+ }
524
+ }
525
+
526
+ // ── input ──
527
+ function handleInput(data: string) {
528
+ if (matchesKey(data, Key.escape)) {
529
+ done(null);
530
+ return;
531
+ }
532
+
533
+ // Tab switching
534
+ if (matchesKey(data, Key.tab)) {
535
+ switchToTab((activeTab + 1) % tabs.length);
536
+ return;
537
+ }
538
+ if (matchesKey(data, Key.shift("tab"))) {
539
+ switchToTab((activeTab - 1 + tabs.length) % tabs.length);
540
+ return;
541
+ }
542
+ if (matchesKey(data, Key.left)) {
543
+ switchToTab((activeTab - 1 + tabs.length) % tabs.length);
544
+ return;
545
+ }
546
+ if (matchesKey(data, Key.right)) {
547
+ switchToTab((activeTab + 1) % tabs.length);
548
+ return;
549
+ }
550
+
551
+ const limit = ci(activeTab);
552
+ const isAnswered = tabAnswers[activeTab] !== null;
553
+
554
+ if (isAnswered) return; // no editing after submit
555
+
556
+ if (isSingle(activeTab)) {
557
+ handleSingleSelectInput(data);
558
+ } else {
559
+ handleMultiSelectInput(data);
560
+ }
561
+ }
562
+
563
+ function handleSingleSelectInput(data: string) {
564
+ const limit = ci(activeTab);
565
+
566
+ if (isOnCustomInput()) {
567
+ if (matchesKey(data, Key.up)) {
568
+ tabOptionIndex[activeTab] = Math.max(0, tabOptionIndex[activeTab] - 1);
569
+ tabInputs[activeTab]!.focused = false;
570
+ refresh();
571
+ return;
572
+ }
573
+ if (matchesKey(data, Key.down)) {
574
+ refresh();
575
+ return;
576
+ }
577
+ if (matchesKey(data, Key.enter)) {
578
+ handleTabSubmit(activeTab, tabInputs[activeTab]!.getValue().trim());
579
+ return;
580
+ }
581
+ tabInputs[activeTab]!.focused = true;
582
+ tabInputs[activeTab]!.handleInput(data);
583
+ refresh();
584
+ return;
585
+ }
586
+
587
+ if (matchesKey(data, Key.up)) {
588
+ tabOptionIndex[activeTab] = Math.max(0, tabOptionIndex[activeTab] - 1);
589
+ tabInputs[activeTab]!.focused = false;
590
+ refresh();
591
+ return;
592
+ }
593
+ if (matchesKey(data, Key.down)) {
594
+ tabOptionIndex[activeTab] = Math.min(limit, tabOptionIndex[activeTab] + 1);
595
+ tabInputs[activeTab]!.focused = tabOptionIndex[activeTab] === limit;
596
+ refresh();
597
+ return;
598
+ }
599
+ if (matchesKey(data, Key.enter)) {
600
+ const opt = tabs[activeTab]?.options[tabOptionIndex[activeTab]];
601
+ if (opt) {
602
+ setAnswer(activeTab, { answer: opt.label, wasCustom: false, index: tabOptionIndex[activeTab] + 1 });
603
+ }
604
+ return;
605
+ }
606
+
607
+ const key = parseKey(data);
608
+ if (key && /^[1-9]$/.test(key)) {
609
+ const index = Number(key) - 1;
610
+ if (index <= limit) {
611
+ tabOptionIndex[activeTab] = index;
612
+ tabInputs[activeTab]!.focused = index === limit;
613
+ refresh();
614
+ }
615
+ }
616
+ }
617
+
618
+ function handleMultiSelectInput(data: string) {
619
+ const limit = ci(activeTab);
620
+
621
+ if (isOnCustomInput()) {
622
+ if (matchesKey(data, Key.up)) {
623
+ tabOptionIndex[activeTab] = Math.max(0, tabOptionIndex[activeTab] - 1);
624
+ tabInputs[activeTab]!.focused = false;
625
+ refresh();
626
+ return;
627
+ }
628
+ if (matchesKey(data, Key.down)) {
629
+ refresh();
630
+ return;
631
+ }
632
+ if (matchesKey(data, Key.space)) {
633
+ refresh();
634
+ return;
635
+ }
636
+ if (matchesKey(data, Key.enter)) {
637
+ handleTabSubmit(activeTab, tabInputs[activeTab]!.getValue().trim());
638
+ return;
639
+ }
640
+ tabInputs[activeTab]!.focused = true;
641
+ tabInputs[activeTab]!.handleInput(data);
642
+ refresh();
643
+ return;
644
+ }
645
+
646
+ // On preset option
647
+ if (matchesKey(data, Key.up)) {
648
+ tabOptionIndex[activeTab] = Math.max(0, tabOptionIndex[activeTab] - 1);
649
+ tabInputs[activeTab]!.focused = false;
650
+ refresh();
651
+ return;
652
+ }
653
+ if (matchesKey(data, Key.down)) {
654
+ tabOptionIndex[activeTab] = Math.min(limit, tabOptionIndex[activeTab] + 1);
655
+ tabInputs[activeTab]!.focused = tabOptionIndex[activeTab] === limit;
656
+ refresh();
657
+ return;
658
+ }
659
+ if (matchesKey(data, Key.space)) {
660
+ const idx = tabOptionIndex[activeTab];
661
+ if (tabSelected[activeTab]!.has(idx)) {
662
+ tabSelected[activeTab]!.delete(idx);
663
+ } else {
664
+ tabSelected[activeTab]!.add(idx);
665
+ }
666
+ refresh();
667
+ return;
668
+ }
669
+ if (matchesKey(data, Key.enter)) {
670
+ handleTabSubmit(activeTab, "");
671
+ return;
672
+ }
673
+ }
674
+
675
+ function switchToTab(index: number) {
676
+ if (index === activeTab) return;
677
+ tabInputs[activeTab]!.focused = false;
678
+ activeTab = index;
679
+ tabInputs[activeTab]!.focused = isOnCustomInput();
680
+ refresh();
681
+ }
682
+
683
+ // ── render ──
684
+
685
+ function renderTabBar(width: number): string[] {
686
+ if (tabs.length <= 1) return [];
687
+
688
+ const tabLabels: string[] = tabs.map((t, i) => {
689
+ const answered = tabAnswers[i] !== null;
690
+ const check = answered ? " ✔" : "";
691
+ return t.title ?? `Q${i + 1}${check}`;
692
+ });
693
+
694
+ const segments: string[] = [];
695
+ let remaining = width;
696
+
697
+ for (let i = 0; i < tabLabels.length; i++) {
698
+ if (remaining <= 3) break;
699
+ const isActive = i === activeTab;
700
+ const prefix = i === 0 ? " " : " ";
701
+ const suffix = " ";
702
+ const tabsLeft = tabLabels.length - i;
703
+ const maxLabelLen = Math.max(4, Math.floor((remaining - 2) / tabsLeft) - 2);
704
+ let label = tabLabels[i]!;
705
+ if (visibleWidth(label) > maxLabelLen) {
706
+ label = label.slice(0, maxLabelLen - 1) + "…";
707
+ }
708
+ const tabContent = `${prefix}${label}${suffix}`;
709
+ const styled = isActive
710
+ ? theme.fg("accent", tabContent)
711
+ : tabAnswers[i] !== null
712
+ ? theme.fg("success", tabContent)
713
+ : theme.fg("muted", tabContent);
714
+ segments.push(styled);
715
+ remaining -= visibleWidth(tabContent);
716
+ }
717
+
718
+ if (segments.length === 0) return [];
719
+ return [segments.join("")];
720
+ }
721
+
722
+ function render(width: number): string[] {
723
+ if (cachedLines && cachedWidth === width) return cachedLines;
724
+
725
+ const lines: string[] = [];
726
+ const renderWidth = Math.max(1, width);
727
+ const tab = tabs[activeTab]!;
728
+ const options = tab.options;
729
+ const limit = ci(activeTab);
730
+ const isAnswered = tabAnswers[activeTab] !== null;
731
+ const currentIdx = tabOptionIndex[activeTab];
732
+ const isMultiSelect = tab.selectType === "multi";
733
+
734
+ function addWrapped(text: string) {
735
+ lines.push(...wrapTextWithAnsi(text, renderWidth));
736
+ }
737
+ function addWrappedWithPrefix(prefix: string, text: string) {
738
+ const prefixWidth = visibleWidth(prefix);
739
+ if (prefixWidth >= renderWidth) { addWrapped(prefix + text); return; }
740
+ const wrapped = wrapTextWithAnsi(text, renderWidth - prefixWidth);
741
+ const cp = " ".repeat(prefixWidth);
742
+ for (let i = 0; i < wrapped.length; i++) {
743
+ lines.push(`${i === 0 ? prefix : cp}${wrapped[i]}`);
744
+ }
745
+ }
746
+
747
+ // Tab bar
748
+ const tabBarLines = renderTabBar(renderWidth);
749
+ if (tabBarLines.length > 0) {
750
+ lines.push(theme.fg("accent", "─".repeat(renderWidth)));
751
+ lines.push(...tabBarLines);
752
+ }
753
+
754
+ // Question
755
+ lines.push(theme.fg("accent", "─".repeat(renderWidth)));
756
+ const tabTitle = tab.title ? theme.fg("accent", `[${tab.title}]`) : "";
757
+ const questionText = isAnswered
758
+ ? theme.fg("success", `✔ ${tab.question}`)
759
+ : theme.fg("text", tab.question);
760
+ addWrappedWithPrefix(tabTitle ? `${tabTitle} ` : " ", questionText);
761
+ lines.push("");
762
+
763
+ // Options
764
+ if (isMultiSelect && isAnswered) {
765
+ // Show selected items
766
+ const ans = tabAnswers[activeTab] as MultiTabAnswer;
767
+ for (let i = 0; i < ans.answers.length; i++) {
768
+ const idx = ans.indices[i];
769
+ const label = idx === -1
770
+ ? theme.fg("success", `✔ ${ans.answers[i]}`)
771
+ : theme.fg("success", `✔ ${idx}. ${ans.answers[i]}`);
772
+ addWrappedWithPrefix(" ", label);
773
+ }
774
+ } else if (isMultiSelect) {
775
+ for (let i = 0; i < options.length; i++) {
776
+ const opt = options[i];
777
+ const isFocused = i === currentIdx && !isOnCustomInput();
778
+ const isChecked = tabSelected[activeTab]!.has(i);
779
+ const checkMark = isChecked ? CHECKED : UNCHECKED;
780
+ const prefix = isFocused ? theme.fg("accent", "> ") : " ";
781
+ const check = isFocused
782
+ ? theme.fg("accent", checkMark)
783
+ : isChecked
784
+ ? theme.fg("success", checkMark)
785
+ : theme.fg("muted", checkMark);
786
+ const label = `${i + 1}. ${opt.label}`;
787
+ const labelColor = isFocused ? "accent" : "text";
788
+ addWrappedWithPrefix(`${prefix}${check} `, theme.fg(labelColor, label));
789
+ if (opt.description) {
790
+ addWrappedWithPrefix(" ", theme.fg("muted", opt.description));
791
+ }
792
+ }
793
+ } else {
794
+ for (let i = 0; i < options.length; i++) {
795
+ const opt = options[i];
796
+ const sel = !isOnCustomInput() && i === currentIdx;
797
+ const disabled = isAnswered;
798
+ const prefix = sel ? theme.fg("accent", "> ") : " ";
799
+ const label = `${i + 1}. ${opt.label}`;
800
+ const color = disabled ? "dim" : sel ? "accent" : "text";
801
+ addWrappedWithPrefix(prefix, theme.fg(color, label));
802
+ if (opt.description) {
803
+ addWrappedWithPrefix(" ", theme.fg("muted", opt.description));
804
+ }
805
+ }
806
+ }
807
+
808
+ // Custom input / answer display
809
+ if (isAnswered) {
810
+ if (!isMultiSelect) {
811
+ const ans = tabAnswers[activeTab] as SingleTabAnswer;
812
+ const displayLabel = ans.wasCustom
813
+ ? theme.fg("success", `✔ ${ans.answer}`)
814
+ : theme.fg("success", `✔ ${ans.index}. ${ans.answer}`);
815
+ addWrappedWithPrefix(" ", displayLabel);
816
+ }
817
+ } else {
818
+ const onCustom = isOnCustomInput();
819
+ const customPrefix = onCustom ? theme.fg("accent", "> ") : " ";
820
+ const customNumber = `${limit + 1}.`;
821
+
822
+ if (isMultiSelect) {
823
+ for (const line of renderMultiSelectInputRow(
824
+ customPrefix,
825
+ customNumber,
826
+ renderWidth,
827
+ onCustom,
828
+ tabInputs[activeTab]!,
829
+ theme,
830
+ )) {
831
+ addWrapped(line);
832
+ }
833
+ } else {
834
+ for (const line of renderSingleSelectInputRow(
835
+ customPrefix,
836
+ customNumber,
837
+ renderWidth,
838
+ onCustom,
839
+ tabInputs[activeTab]!,
840
+ theme,
841
+ )) {
842
+ addWrapped(line);
843
+ }
844
+ }
845
+ }
846
+
847
+ // Footer
848
+ lines.push("");
849
+ const helpParts: string[] = [];
850
+ if (tabs.length > 1) helpParts.push("Tab/←→ switch");
851
+ if (isMultiSelect) {
852
+ helpParts.push("↑↓");
853
+ helpParts.push("Space toggle");
854
+ const n = tabSelected[activeTab]!.size + (tabCustomValues[activeTab] ? 1 : 0);
855
+ helpParts.push(n > 0 ? `Enter submit (${n})` : "Enter submit");
856
+ } else {
857
+ helpParts.push("↑↓ select");
858
+ helpParts.push("1-9 quick pick");
859
+ if (!isAnswered) helpParts.push("Enter confirm");
860
+ }
861
+ helpParts.push("Esc cancel");
862
+ addWrappedWithPrefix(" ", theme.fg("dim", helpParts.join(" • ")));
863
+ lines.push(theme.fg("accent", "─".repeat(renderWidth)));
864
+
865
+ cachedLines = lines;
866
+ cachedWidth = width;
867
+ return lines;
868
+ }
869
+
870
+ return {
871
+ render,
872
+ invalidate: () => { cachedLines = undefined; cachedWidth = undefined; },
239
873
  handleInput,
240
874
  };
241
875
  });
242
876
  }
877
+
878
+ // ────────────────────────────────────────────
879
+ // Shared input row renderers
880
+ // ────────────────────────────────────────────
881
+
882
+ function renderSingleSelectInputRow(
883
+ prefix: string,
884
+ numberLabel: string,
885
+ availableWidth: number,
886
+ selected: boolean,
887
+ input: Input,
888
+ theme: Theme,
889
+ ): string[] {
890
+ const numberPrefix = `${numberLabel} `;
891
+ const numberWidth = visibleWidth(prefix + numberPrefix);
892
+ const inputWidth = Math.max(1, availableWidth - numberWidth);
893
+ const value = input.getValue();
894
+
895
+ if (selected && value.length > 0) {
896
+ input.focused = true;
897
+ const [line = ""] = input.render(inputWidth);
898
+ const stripped = line.startsWith("> ") ? line.slice(2) : line;
899
+ return [`${prefix}${numberPrefix}${stripped}`];
900
+ }
901
+ if (selected) {
902
+ input.focused = true;
903
+ const marker = CURSOR_MARKER;
904
+ const firstChar = CUSTOM_INPUT_PLACEHOLDER[0] ?? " ";
905
+ const rest = CUSTOM_INPUT_PLACEHOLDER.slice(1);
906
+ const cursorFirst = `\x1b[7m${firstChar}\x1b[27m`;
907
+ const restPlaceholder = theme.fg("dim", rest);
908
+ const field = `${marker}${cursorFirst}${restPlaceholder}`;
909
+ const padding = " ".repeat(Math.max(0, inputWidth - visibleWidth(CUSTOM_INPUT_PLACEHOLDER)));
910
+ return [`${prefix}${numberPrefix}${field}${padding}`];
911
+ }
912
+ input.focused = false;
913
+ const display = value.length > 0
914
+ ? theme.fg("text", value)
915
+ : theme.fg("dim", CUSTOM_INPUT_PLACEHOLDER);
916
+ const padding = value.length > 0
917
+ ? ""
918
+ : " ".repeat(Math.max(0, inputWidth - visibleWidth(CUSTOM_INPUT_PLACEHOLDER)));
919
+ return [`${prefix}${numberPrefix}${display}${padding}`];
920
+ }
921
+
922
+ function renderMultiSelectInputRow(
923
+ prefix: string,
924
+ numberLabel: string,
925
+ availableWidth: number,
926
+ selected: boolean,
927
+ input: Input,
928
+ theme: Theme,
929
+ ): string[] {
930
+ // Same visual as single-select input but renders differently in multi-select context
931
+ return renderSingleSelectInputRow(prefix, numberLabel, availableWidth, selected, input, theme);
932
+ }
933
+
934
+ interface Theme {
935
+ fg: (color: string, text: string) => string;
936
+ dim: (text: string) => string;
937
+ }