@tuan_son.dinh/gsd 2.6.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 (227) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +453 -0
  3. package/dist/app-paths.d.ts +4 -0
  4. package/dist/app-paths.js +6 -0
  5. package/dist/cli.d.ts +1 -0
  6. package/dist/cli.js +269 -0
  7. package/dist/loader.d.ts +2 -0
  8. package/dist/loader.js +70 -0
  9. package/dist/logo.d.ts +16 -0
  10. package/dist/logo.js +25 -0
  11. package/dist/onboarding.d.ts +43 -0
  12. package/dist/onboarding.js +418 -0
  13. package/dist/pi-migration.d.ts +14 -0
  14. package/dist/pi-migration.js +57 -0
  15. package/dist/resource-loader.d.ts +22 -0
  16. package/dist/resource-loader.js +60 -0
  17. package/dist/tool-bootstrap.d.ts +4 -0
  18. package/dist/tool-bootstrap.js +74 -0
  19. package/dist/wizard.d.ts +7 -0
  20. package/dist/wizard.js +25 -0
  21. package/package.json +60 -0
  22. package/patches/@mariozechner+pi-coding-agent+0.57.1.patch +108 -0
  23. package/patches/@mariozechner+pi-tui+0.57.1.patch +47 -0
  24. package/pkg/dist/modes/interactive/theme/dark.json +85 -0
  25. package/pkg/dist/modes/interactive/theme/light.json +84 -0
  26. package/pkg/dist/modes/interactive/theme/theme-schema.json +335 -0
  27. package/pkg/dist/modes/interactive/theme/theme.d.ts +78 -0
  28. package/pkg/dist/modes/interactive/theme/theme.d.ts.map +1 -0
  29. package/pkg/dist/modes/interactive/theme/theme.js +949 -0
  30. package/pkg/dist/modes/interactive/theme/theme.js.map +1 -0
  31. package/pkg/package.json +8 -0
  32. package/scripts/postinstall.js +127 -0
  33. package/src/resources/GSD-WORKFLOW.md +661 -0
  34. package/src/resources/agents/researcher.md +29 -0
  35. package/src/resources/agents/scout.md +56 -0
  36. package/src/resources/agents/worker.md +31 -0
  37. package/src/resources/extensions/ask-user-questions.ts +249 -0
  38. package/src/resources/extensions/bg-shell/index.ts +2808 -0
  39. package/src/resources/extensions/browser-tools/BROWSER-TOOLS-V2-PROPOSAL.md +1277 -0
  40. package/src/resources/extensions/browser-tools/core.js +1057 -0
  41. package/src/resources/extensions/browser-tools/index.ts +4989 -0
  42. package/src/resources/extensions/browser-tools/package.json +20 -0
  43. package/src/resources/extensions/context7/index.ts +428 -0
  44. package/src/resources/extensions/context7/package.json +11 -0
  45. package/src/resources/extensions/get-secrets-from-user.ts +352 -0
  46. package/src/resources/extensions/google-search/index.ts +323 -0
  47. package/src/resources/extensions/google-search/package.json +9 -0
  48. package/src/resources/extensions/gsd/activity-log.ts +69 -0
  49. package/src/resources/extensions/gsd/auto.ts +2744 -0
  50. package/src/resources/extensions/gsd/commands.ts +313 -0
  51. package/src/resources/extensions/gsd/crash-recovery.ts +85 -0
  52. package/src/resources/extensions/gsd/dashboard-overlay.ts +521 -0
  53. package/src/resources/extensions/gsd/docs/preferences-reference.md +176 -0
  54. package/src/resources/extensions/gsd/doctor.ts +690 -0
  55. package/src/resources/extensions/gsd/files.ts +732 -0
  56. package/src/resources/extensions/gsd/git-service.ts +597 -0
  57. package/src/resources/extensions/gsd/gitignore.ts +168 -0
  58. package/src/resources/extensions/gsd/guided-flow.ts +817 -0
  59. package/src/resources/extensions/gsd/index.ts +558 -0
  60. package/src/resources/extensions/gsd/metrics.ts +374 -0
  61. package/src/resources/extensions/gsd/migrate/command.ts +218 -0
  62. package/src/resources/extensions/gsd/migrate/index.ts +42 -0
  63. package/src/resources/extensions/gsd/migrate/parser.ts +323 -0
  64. package/src/resources/extensions/gsd/migrate/parsers.ts +624 -0
  65. package/src/resources/extensions/gsd/migrate/preview.ts +48 -0
  66. package/src/resources/extensions/gsd/migrate/transformer.ts +346 -0
  67. package/src/resources/extensions/gsd/migrate/types.ts +370 -0
  68. package/src/resources/extensions/gsd/migrate/validator.ts +55 -0
  69. package/src/resources/extensions/gsd/migrate/writer.ts +539 -0
  70. package/src/resources/extensions/gsd/observability-validator.ts +408 -0
  71. package/src/resources/extensions/gsd/package.json +11 -0
  72. package/src/resources/extensions/gsd/paths.ts +308 -0
  73. package/src/resources/extensions/gsd/preferences.ts +757 -0
  74. package/src/resources/extensions/gsd/prompt-loader.ts +50 -0
  75. package/src/resources/extensions/gsd/prompts/complete-milestone.md +25 -0
  76. package/src/resources/extensions/gsd/prompts/complete-slice.md +29 -0
  77. package/src/resources/extensions/gsd/prompts/discuss.md +189 -0
  78. package/src/resources/extensions/gsd/prompts/doctor-heal.md +29 -0
  79. package/src/resources/extensions/gsd/prompts/execute-task.md +61 -0
  80. package/src/resources/extensions/gsd/prompts/guided-complete-slice.md +1 -0
  81. package/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md +3 -0
  82. package/src/resources/extensions/gsd/prompts/guided-discuss-slice.md +59 -0
  83. package/src/resources/extensions/gsd/prompts/guided-execute-task.md +1 -0
  84. package/src/resources/extensions/gsd/prompts/guided-plan-milestone.md +23 -0
  85. package/src/resources/extensions/gsd/prompts/guided-plan-slice.md +1 -0
  86. package/src/resources/extensions/gsd/prompts/guided-research-slice.md +11 -0
  87. package/src/resources/extensions/gsd/prompts/guided-resume-task.md +1 -0
  88. package/src/resources/extensions/gsd/prompts/plan-milestone.md +65 -0
  89. package/src/resources/extensions/gsd/prompts/plan-slice.md +51 -0
  90. package/src/resources/extensions/gsd/prompts/queue.md +85 -0
  91. package/src/resources/extensions/gsd/prompts/reassess-roadmap.md +48 -0
  92. package/src/resources/extensions/gsd/prompts/replan-slice.md +39 -0
  93. package/src/resources/extensions/gsd/prompts/research-milestone.md +37 -0
  94. package/src/resources/extensions/gsd/prompts/research-slice.md +28 -0
  95. package/src/resources/extensions/gsd/prompts/review-migration.md +66 -0
  96. package/src/resources/extensions/gsd/prompts/run-uat.md +109 -0
  97. package/src/resources/extensions/gsd/prompts/system.md +187 -0
  98. package/src/resources/extensions/gsd/prompts/worktree-merge.md +123 -0
  99. package/src/resources/extensions/gsd/session-forensics.ts +487 -0
  100. package/src/resources/extensions/gsd/skill-discovery.ts +137 -0
  101. package/src/resources/extensions/gsd/state.ts +460 -0
  102. package/src/resources/extensions/gsd/templates/context.md +76 -0
  103. package/src/resources/extensions/gsd/templates/decisions.md +8 -0
  104. package/src/resources/extensions/gsd/templates/milestone-summary.md +73 -0
  105. package/src/resources/extensions/gsd/templates/plan.md +131 -0
  106. package/src/resources/extensions/gsd/templates/preferences.md +24 -0
  107. package/src/resources/extensions/gsd/templates/project.md +31 -0
  108. package/src/resources/extensions/gsd/templates/reassessment.md +28 -0
  109. package/src/resources/extensions/gsd/templates/requirements.md +81 -0
  110. package/src/resources/extensions/gsd/templates/research.md +46 -0
  111. package/src/resources/extensions/gsd/templates/roadmap.md +118 -0
  112. package/src/resources/extensions/gsd/templates/slice-context.md +58 -0
  113. package/src/resources/extensions/gsd/templates/slice-summary.md +99 -0
  114. package/src/resources/extensions/gsd/templates/state.md +19 -0
  115. package/src/resources/extensions/gsd/templates/task-plan.md +52 -0
  116. package/src/resources/extensions/gsd/templates/task-summary.md +57 -0
  117. package/src/resources/extensions/gsd/templates/uat.md +54 -0
  118. package/src/resources/extensions/gsd/tests/activity-log-prune.test.ts +327 -0
  119. package/src/resources/extensions/gsd/tests/auto-preflight.test.ts +56 -0
  120. package/src/resources/extensions/gsd/tests/auto-supervisor.test.mjs +53 -0
  121. package/src/resources/extensions/gsd/tests/complete-milestone.test.ts +225 -0
  122. package/src/resources/extensions/gsd/tests/cost-projection.test.ts +160 -0
  123. package/src/resources/extensions/gsd/tests/derive-state-deps.test.ts +341 -0
  124. package/src/resources/extensions/gsd/tests/derive-state.test.ts +689 -0
  125. package/src/resources/extensions/gsd/tests/discuss-prompt.test.ts +38 -0
  126. package/src/resources/extensions/gsd/tests/doctor.test.ts +505 -0
  127. package/src/resources/extensions/gsd/tests/git-service.test.ts +1313 -0
  128. package/src/resources/extensions/gsd/tests/idle-recovery.test.ts +308 -0
  129. package/src/resources/extensions/gsd/tests/metrics-io.test.ts +201 -0
  130. package/src/resources/extensions/gsd/tests/metrics.test.ts +217 -0
  131. package/src/resources/extensions/gsd/tests/migrate-command.test.ts +390 -0
  132. package/src/resources/extensions/gsd/tests/migrate-parser.test.ts +786 -0
  133. package/src/resources/extensions/gsd/tests/migrate-transformer.test.ts +657 -0
  134. package/src/resources/extensions/gsd/tests/migrate-validator-parsers.test.ts +443 -0
  135. package/src/resources/extensions/gsd/tests/migrate-writer-integration.test.ts +318 -0
  136. package/src/resources/extensions/gsd/tests/migrate-writer.test.ts +420 -0
  137. package/src/resources/extensions/gsd/tests/must-have-parser.test.ts +309 -0
  138. package/src/resources/extensions/gsd/tests/parsers.test.ts +1351 -0
  139. package/src/resources/extensions/gsd/tests/plan-milestone.test.ts +163 -0
  140. package/src/resources/extensions/gsd/tests/plan-quality-validator.test.ts +386 -0
  141. package/src/resources/extensions/gsd/tests/reassess-prompt.test.ts +171 -0
  142. package/src/resources/extensions/gsd/tests/remote-questions.test.ts +155 -0
  143. package/src/resources/extensions/gsd/tests/remote-status.test.ts +99 -0
  144. package/src/resources/extensions/gsd/tests/replan-slice.test.ts +521 -0
  145. package/src/resources/extensions/gsd/tests/requirements.test.ts +125 -0
  146. package/src/resources/extensions/gsd/tests/resolve-ts-hooks.mjs +34 -0
  147. package/src/resources/extensions/gsd/tests/resolve-ts.mjs +11 -0
  148. package/src/resources/extensions/gsd/tests/run-uat.test.ts +348 -0
  149. package/src/resources/extensions/gsd/tests/unit-runtime.test.ts +247 -0
  150. package/src/resources/extensions/gsd/tests/workflow-config.test.mjs +53 -0
  151. package/src/resources/extensions/gsd/tests/workspace-index.test.ts +94 -0
  152. package/src/resources/extensions/gsd/tests/worktree-integration.test.ts +253 -0
  153. package/src/resources/extensions/gsd/tests/worktree-manager.test.ts +160 -0
  154. package/src/resources/extensions/gsd/tests/worktree.test.ts +264 -0
  155. package/src/resources/extensions/gsd/types.ts +159 -0
  156. package/src/resources/extensions/gsd/unit-runtime.ts +184 -0
  157. package/src/resources/extensions/gsd/workspace-index.ts +203 -0
  158. package/src/resources/extensions/gsd/worktree-command.ts +845 -0
  159. package/src/resources/extensions/gsd/worktree-manager.ts +392 -0
  160. package/src/resources/extensions/gsd/worktree.ts +183 -0
  161. package/src/resources/extensions/mac-tools/index.ts +852 -0
  162. package/src/resources/extensions/mac-tools/swift-cli/Package.swift +22 -0
  163. package/src/resources/extensions/mac-tools/swift-cli/Sources/main.swift +1318 -0
  164. package/src/resources/extensions/mcporter/index.ts +429 -0
  165. package/src/resources/extensions/remote-questions/config.ts +81 -0
  166. package/src/resources/extensions/remote-questions/discord-adapter.ts +128 -0
  167. package/src/resources/extensions/remote-questions/format.ts +163 -0
  168. package/src/resources/extensions/remote-questions/manager.ts +192 -0
  169. package/src/resources/extensions/remote-questions/remote-command.ts +307 -0
  170. package/src/resources/extensions/remote-questions/slack-adapter.ts +92 -0
  171. package/src/resources/extensions/remote-questions/status.ts +31 -0
  172. package/src/resources/extensions/remote-questions/store.ts +77 -0
  173. package/src/resources/extensions/remote-questions/types.ts +75 -0
  174. package/src/resources/extensions/search-the-web/cache.ts +78 -0
  175. package/src/resources/extensions/search-the-web/command-search-provider.ts +95 -0
  176. package/src/resources/extensions/search-the-web/format.ts +258 -0
  177. package/src/resources/extensions/search-the-web/http.ts +238 -0
  178. package/src/resources/extensions/search-the-web/index.ts +65 -0
  179. package/src/resources/extensions/search-the-web/native-search.ts +157 -0
  180. package/src/resources/extensions/search-the-web/provider.ts +118 -0
  181. package/src/resources/extensions/search-the-web/tavily.ts +116 -0
  182. package/src/resources/extensions/search-the-web/tool-fetch-page.ts +519 -0
  183. package/src/resources/extensions/search-the-web/tool-llm-context.ts +561 -0
  184. package/src/resources/extensions/search-the-web/tool-search.ts +576 -0
  185. package/src/resources/extensions/search-the-web/url-utils.ts +91 -0
  186. package/src/resources/extensions/shared/confirm-ui.ts +126 -0
  187. package/src/resources/extensions/shared/interview-ui.ts +613 -0
  188. package/src/resources/extensions/shared/next-action-ui.ts +197 -0
  189. package/src/resources/extensions/shared/progress-widget.ts +282 -0
  190. package/src/resources/extensions/shared/terminal.ts +23 -0
  191. package/src/resources/extensions/shared/thinking-widget.ts +107 -0
  192. package/src/resources/extensions/shared/ui.ts +400 -0
  193. package/src/resources/extensions/shared/wizard-ui.ts +551 -0
  194. package/src/resources/extensions/slash-commands/audit.ts +88 -0
  195. package/src/resources/extensions/slash-commands/clear.ts +10 -0
  196. package/src/resources/extensions/slash-commands/create-extension.ts +297 -0
  197. package/src/resources/extensions/slash-commands/create-slash-command.ts +234 -0
  198. package/src/resources/extensions/slash-commands/index.ts +12 -0
  199. package/src/resources/extensions/subagent/agents.ts +126 -0
  200. package/src/resources/extensions/subagent/index.ts +1020 -0
  201. package/src/resources/extensions/voice/index.ts +195 -0
  202. package/src/resources/extensions/voice/speech-recognizer.swift +154 -0
  203. package/src/resources/skills/debug-like-expert/SKILL.md +231 -0
  204. package/src/resources/skills/debug-like-expert/references/debugging-mindset.md +253 -0
  205. package/src/resources/skills/debug-like-expert/references/hypothesis-testing.md +373 -0
  206. package/src/resources/skills/debug-like-expert/references/investigation-techniques.md +337 -0
  207. package/src/resources/skills/debug-like-expert/references/verification-patterns.md +425 -0
  208. package/src/resources/skills/debug-like-expert/references/when-to-research.md +361 -0
  209. package/src/resources/skills/frontend-design/SKILL.md +45 -0
  210. package/src/resources/skills/swiftui/SKILL.md +208 -0
  211. package/src/resources/skills/swiftui/references/animations.md +921 -0
  212. package/src/resources/skills/swiftui/references/architecture.md +1561 -0
  213. package/src/resources/skills/swiftui/references/layout-system.md +1186 -0
  214. package/src/resources/skills/swiftui/references/navigation.md +1492 -0
  215. package/src/resources/skills/swiftui/references/networking-async.md +214 -0
  216. package/src/resources/skills/swiftui/references/performance.md +1706 -0
  217. package/src/resources/skills/swiftui/references/platform-integration.md +204 -0
  218. package/src/resources/skills/swiftui/references/state-management.md +1443 -0
  219. package/src/resources/skills/swiftui/references/swiftdata.md +297 -0
  220. package/src/resources/skills/swiftui/references/testing-debugging.md +247 -0
  221. package/src/resources/skills/swiftui/references/uikit-appkit-interop.md +218 -0
  222. package/src/resources/skills/swiftui/workflows/add-feature.md +191 -0
  223. package/src/resources/skills/swiftui/workflows/build-new-app.md +311 -0
  224. package/src/resources/skills/swiftui/workflows/debug-swiftui.md +192 -0
  225. package/src/resources/skills/swiftui/workflows/optimize-performance.md +197 -0
  226. package/src/resources/skills/swiftui/workflows/ship-app.md +203 -0
  227. package/src/resources/skills/swiftui/workflows/write-tests.md +235 -0
