@pify/ask-question 0.1.0 → 0.2.0

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
@@ -9,7 +9,8 @@ Part of the [Pify suite](https://github.com/pifydev). Install with [`pify instal
9
9
  - **`ask_question`** — the agent batches up to 4 questions, each with up to 4 options (`label` + `description` trade-offs, recommendation marked "(Recommended)" and listed first), optional `multiSelect`, and free-text via "Other…".
10
10
  - **Built entirely on pi's built-in dialogs** (`select`/`input`) — no custom TUI overlay, so it works identically in the terminal and RPC/GUI hosts and can't break with pi UI changes. Multi-select is a checkbox toggle loop with `✓ Done`.
11
11
  - **Discipline encoded in the tool description** (zhushanwen's three conditions): only when 2+ reasonable approaches exist, context is already gathered, and a wrong pick means rework. Never for permissions or things the agent can look up.
12
- - **Declining is an answer**: Esc cleanly reports "the user declined" for the rest of the batch — no error, no re-asking. Headless runs get "proceed with your best judgment and state the assumption" instead of a failure (asking is advisory, unlike the fail-closed safety gates).
12
+ - **Declining is an answer**: Esc cleanly reports "the user declined" for the rest of the batch — no error, no re-asking. Headless runs get the full questionnaire back — every question with its options — plus "proceed with your best judgment and say which option you assumed", so the decision stays in the CI transcript instead of vanishing (asking is advisory, unlike the fail-closed safety gates).
13
+ - **Rows the user can actually pick** (v0.2): two options sharing a label, or one labelled `Other…`, used to render as indistinguishable rows where the second was unselectable. Duplicates are now suffixed, reserved labels renamed, and every pick resolves by its position in the dialog rather than by its text.
13
14
  - Structured results return to the model as both readable text and `details.answers`.
14
15
 
15
16
  ## Why no fancy overlay?
@@ -23,9 +23,10 @@ import {
23
23
  DONE_LABEL,
24
24
  OTHER_LABEL,
25
25
  formatAnswers,
26
- labelFromDisplay,
27
- optionDisplay,
26
+ headlessText,
27
+ parseSingleRow,
28
28
  parseToggleRow,
29
+ singleRows,
29
30
  toggleRows,
30
31
  validateQuestions,
31
32
  type AskAnswer,
@@ -36,30 +37,29 @@ type UiContext = ExtensionContext;
36
37
 
37
38
  export default function askQuestion(pi: ExtensionAPI) {
38
39
  async function askSingle(ctx: UiContext, q: AskQuestion): Promise<AskAnswer> {
39
- const rows = [...q.options.map(optionDisplay), ...(q.allowOther ? [OTHER_LABEL] : [])];
40
+ const rows = singleRows(q.options, q.allowOther);
40
41
  const picked = await ctx.ui.select(q.question, rows);
41
42
  if (picked === undefined) return { question: q.question, answers: [], declined: true };
42
- if (picked === OTHER_LABEL) {
43
+ const action = parseSingleRow(picked, rows, q.options);
44
+ if (!action) return { question: q.question, answers: [], declined: true };
45
+ if (action.kind === "other") {
43
46
  const text = await ctx.ui.input(q.question, "Type your answer");
44
47
  if (text === undefined || !text.trim()) {
45
48
  return { question: q.question, answers: [], declined: true };
46
49
  }
47
50
  return { question: q.question, answers: [], other: text.trim() };
48
51
  }
49
- const label = labelFromDisplay(picked, q.options);
50
- return { question: q.question, answers: label ? [label] : [] };
52
+ return { question: q.question, answers: [q.options[action.index]!.label] };
51
53
  }
52
54
 
53
55
  async function askMulti(ctx: UiContext, q: AskQuestion): Promise<AskAnswer> {
54
56
  const selected = new Set<number>();
55
57
  let other: string | undefined;
56
58
  for (;;) {
57
- const picked = await ctx.ui.select(
58
- `${q.question}\n(toggle options, then ${DONE_LABEL})`,
59
- toggleRows(q.options, selected, q.allowOther),
60
- );
59
+ const rows = toggleRows(q.options, selected, q.allowOther);
60
+ const picked = await ctx.ui.select(`${q.question}\n(toggle options, then ${DONE_LABEL})`, rows);
61
61
  if (picked === undefined) return { question: q.question, answers: [], declined: true };
62
- const action = parseToggleRow(picked, q.options);
62
+ const action = parseToggleRow(picked, rows, q.options);
63
63
  if (!action) continue;
64
64
  if (action.kind === "done") break;
65
65
  if (action.kind === "other") {
@@ -114,13 +114,8 @@ export default function askQuestion(pi: ExtensionAPI) {
114
114
 
115
115
  if (!uiCtx.hasUI) {
116
116
  return {
117
- content: [
118
- {
119
- type: "text",
120
- text: "No UI is available to ask the user. Proceed with your best judgment and clearly state the assumption you made.",
121
- },
122
- ],
123
- details: { headless: true },
117
+ content: [{ type: "text", text: headlessText(result.questions) }],
118
+ details: { headless: true, questions: result.questions },
124
119
  };
125
120
  }
126
121
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pify/ask-question",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Let the model ask instead of guessing: CC AskUserQuestion-shaped tool on built-in dialogs - 1-4 questions, multi-select, Other free-text, works in TUI and RPC",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/ask.ts CHANGED
@@ -75,9 +75,11 @@ export function validateQuestions(raw: unknown): ValidationResult {
75
75
  warnings.push(`question "${entry.question.slice(0, 30)}" has no options and allowOther=false — dropped`);
76
76
  continue;
77
77
  }
78
+ const normalized = normalizeOptions(options);
79
+ warnings.push(...normalized.warnings);
78
80
  questions.push({
79
81
  question: entry.question.trim(),
80
- options,
82
+ options: normalized.options,
81
83
  multiSelect: entry.multiSelect === true,
82
84
  allowOther,
83
85
  });
@@ -89,6 +91,34 @@ export function validateQuestions(raw: unknown): ValidationResult {
89
91
  return { questions, warnings, error: null };
90
92
  }
91
93
 
94
+ /**
95
+ * Make every option in a question distinguishable in the dialog. Two options
96
+ * sharing a label render as identical rows (the second is then unselectable),
97
+ * and an option labelled like a control row steals that row's meaning — both
98
+ * are the model's doing, so they are repaired rather than rejected.
99
+ */
100
+ export function normalizeOptions(options: AskOption[]): { options: AskOption[]; warnings: string[] } {
101
+ const warnings: string[] = [];
102
+ const seen = new Map<string, number>();
103
+ const result: AskOption[] = [];
104
+ for (const option of options) {
105
+ let label = option.label;
106
+ if (label === OTHER_LABEL || label === DONE_LABEL) {
107
+ label = `${label} (option)`;
108
+ warnings.push(`renamed an option labelled "${option.label}" — that label is reserved`);
109
+ }
110
+ const count = seen.get(label) ?? 0;
111
+ seen.set(label, count + 1);
112
+ if (count > 0) {
113
+ const unique = `${label} (${count + 1})`;
114
+ warnings.push(`renamed a duplicate option label "${label}"`);
115
+ label = unique;
116
+ }
117
+ result.push({ ...option, label });
118
+ }
119
+ return { options: result, warnings };
120
+ }
121
+
92
122
  /** Display string for one option in the select dialog. */
93
123
  export function optionDisplay(option: AskOption): string {
94
124
  const desc = option.description
@@ -97,10 +127,24 @@ export function optionDisplay(option: AskOption): string {
97
127
  return `${option.label}${desc}`;
98
128
  }
99
129
 
100
- /** Map a picked display string back to its option label. */
101
- export function labelFromDisplay(display: string, options: AskOption[]): string | null {
102
- const found = options.find((o) => optionDisplay(o) === display);
103
- return found ? found.label : null;
130
+ /** Rows for a single-select question: the options, then Other… if allowed. */
131
+ export function singleRows(options: AskOption[], allowOther: boolean): string[] {
132
+ const rows = options.map(optionDisplay);
133
+ if (allowOther) rows.push(OTHER_LABEL);
134
+ return rows;
135
+ }
136
+
137
+ export type SingleAction = { kind: "option"; index: number } | { kind: "other" };
138
+
139
+ /**
140
+ * Resolve a pick by its position in the rows that were shown. Matching on the
141
+ * display string instead would confuse an option with the control row that
142
+ * happens to read the same.
143
+ */
144
+ export function parseSingleRow(picked: string, rows: string[], options: AskOption[]): SingleAction | null {
145
+ const index = rows.indexOf(picked);
146
+ if (index < 0) return null;
147
+ return index < options.length ? { kind: "option", index } : { kind: "other" };
104
148
  }
105
149
 
106
150
  /** Rows for one round of the multi-select toggle loop. */
@@ -113,12 +157,31 @@ export function toggleRows(options: AskOption[], selected: ReadonlySet<number>,
113
157
 
114
158
  export type ToggleAction = { kind: "toggle"; index: number } | { kind: "done" } | { kind: "other" };
115
159
 
116
- export function parseToggleRow(row: string, options: AskOption[]): ToggleAction | null {
117
- if (row === DONE_LABEL) return { kind: "done" };
118
- if (row === OTHER_LABEL) return { kind: "other" };
119
- const body = row.replace(/^\[[x ]\] /, "");
120
- const index = options.findIndex((o) => optionDisplay(o) === body);
121
- return index >= 0 ? { kind: "toggle", index } : null;
160
+ export function parseToggleRow(row: string, rows: string[], options: AskOption[]): ToggleAction | null {
161
+ const index = rows.indexOf(row);
162
+ if (index < 0) return null;
163
+ if (index < options.length) return { kind: "toggle", index };
164
+ return index === options.length ? { kind: "done" } : { kind: "other" };
165
+ }
166
+
167
+ /**
168
+ * What the model gets back when there is nobody to ask (RPC, CI, headless).
169
+ * Replaying the questions and options keeps the decision in the transcript,
170
+ * so the assumption it states can be checked against what it offered.
171
+ */
172
+ export function headlessText(questions: AskQuestion[]): string {
173
+ const blocks = questions.map((q) => {
174
+ const options = q.options.map((o) => ` - ${optionDisplay(o)}`);
175
+ if (q.allowOther) options.push(" - (free text)");
176
+ return [`Q: ${q.question}`, ...options].join("\n");
177
+ });
178
+ return [
179
+ "No UI is available to ask the user. These are the questions you would have asked:",
180
+ "",
181
+ blocks.join("\n\n"),
182
+ "",
183
+ "Proceed with your best judgment, and say plainly which option you assumed and why.",
184
+ ].join("\n");
122
185
  }
123
186
 
124
187
  /** Text block the model receives. */