{"version":3,"file":"ask-options.d.ts","sourceRoot":"","sources":["../../../../src/modes/interactive/components/ask-options.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,EACN,KAAK,SAAS,EAEd,KAAK,SAAS,EAKd,MAAM,0BAA0B,CAAC;AAClC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mCAAmC,CAAC;AAIrE,MAAM,WAAW,iBAAiB;IACjC;;;OAGG;IACH,MAAM,CAAC,EAAE,OAAO,CAAC;CACjB;AAED,qBAAa,mBAAoB,YAAW,SAAS,EAAE,SAAS;IAC/D,OAAO,CAAC,SAAS,CAAgB;IACjC,OAAO,CAAC,IAAI,CAAK;IACjB,OAAO,CAAC,KAAK,CAAK;IAClB,OAAO,CAAC,OAAO,CAAgB;IAC/B,OAAO,CAAC,WAAW,CAAe;IAClC,OAAO,CAAC,gBAAgB,CAA8B;IACtD,OAAO,CAAC,gBAAgB,CAAa;IAErC,OAAO,CAAC,QAAQ,CAAS;IACzB,IAAI,OAAO,IAAI,OAAO,CAErB;IACD,IAAI,OAAO,CAAC,KAAK,EAAE,OAAO,EAGzB;IAED,wEAAwE;IACxE,OAAO,CAAC,KAAK,CAAyB;IAEtC,YACC,SAAS,EAAE,WAAW,EAAE,EACxB,QAAQ,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,IAAI,EACrC,QAAQ,EAAE,MAAM,IAAI,EACpB,OAAO,GAAE,iBAAsB,EAW/B;IAED,UAAU,IAAI,IAAI,CAEjB;IAED,OAAO,CAAC,QAAQ;IAIhB,OAAO,CAAC,aAAa;IAIrB;;;;;;;;;;OAUG;IACH,OAAO,CAAC,YAAY;IAMpB,OAAO,CAAC,MAAM;IAKd,OAAO,CAAC,WAAW;IAkBnB,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CAG9B;IAED;;;OAGG;IACH,OAAO,CAAC,UAAU;IAkElB,OAAO,CAAC,OAAO;IAmBf,OAAO,CAAC,IAAI;IAOZ,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CA2C9B;CACD","sourcesContent":["/**\n * Options pane — the agent asking the USER for a decision before it acts.\n *\n * Standing on its own it takes the prompt editor's place, so it wears the\n * prompt's frame (`InputFrame`), named \"input needed\" in the top border, with\n * the step's key hints on the first row inside. Embedded in the `--team` attach\n * panel it draws no frame of its own: that panel is already a framed surface,\n * and a second frame inside it is two boxes one column apart.\n *\n * One decision per step:\n *   - tui.select.up / down    move between options (wraps)\n *   - app.options.next (→)    confirm the highlighted option and advance; on the\n *                             last step it submits every answer\n *   - app.options.back (←)    step back to the previous decision\n *   - 1-9                     quick-pick an option\n *   - drop onto the custom row and type a free-form answer when none fit; there\n *     the arrows move the text cursor first and only act on the step at its\n *     ends, so enter is the key that always commits\n *   - tui.select.cancel (esc) skips the whole sequence\n *\n * Answered steps stay on screen as a deterministic breadcrumb so you always\n * see what you've already committed to.\n */\n\nimport {\n\ttype Component,\n\tDEFAULT_INPUT_PROMPT,\n\ttype Focusable,\n\tgetKeybindings,\n\tInput,\n\ttruncateToWidth,\n\tvisibleWidth,\n} from \"@kolisachint/hoocode-tui\";\nimport type { AskQuestion } from \"../../../core/extensions/types.js\";\nimport { SELECT_CURSOR, SELECT_GUTTER, theme } from \"../theme/theme.js\";\nimport { InputFrame } from \"./input-frame.js\";\n\nexport interface AskOptionsOptions {\n\t/**\n\t * Draw the prompt's frame around the pane. False for a pane embedded in a\n\t * surface that already frames it (the team attach panel).\n\t */\n\tframed?: boolean;\n}\n\nexport class AskOptionsComponent implements Component, Focusable {\n\tprivate questions: AskQuestion[];\n\tprivate step = 0;\n\tprivate index = 0;\n\tprivate answers: string[] = [];\n\tprivate customInput = new Input();\n\tprivate onSubmitCallback: (answers: string[]) => void;\n\tprivate onCancelCallback: () => void;\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.customInput.focused = value;\n\t}\n\n\t/** Undefined when the pane is embedded and its host draws the frame. */\n\tprivate frame: InputFrame | undefined;\n\n\tconstructor(\n\t\tquestions: AskQuestion[],\n\t\tonSubmit: (answers: string[]) => void,\n\t\tonCancel: () => void,\n\t\toptions: AskOptionsOptions = {},\n\t) {\n\t\tthis.questions = questions;\n\t\tthis.onSubmitCallback = onSubmit;\n\t\tthis.onCancelCallback = onCancel;\n\t\tif (options.framed !== false) {\n\t\t\tthis.frame = new InputFrame({ title: \"input needed\" });\n\t\t\t// The body is recomputed from live state on every frame, so it goes in\n\t\t\t// as an adapter rather than as a component holding its own lines.\n\t\t\tthis.frame.addChild({ render: (width: number) => this.renderBody(width, false), invalidate: () => {} });\n\t\t}\n\t}\n\n\tinvalidate(): void {\n\t\tthis.frame?.invalidate();\n\t}\n\n\tprivate rowCount(q: AskQuestion): number {\n\t\treturn q.options.length + (q.allowCustom ? 1 : 0);\n\t}\n\n\tprivate isOnCustomRow(q: AskQuestion): boolean {\n\t\treturn !!q.allowCustom && this.index === q.options.length;\n\t}\n\n\t/**\n\t * How the arrow keys divide between the step and the text on the custom row.\n\t *\n\t * The step arrows and the text cursor want the same two keys, and they are\n\t * only ever in conflict on the free-text row with something typed in it.\n\t * There the text gets first refusal and the step takes what is left over at\n\t * the ends: `→` at the end of the answer commits it (with nothing typed the\n\t * end is also the start, which is why the plain custom row still advances),\n\t * and `←` never leaves the step while there is an answer to lose — stepping\n\t * back clears the field, which is not what a cursor key should do.\n\t */\n\tprivate customArrows(): { next: boolean; back: boolean } {\n\t\tif (!this.isOnCustomRow(this.questions[this.step])) return { next: true, back: true };\n\t\tconst typed = this.customInput.getValue().length;\n\t\treturn { next: this.customInput.getCursor() >= typed, back: typed === 0 };\n\t}\n\n\tprivate spread(left: string, right: string, width: number): string {\n\t\tconst gap = Math.max(1, width - visibleWidth(left) - visibleWidth(right));\n\t\treturn truncateToWidth(`${left}${\" \".repeat(gap)}${right}`, width, \"\");\n\t}\n\n\tprivate renderHints(last: boolean): string {\n\t\tconst kb = getKeybindings();\n\t\tconst sep = theme.fg(\"muted\", \" · \");\n\t\tconst hint = (keys: string, desc: string) => theme.fg(\"dim\", keys) + theme.fg(\"muted\", ` ${desc}`);\n\t\tconst upDown = `${kb.getKeys(\"tui.select.up\").join(\"/\")}/${kb.getKeys(\"tui.select.down\").join(\"/\")}`;\n\t\tconst arrows = this.customArrows();\n\t\tconst parts = [hint(upDown, \"move\")];\n\t\t// Mid-answer on the free-text row the arrows are the text cursor's, so the\n\t\t// hint names the key that does commit it.\n\t\tconst nextKeys = arrows.next ? kb.getKeys(\"app.options.next\") : kb.getKeys(\"tui.select.confirm\");\n\t\tparts.push(hint(nextKeys.join(\"/\"), last ? \"submit\" : \"next\"));\n\t\tif (this.step > 0 && arrows.back) {\n\t\t\tparts.push(hint(kb.getKeys(\"app.options.back\").join(\"/\"), \"back\"));\n\t\t}\n\t\tparts.push(hint(kb.getKeys(\"tui.select.cancel\").join(\"/\"), \"skip\"));\n\t\treturn parts.join(sep);\n\t}\n\n\trender(width: number): string[] {\n\t\tif (this.frame) return this.frame.render(width);\n\t\treturn this.renderBody(width, true);\n\t}\n\n\t/**\n\t * The pane's rows. `labelled` prints the \"INPUT NEEDED\" name on the hint\n\t * row; a framed pane has that name in its border already.\n\t */\n\tprivate renderBody(width: number, labelled: boolean): string[] {\n\t\tconst lines: string[] = [];\n\t\tconst cur = this.questions[this.step];\n\t\tconst last = this.step === this.questions.length - 1;\n\n\t\t// Key hints for this step, flush right; the name on the left when the pane\n\t\t// is not carrying it in a border.\n\t\tconst title = labelled ? theme.bold(theme.fg(\"borderAccent\", \"INPUT NEEDED\")) : \"\";\n\t\tlines.push(this.spread(title, this.renderHints(last), width));\n\n\t\t// Answered-step breadcrumb.\n\t\tfor (let i = 0; i < this.step; i++) {\n\t\t\tconst q = this.questions[i];\n\t\t\tconst check = theme.fg(\"success\", \"✓\");\n\t\t\tconst label = theme.fg(\"dim\", q.short ?? q.question);\n\t\t\tconst arrow = theme.fg(\"dim\", \"→ \");\n\t\t\tconst answer = theme.fg(\"accent\", this.answers[i] ?? \"\");\n\t\t\tlines.push(truncateToWidth(`${check} ${label}   ${arrow}${answer}`, width, \"...\"));\n\t\t}\n\t\tif (this.step > 0) lines.push(\"\");\n\n\t\t// Current question.\n\t\tconst stepLabel = theme.fg(\"dim\", `${this.step + 1}/${this.questions.length}`);\n\t\tlines.push(truncateToWidth(`${stepLabel} ${theme.bold(cur.question)}`, width, \"...\"));\n\t\tif (cur.detail) lines.push(truncateToWidth(theme.fg(\"muted\", `    ${cur.detail}`), width, \"...\"));\n\t\tlines.push(\"\");\n\n\t\t// Options.\n\t\tfor (let i = 0; i < cur.options.length; i++) {\n\t\t\tconst o = cur.options[i];\n\t\t\tconst active = i === this.index;\n\t\t\tconst cursor = theme.fg(\"accent\", active ? SELECT_CURSOR : SELECT_GUTTER);\n\t\t\tconst num = theme.fg(active ? \"accent\" : \"dim\", String(i + 1));\n\t\t\tconst label = active ? theme.bold(o.label) : o.label;\n\t\t\tlet line = `${cursor}${num} ${label}`;\n\t\t\tif (o.recommended) line += theme.fg(\"success\", \" (recommended)\");\n\t\t\tif (o.description) line += theme.fg(\"muted\", `   ${o.description}`);\n\t\t\tlines.push(truncateToWidth(line, width, \"...\"));\n\t\t}\n\n\t\t// Custom row.\n\t\tif (cur.allowCustom) {\n\t\t\tconst customIndex = cur.options.length;\n\t\t\tconst active = this.index === customIndex;\n\t\t\tconst cursor = theme.fg(\"accent\", active ? SELECT_CURSOR : SELECT_GUTTER);\n\t\t\tconst plus = theme.fg(active ? \"accent\" : \"dim\", \"+\");\n\t\t\tif (active) {\n\t\t\t\tconst value = this.customInput.getValue();\n\t\t\t\tconst prompt = theme.fg(\"muted\", DEFAULT_INPUT_PROMPT);\n\t\t\t\tconst caret = this._focused ? theme.fg(\"accent\", \"▏\") : \"\";\n\t\t\t\tconst body = value\n\t\t\t\t\t? `${theme.fg(\"text\", value)}${caret}`\n\t\t\t\t\t: `${caret}${theme.fg(\"dim\", \"type your own answer\")}`;\n\t\t\t\tlines.push(truncateToWidth(`${cursor}${plus} ${prompt} ${body}`, width, \"...\"));\n\t\t\t} else {\n\t\t\t\tconst label = theme.fg(\"muted\", \"custom answer\");\n\t\t\t\tconst desc = theme.fg(\"dim\", \"type your own\");\n\t\t\t\tlines.push(truncateToWidth(`${cursor}${plus} ${label}   ${desc}`, width, \"...\"));\n\t\t\t}\n\t\t}\n\n\t\t// Count.\n\t\tlines.push(theme.fg(\"dim\", `(${this.index + 1}/${this.rowCount(cur)})`));\n\t\treturn lines;\n\t}\n\n\tprivate confirm(): void {\n\t\tconst cur = this.questions[this.step];\n\t\tlet value: string;\n\t\tif (this.isOnCustomRow(cur)) {\n\t\t\tvalue = this.customInput.getValue().trim();\n\t\t\tif (!value) return; // can't submit an empty custom answer\n\t\t} else {\n\t\t\tvalue = cur.options[this.index].label;\n\t\t}\n\t\tthis.answers[this.step] = value;\n\t\tif (this.step < this.questions.length - 1) {\n\t\t\tthis.step += 1;\n\t\t\tthis.index = 0;\n\t\t\tthis.customInput.setValue(\"\");\n\t\t} else {\n\t\t\tthis.onSubmitCallback(this.answers.slice());\n\t\t}\n\t}\n\n\tprivate back(): void {\n\t\tif (this.step === 0) return;\n\t\tthis.step -= 1;\n\t\tthis.index = 0;\n\t\tthis.customInput.setValue(\"\");\n\t}\n\n\thandleInput(data: string): void {\n\t\tconst kb = getKeybindings();\n\t\tconst cur = this.questions[this.step];\n\t\tconst rows = this.rowCount(cur);\n\t\tconst onCustom = this.isOnCustomRow(cur);\n\n\t\tif (kb.matches(data, \"tui.select.up\")) {\n\t\t\tthis.index = this.index === 0 ? rows - 1 : this.index - 1;\n\t\t\treturn;\n\t\t}\n\t\tif (kb.matches(data, \"tui.select.down\")) {\n\t\t\tthis.index = this.index === rows - 1 ? 0 : this.index + 1;\n\t\t\treturn;\n\t\t}\n\t\tconst arrows = this.customArrows();\n\t\tif (arrows.back && kb.matches(data, \"app.options.back\")) {\n\t\t\tthis.back();\n\t\t\treturn;\n\t\t}\n\t\tif ((arrows.next && kb.matches(data, \"app.options.next\")) || kb.matches(data, \"tui.select.confirm\")) {\n\t\t\tthis.confirm();\n\t\t\treturn;\n\t\t}\n\t\tif (kb.matches(data, \"tui.select.cancel\")) {\n\t\t\tthis.onCancelCallback();\n\t\t\treturn;\n\t\t}\n\n\t\tif (onCustom) {\n\t\t\t// Delegate free-form typing (insert, backspace, paste, word-delete)\n\t\t\t// to a hidden Input buffer; the value is rendered inline above.\n\t\t\tthis.customInput.handleInput(data);\n\t\t\treturn;\n\t\t}\n\n\t\t// Number quick-pick for the listed options (not the custom row).\n\t\tif (/^[1-9]$/.test(data)) {\n\t\t\tconst n = Number(data) - 1;\n\t\t\tif (n < cur.options.length) {\n\t\t\t\tthis.index = n;\n\t\t\t\tthis.confirm();\n\t\t\t}\n\t\t}\n\t}\n}\n"]}