@dreb/coding-agent 2.50.0 → 2.52.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.
Files changed (44) hide show
  1. package/README.md +7 -5
  2. package/dist/cli/args.d.ts.map +1 -1
  3. package/dist/cli/args.js +11 -6
  4. package/dist/cli/args.js.map +1 -1
  5. package/dist/core/extensions/types.d.ts +31 -11
  6. package/dist/core/extensions/types.d.ts.map +1 -1
  7. package/dist/core/extensions/types.js.map +1 -1
  8. package/dist/core/tools/ask-user.d.ts +28 -20
  9. package/dist/core/tools/ask-user.d.ts.map +1 -1
  10. package/dist/core/tools/ask-user.js +130 -106
  11. package/dist/core/tools/ask-user.js.map +1 -1
  12. package/dist/core/tools/index.d.ts +9 -7
  13. package/dist/core/tools/index.d.ts.map +1 -1
  14. package/dist/core/tools/index.js.map +1 -1
  15. package/dist/modes/interactive/components/ask-wizard.d.ts +83 -0
  16. package/dist/modes/interactive/components/ask-wizard.d.ts.map +1 -0
  17. package/dist/modes/interactive/components/ask-wizard.js +464 -0
  18. package/dist/modes/interactive/components/ask-wizard.js.map +1 -0
  19. package/dist/modes/interactive/interactive-mode.d.ts +4 -2
  20. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  21. package/dist/modes/interactive/interactive-mode.js +7 -5
  22. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  23. package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
  24. package/dist/modes/rpc/rpc-mode.js +100 -20
  25. package/dist/modes/rpc/rpc-mode.js.map +1 -1
  26. package/dist/modes/rpc/rpc-types.d.ts +18 -7
  27. package/dist/modes/rpc/rpc-types.d.ts.map +1 -1
  28. package/dist/modes/rpc/rpc-types.js.map +1 -1
  29. package/docs/dashboard.md +143 -1
  30. package/docs/extensions.md +35 -18
  31. package/docs/rpc.md +26 -8
  32. package/examples/extensions/rpc-demo.ts +29 -0
  33. package/examples/rpc-extension-ui.ts +162 -6
  34. package/package.json +1 -1
  35. package/skills/mach6-implement/SKILL.md +1 -1
  36. package/skills/mach6-issue/SKILL.md +3 -3
  37. package/skills/mach6-plan/SKILL.md +3 -3
  38. package/skills/mach6-publish/SKILL.md +2 -2
  39. package/skills/mach6-push/SKILL.md +2 -2
  40. package/skills/mach6-review/SKILL.md +4 -4
  41. package/dist/modes/interactive/components/ask-user.d.ts +0 -59
  42. package/dist/modes/interactive/components/ask-user.d.ts.map +0 -1
  43. package/dist/modes/interactive/components/ask-user.js +0 -240
  44. package/dist/modes/interactive/components/ask-user.js.map +0 -1
