@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.
- package/CHANGELOG.md +22 -0
- package/README.md +7 -1
- package/dist/core/slash-commands.js +1 -0
- package/dist/defaults/models.json +46 -55
- package/dist/defaults/settings.json +7 -8
- package/dist/extensions/agent-browser.test.ts +178 -2
- package/dist/extensions/agent-browser.ts +2 -0
- package/dist/extensions/copy-turn.test.ts +229 -0
- package/dist/extensions/copy-turn.ts +11 -0
- package/dist/extensions/grep-app/index.test.ts +356 -1
- package/dist/extensions/grep-app/index.ts +6 -1
- package/dist/extensions/handoff-new.test.ts +351 -161
- package/dist/extensions/handoff-new.ts +3 -0
- package/dist/extensions/inline-skills.test.ts +93 -0
- package/dist/extensions/inline-skills.ts +3 -0
- package/dist/extensions/pi-subagents/CHANGELOG.md +26 -3
- package/dist/extensions/pi-subagents/README.md +2 -0
- package/dist/extensions/pi-subagents/agents/builder.md +5 -2
- package/dist/extensions/pi-subagents/agents/commentator.md +1 -1
- package/dist/extensions/pi-subagents/docs/configuration.md +54 -2
- package/dist/extensions/pi-subagents/docs/observability.md +1 -1
- package/dist/extensions/pi-subagents/docs/tool-reference.md +1 -1
- package/dist/extensions/pi-subagents/docs/workflows.md +1 -1
- package/dist/extensions/pi-subagents/package-lock.json +2 -2
- package/dist/extensions/pi-subagents/package.json +1 -1
- package/dist/extensions/pi-subagents/skills/pi-subagents/references/execution-controls.md +1 -1
- package/dist/extensions/pi-subagents/src/agents/agents.ts +9 -3
- package/dist/extensions/pi-subagents/src/extension/config.ts +12 -0
- package/dist/extensions/pi-subagents/src/extension/doctor.ts +40 -0
- package/dist/extensions/pi-subagents/src/extension/index.ts +104 -14
- package/dist/extensions/pi-subagents/src/extension/public-execution.ts +5 -0
- package/dist/extensions/pi-subagents/src/extension/rpc.ts +3 -9
- package/dist/extensions/pi-subagents/src/extension/schemas.ts +2 -2
- package/dist/extensions/pi-subagents/src/intercom/intercom-bridge.ts +4 -1
- package/dist/extensions/pi-subagents/src/missions/lifecycle.ts +4 -7
- package/dist/extensions/pi-subagents/src/missions/store.ts +4 -4
- package/dist/extensions/pi-subagents/src/missions/workflow-state.ts +6 -2
- package/dist/extensions/pi-subagents/src/runs/background/active-async-capacity.ts +374 -0
- package/dist/extensions/pi-subagents/src/runs/background/active-run-index.ts +9 -5
- package/dist/extensions/pi-subagents/src/runs/background/async-execution.ts +117 -29
- package/dist/extensions/pi-subagents/src/runs/background/async-job-tracker.ts +12 -6
- package/dist/extensions/pi-subagents/src/runs/background/async-resume.ts +7 -1
- package/dist/extensions/pi-subagents/src/runs/background/async-status.ts +14 -5
- package/dist/extensions/pi-subagents/src/runs/background/chain-append.ts +33 -15
- package/dist/extensions/pi-subagents/src/runs/background/notify.ts +5 -2
- package/dist/extensions/pi-subagents/src/runs/background/owned-process-tree.ts +104 -0
- package/dist/extensions/pi-subagents/src/runs/background/process-terminal.ts +17 -3
- package/dist/extensions/pi-subagents/src/runs/background/result-watcher.ts +19 -1
- package/dist/extensions/pi-subagents/src/runs/background/retained-children.ts +2 -2
- package/dist/extensions/pi-subagents/src/runs/background/run-id-resolver.ts +5 -2
- package/dist/extensions/pi-subagents/src/runs/background/run-status.ts +12 -6
- package/dist/extensions/pi-subagents/src/runs/background/stale-run-reconciler.ts +3 -3
- package/dist/extensions/pi-subagents/src/runs/background/subagent-runner.ts +84 -36
- package/dist/extensions/pi-subagents/src/runs/background/wait-subscriptions.ts +2 -1
- package/dist/extensions/pi-subagents/src/runs/foreground/chain-execution.ts +37 -2
- package/dist/extensions/pi-subagents/src/runs/foreground/execution.ts +135 -22
- package/dist/extensions/pi-subagents/src/runs/foreground/foreground-control.ts +12 -0
- package/dist/extensions/pi-subagents/src/runs/foreground/foreground-history.ts +7 -4
- package/dist/extensions/pi-subagents/src/runs/foreground/prompt-audit.ts +171 -0
- package/dist/extensions/pi-subagents/src/runs/foreground/subagent-executor.ts +653 -196
- package/dist/extensions/pi-subagents/src/runs/shared/acceptance.ts +13 -4
- package/dist/extensions/pi-subagents/src/runs/shared/llm-intent-arbiter.ts +286 -0
- package/dist/extensions/pi-subagents/src/runs/shared/model-fallback.ts +23 -0
- package/dist/extensions/pi-subagents/src/runs/shared/parallel-utils.ts +2 -0
- package/dist/extensions/pi-subagents/src/runs/shared/pi-args.ts +44 -1
- package/dist/extensions/pi-subagents/src/runs/shared/run-fanout-budget.ts +280 -0
- package/dist/extensions/pi-subagents/src/runs/shared/single-output.ts +4 -2
- package/dist/extensions/pi-subagents/src/runs/shared/task-intent.ts +19 -3
- package/dist/extensions/pi-subagents/src/runs/shared/worktree.ts +17 -5
- package/dist/extensions/pi-subagents/src/shared/session-lineage.ts +71 -0
- package/dist/extensions/pi-subagents/src/shared/types.ts +108 -0
- package/dist/extensions/pi-subagents/src/shared/utils.ts +3 -1
- package/dist/extensions/pi-subagents/src/tui/fleet-status.ts +7 -5
- package/dist/extensions/pi-subagents/src/tui/fleet.ts +226 -13
- package/dist/extensions/pi-subagents/src/workflows/scripted-workflow.ts +22 -5
- package/dist/extensions/pi-subagents/src/workflows/workflow-auto-relaunch.ts +28 -0
- package/dist/extensions/pi-subagents/test/integration/acceptance-file-report.test.ts +87 -0
- package/dist/extensions/pi-subagents/test/integration/async-execution.test.ts +96 -6
- package/dist/extensions/pi-subagents/test/integration/async-status.test.ts +28 -5
- package/dist/extensions/pi-subagents/test/integration/chain-execution.test.ts +8 -5
- package/dist/extensions/pi-subagents/test/integration/fork-context-execution.test.ts +83 -2
- package/dist/extensions/pi-subagents/test/integration/intercom-result-delivery.test.ts +211 -15
- package/dist/extensions/pi-subagents/test/integration/parallel-execution.test.ts +41 -3
- package/dist/extensions/pi-subagents/test/integration/single-execution.test.ts +314 -10
- package/dist/extensions/pi-subagents/test/unit/acceptance.test.ts +31 -1
- package/dist/extensions/pi-subagents/test/unit/active-async-capacity.test.ts +317 -0
- package/dist/extensions/pi-subagents/test/unit/agent-overrides.test.ts +21 -2
- package/dist/extensions/pi-subagents/test/unit/async-recovery-descriptor.test.ts +16 -1
- package/dist/extensions/pi-subagents/test/unit/async-resume.test.ts +6 -1
- package/dist/extensions/pi-subagents/test/unit/chain-append.test.ts +75 -4
- package/dist/extensions/pi-subagents/test/unit/completion-guard.test.ts +18 -0
- package/dist/extensions/pi-subagents/test/unit/doctor.test.ts +29 -1
- package/dist/extensions/pi-subagents/test/unit/fleet-status.test.ts +31 -5
- package/dist/extensions/pi-subagents/test/unit/fleet.test.ts +139 -1
- package/dist/extensions/pi-subagents/test/unit/foreground-control.test.ts +10 -0
- package/dist/extensions/pi-subagents/test/unit/handoff-adoption.test.ts +103 -0
- package/dist/extensions/pi-subagents/test/unit/index-child-registration.test.ts +22 -0
- package/dist/extensions/pi-subagents/test/unit/intercom-bridge.test.ts +2 -1
- package/dist/extensions/pi-subagents/test/unit/llm-intent-arbiter.test.ts +171 -0
- package/dist/extensions/pi-subagents/test/unit/mission-lifecycle.test.ts +30 -4
- package/dist/extensions/pi-subagents/test/unit/model-fallback.test.ts +14 -0
- package/dist/extensions/pi-subagents/test/unit/owned-process-tree.test.ts +69 -0
- package/dist/extensions/pi-subagents/test/unit/pi-args.test.ts +83 -0
- package/dist/extensions/pi-subagents/test/unit/pi-coding-agent-dir.test.ts +2 -2
- package/dist/extensions/pi-subagents/test/unit/process-terminal.test.ts +55 -2
- package/dist/extensions/pi-subagents/test/unit/public-execution.test.ts +12 -1
- package/dist/extensions/pi-subagents/test/unit/rpc.test.ts +7 -7
- package/dist/extensions/pi-subagents/test/unit/run-fanout-budget.test.ts +121 -0
- package/dist/extensions/pi-subagents/test/unit/run-status.test.ts +9 -0
- package/dist/extensions/pi-subagents/test/unit/schemas.test.ts +4 -3
- package/dist/extensions/pi-subagents/test/unit/session-lineage.test.ts +73 -0
- package/dist/extensions/pi-subagents/test/unit/single-output.test.ts +28 -0
- package/dist/extensions/pi-subagents/test/unit/steering-action.test.ts +10 -1
- package/dist/extensions/pi-subagents/test/unit/task-intent.test.ts +39 -0
- package/dist/extensions/pi-subagents/test/unit/timeout-defaults.test.ts +65 -0
- package/dist/extensions/pi-subagents/test/unit/workflow-auto-relaunch.test.ts +45 -0
- package/dist/extensions/pi-subagents/test/unit/worktree.test.ts +61 -0
- package/dist/extensions/ponytail/index.js +11 -0
- package/dist/extensions/ponytail/ponytail-config.cjs +2 -0
- package/dist/extensions/ponytail/ponytail-instructions.cjs +6 -0
- package/dist/extensions/ponytail/test/extension.test.js +274 -140
- package/dist/extensions/ponytail/test/helpers.test.js +280 -92
- package/dist/extensions/question/index.ts +18 -0
- package/dist/extensions/question/question-list.ts +22 -0
- package/dist/extensions/question/tests/batch.test.ts +103 -65
- package/dist/extensions/question/tests/helpers.test.ts +68 -35
- package/dist/extensions/question/tests/question-list.test.ts +506 -204
- package/dist/extensions/question/tests/row-layout.test.ts +180 -114
- package/dist/extensions/question/tests/schemas.test.ts +42 -19
- package/dist/extensions/question/tests/shortcuts.test.ts +106 -84
- package/dist/extensions/question/tests/wizard.test.ts +771 -50
- package/dist/extensions/rtk.test.ts +180 -1
- package/dist/extensions/tokenin-onboarding.ts +3 -0
- package/dist/extensions/undo.test.ts +720 -0
- package/dist/extensions/undo.ts +6 -0
- package/dist/extensions/web-agent-onboarding.test.ts +415 -0
- package/dist/extensions/workflow/extension.ts +3 -3
- package/dist/extensions/workflow/modes.ts +41 -19
- package/dist/modes/interactive/interactive-mode.d.ts +1 -0
- package/dist/modes/interactive/interactive-mode.js +48 -1
- package/dist/skills/pi-subagents/references/execution-controls.md +1 -1
- package/docs/plans/workflow-autoloop-reference.md +1 -2
- package/docs/plans/workflow-handoff-carryover.md +108 -0
- package/docs/quickstart.md +27 -43
- package/docs/settings.md +4 -0
- package/docs/usage.md +1 -0
- package/package.json +5 -1
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
});
|