@ryan_nookpi/pi-extension-ask-user-question 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -16
- package/constants.ts +10 -0
- package/controller.test.ts +281 -0
- package/controller.ts +280 -0
- package/form-ui.test.ts +57 -0
- package/form-ui.ts +38 -0
- package/index.test.ts +113 -0
- package/index.ts +62 -510
- package/output.test.ts +102 -0
- package/output.ts +89 -0
- package/package.json +2 -2
- package/schema.ts +37 -0
- package/state.test.ts +136 -0
- package/state.ts +131 -0
- package/types.ts +85 -0
- package/view.test.ts +337 -0
- package/view.ts +218 -0
package/view.test.ts
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { createAnswerState, normalizeQuestions } from "./state.ts";
|
|
4
|
+
import type { RenderFormInput, RenderTheme } from "./types.ts";
|
|
5
|
+
import { renderFooter, renderForm, renderQuestion, renderSubmitTab, renderTabBar } from "./view.ts";
|
|
6
|
+
|
|
7
|
+
const theme: RenderTheme = {
|
|
8
|
+
fg: (_color, text) => text,
|
|
9
|
+
bg: (_color, text) => `[${text}]`,
|
|
10
|
+
bold: (text) => `**${text}**`,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
function createInput(overrides: Partial<RenderFormInput> = {}): RenderFormInput {
|
|
14
|
+
const questions =
|
|
15
|
+
overrides.questions ??
|
|
16
|
+
normalizeQuestions([
|
|
17
|
+
{
|
|
18
|
+
id: "radio",
|
|
19
|
+
type: "radio",
|
|
20
|
+
prompt: "Pick one",
|
|
21
|
+
options: [
|
|
22
|
+
{ value: "a", label: "Alpha", description: "first" },
|
|
23
|
+
{ value: "b", label: "Beta" },
|
|
24
|
+
],
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
id: "text",
|
|
28
|
+
type: "text",
|
|
29
|
+
prompt: "Explain",
|
|
30
|
+
placeholder: "Type here",
|
|
31
|
+
},
|
|
32
|
+
]);
|
|
33
|
+
const answerState = overrides.answerState ?? createAnswerState(questions);
|
|
34
|
+
return {
|
|
35
|
+
title: "Title",
|
|
36
|
+
description: "Description",
|
|
37
|
+
questions,
|
|
38
|
+
answerState,
|
|
39
|
+
currentTab: 0,
|
|
40
|
+
cursorIdx: 0,
|
|
41
|
+
otherMode: false,
|
|
42
|
+
width: 80,
|
|
43
|
+
theme,
|
|
44
|
+
editorLines: ["editor line"],
|
|
45
|
+
editorText: "",
|
|
46
|
+
...overrides,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe("ask-user-question/view", () => {
|
|
51
|
+
it("renders the review tab with missing required answers", () => {
|
|
52
|
+
const input = createInput({ currentTab: 2 });
|
|
53
|
+
const output = renderForm(input).join("\n");
|
|
54
|
+
|
|
55
|
+
expect(output).toContain("Review & Submit");
|
|
56
|
+
expect(output).toContain("Q1:");
|
|
57
|
+
expect(output).toContain("Required: Q1, Q2");
|
|
58
|
+
expect(output).toContain("Tab/←→ navigate questions • Enter submit • Esc cancel");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("renders radio questions including descriptions and other-mode editor", () => {
|
|
62
|
+
const questions = normalizeQuestions([
|
|
63
|
+
{
|
|
64
|
+
id: "radio",
|
|
65
|
+
type: "radio",
|
|
66
|
+
prompt: "Pick one",
|
|
67
|
+
options: [{ value: "a", label: "Alpha", description: "first" }],
|
|
68
|
+
},
|
|
69
|
+
]);
|
|
70
|
+
const answerState = createAnswerState(questions);
|
|
71
|
+
answerState.radioAnswers.set("radio", { value: "custom", label: "custom", wasCustom: true });
|
|
72
|
+
|
|
73
|
+
const output = renderForm(
|
|
74
|
+
createInput({
|
|
75
|
+
questions,
|
|
76
|
+
answerState,
|
|
77
|
+
otherMode: true,
|
|
78
|
+
cursorIdx: 1,
|
|
79
|
+
editorLines: ["typed value"],
|
|
80
|
+
}),
|
|
81
|
+
).join("\n");
|
|
82
|
+
|
|
83
|
+
expect(output).toContain("**Pick one** [single-select]");
|
|
84
|
+
expect(output).toContain("Alpha");
|
|
85
|
+
expect(output).toContain("first");
|
|
86
|
+
expect(output).toContain("Other: custom");
|
|
87
|
+
expect(output).toContain("Your answer:");
|
|
88
|
+
expect(output).toContain("Enter submit • Esc go back");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("renders checkbox and text questions with placeholders and text footer", () => {
|
|
92
|
+
const questions = normalizeQuestions([
|
|
93
|
+
{
|
|
94
|
+
id: "check",
|
|
95
|
+
type: "checkbox",
|
|
96
|
+
prompt: "Pick many",
|
|
97
|
+
options: [{ value: "a", label: "Alpha" }],
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
id: "text",
|
|
101
|
+
type: "text",
|
|
102
|
+
prompt: "Explain",
|
|
103
|
+
placeholder: "Type here",
|
|
104
|
+
},
|
|
105
|
+
]);
|
|
106
|
+
const answerState = createAnswerState(questions);
|
|
107
|
+
answerState.checkAnswers.set("check", new Set(["a"]));
|
|
108
|
+
answerState.checkCustom.set("check", "custom option");
|
|
109
|
+
|
|
110
|
+
const checkboxOutput = renderForm(createInput({ questions, answerState, editorLines: [], editorText: "" })).join(
|
|
111
|
+
"\n",
|
|
112
|
+
);
|
|
113
|
+
expect(checkboxOutput).toContain("**Pick many** [multi-select]");
|
|
114
|
+
expect(checkboxOutput).toContain("Other: custom option");
|
|
115
|
+
expect(checkboxOutput).toContain("Space toggle");
|
|
116
|
+
|
|
117
|
+
const textOutput = renderForm(
|
|
118
|
+
createInput({ questions, answerState, currentTab: 1, editorLines: ["typed line"], editorText: "" }),
|
|
119
|
+
).join("\n");
|
|
120
|
+
expect(textOutput).toContain("**Explain** [text]");
|
|
121
|
+
expect(textOutput).toContain("Type here");
|
|
122
|
+
expect(textOutput).toContain("typed line");
|
|
123
|
+
expect(textOutput).toContain("Tab/←→ navigate • Enter submit • Esc cancel");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("renders answered submit states and optional question variants", () => {
|
|
127
|
+
const questions = normalizeQuestions([
|
|
128
|
+
{
|
|
129
|
+
id: "radio",
|
|
130
|
+
type: "radio",
|
|
131
|
+
prompt: "Pick one",
|
|
132
|
+
options: [{ value: "a", label: "Alpha" }],
|
|
133
|
+
allowOther: false,
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
id: "check",
|
|
137
|
+
type: "checkbox",
|
|
138
|
+
prompt: "Pick many",
|
|
139
|
+
options: [{ value: "b", label: "Beta", description: "desc" }],
|
|
140
|
+
allowOther: false,
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
id: "text",
|
|
144
|
+
type: "text",
|
|
145
|
+
prompt: "Explain",
|
|
146
|
+
required: false,
|
|
147
|
+
},
|
|
148
|
+
]);
|
|
149
|
+
const answerState = createAnswerState(questions);
|
|
150
|
+
answerState.radioAnswers.set("radio", { value: "a", label: "Alpha", wasCustom: false });
|
|
151
|
+
answerState.checkAnswers.set("check", new Set(["b"]));
|
|
152
|
+
answerState.textAnswers.set("text", "filled");
|
|
153
|
+
|
|
154
|
+
const submitOutput = renderForm(createInput({ questions, answerState, currentTab: 3 })).join("\n");
|
|
155
|
+
expect(submitOutput).toContain("Press Enter to submit");
|
|
156
|
+
expect(submitOutput).toContain("Alpha");
|
|
157
|
+
expect(submitOutput).toContain("b");
|
|
158
|
+
expect(submitOutput).toContain("filled");
|
|
159
|
+
|
|
160
|
+
const radioOutput = renderForm(createInput({ questions, answerState, currentTab: 0, cursorIdx: 0 })).join("\n");
|
|
161
|
+
expect(radioOutput).toContain("❯ ◉ Alpha");
|
|
162
|
+
expect(radioOutput).toContain("Enter select • Esc cancel");
|
|
163
|
+
expect(radioOutput).not.toContain("Other...");
|
|
164
|
+
|
|
165
|
+
const checkboxOutput = renderForm(createInput({ questions, answerState, currentTab: 1, cursorIdx: 0 })).join("\n");
|
|
166
|
+
expect(checkboxOutput).toContain("☑ Beta");
|
|
167
|
+
expect(checkboxOutput).toContain("desc");
|
|
168
|
+
expect(checkboxOutput).toContain("Enter next");
|
|
169
|
+
|
|
170
|
+
const textOutput = renderForm(
|
|
171
|
+
createInput({ questions, answerState, currentTab: 2, editorLines: ["filled"], editorText: "filled" }),
|
|
172
|
+
).join("\n");
|
|
173
|
+
expect(textOutput).toContain("filled");
|
|
174
|
+
expect(textOutput).toContain("Enter submit • Esc cancel");
|
|
175
|
+
expect(textOutput).not.toContain("*required");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("covers direct helper branches for invalid tabs and compact variants", () => {
|
|
179
|
+
const lines: string[] = [];
|
|
180
|
+
const add = (text: string) => lines.push(text);
|
|
181
|
+
const singleQuestionInput = createInput({
|
|
182
|
+
questions: normalizeQuestions([
|
|
183
|
+
{ id: "radio", type: "radio", prompt: "Only", options: [{ value: "a", label: "Alpha" }] },
|
|
184
|
+
]),
|
|
185
|
+
currentTab: 99,
|
|
186
|
+
});
|
|
187
|
+
renderTabBar(singleQuestionInput, add);
|
|
188
|
+
renderQuestion(singleQuestionInput, add, 80);
|
|
189
|
+
renderFooter(singleQuestionInput, add);
|
|
190
|
+
expect(lines).toEqual([]);
|
|
191
|
+
|
|
192
|
+
const submitQuestions = normalizeQuestions([
|
|
193
|
+
{ id: "radio", type: "radio", prompt: "Only", options: [{ value: "a", label: "Alpha" }] },
|
|
194
|
+
{ id: "check", type: "checkbox", prompt: "Many", options: [{ value: "b", label: "Beta" }] },
|
|
195
|
+
]);
|
|
196
|
+
const submitLines: string[] = [];
|
|
197
|
+
const submitInput = createInput({ questions: submitQuestions, answerState: createAnswerState(submitQuestions) });
|
|
198
|
+
submitInput.answerState.radioAnswers.set("radio", { value: "custom", label: "custom", wasCustom: true });
|
|
199
|
+
submitInput.answerState.checkCustom.set("check", "custom");
|
|
200
|
+
renderSubmitTab(submitInput, (text) => submitLines.push(text), 80);
|
|
201
|
+
expect(submitLines.join("\n")).toContain("(wrote) custom");
|
|
202
|
+
|
|
203
|
+
const unansweredLines: string[] = [];
|
|
204
|
+
const unansweredInput = createInput({
|
|
205
|
+
questions: submitQuestions,
|
|
206
|
+
answerState: createAnswerState(submitQuestions),
|
|
207
|
+
});
|
|
208
|
+
unansweredInput.answerState.checkAnswers.delete("check");
|
|
209
|
+
renderSubmitTab(unansweredInput, (text) => unansweredLines.push(text), 80);
|
|
210
|
+
expect(unansweredLines.join("\n")).toContain("(unanswered)");
|
|
211
|
+
|
|
212
|
+
const output = renderForm(createInput({ currentTab: 99 }));
|
|
213
|
+
expect(output.length).toBeGreaterThanOrEqual(2);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("covers unchecked and compact render branches", () => {
|
|
217
|
+
const radioQuestions = normalizeQuestions([
|
|
218
|
+
{ id: "radio", type: "radio", prompt: "Only", options: [{ value: "a", label: "Alpha" }], allowOther: true },
|
|
219
|
+
]);
|
|
220
|
+
const radioState = createAnswerState(radioQuestions);
|
|
221
|
+
radioState.radioAnswers.set("radio", { value: "a", label: "Alpha", wasCustom: false });
|
|
222
|
+
const radioLines: string[] = [];
|
|
223
|
+
renderQuestion(
|
|
224
|
+
createInput({
|
|
225
|
+
title: undefined,
|
|
226
|
+
description: undefined,
|
|
227
|
+
questions: radioQuestions,
|
|
228
|
+
answerState: radioState,
|
|
229
|
+
cursorIdx: 0,
|
|
230
|
+
editorLines: [],
|
|
231
|
+
}),
|
|
232
|
+
(text) => radioLines.push(text),
|
|
233
|
+
80,
|
|
234
|
+
);
|
|
235
|
+
expect(radioLines.join("\n")).toContain("❯ ◉ Alpha");
|
|
236
|
+
expect(radioLines.join("\n")).toContain("Other...");
|
|
237
|
+
const radioSelectedLines: string[] = [];
|
|
238
|
+
renderQuestion(
|
|
239
|
+
createInput({
|
|
240
|
+
title: undefined,
|
|
241
|
+
description: undefined,
|
|
242
|
+
questions: radioQuestions,
|
|
243
|
+
answerState: radioState,
|
|
244
|
+
cursorIdx: 1,
|
|
245
|
+
editorLines: [],
|
|
246
|
+
}),
|
|
247
|
+
(text) => radioSelectedLines.push(text),
|
|
248
|
+
80,
|
|
249
|
+
);
|
|
250
|
+
expect(radioSelectedLines.join("\n")).toContain("◉ Alpha");
|
|
251
|
+
|
|
252
|
+
const checkboxQuestions = normalizeQuestions([
|
|
253
|
+
{ id: "check", type: "checkbox", prompt: "Only", options: [{ value: "a", label: "Alpha" }], allowOther: true },
|
|
254
|
+
]);
|
|
255
|
+
const checkboxState = createAnswerState(checkboxQuestions);
|
|
256
|
+
checkboxState.checkAnswers.delete("check");
|
|
257
|
+
const checkboxOutput = renderForm(
|
|
258
|
+
createInput({
|
|
259
|
+
title: undefined,
|
|
260
|
+
description: undefined,
|
|
261
|
+
questions: checkboxQuestions,
|
|
262
|
+
answerState: checkboxState,
|
|
263
|
+
editorLines: [],
|
|
264
|
+
}),
|
|
265
|
+
).join("\n");
|
|
266
|
+
expect(checkboxOutput).toContain("☐ Alpha");
|
|
267
|
+
expect(checkboxOutput).toContain("Other...");
|
|
268
|
+
expect(checkboxOutput).toContain("Enter submit • Esc cancel");
|
|
269
|
+
checkboxState.checkAnswers.set("check", new Set(["a"]));
|
|
270
|
+
const checkboxOtherCursor = renderForm(
|
|
271
|
+
createInput({
|
|
272
|
+
title: undefined,
|
|
273
|
+
description: undefined,
|
|
274
|
+
questions: checkboxQuestions,
|
|
275
|
+
answerState: checkboxState,
|
|
276
|
+
cursorIdx: 1,
|
|
277
|
+
editorLines: [],
|
|
278
|
+
}),
|
|
279
|
+
).join("\n");
|
|
280
|
+
expect(checkboxOtherCursor).toContain("☑ Alpha");
|
|
281
|
+
expect(checkboxOtherCursor).toContain("❯ ☐ Other...");
|
|
282
|
+
const checkboxLines: string[] = [];
|
|
283
|
+
renderQuestion(
|
|
284
|
+
createInput({
|
|
285
|
+
title: undefined,
|
|
286
|
+
description: undefined,
|
|
287
|
+
questions: checkboxQuestions,
|
|
288
|
+
answerState: checkboxState,
|
|
289
|
+
cursorIdx: 1,
|
|
290
|
+
editorLines: [],
|
|
291
|
+
}),
|
|
292
|
+
(text) => checkboxLines.push(text),
|
|
293
|
+
80,
|
|
294
|
+
);
|
|
295
|
+
expect(checkboxLines.join("\n")).toContain("☑ Alpha");
|
|
296
|
+
checkboxState.checkAnswers.set("check", new Set());
|
|
297
|
+
const checkboxMutedLines: string[] = [];
|
|
298
|
+
renderQuestion(
|
|
299
|
+
createInput({
|
|
300
|
+
title: undefined,
|
|
301
|
+
description: undefined,
|
|
302
|
+
questions: checkboxQuestions,
|
|
303
|
+
answerState: checkboxState,
|
|
304
|
+
cursorIdx: 1,
|
|
305
|
+
editorLines: [],
|
|
306
|
+
}),
|
|
307
|
+
(text) => checkboxMutedLines.push(text),
|
|
308
|
+
80,
|
|
309
|
+
);
|
|
310
|
+
expect(checkboxMutedLines.join("\n")).toContain("☐ Alpha");
|
|
311
|
+
|
|
312
|
+
const textQuestions = normalizeQuestions([{ id: "text", type: "text", prompt: "Only", required: false }]);
|
|
313
|
+
const radioFooterOutput = renderForm(
|
|
314
|
+
createInput({
|
|
315
|
+
title: undefined,
|
|
316
|
+
description: undefined,
|
|
317
|
+
questions: radioQuestions,
|
|
318
|
+
answerState: radioState,
|
|
319
|
+
editorLines: [],
|
|
320
|
+
}),
|
|
321
|
+
).join("\n");
|
|
322
|
+
expect(radioFooterOutput).toContain("Enter select • Esc cancel");
|
|
323
|
+
const textOutput = renderForm(
|
|
324
|
+
createInput({
|
|
325
|
+
title: undefined,
|
|
326
|
+
description: undefined,
|
|
327
|
+
questions: textQuestions,
|
|
328
|
+
answerState: createAnswerState(textQuestions),
|
|
329
|
+
editorLines: [],
|
|
330
|
+
editorText: "",
|
|
331
|
+
}),
|
|
332
|
+
).join("\n");
|
|
333
|
+
expect(textOutput).toContain("**Only** [text]");
|
|
334
|
+
expect(textOutput).toContain("Enter submit • Esc cancel");
|
|
335
|
+
expect(textOutput).not.toContain("*required");
|
|
336
|
+
});
|
|
337
|
+
});
|
package/view.ts
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
|
2
|
+
|
|
3
|
+
import { SYM } from "./constants.ts";
|
|
4
|
+
import { allRequiredAnswered, isAnswered } from "./state.ts";
|
|
5
|
+
import type { RenderFormInput } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
function createLineHelpers(width: number, theme: RenderFormInput["theme"]) {
|
|
8
|
+
const lines: string[] = [];
|
|
9
|
+
const maxWidth = Math.min(width, 120);
|
|
10
|
+
const add = (text: string) => lines.push(truncateToWidth(text, maxWidth));
|
|
11
|
+
const hr = () => add(theme.fg("accent", "─".repeat(maxWidth)));
|
|
12
|
+
return { lines, maxWidth, add, hr };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function renderSubmitTab(input: RenderFormInput, add: (text: string) => void, maxWidth: number): void {
|
|
16
|
+
const { questions, answerState, theme } = input;
|
|
17
|
+
add(` ${theme.fg("accent", theme.bold("Review & Submit"))}`);
|
|
18
|
+
add("");
|
|
19
|
+
|
|
20
|
+
for (const question of questions) {
|
|
21
|
+
const label = theme.fg("muted", `${question.label}:`);
|
|
22
|
+
if (question.type === "radio") {
|
|
23
|
+
const answer = answerState.radioAnswers.get(question.id);
|
|
24
|
+
if (answer) {
|
|
25
|
+
const prefix = answer.wasCustom ? theme.fg("dim", "(wrote) ") : "";
|
|
26
|
+
add(` ${label} ${prefix}${answer.label}`);
|
|
27
|
+
} else {
|
|
28
|
+
add(` ${label} ${theme.fg("warning", "(unanswered)")}`);
|
|
29
|
+
}
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (question.type === "checkbox") {
|
|
34
|
+
const selected = answerState.checkAnswers.get(question.id) ?? new Set<string>();
|
|
35
|
+
const custom = answerState.checkCustom.get(question.id)?.trim();
|
|
36
|
+
const values = [...selected];
|
|
37
|
+
if (custom) values.push(`${theme.fg("dim", "(wrote)")} ${custom}`);
|
|
38
|
+
add(` ${label} ${values.length ? values.join(", ") : theme.fg("warning", "(unanswered)")}`);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const answer = answerState.textAnswers.get(question.id)?.trim();
|
|
43
|
+
if (answer) {
|
|
44
|
+
add(` ${label} ${truncateToWidth(answer, maxWidth - visibleWidth(question.label) - 5)}`);
|
|
45
|
+
} else {
|
|
46
|
+
add(` ${label} ${theme.fg("warning", "(unanswered)")}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
add("");
|
|
51
|
+
if (allRequiredAnswered(answerState, questions)) {
|
|
52
|
+
add(` ${theme.fg("success", "Press Enter to submit")}`);
|
|
53
|
+
} else {
|
|
54
|
+
const missing = questions
|
|
55
|
+
.filter((question) => question.required && !isAnswered(answerState, question))
|
|
56
|
+
.map((question) => question.label)
|
|
57
|
+
.join(", ");
|
|
58
|
+
add(` ${theme.fg("warning", `Required: ${missing}`)}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
add("");
|
|
62
|
+
add(theme.fg("dim", " Tab/←→ navigate questions • Enter submit • Esc cancel"));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function renderTabBar(input: RenderFormInput, add: (text: string) => void): void {
|
|
66
|
+
const { questions, answerState, currentTab, theme } = input;
|
|
67
|
+
if (questions.length <= 1) return;
|
|
68
|
+
|
|
69
|
+
const tabs: string[] = [];
|
|
70
|
+
for (let index = 0; index < questions.length; index += 1) {
|
|
71
|
+
const active = index === currentTab;
|
|
72
|
+
const answered = isAnswered(answerState, questions[index]);
|
|
73
|
+
const icon = answered ? theme.fg("success", SYM.check) : theme.fg("dim", SYM.dot);
|
|
74
|
+
const text = ` ${icon} ${questions[index].label} `;
|
|
75
|
+
tabs.push(active ? theme.bg("selectedBg", theme.fg("text", text)) : theme.fg(answered ? "success" : "muted", text));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const submitActive = currentTab === questions.length;
|
|
79
|
+
const submitText = ` ${SYM.submit} Submit `;
|
|
80
|
+
tabs.push(
|
|
81
|
+
submitActive
|
|
82
|
+
? theme.bg("selectedBg", theme.fg("text", submitText))
|
|
83
|
+
: theme.fg(allRequiredAnswered(answerState, questions) ? "success" : "dim", submitText),
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
add(` ${tabs.join(theme.fg("dim", "│"))}`);
|
|
87
|
+
add("");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function renderQuestion(input: RenderFormInput, add: (text: string) => void, maxWidth: number): void {
|
|
91
|
+
const question = input.questions[input.currentTab];
|
|
92
|
+
if (!question) return;
|
|
93
|
+
|
|
94
|
+
const typeTag =
|
|
95
|
+
question.type === "radio"
|
|
96
|
+
? input.theme.fg("dim", "[single-select]")
|
|
97
|
+
: question.type === "checkbox"
|
|
98
|
+
? input.theme.fg("dim", "[multi-select]")
|
|
99
|
+
: input.theme.fg("dim", "[text]");
|
|
100
|
+
|
|
101
|
+
add(` ${input.theme.fg("text", input.theme.bold(question.prompt))} ${typeTag}`);
|
|
102
|
+
if (question.required) add(` ${input.theme.fg("warning", "*required")}`);
|
|
103
|
+
add("");
|
|
104
|
+
|
|
105
|
+
if (question.type === "radio") {
|
|
106
|
+
const selected = input.answerState.radioAnswers.get(question.id);
|
|
107
|
+
for (let index = 0; index < question.options.length; index += 1) {
|
|
108
|
+
const option = question.options[index];
|
|
109
|
+
const isCursor = index === input.cursorIdx;
|
|
110
|
+
const isSelected = selected?.value === option.value && !selected.wasCustom;
|
|
111
|
+
const bullet = isSelected ? input.theme.fg("accent", SYM.radioOn) : input.theme.fg("dim", SYM.radioOff);
|
|
112
|
+
const pointer = isCursor ? input.theme.fg("accent", SYM.pointer) : " ";
|
|
113
|
+
add(` ${pointer} ${bullet} ${input.theme.fg(isCursor ? "accent" : isSelected ? "text" : "muted", option.label)}`);
|
|
114
|
+
if (option.description) add(` ${input.theme.fg("dim", option.description)}`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (question.allowOther) {
|
|
118
|
+
const isCursor = input.cursorIdx === question.options.length;
|
|
119
|
+
const isSelected = selected?.wasCustom === true;
|
|
120
|
+
const bullet = isSelected ? input.theme.fg("accent", SYM.radioOn) : input.theme.fg("dim", SYM.radioOff);
|
|
121
|
+
const pointer = isCursor ? input.theme.fg("accent", SYM.pointer) : " ";
|
|
122
|
+
const label = isSelected ? `Other: ${selected.label}` : "Other...";
|
|
123
|
+
add(` ${pointer} ${bullet} ${input.theme.fg(isCursor ? "accent" : "muted", label)}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (question.type === "checkbox") {
|
|
128
|
+
const selected = input.answerState.checkAnswers.get(question.id) ?? new Set<string>();
|
|
129
|
+
for (let index = 0; index < question.options.length; index += 1) {
|
|
130
|
+
const option = question.options[index];
|
|
131
|
+
const isCursor = index === input.cursorIdx;
|
|
132
|
+
const isChecked = selected.has(option.value);
|
|
133
|
+
const box = isChecked ? input.theme.fg("accent", SYM.checkOn) : input.theme.fg("dim", SYM.checkOff);
|
|
134
|
+
const pointer = isCursor ? input.theme.fg("accent", SYM.pointer) : " ";
|
|
135
|
+
add(` ${pointer} ${box} ${input.theme.fg(isCursor ? "accent" : isChecked ? "text" : "muted", option.label)}`);
|
|
136
|
+
if (option.description) add(` ${input.theme.fg("dim", option.description)}`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (question.allowOther) {
|
|
140
|
+
const isCursor = input.cursorIdx === question.options.length;
|
|
141
|
+
const custom = input.answerState.checkCustom.get(question.id)?.trim();
|
|
142
|
+
const box = custom ? input.theme.fg("accent", SYM.checkOn) : input.theme.fg("dim", SYM.checkOff);
|
|
143
|
+
const pointer = isCursor ? input.theme.fg("accent", SYM.pointer) : " ";
|
|
144
|
+
const label = custom ? `Other: ${custom}` : "Other...";
|
|
145
|
+
add(` ${pointer} ${box} ${input.theme.fg(isCursor ? "accent" : "muted", label)}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (input.otherMode) {
|
|
150
|
+
add("");
|
|
151
|
+
add(` ${input.theme.fg("muted", " Your answer:")}`);
|
|
152
|
+
for (const line of input.editorLines) add(` ${line}`);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (question.type === "text") {
|
|
157
|
+
if (question.placeholder && !input.editorText) {
|
|
158
|
+
add(` ${input.theme.fg("dim", question.placeholder)}`);
|
|
159
|
+
}
|
|
160
|
+
for (const line of input.editorLines) {
|
|
161
|
+
add(` ${line}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (question.type === "text" && input.editorLines.length === 0) {
|
|
166
|
+
add(` ${truncateToWidth("", maxWidth - 2)}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function renderFooter(input: RenderFormInput, add: (text: string) => void): void {
|
|
171
|
+
const question = input.questions[input.currentTab];
|
|
172
|
+
if (!question) return;
|
|
173
|
+
add("");
|
|
174
|
+
if (input.otherMode) {
|
|
175
|
+
add(input.theme.fg("dim", " Enter submit • Esc go back"));
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (question.type === "text") {
|
|
179
|
+
const nav = input.questions.length > 1 ? "Tab/←→ navigate • " : "";
|
|
180
|
+
add(input.theme.fg("dim", ` ${nav}Enter submit • Esc cancel`));
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (question.type === "checkbox") {
|
|
184
|
+
const nav = input.questions.length > 1 ? "Tab/←→ navigate • " : "";
|
|
185
|
+
const enterAction = input.questions.length > 1 ? "next" : "submit";
|
|
186
|
+
add(input.theme.fg("dim", ` ↑↓ navigate • Space toggle • ${nav}Enter ${enterAction} • Esc cancel`));
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
const nav = input.questions.length > 1 ? "Tab/←→ navigate • " : "";
|
|
190
|
+
add(input.theme.fg("dim", ` ↑↓ navigate • ${nav}Enter select • Esc cancel`));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function renderForm(input: RenderFormInput): string[] {
|
|
194
|
+
const { lines, maxWidth, add, hr } = createLineHelpers(input.width, input.theme);
|
|
195
|
+
hr();
|
|
196
|
+
|
|
197
|
+
if (input.title) add(` ${input.theme.fg("accent", input.theme.bold(input.title))}`);
|
|
198
|
+
if (input.description) add(` ${input.theme.fg("muted", input.description)}`);
|
|
199
|
+
if (input.title || input.description) add("");
|
|
200
|
+
|
|
201
|
+
renderTabBar(input, add);
|
|
202
|
+
|
|
203
|
+
if (input.questions.length > 1 && input.currentTab === input.questions.length) {
|
|
204
|
+
renderSubmitTab(input, add, maxWidth);
|
|
205
|
+
hr();
|
|
206
|
+
return lines;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (!input.questions[input.currentTab]) {
|
|
210
|
+
hr();
|
|
211
|
+
return lines;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
renderQuestion(input, add, maxWidth);
|
|
215
|
+
renderFooter(input, add);
|
|
216
|
+
hr();
|
|
217
|
+
return lines;
|
|
218
|
+
}
|