@@ -0,0 +1,83 @@
1
+ /**
2
+ * ask_user multi-question wizard.
3
+ *
4
+ * Renders one {@link AskRequest} — which carries one or more clarifying
5
+ * questions — as a single native wizard and returns the batch of answers, one
6
+ * {@link AskAnswer} per question, in order.
7
+ *
8
+ * Layout:
9
+ * - Single question (N==1): compact inline panel with no tab strip.
10
+ * - Multiple questions (N>=2): a compact tab strip at the top (one tab per
11
+ * question plus a trailing "✔ Submit" review tab). Only the active tab is
12
+ * rendered and receives keyboard input.
13
+ *
14
+ * Keyboard model:
15
+ * ↑/↓ move a single cursor through the options and, last, the field
16
+ * 1..9 select (single) or toggle (multi) the numbered option
17
+ * Space toggle the highlighted checkbox (multi-select only)
18
+ * Enter single-select: pick the highlighted option, then advance/submit;
19
+ * free-text field: advance/submit the typed answer;
20
+ * review tab: submit the whole batch
21
+ * Shift+Enter insert a newline in a multiline free-text field
22
+ * Tab/Shift+Tab switch to the next/previous tab (N>=2)
23
+ * ←/→ switch tabs when the cursor is not on the free-text field (N>=2)
24
+ * Esc stop the current agent turn
25
+ */
26
+ import { Container, type Focusable, type TUI } from "@dreb/tui";
27
+ import type { AskRequest, AskResult } from "../../../core/extensions/types.js";
28
+ export interface AskWizardComponentOptions {
29
+ tui?: TUI;
30
+ timeout?: number;
31
+ }
32
+ export declare class AskWizardComponent extends Container implements Focusable {
33
+ private questions;
34
+ private drafts;
35
+ /** Stable per-question free-text fields, created once and reused. */
36
+ private fields;
37
+ private requestTitle;
38
+ /** 0..N-1 are question tabs; index N is the review/Submit tab (N>=2 only). */
39
+ private activeTab;
40
+ private onSubmitCallback;
41
+ private onStopCallback;
42
+ private countdown;
43
+ private countdownSeconds;
44
+ private submitted;
45
+ private _focused;
46
+ get focused(): boolean;
47
+ set focused(value: boolean);
48
+ constructor(request: AskRequest, onSubmit: (result: AskResult) => void, onStop: () => void, opts?: AskWizardComponentOptions);
49
+ private optionsFor;
50
+ private isMultiSelect;
51
+ private allowFreeText;
52
+ /** Cursor row index of the free-text field, or -1 when there is none. */
53
+ private freeTextRow;
54
+ private lastRow;
55
+ private cursorOnField;
56
+ private fieldText;
57
+ private isAnswered;
58
+ /** Full (untruncated) tab title, whitespace-normalized. */
59
+ private fullTitle;
60
+ private shortTitle;
61
+ private countdownSuffix;
62
+ /** Full-width accent rule bounding the wizard, à la Claude Code. */
63
+ private rule;
64
+ private rebuild;
65
+ private singleHeader;
66
+ private buildTabStrip;
67
+ private addQuestionPanel;
68
+ private buildHint;
69
+ private addReviewPanel;
70
+ private answerSummary;
71
+ private syncFieldFocus;
72
+ private moveCursor;
73
+ private switchTab;
74
+ /** Move to the next tab, or submit when there is nowhere left to go. */
75
+ private advanceOrSubmit;
76
+ private toggle;
77
+ private answerFor;
78
+ private submit;
79
+ private stop;
80
+ handleInput(keyData: string): void;
81
+ dispose(): void;
82
+ }
83
+ //# sourceMappingURL=ask-wizard.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ask-wizard.d.ts","sourceRoot":"","sources":["../../../../src/modes/interactive/components/ask-wizard.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,OAAO,EAEN,SAAS,EAET,KAAK,SAAS,EAOd,KAAK,GAAG,EACR,MAAM,WAAW,CAAC;AACnB,OAAO,KAAK,EAA0B,UAAU,EAAE,SAAS,EAAE,MAAM,mCAAmC,CAAC;AAMvG,MAAM,WAAW,yBAAyB;IACzC,GAAG,CAAC,EAAE,GAAG,CAAC;IACV,OAAO,CAAC,EAAE,MAAM,CAAC;CACjB;AAkCD,qBAAa,kBAAmB,SAAQ,SAAU,YAAW,SAAS;IACrE,OAAO,CAAC,SAAS,CAAgB;IACjC,OAAO,CAAC,MAAM,CAAU;IACxB,qEAAqE;IACrE,OAAO,CAAC,MAAM,CAAiC;IAC/C,OAAO,CAAC,YAAY,CAAqB;IAEzC,8EAA8E;IAC9E,OAAO,CAAC,SAAS,CAAK;IAEtB,OAAO,CAAC,gBAAgB,CAA8B;IACtD,OAAO,CAAC,cAAc,CAAa;IACnC,OAAO,CAAC,SAAS,CAA6B;IAC9C,OAAO,CAAC,gBAAgB,CAAqB;IAC7C,OAAO,CAAC,SAAS,CAAS;IAE1B,OAAO,CAAC,QAAQ,CAAS;IACzB,IAAI,OAAO,IAAI,OAAO,CAErB;IACD,IAAI,OAAO,CAAC,KAAK,EAAE,OAAO,EAGzB;IAED,YACC,OAAO,EAAE,UAAU,EACnB,QAAQ,EAAE,CAAC,MAAM,EAAE,SAAS,KAAK,IAAI,EACrC,MAAM,EAAE,MAAM,IAAI,EAClB,IAAI,CAAC,EAAE,yBAAyB,EAoChC;IAID,OAAO,CAAC,UAAU;IAIlB,OAAO,CAAC,aAAa;IAIrB,OAAO,CAAC,aAAa;IAIrB,yEAAyE;IACzE,OAAO,CAAC,WAAW;IAInB,OAAO,CAAC,OAAO;IAKf,OAAO,CAAC,aAAa;IAIrB,OAAO,CAAC,SAAS;IAOjB,OAAO,CAAC,UAAU;IAIlB,2DAA2D;IAC3D,OAAO,CAAC,SAAS;IAKjB,OAAO,CAAC,UAAU;IAKlB,OAAO,CAAC,eAAe;IAMvB,qEAAoE;IACpE,OAAO,CAAC,IAAI;IAIZ,OAAO,CAAC,OAAO;IAkBf,OAAO,CAAC,YAAY;IAKpB,OAAO,CAAC,aAAa;IAerB,OAAO,CAAC,gBAAgB;IA+CxB,OAAO,CAAC,SAAS;IAWjB,OAAO,CAAC,cAAc;IAatB,OAAO,CAAC,aAAa;IAcrB,OAAO,CAAC,cAAc;IAStB,OAAO,CAAC,UAAU;IAUlB,OAAO,CAAC,SAAS;IASjB,wEAAwE;IACxE,OAAO,CAAC,eAAe;IAUvB,OAAO,CAAC,MAAM;IASd,OAAO,CAAC,SAAS;IAcjB,OAAO,CAAC,MAAM;IAQd,OAAO,CAAC,IAAI;IASZ,WAAW,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAsHjC;IAED,OAAO,IAAI,IAAI,CAEd;CACD","sourcesContent":["/**\n * ask_user multi-question wizard.\n *\n * Renders one {@link AskRequest} — which carries one or more clarifying\n * questions — as a single native wizard and returns the batch of answers, one\n * {@link AskAnswer} per question, in order.\n *\n * Layout:\n * - Single question (N==1): compact inline panel with no tab strip.\n * - Multiple questions (N>=2): a compact tab strip at the top (one tab per\n * question plus a trailing \"✔ Submit\" review tab). Only the active tab is\n * rendered and receives keyboard input.\n *\n * Keyboard model:\n * ↑/↓ move a single cursor through the options and, last, the field\n * 1..9 select (single) or toggle (multi) the numbered option\n * Space toggle the highlighted checkbox (multi-select only)\n * Enter single-select: pick the highlighted option, then advance/submit;\n * free-text field: advance/submit the typed answer;\n * review tab: submit the whole batch\n * Shift+Enter insert a newline in a multiline free-text field\n * Tab/Shift+Tab switch to the next/previous tab (N>=2)\n * ←/→ switch tabs when the cursor is not on the free-text field (N>=2)\n * Esc stop the current agent turn\n */\n\nimport {\n\ttype Component,\n\tContainer,\n\tEditor,\n\ttype Focusable,\n\tgetKeybindings,\n\tInput,\n\tMarkdown,\n\tmatchesKey,\n\tSpacer,\n\tText,\n\ttype TUI,\n} from \"@dreb/tui\";\nimport type { AskAnswer, AskQuestion, AskRequest, AskResult } from \"../../../core/extensions/types.js\";\nimport { getEditorTheme, getMarkdownTheme, theme } from \"../theme/theme.js\";\nimport { CountdownTimer } from \"./countdown-timer.js\";\nimport { DynamicBorder } from \"./dynamic-border.js\";\nimport { keyHint, rawKeyHint } from \"./keybinding-hints.js\";\n\nexport interface AskWizardComponentOptions {\n\ttui?: TUI;\n\ttimeout?: number;\n}\n\n/**\n * Left-indents a child component (the question Markdown, or the free-text\n * Input/Editor) so it aligns with the wizard's answer block instead of jutting\n * out to the terminal margin. Focus and input still route to the wrapped field\n * directly; only rendering is shifted.\n */\nclass Indented implements Component {\n\tconstructor(\n\t\tprivate child: Component,\n\t\tprivate pad: number,\n\t) {}\n\n\trender(width: number): string[] {\n\t\tconst prefix = \" \".repeat(this.pad);\n\t\treturn this.child.render(Math.max(1, width - this.pad)).map((line) => prefix + line);\n\t}\n\n\tinvalidate(): void {\n\t\tthis.child.invalidate?.();\n\t}\n}\n\n/** Left indent (columns) aligning the question and free-text field with the\n * answer block (past the cursor gutter and the \"N.\" number). */\nconst BODY_INDENT = 3;\n\n/** Per-question draft state (selection + cursor). Free text lives in `fields`. */\ninterface Draft {\n\tselected: string[];\n\tcursorIndex: number;\n}\n\nexport class AskWizardComponent extends Container implements Focusable {\n\tprivate questions: AskQuestion[];\n\tprivate drafts: Draft[];\n\t/** Stable per-question free-text fields, created once and reused. */\n\tprivate fields: (Input | Editor | undefined)[];\n\tprivate requestTitle: string | undefined;\n\n\t/** 0..N-1 are question tabs; index N is the review/Submit tab (N>=2 only). */\n\tprivate activeTab = 0;\n\n\tprivate onSubmitCallback: (result: AskResult) => void;\n\tprivate onStopCallback: () => void;\n\tprivate countdown: CountdownTimer | undefined;\n\tprivate countdownSeconds: number | undefined;\n\tprivate submitted = false;\n\n\tprivate _focused = false;\n\tget focused(): boolean {\n\t\treturn this._focused;\n\t}\n\tset focused(value: boolean) {\n\t\tthis._focused = value;\n\t\tthis.syncFieldFocus();\n\t}\n\n\tconstructor(\n\t\trequest: AskRequest,\n\t\tonSubmit: (result: AskResult) => void,\n\t\tonStop: () => void,\n\t\topts?: AskWizardComponentOptions,\n\t) {\n\t\tsuper();\n\n\t\tthis.questions = request.questions ?? [];\n\t\tthis.requestTitle = request.title?.trim() || undefined;\n\t\tthis.onSubmitCallback = onSubmit;\n\t\tthis.onStopCallback = onStop;\n\n\t\tthis.drafts = this.questions.map(() => ({ selected: [], cursorIndex: 0 }));\n\n\t\tthis.fields = this.questions.map((q) => {\n\t\t\tconst allowFree = q.allowFreeText !== false;\n\t\t\tif (!allowFree) return undefined;\n\t\t\tif (q.multiline && opts?.tui) {\n\t\t\t\t// Never wire editor.onSubmit — Enter is intercepted here so the editor\n\t\t\t\t// keeps its text (it clears itself on submit) across tab switches.\n\t\t\t\treturn new Editor(opts.tui, getEditorTheme(), {});\n\t\t\t}\n\t\t\treturn new Input();\n\t\t});\n\n\t\tif (opts?.timeout && opts.timeout > 0 && opts.tui) {\n\t\t\tthis.countdown = new CountdownTimer(\n\t\t\t\topts.timeout,\n\t\t\t\topts.tui,\n\t\t\t\t(s) => {\n\t\t\t\t\tthis.countdownSeconds = s;\n\t\t\t\t\tthis.rebuild();\n\t\t\t\t},\n\t\t\t\t() => this.stop(),\n\t\t\t);\n\t\t}\n\n\t\tthis.rebuild();\n\t\tthis.syncFieldFocus();\n\t}\n\n\t// --- Per-question helpers -------------------------------------------------\n\n\tprivate optionsFor(i: number): string[] {\n\t\treturn this.questions[i]?.options ?? [];\n\t}\n\n\tprivate isMultiSelect(i: number): boolean {\n\t\treturn this.questions[i]?.multiSelect === true && this.optionsFor(i).length > 0;\n\t}\n\n\tprivate allowFreeText(i: number): boolean {\n\t\treturn this.questions[i]?.allowFreeText !== false;\n\t}\n\n\t/** Cursor row index of the free-text field, or -1 when there is none. */\n\tprivate freeTextRow(i: number): number {\n\t\treturn this.allowFreeText(i) ? this.optionsFor(i).length : -1;\n\t}\n\n\tprivate lastRow(i: number): number {\n\t\tconst opts = this.optionsFor(i).length;\n\t\treturn this.allowFreeText(i) ? opts : opts - 1;\n\t}\n\n\tprivate cursorOnField(i: number): boolean {\n\t\treturn this.allowFreeText(i) && this.drafts[i].cursorIndex === this.freeTextRow(i);\n\t}\n\n\tprivate fieldText(i: number): string {\n\t\tconst field = this.fields[i];\n\t\tif (field instanceof Editor) return field.getText();\n\t\tif (field instanceof Input) return field.getValue();\n\t\treturn \"\";\n\t}\n\n\tprivate isAnswered(i: number): boolean {\n\t\treturn this.drafts[i].selected.length > 0 || this.fieldText(i).trim().length > 0;\n\t}\n\n\t/** Full (untruncated) tab title, whitespace-normalized. */\n\tprivate fullTitle(i: number): string {\n\t\tconst q = this.questions[i];\n\t\treturn (q.title?.trim() || q.question || \"\").replace(/\\s+/g, \" \");\n\t}\n\n\tprivate shortTitle(i: number): string {\n\t\tconst raw = this.fullTitle(i);\n\t\treturn raw.length > 18 ? `${raw.slice(0, 17)}…` : raw;\n\t}\n\n\tprivate countdownSuffix(): string {\n\t\treturn this.countdownSeconds != null ? theme.fg(\"muted\", ` (${this.countdownSeconds}s)`) : \"\";\n\t}\n\n\t// --- Rendering ------------------------------------------------------------\n\n\t/** Full-width accent rule bounding the wizard, à la Claude Code. */\n\tprivate rule(): Component {\n\t\treturn new DynamicBorder((s) => theme.fg(\"accent\", s));\n\t}\n\n\tprivate rebuild(): void {\n\t\tthis.clear();\n\t\tconst n = this.questions.length;\n\t\tthis.addChild(this.rule());\n\t\tif (n === 1) {\n\t\t\tthis.addChild(new Text(this.singleHeader(0), 1, 0));\n\t\t\tthis.addQuestionPanel(0);\n\t\t} else {\n\t\t\tthis.addChild(new Text(this.buildTabStrip(), 1, 0));\n\t\t\tif (this.activeTab < n) {\n\t\t\t\tthis.addQuestionPanel(this.activeTab);\n\t\t\t} else {\n\t\t\t\tthis.addReviewPanel();\n\t\t\t}\n\t\t}\n\t\tthis.addChild(this.rule());\n\t}\n\n\tprivate singleHeader(i: number): string {\n\t\tconst base = this.questions[i]?.title?.trim() || this.requestTitle || \"Question\";\n\t\treturn theme.fg(\"accent\", theme.bold(base)) + this.countdownSuffix();\n\t}\n\n\tprivate buildTabStrip(): string {\n\t\tconst n = this.questions.length;\n\t\tconst parts = this.questions.map((_, i) => {\n\t\t\tconst marker = this.isAnswered(i) ? \"●\" : \"○\";\n\t\t\t// The active tab shows its full title; the rest stay truncated so the\n\t\t\t// strip stays compact.\n\t\t\tconst title = i === this.activeTab ? this.fullTitle(i) : this.shortTitle(i);\n\t\t\tconst label = `${i + 1}.${marker} ${title}`;\n\t\t\treturn i === this.activeTab ? theme.fg(\"accent\", theme.bold(label)) : theme.fg(\"muted\", label);\n\t\t});\n\t\tconst submitLabel = \"✔ Submit\";\n\t\tparts.push(this.activeTab === n ? theme.fg(\"accent\", theme.bold(submitLabel)) : theme.fg(\"muted\", submitLabel));\n\t\treturn parts.join(\" \") + this.countdownSuffix();\n\t}\n\n\tprivate addQuestionPanel(i: number): void {\n\t\tconst q = this.questions[i];\n\t\tconst draft = this.drafts[i];\n\t\tconst opts = this.optionsFor(i);\n\t\tconst multiSelect = this.isMultiSelect(i);\n\n\t\t// Blank line + indented question aligned with the answer block below, so\n\t\t// the prompt reads as a heading over its options rather than crowding them.\n\t\tthis.addChild(new Spacer(1));\n\t\tthis.addChild(new Indented(new Markdown(q.question, 0, 0, getMarkdownTheme(), undefined, true), BODY_INDENT));\n\t\tthis.addChild(new Spacer(1));\n\n\t\tfor (let j = 0; j < opts.length; j++) {\n\t\t\tconst focused = draft.cursorIndex === j;\n\t\t\tconst chosen = draft.selected.includes(opts[j]);\n\t\t\tconst cursor = focused ? theme.fg(\"accent\", \"→ \") : \" \";\n\t\t\tconst num = `${j + 1}.`;\n\t\t\t// Multi-select keeps checkboxes; single-select drops the radio glyph and\n\t\t\t// relies on the green check + accent label to mark the chosen answer.\n\t\t\tlet marker: string;\n\t\t\tif (multiSelect) {\n\t\t\t\tconst box = chosen ? \"[x]\" : \"[ ]\";\n\t\t\t\tmarker = chosen ? theme.fg(\"accent\", box) : box;\n\t\t\t} else {\n\t\t\t\tmarker = chosen ? theme.fg(\"accent\", \"✔\") : \" \";\n\t\t\t}\n\t\t\tconst label = focused || chosen ? theme.fg(\"accent\", opts[j]) : theme.fg(\"text\", opts[j]);\n\t\t\tthis.addChild(new Text(`${cursor}${num} ${marker} ${label}`, 1, 0));\n\t\t}\n\n\t\tif (this.allowFreeText(i)) {\n\t\t\tconst onField = this.cursorOnField(i);\n\t\t\tconst prefix = onField ? theme.fg(\"accent\", \"→ \") : \" \";\n\t\t\tconst labelText = opts.length > 0 ? \"Or type your own answer:\" : \"Your answer:\";\n\t\t\tthis.addChild(\n\t\t\t\tnew Text(prefix + (onField ? theme.fg(\"accent\", labelText) : theme.fg(\"muted\", labelText)), 1, 0),\n\t\t\t);\n\t\t\tconst field = this.fields[i];\n\t\t\t// Indent the field so it aligns under the label instead of sitting at the\n\t\t\t// terminal margin like the main prompt — keeps focus visually inside the\n\t\t\t// wizard, consistent with the option rows.\n\t\t\tif (field) this.addChild(new Indented(field, BODY_INDENT));\n\t\t}\n\n\t\tthis.addChild(new Text(this.buildHint(i), 1, 0));\n\t}\n\n\tprivate buildHint(i: number): string {\n\t\tconst n = this.questions.length;\n\t\tconst parts = [rawKeyHint(\"↑↓\", \"move\")];\n\t\tif (this.isMultiSelect(i)) parts.push(rawKeyHint(\"Space\", \"toggle\"));\n\t\tif (n >= 2) parts.push(rawKeyHint(\"⇥\", \"switch\"));\n\t\tparts.push(keyHint(\"tui.select.confirm\", n === 1 ? \"submit\" : \"next\"));\n\t\tif (this.questions[i]?.multiline) parts.push(keyHint(\"tui.input.newLine\", \"newline\"));\n\t\tparts.push(keyHint(\"tui.select.cancel\", \"stop\"));\n\t\treturn parts.join(\" \");\n\t}\n\n\tprivate addReviewPanel(): void {\n\t\tthis.addChild(new Text(theme.fg(\"accent\", theme.bold(\"Review your answers\")) + this.countdownSuffix(), 1, 0));\n\t\tfor (let i = 0; i < this.questions.length; i++) {\n\t\t\tthis.addChild(new Text(theme.fg(\"muted\", `• ${this.shortTitle(i)} → ${this.answerSummary(i)}`), 1, 0));\n\t\t}\n\t\tconst hint = [\n\t\t\tkeyHint(\"tui.select.confirm\", \"submit\"),\n\t\t\trawKeyHint(\"⇥\", \"switch\"),\n\t\t\tkeyHint(\"tui.select.cancel\", \"stop\"),\n\t\t].join(\" \");\n\t\tthis.addChild(new Text(hint, 1, 0));\n\t}\n\n\tprivate answerSummary(i: number): string {\n\t\tconst draft = this.drafts[i];\n\t\tconst opts = this.optionsFor(i);\n\t\tconst selected = this.isMultiSelect(i)\n\t\t\t? opts.filter((o) => draft.selected.includes(o))\n\t\t\t: draft.selected.slice(0, 1);\n\t\tconst custom = this.fieldText(i).trim();\n\t\tconst parts = [...selected];\n\t\tif (custom) parts.push(custom);\n\t\treturn parts.length > 0 ? parts.join(\", \") : \"(unanswered)\";\n\t}\n\n\t// --- Focus ----------------------------------------------------------------\n\n\tprivate syncFieldFocus(): void {\n\t\tthis.fields.forEach((field, i) => {\n\t\t\tif (!field) return;\n\t\t\tfield.focused = this._focused && this.activeTab === i && this.cursorOnField(i);\n\t\t});\n\t}\n\n\t// --- Navigation -----------------------------------------------------------\n\n\tprivate moveCursor(delta: number): void {\n\t\tconst i = this.activeTab;\n\t\tconst first = this.optionsFor(i).length > 0 ? 0 : Math.max(0, this.freeTextRow(i));\n\t\tconst next = Math.max(first, Math.min(this.lastRow(i), this.drafts[i].cursorIndex + delta));\n\t\tif (next === this.drafts[i].cursorIndex) return;\n\t\tthis.drafts[i].cursorIndex = next;\n\t\tthis.rebuild();\n\t\tthis.syncFieldFocus();\n\t}\n\n\tprivate switchTab(delta: number): void {\n\t\tconst n = this.questions.length;\n\t\tif (n < 2) return;\n\t\tconst total = n + 1; // question tabs + review tab\n\t\tthis.activeTab = (this.activeTab + delta + total) % total;\n\t\tthis.rebuild();\n\t\tthis.syncFieldFocus();\n\t}\n\n\t/** Move to the next tab, or submit when there is nowhere left to go. */\n\tprivate advanceOrSubmit(i: number): void {\n\t\tif (this.questions.length === 1) {\n\t\t\tthis.submit();\n\t\t\treturn;\n\t\t}\n\t\tthis.activeTab = i + 1; // next question, or the review tab (index N)\n\t\tthis.rebuild();\n\t\tthis.syncFieldFocus();\n\t}\n\n\tprivate toggle(i: number, option: string): void {\n\t\tconst draft = this.drafts[i];\n\t\tconst at = draft.selected.indexOf(option);\n\t\tif (at === -1) draft.selected.push(option);\n\t\telse draft.selected.splice(at, 1);\n\t}\n\n\t// --- Submit / stop --------------------------------------------------------\n\n\tprivate answerFor(i: number): AskAnswer {\n\t\tconst draft = this.drafts[i];\n\t\tconst opts = this.optionsFor(i);\n\t\tconst customText = this.fieldText(i).trim() || undefined;\n\t\tif (this.isMultiSelect(i)) {\n\t\t\tconst selected = opts.filter((o) => draft.selected.includes(o));\n\t\t\tif (selected.length === 0 && !customText) return { selected: [], skipped: true };\n\t\t\treturn { selected, customText };\n\t\t}\n\t\tconst selected = draft.selected.slice(0, 1);\n\t\tif (selected.length === 0 && !customText) return { selected: [], skipped: true };\n\t\treturn { selected, customText };\n\t}\n\n\tprivate submit(): void {\n\t\tif (this.submitted) return;\n\t\tthis.submitted = true;\n\t\tthis.countdown?.dispose();\n\t\tconst answers = this.questions.map((_, i) => this.answerFor(i));\n\t\tthis.onSubmitCallback({ answers });\n\t}\n\n\tprivate stop(): void {\n\t\tif (this.submitted) return;\n\t\tthis.submitted = true;\n\t\tthis.countdown?.dispose();\n\t\tthis.onStopCallback();\n\t}\n\n\t// --- Input ----------------------------------------------------------------\n\n\thandleInput(keyData: string): void {\n\t\tconst kb = getKeybindings();\n\t\tconst n = this.questions.length;\n\n\t\tif (kb.matches(keyData, \"tui.select.cancel\")) {\n\t\t\tthis.stop();\n\t\t\treturn;\n\t\t}\n\n\t\t// Tab / Shift+Tab switch tabs before any field sees the key so switching\n\t\t// works regardless of which sub-field currently holds focus.\n\t\tif (n >= 2) {\n\t\t\tif (matchesKey(keyData, \"shift+tab\") || keyData === \"\\x1b[Z\") {\n\t\t\t\tthis.switchTab(-1);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (kb.matches(keyData, \"tui.input.tab\") || keyData === \"\\t\") {\n\t\t\t\tthis.switchTab(1);\n\t\t\t\treturn;\n\t\t\t}\n\t\t}\n\n\t\t// Review tab: Enter submits; arrows switch tabs.\n\t\tif (n >= 2 && this.activeTab === n) {\n\t\t\tif (kb.matches(keyData, \"tui.select.confirm\") || keyData === \"\\n\" || keyData === \"\\r\") {\n\t\t\t\tthis.submit();\n\t\t\t} else if (keyData === \"\\x1b[D\") {\n\t\t\t\tthis.switchTab(-1);\n\t\t\t} else if (keyData === \"\\x1b[C\") {\n\t\t\t\tthis.switchTab(1);\n\t\t\t}\n\t\t\treturn;\n\t\t}\n\n\t\tconst i = this.activeTab;\n\n\t\t// Free-text field has focus: delegate editing, intercept Enter/leave keys.\n\t\tif (this.cursorOnField(i)) {\n\t\t\tconst field = this.fields[i];\n\t\t\tif (field instanceof Editor) {\n\t\t\t\tif (kb.matches(keyData, \"tui.select.up\") && field.getCursor().line === 0) {\n\t\t\t\t\tthis.moveCursor(-1);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\t// Shift+Enter (and bare LF newline) insert a line; real Enter advances.\n\t\t\t\tif (kb.matches(keyData, \"tui.input.newLine\") || keyData === \"\\n\") {\n\t\t\t\t\tfield.handleInput(keyData);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tif (kb.matches(keyData, \"tui.select.confirm\")) {\n\t\t\t\t\tthis.advanceOrSubmit(i);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tfield.handleInput(keyData);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (field instanceof Input) {\n\t\t\t\tif (kb.matches(keyData, \"tui.select.up\")) {\n\t\t\t\t\tthis.moveCursor(-1);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tif (kb.matches(keyData, \"tui.select.confirm\") || keyData === \"\\n\" || keyData === \"\\r\") {\n\t\t\t\t\tthis.advanceOrSubmit(i);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tfield.handleInput(keyData);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\treturn;\n\t\t}\n\n\t\t// Cursor on an option row (or an option-less field-only question handled above).\n\t\tconst opts = this.optionsFor(i);\n\n\t\t// Numeric shortcuts select/toggle an option by its 1-based index.\n\t\tif (keyData.length === 1 && keyData >= \"1\" && keyData <= \"9\") {\n\t\t\tconst index = keyData.charCodeAt(0) - 49; // '1' -> 0\n\t\t\tif (index < opts.length) {\n\t\t\t\tif (this.isMultiSelect(i)) {\n\t\t\t\t\tthis.toggle(i, opts[index]);\n\t\t\t\t} else {\n\t\t\t\t\tthis.drafts[i].selected = [opts[index]];\n\t\t\t\t\tthis.drafts[i].cursorIndex = index;\n\t\t\t\t}\n\t\t\t\tthis.rebuild();\n\t\t\t\tthis.syncFieldFocus();\n\t\t\t}\n\t\t\treturn;\n\t\t}\n\n\t\tif (kb.matches(keyData, \"tui.select.up\")) {\n\t\t\tthis.moveCursor(-1);\n\t\t\treturn;\n\t\t}\n\t\tif (kb.matches(keyData, \"tui.select.down\")) {\n\t\t\tthis.moveCursor(1);\n\t\t\treturn;\n\t\t}\n\t\tif (n >= 2 && keyData === \"\\x1b[D\") {\n\t\t\tthis.switchTab(-1);\n\t\t\treturn;\n\t\t}\n\t\tif (n >= 2 && keyData === \"\\x1b[C\") {\n\t\t\tthis.switchTab(1);\n\t\t\treturn;\n\t\t}\n\t\tif (this.isMultiSelect(i) && keyData === \" \") {\n\t\t\tthis.toggle(i, opts[this.drafts[i].cursorIndex]);\n\t\t\tthis.rebuild();\n\t\t\treturn;\n\t\t}\n\t\tif (kb.matches(keyData, \"tui.select.confirm\") || keyData === \"\\n\" || keyData === \"\\r\") {\n\t\t\tconst option = opts[this.drafts[i].cursorIndex];\n\t\t\tif (option !== undefined && !this.isMultiSelect(i)) {\n\t\t\t\tthis.drafts[i].selected = [option];\n\t\t\t}\n\t\t\tthis.advanceOrSubmit(i);\n\t\t}\n\t}\n\n\tdispose(): void {\n\t\tthis.countdown?.dispose();\n\t}\n}\n"]}
@@ -0,0 +1,464 @@
1
+ /**
2
+ * ask_user multi-question wizard.
3
+ *
4
+ * Renders one {@link AskRequest} — which carries one or more clarifying
5
+ * questions — as a single native wizard and returns the batch of answers, one
6
+ * {@link AskAnswer} per question, in order.
7
+ *
8
+ * Layout:
9
+ * - Single question (N==1): compact inline panel with no tab strip.
10
+ * - Multiple questions (N>=2): a compact tab strip at the top (one tab per
11
+ * question plus a trailing "✔ Submit" review tab). Only the active tab is
12
+ * rendered and receives keyboard input.
13
+ *
14
+ * Keyboard model:
15
+ * ↑/↓ move a single cursor through the options and, last, the field
16
+ * 1..9 select (single) or toggle (multi) the numbered option
17
+ * Space toggle the highlighted checkbox (multi-select only)
18
+ * Enter single-select: pick the highlighted option, then advance/submit;
19
+ * free-text field: advance/submit the typed answer;
20
+ * review tab: submit the whole batch
21
+ * Shift+Enter insert a newline in a multiline free-text field
22
+ * Tab/Shift+Tab switch to the next/previous tab (N>=2)
23
+ * ←/→ switch tabs when the cursor is not on the free-text field (N>=2)
24
+ * Esc stop the current agent turn
25
+ */
26
+ import { Container, Editor, getKeybindings, Input, Markdown, matchesKey, Spacer, Text, } from "@dreb/tui";
27
+ import { getEditorTheme, getMarkdownTheme, theme } from "../theme/theme.js";
28
+ import { CountdownTimer } from "./countdown-timer.js";
29
+ import { DynamicBorder } from "./dynamic-border.js";
30
+ import { keyHint, rawKeyHint } from "./keybinding-hints.js";
31
+ /**
32
+ * Left-indents a child component (the question Markdown, or the free-text
33
+ * Input/Editor) so it aligns with the wizard's answer block instead of jutting
34
+ * out to the terminal margin. Focus and input still route to the wrapped field
35
+ * directly; only rendering is shifted.
36
+ */
37
+ class Indented {
38
+ child;
39
+ pad;
40
+ constructor(child, pad) {
41
+ this.child = child;
42
+ this.pad = pad;
43
+ }
44
+ render(width) {
45
+ const prefix = " ".repeat(this.pad);
46
+ return this.child.render(Math.max(1, width - this.pad)).map((line) => prefix + line);
47
+ }
48
+ invalidate() {
49
+ this.child.invalidate?.();
50
+ }
51
+ }
52
+ /** Left indent (columns) aligning the question and free-text field with the
53
+ * answer block (past the cursor gutter and the "N." number). */
54
+ const BODY_INDENT = 3;
55
+ export class AskWizardComponent extends Container {
56
+ questions;
57
+ drafts;
58
+ /** Stable per-question free-text fields, created once and reused. */
59
+ fields;
60
+ requestTitle;
61
+ /** 0..N-1 are question tabs; index N is the review/Submit tab (N>=2 only). */
62
+ activeTab = 0;
63
+ onSubmitCallback;
64
+ onStopCallback;
65
+ countdown;
66
+ countdownSeconds;
67
+ submitted = false;
68
+ _focused = false;
69
+ get focused() {
70
+ return this._focused;
71
+ }
72
+ set focused(value) {
73
+ this._focused = value;
74
+ this.syncFieldFocus();
75
+ }
76
+ constructor(request, onSubmit, onStop, opts) {
77
+ super();
78
+ this.questions = request.questions ?? [];
79
+ this.requestTitle = request.title?.trim() || undefined;
80
+ this.onSubmitCallback = onSubmit;
81
+ this.onStopCallback = onStop;
82
+ this.drafts = this.questions.map(() => ({ selected: [], cursorIndex: 0 }));
83
+ this.fields = this.questions.map((q) => {
84
+ const allowFree = q.allowFreeText !== false;
85
+ if (!allowFree)
86
+ return undefined;
87
+ if (q.multiline && opts?.tui) {
88
+ // Never wire editor.onSubmit — Enter is intercepted here so the editor
89
+ // keeps its text (it clears itself on submit) across tab switches.
90
+ return new Editor(opts.tui, getEditorTheme(), {});
91
+ }
92
+ return new Input();
93
+ });
94
+ if (opts?.timeout && opts.timeout > 0 && opts.tui) {
95
+ this.countdown = new CountdownTimer(opts.timeout, opts.tui, (s) => {
96
+ this.countdownSeconds = s;
97
+ this.rebuild();
98
+ }, () => this.stop());
99
+ }
100
+ this.rebuild();
101
+ this.syncFieldFocus();
102
+ }
103
+ // --- Per-question helpers -------------------------------------------------
104
+ optionsFor(i) {
105
+ return this.questions[i]?.options ?? [];
106
+ }
107
+ isMultiSelect(i) {
108
+ return this.questions[i]?.multiSelect === true && this.optionsFor(i).length > 0;
109
+ }
110
+ allowFreeText(i) {
111
+ return this.questions[i]?.allowFreeText !== false;
112
+ }
113
+ /** Cursor row index of the free-text field, or -1 when there is none. */
114
+ freeTextRow(i) {
115
+ return this.allowFreeText(i) ? this.optionsFor(i).length : -1;
116
+ }
117
+ lastRow(i) {
118
+ const opts = this.optionsFor(i).length;
119
+ return this.allowFreeText(i) ? opts : opts - 1;
120
+ }
121
+ cursorOnField(i) {
122
+ return this.allowFreeText(i) && this.drafts[i].cursorIndex === this.freeTextRow(i);
123
+ }
124
+ fieldText(i) {
125
+ const field = this.fields[i];
126
+ if (field instanceof Editor)
127
+ return field.getText();
128
+ if (field instanceof Input)
129
+ return field.getValue();
130
+ return "";
131
+ }
132
+ isAnswered(i) {
133
+ return this.drafts[i].selected.length > 0 || this.fieldText(i).trim().length > 0;
134
+ }
135
+ /** Full (untruncated) tab title, whitespace-normalized. */
136
+ fullTitle(i) {
137
+ const q = this.questions[i];
138
+ return (q.title?.trim() || q.question || "").replace(/\s+/g, " ");
139
+ }
140
+ shortTitle(i) {
141
+ const raw = this.fullTitle(i);
142
+ return raw.length > 18 ? `${raw.slice(0, 17)}…` : raw;
143
+ }
144
+ countdownSuffix() {
145
+ return this.countdownSeconds != null ? theme.fg("muted", ` (${this.countdownSeconds}s)`) : "";
146
+ }
147
+ // --- Rendering ------------------------------------------------------------
148
+ /** Full-width accent rule bounding the wizard, à la Claude Code. */
149
+ rule() {
150
+ return new DynamicBorder((s) => theme.fg("accent", s));
151
+ }
152
+ rebuild() {
153
+ this.clear();
154
+ const n = this.questions.length;
155
+ this.addChild(this.rule());
156
+ if (n === 1) {
157
+ this.addChild(new Text(this.singleHeader(0), 1, 0));
158
+ this.addQuestionPanel(0);
159
+ }
160
+ else {
161
+ this.addChild(new Text(this.buildTabStrip(), 1, 0));
162
+ if (this.activeTab < n) {
163
+ this.addQuestionPanel(this.activeTab);
164
+ }
165
+ else {
166
+ this.addReviewPanel();
167
+ }
168
+ }
169
+ this.addChild(this.rule());
170
+ }
171
+ singleHeader(i) {
172
+ const base = this.questions[i]?.title?.trim() || this.requestTitle || "Question";
173
+ return theme.fg("accent", theme.bold(base)) + this.countdownSuffix();
174
+ }
175
+ buildTabStrip() {
176
+ const n = this.questions.length;
177
+ const parts = this.questions.map((_, i) => {
178
+ const marker = this.isAnswered(i) ? "●" : "○";
179
+ // The active tab shows its full title; the rest stay truncated so the
180
+ // strip stays compact.
181
+ const title = i === this.activeTab ? this.fullTitle(i) : this.shortTitle(i);
182
+ const label = `${i + 1}.${marker} ${title}`;
183
+ return i === this.activeTab ? theme.fg("accent", theme.bold(label)) : theme.fg("muted", label);
184
+ });
185
+ const submitLabel = "✔ Submit";
186
+ parts.push(this.activeTab === n ? theme.fg("accent", theme.bold(submitLabel)) : theme.fg("muted", submitLabel));
187
+ return parts.join(" ") + this.countdownSuffix();
188
+ }
189
+ addQuestionPanel(i) {
190
+ const q = this.questions[i];
191
+ const draft = this.drafts[i];
192
+ const opts = this.optionsFor(i);
193
+ const multiSelect = this.isMultiSelect(i);
194
+ // Blank line + indented question aligned with the answer block below, so
195
+ // the prompt reads as a heading over its options rather than crowding them.
196
+ this.addChild(new Spacer(1));
197
+ this.addChild(new Indented(new Markdown(q.question, 0, 0, getMarkdownTheme(), undefined, true), BODY_INDENT));
198
+ this.addChild(new Spacer(1));
199
+ for (let j = 0; j < opts.length; j++) {
200
+ const focused = draft.cursorIndex === j;
201
+ const chosen = draft.selected.includes(opts[j]);
202
+ const cursor = focused ? theme.fg("accent", "→ ") : " ";
203
+ const num = `${j + 1}.`;
204
+ // Multi-select keeps checkboxes; single-select drops the radio glyph and
205
+ // relies on the green check + accent label to mark the chosen answer.
206
+ let marker;
207
+ if (multiSelect) {
208
+ const box = chosen ? "[x]" : "[ ]";
209
+ marker = chosen ? theme.fg("accent", box) : box;
210
+ }
211
+ else {
212
+ marker = chosen ? theme.fg("accent", "✔") : " ";
213
+ }
214
+ const label = focused || chosen ? theme.fg("accent", opts[j]) : theme.fg("text", opts[j]);
215
+ this.addChild(new Text(`${cursor}${num} ${marker} ${label}`, 1, 0));
216
+ }
217
+ if (this.allowFreeText(i)) {
218
+ const onField = this.cursorOnField(i);
219
+ const prefix = onField ? theme.fg("accent", "→ ") : " ";
220
+ const labelText = opts.length > 0 ? "Or type your own answer:" : "Your answer:";
221
+ this.addChild(new Text(prefix + (onField ? theme.fg("accent", labelText) : theme.fg("muted", labelText)), 1, 0));
222
+ const field = this.fields[i];
223
+ // Indent the field so it aligns under the label instead of sitting at the
224
+ // terminal margin like the main prompt — keeps focus visually inside the
225
+ // wizard, consistent with the option rows.
226
+ if (field)
227
+ this.addChild(new Indented(field, BODY_INDENT));
228
+ }
229
+ this.addChild(new Text(this.buildHint(i), 1, 0));
230
+ }
231
+ buildHint(i) {
232
+ const n = this.questions.length;
233
+ const parts = [rawKeyHint("↑↓", "move")];
234
+ if (this.isMultiSelect(i))
235
+ parts.push(rawKeyHint("Space", "toggle"));
236
+ if (n >= 2)
237
+ parts.push(rawKeyHint("⇥", "switch"));
238
+ parts.push(keyHint("tui.select.confirm", n === 1 ? "submit" : "next"));
239
+ if (this.questions[i]?.multiline)
240
+ parts.push(keyHint("tui.input.newLine", "newline"));
241
+ parts.push(keyHint("tui.select.cancel", "stop"));
242
+ return parts.join(" ");
243
+ }
244
+ addReviewPanel() {
245
+ this.addChild(new Text(theme.fg("accent", theme.bold("Review your answers")) + this.countdownSuffix(), 1, 0));
246
+ for (let i = 0; i < this.questions.length; i++) {
247
+ this.addChild(new Text(theme.fg("muted", `• ${this.shortTitle(i)} → ${this.answerSummary(i)}`), 1, 0));
248
+ }
249
+ const hint = [
250
+ keyHint("tui.select.confirm", "submit"),
251
+ rawKeyHint("⇥", "switch"),
252
+ keyHint("tui.select.cancel", "stop"),
253
+ ].join(" ");
254
+ this.addChild(new Text(hint, 1, 0));
255
+ }
256
+ answerSummary(i) {
257
+ const draft = this.drafts[i];
258
+ const opts = this.optionsFor(i);
259
+ const selected = this.isMultiSelect(i)
260
+ ? opts.filter((o) => draft.selected.includes(o))
261
+ : draft.selected.slice(0, 1);
262
+ const custom = this.fieldText(i).trim();
263
+ const parts = [...selected];
264
+ if (custom)
265
+ parts.push(custom);
266
+ return parts.length > 0 ? parts.join(", ") : "(unanswered)";
267
+ }
268
+ // --- Focus ----------------------------------------------------------------
269
+ syncFieldFocus() {
270
+ this.fields.forEach((field, i) => {
271
+ if (!field)
272
+ return;
273
+ field.focused = this._focused && this.activeTab === i && this.cursorOnField(i);
274
+ });
275
+ }
276
+ // --- Navigation -----------------------------------------------------------
277
+ moveCursor(delta) {
278
+ const i = this.activeTab;
279
+ const first = this.optionsFor(i).length > 0 ? 0 : Math.max(0, this.freeTextRow(i));
280
+ const next = Math.max(first, Math.min(this.lastRow(i), this.drafts[i].cursorIndex + delta));
281
+ if (next === this.drafts[i].cursorIndex)
282
+ return;
283
+ this.drafts[i].cursorIndex = next;
284
+ this.rebuild();
285
+ this.syncFieldFocus();
286
+ }
287
+ switchTab(delta) {
288
+ const n = this.questions.length;
289
+ if (n < 2)
290
+ return;
291
+ const total = n + 1; // question tabs + review tab
292
+ this.activeTab = (this.activeTab + delta + total) % total;
293
+ this.rebuild();
294
+ this.syncFieldFocus();
295
+ }
296
+ /** Move to the next tab, or submit when there is nowhere left to go. */
297
+ advanceOrSubmit(i) {
298
+ if (this.questions.length === 1) {
299
+ this.submit();
300
+ return;
301
+ }
302
+ this.activeTab = i + 1; // next question, or the review tab (index N)
303
+ this.rebuild();
304
+ this.syncFieldFocus();
305
+ }
306
+ toggle(i, option) {
307
+ const draft = this.drafts[i];
308
+ const at = draft.selected.indexOf(option);
309
+ if (at === -1)
310
+ draft.selected.push(option);
311
+ else
312
+ draft.selected.splice(at, 1);
313
+ }
314
+ // --- Submit / stop --------------------------------------------------------
315
+ answerFor(i) {
316
+ const draft = this.drafts[i];
317
+ const opts = this.optionsFor(i);
318
+ const customText = this.fieldText(i).trim() || undefined;
319
+ if (this.isMultiSelect(i)) {
320
+ const selected = opts.filter((o) => draft.selected.includes(o));
321
+ if (selected.length === 0 && !customText)
322
+ return { selected: [], skipped: true };
323
+ return { selected, customText };
324
+ }
325
+ const selected = draft.selected.slice(0, 1);
326
+ if (selected.length === 0 && !customText)
327
+ return { selected: [], skipped: true };
328
+ return { selected, customText };
329
+ }
330
+ submit() {
331
+ if (this.submitted)
332
+ return;
333
+ this.submitted = true;
334
+ this.countdown?.dispose();
335
+ const answers = this.questions.map((_, i) => this.answerFor(i));
336
+ this.onSubmitCallback({ answers });
337
+ }
338
+ stop() {
339
+ if (this.submitted)
340
+ return;
341
+ this.submitted = true;
342
+ this.countdown?.dispose();
343
+ this.onStopCallback();
344
+ }
345
+ // --- Input ----------------------------------------------------------------
346
+ handleInput(keyData) {
347
+ const kb = getKeybindings();
348
+ const n = this.questions.length;
349
+ if (kb.matches(keyData, "tui.select.cancel")) {
350
+ this.stop();
351
+ return;
352
+ }
353
+ // Tab / Shift+Tab switch tabs before any field sees the key so switching
354
+ // works regardless of which sub-field currently holds focus.
355
+ if (n >= 2) {
356
+ if (matchesKey(keyData, "shift+tab") || keyData === "\x1b[Z") {
357
+ this.switchTab(-1);
358
+ return;
359
+ }
360
+ if (kb.matches(keyData, "tui.input.tab") || keyData === "\t") {
361
+ this.switchTab(1);
362
+ return;
363
+ }
364
+ }
365
+ // Review tab: Enter submits; arrows switch tabs.
366
+ if (n >= 2 && this.activeTab === n) {
367
+ if (kb.matches(keyData, "tui.select.confirm") || keyData === "\n" || keyData === "\r") {
368
+ this.submit();
369
+ }
370
+ else if (keyData === "\x1b[D") {
371
+ this.switchTab(-1);
372
+ }
373
+ else if (keyData === "\x1b[C") {
374
+ this.switchTab(1);
375
+ }
376
+ return;
377
+ }
378
+ const i = this.activeTab;
379
+ // Free-text field has focus: delegate editing, intercept Enter/leave keys.
380
+ if (this.cursorOnField(i)) {
381
+ const field = this.fields[i];
382
+ if (field instanceof Editor) {
383
+ if (kb.matches(keyData, "tui.select.up") && field.getCursor().line === 0) {
384
+ this.moveCursor(-1);
385
+ return;
386
+ }
387
+ // Shift+Enter (and bare LF newline) insert a line; real Enter advances.
388
+ if (kb.matches(keyData, "tui.input.newLine") || keyData === "\n") {
389
+ field.handleInput(keyData);
390
+ return;
391
+ }
392
+ if (kb.matches(keyData, "tui.select.confirm")) {
393
+ this.advanceOrSubmit(i);
394
+ return;
395
+ }
396
+ field.handleInput(keyData);
397
+ return;
398
+ }
399
+ if (field instanceof Input) {
400
+ if (kb.matches(keyData, "tui.select.up")) {
401
+ this.moveCursor(-1);
402
+ return;
403
+ }
404
+ if (kb.matches(keyData, "tui.select.confirm") || keyData === "\n" || keyData === "\r") {
405
+ this.advanceOrSubmit(i);
406
+ return;
407
+ }
408
+ field.handleInput(keyData);
409
+ return;
410
+ }
411
+ return;
412
+ }
413
+ // Cursor on an option row (or an option-less field-only question handled above).
414
+ const opts = this.optionsFor(i);
415
+ // Numeric shortcuts select/toggle an option by its 1-based index.
416
+ if (keyData.length === 1 && keyData >= "1" && keyData <= "9") {
417
+ const index = keyData.charCodeAt(0) - 49; // '1' -> 0
418
+ if (index < opts.length) {
419
+ if (this.isMultiSelect(i)) {
420
+ this.toggle(i, opts[index]);
421
+ }
422
+ else {
423
+ this.drafts[i].selected = [opts[index]];
424
+ this.drafts[i].cursorIndex = index;
425
+ }
426
+ this.rebuild();
427
+ this.syncFieldFocus();
428
+ }
429
+ return;
430
+ }
431
+ if (kb.matches(keyData, "tui.select.up")) {
432
+ this.moveCursor(-1);
433
+ return;
434
+ }
435
+ if (kb.matches(keyData, "tui.select.down")) {
436
+ this.moveCursor(1);
437
+ return;
438
+ }
439
+ if (n >= 2 && keyData === "\x1b[D") {
440
+ this.switchTab(-1);
441
+ return;
442
+ }
443
+ if (n >= 2 && keyData === "\x1b[C") {
444
+ this.switchTab(1);
445
+ return;
446
+ }
447
+ if (this.isMultiSelect(i) && keyData === " ") {
448
+ this.toggle(i, opts[this.drafts[i].cursorIndex]);
449
+ this.rebuild();
450
+ return;
451
+ }
452
+ if (kb.matches(keyData, "tui.select.confirm") || keyData === "\n" || keyData === "\r") {
453
+ const option = opts[this.drafts[i].cursorIndex];
454
+ if (option !== undefined && !this.isMultiSelect(i)) {
455
+ this.drafts[i].selected = [option];
456
+ }
457
+ this.advanceOrSubmit(i);
458
+ }
459
+ }
460
+ dispose() {
461
+ this.countdown?.dispose();
462
+ }
463
+ }
464
+ //# sourceMappingURL=ask-wizard.js.map