@ryan_nookpi/pi-extension-ask-user-question 0.3.3 → 0.3.5
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/controller.test.ts +12 -0
- package/controller.ts +5 -1
- package/form-ui.test.ts +70 -0
- package/form-ui.ts +94 -1
- package/index.ts +2 -1
- package/package.json +1 -1
package/controller.test.ts
CHANGED
|
@@ -66,6 +66,18 @@ describe("ask-user-question/controller", () => {
|
|
|
66
66
|
expect(empty.done).not.toHaveBeenCalled();
|
|
67
67
|
});
|
|
68
68
|
|
|
69
|
+
it("invalidates cached lines when render width changes (terminal resize)", () => {
|
|
70
|
+
const setup = createController([{ id: "text", type: "text", prompt: "Explain" }]);
|
|
71
|
+
const wide = setup.controller.render(70);
|
|
72
|
+
const narrow = setup.controller.render(34);
|
|
73
|
+
expect(narrow).not.toBe(wide);
|
|
74
|
+
for (const line of narrow) {
|
|
75
|
+
expect(line.length).toBeLessThanOrEqual(34);
|
|
76
|
+
}
|
|
77
|
+
const narrowAgain = setup.controller.render(34);
|
|
78
|
+
expect(narrowAgain).toBe(narrow);
|
|
79
|
+
});
|
|
80
|
+
|
|
69
81
|
it("handles text questions, submission, cancellation, and editor fallback submit", () => {
|
|
70
82
|
const first = createController([{ id: "text", type: "text", prompt: "Explain", default: "seed" }]);
|
|
71
83
|
expect(first.editor.getText()).toBe("seed");
|
package/controller.ts
CHANGED
|
@@ -37,6 +37,7 @@ export function createFormController(input: CreateFormControllerInput): FormCont
|
|
|
37
37
|
let otherMode = false;
|
|
38
38
|
let otherQuestionId: string | null = null;
|
|
39
39
|
let cachedLines: string[] | undefined;
|
|
40
|
+
let cachedWidth: number | undefined;
|
|
40
41
|
|
|
41
42
|
function getCurrentQuestion(): NormalizedQuestion | undefined {
|
|
42
43
|
return questions[currentTab];
|
|
@@ -44,6 +45,7 @@ export function createFormController(input: CreateFormControllerInput): FormCont
|
|
|
44
45
|
|
|
45
46
|
function refresh(): void {
|
|
46
47
|
cachedLines = undefined;
|
|
48
|
+
cachedWidth = undefined;
|
|
47
49
|
input.requestRender();
|
|
48
50
|
}
|
|
49
51
|
|
|
@@ -116,7 +118,8 @@ export function createFormController(input: CreateFormControllerInput): FormCont
|
|
|
116
118
|
|
|
117
119
|
return {
|
|
118
120
|
render(width) {
|
|
119
|
-
if (cachedLines) return cachedLines;
|
|
121
|
+
if (cachedLines && cachedWidth === width) return cachedLines;
|
|
122
|
+
cachedWidth = width;
|
|
120
123
|
cachedLines = renderForm({
|
|
121
124
|
title: input.title,
|
|
122
125
|
description: input.description,
|
|
@@ -134,6 +137,7 @@ export function createFormController(input: CreateFormControllerInput): FormCont
|
|
|
134
137
|
},
|
|
135
138
|
invalidate() {
|
|
136
139
|
cachedLines = undefined;
|
|
140
|
+
cachedWidth = undefined;
|
|
137
141
|
},
|
|
138
142
|
handleInput(data) {
|
|
139
143
|
if (otherMode) {
|
package/form-ui.test.ts
CHANGED
|
@@ -22,6 +22,76 @@ describe("ask-user-question/form-ui", () => {
|
|
|
22
22
|
expect(editorTheme.selectList.noMatch("e")).toBe("e");
|
|
23
23
|
});
|
|
24
24
|
|
|
25
|
+
it("prefers the native askUserQuestion bridge when available", async () => {
|
|
26
|
+
const questions = normalizeQuestions([
|
|
27
|
+
{ id: "choice", type: "radio", prompt: "Pick", options: [{ value: "a", label: "A" }] },
|
|
28
|
+
{ id: "items", type: "checkbox", prompt: "Items", options: [{ value: "x", label: "X" }] },
|
|
29
|
+
{ id: "note", type: "text", prompt: "Note" },
|
|
30
|
+
]);
|
|
31
|
+
const askUserQuestion = vi.fn(async () => ({ choice: "custom", items: ["x", "extra"], note: "ship it" }));
|
|
32
|
+
const custom = vi.fn();
|
|
33
|
+
const ctx = {
|
|
34
|
+
hasUI: true,
|
|
35
|
+
ui: { askUserQuestion, custom },
|
|
36
|
+
} as unknown as ExtensionContext;
|
|
37
|
+
|
|
38
|
+
const result = await runAskUserQuestionForm(ctx, { title: "Title", description: "Desc" }, questions);
|
|
39
|
+
|
|
40
|
+
expect(askUserQuestion).toHaveBeenCalledWith({ title: "Title", description: "Desc", questions });
|
|
41
|
+
expect(custom).not.toHaveBeenCalled();
|
|
42
|
+
expect(result).toEqual({
|
|
43
|
+
title: "Title",
|
|
44
|
+
questions,
|
|
45
|
+
answers: [
|
|
46
|
+
{ id: "choice", type: "radio", value: "custom", wasCustom: true },
|
|
47
|
+
{ id: "items", type: "checkbox", value: ["x", "extra"], wasCustom: true },
|
|
48
|
+
{ id: "note", type: "text", value: "ship it", wasCustom: true },
|
|
49
|
+
],
|
|
50
|
+
cancelled: false,
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("supports the snake_case bridge and maps undefined to cancellation", async () => {
|
|
55
|
+
const questions = normalizeQuestions([{ id: "text", type: "text", prompt: "Explain" }]);
|
|
56
|
+
const signal = new AbortController().signal;
|
|
57
|
+
const askUserQuestion = vi.fn(async () => undefined);
|
|
58
|
+
const ctx = {
|
|
59
|
+
hasUI: true,
|
|
60
|
+
ui: { ask_user_question: askUserQuestion },
|
|
61
|
+
} as unknown as ExtensionContext;
|
|
62
|
+
|
|
63
|
+
const result = await runAskUserQuestionForm(ctx, { title: "Title" }, questions, { signal });
|
|
64
|
+
|
|
65
|
+
expect(askUserQuestion).toHaveBeenCalledWith({ title: "Title", description: undefined, questions }, { signal });
|
|
66
|
+
expect(result).toEqual({ title: "Title", questions, answers: [], cancelled: true });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("coerces unexpected native bridge answers into safe form results", async () => {
|
|
70
|
+
const questions = normalizeQuestions([
|
|
71
|
+
{ id: "choice", type: "radio", prompt: "Pick", options: [{ value: "a", label: "A" }] },
|
|
72
|
+
{ id: "single", type: "checkbox", prompt: "Single", options: [{ value: "solo", label: "Solo" }] },
|
|
73
|
+
{ id: "missing", type: "checkbox", prompt: "Missing", options: [{ value: "x", label: "X" }] },
|
|
74
|
+
]);
|
|
75
|
+
const askUserQuestion = vi.fn(async () => ({ choice: 123, single: "solo" }));
|
|
76
|
+
const ctx = {
|
|
77
|
+
hasUI: true,
|
|
78
|
+
ui: { askUserQuestion },
|
|
79
|
+
} as unknown as ExtensionContext;
|
|
80
|
+
|
|
81
|
+
const result = await runAskUserQuestionForm(ctx, {}, questions);
|
|
82
|
+
|
|
83
|
+
expect(result).toEqual({
|
|
84
|
+
title: undefined,
|
|
85
|
+
questions,
|
|
86
|
+
answers: [
|
|
87
|
+
{ id: "choice", type: "radio", value: "", wasCustom: false },
|
|
88
|
+
{ id: "single", type: "checkbox", value: ["solo"], wasCustom: false },
|
|
89
|
+
{ id: "missing", type: "checkbox", value: [], wasCustom: false },
|
|
90
|
+
],
|
|
91
|
+
cancelled: false,
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
25
95
|
it("wires the custom UI factory to the controller", async () => {
|
|
26
96
|
const questions = normalizeQuestions([{ id: "text", type: "text", prompt: "Explain", default: "seed" }]);
|
|
27
97
|
let captured: FormResult | undefined;
|
package/form-ui.ts
CHANGED
|
@@ -3,7 +3,90 @@ import { Editor, type EditorTheme } from "@mariozechner/pi-tui";
|
|
|
3
3
|
|
|
4
4
|
import { createFormController } from "./controller.ts";
|
|
5
5
|
import { createAnswerState } from "./state.ts";
|
|
6
|
-
import type { FormResult, NormalizedQuestion, RenderTheme } from "./types.ts";
|
|
6
|
+
import type { Answer, FormResult, NormalizedQuestion, RenderTheme } from "./types.ts";
|
|
7
|
+
|
|
8
|
+
interface AskUserQuestionBridgeRequest {
|
|
9
|
+
title?: string;
|
|
10
|
+
description?: string;
|
|
11
|
+
questions: NormalizedQuestion[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type AskUserQuestionBridge = (
|
|
15
|
+
request: AskUserQuestionBridgeRequest,
|
|
16
|
+
options?: { signal?: AbortSignal },
|
|
17
|
+
) => Promise<Record<string, unknown> | undefined>;
|
|
18
|
+
|
|
19
|
+
interface AskUserQuestionBridgeUI {
|
|
20
|
+
askUserQuestion?: AskUserQuestionBridge;
|
|
21
|
+
ask_user_question?: AskUserQuestionBridge;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function getAskUserQuestionBridge(ctx: ExtensionContext): AskUserQuestionBridge | undefined {
|
|
25
|
+
const ui = ctx.ui as ExtensionContext["ui"] & AskUserQuestionBridgeUI;
|
|
26
|
+
if (typeof ui.askUserQuestion === "function") return ui.askUserQuestion.bind(ui);
|
|
27
|
+
if (typeof ui.ask_user_question === "function") return ui.ask_user_question.bind(ui);
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function valueAsString(value: unknown): string {
|
|
32
|
+
return typeof value === "string" ? value : "";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function valueAsStringArray(value: unknown): string[] {
|
|
36
|
+
if (Array.isArray(value)) return value.filter((item): item is string => typeof item === "string");
|
|
37
|
+
return typeof value === "string" ? [value] : [];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function buildAnswersFromBridgeResult(questions: NormalizedQuestion[], result: Record<string, unknown>): Answer[] {
|
|
41
|
+
return questions.map((question) => {
|
|
42
|
+
const rawValue = result[question.id];
|
|
43
|
+
if (question.type === "radio") {
|
|
44
|
+
const value = valueAsString(rawValue);
|
|
45
|
+
const optionValues = new Set(question.options.map((option) => option.value));
|
|
46
|
+
return {
|
|
47
|
+
id: question.id,
|
|
48
|
+
type: "radio",
|
|
49
|
+
value,
|
|
50
|
+
wasCustom: value.length > 0 && !optionValues.has(value),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (question.type === "checkbox") {
|
|
55
|
+
const value = valueAsStringArray(rawValue);
|
|
56
|
+
const optionValues = new Set(question.options.map((option) => option.value));
|
|
57
|
+
return {
|
|
58
|
+
id: question.id,
|
|
59
|
+
type: "checkbox",
|
|
60
|
+
value,
|
|
61
|
+
wasCustom: value.some((item) => !optionValues.has(item)),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
id: question.id,
|
|
67
|
+
type: "text",
|
|
68
|
+
value: valueAsString(rawValue),
|
|
69
|
+
wasCustom: true,
|
|
70
|
+
};
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function buildFormResultFromBridgeResult(
|
|
75
|
+
params: { title?: string },
|
|
76
|
+
questions: NormalizedQuestion[],
|
|
77
|
+
result: Record<string, unknown> | undefined,
|
|
78
|
+
): FormResult {
|
|
79
|
+
if (!result) {
|
|
80
|
+
return { title: params.title, questions, answers: [], cancelled: true };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
title: params.title,
|
|
85
|
+
questions,
|
|
86
|
+
answers: buildAnswersFromBridgeResult(questions, result),
|
|
87
|
+
cancelled: false,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
7
90
|
|
|
8
91
|
export function createEditorTheme(theme: Pick<RenderTheme, "fg">): EditorTheme {
|
|
9
92
|
return {
|
|
@@ -22,7 +105,17 @@ export async function runAskUserQuestionForm(
|
|
|
22
105
|
ctx: ExtensionContext,
|
|
23
106
|
params: { title?: string; description?: string },
|
|
24
107
|
questions: NormalizedQuestion[],
|
|
108
|
+
options: { signal?: AbortSignal } = {},
|
|
25
109
|
): Promise<FormResult> {
|
|
110
|
+
const askUserQuestionBridge = getAskUserQuestionBridge(ctx);
|
|
111
|
+
if (askUserQuestionBridge) {
|
|
112
|
+
const request = { title: params.title, description: params.description, questions };
|
|
113
|
+
const result = options.signal
|
|
114
|
+
? await askUserQuestionBridge(request, { signal: options.signal })
|
|
115
|
+
: await askUserQuestionBridge(request);
|
|
116
|
+
return buildFormResultFromBridgeResult(params, questions, result);
|
|
117
|
+
}
|
|
118
|
+
|
|
26
119
|
return ctx.ui.custom<FormResult>((tui, theme, _keybindings, done) => {
|
|
27
120
|
return createFormController({
|
|
28
121
|
title: params.title,
|
package/index.ts
CHANGED
|
@@ -62,7 +62,7 @@ export default function askUserQuestion(pi: ExtensionAPI) {
|
|
|
62
62
|
promptSnippet: "radio, checkbox, text 입력을 사용하는 인터랙티브 질문 폼 열기",
|
|
63
63
|
promptGuidelines: PROMPT_GUIDELINES,
|
|
64
64
|
parameters: AskUserQuestionParams,
|
|
65
|
-
async execute(_toolCallId, rawParams,
|
|
65
|
+
async execute(_toolCallId, rawParams, signal, _onUpdate, ctx) {
|
|
66
66
|
if (!ctx.hasUI) {
|
|
67
67
|
return errorResult("오류: UI를 사용할 수 없습니다. 현재 비대화형 모드에서 실행 중입니다.");
|
|
68
68
|
}
|
|
@@ -77,6 +77,7 @@ export default function askUserQuestion(pi: ExtensionAPI) {
|
|
|
77
77
|
ctx,
|
|
78
78
|
{ title: params.title, description: params.description },
|
|
79
79
|
questions,
|
|
80
|
+
{ signal },
|
|
80
81
|
);
|
|
81
82
|
return result.cancelled ? buildCancelledResponse(result) : buildSuccessResponse(result);
|
|
82
83
|
},
|