@astralyn/pi 0.82.3 → 0.82.5

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 (158) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/dist/cli/config-selector.d.ts.map +1 -1
  3. package/dist/cli/config-selector.js +3 -2
  4. package/dist/cli/config-selector.js.map +1 -1
  5. package/dist/cli/startup-ui.d.ts.map +1 -1
  6. package/dist/cli/startup-ui.js +3 -2
  7. package/dist/cli/startup-ui.js.map +1 -1
  8. package/dist/core/agent-session.d.ts +2 -0
  9. package/dist/core/agent-session.d.ts.map +1 -1
  10. package/dist/core/agent-session.js +33 -14
  11. package/dist/core/agent-session.js.map +1 -1
  12. package/dist/core/extensions/index.d.ts +2 -2
  13. package/dist/core/extensions/index.d.ts.map +1 -1
  14. package/dist/core/extensions/index.js +1 -1
  15. package/dist/core/extensions/index.js.map +1 -1
  16. package/dist/core/extensions/loader.d.ts +1 -1
  17. package/dist/core/extensions/loader.d.ts.map +1 -1
  18. package/dist/core/extensions/loader.js +2 -3
  19. package/dist/core/extensions/loader.js.map +1 -1
  20. package/dist/core/extensions/types.d.ts +18 -0
  21. package/dist/core/extensions/types.d.ts.map +1 -1
  22. package/dist/core/extensions/types.js +11 -0
  23. package/dist/core/extensions/types.js.map +1 -1
  24. package/dist/extensions/index.d.ts.map +1 -1
  25. package/dist/extensions/index.js +2 -0
  26. package/dist/extensions/index.js.map +1 -1
  27. package/dist/extensions/plan/constants.d.ts +31 -0
  28. package/dist/extensions/plan/constants.d.ts.map +1 -0
  29. package/dist/extensions/plan/constants.js +57 -0
  30. package/dist/extensions/plan/constants.js.map +1 -0
  31. package/dist/extensions/plan/index.d.ts +21 -0
  32. package/dist/extensions/plan/index.d.ts.map +1 -0
  33. package/dist/extensions/plan/index.js +291 -0
  34. package/dist/extensions/plan/index.js.map +1 -0
  35. package/dist/extensions/plan/schema.d.ts +15 -0
  36. package/dist/extensions/plan/schema.d.ts.map +1 -0
  37. package/dist/extensions/plan/schema.js +13 -0
  38. package/dist/extensions/plan/schema.js.map +1 -0
  39. package/dist/extensions/plan/state.d.ts +29 -0
  40. package/dist/extensions/plan/state.d.ts.map +1 -0
  41. package/dist/extensions/plan/state.js +74 -0
  42. package/dist/extensions/plan/state.js.map +1 -0
  43. package/dist/extensions/plan/storage.d.ts +18 -0
  44. package/dist/extensions/plan/storage.d.ts.map +1 -0
  45. package/dist/extensions/plan/storage.js +58 -0
  46. package/dist/extensions/plan/storage.js.map +1 -0
  47. package/dist/extensions/question/dialog.d.ts +3 -1
  48. package/dist/extensions/question/dialog.d.ts.map +1 -1
  49. package/dist/extensions/question/dialog.js +106 -52
  50. package/dist/extensions/question/dialog.js.map +1 -1
  51. package/dist/extensions/question/index.d.ts.map +1 -1
  52. package/dist/extensions/question/index.js +2 -2
  53. package/dist/extensions/question/index.js.map +1 -1
  54. package/dist/extensions/question/render-cache.d.ts +9 -0
  55. package/dist/extensions/question/render-cache.d.ts.map +1 -1
  56. package/dist/extensions/question/render-cache.js +22 -0
  57. package/dist/extensions/question/render-cache.js.map +1 -1
  58. package/dist/extensions/question/results.d.ts.map +1 -1
  59. package/dist/extensions/question/results.js +6 -1
  60. package/dist/extensions/question/results.js.map +1 -1
  61. package/dist/extensions/question/schema.d.ts +4 -0
  62. package/dist/extensions/question/schema.d.ts.map +1 -1
  63. package/dist/extensions/question/schema.js +19 -28
  64. package/dist/extensions/question/schema.js.map +1 -1
  65. package/dist/extensions/question/types.d.ts +1 -1
  66. package/dist/extensions/question/types.d.ts.map +1 -1
  67. package/dist/extensions/question/types.js.map +1 -1
  68. package/dist/extensions/rewind/anchor.d.ts +29 -0
  69. package/dist/extensions/rewind/anchor.d.ts.map +1 -0
  70. package/dist/extensions/rewind/anchor.js +35 -0
  71. package/dist/extensions/rewind/anchor.js.map +1 -0
  72. package/dist/extensions/rewind/engine.d.ts +2 -6
  73. package/dist/extensions/rewind/engine.d.ts.map +1 -1
  74. package/dist/extensions/rewind/engine.js +41 -10
  75. package/dist/extensions/rewind/engine.js.map +1 -1
  76. package/dist/extensions/rewind/index.d.ts.map +1 -1
  77. package/dist/extensions/rewind/index.js +55 -21
  78. package/dist/extensions/rewind/index.js.map +1 -1
  79. package/dist/extensions/rewind/menu.d.ts.map +1 -1
  80. package/dist/extensions/rewind/menu.js +7 -2
  81. package/dist/extensions/rewind/menu.js.map +1 -1
  82. package/dist/extensions/rewind/restore.d.ts +26 -9
  83. package/dist/extensions/rewind/restore.d.ts.map +1 -1
  84. package/dist/extensions/rewind/restore.js +58 -16
  85. package/dist/extensions/rewind/restore.js.map +1 -1
  86. package/dist/extensions/subagent/activity.d.ts +1 -0
  87. package/dist/extensions/subagent/activity.d.ts.map +1 -1
  88. package/dist/extensions/subagent/activity.js +20 -4
  89. package/dist/extensions/subagent/activity.js.map +1 -1
  90. package/dist/extensions/subagent/agents.d.ts.map +1 -1
  91. package/dist/extensions/subagent/agents.js +13 -18
  92. package/dist/extensions/subagent/agents.js.map +1 -1
  93. package/dist/extensions/subagent/constants.d.ts +5 -4
  94. package/dist/extensions/subagent/constants.d.ts.map +1 -1
  95. package/dist/extensions/subagent/constants.js +12 -5
  96. package/dist/extensions/subagent/constants.js.map +1 -1
  97. package/dist/extensions/subagent/index.d.ts.map +1 -1
  98. package/dist/extensions/subagent/index.js +12 -5
  99. package/dist/extensions/subagent/index.js.map +1 -1
  100. package/dist/extensions/subagent/render.d.ts.map +1 -1
  101. package/dist/extensions/subagent/render.js +166 -41
  102. package/dist/extensions/subagent/render.js.map +1 -1
  103. package/dist/extensions/subagent/resolve.d.ts +2 -5
  104. package/dist/extensions/subagent/resolve.d.ts.map +1 -1
  105. package/dist/extensions/subagent/resolve.js +8 -13
  106. package/dist/extensions/subagent/resolve.js.map +1 -1
  107. package/dist/extensions/subagent/runner.d.ts +3 -1
  108. package/dist/extensions/subagent/runner.d.ts.map +1 -1
  109. package/dist/extensions/subagent/runner.js +115 -66
  110. package/dist/extensions/subagent/runner.js.map +1 -1
  111. package/dist/extensions/subagent/schema.d.ts +0 -6
  112. package/dist/extensions/subagent/schema.d.ts.map +1 -1
  113. package/dist/extensions/subagent/schema.js +3 -4
  114. package/dist/extensions/subagent/schema.js.map +1 -1
  115. package/dist/extensions/subagent/sdk-runner.d.ts.map +1 -1
  116. package/dist/extensions/subagent/sdk-runner.js +37 -16
  117. package/dist/extensions/subagent/sdk-runner.js.map +1 -1
  118. package/dist/extensions/subagent/types.d.ts +10 -2
  119. package/dist/extensions/subagent/types.d.ts.map +1 -1
  120. package/dist/extensions/subagent/types.js.map +1 -1
  121. package/dist/extensions/todo/constants.d.ts +1 -1
  122. package/dist/extensions/todo/constants.d.ts.map +1 -1
  123. package/dist/extensions/todo/constants.js +29 -4
  124. package/dist/extensions/todo/constants.js.map +1 -1
  125. package/dist/extensions/todo/index.d.ts +1 -1
  126. package/dist/extensions/todo/index.d.ts.map +1 -1
  127. package/dist/extensions/todo/index.js +22 -3
  128. package/dist/extensions/todo/index.js.map +1 -1
  129. package/dist/extensions/todo/schema.d.ts +6 -5
  130. package/dist/extensions/todo/schema.d.ts.map +1 -1
  131. package/dist/extensions/todo/schema.js +20 -9
  132. package/dist/extensions/todo/schema.js.map +1 -1
  133. package/dist/extensions/todo/state.d.ts +5 -1
  134. package/dist/extensions/todo/state.d.ts.map +1 -1
  135. package/dist/extensions/todo/state.js +184 -38
  136. package/dist/extensions/todo/state.js.map +1 -1
  137. package/dist/extensions/todo/view.d.ts.map +1 -1
  138. package/dist/extensions/todo/view.js +10 -6
  139. package/dist/extensions/todo/view.js.map +1 -1
  140. package/dist/modes/interactive/interactive-mode.d.ts +2 -0
  141. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  142. package/dist/modes/interactive/interactive-mode.js +36 -2
  143. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  144. package/dist/utils/coalescing-terminal.d.ts +116 -0
  145. package/dist/utils/coalescing-terminal.d.ts.map +1 -0
  146. package/dist/utils/coalescing-terminal.js +256 -0
  147. package/dist/utils/coalescing-terminal.js.map +1 -0
  148. package/docs/bundled/extensions/README.md +1 -0
  149. package/docs/bundled/extensions/plan.md +31 -0
  150. package/docs/bundled/extensions/question.md +9 -7
  151. package/docs/bundled/extensions/rewind.md +19 -8
  152. package/docs/bundled/extensions/subagent.md +16 -16
  153. package/docs/bundled/extensions/todo.md +5 -3
  154. package/docs/compaction.md +6 -2
  155. package/docs/docs.json +4 -0
  156. package/docs/extensions.md +4 -2
  157. package/npm-shrinkwrap.json +2 -2
  158. package/package.json +1 -1
