@capdiem/pi-ask-user 0.1.0 → 0.1.2

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 CHANGED
@@ -3,20 +3,26 @@
3
3
  An interactive `ask_user` form tool for the [Pi coding agent](https://pi.dev/).
4
4
 
5
5
  When the model needs your decision, preference, or input — especially during a
6
- grilling / design-interview session — it calls `ask_user` and you answer in an
7
- interactive form instead of reading a plain-text `Q1..QN` block and replying
8
- with numbered text.
6
+ [grilling](https://github.com/mattpocock/skills) / design-interview session — it
7
+ calls `ask_user` and you answer in an interactive **Custom UI** form instead of
8
+ reading a plain-text `Q1..QN` block and replying with numbered text. Each
9
+ question (with its `➡️ recommended answer`) becomes a selectable option or a
10
+ free-text field in the form.
9
11
 
10
12
  ## Features
11
13
 
14
+ - **Questions as a form, not text** — the official pi **Custom UI** mechanism
15
+ (`ctx.ui.custom()`): a full-screen interactive form where each question is a
16
+ selector (choice options with a free-text escape) or a free-text field. No
17
+ more replying to a wall of `Q1..QN` text.
12
18
  - **One call, many questions** — pass the whole round (a grilling frontier, a
13
19
  clarification batch) as a single form with up to 10 questions.
14
20
  - **Choice and free-text** — each question is `type: "choice"` (options list,
15
21
  with an optional "Type something" free-text escape) or `type: "text"`.
16
22
  - **Question numbering (optional)** — set `numbered: true` to label questions `Q1`, `Q2`, … in the form body with an optional short title (`Q1 - Scope:`), mirroring the original grilling format. Ordinary (non-grill) forms show just the prompt.
17
23
  - **Recommended-answer hints** — each question may carry a `recommendation` (the grilling skill's `➡️ recommended answer`). When it matches one of a choice question's options, that option is marked with a **`★`** between the option number and the label (bold label), and its description (muted) plus the recommendation detail (default + bold, wrapped as `(推荐:…)`) are shown together on one line — a leading title in the recommendation is stripped (grill shape `<标题> - <详情>`, taking only the `<详情>` after the first dash). Otherwise the recommendation appears dimmed under the question as `Recommended: …`.
18
- - **TUI mode** — a full-screen tabbed form (↑↓ select, Tab/←→ switch, Enter
19
- confirm, Esc cancel) via `ctx.ui.custom()`.
24
+ - **TUI mode** — a full-screen tabbed **Custom UI** form (↑↓ select, Tab/←→
25
+ switch, Enter confirm, Esc cancel) via `ctx.ui.custom()`.
20
26
  - **RPC mode** — the same questions as sequential `select`/`input` dialogs over
21
27
  the [extension UI protocol](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/rpc.md#extension-ui-protocol).
22
28
  The recommended option's label carries the `(推荐:…)` detail merged in,
@@ -67,8 +73,17 @@ The top-level call accepts an optional `numbered` flag (default `false`):
67
73
  ## Wiring it into grilling skills
68
74
 
69
75
  `ask_user` ships `promptGuidelines` that tell the model to prefer the form over
70
- plain-text `Q1..QN`. To make a grilling skill (e.g. Matt Pocock's
71
- `/grilling`) deterministic, add one line to the skill:
76
+ plain-text `Q1..QN`. It maps naturally onto [Matt Pocock's `grilling`
77
+ skill](https://github.com/mattpocock/skills) format:
78
+
79
+ | grilling skill | `ask_user` |
80
+ | --- | --- |
81
+ | `❓ Q1 - <title>: <body>` | numbered question (`numbered: true`) with title |
82
+ | choice options in the body | `type: "choice"` options |
83
+ | `➡️ <recommended answer>` | `recommendation` hint (marked `★` on the matching option) |
84
+ | free-form asks | `type: "text"` questions |
85
+
86
+ To make a grilling skill deterministic, add one line to the skill:
72
87
 
73
88
  > Present each round's frontier via the `ask_user` tool as a form. If the
74
89
  > `ask_user` tool is not available, fall back to numbered plain-text questions.
package/index.min.js.map CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "version": 3,
3
- "sources": ["..\\..\\extensions\\pi-ask-user\\index.ts"],
3
+ "sources": ["../../extensions/pi-ask-user/index.ts"],
4
4
  "sourcesContent": [
5
5
  "/**\n * Ask User Questions - interactive question form tool\n *\n * Lets the LLM ask the user one or more questions as an interactive form\n * (choice + free-text), instead of dumping plain-text \"Q1..QN\" blocks.\n *\n * Mode behavior:\n * - TUI mode: full-screen tabbed form via ctx.ui.custom()\n * - RPC mode: per-question select/input dialogs over the extension UI protocol\n * - print/json mode: structured fallback so the LLM can ask in plain text\n *\n * Each question carries a \"recommendation\" hint (the grilling skill's\n * \"➡️ recommended answer\"). When it matches one of a choice question's\n * options, it is highlighted on that option; otherwise it is shown dimmed\n * under the prompt.\n */\n\nimport type { ExtensionAPI, ExtensionContext, Theme } from \"@earendil-works/pi-coding-agent\";\nimport { StringEnum } from \"@earendil-works/pi-ai\";\nimport {\n Editor,\n type EditorTheme,\n Key,\n matchesKey,\n Text,\n visibleWidth,\n wrapTextWithAnsi,\n} from \"@earendil-works/pi-tui\";\nimport { Type } from \"typebox\";\n\n// ---------- Types ----------\n\ninterface QuestionOption {\n value: string;\n label: string;\n description?: string;\n}\n\ninterface Question {\n id: string;\n title?: string;\n prompt: string;\n type: \"choice\" | \"text\";\n options?: QuestionOption[];\n allowOther: boolean;\n recommendation?: string;\n number: number;\n numbered: boolean;\n}\n\ninterface Answer {\n id: string;\n value: string;\n label: string;\n wasCustom: boolean;\n index?: number;\n}\n\ninterface AskUserResult {\n questions: Question[];\n answers: Answer[];\n cancelled: boolean;\n}\n\n// ---------- Schema ----------\n\nconst QuestionOptionSchema = Type.Object({\n value: Type.String({ description: \"Value returned to the LLM when this option is selected\" }),\n label: Type.String({ description: \"Display label shown to the user\" }),\n description: Type.Optional(\n Type.String({ description: \"Optional description shown under the label\" }),\n ),\n});\n\nconst QuestionSchema = Type.Object({\n id: Type.String({ description: \"Unique question id (e.g. q1, q2, scope)\" }),\n title: Type.Optional(\n Type.String({ description: \"Optional short title shown after the question number, e.g. 'Scope'\" }),\n ),\n prompt: Type.String({ description: \"Full question text to display\" }),\n type: StringEnum([\"choice\", \"text\"] as const, {\n description: \"'choice' for an options list, 'text' for free-form input\",\n }),\n options: Type.Optional(\n Type.Array(QuestionOptionSchema, { description: \"Options for type=choice (required for choice)\" }),\n ),\n allowOther: Type.Optional(\n Type.Boolean({ description: \"Choice questions: allow free-text 'Type something' (default true)\" }),\n ),\n recommendation: Type.Optional(\n Type.String({\n description:\n \"Your recommended answer (the grilling '➡️ recommended answer'). When it matches an option's value or label, that option is highlighted in the form.\",\n }),\n ),\n});\n\nconst AskUserParams = Type.Object({\n questions: Type.Array(QuestionSchema, {\n description: \"One or more questions to ask the user in a single interactive form\",\n minItems: 1,\n maxItems: 10,\n }),\n numbered: Type.Optional(\n Type.Boolean({\n description: \"Prefix each question with its number (Q1, Q2…) in grilling style. Default: false.\",\n }),\n ),\n});\n\n// ---------- Helpers ----------\n\n/** Raw question shape as accepted by the tool (optional fields not yet normalized). */\ninterface RawQuestion {\n id: string;\n title?: string;\n prompt: string;\n type: \"choice\" | \"text\";\n options?: QuestionOption[];\n allowOther?: boolean;\n recommendation?: string;\n}\n\nfunction defaultQuestions(params: {\n questions: RawQuestion[];\n numbered?: boolean;\n}): Question[] {\n return params.questions.map((q, i) => ({\n ...q,\n allowOther: q.allowOther !== false,\n options: q.type === \"choice\" ? q.options ?? [] : undefined,\n number: i + 1,\n numbered: params.numbered === true,\n }));\n}\n\n/**\n * Question label: \"Q1 - Scope\" when numbered, \"Scope\" when not. Empty string\n * when not numbered and no title is set.\n */\nfunction questionLabel(q: Pick<Question, \"number\" | \"numbered\" | \"title\">): string {\n if (q.numbered) return `Q${q.number}${q.title ? ` - ${q.title}` : \"\"}`;\n return q.title ?? \"\";\n}\n\n/** Whether a question's recommendation points at a specific option (heuristic match). */\nfunction recommendationMatch(q: Question, opt: QuestionOption): boolean {\n const rec = q.recommendation?.trim().toLowerCase();\n if (!rec) return false;\n const value = (opt.value ?? \"\").trim().toLowerCase();\n const label = (opt.label ?? \"\").trim().toLowerCase();\n if (value && rec === value) return true;\n if (label && rec === label) return true;\n if (value.length >= 3 && rec.includes(value)) return true;\n if (label.length >= 3 && rec.includes(label)) return true;\n if (rec.length >= 3) {\n if (value && value.includes(rec)) return true;\n if (label && label.includes(rec)) return true;\n }\n return false;\n}\n\n/**\n * Index of the option a question's recommendation points at, or -1 when the\n * recommendation is absent or matches no option. Returns the first match.\n */\nfunction recommendedIndex(q: Question): number {\n if (!q.recommendation || q.type !== \"choice\") return -1;\n const opts = q.options ?? [];\n for (let i = 0; i < opts.length; i++) {\n if (recommendationMatch(q, opts[i])) return i;\n }\n return -1;\n}\n\n/**\n * Reduce a recommendation to its \"detail\" for display.\n * Grill recommendation shape is \"<标题> - <详情>\"; we take only the <详情>\n * after the first dash separator (em/en dash, spaced hyphen, fullwidth\n * hyphen-minus), so a leading echo of the option title is dropped even when\n * the title carries extra text like \"(现状)\". A leading echo of the option\n * label/value is also stripped for the no-dash case.\n */\nfunction stripRecommendationTitle(rec: string, opt?: QuestionOption): string {\n let cleaned = rec.trim();\n if (opt) {\n for (const token of [opt.label, opt.value]) {\n const t = token?.trim();\n if (!t) continue;\n if (cleaned.toLowerCase().startsWith(t.toLowerCase())) {\n cleaned = cleaned.slice(t.length).trimStart();\n break;\n }\n }\n }\n const m = cleaned.match(/[\\u2014\\u2013]| - |-/);\n if (m && typeof m.index === \"number\") {\n cleaned = cleaned\n .slice(m.index + m[0].length)\n .replace(/^[\\s::,,、.…-]+/, \"\");\n }\n return cleaned.trim() || rec.trim();\n}\n\nfunction textResult(\n message: string,\n questions: Question[],\n cancelled = true,\n): { content: { type: \"text\"; text: string }[]; details: AskUserResult } {\n return {\n content: [{ type: \"text\", text: message }],\n details: { questions, answers: [], cancelled },\n };\n}\n\nfunction formatAnswers(questions: Question[], answers: Answer[]): string {\n const lines = answers.map((a) => {\n const q = questions.find((x) => x.id === a.id);\n const label = q ? questionLabel(q) || `Q${q.number}` : a.id;\n if (a.wasCustom) return `${label}: user wrote: ${a.label}`;\n const idx = a.index ? ` ${a.index}.` : \"\";\n return `${label}: user selected:${idx} ${a.label}`;\n });\n return lines.join(\"\\n\");\n}\n\n// ---------- TUI form ----------\n\nfunction editorTheme(theme: Theme): EditorTheme {\n return {\n borderColor: (s) => theme.fg(\"accent\", s),\n selectList: {\n selectedPrefix: (t) => theme.fg(\"accent\", t),\n selectedText: (t) => theme.fg(\"accent\", t),\n description: (t) => theme.fg(\"muted\", t),\n scrollInfo: (t) => theme.fg(\"dim\", t),\n noMatch: (t) => theme.fg(\"warning\", t),\n },\n };\n}\n\nasync function presentTuiForm(\n ctx: ExtensionContext,\n questions: Question[],\n): Promise<AskUserResult> {\n return ctx.ui.custom<AskUserResult>((tui, theme, _kb, done) => {\n const isMulti = questions.length > 1;\n const submitTab = questions.length;\n\n let currentTab = 0;\n let optionIndex = 0;\n let inputMode = false;\n let inputQuestionId: string | null = null;\n let cachedLines: string[] | undefined;\n const answers = new Map<string, Answer>();\n\n const editor = new Editor(tui, editorTheme(theme));\n\n // ---------- helpers ----------\n\n function refresh() {\n cachedLines = undefined;\n tui.requestRender();\n }\n\n function submit(cancelled: boolean) {\n done({ questions, answers: Array.from(answers.values()), cancelled });\n }\n\n function currentQuestion(): Question | undefined {\n return questions[currentTab];\n }\n\n function currentOptions(): Array<QuestionOption & { isOther?: boolean }> {\n const q = currentQuestion();\n if (!q || q.type !== \"choice\") return [];\n const opts: Array<QuestionOption & { isOther?: boolean }> = [...(q.options ?? [])];\n if (q.allowOther) {\n opts.push({ value: \"__other__\", label: \"Type something.\", isOther: true });\n }\n return opts;\n }\n\n function allAnswered(): boolean {\n return questions.every((q) => answers.has(q.id));\n }\n\n function advanceAfterAnswer() {\n if (!isMulti) {\n submit(false);\n return;\n }\n if (currentTab < submitTab - 1) {\n currentTab++;\n } else {\n currentTab = submitTab;\n }\n optionIndex = 0;\n inputMode = false;\n inputQuestionId = null;\n refresh();\n }\n\n function saveAnswer(\n questionId: string,\n value: string,\n label: string,\n wasCustom: boolean,\n index?: number,\n ) {\n answers.set(questionId, { id: questionId, value, label, wasCustom, index });\n }\n\n function openInput(questionId: string) {\n inputMode = true;\n inputQuestionId = questionId;\n editor.setText(\"\");\n refresh();\n }\n\n editor.onSubmit = (value) => {\n if (!inputQuestionId) return;\n const trimmed = value.trim() || \"(no response)\";\n saveAnswer(inputQuestionId, trimmed, trimmed, true);\n inputMode = false;\n inputQuestionId = null;\n editor.setText(\"\");\n advanceAfterAnswer();\n };\n\n // ---------- input ----------\n\n function handleInput(data: string) {\n if (inputMode) {\n if (matchesKey(data, Key.escape)) {\n inputMode = false;\n inputQuestionId = null;\n editor.setText(\"\");\n refresh();\n return;\n }\n editor.handleInput(data);\n refresh();\n return;\n }\n\n // Tab navigation (multi-question only)\n if (isMulti) {\n if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {\n currentTab = (currentTab + 1) % (submitTab + 1);\n optionIndex = 0;\n refresh();\n return;\n }\n if (matchesKey(data, Key.shift(\"tab\")) || matchesKey(data, Key.left)) {\n currentTab = (currentTab - 1 + submitTab + 1) % (submitTab + 1);\n optionIndex = 0;\n refresh();\n return;\n }\n }\n\n const q = currentQuestion();\n\n // Submit tab\n if (currentTab === submitTab) {\n if (matchesKey(data, Key.enter) && allAnswered()) {\n submit(false);\n } else if (matchesKey(data, Key.escape)) {\n submit(true);\n }\n return;\n }\n\n if (!q) return;\n\n if (q.type === \"text\") {\n if (matchesKey(data, Key.enter)) {\n openInput(q.id);\n } else if (matchesKey(data, Key.escape)) {\n submit(true);\n }\n return;\n }\n\n // Choice navigation\n const opts = currentOptions();\n if (matchesKey(data, Key.up)) {\n optionIndex = Math.max(0, optionIndex - 1);\n refresh();\n return;\n }\n if (matchesKey(data, Key.down)) {\n optionIndex = Math.min(opts.length - 1, optionIndex + 1);\n refresh();\n return;\n }\n if (matchesKey(data, Key.enter)) {\n const opt = opts[optionIndex];\n if (!opt) return;\n if (opt.isOther) {\n openInput(q.id);\n return;\n }\n saveAnswer(q.id, opt.value, opt.label, false, optionIndex + 1);\n advanceAfterAnswer();\n return;\n }\n if (matchesKey(data, Key.escape)) {\n submit(true);\n }\n }\n\n // ---------- render ----------\n\n function render(width: number): string[] {\n if (cachedLines) return cachedLines;\n\n const lines: string[] = [];\n const renderWidth = Math.max(1, width);\n\n function addWrapped(text: string) {\n lines.push(...wrapTextWithAnsi(text, renderWidth));\n }\n\n function addWrappedWithPrefix(prefix: string, text: string) {\n const prefixWidth = visibleWidth(prefix);\n if (prefixWidth >= renderWidth) {\n addWrapped(prefix + text);\n return;\n }\n const wrapped = wrapTextWithAnsi(text, renderWidth - prefixWidth);\n const continuationPrefix = \" \".repeat(prefixWidth);\n for (let i = 0; i < wrapped.length; i++) {\n lines.push(`${i === 0 ? prefix : continuationPrefix}${wrapped[i]}`);\n }\n }\n\n function renderPromptAndRecommendation(q: Question) {\n const recOnOption = recommendedIndex(q) >= 0;\n const label = questionLabel(q);\n const head = label\n ? `${theme.fg(\"accent\", theme.bold(`${label}:`))} ${theme.fg(\"text\", q.prompt)}`\n : theme.fg(\"text\", q.prompt);\n addWrappedWithPrefix(\" \", head);\n if (q.recommendation && !recOnOption) {\n lines.push(\"\");\n addWrappedWithPrefix(\n \" \",\n theme.fg(\"dim\", `Recommended: ${stripRecommendationTitle(q.recommendation)}`),\n );\n }\n lines.push(\"\");\n }\n\n lines.push(theme.fg(\"accent\", \"─\".repeat(renderWidth)));\n\n // Tab bar (multi-question only)\n if (isMulti) {\n const tabs: string[] = [\"← \"];\n for (let i = 0; i < questions.length; i++) {\n const isActive = i === currentTab;\n const isAnswered = answers.has(questions[i].id);\n const lbl = questions[i].title ?? `Q${i + 1}`;\n const box = isAnswered ? \"■\" : \"□\";\n const color = isAnswered ? \"success\" : \"muted\";\n const text = ` ${box} ${lbl} `;\n const styled = isActive\n ? theme.bg(\"selectedBg\", theme.fg(\"text\", text))\n : theme.fg(color, text);\n tabs.push(`${styled} `);\n }\n const canSubmit = allAnswered();\n const isSubmitTab = currentTab === submitTab;\n const submitText = \" ✓ Submit \";\n const submitStyled = isSubmitTab\n ? theme.bg(\"selectedBg\", theme.fg(\"text\", submitText))\n : theme.fg(canSubmit ? \"success\" : \"dim\", submitText);\n tabs.push(`${submitStyled} →`);\n addWrappedWithPrefix(\" \", tabs.join(\"\"));\n lines.push(\"\");\n }\n\n // Render an option list, highlighting the recommended option if any.\n function renderOptions() {\n const opts = currentOptions();\n const q = currentQuestion();\n const recIdx = q ? recommendedIndex(q) : -1;\n for (let i = 0; i < opts.length; i++) {\n const opt = opts[i];\n const selected = i === optionIndex;\n const isOther = opt.isOther === true;\n const recommended = !isOther && i === recIdx;\n const prefix = selected ? theme.fg(\"accent\", \"> \") : \" \";\n const labelBase = `${i + 1}. ${recommended ? \"★ \" : \"\"}${opt.label}${isOther && inputMode ? \" ✎\" : \"\"}`;\n const label = recommended ? theme.bold(labelBase) : labelBase;\n const color = selected || (isOther && inputMode) ? \"accent\" : \"text\";\n addWrappedWithPrefix(prefix, theme.fg(color, label));\n if (recommended && q?.recommendation) {\n // Recommended option: description (muted) then the recommendation\n // detail (default + bold) wrapped as (推荐:<详情>), same line.\n const detail = stripRecommendationTitle(q.recommendation, opt);\n const desc = opt.description ? theme.fg(\"muted\", opt.description) : \"\";\n addWrappedWithPrefix(\" \", desc + theme.bold(`(推荐:${detail})`));\n } else if (opt.description) {\n addWrappedWithPrefix(\" \", theme.fg(\"muted\", opt.description));\n }\n }\n }\n\n const q = currentQuestion();\n\n // Content\n if (inputMode && q) {\n renderPromptAndRecommendation(q);\n if (q.type === \"choice\") renderOptions();\n lines.push(\"\");\n addWrappedWithPrefix(\" \", theme.fg(\"muted\", \"Your answer:\"));\n for (const line of editor.render(Math.max(1, renderWidth - 2))) {\n lines.push(` ${line}`);\n }\n lines.push(\"\");\n addWrappedWithPrefix(\" \", theme.fg(\"dim\", \"Enter to submit • Esc to cancel\"));\n } else if (currentTab === submitTab) {\n addWrappedWithPrefix(\" \", theme.fg(\"accent\", theme.bold(\"Ready to submit\")));\n lines.push(\"\");\n for (const question of questions) {\n const answer = answers.get(question.id);\n if (answer) {\n const prefix = answer.wasCustom ? \"(wrote) \" : \"\";\n const summary = `${theme.fg(\"muted\", `${questionLabel(question)}: `)}${theme.fg(\"text\", prefix + answer.label)}`;\n addWrappedWithPrefix(\" \", summary);\n }\n }\n lines.push(\"\");\n if (allAnswered()) {\n addWrappedWithPrefix(\" \", theme.fg(\"success\", \"Press Enter to submit\"));\n } else {\n const missing = questions\n .filter((x) => !answers.has(x.id))\n .map((x) => questionLabel(x))\n .join(\", \");\n addWrappedWithPrefix(\" \", theme.fg(\"warning\", `Unanswered: ${missing}`));\n }\n } else if (q) {\n renderPromptAndRecommendation(q);\n if (q.type === \"choice\") {\n renderOptions();\n } else {\n const answer = answers.get(q.id);\n addWrappedWithPrefix(\" \", theme.fg(\"muted\", \"Free-form answer\"));\n lines.push(\"\");\n if (answer) {\n addWrappedWithPrefix(\" \", theme.fg(\"text\", ` ${answer.label}`));\n lines.push(\"\");\n addWrappedWithPrefix(\" \", theme.fg(\"dim\", \"Enter to edit • Esc cancel\"));\n } else {\n addWrappedWithPrefix(\" \", theme.fg(\"dim\", \"Press Enter to type your answer\"));\n }\n }\n }\n\n lines.push(\"\");\n if (!inputMode) {\n const help = isMulti\n ? \"Tab/←→ navigate • ↑↓ select • Enter confirm • Esc cancel\"\n : \"↑↓ select • Enter confirm • Esc cancel\";\n addWrappedWithPrefix(\" \", theme.fg(\"dim\", help));\n }\n lines.push(theme.fg(\"accent\", \"─\".repeat(renderWidth)));\n\n cachedLines = lines;\n return lines;\n }\n\n return {\n render,\n invalidate: () => {\n cachedLines = undefined;\n },\n handleInput,\n };\n });\n}\n\n// ---------- RPC fallback (per-question dialogs) ----------\n\nasync function presentRpcDialogs(\n ctx: ExtensionContext,\n questions: Question[],\n): Promise<AskUserResult> {\n const answers: Answer[] = [];\n let cancelled = false;\n\n for (const q of questions) {\n if (q.type === \"choice\") {\n // Merge the recommendation detail into the recommended option's label\n // (e.g. \"保留 fallback 行(推荐:…)\"), then reverse-map the selected\n // display string back to the original option index.\n const opts = q.options ?? [];\n const recIdx = recommendedIndex(q);\n const display: { index: number; label: string }[] = opts.map((o, idx) => ({\n index: idx,\n label:\n idx === recIdx && q.recommendation\n ? `${o.label}(推荐:${stripRecommendationTitle(q.recommendation, o)})`\n : o.label,\n }));\n if (q.allowOther) display.push({ index: -1, label: \"Type something...\" });\n const label = questionLabel(q);\n const title = label ? `${label}: ${q.prompt}` : q.prompt;\n const choice = await ctx.ui.select(title, display.map((d) => d.label));\n if (choice === undefined) {\n cancelled = true;\n break;\n }\n if (choice === \"Type something...\") {\n const value = await ctx.ui.input(q.prompt, \"Type your answer\");\n if (value === undefined) {\n cancelled = true;\n break;\n }\n answers.push({ id: q.id, value, label: value, wasCustom: true });\n } else {\n const found = display.find((d) => d.label === choice);\n const idx = found ? found.index : -1;\n const opt = idx >= 0 ? opts[idx] : undefined;\n answers.push({\n id: q.id,\n value: opt?.value ?? choice,\n label: opt?.label ?? choice,\n wasCustom: false,\n index: idx >= 0 ? idx + 1 : undefined,\n });\n }\n } else {\n const value = await ctx.ui.input(q.prompt, questionLabel(q) || q.prompt);\n if (value === undefined) {\n cancelled = true;\n break;\n }\n answers.push({ id: q.id, value, label: value, wasCustom: true });\n }\n }\n\n return { questions, answers, cancelled };\n}\n\n// ---------- Extension ----------\n\nexport default function askUserExtension(pi: ExtensionAPI): void {\n pi.registerTool({\n name: \"ask_user\",\n label: \"Ask User\",\n description:\n \"Ask the user one or more questions as an interactive form (choice options or free-text). \" +\n \"Use when you need the user's decision, preference, or input to continue — especially to \" +\n \"present a round of design/planning questions with your recommended answer for each. \" +\n \"Each question may include a 'recommendation'; when it matches one of the options, that option is highlighted.\",\n promptSnippet: \"Ask the user questions through an interactive form\",\n promptGuidelines: [\n \"Use ask_user to put questions to the user as an interactive form instead of printing plain-text Q1..QN blocks.\",\n \"When a single turn has multiple related questions (e.g. a grilling round's frontier), pass them all in one ask_user call — one question per entry, with type 'choice' or 'text'.\",\n \"For each question you can include a 'recommendation' with your recommended answer. When it matches an option's value or label, that option is highlighted in the form; otherwise it is shown as a hint under the question.\",\n \"For grilling-style rounds set numbered: true so the questions are prefixed Q1/Q2. For ordinary questions omit it — the form then shows just the prompt (plus an optional title).\",\n \"ask_user works in TUI mode (full form) and RPC mode (sequential dialogs). In print/json mode it returns the questions as text so you can ask them in plain text.\",\n \"If ask_user reports 'cancelled', stop and let the user redirect instead of re-asking the same questions.\",\n ],\n parameters: AskUserParams,\n executionMode: \"sequential\",\n\n async execute(_toolCallId, params, signal, _onUpdate, ctx) {\n if (signal?.aborted) {\n return textResult(\"Cancelled\", [], true);\n }\n const questions = defaultQuestions(params);\n\n if (ctx.mode === \"tui\") {\n const result = await presentTuiForm(ctx, questions);\n if (result.cancelled) {\n return {\n content: [{ type: \"text\", text: \"User cancelled the questions\" }],\n details: result,\n };\n }\n return {\n content: [{ type: \"text\", text: formatAnswers(questions, result.answers) }],\n details: result,\n };\n }\n\n if (ctx.mode === \"rpc\") {\n const result = await presentRpcDialogs(ctx, questions);\n if (result.cancelled) {\n return {\n content: [{ type: \"text\", text: \"User cancelled the questions\" }],\n details: result,\n };\n }\n return {\n content: [{ type: \"text\", text: formatAnswers(questions, result.answers) }],\n details: result,\n };\n }\n\n // Non-interactive modes: structured fallback so the LLM asks in plain text.\n const fallback = questions\n .map((q) => {\n const rec = q.recommendation ? `\\n Recommended: ${q.recommendation}` : \"\";\n const opts =\n q.type === \"choice\"\n ? `\\n Options: ${(q.options ?? []).map((o) => `${o.label}`).join(\" | \")}`\n : \"\";\n const label = questionLabel(q);\n return `${label ? `${label}: ` : \"\"}${q.prompt}${opts}${rec}`;\n })\n .join(\"\\n\\n\");\n return {\n content: [\n {\n type: \"text\",\n text:\n \"Interactive form unavailable in this mode. Ask the user the following questions as plain text (numbered Q1..QN):\\n\\n\" +\n fallback,\n },\n ],\n details: { questions, answers: [], cancelled: false },\n };\n },\n\n renderCall(args, theme, context) {\n const text = (context.lastComponent as Text | undefined) ?? new Text(\"\", 0, 0);\n const qs = Array.isArray(args.questions) ? (args.questions as Question[]) : [];\n const labels = qs\n .map((q, i) => (q.title ? `Q${i + 1} - ${q.title}` : q.id || `Q${i + 1}`))\n .join(\", \");\n let content = theme.fg(\"toolTitle\", theme.bold(\"ask_user \"));\n content += theme.fg(\"muted\", `${qs.length} question${qs.length !== 1 ? \"s\" : \"\"}`);\n if (labels) content += theme.fg(\"dim\", ` (${labels})`);\n text.setText(content);\n return text;\n },\n\n renderResult(result, _options, theme, context) {\n const text = (context.lastComponent as Text | undefined) ?? new Text(\"\", 0, 0);\n const details = result.details as AskUserResult | undefined;\n if (!details) {\n const out = result.content\n .filter((c): c is { type: \"text\"; text: string } => c.type === \"text\")\n .map((c) => c.text)\n .join(\"\\n\");\n text.setText(theme.fg(\"warning\", out || \"ask_user\"));\n return text;\n }\n if (details.cancelled) {\n text.setText(theme.fg(\"warning\", \"Cancelled\"));\n return text;\n }\n const lines = details.answers.map((a) => {\n const q = details.questions.find((x) => x.id === a.id);\n const label = q ? questionLabel(q) || `Q${q.number}` : a.id;\n if (a.wasCustom) {\n return `${theme.fg(\"success\", \"✓ \")}${theme.fg(\"accent\", label)}: ${theme.fg(\"muted\", \"(wrote) \")}${a.label}`;\n }\n const display = a.index ? `${a.index}. ${a.label}` : a.label;\n return `${theme.fg(\"success\", \"✓ \")}${theme.fg(\"accent\", label)}: ${display}`;\n });\n text.setText(lines.join(\"\\n\"));\n return text;\n },\n });\n}\n"
6
6
  ],
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@capdiem/pi-ask-user",
3
- "version": "0.1.0",
4
- "description": "An interactive ask_user form tool for the Pi coding agent: choice or free-text questions with recommended-answer hints, in TUI (full form) and RPC (sequential dialogs)",
3
+ "version": "0.1.2",
4
+ "description": "An interactive ask_user form (Custom UI) for the Pi coding agent: turns grilling-skill questions into selectable options with recommended-answer hints TUI full form, RPC sequential dialogs",
5
5
  "type": "module",
6
6
  "pi": {
7
7
  "extensions": [
@@ -13,8 +13,11 @@
13
13
  "pi",
14
14
  "coding-agent",
15
15
  "ask-user",
16
- "questionnaire",
16
+ "custom-ui",
17
17
  "form",
18
+ "form-selector",
19
+ "interactive-form",
20
+ "questionnaire",
18
21
  "grilling"
19
22
  ],
20
23
  "author": "capdiem <capdiem@live.com>",