@selesai/code 0.8.4 → 0.8.6

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 (147) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +7 -1
  3. package/dist/core/slash-commands.js +1 -0
  4. package/dist/defaults/models.json +46 -55
  5. package/dist/defaults/settings.json +7 -8
  6. package/dist/extensions/agent-browser.test.ts +178 -2
  7. package/dist/extensions/agent-browser.ts +2 -0
  8. package/dist/extensions/copy-turn.test.ts +229 -0
  9. package/dist/extensions/copy-turn.ts +11 -0
  10. package/dist/extensions/grep-app/index.test.ts +356 -1
  11. package/dist/extensions/grep-app/index.ts +6 -1
  12. package/dist/extensions/handoff-new.test.ts +351 -161
  13. package/dist/extensions/handoff-new.ts +3 -0
  14. package/dist/extensions/inline-skills.test.ts +93 -0
  15. package/dist/extensions/inline-skills.ts +3 -0
  16. package/dist/extensions/pi-subagents/CHANGELOG.md +26 -3
  17. package/dist/extensions/pi-subagents/README.md +2 -0
  18. package/dist/extensions/pi-subagents/agents/builder.md +5 -2
  19. package/dist/extensions/pi-subagents/agents/commentator.md +1 -1
  20. package/dist/extensions/pi-subagents/docs/configuration.md +54 -2
  21. package/dist/extensions/pi-subagents/docs/observability.md +1 -1
  22. package/dist/extensions/pi-subagents/docs/tool-reference.md +1 -1
  23. package/dist/extensions/pi-subagents/docs/workflows.md +1 -1
  24. package/dist/extensions/pi-subagents/package-lock.json +2 -2
  25. package/dist/extensions/pi-subagents/package.json +1 -1
  26. package/dist/extensions/pi-subagents/skills/pi-subagents/references/execution-controls.md +1 -1
  27. package/dist/extensions/pi-subagents/src/agents/agents.ts +9 -3
  28. package/dist/extensions/pi-subagents/src/extension/config.ts +12 -0
  29. package/dist/extensions/pi-subagents/src/extension/doctor.ts +40 -0
  30. package/dist/extensions/pi-subagents/src/extension/index.ts +104 -14
  31. package/dist/extensions/pi-subagents/src/extension/public-execution.ts +5 -0
  32. package/dist/extensions/pi-subagents/src/extension/rpc.ts +3 -9
  33. package/dist/extensions/pi-subagents/src/extension/schemas.ts +2 -2
  34. package/dist/extensions/pi-subagents/src/intercom/intercom-bridge.ts +4 -1
  35. package/dist/extensions/pi-subagents/src/missions/lifecycle.ts +4 -7
  36. package/dist/extensions/pi-subagents/src/missions/store.ts +4 -4
  37. package/dist/extensions/pi-subagents/src/missions/workflow-state.ts +6 -2
  38. package/dist/extensions/pi-subagents/src/runs/background/active-async-capacity.ts +374 -0
  39. package/dist/extensions/pi-subagents/src/runs/background/active-run-index.ts +9 -5
  40. package/dist/extensions/pi-subagents/src/runs/background/async-execution.ts +117 -29
  41. package/dist/extensions/pi-subagents/src/runs/background/async-job-tracker.ts +12 -6
  42. package/dist/extensions/pi-subagents/src/runs/background/async-resume.ts +7 -1
  43. package/dist/extensions/pi-subagents/src/runs/background/async-status.ts +14 -5
  44. package/dist/extensions/pi-subagents/src/runs/background/chain-append.ts +33 -15
  45. package/dist/extensions/pi-subagents/src/runs/background/notify.ts +5 -2
  46. package/dist/extensions/pi-subagents/src/runs/background/owned-process-tree.ts +104 -0
  47. package/dist/extensions/pi-subagents/src/runs/background/process-terminal.ts +17 -3
  48. package/dist/extensions/pi-subagents/src/runs/background/result-watcher.ts +19 -1
  49. package/dist/extensions/pi-subagents/src/runs/background/retained-children.ts +2 -2
  50. package/dist/extensions/pi-subagents/src/runs/background/run-id-resolver.ts +5 -2
  51. package/dist/extensions/pi-subagents/src/runs/background/run-status.ts +12 -6
  52. package/dist/extensions/pi-subagents/src/runs/background/stale-run-reconciler.ts +3 -3
  53. package/dist/extensions/pi-subagents/src/runs/background/subagent-runner.ts +84 -36
  54. package/dist/extensions/pi-subagents/src/runs/background/wait-subscriptions.ts +2 -1
  55. package/dist/extensions/pi-subagents/src/runs/foreground/chain-execution.ts +37 -2
  56. package/dist/extensions/pi-subagents/src/runs/foreground/execution.ts +135 -22
  57. package/dist/extensions/pi-subagents/src/runs/foreground/foreground-control.ts +12 -0
  58. package/dist/extensions/pi-subagents/src/runs/foreground/foreground-history.ts +7 -4
  59. package/dist/extensions/pi-subagents/src/runs/foreground/prompt-audit.ts +171 -0
  60. package/dist/extensions/pi-subagents/src/runs/foreground/subagent-executor.ts +653 -196
  61. package/dist/extensions/pi-subagents/src/runs/shared/acceptance.ts +13 -4
  62. package/dist/extensions/pi-subagents/src/runs/shared/llm-intent-arbiter.ts +286 -0
  63. package/dist/extensions/pi-subagents/src/runs/shared/model-fallback.ts +23 -0
  64. package/dist/extensions/pi-subagents/src/runs/shared/parallel-utils.ts +2 -0
  65. package/dist/extensions/pi-subagents/src/runs/shared/pi-args.ts +44 -1
  66. package/dist/extensions/pi-subagents/src/runs/shared/run-fanout-budget.ts +280 -0
  67. package/dist/extensions/pi-subagents/src/runs/shared/single-output.ts +4 -2
  68. package/dist/extensions/pi-subagents/src/runs/shared/task-intent.ts +19 -3
  69. package/dist/extensions/pi-subagents/src/runs/shared/worktree.ts +17 -5
  70. package/dist/extensions/pi-subagents/src/shared/session-lineage.ts +71 -0
  71. package/dist/extensions/pi-subagents/src/shared/types.ts +108 -0
  72. package/dist/extensions/pi-subagents/src/shared/utils.ts +3 -1
  73. package/dist/extensions/pi-subagents/src/tui/fleet-status.ts +7 -5
  74. package/dist/extensions/pi-subagents/src/tui/fleet.ts +226 -13
  75. package/dist/extensions/pi-subagents/src/workflows/scripted-workflow.ts +22 -5
  76. package/dist/extensions/pi-subagents/src/workflows/workflow-auto-relaunch.ts +28 -0
  77. package/dist/extensions/pi-subagents/test/integration/acceptance-file-report.test.ts +87 -0
  78. package/dist/extensions/pi-subagents/test/integration/async-execution.test.ts +96 -6
  79. package/dist/extensions/pi-subagents/test/integration/async-status.test.ts +28 -5
  80. package/dist/extensions/pi-subagents/test/integration/chain-execution.test.ts +8 -5
  81. package/dist/extensions/pi-subagents/test/integration/fork-context-execution.test.ts +83 -2
  82. package/dist/extensions/pi-subagents/test/integration/intercom-result-delivery.test.ts +211 -15
  83. package/dist/extensions/pi-subagents/test/integration/parallel-execution.test.ts +41 -3
  84. package/dist/extensions/pi-subagents/test/integration/single-execution.test.ts +314 -10
  85. package/dist/extensions/pi-subagents/test/unit/acceptance.test.ts +31 -1
  86. package/dist/extensions/pi-subagents/test/unit/active-async-capacity.test.ts +317 -0
  87. package/dist/extensions/pi-subagents/test/unit/agent-overrides.test.ts +21 -2
  88. package/dist/extensions/pi-subagents/test/unit/async-recovery-descriptor.test.ts +16 -1
  89. package/dist/extensions/pi-subagents/test/unit/async-resume.test.ts +6 -1
  90. package/dist/extensions/pi-subagents/test/unit/chain-append.test.ts +75 -4
  91. package/dist/extensions/pi-subagents/test/unit/completion-guard.test.ts +18 -0
  92. package/dist/extensions/pi-subagents/test/unit/doctor.test.ts +29 -1
  93. package/dist/extensions/pi-subagents/test/unit/fleet-status.test.ts +31 -5
  94. package/dist/extensions/pi-subagents/test/unit/fleet.test.ts +139 -1
  95. package/dist/extensions/pi-subagents/test/unit/foreground-control.test.ts +10 -0
  96. package/dist/extensions/pi-subagents/test/unit/handoff-adoption.test.ts +103 -0
  97. package/dist/extensions/pi-subagents/test/unit/index-child-registration.test.ts +22 -0
  98. package/dist/extensions/pi-subagents/test/unit/intercom-bridge.test.ts +2 -1
  99. package/dist/extensions/pi-subagents/test/unit/llm-intent-arbiter.test.ts +171 -0
  100. package/dist/extensions/pi-subagents/test/unit/mission-lifecycle.test.ts +30 -4
  101. package/dist/extensions/pi-subagents/test/unit/model-fallback.test.ts +14 -0
  102. package/dist/extensions/pi-subagents/test/unit/owned-process-tree.test.ts +69 -0
  103. package/dist/extensions/pi-subagents/test/unit/pi-args.test.ts +83 -0
  104. package/dist/extensions/pi-subagents/test/unit/pi-coding-agent-dir.test.ts +2 -2
  105. package/dist/extensions/pi-subagents/test/unit/process-terminal.test.ts +55 -2
  106. package/dist/extensions/pi-subagents/test/unit/public-execution.test.ts +12 -1
  107. package/dist/extensions/pi-subagents/test/unit/rpc.test.ts +7 -7
  108. package/dist/extensions/pi-subagents/test/unit/run-fanout-budget.test.ts +121 -0
  109. package/dist/extensions/pi-subagents/test/unit/run-status.test.ts +9 -0
  110. package/dist/extensions/pi-subagents/test/unit/schemas.test.ts +4 -3
  111. package/dist/extensions/pi-subagents/test/unit/session-lineage.test.ts +73 -0
  112. package/dist/extensions/pi-subagents/test/unit/single-output.test.ts +28 -0
  113. package/dist/extensions/pi-subagents/test/unit/steering-action.test.ts +10 -1
  114. package/dist/extensions/pi-subagents/test/unit/task-intent.test.ts +39 -0
  115. package/dist/extensions/pi-subagents/test/unit/timeout-defaults.test.ts +65 -0
  116. package/dist/extensions/pi-subagents/test/unit/workflow-auto-relaunch.test.ts +45 -0
  117. package/dist/extensions/pi-subagents/test/unit/worktree.test.ts +61 -0
  118. package/dist/extensions/ponytail/index.js +11 -0
  119. package/dist/extensions/ponytail/ponytail-config.cjs +2 -0
  120. package/dist/extensions/ponytail/ponytail-instructions.cjs +6 -0
  121. package/dist/extensions/ponytail/test/extension.test.js +274 -140
  122. package/dist/extensions/ponytail/test/helpers.test.js +280 -92
  123. package/dist/extensions/question/index.ts +18 -0
  124. package/dist/extensions/question/question-list.ts +22 -0
  125. package/dist/extensions/question/tests/batch.test.ts +103 -65
  126. package/dist/extensions/question/tests/helpers.test.ts +68 -35
  127. package/dist/extensions/question/tests/question-list.test.ts +506 -204
  128. package/dist/extensions/question/tests/row-layout.test.ts +180 -114
  129. package/dist/extensions/question/tests/schemas.test.ts +42 -19
  130. package/dist/extensions/question/tests/shortcuts.test.ts +106 -84
  131. package/dist/extensions/question/tests/wizard.test.ts +771 -50
  132. package/dist/extensions/rtk.test.ts +180 -1
  133. package/dist/extensions/tokenin-onboarding.ts +3 -0
  134. package/dist/extensions/undo.test.ts +720 -0
  135. package/dist/extensions/undo.ts +6 -0
  136. package/dist/extensions/web-agent-onboarding.test.ts +415 -0
  137. package/dist/extensions/workflow/extension.ts +3 -3
  138. package/dist/extensions/workflow/modes.ts +41 -19
  139. package/dist/modes/interactive/interactive-mode.d.ts +1 -0
  140. package/dist/modes/interactive/interactive-mode.js +48 -1
  141. package/dist/skills/pi-subagents/references/execution-controls.md +1 -1
  142. package/docs/plans/workflow-autoloop-reference.md +1 -2
  143. package/docs/plans/workflow-handoff-carryover.md +108 -0
  144. package/docs/quickstart.md +27 -43
  145. package/docs/settings.md +4 -0
  146. package/docs/usage.md +1 -0
  147. package/package.json +5 -1