@@ -1 +1 @@
1
- {"version":3,"file":"dialog.d.ts","sourceRoot":"","sources":["../../../src/extensions/question/dialog.ts"],"names":[],"mappings":"AAAA,OAAO,EAAsC,KAAK,GAAG,EAAiC,MAAM,wBAAwB,CAAC;AACrH,OAAO,EAAoB,KAAK,KAAK,EAAE,MAAM,wCAAwC,CAAC;AAYtF,OAAO,KAAK,EAAE,YAAY,EAA4B,QAAQ,EAAkB,MAAM,YAAY,CAAC;AAYnG,wBAAgB,oBAAoB,CAAC,SAAS,EAAE,QAAQ,EAAE;;;;;EAilBzD","sourcesContent":["import { Editor, type EditorTheme, Markdown, type TUI, truncateToWidth, visibleWidth } from \"@earendil-works/pi-tui\";\nimport { getMarkdownTheme, type Theme } from \"../../modes/interactive/theme/theme.ts\";\nimport { ruleBorder, wrapWithPrefix } from \"./dialog-primitives.ts\";\nimport { QUESTION_LIMITS } from \"./limits.ts\";\nimport { WidthCachedRender } from \"./render-cache.ts\";\nimport {\n\tdisplayOptions,\n\tfirstUnanswered,\n\thasAnswer,\n\thasMultiAnswer,\n\tnewQuestionState,\n\torderedAnswers,\n} from \"./state.ts\";\nimport type { DialogResult, DisplayOption, InputMode, Question, QuestionOption } from \"./types.ts\";\n\ntype DialogView = \"question\" | \"review\";\n\ntype DialogKeybindings = {\n\tmatches(data: string, keybinding: string): boolean;\n\tgetKeys(keybinding: string): string[];\n};\n\nconst PREVIEW_MAX_LINES = 16;\nconst PREVIEW_CHROME_ROWS = 18;\n\nexport function createQuestionDialog(questions: Question[]) {\n\treturn (tui: TUI, theme: Theme, keybindings: unknown, done: (result: DialogResult) => void) => {\n\t\tlet currentIdx = 0;\n\t\tlet view: DialogView = \"question\";\n\t\tlet inputMode: InputMode;\n\t\tlet noteTarget: string | undefined;\n\t\tlet dialogFocused = false;\n\t\tlet footerFocused = false;\n\t\tconst cache = new WidthCachedRender();\n\t\tconst states = questions.map(() => newQuestionState());\n\t\tconst bindings = keybindings as DialogKeybindings;\n\n\t\tconst editorTheme: EditorTheme = {\n\t\t\tborderColor: (s) => theme.fg(\"accent\", s),\n\t\t\tselectList: {\n\t\t\t\tselectedPrefix: (t) => theme.fg(\"accent\", t),\n\t\t\t\tselectedText: (t) => theme.fg(\"accent\", t),\n\t\t\t\tdescription: (t) => theme.fg(\"muted\", t),\n\t\t\t\tscrollInfo: (t) => theme.fg(\"dim\", t),\n\t\t\t\tnoMatch: (t) => theme.fg(\"warning\", t),\n\t\t\t},\n\t\t};\n\t\tconst editor = new Editor(tui, editorTheme);\n\n\t\tconst currentQuestion = () => questions[currentIdx];\n\t\tconst currentState = () => states[currentIdx];\n\t\tconst keyMatches = (data: string, keybinding: string) => bindings.matches(data, keybinding);\n\t\tconst keyLabel = (keybinding: string, fallback: string) => bindings.getKeys(keybinding).join(\"/\") || fallback;\n\n\t\tfunction refresh(): void {\n\t\t\tcache.invalidate();\n\t\t\ttui.requestRender();\n\t\t}\n\n\t\tfunction syncEditorFocus(): void {\n\t\t\teditor.focused = dialogFocused && inputMode !== undefined;\n\t\t}\n\n\t\tfunction setCurrentIdx(index: number): void {\n\t\t\tcurrentIdx = Math.max(0, Math.min(questions.length - 1, index));\n\t\t\tview = \"question\";\n\t\t\tinputMode = undefined;\n\t\t\tnoteTarget = undefined;\n\t\t\tfooterFocused = false;\n\t\t\teditor.setText(\"\");\n\t\t\tsyncEditorFocus();\n\t\t\trefresh();\n\t\t}\n\n\t\tfunction clearWarning(): void {\n\t\t\tcurrentState().warning = undefined;\n\t\t}\n\n\t\tfunction focusedOption(): DisplayOption | undefined {\n\t\t\treturn displayOptions(currentQuestion())[currentState().optionIndex];\n\t\t}\n\n\t\tfunction showReview(): void {\n\t\t\tconst missing = firstUnanswered(states);\n\t\t\tif (missing !== undefined) {\n\t\t\t\tsetCurrentIdx(missing);\n\t\t\t\tcurrentState().warning = \"Answer this question before reviewing your answers.\";\n\t\t\t\trefresh();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tview = \"review\";\n\t\t\tinputMode = undefined;\n\t\t\tnoteTarget = undefined;\n\t\t\tfooterFocused = false;\n\t\t\teditor.setText(\"\");\n\t\t\tsyncEditorFocus();\n\t\t\trefresh();\n\t\t}\n\n\t\tfunction advanceAfterAnswer(): void {\n\t\t\tif (questions.length === 1 && !currentQuestion().multiSelect) {\n\t\t\t\tdone({ answers: orderedAnswers(questions, states), outcome: \"answered\" });\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tconst missing = firstUnanswered(states);\n\t\t\tif (missing === undefined) {\n\t\t\t\tshowReview();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tsetCurrentIdx(missing);\n\t\t}\n\n\t\tfunction beginCustomInput(): void {\n\t\t\tconst state = currentState();\n\t\t\tinputMode = \"custom\";\n\t\t\tnoteTarget = undefined;\n\t\t\tconst existingSingle = state.singleAnswer?.kind === \"custom\" ? state.singleAnswer.answer : undefined;\n\t\t\teditor.setText(state.customAnswer?.text ?? existingSingle ?? \"\");\n\t\t\tsyncEditorFocus();\n\t\t\trefresh();\n\t\t}\n\n\t\tfunction selectSingleOption(option: QuestionOption): void {\n\t\t\tconst question = currentQuestion();\n\t\t\tconst state = currentState();\n\t\t\tstate.singleAnswer = {\n\t\t\t\tquestionIndex: currentIdx,\n\t\t\t\tquestion: question.question,\n\t\t\t\theader: question.header,\n\t\t\t\tkind: \"option\",\n\t\t\t\tanswer: option.label,\n\t\t\t\t...(option.preview ? { preview: option.preview } : {}),\n\t\t\t};\n\t\t\tstate.warning = undefined;\n\t\t}\n\n\t\tfunction beginNotesInput(): void {\n\t\t\tconst question = currentQuestion();\n\t\t\tconst state = currentState();\n\t\t\tconst option = focusedOption();\n\t\t\tif (!option || option.kind === \"other\") {\n\t\t\t\tbeginCustomInput();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (question.multiSelect && !state.multiSelected.has(option.optionIndex)) {\n\t\t\t\tstate.warning = \"Select the option first, then add notes.\";\n\t\t\t\trefresh();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (!question.multiSelect) selectSingleOption(option);\n\t\t\tinputMode = \"notes\";\n\t\t\tnoteTarget = option.label;\n\t\t\teditor.setText(state.notesByOption.get(option.label) ?? \"\");\n\t\t\tsyncEditorFocus();\n\t\t\trefresh();\n\t\t}\n\n\t\tfunction recordSingle(option: DisplayOption): void {\n\t\t\tif (option.kind === \"other\") {\n\t\t\t\tif (currentState().singleAnswer?.kind === \"custom\") {\n\t\t\t\t\tadvanceAfterAnswer();\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tbeginCustomInput();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tselectSingleOption(option);\n\t\t\tadvanceAfterAnswer();\n\t\t}\n\n\t\tfunction recordMulti(): void {\n\t\t\tconst state = currentState();\n\t\t\tif (!hasMultiAnswer(state)) {\n\t\t\t\tstate.warning = \"Select at least one option, type a custom answer, or cancel the questions.\";\n\t\t\t\trefresh();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tstate.warning = undefined;\n\t\t\tadvanceAfterAnswer();\n\t\t}\n\n\t\tfunction finishReview(): void {\n\t\t\tconst missing = firstUnanswered(states);\n\t\t\tif (missing !== undefined) {\n\t\t\t\tsetCurrentIdx(missing);\n\t\t\t\tcurrentState().warning = \"Answer this question before submitting.\";\n\t\t\t\trefresh();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tdone({ answers: orderedAnswers(questions, states), outcome: \"answered\" });\n\t\t}\n\n\t\teditor.onSubmit = (value) => {\n\t\t\tconst trimmed = value.trim();\n\t\t\tconst question = currentQuestion();\n\t\t\tconst state = currentState();\n\t\t\tif (trimmed.length > QUESTION_LIMITS.userTextChars) {\n\t\t\t\tstate.warning = `Keep notes and custom answers under ${QUESTION_LIMITS.userTextChars} characters.`;\n\t\t\t\trefresh();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (inputMode === \"notes\") {\n\t\t\t\tif (noteTarget) {\n\t\t\t\t\tif (trimmed) state.notesByOption.set(noteTarget, trimmed);\n\t\t\t\t\telse state.notesByOption.delete(noteTarget);\n\t\t\t\t}\n\t\t\t\tinputMode = undefined;\n\t\t\t\tnoteTarget = undefined;\n\t\t\t\teditor.setText(\"\");\n\t\t\t\tsyncEditorFocus();\n\t\t\t\trefresh();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (inputMode === \"custom\") {\n\t\t\t\tif (!trimmed) {\n\t\t\t\t\tinputMode = undefined;\n\t\t\t\t\teditor.setText(\"\");\n\t\t\t\t\tsyncEditorFocus();\n\t\t\t\t\trefresh();\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tif (question.multiSelect) {\n\t\t\t\t\tstate.customAnswer = { text: trimmed, selected: true };\n\t\t\t\t\tstate.optionIndex = question.options.length;\n\t\t\t\t\tinputMode = undefined;\n\t\t\t\t\teditor.setText(\"\");\n\t\t\t\t\tsyncEditorFocus();\n\t\t\t\t\trefresh();\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tstate.singleAnswer = {\n\t\t\t\t\tquestionIndex: currentIdx,\n\t\t\t\t\tquestion: question.question,\n\t\t\t\t\theader: question.header,\n\t\t\t\t\tkind: \"custom\",\n\t\t\t\t\tanswer: trimmed,\n\t\t\t\t};\n\t\t\t\tinputMode = undefined;\n\t\t\t\teditor.setText(\"\");\n\t\t\t\tsyncEditorFocus();\n\t\t\t\tadvanceAfterAnswer();\n\t\t\t}\n\t\t};\n\n\t\tfunction handleReviewInput(data: string): void {\n\t\t\tif (keyMatches(data, \"tui.editor.cursorLeft\")) {\n\t\t\t\tsetCurrentIdx(questions.length - 1);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (keyMatches(data, \"tui.select.confirm\")) {\n\t\t\t\tfinishReview();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (keyMatches(data, \"tui.select.cancel\")) {\n\t\t\t\tsetCurrentIdx(questions.length - 1);\n\t\t\t}\n\t\t}\n\n\t\tfunction handleQuestionInput(data: string): void {\n\t\t\tif (inputMode) {\n\t\t\t\tif (keyMatches(data, \"tui.select.cancel\")) {\n\t\t\t\t\tinputMode = undefined;\n\t\t\t\t\tnoteTarget = undefined;\n\t\t\t\t\teditor.setText(\"\");\n\t\t\t\t\tsyncEditorFocus();\n\t\t\t\t\trefresh();\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\teditor.handleInput(data);\n\t\t\t\trefresh();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (keyMatches(data, \"tui.editor.cursorLeft\")) {\n\t\t\t\tif (currentIdx > 0) setCurrentIdx(currentIdx - 1);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (keyMatches(data, \"tui.editor.cursorRight\")) {\n\t\t\t\tif (currentIdx < questions.length - 1) setCurrentIdx(currentIdx + 1);\n\t\t\t\telse if (firstUnanswered(states) === undefined) showReview();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst state = currentState();\n\t\t\tconst options = displayOptions(currentQuestion());\n\t\t\tif (keyMatches(data, \"tui.select.up\")) {\n\t\t\t\tif (footerFocused) {\n\t\t\t\t\tfooterFocused = false;\n\t\t\t\t\tstate.optionIndex = options.length - 1;\n\t\t\t\t} else if (state.optionIndex > 0) {\n\t\t\t\t\tstate.optionIndex--;\n\t\t\t\t}\n\t\t\t\tclearWarning();\n\t\t\t\trefresh();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (keyMatches(data, \"tui.select.down\")) {\n\t\t\t\tif (footerFocused) return;\n\t\t\t\tif (state.optionIndex < options.length - 1) state.optionIndex++;\n\t\t\t\telse footerFocused = true;\n\t\t\t\tclearWarning();\n\t\t\t\trefresh();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (keyMatches(data, \"tui.input.tab\")) {\n\t\t\t\tif (!footerFocused) beginNotesInput();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (footerFocused) {\n\t\t\t\tif (keyMatches(data, \"tui.select.confirm\")) {\n\t\t\t\t\tdone({ answers: orderedAnswers(questions, states), outcome: \"needs_clarification\" });\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tif (keyMatches(data, \"tui.select.cancel\")) {\n\t\t\t\t\tdone({ answers: orderedAnswers(questions, states), outcome: \"cancelled\" });\n\t\t\t\t}\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst question = currentQuestion();\n\t\t\tconst option = options[state.optionIndex];\n\t\t\tif (question.multiSelect && keyMatches(data, \"app.list.toggle\")) {\n\t\t\t\tif (option?.kind === \"other\") {\n\t\t\t\t\tif (state.customAnswer) {\n\t\t\t\t\t\tstate.customAnswer.selected = !state.customAnswer.selected;\n\t\t\t\t\t\tclearWarning();\n\t\t\t\t\t} else {\n\t\t\t\t\t\tstate.warning = `Press ${keyLabel(\"tui.input.tab\", \"Tab\")} to type a custom answer.`;\n\t\t\t\t\t}\n\t\t\t\t\trefresh();\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tif (option?.kind === \"option\") {\n\t\t\t\t\tif (state.multiSelected.has(option.optionIndex)) state.multiSelected.delete(option.optionIndex);\n\t\t\t\t\telse state.multiSelected.add(option.optionIndex);\n\t\t\t\t}\n\t\t\t\tclearWarning();\n\t\t\t\trefresh();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (keyMatches(data, \"tui.select.confirm\")) {\n\t\t\t\tif (question.multiSelect) recordMulti();\n\t\t\t\telse if (option) recordSingle(option);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (keyMatches(data, \"tui.select.cancel\")) {\n\t\t\t\tdone({ answers: orderedAnswers(questions, states), outcome: \"cancelled\" });\n\t\t\t}\n\t\t}\n\n\t\tfunction handleInput(data: string): void {\n\t\t\tif (view === \"review\") handleReviewInput(data);\n\t\t\telse handleQuestionInput(data);\n\t\t}\n\n\t\tfunction render(width: number): string[] {\n\t\t\treturn cache.get(width, tui.terminal.rows, compute);\n\t\t}\n\n\t\tfunction renderTabs(renderWidth: number, lines: string[]): void {\n\t\t\tif (questions.length < 2) return;\n\t\t\tconst tabCount = questions.length + 1;\n\t\t\tconst headerWidth = Math.max(4, Math.floor((renderWidth - tabCount * 5) / tabCount));\n\t\t\tconst tabs = [\n\t\t\t\t...questions.map((question, index) => {\n\t\t\t\t\tconst answered = hasAnswer(states[index]);\n\t\t\t\t\tconst label = ` ${answered ? \"■\" : \"□\"} ${truncateToWidth(question.header, headerWidth)} `;\n\t\t\t\t\tif (view === \"question\" && index === currentIdx) {\n\t\t\t\t\t\treturn theme.bg(\"selectedBg\", theme.fg(answered ? \"success\" : \"text\", label));\n\t\t\t\t\t}\n\t\t\t\t\treturn theme.fg(answered ? \"success\" : \"muted\", label);\n\t\t\t\t}),\n\t\t\t\tview === \"review\"\n\t\t\t\t\t? theme.bg(\"selectedBg\", theme.fg(\"text\", \" ✓ Submit \"))\n\t\t\t\t\t: theme.fg(firstUnanswered(states) === undefined ? \"success\" : \"dim\", \" ✓ Submit \"),\n\t\t\t].join(\" \");\n\t\t\twrapWithPrefix(\" \", tabs, renderWidth, lines);\n\t\t\tlines.push(\"\");\n\t\t}\n\n\t\tfunction previewLines(previewText: string, width: number, terminalRows: number): string[] {\n\t\t\tconst markdown = new Markdown(previewText, 1, 0, getMarkdownTheme());\n\t\t\tconst rendered = markdown.render(Math.max(1, width));\n\t\t\tconst maxLines = Math.max(4, Math.min(PREVIEW_MAX_LINES, terminalRows - PREVIEW_CHROME_ROWS));\n\t\t\tif (rendered.length <= maxLines) return rendered;\n\t\t\treturn [\n\t\t\t\t...rendered.slice(0, maxLines),\n\t\t\t\ttheme.fg(\n\t\t\t\t\t\"dim\",\n\t\t\t\t\ttruncateToWidth(`… ${rendered.length - maxLines} preview lines hidden`, Math.max(1, width)),\n\t\t\t\t),\n\t\t\t];\n\t\t}\n\n\t\tfunction renderReview(renderWidth: number, lines: string[]): void {\n\t\t\twrapWithPrefix(\" \", theme.fg(\"accent\", \"Review answers\"), renderWidth, lines);\n\t\t\tlines.push(\"\");\n\t\t\tfor (const answer of orderedAnswers(questions, states)) {\n\t\t\t\twrapWithPrefix(\n\t\t\t\t\t\" \",\n\t\t\t\t\ttheme.fg(\"text\", `${answer.questionIndex + 1}. ${answer.question}`),\n\t\t\t\t\trenderWidth,\n\t\t\t\t\tlines,\n\t\t\t\t);\n\t\t\t\tconst response =\n\t\t\t\t\tanswer.kind === \"multi\"\n\t\t\t\t\t\t? (answer.selected?.join(\", \") ?? \"(no input)\")\n\t\t\t\t\t\t: (answer.answer ?? \"(no input)\");\n\t\t\t\twrapWithPrefix(\" \", theme.fg(\"success\", response), renderWidth, lines);\n\t\t\t\tfor (const note of answer.notes ?? []) {\n\t\t\t\t\twrapWithPrefix(\" \", theme.fg(\"muted\", `Note for ${note.option}: ${note.text}`), renderWidth, lines);\n\t\t\t\t}\n\t\t\t\tlines.push(\"\");\n\t\t\t}\n\t\t\twrapWithPrefix(\n\t\t\t\t\" \",\n\t\t\t\ttheme.fg(\n\t\t\t\t\t\"dim\",\n\t\t\t\t\t`${keyLabel(\"tui.select.confirm\", \"Enter\")} to submit • ${keyLabel(\"tui.select.cancel\", \"Esc\")} to edit the last question`,\n\t\t\t\t),\n\t\t\t\trenderWidth,\n\t\t\t\tlines,\n\t\t\t);\n\t\t}\n\n\t\tfunction compute(width: number, terminalRows: number): string[] {\n\t\t\tconst lines: string[] = [];\n\t\t\tconst renderWidth = Math.max(1, width);\n\t\t\tlines.push(ruleBorder(theme, renderWidth));\n\t\t\trenderTabs(renderWidth, lines);\n\n\t\t\tif (view === \"review\") {\n\t\t\t\trenderReview(renderWidth, lines);\n\t\t\t\tlines.push(ruleBorder(theme, renderWidth));\n\t\t\t\treturn lines;\n\t\t\t}\n\n\t\t\tconst question = currentQuestion();\n\t\t\tconst state = currentState();\n\t\t\tconst options = displayOptions(question);\n\t\t\tconst isMulti = question.multiSelect === true;\n\t\t\twrapWithPrefix(\n\t\t\t\t\" \",\n\t\t\t\t`${theme.fg(\"accent\", question.header)} ${theme.fg(\"text\", question.question)}`,\n\t\t\t\trenderWidth,\n\t\t\t\tlines,\n\t\t\t);\n\t\t\tlines.push(\"\");\n\n\t\t\tconst optionLines: string[] = [];\n\t\t\tconst hasPreview = !isMulti && options.some((option) => option.kind === \"option\" && option.preview);\n\t\t\tconst showPreviewSideBySide = hasPreview && renderWidth >= 60;\n\t\t\tconst listWidth = showPreviewSideBySide ? Math.max(20, Math.floor(renderWidth * 0.4)) : renderWidth;\n\n\t\t\tfor (let index = 0; index < options.length; index++) {\n\t\t\t\tconst option = options[index];\n\t\t\t\tconst focused = !footerFocused && index === state.optionIndex;\n\t\t\t\tconst checked =\n\t\t\t\t\tisMulti &&\n\t\t\t\t\t((option.kind === \"option\" && state.multiSelected.has(option.optionIndex)) ||\n\t\t\t\t\t\t(option.kind === \"other\" && state.customAnswer?.selected === true));\n\t\t\t\tconst selectedSingle =\n\t\t\t\t\t!isMulti &&\n\t\t\t\t\t(option.kind === \"other\"\n\t\t\t\t\t\t? state.singleAnswer?.kind === \"custom\"\n\t\t\t\t\t\t: state.singleAnswer?.answer === option.label);\n\t\t\t\tconst marker = isMulti\n\t\t\t\t\t? option.kind === \"other\"\n\t\t\t\t\t\t? state.customAnswer\n\t\t\t\t\t\t\t? checked\n\t\t\t\t\t\t\t\t? theme.fg(\"success\", \"[x]\")\n\t\t\t\t\t\t\t\t: theme.fg(\"dim\", \"[ ]\")\n\t\t\t\t\t\t\t: \" \"\n\t\t\t\t\t\t: checked\n\t\t\t\t\t\t\t? theme.fg(\"success\", \"[x]\")\n\t\t\t\t\t\t\t: theme.fg(\"dim\", \"[ ]\")\n\t\t\t\t\t: \"\";\n\t\t\t\tconst focusArrow = focused ? theme.fg(\"accent\", \"→\") : \" \";\n\t\t\t\tconst note =\n\t\t\t\t\toption.kind === \"option\" && state.notesByOption.has(option.label) ? theme.fg(\"success\", \" +note\") : \"\";\n\t\t\t\tconst customText =\n\t\t\t\t\toption.kind === \"other\"\n\t\t\t\t\t\t? (state.customAnswer?.text ??\n\t\t\t\t\t\t\t(state.singleAnswer?.kind === \"custom\" ? state.singleAnswer.answer : undefined))\n\t\t\t\t\t\t: undefined;\n\t\t\t\tconst labelText = customText ? `${option.label} ✎ ${customText}` : option.label;\n\t\t\t\tconst label = `${index + 1}. ${labelText}${selectedSingle ? \" ✓\" : \"\"}${note}`;\n\t\t\t\tconst color = focused ? \"accent\" : selectedSingle || checked ? \"success\" : \"text\";\n\t\t\t\twrapWithPrefix(`${focusArrow} ${marker} `, theme.fg(color, label), listWidth, optionLines);\n\t\t\t\tif (option.kind === \"option\" && option.description) {\n\t\t\t\t\twrapWithPrefix(\" \", theme.fg(\"muted\", option.description), listWidth, optionLines);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif (inputMode) {\n\t\t\t\toptionLines.push(\"\");\n\t\t\t\tconst label = inputMode === \"notes\" ? `Notes for ${noteTarget ?? \"option\"}:` : \"Your answer:\";\n\t\t\t\twrapWithPrefix(\" \", theme.fg(\"muted\", label), listWidth, optionLines);\n\t\t\t\tfor (const line of editor.render(Math.max(1, listWidth - 2))) optionLines.push(` ${line}`);\n\t\t\t}\n\n\t\t\tif (showPreviewSideBySide) {\n\t\t\t\tconst gap = 2;\n\t\t\t\tconst rightWidth = renderWidth - listWidth - gap;\n\t\t\t\tconst focused = options[state.optionIndex];\n\t\t\t\tconst previewText = focused?.kind === \"option\" && focused.preview ? focused.preview : \"\";\n\t\t\t\tconst rightLines = previewText\n\t\t\t\t\t? previewLines(previewText, rightWidth, terminalRows)\n\t\t\t\t\t: [theme.fg(\"dim\", \"(no preview)\")];\n\t\t\t\tconst maxRows = Math.max(optionLines.length, rightLines.length);\n\t\t\t\tconst pad = \" \".repeat(gap);\n\t\t\t\tfor (let row = 0; row < maxRows; row++) {\n\t\t\t\t\tconst left = row < optionLines.length ? optionLines[row] : \"\";\n\t\t\t\t\tconst leftPadded = left + \" \".repeat(Math.max(0, listWidth - visibleWidth(left)));\n\t\t\t\t\tconst right = row < rightLines.length ? rightLines[row] : \"\";\n\t\t\t\t\tlines.push(`${leftPadded}${pad}${right}`);\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tlines.push(...optionLines);\n\t\t\t\tif (hasPreview) {\n\t\t\t\t\tconst focused = options[state.optionIndex];\n\t\t\t\t\tconst previewText = focused?.kind === \"option\" && focused.preview ? focused.preview : \"\";\n\t\t\t\t\tif (previewText) {\n\t\t\t\t\t\tlines.push(\"\");\n\t\t\t\t\t\tlines.push(ruleBorder(theme, renderWidth, \"dim\"));\n\t\t\t\t\t\tlines.push(...previewLines(previewText, renderWidth, terminalRows));\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tlines.push(\"\");\n\t\t\tconst chatPrefix = footerFocused ? theme.fg(\"accent\", \"→\") : \" \";\n\t\t\twrapWithPrefix(\n\t\t\t\t`${chatPrefix} `,\n\t\t\t\ttheme.fg(footerFocused ? \"accent\" : \"text\", \"Chat about this\"),\n\t\t\t\trenderWidth,\n\t\t\t\tlines,\n\t\t\t);\n\t\t\tif (state.warning) {\n\t\t\t\tlines.push(\"\");\n\t\t\t\twrapWithPrefix(\" \", theme.fg(\"warning\", state.warning), renderWidth, lines);\n\t\t\t}\n\t\t\tlines.push(\"\");\n\t\t\tif (inputMode === \"notes\") {\n\t\t\t\twrapWithPrefix(\n\t\t\t\t\t\" \",\n\t\t\t\t\ttheme.fg(\n\t\t\t\t\t\t\"dim\",\n\t\t\t\t\t\t`${keyLabel(\"tui.select.confirm\", \"Enter\")} to save notes • ${keyLabel(\"tui.select.cancel\", \"Esc\")} to go back`,\n\t\t\t\t\t),\n\t\t\t\t\trenderWidth,\n\t\t\t\t\tlines,\n\t\t\t\t);\n\t\t\t} else if (inputMode === \"custom\") {\n\t\t\t\tconst hint = isMulti ? \"to save custom answer\" : \"to continue\";\n\t\t\t\twrapWithPrefix(\n\t\t\t\t\t\" \",\n\t\t\t\t\ttheme.fg(\n\t\t\t\t\t\t\"dim\",\n\t\t\t\t\t\t`${keyLabel(\"tui.select.confirm\", \"Enter\")} ${hint} • ${keyLabel(\"tui.select.cancel\", \"Esc\")} to go back`,\n\t\t\t\t\t),\n\t\t\t\t\trenderWidth,\n\t\t\t\t\tlines,\n\t\t\t\t);\n\t\t\t} else if (footerFocused) {\n\t\t\t\twrapWithPrefix(\n\t\t\t\t\t\" \",\n\t\t\t\t\ttheme.fg(\n\t\t\t\t\t\t\"dim\",\n\t\t\t\t\t\t`${keyLabel(\"tui.select.confirm\", \"Enter\")} to discuss • ${keyLabel(\"tui.select.up\", \"Up\")} to return to options`,\n\t\t\t\t\t),\n\t\t\t\t\trenderWidth,\n\t\t\t\t\tlines,\n\t\t\t\t);\n\t\t\t} else if (isMulti) {\n\t\t\t\twrapWithPrefix(\n\t\t\t\t\t\" \",\n\t\t\t\t\ttheme.fg(\n\t\t\t\t\t\t\"dim\",\n\t\t\t\t\t\t`${keyLabel(\"app.list.toggle\", \"Space\")} to toggle • ${keyLabel(\"tui.input.tab\", \"Tab\")} for notes/custom • ${keyLabel(\"tui.select.confirm\", \"Enter\")} to continue • ${keyLabel(\"tui.editor.cursorLeft\", \"Left\")}/${keyLabel(\"tui.editor.cursorRight\", \"Right\")} questions • ${keyLabel(\"tui.select.cancel\", \"Esc\")} to cancel`,\n\t\t\t\t\t),\n\t\t\t\t\trenderWidth,\n\t\t\t\t\tlines,\n\t\t\t\t);\n\t\t\t} else {\n\t\t\t\twrapWithPrefix(\n\t\t\t\t\t\" \",\n\t\t\t\t\ttheme.fg(\n\t\t\t\t\t\t\"dim\",\n\t\t\t\t\t\t`${keyLabel(\"tui.input.tab\", \"Tab\")} for notes/custom • ${keyLabel(\"tui.select.confirm\", \"Enter\")} to select • ${keyLabel(\"tui.editor.cursorLeft\", \"Left\")}/${keyLabel(\"tui.editor.cursorRight\", \"Right\")} questions • ${keyLabel(\"tui.select.cancel\", \"Esc\")} to cancel`,\n\t\t\t\t\t),\n\t\t\t\t\trenderWidth,\n\t\t\t\t\tlines,\n\t\t\t\t);\n\t\t\t}\n\t\t\tlines.push(ruleBorder(theme, renderWidth));\n\t\t\treturn lines;\n\t\t}\n\n\t\treturn {\n\t\t\tget focused() {\n\t\t\t\treturn dialogFocused;\n\t\t\t},\n\t\t\tset focused(value: boolean) {\n\t\t\t\tdialogFocused = value;\n\t\t\t\tsyncEditorFocus();\n\t\t\t},\n\t\t\trender,\n\t\t\tinvalidate: () => {\n\t\t\t\tcache.invalidate();\n\t\t\t},\n\t\t\thandleInput,\n\t\t};\n\t};\n}\n"]}
1
+ {"version":3,"file":"dialog.d.ts","sourceRoot":"","sources":["../../../src/extensions/question/dialog.ts"],"names":[],"mappings":"AAAA,OAAO,EAAsC,KAAK,GAAG,EAAiC,MAAM,wBAAwB,CAAC;AACrH,OAAO,KAAK,EAAc,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AAChF,OAAO,EAAoB,KAAK,KAAK,EAAE,MAAM,wCAAwC,CAAC;AAYtF,OAAO,KAAK,EAAE,YAAY,EAA4B,QAAQ,EAAkC,MAAM,YAAY,CAAC;AAanH,wBAAgB,oBAAoB,CAAC,SAAS,EAAE,QAAQ,EAAE,EAAE,MAAM,CAAC,EAAE,WAAW;;;;;;EAkoB/E","sourcesContent":["import { Editor, type EditorTheme, Markdown, type TUI, truncateToWidth, visibleWidth } from \"@earendil-works/pi-tui\";\nimport type { Keybinding, KeybindingsManager } from \"../../core/keybindings.ts\";\nimport { getMarkdownTheme, type Theme } from \"../../modes/interactive/theme/theme.ts\";\nimport { ruleBorder, wrapWithPrefix } from \"./dialog-primitives.ts\";\nimport { QUESTION_LIMITS } from \"./limits.ts\";\nimport { PreviewLinesCache, WidthCachedRender } from \"./render-cache.ts\";\nimport {\n\tdisplayOptions,\n\tfirstUnanswered,\n\thasAnswer,\n\thasMultiAnswer,\n\tnewQuestionState,\n\torderedAnswers,\n} from \"./state.ts\";\nimport type { DialogResult, DisplayOption, InputMode, Question, QuestionAnswer, QuestionOption } from \"./types.ts\";\n\ntype DialogView = \"question\" | \"review\";\n\nconst PREVIEW_MAX_LINES = 16;\n/**\n * Estimated non-preview rows in the stacked (narrow) layout: top/bottom rules,\n * tab strip + blank, question line + blank, up to 5 option rows with\n * descriptions, the \"Chat about this\" row, warning slot, and the hint line.\n * Reserved so the preview clamps short of the terminal height.\n */\nconst PREVIEW_CHROME_ROWS = 18;\n\nexport function createQuestionDialog(questions: Question[], signal?: AbortSignal) {\n\treturn (tui: TUI, theme: Theme, keybindings: KeybindingsManager, done: (result: DialogResult) => void) => {\n\t\tlet currentIdx = 0;\n\t\tlet view: DialogView = \"question\";\n\t\tlet inputMode: InputMode;\n\t\tlet noteTarget: string | undefined;\n\t\tlet notesSnapshot: { prior: QuestionAnswer | undefined } | undefined;\n\t\tlet dialogFocused = false;\n\t\tlet footerFocused = false;\n\t\tlet finished = false;\n\t\tconst cache = new WidthCachedRender();\n\t\tconst previewCache = new PreviewLinesCache();\n\t\tconst states = questions.map(() => newQuestionState());\n\t\tconst optionsByQuestion = questions.map((question) => displayOptions(question));\n\n\t\tconst editorTheme: EditorTheme = {\n\t\t\tborderColor: (s) => theme.fg(\"accent\", s),\n\t\t\tselectList: {\n\t\t\t\tselectedPrefix: (t) => theme.fg(\"accent\", t),\n\t\t\t\tselectedText: (t) => theme.fg(\"accent\", t),\n\t\t\t\tdescription: (t) => theme.fg(\"muted\", t),\n\t\t\t\tscrollInfo: (t) => theme.fg(\"dim\", t),\n\t\t\t\tnoMatch: (t) => theme.fg(\"warning\", t),\n\t\t\t},\n\t\t};\n\t\tconst editor = new Editor(tui, editorTheme);\n\n\t\tconst currentQuestion = () => questions[currentIdx];\n\t\tconst currentState = () => states[currentIdx];\n\t\tconst currentOptions = () => optionsByQuestion[currentIdx];\n\t\tconst keyMatches = (data: string, keybinding: Keybinding) => keybindings.matches(data, keybinding);\n\t\tconst keyLabel = (keybinding: Keybinding, fallback: string) =>\n\t\t\tkeybindings.getKeys(keybinding).join(\"/\") || fallback;\n\n\t\tfunction finish(result: DialogResult): void {\n\t\t\tif (finished) return;\n\t\t\tfinished = true;\n\t\t\tdone(result);\n\t\t}\n\n\t\tconst onAbort = () => finish({ answers: orderedAnswers(questions, states), outcome: \"cancelled\" });\n\t\tif (signal?.aborted) onAbort();\n\t\telse signal?.addEventListener(\"abort\", onAbort, { once: true });\n\n\t\tfunction refresh(): void {\n\t\t\tcache.invalidate();\n\t\t\ttui.requestRender();\n\t\t}\n\n\t\tfunction syncEditorFocus(): void {\n\t\t\teditor.focused = dialogFocused && inputMode !== undefined;\n\t\t}\n\n\t\tfunction setCurrentIdx(index: number): void {\n\t\t\tcurrentIdx = Math.max(0, Math.min(questions.length - 1, index));\n\t\t\tview = \"question\";\n\t\t\tinputMode = undefined;\n\t\t\tnoteTarget = undefined;\n\t\t\tnotesSnapshot = undefined;\n\t\t\tfooterFocused = false;\n\t\t\teditor.setText(\"\");\n\t\t\tsyncEditorFocus();\n\t\t\trefresh();\n\t\t}\n\n\t\tfunction clearWarning(): void {\n\t\t\tcurrentState().warning = undefined;\n\t\t}\n\n\t\tfunction focusedOption(): DisplayOption | undefined {\n\t\t\treturn currentOptions()[currentState().optionIndex];\n\t\t}\n\n\t\tfunction showReview(): void {\n\t\t\tconst missing = firstUnanswered(states);\n\t\t\tif (missing !== undefined) {\n\t\t\t\tsetCurrentIdx(missing);\n\t\t\t\tcurrentState().warning = \"Answer this question before reviewing your answers.\";\n\t\t\t\trefresh();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tview = \"review\";\n\t\t\tinputMode = undefined;\n\t\t\tnoteTarget = undefined;\n\t\t\tfooterFocused = false;\n\t\t\teditor.setText(\"\");\n\t\t\tsyncEditorFocus();\n\t\t\trefresh();\n\t\t}\n\n\t\tfunction advanceAfterAnswer(): void {\n\t\t\tif (questions.length === 1 && !currentQuestion().multiSelect) {\n\t\t\t\tfinish({ answers: orderedAnswers(questions, states), outcome: \"answered\" });\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tconst missing = firstUnanswered(states);\n\t\t\tif (missing === undefined) {\n\t\t\t\tshowReview();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tsetCurrentIdx(missing);\n\t\t}\n\n\t\tfunction beginCustomInput(): void {\n\t\t\tconst state = currentState();\n\t\t\tinputMode = \"custom\";\n\t\t\tnoteTarget = undefined;\n\t\t\tconst existingSingle = state.singleAnswer?.kind === \"custom\" ? state.singleAnswer.answer : undefined;\n\t\t\teditor.setText(state.customAnswer?.text ?? existingSingle ?? \"\");\n\t\t\tsyncEditorFocus();\n\t\t\trefresh();\n\t\t}\n\n\t\tfunction selectSingleOption(option: QuestionOption): void {\n\t\t\tconst question = currentQuestion();\n\t\t\tconst state = currentState();\n\t\t\tstate.singleAnswer = {\n\t\t\t\tquestionIndex: currentIdx,\n\t\t\t\tquestion: question.question,\n\t\t\t\theader: question.header,\n\t\t\t\tkind: \"option\",\n\t\t\t\tanswer: option.label,\n\t\t\t\t...(option.preview ? { preview: option.preview } : {}),\n\t\t\t};\n\t\t\tstate.warning = undefined;\n\t\t}\n\n\t\tfunction beginNotesInput(): void {\n\t\t\tconst question = currentQuestion();\n\t\t\tconst state = currentState();\n\t\t\tconst option = focusedOption();\n\t\t\tif (!option || option.kind === \"other\") {\n\t\t\t\tbeginCustomInput();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (question.multiSelect && !state.multiSelected.has(option.optionIndex)) {\n\t\t\t\tstate.warning = \"Select the option first, then add notes.\";\n\t\t\t\trefresh();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (!question.multiSelect) {\n\t\t\t\tnotesSnapshot = { prior: state.singleAnswer };\n\t\t\t\tselectSingleOption(option);\n\t\t\t}\n\t\t\tinputMode = \"notes\";\n\t\t\tnoteTarget = option.label;\n\t\t\teditor.setText(state.notesByOption.get(option.label) ?? \"\");\n\t\t\tsyncEditorFocus();\n\t\t\trefresh();\n\t\t}\n\n\t\tfunction recordSingle(option: DisplayOption): void {\n\t\t\tif (option.kind === \"other\") {\n\t\t\t\tif (currentState().singleAnswer?.kind === \"custom\") {\n\t\t\t\t\tadvanceAfterAnswer();\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tbeginCustomInput();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tselectSingleOption(option);\n\t\t\tadvanceAfterAnswer();\n\t\t}\n\n\t\tfunction recordMulti(): void {\n\t\t\tconst state = currentState();\n\t\t\tif (!hasMultiAnswer(state)) {\n\t\t\t\tstate.warning = \"Select at least one option, type a custom answer, or cancel the questions.\";\n\t\t\t\trefresh();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tstate.warning = undefined;\n\t\t\tadvanceAfterAnswer();\n\t\t}\n\n\t\tfunction toggleMultiOption(option: DisplayOption): void {\n\t\t\tconst state = currentState();\n\t\t\tif (option.kind === \"other\") {\n\t\t\t\tif (state.customAnswer) {\n\t\t\t\t\tstate.customAnswer.selected = !state.customAnswer.selected;\n\t\t\t\t\tclearWarning();\n\t\t\t\t} else {\n\t\t\t\t\tstate.warning = `Press ${keyLabel(\"tui.input.tab\", \"Tab\")} to type a custom answer.`;\n\t\t\t\t}\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (state.multiSelected.has(option.optionIndex)) state.multiSelected.delete(option.optionIndex);\n\t\t\telse state.multiSelected.add(option.optionIndex);\n\t\t\tclearWarning();\n\t\t}\n\n\t\tfunction finishReview(): void {\n\t\t\tconst missing = firstUnanswered(states);\n\t\t\tif (missing !== undefined) {\n\t\t\t\tsetCurrentIdx(missing);\n\t\t\t\tcurrentState().warning = \"Answer this question before submitting.\";\n\t\t\t\trefresh();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tfinish({ answers: orderedAnswers(questions, states), outcome: \"answered\" });\n\t\t}\n\n\t\teditor.onSubmit = (value) => {\n\t\t\tconst trimmed = value.trim();\n\t\t\tconst question = currentQuestion();\n\t\t\tconst state = currentState();\n\t\t\tif (trimmed.length > QUESTION_LIMITS.userTextChars) {\n\t\t\t\tstate.warning = `Keep notes and custom answers under ${QUESTION_LIMITS.userTextChars} characters.`;\n\t\t\t\trefresh();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (inputMode === \"notes\") {\n\t\t\t\tif (noteTarget) {\n\t\t\t\t\tif (trimmed) state.notesByOption.set(noteTarget, trimmed);\n\t\t\t\t\telse state.notesByOption.delete(noteTarget);\n\t\t\t\t}\n\t\t\t\tinputMode = undefined;\n\t\t\t\tnoteTarget = undefined;\n\t\t\t\tnotesSnapshot = undefined;\n\t\t\t\teditor.setText(\"\");\n\t\t\t\tsyncEditorFocus();\n\t\t\t\trefresh();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (inputMode === \"custom\") {\n\t\t\t\tif (!trimmed) {\n\t\t\t\t\tinputMode = undefined;\n\t\t\t\t\teditor.setText(\"\");\n\t\t\t\t\tsyncEditorFocus();\n\t\t\t\t\trefresh();\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tif (question.multiSelect) {\n\t\t\t\t\tstate.customAnswer = { text: trimmed, selected: true };\n\t\t\t\t\tstate.optionIndex = question.options.length;\n\t\t\t\t\tinputMode = undefined;\n\t\t\t\t\teditor.setText(\"\");\n\t\t\t\t\tsyncEditorFocus();\n\t\t\t\t\trefresh();\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tstate.singleAnswer = {\n\t\t\t\t\tquestionIndex: currentIdx,\n\t\t\t\t\tquestion: question.question,\n\t\t\t\t\theader: question.header,\n\t\t\t\t\tkind: \"custom\",\n\t\t\t\t\tanswer: trimmed,\n\t\t\t\t};\n\t\t\t\tinputMode = undefined;\n\t\t\t\teditor.setText(\"\");\n\t\t\t\tsyncEditorFocus();\n\t\t\t\tadvanceAfterAnswer();\n\t\t\t}\n\t\t};\n\n\t\tfunction handleReviewInput(data: string): void {\n\t\t\tif (keyMatches(data, \"tui.editor.cursorLeft\")) {\n\t\t\t\tsetCurrentIdx(questions.length - 1);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (keyMatches(data, \"tui.select.confirm\")) {\n\t\t\t\tfinishReview();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (keyMatches(data, \"tui.select.cancel\")) {\n\t\t\t\tsetCurrentIdx(questions.length - 1);\n\t\t\t}\n\t\t}\n\n\t\tfunction handleQuestionInput(data: string): void {\n\t\t\tif (inputMode) {\n\t\t\t\tif (keyMatches(data, \"tui.select.cancel\")) {\n\t\t\t\t\tif (inputMode === \"notes\" && notesSnapshot) {\n\t\t\t\t\t\tcurrentState().singleAnswer = notesSnapshot.prior;\n\t\t\t\t\t}\n\t\t\t\t\tinputMode = undefined;\n\t\t\t\t\tnoteTarget = undefined;\n\t\t\t\t\tnotesSnapshot = undefined;\n\t\t\t\t\teditor.setText(\"\");\n\t\t\t\t\tsyncEditorFocus();\n\t\t\t\t\trefresh();\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\teditor.handleInput(data);\n\t\t\t\trefresh();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (keyMatches(data, \"tui.editor.cursorLeft\")) {\n\t\t\t\tif (currentIdx > 0) setCurrentIdx(currentIdx - 1);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (keyMatches(data, \"tui.editor.cursorRight\")) {\n\t\t\t\tif (currentIdx < questions.length - 1) setCurrentIdx(currentIdx + 1);\n\t\t\t\telse if (firstUnanswered(states) === undefined) showReview();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst state = currentState();\n\t\t\tconst options = currentOptions();\n\t\t\tif (keyMatches(data, \"tui.select.up\")) {\n\t\t\t\tif (footerFocused) {\n\t\t\t\t\tfooterFocused = false;\n\t\t\t\t\tstate.optionIndex = options.length - 1;\n\t\t\t\t} else if (state.optionIndex > 0) {\n\t\t\t\t\tstate.optionIndex--;\n\t\t\t\t}\n\t\t\t\tclearWarning();\n\t\t\t\trefresh();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (keyMatches(data, \"tui.select.down\")) {\n\t\t\t\tif (footerFocused) return;\n\t\t\t\tif (state.optionIndex < options.length - 1) state.optionIndex++;\n\t\t\t\telse footerFocused = true;\n\t\t\t\tclearWarning();\n\t\t\t\trefresh();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (keyMatches(data, \"tui.input.tab\")) {\n\t\t\t\tif (!footerFocused) beginNotesInput();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (data.length === 1 && data >= \"1\" && data <= \"9\") {\n\t\t\t\tconst index = data.charCodeAt(0) - 49;\n\t\t\t\tconst target = options[index];\n\t\t\t\tif (target) {\n\t\t\t\t\tfooterFocused = false;\n\t\t\t\t\tstate.optionIndex = index;\n\t\t\t\t\tclearWarning();\n\t\t\t\t\tif (currentQuestion().multiSelect) {\n\t\t\t\t\t\ttoggleMultiOption(target);\n\t\t\t\t\t\trefresh();\n\t\t\t\t\t} else {\n\t\t\t\t\t\trecordSingle(target);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (footerFocused) {\n\t\t\t\tif (keyMatches(data, \"tui.select.confirm\")) {\n\t\t\t\t\tfinish({ answers: orderedAnswers(questions, states), outcome: \"needs_clarification\" });\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tif (keyMatches(data, \"tui.select.cancel\")) {\n\t\t\t\t\tfinish({ answers: orderedAnswers(questions, states), outcome: \"cancelled\" });\n\t\t\t\t}\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst question = currentQuestion();\n\t\t\tconst option = options[state.optionIndex];\n\t\t\tif (question.multiSelect && keyMatches(data, \"app.list.toggle\")) {\n\t\t\t\tif (option) toggleMultiOption(option);\n\t\t\t\trefresh();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (keyMatches(data, \"tui.select.confirm\")) {\n\t\t\t\tif (question.multiSelect) {\n\t\t\t\t\tif (option?.kind === \"other\" && !state.customAnswer) {\n\t\t\t\t\t\tbeginCustomInput();\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t\trecordMulti();\n\t\t\t\t} else if (option) recordSingle(option);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (keyMatches(data, \"tui.select.cancel\")) {\n\t\t\t\tfinish({ answers: orderedAnswers(questions, states), outcome: \"cancelled\" });\n\t\t\t}\n\t\t}\n\n\t\tfunction handleInput(data: string): void {\n\t\t\tif (view === \"review\") handleReviewInput(data);\n\t\t\telse handleQuestionInput(data);\n\t\t}\n\n\t\tfunction render(width: number): string[] {\n\t\t\treturn cache.get(width, tui.terminal.rows, compute);\n\t\t}\n\n\t\tfunction renderTabs(renderWidth: number, lines: string[]): void {\n\t\t\tif (questions.length < 2) return;\n\t\t\tconst tabCount = questions.length + 1;\n\t\t\tconst headerWidth = Math.max(4, Math.floor((renderWidth - tabCount * 5) / tabCount));\n\t\t\tconst tabs = [\n\t\t\t\t...questions.map((question, index) => {\n\t\t\t\t\tconst answered = hasAnswer(states[index]);\n\t\t\t\t\tconst label = ` ${answered ? \"■\" : \"□\"} ${truncateToWidth(question.header, headerWidth)} `;\n\t\t\t\t\tif (view === \"question\" && index === currentIdx) {\n\t\t\t\t\t\treturn theme.bg(\"selectedBg\", theme.fg(answered ? \"success\" : \"text\", label));\n\t\t\t\t\t}\n\t\t\t\t\treturn theme.fg(answered ? \"success\" : \"muted\", label);\n\t\t\t\t}),\n\t\t\t\tview === \"review\"\n\t\t\t\t\t? theme.bg(\"selectedBg\", theme.fg(\"text\", \" ✓ Submit \"))\n\t\t\t\t\t: theme.fg(firstUnanswered(states) === undefined ? \"success\" : \"dim\", \" ✓ Submit \"),\n\t\t\t].join(\" \");\n\t\t\twrapWithPrefix(\" \", tabs, renderWidth, lines);\n\t\t\tlines.push(\"\");\n\t\t}\n\n\t\tfunction previewLines(previewText: string, width: number, terminalRows: number): string[] {\n\t\t\tconst maxLines = Math.max(4, Math.min(PREVIEW_MAX_LINES, terminalRows - PREVIEW_CHROME_ROWS));\n\t\t\treturn previewCache.get(previewText, width, maxLines, () => {\n\t\t\t\tconst markdown = new Markdown(previewText, 1, 0, getMarkdownTheme());\n\t\t\t\tconst rendered = markdown.render(Math.max(1, width));\n\t\t\t\tif (rendered.length <= maxLines) return rendered;\n\t\t\t\treturn [\n\t\t\t\t\t...rendered.slice(0, maxLines),\n\t\t\t\t\ttheme.fg(\n\t\t\t\t\t\t\"dim\",\n\t\t\t\t\t\ttruncateToWidth(`… ${rendered.length - maxLines} preview lines hidden`, Math.max(1, width)),\n\t\t\t\t\t),\n\t\t\t\t];\n\t\t\t});\n\t\t}\n\n\t\t/** Multi-select checkbox marker; the other row shows no box until a custom answer exists. */\n\t\tfunction multiMarker(option: DisplayOption, checked: boolean, hasCustomAnswer: boolean): string {\n\t\t\tif (option.kind === \"other\" && !hasCustomAnswer) return \" \";\n\t\t\treturn checked ? theme.fg(\"success\", \"[x]\") : theme.fg(\"dim\", \"[ ]\");\n\t\t}\n\n\t\tfunction renderReview(renderWidth: number, lines: string[]): void {\n\t\t\twrapWithPrefix(\" \", theme.fg(\"accent\", \"Review answers\"), renderWidth, lines);\n\t\t\tlines.push(\"\");\n\t\t\tfor (const answer of orderedAnswers(questions, states)) {\n\t\t\t\twrapWithPrefix(\n\t\t\t\t\t\" \",\n\t\t\t\t\ttheme.fg(\"text\", `${answer.questionIndex + 1}. ${answer.question}`),\n\t\t\t\t\trenderWidth,\n\t\t\t\t\tlines,\n\t\t\t\t);\n\t\t\t\tconst response =\n\t\t\t\t\tanswer.kind === \"multi\"\n\t\t\t\t\t\t? (answer.selected?.join(\", \") ?? \"(no input)\")\n\t\t\t\t\t\t: (answer.answer ?? \"(no input)\");\n\t\t\t\twrapWithPrefix(\" \", theme.fg(\"success\", response), renderWidth, lines);\n\t\t\t\tfor (const note of answer.notes ?? []) {\n\t\t\t\t\twrapWithPrefix(\" \", theme.fg(\"muted\", `Note for ${note.option}: ${note.text}`), renderWidth, lines);\n\t\t\t\t}\n\t\t\t\tlines.push(\"\");\n\t\t\t}\n\t\t\twrapWithPrefix(\n\t\t\t\t\" \",\n\t\t\t\ttheme.fg(\n\t\t\t\t\t\"dim\",\n\t\t\t\t\t`${keyLabel(\"tui.select.confirm\", \"Enter\")} to submit • ${keyLabel(\"tui.select.cancel\", \"Esc\")} to edit the last question`,\n\t\t\t\t),\n\t\t\t\trenderWidth,\n\t\t\t\tlines,\n\t\t\t);\n\t\t}\n\n\t\tfunction compute(width: number, terminalRows: number): string[] {\n\t\t\tconst lines: string[] = [];\n\t\t\tconst renderWidth = Math.max(1, width);\n\t\t\tlines.push(ruleBorder(theme, renderWidth));\n\t\t\trenderTabs(renderWidth, lines);\n\n\t\t\tif (view === \"review\") {\n\t\t\t\trenderReview(renderWidth, lines);\n\t\t\t\tlines.push(ruleBorder(theme, renderWidth));\n\t\t\t\treturn lines;\n\t\t\t}\n\n\t\t\tconst question = currentQuestion();\n\t\t\tconst state = currentState();\n\t\t\tconst options = currentOptions();\n\t\t\tconst isMulti = question.multiSelect === true;\n\t\t\twrapWithPrefix(\n\t\t\t\t\" \",\n\t\t\t\t`${theme.fg(\"accent\", question.header)} ${theme.fg(\"text\", question.question)}`,\n\t\t\t\trenderWidth,\n\t\t\t\tlines,\n\t\t\t);\n\t\t\tlines.push(\"\");\n\n\t\t\tconst optionLines: string[] = [];\n\t\t\tconst hasPreview = !isMulti && options.some((option) => option.kind === \"option\" && option.preview);\n\t\t\tconst showPreviewSideBySide = hasPreview && renderWidth >= 60;\n\t\t\tconst listWidth = showPreviewSideBySide ? Math.max(20, Math.floor(renderWidth * 0.4)) : renderWidth;\n\n\t\t\tfor (let index = 0; index < options.length; index++) {\n\t\t\t\tconst option = options[index];\n\t\t\t\tconst focused = !footerFocused && index === state.optionIndex;\n\t\t\t\tconst checked =\n\t\t\t\t\tisMulti &&\n\t\t\t\t\t((option.kind === \"option\" && state.multiSelected.has(option.optionIndex)) ||\n\t\t\t\t\t\t(option.kind === \"other\" && state.customAnswer?.selected === true));\n\t\t\t\tconst selectedSingle =\n\t\t\t\t\t!isMulti &&\n\t\t\t\t\t(option.kind === \"other\"\n\t\t\t\t\t\t? state.singleAnswer?.kind === \"custom\"\n\t\t\t\t\t\t: state.singleAnswer?.answer === option.label);\n\t\t\t\tconst marker = isMulti ? multiMarker(option, checked, state.customAnswer !== undefined) : \"\";\n\t\t\t\tconst focusArrow = focused ? theme.fg(\"accent\", \"→\") : \" \";\n\t\t\t\tconst note =\n\t\t\t\t\toption.kind === \"option\" && state.notesByOption.has(option.label) ? theme.fg(\"success\", \" +note\") : \"\";\n\t\t\t\tconst customText =\n\t\t\t\t\toption.kind === \"other\"\n\t\t\t\t\t\t? (state.customAnswer?.text ??\n\t\t\t\t\t\t\t(state.singleAnswer?.kind === \"custom\" ? state.singleAnswer.answer : undefined))\n\t\t\t\t\t\t: undefined;\n\t\t\t\tconst labelText = customText ? `${option.label} ✎ ${customText}` : option.label;\n\t\t\t\tconst label = `${index + 1}. ${labelText}${selectedSingle ? \" ✓\" : \"\"}${note}`;\n\t\t\t\tconst color = focused ? \"accent\" : selectedSingle || checked ? \"success\" : \"text\";\n\t\t\t\twrapWithPrefix(`${focusArrow} ${marker} `, theme.fg(color, label), listWidth, optionLines);\n\t\t\t\tif (option.kind === \"option\" && option.description) {\n\t\t\t\t\twrapWithPrefix(\" \", theme.fg(\"muted\", option.description), listWidth, optionLines);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif (inputMode) {\n\t\t\t\toptionLines.push(\"\");\n\t\t\t\tconst label = inputMode === \"notes\" ? `Notes for ${noteTarget ?? \"option\"}:` : \"Your answer:\";\n\t\t\t\twrapWithPrefix(\" \", theme.fg(\"muted\", label), listWidth, optionLines);\n\t\t\t\tfor (const line of editor.render(Math.max(1, listWidth - 2))) optionLines.push(` ${line}`);\n\t\t\t}\n\n\t\t\tif (showPreviewSideBySide) {\n\t\t\t\tconst gap = 2;\n\t\t\t\tconst rightWidth = renderWidth - listWidth - gap;\n\t\t\t\tconst focused = options[state.optionIndex];\n\t\t\t\tconst previewText = focused?.kind === \"option\" && focused.preview ? focused.preview : \"\";\n\t\t\t\tconst rightLines = previewText\n\t\t\t\t\t? previewLines(previewText, rightWidth, terminalRows)\n\t\t\t\t\t: [theme.fg(\"dim\", \"(no preview)\")];\n\t\t\t\tconst maxRows = Math.max(optionLines.length, rightLines.length);\n\t\t\t\tconst pad = \" \".repeat(gap);\n\t\t\t\tfor (let row = 0; row < maxRows; row++) {\n\t\t\t\t\tconst left = row < optionLines.length ? optionLines[row] : \"\";\n\t\t\t\t\tconst leftPadded = left + \" \".repeat(Math.max(0, listWidth - visibleWidth(left)));\n\t\t\t\t\tconst right = row < rightLines.length ? rightLines[row] : \"\";\n\t\t\t\t\tlines.push(`${leftPadded}${pad}${right}`);\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tlines.push(...optionLines);\n\t\t\t\tif (hasPreview) {\n\t\t\t\t\tconst focused = options[state.optionIndex];\n\t\t\t\t\tconst previewText = focused?.kind === \"option\" && focused.preview ? focused.preview : \"\";\n\t\t\t\t\tif (previewText) {\n\t\t\t\t\t\tlines.push(\"\");\n\t\t\t\t\t\tlines.push(ruleBorder(theme, renderWidth, \"dim\"));\n\t\t\t\t\t\tlines.push(...previewLines(previewText, renderWidth, terminalRows));\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tlines.push(\"\");\n\t\t\tconst chatPrefix = footerFocused ? theme.fg(\"accent\", \"→\") : \" \";\n\t\t\twrapWithPrefix(\n\t\t\t\t`${chatPrefix} `,\n\t\t\t\ttheme.fg(footerFocused ? \"accent\" : \"text\", \"Chat about this\"),\n\t\t\t\trenderWidth,\n\t\t\t\tlines,\n\t\t\t);\n\t\t\tif (state.warning) {\n\t\t\t\tlines.push(\"\");\n\t\t\t\twrapWithPrefix(\" \", theme.fg(\"warning\", state.warning), renderWidth, lines);\n\t\t\t}\n\t\t\tlines.push(\"\");\n\t\t\tif (inputMode === \"notes\") {\n\t\t\t\twrapWithPrefix(\n\t\t\t\t\t\" \",\n\t\t\t\t\ttheme.fg(\n\t\t\t\t\t\t\"dim\",\n\t\t\t\t\t\t`${keyLabel(\"tui.select.confirm\", \"Enter\")} to save notes • ${keyLabel(\"tui.select.cancel\", \"Esc\")} to go back`,\n\t\t\t\t\t),\n\t\t\t\t\trenderWidth,\n\t\t\t\t\tlines,\n\t\t\t\t);\n\t\t\t} else if (inputMode === \"custom\") {\n\t\t\t\tconst hint = isMulti ? \"to save custom answer\" : \"to continue\";\n\t\t\t\twrapWithPrefix(\n\t\t\t\t\t\" \",\n\t\t\t\t\ttheme.fg(\n\t\t\t\t\t\t\"dim\",\n\t\t\t\t\t\t`${keyLabel(\"tui.select.confirm\", \"Enter\")} ${hint} • ${keyLabel(\"tui.select.cancel\", \"Esc\")} to go back`,\n\t\t\t\t\t),\n\t\t\t\t\trenderWidth,\n\t\t\t\t\tlines,\n\t\t\t\t);\n\t\t\t} else if (footerFocused) {\n\t\t\t\twrapWithPrefix(\n\t\t\t\t\t\" \",\n\t\t\t\t\ttheme.fg(\n\t\t\t\t\t\t\"dim\",\n\t\t\t\t\t\t`${keyLabel(\"tui.select.confirm\", \"Enter\")} to discuss • ${keyLabel(\"tui.select.up\", \"Up\")} to return to options`,\n\t\t\t\t\t),\n\t\t\t\t\trenderWidth,\n\t\t\t\t\tlines,\n\t\t\t\t);\n\t\t\t} else if (isMulti) {\n\t\t\t\twrapWithPrefix(\n\t\t\t\t\t\" \",\n\t\t\t\t\ttheme.fg(\n\t\t\t\t\t\t\"dim\",\n\t\t\t\t\t\t`${keyLabel(\"app.list.toggle\", \"Space\")} to toggle • ${keyLabel(\"tui.input.tab\", \"Tab\")} for notes/custom • ${keyLabel(\"tui.select.confirm\", \"Enter\")} to continue • ${keyLabel(\"tui.editor.cursorLeft\", \"Left\")}/${keyLabel(\"tui.editor.cursorRight\", \"Right\")} questions • ${keyLabel(\"tui.select.cancel\", \"Esc\")} to cancel`,\n\t\t\t\t\t),\n\t\t\t\t\trenderWidth,\n\t\t\t\t\tlines,\n\t\t\t\t);\n\t\t\t} else {\n\t\t\t\twrapWithPrefix(\n\t\t\t\t\t\" \",\n\t\t\t\t\ttheme.fg(\n\t\t\t\t\t\t\"dim\",\n\t\t\t\t\t\t`${keyLabel(\"tui.input.tab\", \"Tab\")} for notes/custom • ${keyLabel(\"tui.select.confirm\", \"Enter\")} to select • ${keyLabel(\"tui.editor.cursorLeft\", \"Left\")}/${keyLabel(\"tui.editor.cursorRight\", \"Right\")} questions • ${keyLabel(\"tui.select.cancel\", \"Esc\")} to cancel`,\n\t\t\t\t\t),\n\t\t\t\t\trenderWidth,\n\t\t\t\t\tlines,\n\t\t\t\t);\n\t\t\t}\n\t\t\tlines.push(ruleBorder(theme, renderWidth));\n\t\t\treturn lines;\n\t\t}\n\n\t\treturn {\n\t\t\tget focused() {\n\t\t\t\treturn dialogFocused;\n\t\t\t},\n\t\t\tset focused(value: boolean) {\n\t\t\t\tdialogFocused = value;\n\t\t\t\tsyncEditorFocus();\n\t\t\t},\n\t\t\trender,\n\t\t\tinvalidate: () => {\n\t\t\t\tcache.invalidate();\n\t\t\t},\n\t\t\thandleInput,\n\t\t\tdispose() {\n\t\t\t\tsignal?.removeEventListener(\"abort\", onAbort);\n\t\t\t},\n\t\t};\n\t};\n}\n"]}
@@ -2,21 +2,30 @@ import { Editor, Markdown, truncateToWidth, visibleWidth } from "@earendil-works
2
2
  import { getMarkdownTheme } from "../../modes/interactive/theme/theme.js";
