@ryan_nookpi/pi-extension-ask-user-question 0.3.2 → 0.3.4

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/form-ui.test.ts CHANGED
@@ -22,6 +22,50 @@ 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
+
25
69
  it("wires the custom UI factory to the controller", async () => {
26
70
  const questions = normalizeQuestions([{ id: "text", type: "text", prompt: "Explain", default: "seed" }]);
27
71
  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, _signal, _onUpdate, ctx) {
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
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ryan_nookpi/pi-extension-ask-user-question",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "AskUserQuestion tool extension for pi.",
5
5
  "license": "MIT",
6
6
  "repository": {
package/view.test.ts CHANGED
@@ -197,7 +197,7 @@ describe("ask-user-question/view", () => {
197
197
  const submitInput = createInput({ questions: submitQuestions, answerState: createAnswerState(submitQuestions) });
198
198
  submitInput.answerState.radioAnswers.set("radio", { value: "custom", label: "custom", wasCustom: true });
199
199
  submitInput.answerState.checkCustom.set("check", "custom");
200
- renderSubmitTab(submitInput, (text) => submitLines.push(text), 80);
200
+ renderSubmitTab(submitInput, (text) => submitLines.push(text));
201
201
  expect(submitLines.join("\n")).toContain("(직접 입력) custom");
202
202
 
203
203
  const unansweredLines: string[] = [];
@@ -206,7 +206,7 @@ describe("ask-user-question/view", () => {
206
206
  answerState: createAnswerState(submitQuestions),
207
207
  });
208
208
  unansweredInput.answerState.checkAnswers.delete("check");
209
- renderSubmitTab(unansweredInput, (text) => unansweredLines.push(text), 80);
209
+ renderSubmitTab(unansweredInput, (text) => unansweredLines.push(text));
210
210
  expect(unansweredLines.join("\n")).toContain("(미응답)");
211
211
 
212
212
  const output = renderForm(createInput({ currentTab: 99 }));
package/view.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
1
+ import { truncateToWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
2
2
 
3
3
  import { SYM } from "./constants.ts";
4
4
  import { allRequiredAnswered, isAnswered } from "./state.ts";
@@ -7,12 +7,12 @@ import type { RenderFormInput } from "./types.ts";
7
7
  function createLineHelpers(width: number, theme: RenderFormInput["theme"]) {
8
8
  const lines: string[] = [];
9
9
  const maxWidth = Math.min(width, 120);
10
- const add = (text: string) => lines.push(truncateToWidth(text, maxWidth));
10
+ const add = (text: string) => lines.push(...wrapTextWithAnsi(text, maxWidth));
11
11
  const hr = () => add(theme.fg("accent", "─".repeat(maxWidth)));
12
12
  return { lines, maxWidth, add, hr };
13
13
  }
14
14
 
15
- export function renderSubmitTab(input: RenderFormInput, add: (text: string) => void, maxWidth: number): void {
15
+ export function renderSubmitTab(input: RenderFormInput, add: (text: string) => void): void {
16
16
  const { questions, answerState, theme } = input;
17
17
  add(` ${theme.fg("accent", theme.bold("검토 및 제출"))}`);
18
18
  add("");
@@ -41,7 +41,7 @@ export function renderSubmitTab(input: RenderFormInput, add: (text: string) => v
41
41
 
42
42
  const answer = answerState.textAnswers.get(question.id)?.trim();
43
43
  if (answer) {
44
- add(` ${label} ${truncateToWidth(answer, maxWidth - visibleWidth(question.label) - 5)}`);
44
+ add(` ${label} ${answer}`);
45
45
  } else {
46
46
  add(` ${label} ${theme.fg("warning", "(미응답)")}`);
47
47
  }
@@ -201,7 +201,7 @@ export function renderForm(input: RenderFormInput): string[] {
201
201
  renderTabBar(input, add);
202
202
 
203
203
  if (input.questions.length > 1 && input.currentTab === input.questions.length) {
204
- renderSubmitTab(input, add, maxWidth);
204
+ renderSubmitTab(input, add);
205
205
  hr();
206
206
  return lines;
207
207
  }