@ryan_nookpi/pi-extension-ask-user-question 0.1.1 → 0.2.1
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 +78 -14
- 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 +36 -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("검토 및 제출");
|
|
56
|
+
expect(output).toContain("Q1:");
|
|
57
|
+
expect(output).toContain("필수 응답: Q1, Q2");
|
|
58
|
+
expect(output).toContain("Tab/←→ 질문 이동 • Enter 제출 • Esc 취소");
|
|
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** [단일 선택]");
|
|
84
|
+
expect(output).toContain("Alpha");
|
|
85
|
+
expect(output).toContain("first");
|
|
86
|
+
expect(output).toContain("기타: custom");
|
|
87
|
+
expect(output).toContain("직접 입력:");
|
|
88
|
+
expect(output).toContain("Enter 제출 • Esc 돌아가기");
|
|
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** [복수 선택]");
|
|
114
|
+
expect(checkboxOutput).toContain("기타: custom option");
|
|
115
|
+
expect(checkboxOutput).toContain("Space 토글");
|
|
116
|
+
|
|
117
|
+
const textOutput = renderForm(
|
|
118
|
+
createInput({ questions, answerState, currentTab: 1, editorLines: ["typed line"], editorText: "" }),
|
|
119
|
+
).join("\n");
|
|
120
|
+
expect(textOutput).toContain("**Explain** [텍스트]");
|
|
121
|
+
expect(textOutput).toContain("Type here");
|
|
122
|
+
expect(textOutput).toContain("typed line");
|
|
123
|
+
expect(textOutput).toContain("Tab/←→ 이동 • Enter 제출 • Esc 취소");
|
|
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("Enter로 제출");
|
|
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 선택 • Esc 취소");
|
|
163
|
+
expect(radioOutput).not.toContain("기타...");
|
|
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 다음");
|
|
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 제출 • Esc 취소");
|
|
175
|
+
expect(textOutput).not.toContain("*필수");
|
|
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("(직접 입력) 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("(미응답)");
|
|
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("기타...");
|
|
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("기타...");
|
|
268
|
+
expect(checkboxOutput).toContain("Enter 제출 • Esc 취소");
|
|
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("❯ ☐ 기타...");
|
|
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 선택 • Esc 취소");
|
|
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** [텍스트]");
|
|
334
|
+
expect(textOutput).toContain("Enter 제출 • Esc 취소");
|
|
335
|
+
expect(textOutput).not.toContain("*필수");
|
|
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("검토 및 제출"))}`);
|
|
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", "(직접 입력) ") : "";
|
|
26
|
+
add(` ${label} ${prefix}${answer.label}`);
|
|
27
|
+
} else {
|
|
28
|
+
add(` ${label} ${theme.fg("warning", "(미응답)")}`);
|
|
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", "(직접 입력)")} ${custom}`);
|
|
38
|
+
add(` ${label} ${values.length ? values.join(", ") : theme.fg("warning", "(미응답)")}`);
|
|
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", "(미응답)")}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
add("");
|
|
51
|
+
if (allRequiredAnswered(answerState, questions)) {
|
|
52
|
+
add(` ${theme.fg("success", "Enter로 제출")}`);
|
|
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", `필수 응답: ${missing}`)}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
add("");
|
|
62
|
+
add(theme.fg("dim", " Tab/←→ 질문 이동 • Enter 제출 • Esc 취소"));
|
|
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} 제출 `;
|
|
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", "[단일 선택]")
|
|
97
|
+
: question.type === "checkbox"
|
|
98
|
+
? input.theme.fg("dim", "[복수 선택]")
|
|
99
|
+
: input.theme.fg("dim", "[텍스트]");
|
|
100
|
+
|
|
101
|
+
add(` ${input.theme.fg("text", input.theme.bold(question.prompt))} ${typeTag}`);
|
|
102
|
+
if (question.required) add(` ${input.theme.fg("warning", "*필수")}`);
|
|
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 ? `기타: ${selected.label}` : "기타...";
|
|
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 ? `기타: ${custom}` : "기타...";
|
|
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", " 직접 입력:")}`);
|
|
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 제출 • Esc 돌아가기"));
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (question.type === "text") {
|
|
179
|
+
const nav = input.questions.length > 1 ? "Tab/←→ 이동 • " : "";
|
|
180
|
+
add(input.theme.fg("dim", ` ${nav}Enter 제출 • Esc 취소`));
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (question.type === "checkbox") {
|
|
184
|
+
const nav = input.questions.length > 1 ? "Tab/←→ 이동 • " : "";
|
|
185
|
+
const enterAction = input.questions.length > 1 ? "다음" : "제출";
|
|
186
|
+
add(input.theme.fg("dim", ` ↑↓ 이동 • Space 토글 • ${nav}Enter ${enterAction} • Esc 취소`));
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
const nav = input.questions.length > 1 ? "Tab/←→ 이동 • " : "";
|
|
190
|
+
add(input.theme.fg("dim", ` ↑↓ 이동 • ${nav}Enter 선택 • Esc 취소`));
|
|
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
|
+
}
|