3
3
  import { ruleBorder, wrapWithPrefix } from "./dialog-primitives.js";
4
4
  import { QUESTION_LIMITS } from "./limits.js";
5
- import { WidthCachedRender } from "./render-cache.js";
5
+ import { PreviewLinesCache, WidthCachedRender } from "./render-cache.js";
6
6
  import { displayOptions, firstUnanswered, hasAnswer, hasMultiAnswer, newQuestionState, orderedAnswers, } from "./state.js";
7
7
  const PREVIEW_MAX_LINES = 16;
8
+ /**
9
+ * Estimated non-preview rows in the stacked (narrow) layout: top/bottom rules,
10
+ * tab strip + blank, question line + blank, up to 5 option rows with
11
+ * descriptions, the "Chat about this" row, warning slot, and the hint line.
12
+ * Reserved so the preview clamps short of the terminal height.
13
+ */
8
14
  const PREVIEW_CHROME_ROWS = 18;
9
- export function createQuestionDialog(questions) {
15
+ export function createQuestionDialog(questions, signal) {
10
16
  return (tui, theme, keybindings, done) => {
11
17
  let currentIdx = 0;
12
18
  let view = "question";
13
19
  let inputMode;
14
20
  let noteTarget;
21
+ let notesSnapshot;
15
22
  let dialogFocused = false;
16
23
  let footerFocused = false;
24
+ let finished = false;
17
25
  const cache = new WidthCachedRender();
26
+ const previewCache = new PreviewLinesCache();
18
27
  const states = questions.map(() => newQuestionState());
19
- const bindings = keybindings;
28
+ const optionsByQuestion = questions.map((question) => displayOptions(question));
20
29
  const editorTheme = {
21
30
  borderColor: (s) => theme.fg("accent", s),
22
31
  selectList: {
@@ -30,8 +39,20 @@ export function createQuestionDialog(questions) {
30
39
  const editor = new Editor(tui, editorTheme);
31
40
  const currentQuestion = () => questions[currentIdx];
32
41
  const currentState = () => states[currentIdx];
33
- const keyMatches = (data, keybinding) => bindings.matches(data, keybinding);
34
- const keyLabel = (keybinding, fallback) => bindings.getKeys(keybinding).join("/") || fallback;
42
+ const currentOptions = () => optionsByQuestion[currentIdx];
43
+ const keyMatches = (data, keybinding) => keybindings.matches(data, keybinding);
44
+ const keyLabel = (keybinding, fallback) => keybindings.getKeys(keybinding).join("/") || fallback;
45
+ function finish(result) {
46
+ if (finished)
47
+ return;
48
+ finished = true;
49
+ done(result);
50
+ }
51
+ const onAbort = () => finish({ answers: orderedAnswers(questions, states), outcome: "cancelled" });
52
+ if (signal?.aborted)
53
+ onAbort();
54
+ else
55
+ signal?.addEventListener("abort", onAbort, { once: true });
35
56
  function refresh() {
36
57
  cache.invalidate();
37
58
  tui.requestRender();
@@ -44,6 +65,7 @@ export function createQuestionDialog(questions) {
44
65
  view = "question";
45
66
  inputMode = undefined;
46
67
  noteTarget = undefined;
68
+ notesSnapshot = undefined;
47
69
  footerFocused = false;
48
70
  editor.setText("");
49
71
  syncEditorFocus();
@@ -53,7 +75,7 @@ export function createQuestionDialog(questions) {
53
75
  currentState().warning = undefined;
54
76
  }
55
77
  function focusedOption() {
56
- return displayOptions(currentQuestion())[currentState().optionIndex];
78
+ return currentOptions()[currentState().optionIndex];
57
79
  }
58
80
  function showReview() {
59
81
  const missing = firstUnanswered(states);
@@ -73,7 +95,7 @@ export function createQuestionDialog(questions) {
73
95
  }
74
96
  function advanceAfterAnswer() {
75
97
  if (questions.length === 1 && !currentQuestion().multiSelect) {
76
- done({ answers: orderedAnswers(questions, states), outcome: "answered" });
98
+ finish({ answers: orderedAnswers(questions, states), outcome: "answered" });
77
99
  return;
78
100
  }
79
101
  const missing = firstUnanswered(states);
@@ -118,8 +140,10 @@ export function createQuestionDialog(questions) {
118
140
  refresh();
119
141
  return;
120
142
  }
121
- if (!question.multiSelect)
143
+ if (!question.multiSelect) {
144
+ notesSnapshot = { prior: state.singleAnswer };
122
145
  selectSingleOption(option);
146
+ }
123
147
  inputMode = "notes";
124
148
  noteTarget = option.label;
125
149
  editor.setText(state.notesByOption.get(option.label) ?? "");
@@ -148,6 +172,24 @@ export function createQuestionDialog(questions) {
148
172
  state.warning = undefined;
149
173
  advanceAfterAnswer();
150
174
  }
175
+ function toggleMultiOption(option) {
176
+ const state = currentState();
177
+ if (option.kind === "other") {
178
+ if (state.customAnswer) {
179
+ state.customAnswer.selected = !state.customAnswer.selected;
180
+ clearWarning();
181
+ }
182
+ else {
183
+ state.warning = `Press ${keyLabel("tui.input.tab", "Tab")} to type a custom answer.`;
184
+ }
185
+ return;
186
+ }
187
+ if (state.multiSelected.has(option.optionIndex))
188
+ state.multiSelected.delete(option.optionIndex);
189
+ else
190
+ state.multiSelected.add(option.optionIndex);
191
+ clearWarning();
192
+ }
151
193
  function finishReview() {
152
194
  const missing = firstUnanswered(states);
153
195
  if (missing !== undefined) {
@@ -156,7 +198,7 @@ export function createQuestionDialog(questions) {
156
198
  refresh();
157
199
  return;
158
200
  }
159
- done({ answers: orderedAnswers(questions, states), outcome: "answered" });
201
+ finish({ answers: orderedAnswers(questions, states), outcome: "answered" });
160
202
  }
161
203
  editor.onSubmit = (value) => {
162
204
  const trimmed = value.trim();
@@ -176,6 +218,7 @@ export function createQuestionDialog(questions) {
176
218
  }
177
219
  inputMode = undefined;
178
220
  noteTarget = undefined;
221
+ notesSnapshot = undefined;
179
222
  editor.setText("");
180
223
  syncEditorFocus();
181
224
  refresh();
@@ -227,8 +270,12 @@ export function createQuestionDialog(questions) {
227
270
  function handleQuestionInput(data) {
228
271
  if (inputMode) {
229
272
  if (keyMatches(data, "tui.select.cancel")) {
273
+ if (inputMode === "notes" && notesSnapshot) {
274
+ currentState().singleAnswer = notesSnapshot.prior;
275
+ }
230
276
  inputMode = undefined;
231
277
  noteTarget = undefined;
278
+ notesSnapshot = undefined;
232
279
  editor.setText("");
233
280
  syncEditorFocus();
234
281
  refresh();
@@ -251,7 +298,7 @@ export function createQuestionDialog(questions) {
251
298
  return;
252
299
  }
253
300
  const state = currentState();
254
- const options = displayOptions(currentQuestion());
301
+ const options = currentOptions();
255
302
  if (keyMatches(data, "tui.select.up")) {
256
303
  if (footerFocused) {
257
304
  footerFocused = false;
@@ -280,49 +327,55 @@ export function createQuestionDialog(questions) {
280
327
  beginNotesInput();
281
328
  return;
282
329
  }
330
+ if (data.length === 1 && data >= "1" && data <= "9") {
331
+ const index = data.charCodeAt(0) - 49;
332
+ const target = options[index];
333
+ if (target) {
334
+ footerFocused = false;
335
+ state.optionIndex = index;
336
+ clearWarning();
337
+ if (currentQuestion().multiSelect) {
338
+ toggleMultiOption(target);
339
+ refresh();
340
+ }
341
+ else {
342
+ recordSingle(target);
343
+ }
344
+ }
345
+ return;
346
+ }
283
347
  if (footerFocused) {
284
348
  if (keyMatches(data, "tui.select.confirm")) {
285
- done({ answers: orderedAnswers(questions, states), outcome: "needs_clarification" });
349
+ finish({ answers: orderedAnswers(questions, states), outcome: "needs_clarification" });
286
350
  return;
287
351
  }
288
352
  if (keyMatches(data, "tui.select.cancel")) {
289
- done({ answers: orderedAnswers(questions, states), outcome: "cancelled" });
353
+ finish({ answers: orderedAnswers(questions, states), outcome: "cancelled" });
290
354
  }
291
355
  return;
292
356
  }
293
357
  const question = currentQuestion();
294
358
  const option = options[state.optionIndex];
295
359
  if (question.multiSelect && keyMatches(data, "app.list.toggle")) {
296
- if (option?.kind === "other") {
297
- if (state.customAnswer) {
298
- state.customAnswer.selected = !state.customAnswer.selected;
299
- clearWarning();
300
- }
301
- else {
302
- state.warning = `Press ${keyLabel("tui.input.tab", "Tab")} to type a custom answer.`;
303
- }
304
- refresh();
305
- return;
306
- }
307
- if (option?.kind === "option") {
308
- if (state.multiSelected.has(option.optionIndex))
309
- state.multiSelected.delete(option.optionIndex);
310
- else
311
- state.multiSelected.add(option.optionIndex);
312
- }
313
- clearWarning();
360
+ if (option)
361
+ toggleMultiOption(option);
314
362
  refresh();
315
363
  return;
316
364
  }
317
365
  if (keyMatches(data, "tui.select.confirm")) {
318
- if (question.multiSelect)
366
+ if (question.multiSelect) {
367
+ if (option?.kind === "other" && !state.customAnswer) {
368
+ beginCustomInput();
369
+ return;
370
+ }
319
371
  recordMulti();
372
+ }
320
373
  else if (option)
321
374
  recordSingle(option);
322
375
  return;
323
376
  }
324
377
  if (keyMatches(data, "tui.select.cancel")) {
325
- done({ answers: orderedAnswers(questions, states), outcome: "cancelled" });
378
+ finish({ answers: orderedAnswers(questions, states), outcome: "cancelled" });
326
379
  }
327
380
  }
328
381
  function handleInput(data) {
@@ -356,15 +409,23 @@ export function createQuestionDialog(questions) {
356
409
  lines.push("");
357
410
  }
358
411
  function previewLines(previewText, width, terminalRows) {
359
- const markdown = new Markdown(previewText, 1, 0, getMarkdownTheme());
360
- const rendered = markdown.render(Math.max(1, width));
361
412
  const maxLines = Math.max(4, Math.min(PREVIEW_MAX_LINES, terminalRows - PREVIEW_CHROME_ROWS));
362
- if (rendered.length <= maxLines)
363
- return rendered;
364
- return [
365
- ...rendered.slice(0, maxLines),
366
- theme.fg("dim", truncateToWidth(`… ${rendered.length - maxLines} preview lines hidden`, Math.max(1, width))),
367
- ];
413
+ return previewCache.get(previewText, width, maxLines, () => {
414
+ const markdown = new Markdown(previewText, 1, 0, getMarkdownTheme());
415
+ const rendered = markdown.render(Math.max(1, width));
416
+ if (rendered.length <= maxLines)
417
+ return rendered;
418
+ return [
419
+ ...rendered.slice(0, maxLines),
420
+ theme.fg("dim", truncateToWidth(`… ${rendered.length - maxLines} preview lines hidden`, Math.max(1, width))),
421
+ ];
422
+ });
423
+ }
424
+ /** Multi-select checkbox marker; the other row shows no box until a custom answer exists. */
425
+ function multiMarker(option, checked, hasCustomAnswer) {
426
+ if (option.kind === "other" && !hasCustomAnswer)
427
+ return " ";
428
+ return checked ? theme.fg("success", "[x]") : theme.fg("dim", "[ ]");
368
429
  }
369
430
  function renderReview(renderWidth, lines) {
370
431
  wrapWithPrefix(" ", theme.fg("accent", "Review answers"), renderWidth, lines);
@@ -394,7 +455,7 @@ export function createQuestionDialog(questions) {
394
455
  }
395
456
  const question = currentQuestion();
396
457
  const state = currentState();
397
- const options = displayOptions(question);
458
+ const options = currentOptions();
398
459
  const isMulti = question.multiSelect === true;
399
460
  wrapWithPrefix(" ", `${theme.fg("accent", question.header)} ${theme.fg("text", question.question)}`, renderWidth, lines);
400
461
  lines.push("");
@@ -412,17 +473,7 @@ export function createQuestionDialog(questions) {
412
473
  (option.kind === "other"
413
474
  ? state.singleAnswer?.kind === "custom"
414
475
  : state.singleAnswer?.answer === option.label);
415
- const marker = isMulti
416
- ? option.kind === "other"
417
- ? state.customAnswer
418
- ? checked
419
- ? theme.fg("success", "[x]")
420
- : theme.fg("dim", "[ ]")
421
- : " "
422
- : checked
423
- ? theme.fg("success", "[x]")
424
- : theme.fg("dim", "[ ]")
425
- : "";
476
+ const marker = isMulti ? multiMarker(option, checked, state.customAnswer !== undefined) : "";
426
477
  const focusArrow = focused ? theme.fg("accent", "→") : " ";
427
478
  const note = option.kind === "option" && state.notesByOption.has(option.label) ? theme.fg("success", " +note") : "";
428
479
  const customText = option.kind === "other"
@@ -513,6 +564,9 @@ export function createQuestionDialog(questions) {
513
564
  cache.invalidate();
514
565
  },
515
566
  handleInput,
567
+ dispose() {
568
+ signal?.removeEventListener("abort", onAbort);
569
+ },
516
570
  };
517
571
  };
518
572
  }