@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.
@@ -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, _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.3",
3
+ "version": "0.3.5",
4
4
  "description": "AskUserQuestion tool extension for pi.",
5
5
  "license": "MIT",
6
6
  "repository": {