@@ -1,5 +1,4 @@
1
- import test from "node:test";
2
- import assert from "node:assert/strict";
1
+ import { describe, expect, it } from "vitest";
3
2
 
4
3
  import questionExtension from "../index.ts";
5
4
 
@@ -44,59 +43,781 @@ function type(component: any, text: string) {
44
43
  for (const character of text) component.handleInput(character);
45
44
  }
46
45
 
47
- test("wizard submits typed selections and text without review comments", async () => {
48
- const { component, execution } = await runWizard({
49
- questions: [
50
- { id: "choice", type: "select", question: "Choose", options: [{ value: "one", label: "One" }] },
51
- { id: "many", type: "multiselect", question: "Choose many", options: [{ value: "a", label: "A" }, { value: "b", label: "B" }] },
52
- { id: "note", type: "text", question: "Explain" },
53
- ],
54
- });
55
- component().handleInput(ENTER); // choice commits and advances
56
- component().handleInput(" ");
57
- component().handleInput(DOWN);
58
- component().handleInput(" ");
59
- component().handleInput(ENTER); // multi-select commits and advances
60
- type(component(), "details");
61
- component().handleInput(ENTER); // text commits and advances to review
62
- assert.doesNotMatch(component().render(120).join("\n"), /Optional comment/);
63
- component().handleInput(ENTER);
64
-
65
- const result = await execution;
66
- assert.deepEqual(result.details, {
67
- status: "submitted",
68
- answers: [
69
- { id: "choice", status: "answered", response: { kind: "selection", values: ["one"] } },
70
- { id: "many", status: "answered", response: { kind: "selection", values: ["a", "b"] } },
71
- { id: "note", status: "answered", response: { kind: "text", text: "details" } },
72
- ],
46
+ describe("question wizard", () => {
47
+ it("submits typed selections and text without review comments", async () => {
48
+ const { component, execution } = await runWizard({
49
+ questions: [
50
+ { id: "choice", type: "select", question: "Choose", options: [{ value: "one", label: "One" }] },
51
+ { id: "many", type: "multiselect", question: "Choose many", options: [{ value: "a", label: "A" }, { value: "b", label: "B" }] },
52
+ { id: "note", type: "text", question: "Explain" },
53
+ ],
54
+ });
55
+ component().handleInput(ENTER); // choice commits and advances
56
+ component().handleInput(" ");
57
+ component().handleInput(DOWN);
58
+ component().handleInput(" ");
59
+ component().handleInput(ENTER); // multi-select commits and advances
60
+ type(component(), "details");
61
+ component().handleInput(ENTER); // text commits and advances to review
62
+ expect(component().render(120).join("\n")).not.toMatch(/Optional comment/);
63
+ component().handleInput(ENTER);
64
+
65
+ const result = await execution;
66
+ expect(result.details).toEqual({
67
+ status: "submitted",
68
+ answers: [
69
+ { id: "choice", status: "answered", response: { kind: "selection", values: ["one"] } },
70
+ { id: "many", status: "answered", response: { kind: "selection", values: ["a", "b"] } },
71
+ { id: "note", status: "answered", response: { kind: "text", text: "details" } },
72
+ ],
73
+ });
74
+ });
75
+
76
+ it("preserves skipped, Other, and cancelled outcomes", async () => {
77
+ const skipped = await runWizard({ questions: [{ type: "select", question: "Skip", options: [{ value: "x", label: "X" }] }] });
78
+ skipped.component().handleInput(TAB);
79
+ skipped.component().handleInput(ENTER);
80
+ expect((await skipped.execution).details).toEqual({ status: "submitted", answers: [{ id: "q1", status: "skipped" }] });
81
+
82
+ const other = await runWizard({ questions: [{ type: "select", question: "Other", options: [{ value: "x", label: "X" }], allowOther: true }] });
83
+ other.component().handleInput(DOWN);
84
+ other.component().handleInput(ENTER);
85
+ type(other.component(), "custom");
86
+ other.component().handleInput(ENTER); // one answered question submits immediately
87
+ expect((await other.execution).details).toEqual({
88
+ status: "submitted",
89
+ answers: [{ id: "q1", status: "answered", response: { kind: "selection", values: [], otherText: "custom" } }],
90
+ });
91
+
92
+ const selected = await runWizard({ questions: [{ type: "select", question: "Select", options: [{ value: "x", label: "X" }] }] });
93
+ selected.component().handleInput(ENTER);
94
+ expect((await selected.execution).details).toEqual({
95
+ status: "submitted",
96
+ answers: [{ id: "q1", status: "answered", response: { kind: "selection", values: ["x"] } }],
97
+ });
98
+
99
+ const cancelled = await runWizard({ questions: [{ type: "select", question: "Cancel", options: [{ value: "x", label: "X" }] }] });
100
+ cancelled.component().handleInput(ESC);
101
+ expect((await cancelled.execution).details).toEqual({ status: "cancelled", reason: "user" });
102
+ });
103
+
104
+ it("navigates back to edit answers and re-submits", async () => {
105
+ const { component, execution } = await runWizard({
106
+ questions: [
107
+ { type: "select", question: "One", options: [{ value: "a", label: "A" }, { value: "b", label: "B" }] },
108
+ { type: "text", question: "Two" },
109
+ ],
110
+ });
111
+ component().handleInput(ENTER); // select "a", advance
112
+ type(component(), "answer");
113
+ component().handleInput(ENTER); // text commits, advance to review
114
+ component().handleInput("\x1b[D"); // left = back to q2 (text)
115
+ component().handleInput(ESC); // blur the text editor
116
+ component().handleInput(SHIFT_TAB); // back to q1
117
+ component().handleInput(DOWN); // move to "b"
118
+ component().handleInput(ENTER); // commit "b", advance to q2
119
+ component().handleInput(ENTER); // commit q2 text (restored "answer"), advance to review
120
+ component().handleInput(ENTER); // submit
121
+
122
+ const result = await execution;
123
+ expect(result.details).toEqual({
124
+ status: "submitted",
125
+ answers: [
126
+ { id: "q1", status: "answered", response: { kind: "selection", values: ["b"] } },
127
+ { id: "q2", status: "answered", response: { kind: "text", text: "answer" } },
128
+ ],
129
+ });
130
+ });
131
+
132
+ it("text question escape blurs, enter re-focuses, escape again cancels", async () => {
133
+ const { component, execution } = await runWizard({
134
+ questions: [{ type: "text", question: "Type" }],
135
+ });
136
+ component().handleInput(ESC); // blur editor
137
+ component().handleInput(ENTER); // re-focus
138
+ type(component(), "typed");
139
+ component().handleInput(ENTER); // commit + submit (single question)
140
+ const result = await execution;
141
+ expect(result.details).toEqual({
142
+ status: "submitted",
143
+ answers: [{ id: "q1", status: "answered", response: { kind: "text", text: "typed" } }],
144
+ });
145
+ });
146
+
147
+ it("cancels from a text question via escape when not editing", async () => {
148
+ const { component, execution } = await runWizard({
149
+ questions: [{ type: "text", question: "Type" }],
150
+ });
151
+ component().handleInput(ESC); // blur editor
152
+ component().handleInput(ESC); // cancel
153
+ expect((await execution).details).toEqual({ status: "cancelled", reason: "user" });
154
+ });
155
+
156
+ it("emits question:cancelled and question:submitted events", async () => {
157
+ const emitted: string[] = [];
158
+ let tool: any;
159
+ let component: any;
160
+ let done!: (value: unknown) => void;
161
+ questionExtension({
162
+ registerTool: (entry: unknown) => (tool = entry),
163
+ events: { emit: (name: string) => emitted.push(name) },
164
+ } as any);
165
+ const execution = tool.execute("call", { questions: [{ type: "select", question: "Q", options: [{ value: "x", label: "X" }] }] }, undefined, undefined, {
166
+ mode: "tui",
167
+ ui: {
168
+ custom: (factory: (...args: any[]) => unknown) => new Promise((resolve) => {
169
+ done = resolve;
170
+ component = factory(tui, theme, keybindings, done);
171
+ }),
172
+ },
173
+ } as any);
174
+ component.handleInput(ESC);
175
+ await execution;
176
+ expect(emitted).toEqual(["question:cancelled"]);
177
+
178
+ const emitted2: string[] = [];
179
+ let tool2: any;
180
+ let component2: any;
181
+ let done2!: (value: unknown) => void;
182
+ questionExtension({
183
+ registerTool: (entry: unknown) => (tool2 = entry),
184
+ events: { emit: (name: string) => emitted2.push(name) },
185
+ } as any);
186
+ const execution2 = tool2.execute("call", { questions: [{ type: "select", question: "Q", options: [{ value: "x", label: "X" }] }] }, undefined, undefined, {
187
+ mode: "tui",
188
+ ui: {
189
+ custom: (factory: (...args: any[]) => unknown) => new Promise((resolve) => {
190
+ done2 = resolve;
191
+ component2 = factory(tui, theme, keybindings, done2);
192
+ }),
193
+ },
194
+ } as any);
195
+ component2.handleInput(ENTER);
196
+ await execution2;
197
+ expect(emitted2).toEqual(["question:submitted"]);
198
+ });
199
+
200
+ it("rejects non-TUI modes and invalid params", async () => {
201
+ let tool: any;
202
+ questionExtension({ registerTool: (entry: unknown) => (tool = entry), events: { emit() {} } } as any);
203
+
204
+ await expect(tool.execute("call", { questions: [{ type: "select", question: "Q", options: [{ value: "x", label: "X" }] }] }, undefined, undefined, {
205
+ mode: "rpc",
206
+ } as any)).rejects.toThrow("Question requires terminal TUI mode.");
207
+
208
+ await expect(tool.execute("call", { questions: [] }, undefined, undefined, {
209
+ mode: "tui",
210
+ } as any)).rejects.toThrow("questions must contain at least one question.");
211
+ });
212
+
213
+ it("returns cancelled when the signal is already aborted", async () => {
214
+ let tool: any;
215
+ questionExtension({ registerTool: (entry: unknown) => (tool = entry), events: { emit() {} } } as any);
216
+ const signal = { aborted: true, reason: new Error("cancelled") };
217
+ const result = await tool.execute("call", { questions: [{ type: "select", question: "Q", options: [{ value: "x", label: "X" }] }] }, signal, undefined, {
218
+ mode: "tui",
219
+ } as any);
220
+ expect(result.details).toEqual({ status: "cancelled", reason: "user" });
221
+ });
222
+
223
+ it("throws when the TUI custom factory returns no result", async () => {
224
+ let tool: any;
225
+ questionExtension({ registerTool: (entry: unknown) => (tool = entry), events: { emit() {} } } as any);
226
+ await expect(tool.execute("call", { questions: [{ type: "select", question: "Q", options: [{ value: "x", label: "X" }] }] }, undefined, undefined, {
227
+ mode: "tui",
228
+ ui: { custom: async () => undefined },
229
+ } as any)).rejects.toThrow("Question TUI did not return a result.");
73
230
  });
74
- });
75
231
 
76
- test("wizard preserves skipped, Other, and cancelled outcomes", async () => {
77
- const skipped = await runWizard({ questions: [{ type: "select", question: "Skip", options: [{ value: "x", label: "X" }] }] });
78
- skipped.component().handleInput(TAB);
79
- skipped.component().handleInput(ENTER);
80
- assert.deepEqual((await skipped.execution).details, { status: "submitted", answers: [{ id: "q1", status: "skipped" }] });
232
+ it("renderCall and renderResult produce summary text", async () => {
233
+ let tool: any;
234
+ questionExtension({ registerTool: (entry: unknown) => (tool = entry), events: { emit() {} } } as any);
81
235
 
82
- const other = await runWizard({ questions: [{ type: "select", question: "Other", options: [{ value: "x", label: "X" }], allowOther: true }] });
83
- other.component().handleInput(DOWN);
84
- other.component().handleInput(ENTER);
85
- type(other.component(), "custom");
86
- other.component().handleInput(ENTER); // one answered question submits immediately
87
- assert.deepEqual((await other.execution).details, {
88
- status: "submitted",
89
- answers: [{ id: "q1", status: "answered", response: { kind: "selection", values: [], otherText: "custom" } }],
236
+ const call = tool.renderCall({ questions: [{ id: "a", label: "Alpha", type: "select", question: "Q", options: [{ value: "x", label: "X" }] }] }, theme);
237
+ expect(call.render(80).join("\n")).toContain("QUESTION");
238
+ expect(call.render(80).join("\n")).toContain("Alpha");
239
+
240
+ const result = {
241
+ content: [{ type: "text", text: "waiting" }],
242
+ details: {
243
+ status: "submitted",
244
+ answers: [
245
+ { id: "a", status: "answered", response: { kind: "selection", values: ["x"] } },
246
+ { id: "b", status: "skipped" },
247
+ { id: "c", status: "unanswered" },
248
+ ],
249
+ },
250
+ };
251
+ const partial = tool.renderResult(result, { isPartial: true }, theme, {});
252
+ expect(partial.render(80).join("\n")).toContain("waiting");
253
+
254
+ const full = tool.renderResult(result, { isPartial: false, expanded: true }, theme, {
255
+ args: { questions: [{ id: "a", label: "Alpha", type: "select", question: "Q", options: [{ value: "x", label: "X" }] }] },
256
+ });
257
+ const fullText = full.render(80).join("\n");
258
+ expect(fullText).toContain("submitted");
259
+ expect(fullText).toContain("1 answered");
260
+ expect(fullText).toContain("X"); // option label from the expanded answer row
261
+
262
+ const cancelled = tool.renderResult({ content: [], details: { status: "cancelled", reason: "user" } }, { isPartial: false }, theme, {});
263
+ expect(cancelled.render(80).join("\n")).toContain("question cancelled");
264
+ });
265
+
266
+ it("renderResult expanded maps unknown ids to raw values", async () => {
267
+ let tool: any;
268
+ questionExtension({ registerTool: (entry: unknown) => (tool = entry), events: { emit() {} } } as any);
269
+ const result = {
270
+ content: [],
271
+ details: {
272
+ status: "submitted",
273
+ answers: [
274
+ { id: "q1", status: "answered", response: { kind: "selection", values: ["x"] } },
275
+ ],
276
+ },
277
+ };
278
+ const rendered = tool.renderResult(result, { isPartial: false, expanded: true }, theme, { args: { questions: [] } });
279
+ expect(rendered.render(80).join("\n")).toContain("q1");
280
+ });
281
+
282
+ it("navigates with Tab/Shift+Tab and skips blank answers", async () => {
283
+ const { component, execution } = await runWizard({
284
+ questions: [
285
+ { type: "select", question: "One", options: [{ value: "a", label: "A" }] },
286
+ { type: "select", question: "Two", options: [{ value: "b", label: "B" }] },
287
+ ],
288
+ });
289
+ component().handleInput(TAB); // skip q1
290
+ component().handleInput(ENTER); // answer q2
291
+ component().handleInput("\x1b[D"); // left: back to q2 from review
292
+ component().handleInput(SHIFT_TAB); // back to q1
293
+ component().handleInput(ENTER); // answer q1
294
+ component().handleInput(ENTER); // advance to review
295
+ component().handleInput(ENTER); // submit
296
+ const result = await execution;
297
+ expect(result.details).toEqual({
298
+ status: "submitted",
299
+ answers: [
300
+ { id: "q1", status: "answered", response: { kind: "selection", values: ["a"] } },
301
+ { id: "q2", status: "answered", response: { kind: "selection", values: ["b"] } },
302
+ ],
303
+ });
304
+ });
305
+
306
+ it("renders narrow borders and context text", async () => {
307
+ const { component } = await runWizard({
308
+ questions: [
309
+ { type: "select", question: "Q", context: "Some context", options: [{ value: "a", label: "A" }] },
310
+ ],
311
+ });
312
+ const lines = component().render(4);
313
+ expect(lines.length).toBeGreaterThan(0);
314
+ expect(lines.join("\n")).toContain("╭");
315
+ expect(lines.join("\n")).toContain("╰");
316
+ });
317
+
318
+ it("restores a previous selection response with otherText", async () => {
319
+ const { component, execution } = await runWizard({
320
+ questions: [
321
+ { type: "select", question: "Q", options: [{ value: "a", label: "A" }], allowOther: true },
322
+ ],
323
+ });
324
+ // Answer with Other, then navigate back and verify the draft is restored.
325
+ component().handleInput(DOWN); // to freeform
326
+ component().handleInput(ENTER); // open other editor
327
+ type(component(), "custom answer");
328
+ component().handleInput(ENTER); // commit + submit (single question)
329
+ const result = await execution;
330
+ expect(result.details).toEqual({
331
+ status: "submitted",
332
+ answers: [{ id: "q1", status: "answered", response: { kind: "selection", values: [], otherText: "custom answer" } }],
333
+ });
334
+ });
335
+
336
+ it("review page renders answered, skipped, and unanswered states", async () => {
337
+ const { component, execution } = await runWizard({
338
+ questions: [
339
+ { type: "select", question: "One", options: [{ value: "a", label: "A" }] },
340
+ { type: "select", question: "Two", options: [{ value: "b", label: "B" }] },
341
+ { type: "select", question: "Three", options: [{ value: "c", label: "C" }] },
342
+ ],
343
+ });
344
+ component().handleInput(ENTER); // answer q1
345
+ component().handleInput(TAB); // skip q2
346
+ component().handleInput(ENTER); // answer q3 → review
347
+ const lines = component().render(120).join("\n");
348
+ expect(lines).toContain("✓");
349
+ expect(lines).toContain("−");
350
+ expect(lines).toContain("Submit");
351
+ component().handleInput(ENTER); // submit
352
+ const result = await execution;
353
+ expect(result.details.answers).toHaveLength(3);
90
354
  });
91
355
 
92
- const selected = await runWizard({ questions: [{ type: "select", question: "Select", options: [{ value: "x", label: "X" }] }] });
93
- selected.component().handleInput(ENTER);
94
- assert.deepEqual((await selected.execution).details, {
95
- status: "submitted",
96
- answers: [{ id: "q1", status: "answered", response: { kind: "selection", values: ["x"] } }],
356
+ it("review page truncates long answer lists", async () => {
357
+ const questions = Array.from({ length: 30 }, (_, i) => ({
358
+ type: "select" as const,
359
+ question: `Question ${i}`,
360
+ options: [{ value: `v${i}`, label: `V${i}` }],
361
+ }));
362
+ const { component, execution } = await runWizard({ questions });
363
+ for (let i = 0; i < 30; i++) {
364
+ component().handleInput(ENTER);
365
+ }
366
+ const lines = component().render(120).join("\n");
367
+ expect(lines).toContain("↑ earlier answers");
368
+ component().handleInput(ENTER); // submit
369
+ const result = await execution;
370
+ expect(result.details.answers).toHaveLength(30);
97
371
  });
98
372
 
99
- const cancelled = await runWizard({ questions: [{ type: "select", question: "Cancel", options: [{ value: "x", label: "X" }] }] });
100
- cancelled.component().handleInput(ESC);
101
- assert.deepEqual((await cancelled.execution).details, { status: "cancelled", reason: "user" });
373
+ it("review page cancels on escape and goes back on left", async () => {
374
+ const { component, execution } = await runWizard({
375
+ questions: [
376
+ { type: "select", question: "One", options: [{ value: "a", label: "A" }] },
377
+ { type: "select", question: "Two", options: [{ value: "b", label: "B" }] },
378
+ ],
379
+ });
380
+ component().handleInput(ENTER);
381
+ component().handleInput(ENTER); // → review
382
+ component().handleInput(ESC); // cancel
383
+ expect((await execution).details).toEqual({ status: "cancelled", reason: "user" });
384
+
385
+ const w2 = await runWizard({
386
+ questions: [
387
+ { type: "select", question: "One", options: [{ value: "a", label: "A" }] },
388
+ { type: "select", question: "Two", options: [{ value: "b", label: "B" }] },
389
+ ],
390
+ });
391
+ w2.component().handleInput(ENTER);
392
+ w2.component().handleInput(ENTER); // → review
393
+ w2.component().handleInput("\x1b[D"); // left → back to q2
394
+ w2.component().handleInput(ENTER); // commit q2
395
+ w2.component().handleInput(ENTER); // → review
396
+ w2.component().handleInput(ENTER); // submit
397
+ expect((await w2.execution).details.answers).toHaveLength(2);
398
+ });
399
+
400
+ it("focused setter propagates to the question component", async () => {
401
+ const { component } = await runWizard({
402
+ questions: [{ type: "select", question: "Q", options: [{ value: "a", label: "A" }] }],
403
+ });
404
+ component().focused = true;
405
+ expect(component().focused).toBe(true);
406
+ });
407
+
408
+ it("onUpdate reports waiting for answers", async () => {
409
+ let tool: any;
410
+ let component: any;
411
+ let done!: (value: unknown) => void;
412
+ const updates: any[] = [];
413
+ questionExtension({ registerTool: (entry: unknown) => (tool = entry), events: { emit() {} } } as any);
414
+ const execution = tool.execute("call", { questions: [{ type: "select", question: "Q", options: [{ value: "a", label: "A" }] }] }, undefined, (u: any) => updates.push(u), {
415
+ mode: "tui",
416
+ ui: {
417
+ custom: (factory: (...args: any[]) => unknown) => new Promise((resolve) => {
418
+ done = resolve;
419
+ component = factory(tui, theme, keybindings, done);
420
+ }),
421
+ },
422
+ } as any);
423
+ expect(updates).toHaveLength(1);
424
+ expect(updates[0].content[0].text).toContain("Waiting for answers to 1 question");
425
+ component.handleInput(ENTER);
426
+ await execution;
427
+ });
428
+
429
+ it("signal abort during the wizard resolves cancelled", async () => {
430
+ let tool: any;
431
+ let component: any;
432
+ let done!: (value: unknown) => void;
433
+ const controller = new AbortController();
434
+ questionExtension({ registerTool: (entry: unknown) => (tool = entry), events: { emit() {} } } as any);
435
+ const execution = tool.execute("call", { questions: [{ type: "select", question: "Q", options: [{ value: "a", label: "A" }] }] }, controller.signal, undefined, {
436
+ mode: "tui",
437
+ ui: {
438
+ custom: (factory: (...args: any[]) => unknown) => new Promise((resolve) => {
439
+ done = resolve;
440
+ component = factory(tui, theme, keybindings, done);
441
+ }),
442
+ },
443
+ } as any);
444
+ controller.abort();
445
+ const result = await execution;
446
+ expect(result.details).toEqual({ status: "cancelled", reason: "user" });
447
+ });
448
+
449
+ it("restores a text response draft when navigating back", async () => {
450
+ const { component, execution } = await runWizard({
451
+ questions: [
452
+ { type: "text", question: "One" },
453
+ { type: "select", question: "Two", options: [{ value: "b", label: "B" }] },
454
+ ],
455
+ });
456
+ type(component(), "draft answer");
457
+ component().handleInput(ENTER); // commit q1
458
+ component().handleInput(ENTER); // answer q2 → review
459
+ component().handleInput("\x1b[D"); // left → back to q2
460
+ component().handleInput(SHIFT_TAB); // back to q1
461
+ // The text draft should be restored.
462
+ component().handleInput(ENTER); // commit q1 (restored draft)
463
+ component().handleInput(ENTER); // commit q2
464
+ component().handleInput(ENTER); // submit
465
+ const result = await execution;
466
+ expect(result.details.answers[0]).toEqual({ id: "q1", status: "answered", response: { kind: "text", text: "draft answer" } });
467
+ });
468
+
469
+ it("restores a selection response with otherText when navigating back", async () => {
470
+ const { component, execution } = await runWizard({
471
+ questions: [
472
+ { type: "select", question: "One", options: [{ value: "a", label: "A" }], allowOther: true },
473
+ { type: "select", question: "Two", options: [{ value: "b", label: "B" }] },
474
+ ],
475
+ });
476
+ component().handleInput(DOWN); // to freeform
477
+ component().handleInput(ENTER); // open other editor
478
+ type(component(), "custom");
479
+ component().handleInput(ENTER); // commit q1 → q2
480
+ component().handleInput(ENTER); // answer q2 → review
481
+ component().handleInput("\x1b[D"); // left → back to q2
482
+ component().handleInput(SHIFT_TAB); // back to q1
483
+ // The other draft should be restored in the editor.
484
+ component().handleInput(ENTER); // commit q1 (restored other)
485
+ component().handleInput(ENTER); // commit q2
486
+ component().handleInput(ENTER); // submit
487
+ const result = await execution;
488
+ expect(result.details.answers[0]).toEqual({ id: "q1", status: "answered", response: { kind: "selection", values: [], otherText: "custom" } });
489
+ });
490
+
491
+ it("focused setter propagates to the review component", async () => {
492
+ const { component } = await runWizard({
493
+ questions: [
494
+ { type: "select", question: "One", options: [{ value: "a", label: "A" }] },
495
+ { type: "select", question: "Two", options: [{ value: "b", label: "B" }] },
496
+ ],
497
+ });
498
+ component().handleInput(ENTER);
499
+ component().handleInput(ENTER); // → review
500
+ component().focused = true;
501
+ expect(component().focused).toBe(true);
502
+ });
503
+
504
+ it("renderResult expanded maps unknown ids to raw values", async () => {
505
+ let tool: any;
506
+ questionExtension({ registerTool: (entry: unknown) => (tool = entry), events: { emit() {} } } as any);
507
+ const result = {
508
+ content: [],
509
+ details: {
510
+ status: "submitted",
511
+ answers: [
512
+ { id: "q1", status: "answered", response: { kind: "selection", values: ["x"] } },
513
+ ],
514
+ },
515
+ };
516
+ const rendered = tool.renderResult(result, { isPartial: false, expanded: true }, theme, { args: { questions: [] } });
517
+ expect(rendered.render(80).join("\n")).toContain("q1");
518
+ });
519
+
520
+ it("renderResult expanded maps known ids to labels and Other", async () => {
521
+ let tool: any;
522
+ questionExtension({ registerTool: (entry: unknown) => (tool = entry), events: { emit() {} } } as any);
523
+ const result = {
524
+ content: [],
525
+ details: {
526
+ status: "submitted",
527
+ answers: [
528
+ { id: "a", status: "answered", response: { kind: "selection", values: ["x"], otherText: "extra" } },
529
+ { id: "b", status: "skipped" },
530
+ { id: "c", status: "unanswered" },
531
+ ],
532
+ },
533
+ };
534
+ const rendered = tool.renderResult(result, { isPartial: false, expanded: true }, theme, {
535
+ args: { questions: [{ id: "a", label: "Alpha", type: "select", question: "Q", options: [{ value: "x", label: "X" }] }] },
536
+ });
537
+ const text = rendered.render(80).join("\n");
538
+ expect(text).toContain("X"); // option label mapped from the question
539
+ expect(text).toContain("Other: extra");
540
+ expect(text).toContain("skipped");
541
+ expect(text).toContain("unanswered");
542
+ });
543
+
544
+ it("renderResult expanded maps unknown option values to raw values", async () => {
545
+ let tool: any;
546
+ questionExtension({ registerTool: (entry: unknown) => (tool = entry), events: { emit() {} } } as any);
547
+ const result = {
548
+ content: [],
549
+ details: {
550
+ status: "submitted",
551
+ answers: [
552
+ { id: "a", status: "answered", response: { kind: "selection", values: ["unknown-value"] } },
553
+ ],
554
+ },
555
+ };
556
+ const rendered = tool.renderResult(result, { isPartial: false, expanded: true }, theme, {
557
+ args: { questions: [{ id: "a", label: "Alpha", type: "select", question: "Q", options: [{ value: "x", label: "X" }] }] },
558
+ });
559
+ expect(rendered.render(80).join("\n")).toContain("unknown-value");
560
+ });
561
+
562
+ it("renders wide borders with the full label", async () => {
563
+ const { component } = await runWizard({
564
+ questions: [
565
+ { type: "select", question: "Q", options: [{ value: "a", label: "A" }] },
566
+ ],
567
+ });
568
+ const lines = component().render(120).join("\n");
569
+ expect(lines).toContain("╭─ question");
570
+ expect(lines).toContain("v2");
571
+ });
572
+
573
+ it("renders the text editor theme when answering a text question", async () => {
574
+ const { component, execution } = await runWizard({
575
+ questions: [{ type: "text", question: "Type" }],
576
+ });
577
+ // Render the text editor to exercise editorTheme.
578
+ component().render(120);
579
+ type(component(), "answer");
580
+ component().handleInput(ENTER);
581
+ const result = await execution;
582
+ expect(result.details.answers[0].response).toEqual({ kind: "text", text: "answer" });
583
+ });
584
+
585
+ it("escape from other mode returns to select and preserves the draft", async () => {
586
+ const { component, execution } = await runWizard({
587
+ questions: [
588
+ { type: "select", question: "One", options: [{ value: "a", label: "A" }], allowOther: true },
589
+ { type: "select", question: "Two", options: [{ value: "b", label: "B" }] },
590
+ ],
591
+ });
592
+ component().handleInput(DOWN); // to freeform
593
+ component().handleInput(ENTER); // open other editor
594
+ type(component(), "draft");
595
+ component().handleInput(ESC); // escape back to select mode
596
+ component().handleInput("\x0b"); // ctrl+k = vim up to the option row
597
+ component().handleInput(ENTER); // commit q1 (selection)
598
+ component().handleInput(ENTER); // answer q2 → review
599
+ component().handleInput(ENTER); // submit
600
+ const result = await execution;
601
+ expect(result.details.answers[0]).toEqual({ id: "q1", status: "answered", response: { kind: "selection", values: ["a"] } });
602
+ });
603
+
604
+ it("reads the review component focused state", async () => {
605
+ const { component } = await runWizard({
606
+ questions: [
607
+ { type: "select", question: "One", options: [{ value: "a", label: "A" }] },
608
+ { type: "select", question: "Two", options: [{ value: "b", label: "B" }] },
609
+ ],
610
+ });
611
+ component().handleInput(ENTER);
612
+ component().handleInput(ENTER); // → review
613
+ component().focused = true;
614
+ expect(component().focused).toBe(true);
615
+ });
616
+
617
+ it("reads the question component focused state", async () => {
618
+ const { component } = await runWizard({
619
+ questions: [{ type: "select", question: "Q", options: [{ value: "a", label: "A" }] }],
620
+ });
621
+ expect(component().focused).toBe(false);
622
+ component().focused = true;
623
+ expect(component().focused).toBe(true);
624
+ });
625
+
626
+ it("renderCall handles missing labels and ids", async () => {
627
+ let tool: any;
628
+ questionExtension({ registerTool: (entry: unknown) => (tool = entry), events: { emit() {} } } as any);
629
+
630
+ const withId = tool.renderCall({ questions: [{ id: "q1", type: "select", question: "Q", options: [{ value: "a", label: "A" }] }] }, theme);
631
+ expect(withId.render(80).join("\n")).toContain("q1");
632
+
633
+ const withIndex = tool.renderCall({ questions: [{ type: "select", question: "Q", options: [{ value: "a", label: "A" }] }] }, theme);
634
+ expect(withIndex.render(80).join("\n")).toContain("Q1");
635
+
636
+ const noQuestions = tool.renderCall({}, theme);
637
+ expect(noQuestions.render(80).join("\n")).toContain("0 questions");
638
+ });
639
+
640
+ it("renderResult partial with no text content falls back", async () => {
641
+ let tool: any;
642
+ questionExtension({ registerTool: (entry: unknown) => (tool = entry), events: { emit() {} } } as any);
643
+ const rendered = tool.renderResult({ content: [] }, { isPartial: true }, theme, {});
644
+ expect(rendered.render(80).join("\n")).toContain("Waiting for user input");
645
+ });
646
+
647
+ it("renderResult non-expanded omits answer details", async () => {
648
+ let tool: any;
649
+ questionExtension({ registerTool: (entry: unknown) => (tool = entry), events: { emit() {} } } as any);
650
+ const result = {
651
+ content: [],
652
+ details: {
653
+ status: "submitted",
654
+ answers: [{ id: "a", status: "answered", response: { kind: "text", text: "x" } }],
655
+ },
656
+ };
657
+ const rendered = tool.renderResult(result, { isPartial: false }, theme, {});
658
+ expect(rendered.render(80).join("\n")).toContain("submitted");
659
+ });
660
+
661
+ it("renderResult expanded with text answers uses raw text", async () => {
662
+ let tool: any;
663
+ questionExtension({ registerTool: (entry: unknown) => (tool = entry), events: { emit() {} } } as any);
664
+ const result = {
665
+ content: [],
666
+ details: {
667
+ status: "submitted",
668
+ answers: [{ id: "a", status: "answered", response: { kind: "text", text: "plain text" } }],
669
+ },
670
+ };
671
+ const rendered = tool.renderResult(result, { isPartial: false, expanded: true }, theme, {
672
+ args: { questions: [{ id: "a", label: "Alpha", type: "text", question: "Q" }] },
673
+ });
674
+ expect(rendered.render(80).join("\n")).toContain("plain text");
675
+ });
676
+
677
+ it("renderResult expanded with missing question falls back to raw answer", async () => {
678
+ let tool: any;
679
+ questionExtension({ registerTool: (entry: unknown) => (tool = entry), events: { emit() {} } } as any);
680
+ const result = {
681
+ content: [],
682
+ details: {
683
+ status: "submitted",
684
+ answers: [{ id: "a", status: "answered", response: { kind: "selection", values: ["x"] } }],
685
+ },
686
+ };
687
+ const rendered = tool.renderResult(result, { isPartial: false, expanded: true }, theme, { args: {} });
688
+ expect(rendered.render(80).join("\n")).toContain("x");
689
+ });
690
+
691
+ it("renderResult expanded matches questions by generated ids", async () => {
692
+ let tool: any;
693
+ questionExtension({ registerTool: (entry: unknown) => (tool = entry), events: { emit() {} } } as any);
694
+ const result = {
695
+ content: [],
696
+ details: {
697
+ status: "submitted",
698
+ answers: [{ id: "q1", status: "answered", response: { kind: "selection", values: ["x"] } }],
699
+ },
700
+ };
701
+ const rendered = tool.renderResult(result, { isPartial: false, expanded: true }, theme, {
702
+ args: { questions: [{ type: "select", question: "Q", options: [{ value: "x", label: "X" }] }] },
703
+ });
704
+ expect(rendered.render(80).join("\n")).toContain("X");
705
+ });
706
+
707
+ it("reads the question component focused getter", async () => {
708
+ const { component } = await runWizard({
709
+ questions: [{ type: "select", question: "Q", options: [{ value: "a", label: "A" }] }],
710
+ });
711
+ component().focused = true;
712
+ expect(component().focused).toBe(true);
713
+ });
714
+
715
+ it("reads the review component focused getter", async () => {
716
+ const { component } = await runWizard({
717
+ questions: [
718
+ { type: "select", question: "One", options: [{ value: "a", label: "A" }] },
719
+ { type: "select", question: "Two", options: [{ value: "b", label: "B" }] },
720
+ ],
721
+ });
722
+ component().handleInput(ENTER);
723
+ component().handleInput(ENTER); // → review
724
+ component().focused = true;
725
+ expect(component().focused).toBe(true);
726
+ });
727
+
728
+ it("renders the text editor to exercise the editor theme", async () => {
729
+ const { component, execution } = await runWizard({
730
+ questions: [{ type: "text", question: "Type" }],
731
+ });
732
+ // Render at a wide width so the editor renders and uses the theme.
733
+ component().render(120);
734
+ type(component(), "answer");
735
+ component().handleInput(ENTER);
736
+ const result = await execution;
737
+ expect(result.details.answers[0].response).toEqual({ kind: "text", text: "answer" });
738
+ });
739
+
740
+ it("re-focuses a blurred text editor with enter", async () => {
741
+ const { component, execution } = await runWizard({
742
+ questions: [{ type: "text", question: "Type" }],
743
+ });
744
+ component().handleInput(ESC); // blur editor
745
+ component().handleInput(ENTER); // re-focus
746
+ type(component(), "typed");
747
+ component().handleInput(ENTER); // commit + submit
748
+ const result = await execution;
749
+ expect(result.details.answers[0].response).toEqual({ kind: "text", text: "typed" });
750
+ });
751
+
752
+ it("saveCurrent is a no-op on the review page", async () => {
753
+ const { component, execution } = await runWizard({
754
+ questions: [
755
+ { type: "select", question: "One", options: [{ value: "a", label: "A" }] },
756
+ { type: "select", question: "Two", options: [{ value: "b", label: "B" }] },
757
+ ],
758
+ });
759
+ component().handleInput(ENTER);
760
+ component().handleInput(ENTER); // → review
761
+ // Tab on the review page does nothing (no crash).
762
+ component().handleInput(TAB);
763
+ component().handleInput(ENTER); // submit
764
+ const result = await execution;
765
+ expect(result.details.answers).toHaveLength(2);
766
+ });
767
+
768
+ it("goPrevious from the first question stays put", async () => {
769
+ const { component, execution } = await runWizard({
770
+ questions: [
771
+ { type: "select", question: "One", options: [{ value: "a", label: "A" }] },
772
+ { type: "select", question: "Two", options: [{ value: "b", label: "B" }] },
773
+ ],
774
+ });
775
+ component().handleInput(SHIFT_TAB); // back from q1 → stays on q1
776
+ component().handleInput(ENTER); // answer q1
777
+ component().handleInput(ENTER); // answer q2 → review
778
+ component().handleInput(ENTER); // submit
779
+ const result = await execution;
780
+ expect(result.details.answers).toHaveLength(2);
781
+ });
782
+
783
+ it("commitCurrent from the last question advances to review", async () => {
784
+ const { component, execution } = await runWizard({
785
+ questions: [
786
+ { type: "select", question: "One", options: [{ value: "a", label: "A" }] },
787
+ { type: "select", question: "Two", options: [{ value: "b", label: "B" }] },
788
+ ],
789
+ });
790
+ component().handleInput(ENTER); // answer q1 → q2
791
+ component().handleInput(ENTER); // answer q2 → review
792
+ component().handleInput(ENTER); // submit
793
+ const result = await execution;
794
+ expect(result.details.answers).toHaveLength(2);
795
+ });
796
+
797
+ it("onUpdate reports waiting for multiple questions", async () => {
798
+ let tool: any;
799
+ let component: any;
800
+ let done!: (value: unknown) => void;
801
+ const updates: any[] = [];
802
+ questionExtension({ registerTool: (entry: unknown) => (tool = entry), events: { emit() {} } } as any);
803
+ const execution = tool.execute("call", {
804
+ questions: [
805
+ { type: "select", question: "Q1", options: [{ value: "a", label: "A" }] },
806
+ { type: "select", question: "Q2", options: [{ value: "b", label: "B" }] },
807
+ ],
808
+ }, undefined, (u: any) => updates.push(u), {
809
+ mode: "tui",
810
+ ui: {
811
+ custom: (factory: (...args: any[]) => unknown) => new Promise((resolve) => {
812
+ done = resolve;
813
+ component = factory(tui, theme, keybindings, done);
814
+ }),
815
+ },
816
+ } as any);
817
+ expect(updates[0].content[0].text).toContain("Waiting for answers to 2 questions");
818
+ component.handleInput(ENTER);
819
+ component.handleInput(ENTER);
820
+ component.handleInput(ENTER);
821
+ await execution;
822
+ });
102
823
  });