@@ -0,0 +1,613 @@
1
+ /**
2
+ * Shared interview round UI widget.
3
+ *
4
+ * Used by /interview-me and /gsd-new-project.
5
+ *
6
+ * Renders a paged, keyboard-driven question UI with:
7
+ * - Single-select (radio) questions
8
+ * - Multi-select (checkbox) questions via allowMultiple: true
9
+ * - Optional notes field (Tab to open)
10
+ * - Review screen before submitting — shows all answers, single submit button
11
+ * - Exit confirmation on Esc — "End interview?" with keep-going as default
12
+ * - focusNotes dimming: checked/committed items stay visible, others dim
13
+ *
14
+ * Navigation:
15
+ * ←/→ move between questions
16
+ * ↑/↓ move cursor within a question's options
17
+ * Enter/Space commit selection and advance
18
+ * Tab open/close notes field
19
+ * Esc exit confirmation overlay (keep-going is default)
20
+ *
21
+ * On last question, Enter advances to a review screen instead of submitting directly.
22
+ * From the review screen:
23
+ * ← back to last question
24
+ * Enter / → submit all answers
25
+ * Esc exit confirmation
26
+ */
27
+
28
+ import type { ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
29
+ import { type Theme } from "@mariozechner/pi-coding-agent";
30
+ import {
31
+ Editor,
32
+ Key,
33
+ matchesKey,
34
+ truncateToWidth,
35
+ type TUI,
36
+ } from "@mariozechner/pi-tui";
37
+ import { makeUI, INDENT } from "./ui.js";
38
+
39
+ // ─── Exported types ───────────────────────────────────────────────────────────
40
+
41
+ export interface QuestionOption {
42
+ label: string;
43
+ description: string;
44
+ }
45
+
46
+ export interface Question {
47
+ id: string;
48
+ header: string;
49
+ question: string;
50
+ options: QuestionOption[];
51
+ /** If true, user can toggle multiple options with SPACE, confirm with ENTER */
52
+ allowMultiple?: boolean;
53
+ }
54
+
55
+ export interface RoundResult {
56
+ /** Always false — end is handled by showWrapUpScreen, not per-question */
57
+ endInterview: false;
58
+ answers: Record<string, { selected: string | string[]; notes: string }>;
59
+ }
60
+
61
+ export interface WrapUpResult {
62
+ /** true = wrap up and write file, false = keep going */
63
+ satisfied: boolean;
64
+ }
65
+
66
+ // ─── Options ─────────────────────────────────────────────────────────────────
67
+
68
+ export interface InterviewRoundOptions {
69
+ /**
70
+ * Optional progress string shown in the header — e.g. "Batch 2/3 • 12 asked".
71
+ * Caller formats it however makes sense for their context.
72
+ * If omitted, no progress line is shown.
73
+ */
74
+ progress?: string;
75
+ /**
76
+ * Label for the review screen header. Defaults to "Review your answers".
77
+ */
78
+ reviewHeadline?: string;
79
+ /**
80
+ * Label for the Esc-confirm overlay header. Defaults to "End interview?".
81
+ */
82
+ exitHeadline?: string;
83
+ /**
84
+ * Text for the "exit" hint shown in the review screen footer and exit confirm overlay.
85
+ * Defaults to "end interview".
86
+ */
87
+ exitLabel?: string;
88
+ }
89
+
90
+ export interface WrapUpOptions {
91
+ /**
92
+ * Optional progress string shown below the headline — e.g. "12 questions answered so far".
93
+ * Caller formats it however makes sense for their context.
94
+ * If omitted, no progress line is shown.
95
+ */
96
+ progress?: string;
97
+ /** Caller-specific text for the wrap-up screen headline */
98
+ headline: string;
99
+ /** Label for the "keep going" option (shown first — safe default) */
100
+ keepGoingLabel: string;
101
+ /** Label for the "I'm satisfied" option (shown second) */
102
+ satisfiedLabel: string;
103
+ }
104
+
105
+ // ─── Constants ────────────────────────────────────────────────────────────────
106
+
107
+ const OTHER_OPTION_LABEL = "None of the above";
108
+ const OTHER_OPTION_DESCRIPTION = "Optionally, add details in notes below.";
109
+
110
+ // ─── Wrap-up screen ───────────────────────────────────────────────────────────
111
+
112
+ export async function showWrapUpScreen(
113
+ opts: WrapUpOptions,
114
+ ctx: ExtensionCommandContext,
115
+ ): Promise<WrapUpResult> {
116
+ return ctx.ui.custom<WrapUpResult>((tui: TUI, theme: Theme, _kb, done) => {
117
+ // 0 = "Keep going", 1 = "I'm satisfied" — default to satisfied (1)
118
+ let cursorIdx = 1;
119
+ let cachedLines: string[] | undefined;
120
+
121
+ function refresh() {
122
+ cachedLines = undefined;
123
+ tui.requestRender();
124
+ }
125
+
126
+ function handleInput(data: string) {
127
+ if (matchesKey(data, Key.up) || matchesKey(data, Key.left)) { cursorIdx = 1; refresh(); return; }
128
+ if (matchesKey(data, Key.down) || matchesKey(data, Key.right)) { cursorIdx = 0; refresh(); return; }
129
+ if (data === "1") { done({ satisfied: true }); return; }
130
+ if (data === "2") { done({ satisfied: false }); return; }
131
+ // Esc = "keep going" (the safe/non-destructive default)
132
+ if (matchesKey(data, Key.escape)) { done({ satisfied: false }); return; }
133
+ if (matchesKey(data, Key.enter) || matchesKey(data, Key.space)) {
134
+ done({ satisfied: cursorIdx === 1 });
135
+ return;
136
+ }
137
+ }
138
+
139
+ function render(width: number): string[] {
140
+ if (cachedLines) return cachedLines;
141
+ const ui = makeUI(theme, width);
142
+ const lines: string[] = [];
143
+ const push = (...rows: string[][]) => { for (const r of rows) lines.push(...r); };
144
+
145
+ push(ui.bar(), ui.blank(), ui.header(` ${opts.headline}`), ui.blank());
146
+ if (opts.progress) push(ui.meta(` ${opts.progress}`), ui.blank());
147
+
148
+ if (cursorIdx === 1) {
149
+ push(ui.actionSelected(1, opts.satisfiedLabel, "Wrap up now and generate the output."));
150
+ } else {
151
+ push(ui.actionUnselected(1, opts.satisfiedLabel, "Wrap up now and generate the output."));
152
+ }
153
+ push(ui.blank());
154
+ if (cursorIdx === 0) {
155
+ push(ui.actionSelected(2, opts.keepGoingLabel, "Continue with another batch of questions."));
156
+ } else {
157
+ push(ui.actionUnselected(2, opts.keepGoingLabel, "Continue with another batch of questions."));
158
+ }
159
+ push(
160
+ ui.blank(),
161
+ ui.hints(["↑/↓ to choose", "1/2 to quick-select", "enter to confirm"]),
162
+ ui.bar(),
163
+ );
164
+
165
+ cachedLines = lines;
166
+ return lines;
167
+ }
168
+
169
+ return {
170
+ render,
171
+ invalidate: () => { cachedLines = undefined; },
172
+ handleInput,
173
+ };
174
+ });
175
+ }
176
+
177
+ // ─── Interview round ──────────────────────────────────────────────────────────
178
+
179
+ export async function showInterviewRound(
180
+ questions: Question[],
181
+ opts: InterviewRoundOptions,
182
+ ctx: ExtensionCommandContext,
183
+ ): Promise<RoundResult> {
184
+ return ctx.ui.custom<RoundResult>((tui: TUI, theme: Theme, _kb, done) => {
185
+
186
+ interface QuestionState {
187
+ cursorIndex: number;
188
+ committedIndex: number | null;
189
+ checkedIndices: Set<number>;
190
+ notes: string;
191
+ notesVisible: boolean;
192
+ }
193
+
194
+ const states: QuestionState[] = questions.map(() => ({
195
+ cursorIndex: 0,
196
+ committedIndex: null,
197
+ checkedIndices: new Set(),
198
+ notes: "",
199
+ notesVisible: false,
200
+ }));
201
+
202
+ const isMultiQuestion = questions.length > 1;
203
+ let currentIdx = 0;
204
+ let focusNotes = false;
205
+ let showingReview = false;
206
+ let showingExitConfirm = false;
207
+ let exitCursor = 0; // 0 = keep going (default), 1 = end interview
208
+ let cachedLines: string[] | undefined;
209
+
210
+ // Editor is created once; editorTheme comes from the design system
211
+ const editorRef = { current: null as Editor | null };
212
+
213
+ function getEditor(): Editor {
214
+ if (!editorRef.current) {
215
+ editorRef.current = new Editor(tui, makeUI(theme, 80).editorTheme);
216
+ }
217
+ return editorRef.current;
218
+ }
219
+
220
+ function refresh() {
221
+ cachedLines = undefined;
222
+ tui.requestRender();
223
+ }
224
+
225
+ function isMultiSelect(qIdx: number): boolean {
226
+ return !!questions[qIdx].allowMultiple;
227
+ }
228
+
229
+ function totalOpts(qIdx: number): number {
230
+ return questions[qIdx].options.length + 1;
231
+ }
232
+
233
+ function noneOrDoneIdx(qIdx: number): number {
234
+ return questions[qIdx].options.length;
235
+ }
236
+
237
+ function saveEditorToState() {
238
+ states[currentIdx].notes = getEditor().getText().trim();
239
+ }
240
+
241
+ function loadStateToEditor() {
242
+ getEditor().setText(states[currentIdx].notes);
243
+ }
244
+
245
+ function isQuestionAnswered(idx: number): boolean {
246
+ if (isMultiSelect(idx)) return states[idx].checkedIndices.size > 0;
247
+ return states[idx].committedIndex !== null;
248
+ }
249
+
250
+ function allAnswered(): boolean {
251
+ return questions.every((_, i) => isQuestionAnswered(i));
252
+ }
253
+
254
+ function switchQuestion(newIdx: number) {
255
+ if (newIdx === currentIdx) return;
256
+ saveEditorToState();
257
+ currentIdx = newIdx;
258
+ loadStateToEditor();
259
+ focusNotes = states[currentIdx].notesVisible && states[currentIdx].notes.length > 0;
260
+ refresh();
261
+ }
262
+
263
+ function buildResult(): RoundResult {
264
+ const answers: Record<string, { selected: string | string[]; notes: string }> = {};
265
+ for (let i = 0; i < questions.length; i++) {
266
+ const q = questions[i];
267
+ const st = states[i];
268
+ const notes = st.notes.trim();
269
+
270
+ if (isMultiSelect(i)) {
271
+ const sorted = Array.from(st.checkedIndices).sort((a, b) => a - b);
272
+ const selected = sorted.map((idx) => q.options[idx].label);
273
+ if (selected.length > 0 || notes) answers[q.id] = { selected, notes };
274
+ } else {
275
+ if (st.committedIndex === null && !notes) continue;
276
+ let selected = OTHER_OPTION_LABEL;
277
+ if (st.committedIndex !== null) {
278
+ const idx = st.committedIndex;
279
+ if (idx < q.options.length) selected = q.options[idx].label;
280
+ else if (idx === noneOrDoneIdx(i)) selected = OTHER_OPTION_LABEL;
281
+ }
282
+ answers[q.id] = { selected, notes };
283
+ }
284
+ }
285
+ return { endInterview: false, answers };
286
+ }
287
+
288
+ function submit() {
289
+ saveEditorToState();
290
+ done(buildResult());
291
+ }
292
+
293
+ function goNextOrSubmit() {
294
+ if (!isMultiSelect(currentIdx)) {
295
+ states[currentIdx].committedIndex = states[currentIdx].cursorIndex;
296
+ }
297
+
298
+ if (isMultiQuestion && currentIdx < questions.length - 1) {
299
+ let next = currentIdx + 1;
300
+ for (let i = 0; i < questions.length; i++) {
301
+ const candidate = (currentIdx + 1 + i) % questions.length;
302
+ if (!isQuestionAnswered(candidate)) { next = candidate; break; }
303
+ }
304
+ switchQuestion(next);
305
+ } else if (allAnswered()) {
306
+ saveEditorToState();
307
+ showingReview = true;
308
+ refresh();
309
+ }
310
+ }
311
+
312
+ // ── Input handler ────────────────────────────────────────────────────
313
+
314
+ function handleInput(data: string) {
315
+ // ── Exit confirmation overlay ──────────────────────────────────
316
+ if (showingExitConfirm) {
317
+ if (matchesKey(data, Key.up) || matchesKey(data, Key.left)) { exitCursor = 0; refresh(); return; }
318
+ if (matchesKey(data, Key.down) || matchesKey(data, Key.right)) { exitCursor = 1; refresh(); return; }
319
+ if (data === "1") { showingExitConfirm = false; refresh(); return; }
320
+ if (data === "2") { done({ endInterview: false, answers: {} }); return; }
321
+ if (matchesKey(data, Key.enter) || matchesKey(data, Key.space)) {
322
+ if (exitCursor === 0) { showingExitConfirm = false; refresh(); }
323
+ else { done({ endInterview: false, answers: {} }); }
324
+ return;
325
+ }
326
+ if (matchesKey(data, Key.escape)) { showingExitConfirm = false; refresh(); return; }
327
+ return;
328
+ }
329
+
330
+ // ── Review screen ────────────────────────────────────────────
331
+ if (showingReview) {
332
+ if (matchesKey(data, Key.escape) || matchesKey(data, Key.left)) {
333
+ showingReview = false;
334
+ switchQuestion(questions.length - 1);
335
+ return;
336
+ }
337
+ if (matchesKey(data, Key.enter) || matchesKey(data, Key.right) || matchesKey(data, Key.space)) {
338
+ submit();
339
+ return;
340
+ }
341
+ return;
342
+ }
343
+
344
+ const st = states[currentIdx];
345
+ const optCount = totalOpts(currentIdx);
346
+ const multiSel = isMultiSelect(currentIdx);
347
+
348
+ // ── Esc → exit confirmation ──────────────────────────────────
349
+ if (matchesKey(data, Key.escape)) {
350
+ if (focusNotes) {
351
+ saveEditorToState();
352
+ focusNotes = false;
353
+ st.notesVisible = st.notes.length > 0;
354
+ refresh();
355
+ } else {
356
+ showingExitConfirm = true;
357
+ exitCursor = 0;
358
+ refresh();
359
+ }
360
+ return;
361
+ }
362
+
363
+ // ── Notes mode ───────────────────────────────────────────────
364
+ if (focusNotes) {
365
+ if (matchesKey(data, Key.tab)) {
366
+ saveEditorToState();
367
+ focusNotes = false;
368
+ st.notesVisible = st.notes.length > 0;
369
+ refresh();
370
+ return;
371
+ }
372
+ if (matchesKey(data, Key.enter)) {
373
+ saveEditorToState();
374
+ focusNotes = false;
375
+ if (!multiSel && st.committedIndex === null) st.committedIndex = noneOrDoneIdx(currentIdx);
376
+ goNextOrSubmit();
377
+ return;
378
+ }
379
+ getEditor().handleInput(data);
380
+ refresh();
381
+ return;
382
+ }
383
+
384
+ // ── Multi-question navigation ────────────────────────────────
385
+ if (isMultiQuestion) {
386
+ if (matchesKey(data, Key.left)) { switchQuestion((currentIdx - 1 + questions.length) % questions.length); return; }
387
+ if (matchesKey(data, Key.right)) { switchQuestion((currentIdx + 1) % questions.length); return; }
388
+ }
389
+
390
+ // ── Cursor navigation ────────────────────────────────────────
391
+ if (matchesKey(data, Key.up)) { st.cursorIndex = (st.cursorIndex - 1 + optCount) % optCount; refresh(); return; }
392
+ if (matchesKey(data, Key.down)) { st.cursorIndex = (st.cursorIndex + 1) % optCount; refresh(); return; }
393
+
394
+ if (multiSel) {
395
+ const doneI = noneOrDoneIdx(currentIdx);
396
+ if (matchesKey(data, Key.space)) {
397
+ if (st.cursorIndex < doneI) {
398
+ if (st.checkedIndices.has(st.cursorIndex)) st.checkedIndices.delete(st.cursorIndex);
399
+ else st.checkedIndices.add(st.cursorIndex);
400
+ refresh();
401
+ }
402
+ return;
403
+ }
404
+ if (matchesKey(data, Key.enter)) { goNextOrSubmit(); return; }
405
+ if (matchesKey(data, Key.tab)) { st.notesVisible = true; focusNotes = true; loadStateToEditor(); refresh(); return; }
406
+ } else {
407
+ if (data.length === 1 && data >= "1" && data <= "9") {
408
+ const idx = parseInt(data, 10) - 1;
409
+ if (idx < optCount) { st.cursorIndex = idx; st.committedIndex = idx; goNextOrSubmit(); return; }
410
+ }
411
+ if (matchesKey(data, Key.space)) { st.committedIndex = st.cursorIndex; refresh(); return; }
412
+ if (matchesKey(data, Key.tab)) { st.notesVisible = true; focusNotes = true; loadStateToEditor(); refresh(); return; }
413
+ if (matchesKey(data, Key.enter)) { goNextOrSubmit(); return; }
414
+ }
415
+ }
416
+
417
+ // ── Review screen ────────────────────────────────────────────────
418
+
419
+ function renderReviewScreen(width: number): string[] {
420
+ const ui = makeUI(theme, width);
421
+ const lines: string[] = [];
422
+ const push = (...rows: string[][]) => { for (const r of rows) lines.push(...r); };
423
+
424
+ push(ui.bar(), ui.blank(), ui.header(` ${opts.reviewHeadline ?? "Review your answers"}`), ui.blank());
425
+
426
+ for (let i = 0; i < questions.length; i++) {
427
+ const q = questions[i];
428
+ const st = states[i];
429
+
430
+ push(ui.subtitle(` ${q.question}`));
431
+
432
+ if (isMultiSelect(i)) {
433
+ const selected = Array.from(st.checkedIndices).sort((a, b) => a - b).map((idx) => q.options[idx].label);
434
+ for (const label of selected) push(ui.answer(` ${INDENT.cursor}${label}`));
435
+ } else {
436
+ let label = OTHER_OPTION_LABEL;
437
+ if (st.committedIndex !== null && st.committedIndex < q.options.length) {
438
+ label = q.options[st.committedIndex].label;
439
+ }
440
+ push(ui.answer(` ${INDENT.cursor}${label}`));
441
+ }
442
+
443
+ if (st.notes) push(ui.note(`${INDENT.note}note: ${st.notes}`));
444
+ push(ui.blank());
445
+ }
446
+
447
+ push(
448
+ ui.actionSelected(0, "Submit answers"),
449
+ ui.blank(),
450
+ ui.hints(["← to go back and edit", "enter to submit", `esc to ${opts.exitLabel ?? "end interview"}`]),
451
+ ui.bar(),
452
+ );
453
+
454
+ return lines;
455
+ }
456
+
457
+ // ── Exit confirm screen ──────────────────────────────────────────
458
+
459
+ function renderExitConfirm(width: number): string[] {
460
+ const ui = makeUI(theme, width);
461
+ const lines: string[] = [];
462
+ const push = (...rows: string[][]) => { for (const r of rows) lines.push(...r); };
463
+
464
+ push(
465
+ ui.bar(),
466
+ ui.blank(),
467
+ ui.header(` ${opts.exitHeadline ?? "End interview?"}`),
468
+ ui.blank(),
469
+ ui.subtitle(" Answers from this batch won't be saved."),
470
+ ui.blank(),
471
+ );
472
+
473
+ const keepGoingLabel = "Keep going";
474
+ const exitActionLabel = opts.exitLabel
475
+ ? opts.exitLabel.charAt(0).toUpperCase() + opts.exitLabel.slice(1)
476
+ : "End interview";
477
+ if (exitCursor === 0) {
478
+ push(ui.actionSelected(1, keepGoingLabel, "Return and keep going."));
479
+ } else {
480
+ push(ui.actionUnselected(1, keepGoingLabel, "Return and keep going."));
481
+ }
482
+ push(ui.blank());
483
+ if (exitCursor === 1) {
484
+ push(ui.actionSelected(2, exitActionLabel, "Exit and discard this batch of answers."));
485
+ } else {
486
+ push(ui.actionUnselected(2, exitActionLabel, "Exit and discard this batch of answers."));
487
+ }
488
+ push(
489
+ ui.blank(),
490
+ ui.hints(["↑/↓ to choose", "1/2 to quick-select", "enter to confirm"]),
491
+ ui.bar(),
492
+ );
493
+
494
+ return lines;
495
+ }
496
+
497
+ // ── Main render ──────────────────────────────────────────────────
498
+
499
+ function render(width: number): string[] {
500
+ if (cachedLines) return cachedLines;
501
+
502
+ if (showingExitConfirm) { cachedLines = renderExitConfirm(width); return cachedLines; }
503
+ if (showingReview) { cachedLines = renderReviewScreen(width); return cachedLines; }
504
+
505
+ const ui = makeUI(theme, width);
506
+ const lines: string[] = [];
507
+ const push = (...rows: string[][]) => { for (const r of rows) lines.push(...r); };
508
+
509
+ const q = questions[currentIdx];
510
+ const st = states[currentIdx];
511
+ const multiSel = isMultiSelect(currentIdx);
512
+
513
+ push(ui.bar());
514
+
515
+ // ── Progress header ────────────────────────────────────────────
516
+ if (isMultiQuestion) {
517
+ const unanswered = questions.filter((_, i) => !isQuestionAnswered(i)).length;
518
+ const answeredSet = new Set(questions.map((_, i) => i).filter(i => isQuestionAnswered(i)));
519
+ push(ui.questionTabs(questions.map(q => q.header), currentIdx, answeredSet));
520
+ push(ui.blank());
521
+ const progressParts = [
522
+ opts.progress,
523
+ `Question ${currentIdx + 1}/${questions.length}`,
524
+ unanswered > 0 ? `${unanswered} unanswered` : null,
525
+ ].filter(Boolean).join(" • ");
526
+ if (progressParts) push(ui.meta(` ${progressParts}`));
527
+ push(ui.blank());
528
+ } else {
529
+ if (opts.progress) push(ui.meta(` ${opts.progress}`), ui.blank());
530
+ }
531
+
532
+ // ── Question text ──────────────────────────────────────────────
533
+ push(ui.question(` ${q.question}`));
534
+ if (multiSel) push(ui.meta(" (Select all that apply)"));
535
+ push(ui.blank());
536
+
537
+ // ── Options ───────────────────────────────────────────────────
538
+ for (let i = 0; i < q.options.length; i++) {
539
+ const opt = q.options[i];
540
+ const isCursor = i === st.cursorIndex;
541
+
542
+ if (multiSel) {
543
+ const isChecked = st.checkedIndices.has(i);
544
+ if (isCursor && !focusNotes) push(ui.checkboxSelected(opt.label, opt.description, isChecked));
545
+ else push(ui.checkboxUnselected(opt.label, opt.description, isChecked, focusNotes));
546
+ } else {
547
+ const isCommitted = i === st.committedIndex;
548
+ if (isCursor && !focusNotes) {
549
+ push(ui.optionSelected(i + 1, opt.label, opt.description, isCommitted));
550
+ } else {
551
+ push(ui.optionUnselected(i + 1, opt.label, opt.description, { isCommitted, isFocusDimmed: focusNotes }));
552
+ }
553
+ }
554
+ }
555
+
556
+ // ── None / Done slot ───────────────────────────────────────────
557
+ const ndIdx = noneOrDoneIdx(currentIdx);
558
+ const ndCursor = ndIdx === st.cursorIndex;
559
+
560
+ if (multiSel) {
561
+ push(ui.blank());
562
+ if (ndCursor && !focusNotes) push(ui.doneSelected());
563
+ else push(ui.doneUnselected());
564
+ } else {
565
+ const ndCommitted = ndIdx === st.committedIndex;
566
+ if (ndCursor && !focusNotes) {
567
+ push(ui.slotSelected(OTHER_OPTION_LABEL, OTHER_OPTION_DESCRIPTION, ndCommitted));
568
+ } else {
569
+ push(ui.slotUnselected(OTHER_OPTION_LABEL, OTHER_OPTION_DESCRIPTION, { isCommitted: ndCommitted, isFocusDimmed: focusNotes }));
570
+ }
571
+ }
572
+
573
+ // ── Notes area ─────────────────────────────────────────────────
574
+ if (st.notesVisible || focusNotes) {
575
+ push(ui.blank(), ui.notesLabel(focusNotes));
576
+ if (focusNotes) {
577
+ for (const line of getEditor().render(width - 2)) lines.push(truncateToWidth(` ${line}`, width));
578
+ } else if (st.notes) {
579
+ push(ui.notesText(st.notes));
580
+ }
581
+ }
582
+
583
+ // ── Footer hints ───────────────────────────────────────────────
584
+ push(ui.blank());
585
+ const isLast = !isMultiQuestion || currentIdx === questions.length - 1;
586
+ const hints: string[] = [];
587
+ if (focusNotes) {
588
+ hints.push("enter to confirm");
589
+ hints.push("tab or esc to close notes");
590
+ } else if (multiSel) {
591
+ hints.push("space to toggle");
592
+ if (isMultiQuestion) hints.push("←/→ navigate questions");
593
+ hints.push("tab to add notes");
594
+ hints.push(isLast && allAnswered() ? "enter to review" : "enter to next");
595
+ } else {
596
+ if (st.committedIndex !== null || !isMultiQuestion) hints.push("tab to add notes");
597
+ if (isMultiQuestion) hints.push("←/→ navigate");
598
+ hints.push(isLast && allAnswered() ? "enter to review" : "enter to next");
599
+ }
600
+ hints.push("esc to exit");
601
+ push(ui.hints(hints), ui.bar());
602
+
603
+ cachedLines = lines;
604
+ return lines;
605
+ }
606
+
607
+ return {
608
+ render,
609
+ invalidate: () => { cachedLines = undefined; },
610
+ handleInput,
611
+ };
612
+ });
613
+ }