@pify/ask-question 0.1.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/LICENSE +21 -0
- package/README.md +21 -0
- package/extensions/ask-question.ts +147 -0
- package/package.json +69 -0
- package/skills/ask-question/SKILL.md +39 -0
- package/src/ask.ts +134 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 pifydev
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# @pify/ask-question
|
|
2
|
+
|
|
3
|
+
Let the model ask instead of guessing — a Claude Code `AskUserQuestion`-shaped tool for [pi](https://github.com/earendil-works/pi): 1-4 structured questions, written-out options with trade-offs, multi-select, and an "Other…" free-text path.
|
|
4
|
+
|
|
5
|
+
Part of the [Pify suite](https://github.com/pifydev). Install with [`pify install ask-question`](https://github.com/pifydev/cli) or `pi install npm:@pify/ask-question`.
|
|
6
|
+
|
|
7
|
+
## What it does
|
|
8
|
+
|
|
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
|
+
- **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
|
+
- **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).
|
|
13
|
+
- Structured results return to the model as both readable text and `details.answers`.
|
|
14
|
+
|
|
15
|
+
## Why no fancy overlay?
|
|
16
|
+
|
|
17
|
+
The three big prior arts (6-16k lines each) all build custom TUI overlays — tabbed questionnaires, split-pane previews, searchable lists. They're impressive and fragile. `@henryqw/pi-ask-question` proved 256 lines of built-in dialogs covers the core; this package takes that floor and adds the CC schema, multi-select, and discipline. The overlay experience can return as v0.2 if demand appears.
|
|
18
|
+
|
|
19
|
+
## License
|
|
20
|
+
|
|
21
|
+
MIT © [Pify maintainers](https://github.com/pifydev)
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pify/ask-question — let the model ask instead of guessing.
|
|
3
|
+
*
|
|
4
|
+
* One tool, Claude Code AskUserQuestion-shaped: 1-4 questions with up to 4
|
|
5
|
+
* written-out options each, multi-select, and an "Other…" free-text path.
|
|
6
|
+
* Built ENTIRELY on pi's built-in dialogs (ui.select / ui.input) — no custom
|
|
7
|
+
* TUI overlay, so it works identically in the terminal and in RPC/GUI hosts
|
|
8
|
+
* and cannot break with pi UI changes (HenryQW's floor, deliberately chosen
|
|
9
|
+
* over the 6-16k-line overlay implementations).
|
|
10
|
+
*
|
|
11
|
+
* Asking is advisory, not a gate: declining is a clean answer, and headless
|
|
12
|
+
* runs get "proceed with your best judgment" instead of an error.
|
|
13
|
+
*
|
|
14
|
+
* Design synthesis: calling discipline (@zhushanwen/pi-ask-user), 4-question
|
|
15
|
+
* batching + recommended convention (rpiv-ask-user-question, Claude Code),
|
|
16
|
+
* multi-select (edlsh/pi-ask-user), built-in-dialog minimalism
|
|
17
|
+
* (@henryqw/pi-ask-question).
|
|
18
|
+
*/
|
|
19
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
20
|
+
import { Type } from "typebox";
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
DONE_LABEL,
|
|
24
|
+
OTHER_LABEL,
|
|
25
|
+
formatAnswers,
|
|
26
|
+
labelFromDisplay,
|
|
27
|
+
optionDisplay,
|
|
28
|
+
parseToggleRow,
|
|
29
|
+
toggleRows,
|
|
30
|
+
validateQuestions,
|
|
31
|
+
type AskAnswer,
|
|
32
|
+
type AskQuestion,
|
|
33
|
+
} from "../src/ask.ts";
|
|
34
|
+
|
|
35
|
+
type UiContext = ExtensionContext;
|
|
36
|
+
|
|
37
|
+
export default function askQuestion(pi: ExtensionAPI) {
|
|
38
|
+
async function askSingle(ctx: UiContext, q: AskQuestion): Promise<AskAnswer> {
|
|
39
|
+
const rows = [...q.options.map(optionDisplay), ...(q.allowOther ? [OTHER_LABEL] : [])];
|
|
40
|
+
const picked = await ctx.ui.select(q.question, rows);
|
|
41
|
+
if (picked === undefined) return { question: q.question, answers: [], declined: true };
|
|
42
|
+
if (picked === OTHER_LABEL) {
|
|
43
|
+
const text = await ctx.ui.input(q.question, "Type your answer");
|
|
44
|
+
if (text === undefined || !text.trim()) {
|
|
45
|
+
return { question: q.question, answers: [], declined: true };
|
|
46
|
+
}
|
|
47
|
+
return { question: q.question, answers: [], other: text.trim() };
|
|
48
|
+
}
|
|
49
|
+
const label = labelFromDisplay(picked, q.options);
|
|
50
|
+
return { question: q.question, answers: label ? [label] : [] };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function askMulti(ctx: UiContext, q: AskQuestion): Promise<AskAnswer> {
|
|
54
|
+
const selected = new Set<number>();
|
|
55
|
+
let other: string | undefined;
|
|
56
|
+
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
|
+
);
|
|
61
|
+
if (picked === undefined) return { question: q.question, answers: [], declined: true };
|
|
62
|
+
const action = parseToggleRow(picked, q.options);
|
|
63
|
+
if (!action) continue;
|
|
64
|
+
if (action.kind === "done") break;
|
|
65
|
+
if (action.kind === "other") {
|
|
66
|
+
const text = await ctx.ui.input(q.question, "Type your answer");
|
|
67
|
+
if (text?.trim()) other = text.trim();
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (selected.has(action.index)) selected.delete(action.index);
|
|
71
|
+
else selected.add(action.index);
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
question: q.question,
|
|
75
|
+
answers: [...selected].sort((a, b) => a - b).map((i) => q.options[i]!.label),
|
|
76
|
+
...(other ? { other } : {}),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
pi.registerTool({
|
|
81
|
+
name: "ask_question",
|
|
82
|
+
label: "Ask the user",
|
|
83
|
+
description:
|
|
84
|
+
"Ask the user 1-4 structured questions, each with up to 4 written-out options (mark your " +
|
|
85
|
+
"recommendation by appending ' (Recommended)' to its label and putting it first), optional " +
|
|
86
|
+
"multiSelect, and an Other free-text path (allowOther, default true). " +
|
|
87
|
+
"Call ONLY when all three hold: the request has 2+ reasonable approaches; you have already " +
|
|
88
|
+
"gathered context (read/grep) and it is still genuinely ambiguous; and picking wrong means " +
|
|
89
|
+
"redoing real work. Never use it for permissions, for things you can look up yourself, or to " +
|
|
90
|
+
"confirm a plan you are confident in. A declined answer is an answer — respect it and proceed.",
|
|
91
|
+
parameters: Type.Object({
|
|
92
|
+
questions: Type.Array(
|
|
93
|
+
Type.Object({
|
|
94
|
+
question: Type.String({ description: "The complete question, ending with a question mark" }),
|
|
95
|
+
options: Type.Optional(
|
|
96
|
+
Type.Array(
|
|
97
|
+
Type.Object({
|
|
98
|
+
label: Type.String({ description: "Concise choice (1-6 words)" }),
|
|
99
|
+
description: Type.Optional(Type.String({ description: "Trade-offs of this choice" })),
|
|
100
|
+
}),
|
|
101
|
+
{ maxItems: 4 },
|
|
102
|
+
),
|
|
103
|
+
),
|
|
104
|
+
multiSelect: Type.Optional(Type.Boolean({ description: "Allow selecting several options" })),
|
|
105
|
+
allowOther: Type.Optional(Type.Boolean({ description: "Offer a free-text Other path (default true)" })),
|
|
106
|
+
}),
|
|
107
|
+
{ minItems: 1, maxItems: 4 },
|
|
108
|
+
),
|
|
109
|
+
}),
|
|
110
|
+
async execute(_id, params: { questions: unknown }, _signal, _onUpdate, ctx) {
|
|
111
|
+
const uiCtx = ctx as UiContext;
|
|
112
|
+
const result = validateQuestions(params.questions);
|
|
113
|
+
if (result.error) throw new Error(result.error);
|
|
114
|
+
|
|
115
|
+
if (!uiCtx.hasUI) {
|
|
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 },
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const answers: AskAnswer[] = [];
|
|
128
|
+
for (const q of result.questions) {
|
|
129
|
+
const answer = q.multiSelect ? await askMulti(uiCtx, q) : await askSingle(uiCtx, q);
|
|
130
|
+
answers.push(answer);
|
|
131
|
+
if (answer.declined) {
|
|
132
|
+
// Esc aborts the rest — the user is opting out of the questionnaire.
|
|
133
|
+
for (const rest of result.questions.slice(answers.length)) {
|
|
134
|
+
answers.push({ question: rest.question, answers: [], declined: true });
|
|
135
|
+
}
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const text = [
|
|
141
|
+
formatAnswers(answers),
|
|
142
|
+
...(result.warnings.length > 0 ? [`Warnings: ${result.warnings.join("; ")}`] : []),
|
|
143
|
+
].join("\n\n");
|
|
144
|
+
return { content: [{ type: "text", text }], details: { answers } };
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pify/ask-question",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"pi-extension",
|
|
8
|
+
"pi",
|
|
9
|
+
"pify",
|
|
10
|
+
"ask",
|
|
11
|
+
"question",
|
|
12
|
+
"interactive"
|
|
13
|
+
],
|
|
14
|
+
"homepage": "https://github.com/pifydev/ask-question#readme",
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/pifydev/ask-question/issues"
|
|
17
|
+
},
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/pifydev/ask-question.git"
|
|
21
|
+
},
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"author": "Pify maintainers",
|
|
24
|
+
"type": "module",
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=22.19.0"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"extensions",
|
|
30
|
+
"src",
|
|
31
|
+
"skills",
|
|
32
|
+
"README.md",
|
|
33
|
+
"LICENSE"
|
|
34
|
+
],
|
|
35
|
+
"pi": {
|
|
36
|
+
"extensions": [
|
|
37
|
+
"./extensions/ask-question.ts"
|
|
38
|
+
],
|
|
39
|
+
"skills": [
|
|
40
|
+
"./skills"
|
|
41
|
+
]
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"typecheck": "tsc --noEmit",
|
|
45
|
+
"test": "bun test",
|
|
46
|
+
"prepublishOnly": "npm run typecheck && npm test"
|
|
47
|
+
},
|
|
48
|
+
"peerDependencies": {
|
|
49
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
50
|
+
"typebox": "*"
|
|
51
|
+
},
|
|
52
|
+
"peerDependenciesMeta": {
|
|
53
|
+
"@earendil-works/pi-coding-agent": {
|
|
54
|
+
"optional": true
|
|
55
|
+
},
|
|
56
|
+
"typebox": {
|
|
57
|
+
"optional": true
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
"devDependencies": {
|
|
61
|
+
"@earendil-works/pi-coding-agent": "^0.84.4",
|
|
62
|
+
"@types/node": "^22.10.2",
|
|
63
|
+
"typebox": "^1.1.38",
|
|
64
|
+
"typescript": "^5.7.2"
|
|
65
|
+
},
|
|
66
|
+
"publishConfig": {
|
|
67
|
+
"access": "public"
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ask-question
|
|
3
|
+
description: Use when a decision is genuinely ambiguous after gathering context and picking wrong means rework - explains the ask_question discipline and how to write good options
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Asking the user
|
|
7
|
+
|
|
8
|
+
This project has the `@pify/ask-question` extension installed: `ask_question`
|
|
9
|
+
presents 1-4 structured questions through pi's dialogs.
|
|
10
|
+
|
|
11
|
+
## The three conditions (all must hold)
|
|
12
|
+
|
|
13
|
+
1. The request has 2+ reasonable approaches.
|
|
14
|
+
2. You already gathered context (read/grep) and it is STILL ambiguous.
|
|
15
|
+
3. Picking wrong means redoing real work.
|
|
16
|
+
|
|
17
|
+
If any fails: don't ask. Look it up, or pick the obvious option and say so.
|
|
18
|
+
|
|
19
|
+
## Never use it for
|
|
20
|
+
|
|
21
|
+
- Permissions or approvals (safety gates own that).
|
|
22
|
+
- Facts you can verify in the codebase yourself.
|
|
23
|
+
- Confirming a plan you are confident in ("shall I proceed?").
|
|
24
|
+
- More than one call per decision point — batch related questions (max 4).
|
|
25
|
+
|
|
26
|
+
## Writing good questions
|
|
27
|
+
|
|
28
|
+
- Complete question ending with "?"; options are concise (1-6 words) with
|
|
29
|
+
trade-offs in descriptions.
|
|
30
|
+
- Put your recommendation FIRST and append " (Recommended)" to its label.
|
|
31
|
+
- Use multiSelect only when choices are not mutually exclusive.
|
|
32
|
+
- Leave allowOther on unless free text makes no sense.
|
|
33
|
+
|
|
34
|
+
## Respecting answers
|
|
35
|
+
|
|
36
|
+
- A declined answer (Esc) is an answer: proceed on your best judgment,
|
|
37
|
+
state the assumption, and do not re-ask.
|
|
38
|
+
- With no UI (headless), the tool tells you to proceed — do that, and make
|
|
39
|
+
the assumption explicit in your reply.
|
package/src/ask.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure logic for @pify/ask-question.
|
|
3
|
+
* No imports from pi packages; fully unit-testable.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface AskOption {
|
|
7
|
+
label: string;
|
|
8
|
+
description?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface AskQuestion {
|
|
12
|
+
question: string;
|
|
13
|
+
options: AskOption[];
|
|
14
|
+
multiSelect: boolean;
|
|
15
|
+
allowOther: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface AskAnswer {
|
|
19
|
+
question: string;
|
|
20
|
+
/** Selected option labels (empty when only `other` was given). */
|
|
21
|
+
answers: string[];
|
|
22
|
+
/** Free-text answer via "Other…", when used. */
|
|
23
|
+
other?: string;
|
|
24
|
+
declined?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const MAX_QUESTIONS = 4;
|
|
28
|
+
export const MAX_OPTIONS = 4;
|
|
29
|
+
export const OTHER_LABEL = "Other…";
|
|
30
|
+
export const DONE_LABEL = "✓ Done";
|
|
31
|
+
|
|
32
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
33
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ValidationResult {
|
|
37
|
+
questions: AskQuestion[];
|
|
38
|
+
warnings: string[];
|
|
39
|
+
error: string | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function validateQuestions(raw: unknown): ValidationResult {
|
|
43
|
+
const warnings: string[] = [];
|
|
44
|
+
if (!Array.isArray(raw) || raw.length === 0) {
|
|
45
|
+
return { questions: [], warnings, error: "questions must be a non-empty array" };
|
|
46
|
+
}
|
|
47
|
+
if (raw.length > MAX_QUESTIONS) warnings.push(`capped at ${MAX_QUESTIONS} questions`);
|
|
48
|
+
|
|
49
|
+
const questions: AskQuestion[] = [];
|
|
50
|
+
for (const entry of raw.slice(0, MAX_QUESTIONS)) {
|
|
51
|
+
if (!isRecord(entry) || typeof entry.question !== "string" || !entry.question.trim()) {
|
|
52
|
+
warnings.push("dropped a question without text");
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const options: AskOption[] = [];
|
|
56
|
+
if (Array.isArray(entry.options)) {
|
|
57
|
+
for (const opt of entry.options.slice(0, MAX_OPTIONS)) {
|
|
58
|
+
if (isRecord(opt) && typeof opt.label === "string" && opt.label.trim()) {
|
|
59
|
+
options.push({
|
|
60
|
+
label: opt.label.trim(),
|
|
61
|
+
...(typeof opt.description === "string" && opt.description.trim()
|
|
62
|
+
? { description: opt.description.trim() }
|
|
63
|
+
: {}),
|
|
64
|
+
});
|
|
65
|
+
} else {
|
|
66
|
+
warnings.push("dropped an option without a label");
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (entry.options.length > MAX_OPTIONS) {
|
|
70
|
+
warnings.push(`options capped at ${MAX_OPTIONS} per question`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const allowOther = entry.allowOther !== false;
|
|
74
|
+
if (options.length === 0 && !allowOther) {
|
|
75
|
+
warnings.push(`question "${entry.question.slice(0, 30)}" has no options and allowOther=false — dropped`);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
questions.push({
|
|
79
|
+
question: entry.question.trim(),
|
|
80
|
+
options,
|
|
81
|
+
multiSelect: entry.multiSelect === true,
|
|
82
|
+
allowOther,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (questions.length === 0) {
|
|
87
|
+
return { questions, warnings, error: "no valid questions remained" };
|
|
88
|
+
}
|
|
89
|
+
return { questions, warnings, error: null };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Display string for one option in the select dialog. */
|
|
93
|
+
export function optionDisplay(option: AskOption): string {
|
|
94
|
+
const desc = option.description
|
|
95
|
+
? ` — ${option.description.length > 60 ? `${option.description.slice(0, 60)}…` : option.description}`
|
|
96
|
+
: "";
|
|
97
|
+
return `${option.label}${desc}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
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;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Rows for one round of the multi-select toggle loop. */
|
|
107
|
+
export function toggleRows(options: AskOption[], selected: ReadonlySet<number>, allowOther: boolean): string[] {
|
|
108
|
+
const rows = options.map((o, i) => `[${selected.has(i) ? "x" : " "}] ${optionDisplay(o)}`);
|
|
109
|
+
rows.push(DONE_LABEL);
|
|
110
|
+
if (allowOther) rows.push(OTHER_LABEL);
|
|
111
|
+
return rows;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export type ToggleAction = { kind: "toggle"; index: number } | { kind: "done" } | { kind: "other" };
|
|
115
|
+
|
|
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;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Text block the model receives. */
|
|
125
|
+
export function formatAnswers(answers: AskAnswer[]): string {
|
|
126
|
+
return answers
|
|
127
|
+
.map((a) => {
|
|
128
|
+
if (a.declined) return `Q: ${a.question}\nA: (the user declined to answer)`;
|
|
129
|
+
const parts = [...a.answers];
|
|
130
|
+
if (a.other) parts.push(`Other: ${a.other}`);
|
|
131
|
+
return `Q: ${a.question}\nA: ${parts.length > 0 ? parts.join("; ") : "(no selection)"}`;
|
|
132
|
+
})
|
|
133
|
+
.join("\n\n");
|
|
134
|
+
}
|