@ryan_nookpi/pi-extension-ask-user-question 0.2.0 → 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 +64 -17
- package/controller.test.ts +1 -1
- package/index.test.ts +4 -4
- package/index.ts +15 -15
- package/output.test.ts +5 -5
- package/output.ts +8 -8
- package/package.json +1 -1
- package/schema.ts +15 -16
- package/view.test.ts +28 -28
- package/view.ts +25 -25
package/README.md
CHANGED
|
@@ -1,43 +1,90 @@
|
|
|
1
1
|
# @ryan_nookpi/pi-extension-ask-user-question
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
pi에서 `ask_user_question` 도구를 추가해, 사용자에게 여러 질문을 한 번에 구조화해서 받을 수 있게 해주는 익스텐션입니다.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
- `radio` questions for single choice
|
|
8
|
-
- `checkbox` questions for multiple choice
|
|
9
|
-
- `text` questions for free-form answers
|
|
10
|
-
- optional `Other...` inputs for custom answers
|
|
11
|
-
|
|
12
|
-
## Install
|
|
5
|
+
## 설치
|
|
13
6
|
|
|
14
7
|
```bash
|
|
15
8
|
pi install npm:@ryan_nookpi/pi-extension-ask-user-question
|
|
16
9
|
```
|
|
17
10
|
|
|
18
|
-
##
|
|
11
|
+
## 무엇을 할 수 있나
|
|
12
|
+
|
|
13
|
+
- `radio`: 하나만 고르는 단일 선택 질문
|
|
14
|
+
- `checkbox`: 여러 개를 고르는 복수 선택 질문
|
|
15
|
+
- `text`: 자유 입력 질문
|
|
16
|
+
- `allowOther: true`: 선택지 외 값을 직접 입력하는 `기타...` 경로 제공
|
|
17
|
+
- 여러 질문을 한 번의 폼으로 묶어 입력 받기
|
|
18
|
+
|
|
19
|
+
## 언제 쓰면 좋은가
|
|
20
|
+
|
|
21
|
+
- 요구사항이 모호해서 확인이 필요할 때
|
|
22
|
+
- 여러 선택지 중 하나를 고르게 해야 할 때
|
|
23
|
+
- 선호도, 배포 환경, 옵션 조합 등을 한 번에 수집할 때
|
|
24
|
+
- 일반 텍스트 질문보다 구조화된 응답이 필요할 때
|
|
25
|
+
|
|
26
|
+
## 파라미터 가이드
|
|
27
|
+
|
|
28
|
+
### 최상위 필드
|
|
29
|
+
|
|
30
|
+
- `title`: 폼 상단 제목
|
|
31
|
+
- `description`: 폼 설명/안내 문구
|
|
32
|
+
- `questions`: 질문 배열
|
|
33
|
+
|
|
34
|
+
### 질문 필드
|
|
35
|
+
|
|
36
|
+
- `id`: 질문 식별자
|
|
37
|
+
- `type`: `radio` | `checkbox` | `text`
|
|
38
|
+
- `prompt`: 사용자에게 보여줄 질문 문구
|
|
39
|
+
- `label`: 탭에 표시할 짧은 이름. 생략하면 `Q1`, `Q2`처럼 자동 생성
|
|
40
|
+
- `options`: `radio`/`checkbox`에서 사용할 선택지 목록
|
|
41
|
+
- `allowOther`: `기타...` 직접 입력 허용 여부
|
|
42
|
+
- `required`: 필수 응답 여부
|
|
43
|
+
- `placeholder`: `text` 입력창 플레이스홀더
|
|
44
|
+
- `default`: 기본값. `radio`/`text`는 문자열, `checkbox`는 문자열 배열
|
|
45
|
+
|
|
46
|
+
### 옵션 필드
|
|
47
|
+
|
|
48
|
+
- `value`: 실제 반환값
|
|
49
|
+
- `label`: 화면에 보이는 문구
|
|
50
|
+
- `description`: 선택지 아래 보조 설명
|
|
51
|
+
|
|
52
|
+
## 예시
|
|
19
53
|
|
|
20
54
|
```json
|
|
21
55
|
{
|
|
22
|
-
"title": "
|
|
23
|
-
"description": "
|
|
56
|
+
"title": "배포 설정 확인",
|
|
57
|
+
"description": "진행 전에 몇 가지 선택이 필요합니다.",
|
|
24
58
|
"questions": [
|
|
25
59
|
{
|
|
26
60
|
"id": "env",
|
|
27
61
|
"type": "radio",
|
|
28
|
-
"prompt": "
|
|
62
|
+
"prompt": "어느 환경에 배포할까요?",
|
|
29
63
|
"options": [
|
|
30
|
-
{ "value": "staging", "label": "
|
|
31
|
-
{ "value": "prod", "label": "
|
|
64
|
+
{ "value": "staging", "label": "스테이징" },
|
|
65
|
+
{ "value": "prod", "label": "프로덕션" }
|
|
66
|
+
]
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
"id": "targets",
|
|
70
|
+
"type": "checkbox",
|
|
71
|
+
"prompt": "이번에 함께 반영할 항목은 무엇인가요?",
|
|
72
|
+
"options": [
|
|
73
|
+
{ "value": "web", "label": "웹" },
|
|
74
|
+
{ "value": "admin", "label": "어드민" }
|
|
32
75
|
]
|
|
33
76
|
},
|
|
34
77
|
{
|
|
35
78
|
"id": "notes",
|
|
36
79
|
"type": "text",
|
|
37
|
-
"prompt": "
|
|
80
|
+
"prompt": "추가로 알아야 할 점이 있나요?",
|
|
38
81
|
"required": false,
|
|
39
|
-
"placeholder": "
|
|
82
|
+
"placeholder": "선택 사항"
|
|
40
83
|
}
|
|
41
84
|
]
|
|
42
85
|
}
|
|
43
86
|
```
|
|
87
|
+
|
|
88
|
+
## 반환 형태
|
|
89
|
+
|
|
90
|
+
응답은 각 질문의 `id` 기준으로 정리되어 반환됩니다. 취소 시에는 `cancelled: true`가 내려오고, 완료 시에는 질문별 답변 목록이 포함됩니다.
|
package/controller.test.ts
CHANGED
|
@@ -112,7 +112,7 @@ describe("ask-user-question/controller", () => {
|
|
|
112
112
|
setup.controller.handleInput("\r");
|
|
113
113
|
expect(setup.controller.getState()).toMatchObject({ otherMode: true, otherQuestionId: "radio" });
|
|
114
114
|
expect(setup.editor.getText()).toBe("saved");
|
|
115
|
-
expect(setup.controller.render(80).join("\n")).toContain("
|
|
115
|
+
expect(setup.controller.render(80).join("\n")).toContain("직접 입력:");
|
|
116
116
|
|
|
117
117
|
setup.controller.handleInput("x");
|
|
118
118
|
setup.controller.handleInput("\t");
|
package/index.test.ts
CHANGED
|
@@ -21,7 +21,7 @@ describe("ask-user-question extension", () => {
|
|
|
21
21
|
expect(AskUserQuestionParams).toBeTruthy();
|
|
22
22
|
expect(errorResult("boom").content[0].text).toBe("boom");
|
|
23
23
|
expect(normalizeQuestions([{ id: "x", type: "text", prompt: "Q" }])[0].label).toBe("Q1");
|
|
24
|
-
expect(buildRenderCallText({ questions: [{ id: "x", type: "text", prompt: "Q" }] }, theme)).toContain("1
|
|
24
|
+
expect(buildRenderCallText({ questions: [{ id: "x", type: "text", prompt: "Q" }] }, theme)).toContain("1개 문항");
|
|
25
25
|
expect(buildRenderResultText({ content: [{ type: "text", text: "plain" }] }, theme)).toBe("plain");
|
|
26
26
|
});
|
|
27
27
|
|
|
@@ -44,9 +44,9 @@ describe("ask-user-question extension", () => {
|
|
|
44
44
|
} as unknown as ExtensionContext);
|
|
45
45
|
|
|
46
46
|
expect(noUi).toMatchObject({
|
|
47
|
-
content: [{ type: "text", text: "
|
|
47
|
+
content: [{ type: "text", text: "오류: UI를 사용할 수 없습니다. 현재 비대화형 모드에서 실행 중입니다." }],
|
|
48
48
|
});
|
|
49
|
-
expect(noQuestions).toMatchObject({ content: [{ type: "text", text: "
|
|
49
|
+
expect(noQuestions).toMatchObject({ content: [{ type: "text", text: "오류: 질문이 제공되지 않았습니다." }] });
|
|
50
50
|
});
|
|
51
51
|
|
|
52
52
|
it("returns cancelled and successful execution results", async () => {
|
|
@@ -103,7 +103,7 @@ describe("ask-user-question extension", () => {
|
|
|
103
103
|
} as unknown as ExtensionContext,
|
|
104
104
|
);
|
|
105
105
|
|
|
106
|
-
expect(cancelledResult).toMatchObject({ content: [{ type: "text", text: "
|
|
106
|
+
expect(cancelledResult).toMatchObject({ content: [{ type: "text", text: "사용자가 입력 폼을 취소했습니다" }] });
|
|
107
107
|
expect(successResult).toMatchObject({ content: [{ type: "text", text: "Q1: hello\nQ2: a" }] });
|
|
108
108
|
expect(
|
|
109
109
|
tool.renderCall({ title: "Title", questions: [{ id: "x", type: "text", prompt: "Q" }] }, theme),
|
package/index.ts
CHANGED
|
@@ -13,25 +13,25 @@ import { AskUserQuestionParams } from "./schema.ts";
|
|
|
13
13
|
import { normalizeQuestions } from "./state.ts";
|
|
14
14
|
import type { AskUserQuestionParamsInput, FormResult, Question } from "./types.ts";
|
|
15
15
|
|
|
16
|
-
const TOOL_DESCRIPTION =
|
|
17
|
-
- **radio**:
|
|
18
|
-
- **checkbox**:
|
|
19
|
-
- **text**:
|
|
16
|
+
const TOOL_DESCRIPTION = `인터랙티브 폼으로 사용자에게 하나 이상의 질문을 묻습니다. 지원하는 질문 유형은 다음 세 가지입니다:
|
|
17
|
+
- **radio**: 미리 정의된 보기 중 하나를 고르는 단일 선택
|
|
18
|
+
- **checkbox**: 여러 보기를 동시에 고르는 복수 선택
|
|
19
|
+
- **text**: 자유롭게 입력하는 텍스트 답변
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
radio/checkbox 질문에는 사용자가 직접 값을 입력할 수 있는 "기타..." 옵션을 포함할 수 있습니다.
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
요구사항을 확인하거나, 선호도를 물어보거나, 구현 방향에 대한 결정을 받아야 할 때 사용하세요. 일반 텍스트로 질문을 던지는 대신 이 도구를 우선 사용합니다.`;
|
|
24
24
|
|
|
25
25
|
const PROMPT_GUIDELINES = [
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
"
|
|
29
|
-
"
|
|
26
|
+
"구조화된 사용자 입력이 필요하면 일반 텍스트 질문 대신 ask_user_question을 사용하세요.",
|
|
27
|
+
"단일 선택은 radio, 복수 선택은 checkbox, 서술형 답변은 text를 우선 사용하세요.",
|
|
28
|
+
"선택지가 완전히 닫혀 있지 않다면 allowOther: true로 '기타' 입력 경로를 열어두세요.",
|
|
29
|
+
"관련 질문은 여러 번 나누지 말고 한 번의 호출에 묶어 전달하세요.",
|
|
30
30
|
];
|
|
31
31
|
|
|
32
32
|
function buildCancelledResponse(result: FormResult) {
|
|
33
33
|
return {
|
|
34
|
-
content: [{ type: "text" as const, text: "
|
|
34
|
+
content: [{ type: "text" as const, text: "사용자가 입력 폼을 취소했습니다" }],
|
|
35
35
|
details: result,
|
|
36
36
|
};
|
|
37
37
|
}
|
|
@@ -49,19 +49,19 @@ export { AskUserQuestionParams, buildRenderCallText, buildRenderResultText, erro
|
|
|
49
49
|
export default function askUserQuestion(pi: ExtensionAPI) {
|
|
50
50
|
pi.registerTool({
|
|
51
51
|
name: "ask_user_question",
|
|
52
|
-
label: "
|
|
52
|
+
label: "사용자 질문",
|
|
53
53
|
description: TOOL_DESCRIPTION,
|
|
54
|
-
promptSnippet: "
|
|
54
|
+
promptSnippet: "radio, checkbox, text 입력을 사용하는 인터랙티브 질문 폼 열기",
|
|
55
55
|
promptGuidelines: PROMPT_GUIDELINES,
|
|
56
56
|
parameters: AskUserQuestionParams,
|
|
57
57
|
async execute(_toolCallId, rawParams, _signal, _onUpdate, ctx) {
|
|
58
58
|
if (!ctx.hasUI) {
|
|
59
|
-
return errorResult("
|
|
59
|
+
return errorResult("오류: UI를 사용할 수 없습니다. 현재 비대화형 모드에서 실행 중입니다.");
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
const params = rawParams as AskUserQuestionParamsInput;
|
|
63
63
|
if (!params.questions.length) {
|
|
64
|
-
return errorResult("
|
|
64
|
+
return errorResult("오류: 질문이 제공되지 않았습니다.");
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
const questions = normalizeQuestions(params.questions as Question[]);
|
package/output.test.ts
CHANGED
|
@@ -40,7 +40,7 @@ describe("ask-user-question/output", () => {
|
|
|
40
40
|
});
|
|
41
41
|
|
|
42
42
|
it("formats successful result content", () => {
|
|
43
|
-
expect(formatResultContent(formResult)).toBe("One: (
|
|
43
|
+
expect(formatResultContent(formResult)).toBe("One: (직접 입력) custom\nTwo: (선택 없음)\nThree: (비어 있음)");
|
|
44
44
|
expect(
|
|
45
45
|
formatResultContent({
|
|
46
46
|
...formResult,
|
|
@@ -62,8 +62,8 @@ describe("ask-user-question/output", () => {
|
|
|
62
62
|
it("builds render-call summaries", () => {
|
|
63
63
|
expect(
|
|
64
64
|
buildRenderCallText({ title: "Title", questions: [{ id: "x", type: "radio", prompt: "Q" }] }, theme),
|
|
65
|
-
).toContain("Title 1
|
|
66
|
-
expect(buildRenderCallText({}, theme)).toContain("0
|
|
65
|
+
).toContain("Title 1개 문항 (radio)");
|
|
66
|
+
expect(buildRenderCallText({}, theme)).toContain("0개 문항");
|
|
67
67
|
expect(renderCall({ title: "Title", questions: [] }, theme)).toBeTruthy();
|
|
68
68
|
});
|
|
69
69
|
|
|
@@ -72,9 +72,9 @@ describe("ask-user-question/output", () => {
|
|
|
72
72
|
expect(buildRenderResultText({ content: [{ type: "text" }] }, theme)).toBe("");
|
|
73
73
|
expect(buildRenderResultText({ content: [{ type: "image", text: "ignored" }] }, theme)).toBe("");
|
|
74
74
|
expect(buildRenderResultText({}, theme)).toBe("");
|
|
75
|
-
expect(buildRenderResultText({ details: { ...formResult, cancelled: true } }, theme)).toBe("
|
|
75
|
+
expect(buildRenderResultText({ details: { ...formResult, cancelled: true } }, theme)).toBe("취소됨");
|
|
76
76
|
expect(buildRenderResultText({ details: formResult }, theme)).toBe(
|
|
77
|
-
"✓ One: (
|
|
77
|
+
"✓ One: (직접 입력) custom\n✓ Two: (선택 없음)\n✓ Three: (비어 있음)",
|
|
78
78
|
);
|
|
79
79
|
expect(
|
|
80
80
|
buildRenderResultText(
|
package/output.ts
CHANGED
|
@@ -19,14 +19,14 @@ export function formatResultContent(result: FormResult): string {
|
|
|
19
19
|
const question = result.questions.find((candidate) => candidate.id === answer.id);
|
|
20
20
|
const label = question?.label || answer.id;
|
|
21
21
|
if (answer.type === "radio") {
|
|
22
|
-
const prefix = answer.wasCustom ? "(
|
|
22
|
+
const prefix = answer.wasCustom ? "(직접 입력) " : "";
|
|
23
23
|
return `${label}: ${prefix}${answer.value}`;
|
|
24
24
|
}
|
|
25
25
|
if (answer.type === "checkbox") {
|
|
26
26
|
const values = Array.isArray(answer.value) ? answer.value : [answer.value];
|
|
27
|
-
return values.length === 0 ? `${label}: (
|
|
27
|
+
return values.length === 0 ? `${label}: (선택 없음)` : `${label}: ${values.join(", ")}`;
|
|
28
28
|
}
|
|
29
|
-
return `${label}: ${answer.value || "(
|
|
29
|
+
return `${label}: ${answer.value || "(비어 있음)"}`;
|
|
30
30
|
})
|
|
31
31
|
.join("\n");
|
|
32
32
|
}
|
|
@@ -37,7 +37,7 @@ export function buildRenderCallText(args: { questions?: Question[]; title?: stri
|
|
|
37
37
|
if (args.title) {
|
|
38
38
|
text += `${theme.fg("accent", args.title)} `;
|
|
39
39
|
}
|
|
40
|
-
text += theme.fg("muted", `${questions.length}
|
|
40
|
+
text += theme.fg("muted", `${questions.length}개 문항`);
|
|
41
41
|
const types = [...new Set(questions.map((question) => question.type))].join(", ");
|
|
42
42
|
if (types) {
|
|
43
43
|
text += theme.fg("dim", ` (${types})`);
|
|
@@ -56,7 +56,7 @@ export function buildRenderResultText(
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
if (details.cancelled) {
|
|
59
|
-
return theme.fg("warning", "
|
|
59
|
+
return theme.fg("warning", "취소됨");
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
return details.answers
|
|
@@ -64,15 +64,15 @@ export function buildRenderResultText(
|
|
|
64
64
|
const question = details.questions.find((candidate) => candidate.id === answer.id);
|
|
65
65
|
const label = question?.label || answer.id;
|
|
66
66
|
if (answer.type === "radio") {
|
|
67
|
-
const prefix = answer.wasCustom ? theme.fg("dim", "(
|
|
67
|
+
const prefix = answer.wasCustom ? theme.fg("dim", "(직접 입력) ") : "";
|
|
68
68
|
return `${theme.fg("success", SYM.check)} ${theme.fg("accent", label)}: ${prefix}${answer.value}`;
|
|
69
69
|
}
|
|
70
70
|
if (answer.type === "checkbox") {
|
|
71
71
|
const values = Array.isArray(answer.value) ? answer.value : [answer.value];
|
|
72
|
-
const display = values.length ? values.join(", ") : theme.fg("dim", "(
|
|
72
|
+
const display = values.length ? values.join(", ") : theme.fg("dim", "(선택 없음)");
|
|
73
73
|
return `${theme.fg("success", SYM.check)} ${theme.fg("accent", label)}: ${display}`;
|
|
74
74
|
}
|
|
75
|
-
return `${theme.fg("success", SYM.check)} ${theme.fg("accent", label)}: ${answer.value || theme.fg("dim", "(
|
|
75
|
+
return `${theme.fg("success", SYM.check)} ${theme.fg("accent", label)}: ${answer.value || theme.fg("dim", "(비어 있음)")}`;
|
|
76
76
|
})
|
|
77
77
|
.join("\n");
|
|
78
78
|
}
|
package/package.json
CHANGED
package/schema.ts
CHANGED
|
@@ -2,36 +2,35 @@ import { StringEnum } from "@mariozechner/pi-ai";
|
|
|
2
2
|
import { Type } from "@sinclair/typebox";
|
|
3
3
|
|
|
4
4
|
export const OptionSchema = Type.Object({
|
|
5
|
-
value: Type.String({ description: "
|
|
6
|
-
label: Type.String({ description: "
|
|
7
|
-
description: Type.Optional(Type.String({ description: "
|
|
5
|
+
value: Type.String({ description: "선택 시 반환할 값" }),
|
|
6
|
+
label: Type.String({ description: "화면에 표시할 라벨" }),
|
|
7
|
+
description: Type.Optional(Type.String({ description: "옵션 아래에 표시할 보조 설명" })),
|
|
8
8
|
});
|
|
9
9
|
|
|
10
10
|
export const QuestionSchema = Type.Object({
|
|
11
|
-
id: Type.String({ description: "
|
|
11
|
+
id: Type.String({ description: "질문 고유 식별자" }),
|
|
12
12
|
type: StringEnum(["radio", "checkbox", "text"] as const, {
|
|
13
|
-
description: "
|
|
13
|
+
description: "질문 유형: radio(단일 선택), checkbox(복수 선택), text(자유 입력)",
|
|
14
14
|
}),
|
|
15
|
-
prompt: Type.String({ description: "
|
|
16
|
-
label: Type.Optional(Type.String({ description: "
|
|
17
|
-
options: Type.Optional(Type.Array(OptionSchema, { description: "
|
|
15
|
+
prompt: Type.String({ description: "사용자에게 표시할 질문 문구" }),
|
|
16
|
+
label: Type.Optional(Type.String({ description: "탭 바에 표시할 짧은 라벨(기본값: Q1, Q2...)" })),
|
|
17
|
+
options: Type.Optional(Type.Array(OptionSchema, { description: "radio/checkbox용 선택지 목록" })),
|
|
18
18
|
allowOther: Type.Optional(
|
|
19
|
-
Type.Boolean({ description: "
|
|
19
|
+
Type.Boolean({ description: "'기타...' 직접 입력 옵션 추가 여부 (radio/checkbox 기본값: true)" }),
|
|
20
20
|
),
|
|
21
|
-
required: Type.Optional(Type.Boolean({ description: "
|
|
22
|
-
placeholder: Type.Optional(Type.String({ description: "
|
|
21
|
+
required: Type.Optional(Type.Boolean({ description: "응답 필수 여부 (기본값: true)" })),
|
|
22
|
+
placeholder: Type.Optional(Type.String({ description: "text 입력 시 표시할 플레이스홀더" })),
|
|
23
23
|
default: Type.Optional(
|
|
24
24
|
Type.Union([Type.String(), Type.Array(Type.String())], {
|
|
25
|
-
description: "
|
|
25
|
+
description: "기본값. radio/text는 문자열, checkbox는 문자열 배열",
|
|
26
26
|
}),
|
|
27
27
|
),
|
|
28
28
|
});
|
|
29
29
|
|
|
30
30
|
export const AskUserQuestionParams = Type.Object({
|
|
31
|
-
title: Type.Optional(Type.String({ description: "
|
|
32
|
-
description: Type.Optional(Type.String({ description: "
|
|
31
|
+
title: Type.Optional(Type.String({ description: "폼 상단에 표시할 제목" })),
|
|
32
|
+
description: Type.Optional(Type.String({ description: "폼 상단에 표시할 안내 문구" })),
|
|
33
33
|
questions: Type.Array(QuestionSchema, {
|
|
34
|
-
description:
|
|
35
|
-
"One or more questions to ask. Use radio for single-select, checkbox for multi-select, text for free input",
|
|
34
|
+
description: "질문 목록. radio는 단일 선택, checkbox는 복수 선택, text는 자유 입력",
|
|
36
35
|
}),
|
|
37
36
|
});
|
package/view.test.ts
CHANGED
|
@@ -52,10 +52,10 @@ describe("ask-user-question/view", () => {
|
|
|
52
52
|
const input = createInput({ currentTab: 2 });
|
|
53
53
|
const output = renderForm(input).join("\n");
|
|
54
54
|
|
|
55
|
-
expect(output).toContain("
|
|
55
|
+
expect(output).toContain("검토 및 제출");
|
|
56
56
|
expect(output).toContain("Q1:");
|
|
57
|
-
expect(output).toContain("
|
|
58
|
-
expect(output).toContain("Tab/←→
|
|
57
|
+
expect(output).toContain("필수 응답: Q1, Q2");
|
|
58
|
+
expect(output).toContain("Tab/←→ 질문 이동 • Enter 제출 • Esc 취소");
|
|
59
59
|
});
|
|
60
60
|
|
|
61
61
|
it("renders radio questions including descriptions and other-mode editor", () => {
|
|
@@ -80,12 +80,12 @@ describe("ask-user-question/view", () => {
|
|
|
80
80
|
}),
|
|
81
81
|
).join("\n");
|
|
82
82
|
|
|
83
|
-
expect(output).toContain("**Pick one** [
|
|
83
|
+
expect(output).toContain("**Pick one** [단일 선택]");
|
|
84
84
|
expect(output).toContain("Alpha");
|
|
85
85
|
expect(output).toContain("first");
|
|
86
|
-
expect(output).toContain("
|
|
87
|
-
expect(output).toContain("
|
|
88
|
-
expect(output).toContain("Enter
|
|
86
|
+
expect(output).toContain("기타: custom");
|
|
87
|
+
expect(output).toContain("직접 입력:");
|
|
88
|
+
expect(output).toContain("Enter 제출 • Esc 돌아가기");
|
|
89
89
|
});
|
|
90
90
|
|
|
91
91
|
it("renders checkbox and text questions with placeholders and text footer", () => {
|
|
@@ -110,17 +110,17 @@ describe("ask-user-question/view", () => {
|
|
|
110
110
|
const checkboxOutput = renderForm(createInput({ questions, answerState, editorLines: [], editorText: "" })).join(
|
|
111
111
|
"\n",
|
|
112
112
|
);
|
|
113
|
-
expect(checkboxOutput).toContain("**Pick many** [
|
|
114
|
-
expect(checkboxOutput).toContain("
|
|
115
|
-
expect(checkboxOutput).toContain("Space
|
|
113
|
+
expect(checkboxOutput).toContain("**Pick many** [복수 선택]");
|
|
114
|
+
expect(checkboxOutput).toContain("기타: custom option");
|
|
115
|
+
expect(checkboxOutput).toContain("Space 토글");
|
|
116
116
|
|
|
117
117
|
const textOutput = renderForm(
|
|
118
118
|
createInput({ questions, answerState, currentTab: 1, editorLines: ["typed line"], editorText: "" }),
|
|
119
119
|
).join("\n");
|
|
120
|
-
expect(textOutput).toContain("**Explain** [
|
|
120
|
+
expect(textOutput).toContain("**Explain** [텍스트]");
|
|
121
121
|
expect(textOutput).toContain("Type here");
|
|
122
122
|
expect(textOutput).toContain("typed line");
|
|
123
|
-
expect(textOutput).toContain("Tab/←→
|
|
123
|
+
expect(textOutput).toContain("Tab/←→ 이동 • Enter 제출 • Esc 취소");
|
|
124
124
|
});
|
|
125
125
|
|
|
126
126
|
it("renders answered submit states and optional question variants", () => {
|
|
@@ -152,27 +152,27 @@ describe("ask-user-question/view", () => {
|
|
|
152
152
|
answerState.textAnswers.set("text", "filled");
|
|
153
153
|
|
|
154
154
|
const submitOutput = renderForm(createInput({ questions, answerState, currentTab: 3 })).join("\n");
|
|
155
|
-
expect(submitOutput).toContain("
|
|
155
|
+
expect(submitOutput).toContain("Enter로 제출");
|
|
156
156
|
expect(submitOutput).toContain("Alpha");
|
|
157
157
|
expect(submitOutput).toContain("b");
|
|
158
158
|
expect(submitOutput).toContain("filled");
|
|
159
159
|
|
|
160
160
|
const radioOutput = renderForm(createInput({ questions, answerState, currentTab: 0, cursorIdx: 0 })).join("\n");
|
|
161
161
|
expect(radioOutput).toContain("❯ ◉ Alpha");
|
|
162
|
-
expect(radioOutput).toContain("Enter
|
|
163
|
-
expect(radioOutput).not.toContain("
|
|
162
|
+
expect(radioOutput).toContain("Enter 선택 • Esc 취소");
|
|
163
|
+
expect(radioOutput).not.toContain("기타...");
|
|
164
164
|
|
|
165
165
|
const checkboxOutput = renderForm(createInput({ questions, answerState, currentTab: 1, cursorIdx: 0 })).join("\n");
|
|
166
166
|
expect(checkboxOutput).toContain("☑ Beta");
|
|
167
167
|
expect(checkboxOutput).toContain("desc");
|
|
168
|
-
expect(checkboxOutput).toContain("Enter
|
|
168
|
+
expect(checkboxOutput).toContain("Enter 다음");
|
|
169
169
|
|
|
170
170
|
const textOutput = renderForm(
|
|
171
171
|
createInput({ questions, answerState, currentTab: 2, editorLines: ["filled"], editorText: "filled" }),
|
|
172
172
|
).join("\n");
|
|
173
173
|
expect(textOutput).toContain("filled");
|
|
174
|
-
expect(textOutput).toContain("Enter
|
|
175
|
-
expect(textOutput).not.toContain("
|
|
174
|
+
expect(textOutput).toContain("Enter 제출 • Esc 취소");
|
|
175
|
+
expect(textOutput).not.toContain("*필수");
|
|
176
176
|
});
|
|
177
177
|
|
|
178
178
|
it("covers direct helper branches for invalid tabs and compact variants", () => {
|
|
@@ -198,7 +198,7 @@ describe("ask-user-question/view", () => {
|
|
|
198
198
|
submitInput.answerState.radioAnswers.set("radio", { value: "custom", label: "custom", wasCustom: true });
|
|
199
199
|
submitInput.answerState.checkCustom.set("check", "custom");
|
|
200
200
|
renderSubmitTab(submitInput, (text) => submitLines.push(text), 80);
|
|
201
|
-
expect(submitLines.join("\n")).toContain("(
|
|
201
|
+
expect(submitLines.join("\n")).toContain("(직접 입력) custom");
|
|
202
202
|
|
|
203
203
|
const unansweredLines: string[] = [];
|
|
204
204
|
const unansweredInput = createInput({
|
|
@@ -207,7 +207,7 @@ describe("ask-user-question/view", () => {
|
|
|
207
207
|
});
|
|
208
208
|
unansweredInput.answerState.checkAnswers.delete("check");
|
|
209
209
|
renderSubmitTab(unansweredInput, (text) => unansweredLines.push(text), 80);
|
|
210
|
-
expect(unansweredLines.join("\n")).toContain("(
|
|
210
|
+
expect(unansweredLines.join("\n")).toContain("(미응답)");
|
|
211
211
|
|
|
212
212
|
const output = renderForm(createInput({ currentTab: 99 }));
|
|
213
213
|
expect(output.length).toBeGreaterThanOrEqual(2);
|
|
@@ -233,7 +233,7 @@ describe("ask-user-question/view", () => {
|
|
|
233
233
|
80,
|
|
234
234
|
);
|
|
235
235
|
expect(radioLines.join("\n")).toContain("❯ ◉ Alpha");
|
|
236
|
-
expect(radioLines.join("\n")).toContain("
|
|
236
|
+
expect(radioLines.join("\n")).toContain("기타...");
|
|
237
237
|
const radioSelectedLines: string[] = [];
|
|
238
238
|
renderQuestion(
|
|
239
239
|
createInput({
|
|
@@ -264,8 +264,8 @@ describe("ask-user-question/view", () => {
|
|
|
264
264
|
}),
|
|
265
265
|
).join("\n");
|
|
266
266
|
expect(checkboxOutput).toContain("☐ Alpha");
|
|
267
|
-
expect(checkboxOutput).toContain("
|
|
268
|
-
expect(checkboxOutput).toContain("Enter
|
|
267
|
+
expect(checkboxOutput).toContain("기타...");
|
|
268
|
+
expect(checkboxOutput).toContain("Enter 제출 • Esc 취소");
|
|
269
269
|
checkboxState.checkAnswers.set("check", new Set(["a"]));
|
|
270
270
|
const checkboxOtherCursor = renderForm(
|
|
271
271
|
createInput({
|
|
@@ -278,7 +278,7 @@ describe("ask-user-question/view", () => {
|
|
|
278
278
|
}),
|
|
279
279
|
).join("\n");
|
|
280
280
|
expect(checkboxOtherCursor).toContain("☑ Alpha");
|
|
281
|
-
expect(checkboxOtherCursor).toContain("❯ ☐
|
|
281
|
+
expect(checkboxOtherCursor).toContain("❯ ☐ 기타...");
|
|
282
282
|
const checkboxLines: string[] = [];
|
|
283
283
|
renderQuestion(
|
|
284
284
|
createInput({
|
|
@@ -319,7 +319,7 @@ describe("ask-user-question/view", () => {
|
|
|
319
319
|
editorLines: [],
|
|
320
320
|
}),
|
|
321
321
|
).join("\n");
|
|
322
|
-
expect(radioFooterOutput).toContain("Enter
|
|
322
|
+
expect(radioFooterOutput).toContain("Enter 선택 • Esc 취소");
|
|
323
323
|
const textOutput = renderForm(
|
|
324
324
|
createInput({
|
|
325
325
|
title: undefined,
|
|
@@ -330,8 +330,8 @@ describe("ask-user-question/view", () => {
|
|
|
330
330
|
editorText: "",
|
|
331
331
|
}),
|
|
332
332
|
).join("\n");
|
|
333
|
-
expect(textOutput).toContain("**Only** [
|
|
334
|
-
expect(textOutput).toContain("Enter
|
|
335
|
-
expect(textOutput).not.toContain("
|
|
333
|
+
expect(textOutput).toContain("**Only** [텍스트]");
|
|
334
|
+
expect(textOutput).toContain("Enter 제출 • Esc 취소");
|
|
335
|
+
expect(textOutput).not.toContain("*필수");
|
|
336
336
|
});
|
|
337
337
|
});
|
package/view.ts
CHANGED
|
@@ -14,7 +14,7 @@ function createLineHelpers(width: number, theme: RenderFormInput["theme"]) {
|
|
|
14
14
|
|
|
15
15
|
export function renderSubmitTab(input: RenderFormInput, add: (text: string) => void, maxWidth: number): void {
|
|
16
16
|
const { questions, answerState, theme } = input;
|
|
17
|
-
add(` ${theme.fg("accent", theme.bold("
|
|
17
|
+
add(` ${theme.fg("accent", theme.bold("검토 및 제출"))}`);
|
|
18
18
|
add("");
|
|
19
19
|
|
|
20
20
|
for (const question of questions) {
|
|
@@ -22,10 +22,10 @@ export function renderSubmitTab(input: RenderFormInput, add: (text: string) => v
|
|
|
22
22
|
if (question.type === "radio") {
|
|
23
23
|
const answer = answerState.radioAnswers.get(question.id);
|
|
24
24
|
if (answer) {
|
|
25
|
-
const prefix = answer.wasCustom ? theme.fg("dim", "(
|
|
25
|
+
const prefix = answer.wasCustom ? theme.fg("dim", "(직접 입력) ") : "";
|
|
26
26
|
add(` ${label} ${prefix}${answer.label}`);
|
|
27
27
|
} else {
|
|
28
|
-
add(` ${label} ${theme.fg("warning", "(
|
|
28
|
+
add(` ${label} ${theme.fg("warning", "(미응답)")}`);
|
|
29
29
|
}
|
|
30
30
|
continue;
|
|
31
31
|
}
|
|
@@ -34,8 +34,8 @@ export function renderSubmitTab(input: RenderFormInput, add: (text: string) => v
|
|
|
34
34
|
const selected = answerState.checkAnswers.get(question.id) ?? new Set<string>();
|
|
35
35
|
const custom = answerState.checkCustom.get(question.id)?.trim();
|
|
36
36
|
const values = [...selected];
|
|
37
|
-
if (custom) values.push(`${theme.fg("dim", "(
|
|
38
|
-
add(` ${label} ${values.length ? values.join(", ") : theme.fg("warning", "(
|
|
37
|
+
if (custom) values.push(`${theme.fg("dim", "(직접 입력)")} ${custom}`);
|
|
38
|
+
add(` ${label} ${values.length ? values.join(", ") : theme.fg("warning", "(미응답)")}`);
|
|
39
39
|
continue;
|
|
40
40
|
}
|
|
41
41
|
|
|
@@ -43,23 +43,23 @@ export function renderSubmitTab(input: RenderFormInput, add: (text: string) => v
|
|
|
43
43
|
if (answer) {
|
|
44
44
|
add(` ${label} ${truncateToWidth(answer, maxWidth - visibleWidth(question.label) - 5)}`);
|
|
45
45
|
} else {
|
|
46
|
-
add(` ${label} ${theme.fg("warning", "(
|
|
46
|
+
add(` ${label} ${theme.fg("warning", "(미응답)")}`);
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
add("");
|
|
51
51
|
if (allRequiredAnswered(answerState, questions)) {
|
|
52
|
-
add(` ${theme.fg("success", "
|
|
52
|
+
add(` ${theme.fg("success", "Enter로 제출")}`);
|
|
53
53
|
} else {
|
|
54
54
|
const missing = questions
|
|
55
55
|
.filter((question) => question.required && !isAnswered(answerState, question))
|
|
56
56
|
.map((question) => question.label)
|
|
57
57
|
.join(", ");
|
|
58
|
-
add(` ${theme.fg("warning",
|
|
58
|
+
add(` ${theme.fg("warning", `필수 응답: ${missing}`)}`);
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
add("");
|
|
62
|
-
add(theme.fg("dim", " Tab/←→
|
|
62
|
+
add(theme.fg("dim", " Tab/←→ 질문 이동 • Enter 제출 • Esc 취소"));
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
export function renderTabBar(input: RenderFormInput, add: (text: string) => void): void {
|
|
@@ -76,7 +76,7 @@ export function renderTabBar(input: RenderFormInput, add: (text: string) => void
|
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
const submitActive = currentTab === questions.length;
|
|
79
|
-
const submitText = ` ${SYM.submit}
|
|
79
|
+
const submitText = ` ${SYM.submit} 제출 `;
|
|
80
80
|
tabs.push(
|
|
81
81
|
submitActive
|
|
82
82
|
? theme.bg("selectedBg", theme.fg("text", submitText))
|
|
@@ -93,13 +93,13 @@ export function renderQuestion(input: RenderFormInput, add: (text: string) => vo
|
|
|
93
93
|
|
|
94
94
|
const typeTag =
|
|
95
95
|
question.type === "radio"
|
|
96
|
-
? input.theme.fg("dim", "[
|
|
96
|
+
? input.theme.fg("dim", "[단일 선택]")
|
|
97
97
|
: question.type === "checkbox"
|
|
98
|
-
? input.theme.fg("dim", "[
|
|
99
|
-
: input.theme.fg("dim", "[
|
|
98
|
+
? input.theme.fg("dim", "[복수 선택]")
|
|
99
|
+
: input.theme.fg("dim", "[텍스트]");
|
|
100
100
|
|
|
101
101
|
add(` ${input.theme.fg("text", input.theme.bold(question.prompt))} ${typeTag}`);
|
|
102
|
-
if (question.required) add(` ${input.theme.fg("warning", "
|
|
102
|
+
if (question.required) add(` ${input.theme.fg("warning", "*필수")}`);
|
|
103
103
|
add("");
|
|
104
104
|
|
|
105
105
|
if (question.type === "radio") {
|
|
@@ -119,7 +119,7 @@ export function renderQuestion(input: RenderFormInput, add: (text: string) => vo
|
|
|
119
119
|
const isSelected = selected?.wasCustom === true;
|
|
120
120
|
const bullet = isSelected ? input.theme.fg("accent", SYM.radioOn) : input.theme.fg("dim", SYM.radioOff);
|
|
121
121
|
const pointer = isCursor ? input.theme.fg("accent", SYM.pointer) : " ";
|
|
122
|
-
const label = isSelected ?
|
|
122
|
+
const label = isSelected ? `기타: ${selected.label}` : "기타...";
|
|
123
123
|
add(` ${pointer} ${bullet} ${input.theme.fg(isCursor ? "accent" : "muted", label)}`);
|
|
124
124
|
}
|
|
125
125
|
}
|
|
@@ -141,14 +141,14 @@ export function renderQuestion(input: RenderFormInput, add: (text: string) => vo
|
|
|
141
141
|
const custom = input.answerState.checkCustom.get(question.id)?.trim();
|
|
142
142
|
const box = custom ? input.theme.fg("accent", SYM.checkOn) : input.theme.fg("dim", SYM.checkOff);
|
|
143
143
|
const pointer = isCursor ? input.theme.fg("accent", SYM.pointer) : " ";
|
|
144
|
-
const label = custom ?
|
|
144
|
+
const label = custom ? `기타: ${custom}` : "기타...";
|
|
145
145
|
add(` ${pointer} ${box} ${input.theme.fg(isCursor ? "accent" : "muted", label)}`);
|
|
146
146
|
}
|
|
147
147
|
}
|
|
148
148
|
|
|
149
149
|
if (input.otherMode) {
|
|
150
150
|
add("");
|
|
151
|
-
add(` ${input.theme.fg("muted", "
|
|
151
|
+
add(` ${input.theme.fg("muted", " 직접 입력:")}`);
|
|
152
152
|
for (const line of input.editorLines) add(` ${line}`);
|
|
153
153
|
return;
|
|
154
154
|
}
|
|
@@ -172,22 +172,22 @@ export function renderFooter(input: RenderFormInput, add: (text: string) => void
|
|
|
172
172
|
if (!question) return;
|
|
173
173
|
add("");
|
|
174
174
|
if (input.otherMode) {
|
|
175
|
-
add(input.theme.fg("dim", " Enter
|
|
175
|
+
add(input.theme.fg("dim", " Enter 제출 • Esc 돌아가기"));
|
|
176
176
|
return;
|
|
177
177
|
}
|
|
178
178
|
if (question.type === "text") {
|
|
179
|
-
const nav = input.questions.length > 1 ? "Tab/←→
|
|
180
|
-
add(input.theme.fg("dim", ` ${nav}Enter
|
|
179
|
+
const nav = input.questions.length > 1 ? "Tab/←→ 이동 • " : "";
|
|
180
|
+
add(input.theme.fg("dim", ` ${nav}Enter 제출 • Esc 취소`));
|
|
181
181
|
return;
|
|
182
182
|
}
|
|
183
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", ` ↑↓
|
|
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
187
|
return;
|
|
188
188
|
}
|
|
189
|
-
const nav = input.questions.length > 1 ? "Tab/←→
|
|
190
|
-
add(input.theme.fg("dim", ` ↑↓
|
|
189
|
+
const nav = input.questions.length > 1 ? "Tab/←→ 이동 • " : "";
|
|
190
|
+
add(input.theme.fg("dim", ` ↑↓ 이동 • ${nav}Enter 선택 • Esc 취소`));
|
|
191
191
|
}
|
|
192
192
|
|
|
193
193
|
export function renderForm(input: RenderFormInput): string[] {
|