@xynogen/pix-ask 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/package.json +43 -0
- package/src/ask.test.ts +243 -0
- package/src/components.ts +55 -0
- package/src/helpers.ts +77 -0
- package/src/index.ts +130 -0
- package/src/questionnaire.ts +609 -0
- package/src/rpc.ts +84 -0
- package/src/schema.ts +69 -0
- package/src/types.ts +17 -0
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@xynogen/pix-ask",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Pi tool — structured questionnaire UI (ask_user)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"test": "bun test"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"pi": {
|
|
16
|
+
"extensions": [
|
|
17
|
+
"src/index.ts"
|
|
18
|
+
]
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"pi",
|
|
22
|
+
"pi-package",
|
|
23
|
+
"pi-extension",
|
|
24
|
+
"ask"
|
|
25
|
+
],
|
|
26
|
+
"author": "xynogen",
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "git+https://github.com/xynogen/pix-mono.git",
|
|
31
|
+
"directory": "packages/pix-ask"
|
|
32
|
+
},
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"typebox": "^1.1.38"
|
|
38
|
+
},
|
|
39
|
+
"peerDependencies": {
|
|
40
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
41
|
+
"@earendil-works/pi-tui": "*"
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/ask.test.ts
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ask.test.ts — tests for the ask questionnaire tool
|
|
3
|
+
*
|
|
4
|
+
* Tests cover pure functions (schema validation, sentinel logic, answer
|
|
5
|
+
* formatting). TUI components are not tested here.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test } from "bun:test";
|
|
9
|
+
import {
|
|
10
|
+
buildResponseText,
|
|
11
|
+
formatAnswerScalar,
|
|
12
|
+
hasAnyPreview,
|
|
13
|
+
type OptionData,
|
|
14
|
+
type QuestionData,
|
|
15
|
+
sentinelsFor,
|
|
16
|
+
} from "./index.ts";
|
|
17
|
+
|
|
18
|
+
// ── Fixtures ──────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
const opt = (
|
|
21
|
+
label: string,
|
|
22
|
+
description = "Test option",
|
|
23
|
+
preview?: string,
|
|
24
|
+
): OptionData => ({
|
|
25
|
+
label,
|
|
26
|
+
description,
|
|
27
|
+
...(preview ? { preview } : {}),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const qSingle: QuestionData = {
|
|
31
|
+
question: "Which approach?",
|
|
32
|
+
header: "Approach",
|
|
33
|
+
options: [
|
|
34
|
+
opt("REST", "Traditional REST API"),
|
|
35
|
+
opt("GraphQL", "Query language for APIs"),
|
|
36
|
+
],
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const qMulti: QuestionData = {
|
|
40
|
+
question: "Which features?",
|
|
41
|
+
header: "Features",
|
|
42
|
+
options: [
|
|
43
|
+
opt("Auth", "User authentication"),
|
|
44
|
+
opt("Search", "Full text search"),
|
|
45
|
+
opt("Export", "Data export"),
|
|
46
|
+
],
|
|
47
|
+
multiSelect: true,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const qWithPreview: QuestionData = {
|
|
51
|
+
question: "Pick a component?",
|
|
52
|
+
header: "Component",
|
|
53
|
+
options: [
|
|
54
|
+
opt("Button", "Clickable button", "<Button>Primary</Button>"),
|
|
55
|
+
opt("Card", "Container card", "<Card><Content/></Card>"),
|
|
56
|
+
],
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const qSingleNoPreview: QuestionData = {
|
|
60
|
+
question: "Color?",
|
|
61
|
+
header: "Color",
|
|
62
|
+
options: [opt("Red", "Ruby red"), opt("Blue", "Ocean blue")],
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// ── hasAnyPreview ─────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
describe("hasAnyPreview", () => {
|
|
68
|
+
test("returns false when no option has preview", () => {
|
|
69
|
+
expect(hasAnyPreview(qSingle)).toBe(false);
|
|
70
|
+
expect(hasAnyPreview(qMulti)).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("returns true when at least one option has preview", () => {
|
|
74
|
+
expect(hasAnyPreview(qWithPreview)).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("returns false for empty options", () => {
|
|
78
|
+
const q: QuestionData = { question: "?", header: "X", options: [] };
|
|
79
|
+
expect(hasAnyPreview(q)).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// ── sentinelsFor ──────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
describe("sentinelsFor", () => {
|
|
86
|
+
test('single-select without preview appends "Type something."', () => {
|
|
87
|
+
const r = sentinelsFor(qSingleNoPreview);
|
|
88
|
+
expect(r).toHaveLength(1);
|
|
89
|
+
expect(r[0]?.kind).toBe("other");
|
|
90
|
+
expect(r[0]?.label).toBe("Type something.");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('single-select with preview appends nothing (only "Chat about this" is separate)', () => {
|
|
94
|
+
const r = sentinelsFor(qWithPreview);
|
|
95
|
+
expect(r).toHaveLength(0);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('multi-select appends "Next"', () => {
|
|
99
|
+
const r = sentinelsFor(qMulti);
|
|
100
|
+
expect(r).toHaveLength(1);
|
|
101
|
+
expect(r[0]?.kind).toBe("next");
|
|
102
|
+
expect(r[0]?.label).toBe("Next");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("multi-select never appends Type something.", () => {
|
|
106
|
+
const r = sentinelsFor({ ...qMulti, multiSelect: true });
|
|
107
|
+
expect(r.every((s) => s.kind !== "other")).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("empty options still gets freeform sentinel (no preview = single-select)", () => {
|
|
111
|
+
const r = sentinelsFor({ question: "?", header: "X", options: [] });
|
|
112
|
+
expect(r).toHaveLength(1);
|
|
113
|
+
expect(r[0]?.kind).toBe("other");
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// ── formatAnswerScalar ────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
describe("formatAnswerScalar", () => {
|
|
120
|
+
test("option kind returns the answer string", () => {
|
|
121
|
+
const a = {
|
|
122
|
+
questionIndex: 0,
|
|
123
|
+
question: "Q",
|
|
124
|
+
kind: "option" as const,
|
|
125
|
+
answer: "REST",
|
|
126
|
+
};
|
|
127
|
+
expect(formatAnswerScalar(a)).toBe("REST");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("multi kind joins selected with comma", () => {
|
|
131
|
+
const a = {
|
|
132
|
+
questionIndex: 0,
|
|
133
|
+
question: "Q",
|
|
134
|
+
kind: "multi" as const,
|
|
135
|
+
answer: null,
|
|
136
|
+
selected: ["Auth", "Search"],
|
|
137
|
+
};
|
|
138
|
+
expect(formatAnswerScalar(a)).toBe("Auth, Search");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("custom kind returns the typed text", () => {
|
|
142
|
+
const a = {
|
|
143
|
+
questionIndex: 0,
|
|
144
|
+
question: "Q",
|
|
145
|
+
kind: "custom" as const,
|
|
146
|
+
answer: "my custom answer",
|
|
147
|
+
};
|
|
148
|
+
expect(formatAnswerScalar(a)).toBe("my custom answer");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("chat kind returns (chat)", () => {
|
|
152
|
+
const a = {
|
|
153
|
+
questionIndex: 0,
|
|
154
|
+
question: "Q",
|
|
155
|
+
kind: "chat" as const,
|
|
156
|
+
answer: null,
|
|
157
|
+
};
|
|
158
|
+
expect(formatAnswerScalar(a)).toBe("(chat)");
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// ── buildResponseText ─────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
describe("buildResponseText", () => {
|
|
165
|
+
test("formats single answer", () => {
|
|
166
|
+
const answers = [
|
|
167
|
+
{
|
|
168
|
+
questionIndex: 0,
|
|
169
|
+
question: "Which approach?",
|
|
170
|
+
kind: "option" as const,
|
|
171
|
+
answer: "REST",
|
|
172
|
+
},
|
|
173
|
+
];
|
|
174
|
+
const text = buildResponseText(answers, [qSingle]);
|
|
175
|
+
expect(text).toContain("REST");
|
|
176
|
+
expect(text).toContain("Which approach?");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("formats multi-select answer", () => {
|
|
180
|
+
const answers = [
|
|
181
|
+
{
|
|
182
|
+
questionIndex: 0,
|
|
183
|
+
question: "Which features?",
|
|
184
|
+
kind: "multi" as const,
|
|
185
|
+
answer: null,
|
|
186
|
+
selected: ["Auth", "Search"],
|
|
187
|
+
},
|
|
188
|
+
];
|
|
189
|
+
const text = buildResponseText(answers, [qMulti]);
|
|
190
|
+
expect(text).toContain("Auth, Search");
|
|
191
|
+
expect(text).toContain("Which features?");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("includes preview in response when present", () => {
|
|
195
|
+
const answers = [
|
|
196
|
+
{
|
|
197
|
+
questionIndex: 0,
|
|
198
|
+
question: "Pick a component?",
|
|
199
|
+
kind: "option" as const,
|
|
200
|
+
answer: "Button",
|
|
201
|
+
preview: "<Button>Primary</Button>",
|
|
202
|
+
},
|
|
203
|
+
];
|
|
204
|
+
const text = buildResponseText(answers, [qWithPreview]);
|
|
205
|
+
expect(text).toContain("preview: <Button>Primary</Button>");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("formats multiple answers", () => {
|
|
209
|
+
const qs = [qSingle, qMulti];
|
|
210
|
+
const answers = [
|
|
211
|
+
{
|
|
212
|
+
questionIndex: 0,
|
|
213
|
+
question: "Which approach?",
|
|
214
|
+
kind: "option" as const,
|
|
215
|
+
answer: "GraphQL",
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
questionIndex: 1,
|
|
219
|
+
question: "Which features?",
|
|
220
|
+
kind: "multi" as const,
|
|
221
|
+
answer: null,
|
|
222
|
+
selected: ["Export"],
|
|
223
|
+
},
|
|
224
|
+
];
|
|
225
|
+
const text = buildResponseText(answers, qs);
|
|
226
|
+
expect(text).toContain("GraphQL");
|
|
227
|
+
expect(text).toContain("Export");
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("shows declined message when no answers", () => {
|
|
231
|
+
const text = buildResponseText([], [qSingle]);
|
|
232
|
+
expect(text).toContain("declined");
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// ── Tool registration shape ─────────────────────────────────────────
|
|
237
|
+
|
|
238
|
+
describe("registerAsk", () => {
|
|
239
|
+
test("exports a default function", async () => {
|
|
240
|
+
const mod = await import("./index.ts");
|
|
241
|
+
expect(typeof mod.default).toBe("function");
|
|
242
|
+
});
|
|
243
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { type Component, truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import type { QuestionData } from "./schema.js";
|
|
4
|
+
|
|
5
|
+
// ── Color helpers ──────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
export function borderColor(theme: Theme): (s: string) => string {
|
|
8
|
+
return (s: string) => theme.fg("accent", s);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function dim(theme: Theme): (s: string) => string {
|
|
12
|
+
return (s: string) => theme.fg("dim", s);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// ── TabBar ─────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
export class TabBar implements Component {
|
|
18
|
+
private questions: QuestionData[];
|
|
19
|
+
private activeIndex: number;
|
|
20
|
+
private theme: Theme;
|
|
21
|
+
|
|
22
|
+
constructor(questions: QuestionData[], activeIndex: number, theme: Theme) {
|
|
23
|
+
this.questions = questions;
|
|
24
|
+
this.activeIndex = activeIndex;
|
|
25
|
+
this.theme = theme;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
invalidate(): void {}
|
|
29
|
+
|
|
30
|
+
render(width: number): string[] {
|
|
31
|
+
const t = this.theme;
|
|
32
|
+
const inner = Math.max(10, width - 2);
|
|
33
|
+
|
|
34
|
+
const parts: string[] = [];
|
|
35
|
+
for (let i = 0; i < this.questions.length; i++) {
|
|
36
|
+
const active = i === this.activeIndex;
|
|
37
|
+
const num = `${i + 1}`;
|
|
38
|
+
const tag = `${num}.${this.questions[i]?.header}`;
|
|
39
|
+
parts.push(active ? t.fg("accent", t.bold(tag)) : t.fg("dim", tag));
|
|
40
|
+
}
|
|
41
|
+
const line = parts.join(t.fg("dim", " "));
|
|
42
|
+
return [
|
|
43
|
+
truncateToWidth(
|
|
44
|
+
t.fg("accent", "╭─") +
|
|
45
|
+
line +
|
|
46
|
+
t.fg(
|
|
47
|
+
"accent",
|
|
48
|
+
`${"─".repeat(Math.max(0, inner - line.length - 1))}╮`,
|
|
49
|
+
),
|
|
50
|
+
width,
|
|
51
|
+
"",
|
|
52
|
+
),
|
|
53
|
+
].filter(Boolean);
|
|
54
|
+
}
|
|
55
|
+
}
|
package/src/helpers.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { MarkdownTheme } from "@earendil-works/pi-tui";
|
|
3
|
+
import type { OptionData, QuestionData } from "./schema.js";
|
|
4
|
+
import { SENTINEL_CHAT, SENTINEL_FREEFORM, SENTINEL_NEXT } from "./schema.js";
|
|
5
|
+
import type { AnswerKind, QuestionAnswer } from "./types.js";
|
|
6
|
+
|
|
7
|
+
// ── Markdown theme ─────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
export function safeMarkdownTheme(): MarkdownTheme | undefined {
|
|
10
|
+
try {
|
|
11
|
+
const md = getMarkdownTheme();
|
|
12
|
+
if (!md) return undefined;
|
|
13
|
+
md.bold("");
|
|
14
|
+
return md;
|
|
15
|
+
} catch {
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ── Option / question helpers ──────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
export function hasAnyPreview(q: QuestionData): boolean {
|
|
23
|
+
return q.options.some(
|
|
24
|
+
(o) => typeof o.preview === "string" && o.preview.length > 0,
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Which sentinel rows are auto-appended for a question. */
|
|
29
|
+
export function sentinelsFor(
|
|
30
|
+
q: QuestionData,
|
|
31
|
+
): Array<{ kind: string; label: string }> {
|
|
32
|
+
const out: Array<{ kind: string; label: string }> = [];
|
|
33
|
+
if (q.multiSelect) {
|
|
34
|
+
out.push({ kind: "next", label: SENTINEL_NEXT });
|
|
35
|
+
} else if (!hasAnyPreview(q)) {
|
|
36
|
+
out.push({ kind: "other", label: SENTINEL_FREEFORM });
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ── Answer formatting ──────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
export function formatAnswerScalar(a: QuestionAnswer): string {
|
|
44
|
+
if (a.kind === "multi") return (a.selected ?? []).join(", ");
|
|
45
|
+
if (a.kind === "custom") return a.answer ?? "(custom)";
|
|
46
|
+
if (a.kind === "chat") return "(chat)";
|
|
47
|
+
return a.answer ?? "(selected)";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function buildResponseText(
|
|
51
|
+
answers: QuestionAnswer[],
|
|
52
|
+
questions: QuestionData[],
|
|
53
|
+
): string {
|
|
54
|
+
const segs: string[] = [];
|
|
55
|
+
for (const a of answers) {
|
|
56
|
+
const q = questions[a.questionIndex]?.question ?? `Q${a.questionIndex + 1}`;
|
|
57
|
+
let s = `"${q}"="${formatAnswerScalar(a)}"`;
|
|
58
|
+
if (a.preview) s += `. selected preview: ${a.preview}`;
|
|
59
|
+
segs.push(s);
|
|
60
|
+
}
|
|
61
|
+
return segs.length
|
|
62
|
+
? `User answered: ${segs.join(". ")}.`
|
|
63
|
+
: "User declined to answer questions.";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── Scroll indicator ───────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
export function scrollIndicator(index: number, total: number): string {
|
|
69
|
+
if (total <= 1) return "";
|
|
70
|
+
const pos = Math.round((index / (total - 1)) * 6);
|
|
71
|
+
const bar = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦"][pos] ?? "·";
|
|
72
|
+
return ` ${bar} ${index + 1}/${total}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export type { AnswerKind, OptionData, QuestionData };
|
|
76
|
+
// Re-export sentinel constants so callers don't need to import schema directly
|
|
77
|
+
export { SENTINEL_CHAT, SENTINEL_FREEFORM, SENTINEL_NEXT };
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
3
|
+
|
|
4
|
+
import { buildResponseText } from "./helpers.js";
|
|
5
|
+
import { AskQuestionnaire } from "./questionnaire.js";
|
|
6
|
+
import { rpcFallback } from "./rpc.js";
|
|
7
|
+
import type { Params } from "./schema.js";
|
|
8
|
+
import {
|
|
9
|
+
MAX_OPTIONS,
|
|
10
|
+
MAX_QUESTIONS,
|
|
11
|
+
MIN_OPTIONS,
|
|
12
|
+
ParamsSchema,
|
|
13
|
+
SENTINEL_CHAT,
|
|
14
|
+
SENTINEL_FREEFORM,
|
|
15
|
+
} from "./schema.js";
|
|
16
|
+
import type { QuestionAnswer, QuestionnaireResult } from "./types.js";
|
|
17
|
+
|
|
18
|
+
// ── Re-exports (consumed by tests and single-select-layout) ───────────
|
|
19
|
+
|
|
20
|
+
export {
|
|
21
|
+
buildResponseText,
|
|
22
|
+
formatAnswerScalar,
|
|
23
|
+
hasAnyPreview,
|
|
24
|
+
sentinelsFor,
|
|
25
|
+
} from "./helpers.js";
|
|
26
|
+
export type { OptionData, QuestionData } from "./schema.js";
|
|
27
|
+
export type {
|
|
28
|
+
AnswerKind,
|
|
29
|
+
QuestionAnswer,
|
|
30
|
+
QuestionnaireResult,
|
|
31
|
+
} from "./types.js";
|
|
32
|
+
|
|
33
|
+
// ── Tool registration ──────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
export default function registerAsk(pi: ExtensionAPI): void {
|
|
36
|
+
pi.registerTool({
|
|
37
|
+
name: "ask_user",
|
|
38
|
+
label: "Ask",
|
|
39
|
+
description: `Ask the user up to ${MAX_QUESTIONS} structured questions (${MIN_OPTIONS}-${MAX_OPTIONS} options each) when requirements are ambiguous.`,
|
|
40
|
+
promptSnippet: `Ask the user up to ${MAX_QUESTIONS} structured questions (${MIN_OPTIONS}-${MAX_OPTIONS} options each) when requirements are ambiguous`,
|
|
41
|
+
promptGuidelines: [
|
|
42
|
+
`Use ask whenever the user's request is underspecified and you cannot proceed without concrete decisions — you can ask up to ${MAX_QUESTIONS} questions per invocation.`,
|
|
43
|
+
`Each question MUST have ${MIN_OPTIONS}-${MAX_OPTIONS} options. Every option requires a concise label (1-5 words) and a description explaining what the choice means or its trade-offs. The user can additionally type a custom answer ("${SENTINEL_FREEFORM}" row is appended automatically to single-select questions) or pick "${SENTINEL_CHAT}" to abandon the questionnaire.`,
|
|
44
|
+
`Set multiSelect: true when multiple answers are valid; this suppresses the "${SENTINEL_FREEFORM}" row. Provide an options[].preview markdown string when an option benefits from richer side-by-side context (mockups, code snippets, diagrams, configs) — single-select only. NOTE: any non-empty preview on a single-select question ALSO suppresses the "${SENTINEL_FREEFORM}" row (no room in the side-by-side layout); "${SENTINEL_CHAT}" remains the escape hatch. If you recommend a specific option, make it the first option and append "(Recommended)" to its label.`,
|
|
45
|
+
"Do not stack multiple ask calls back-to-back — group all clarifying questions into one invocation.",
|
|
46
|
+
],
|
|
47
|
+
executionMode: "sequential",
|
|
48
|
+
parameters: ParamsSchema,
|
|
49
|
+
|
|
50
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
51
|
+
if (signal?.aborted) {
|
|
52
|
+
return {
|
|
53
|
+
content: [{ type: "text", text: "Cancelled" }],
|
|
54
|
+
details: { answers: [], cancelled: true },
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const typed = params as unknown as Params;
|
|
59
|
+
|
|
60
|
+
if (!Array.isArray(typed.questions) || typed.questions.length === 0) {
|
|
61
|
+
return {
|
|
62
|
+
content: [
|
|
63
|
+
{ type: "text", text: "At least one question is required." },
|
|
64
|
+
],
|
|
65
|
+
isError: true,
|
|
66
|
+
details: { answers: [], cancelled: true },
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (!ctx.hasUI) {
|
|
71
|
+
const result = await rpcFallback(ctx.ui, typed);
|
|
72
|
+
const text = result.cancelled
|
|
73
|
+
? "User cancelled the questionnaire"
|
|
74
|
+
: buildResponseText(result.answers, typed.questions);
|
|
75
|
+
return { content: [{ type: "text", text }], details: result };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const result = await ctx.ui.custom<QuestionnaireResult | null>(
|
|
79
|
+
(tui, theme, keybindings, done) => {
|
|
80
|
+
if (signal) {
|
|
81
|
+
signal.addEventListener(
|
|
82
|
+
"abort",
|
|
83
|
+
() => done({ answers: [], cancelled: true }),
|
|
84
|
+
{ once: true },
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
return new AskQuestionnaire(typed, tui, theme, keybindings, done);
|
|
88
|
+
},
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
if (!result || result.cancelled) {
|
|
92
|
+
return {
|
|
93
|
+
content: [{ type: "text", text: "User cancelled the questionnaire" }],
|
|
94
|
+
details: result ?? { answers: [], cancelled: true },
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const text = buildResponseText(result.answers, typed.questions);
|
|
99
|
+
return { content: [{ type: "text", text }], details: result };
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
renderCall(args, theme) {
|
|
103
|
+
const questions = Array.isArray(args.questions) ? args.questions : [];
|
|
104
|
+
const count = questions.length;
|
|
105
|
+
const firstQ = (questions[0]?.question ?? "") as string;
|
|
106
|
+
let text = theme.fg("toolTitle", theme.bold(`ask (${count}) `));
|
|
107
|
+
text += theme.fg("muted", firstQ);
|
|
108
|
+
if (count > 1) text += theme.fg("dim", ` +${count - 1} more`);
|
|
109
|
+
return new Text(text, 0, 0);
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
renderResult(result, options, theme) {
|
|
113
|
+
const details = result.details as
|
|
114
|
+
| { answers?: QuestionAnswer[]; cancelled?: boolean }
|
|
115
|
+
| undefined;
|
|
116
|
+
if (options.isPartial) {
|
|
117
|
+
return new Text(theme.fg("muted", "Waiting for user input..."), 0, 0);
|
|
118
|
+
}
|
|
119
|
+
if (!details || details.cancelled || !details.answers?.length) {
|
|
120
|
+
return new Text(theme.fg("warning", "Cancelled"), 0, 0);
|
|
121
|
+
}
|
|
122
|
+
const texts = details.answers.map((a) => {
|
|
123
|
+
const v =
|
|
124
|
+
a.kind === "multi" ? (a.selected ?? []).join(", ") : (a.answer ?? "");
|
|
125
|
+
return `${a.questionIndex + 1}: ${v}`;
|
|
126
|
+
});
|
|
127
|
+
return new Text(theme.fg("success", `✓ ${texts.join(" • ")}`), 0, 0);
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
}
|
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
Container,
|
|
4
|
+
decodeKittyPrintable,
|
|
5
|
+
Editor,
|
|
6
|
+
fuzzyFilter,
|
|
7
|
+
Key,
|
|
8
|
+
type KeybindingsManager,
|
|
9
|
+
Markdown,
|
|
10
|
+
matchesKey,
|
|
11
|
+
type TUI,
|
|
12
|
+
truncateToWidth,
|
|
13
|
+
wrapTextWithAnsi,
|
|
14
|
+
} from "@earendil-works/pi-tui";
|
|
15
|
+
import { dim } from "./components.js";
|
|
16
|
+
import { safeMarkdownTheme, sentinelsFor } from "./helpers.js";
|
|
17
|
+
import type { OptionData, Params, QuestionData } from "./schema.js";
|
|
18
|
+
import {
|
|
19
|
+
SENTINEL_CHAT,
|
|
20
|
+
SENTINEL_FREEFORM,
|
|
21
|
+
SENTINEL_NEXT,
|
|
22
|
+
SEPARATOR,
|
|
23
|
+
SPLIT_PANE_MIN_WIDTH,
|
|
24
|
+
} from "./schema.js";
|
|
25
|
+
import type {
|
|
26
|
+
AnswerKind,
|
|
27
|
+
QuestionAnswer,
|
|
28
|
+
QuestionnaireResult,
|
|
29
|
+
} from "./types.js";
|
|
30
|
+
|
|
31
|
+
// ── AskQuestionnaire ───────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
export class AskQuestionnaire extends Container {
|
|
34
|
+
private params: Params;
|
|
35
|
+
private tui: TUI;
|
|
36
|
+
private theme: Theme;
|
|
37
|
+
private keybindings: KeybindingsManager;
|
|
38
|
+
private onDone: (result: QuestionnaireResult | null) => void;
|
|
39
|
+
|
|
40
|
+
private currentIndex = 0;
|
|
41
|
+
private answers: QuestionAnswer[] = [];
|
|
42
|
+
private searchQuery = "";
|
|
43
|
+
private selectedOptionIndex = 0;
|
|
44
|
+
private multiChecked = new Set<number>();
|
|
45
|
+
private inputMode = false;
|
|
46
|
+
private editor?: Editor;
|
|
47
|
+
private mdTheme = safeMarkdownTheme();
|
|
48
|
+
|
|
49
|
+
constructor(
|
|
50
|
+
params: Params,
|
|
51
|
+
tui: TUI,
|
|
52
|
+
theme: Theme,
|
|
53
|
+
keybindings: KeybindingsManager,
|
|
54
|
+
onDone: (result: QuestionnaireResult | null) => void,
|
|
55
|
+
) {
|
|
56
|
+
super();
|
|
57
|
+
this.params = params;
|
|
58
|
+
this.tui = tui;
|
|
59
|
+
this.theme = theme;
|
|
60
|
+
this.keybindings = keybindings;
|
|
61
|
+
this.onDone = onDone;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── Accessors ──────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
private get currentQ(): QuestionData {
|
|
67
|
+
return this.params.questions[this.currentIndex]!;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private get filteredOptions(): OptionData[] {
|
|
71
|
+
if (!this.searchQuery) return this.currentQ.options;
|
|
72
|
+
return fuzzyFilter(
|
|
73
|
+
this.currentQ.options,
|
|
74
|
+
this.searchQuery,
|
|
75
|
+
(o) => `${o.label} ${o.description}`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private get mainListItems(): Array<{
|
|
80
|
+
kind: string;
|
|
81
|
+
label?: string;
|
|
82
|
+
option?: OptionData;
|
|
83
|
+
}> {
|
|
84
|
+
const items: Array<{ kind: string; label?: string; option?: OptionData }> =
|
|
85
|
+
[];
|
|
86
|
+
for (const o of this.filteredOptions) {
|
|
87
|
+
items.push({ kind: "option", option: o });
|
|
88
|
+
}
|
|
89
|
+
for (const s of sentinelsFor(this.currentQ)) {
|
|
90
|
+
items.push({ kind: s.kind, label: s.label });
|
|
91
|
+
}
|
|
92
|
+
return items;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private get totalItems(): number {
|
|
96
|
+
return this.mainListItems.length;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private get selectedItem(): (typeof this.mainListItems)[0] | undefined {
|
|
100
|
+
return this.mainListItems[this.selectedOptionIndex];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── Layout ─────────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
private ensureEditor(): Editor {
|
|
106
|
+
if (this.editor) return this.editor;
|
|
107
|
+
const editor = new Editor(this.tui, {
|
|
108
|
+
borderColor: (s: string) => this.theme.fg("accent", s),
|
|
109
|
+
selectList: {
|
|
110
|
+
selectedPrefix: (s: string) => this.theme.fg("accent", s),
|
|
111
|
+
selectedText: (s: string) => this.theme.fg("accent", s),
|
|
112
|
+
description: (s: string) => this.theme.fg("muted", s),
|
|
113
|
+
scrollInfo: (s: string) => this.theme.fg("dim", s),
|
|
114
|
+
noMatch: (s: string) => this.theme.fg("warning", s),
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
editor.disableSubmit = false;
|
|
118
|
+
editor.onSubmit = (text: string) => this.handleFreeformSubmit(text);
|
|
119
|
+
editor.focused = true;
|
|
120
|
+
this.editor = editor;
|
|
121
|
+
return editor;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private refresh(): void {
|
|
125
|
+
this.invalidate();
|
|
126
|
+
this.tui.requestRender();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── Answer management ──────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
private recordAnswer(
|
|
132
|
+
kind: AnswerKind,
|
|
133
|
+
answer: string | null,
|
|
134
|
+
selected?: string[],
|
|
135
|
+
preview?: string,
|
|
136
|
+
): void {
|
|
137
|
+
this.answers = this.answers.filter(
|
|
138
|
+
(a) => a.questionIndex !== this.currentIndex,
|
|
139
|
+
);
|
|
140
|
+
this.answers.push({
|
|
141
|
+
questionIndex: this.currentIndex,
|
|
142
|
+
question: this.currentQ.question,
|
|
143
|
+
kind,
|
|
144
|
+
answer,
|
|
145
|
+
selected,
|
|
146
|
+
preview,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private commitAnswer(): void {
|
|
151
|
+
const item = this.selectedItem;
|
|
152
|
+
if (!item) {
|
|
153
|
+
this.cancel();
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (item.kind === "option" && item.option) {
|
|
158
|
+
this.recordAnswer(
|
|
159
|
+
"option",
|
|
160
|
+
item.option.label,
|
|
161
|
+
undefined,
|
|
162
|
+
item.option.preview,
|
|
163
|
+
);
|
|
164
|
+
this.nextQuestion();
|
|
165
|
+
} else if (item.kind === "other") {
|
|
166
|
+
this.inputMode = true;
|
|
167
|
+
this.ensureEditor().focused = true;
|
|
168
|
+
this.refresh();
|
|
169
|
+
} else if (item.kind === "next") {
|
|
170
|
+
const selected = Array.from(this.multiChecked)
|
|
171
|
+
.sort((a, b) => a - b)
|
|
172
|
+
.map((i) => this.currentQ.options[i]?.label);
|
|
173
|
+
if (selected.length === 0) {
|
|
174
|
+
this.cancel();
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
this.recordAnswer("multi", null, selected);
|
|
178
|
+
this.nextQuestion();
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
private handleFreeformSubmit(text: string): void {
|
|
183
|
+
if (!text.trim()) {
|
|
184
|
+
this.cancel();
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
this.recordAnswer("custom", text.trim());
|
|
188
|
+
this.nextQuestion();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private gotoQuestion(index: number): void {
|
|
192
|
+
if (index < 0 || index >= this.params.questions.length) return;
|
|
193
|
+
this.currentIndex = index;
|
|
194
|
+
this.searchQuery = "";
|
|
195
|
+
this.multiChecked.clear();
|
|
196
|
+
this.inputMode = false;
|
|
197
|
+
this.selectedOptionIndex = 0;
|
|
198
|
+
this.editor = undefined;
|
|
199
|
+
this.restoreAnswerState();
|
|
200
|
+
this.refresh();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
private restoreAnswerState(): void {
|
|
204
|
+
const prev = this.answers.find(
|
|
205
|
+
(a) => a.questionIndex === this.currentIndex,
|
|
206
|
+
);
|
|
207
|
+
if (!prev) return;
|
|
208
|
+
const q = this.currentQ;
|
|
209
|
+
if (prev.kind === "multi") {
|
|
210
|
+
for (let i = 0; i < q.options.length; i++) {
|
|
211
|
+
if (prev.selected?.includes(q.options[i]!.label)) {
|
|
212
|
+
this.multiChecked.add(i);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
} else if (prev.kind === "option" && prev.answer) {
|
|
216
|
+
const idx = this.mainListItems.findIndex(
|
|
217
|
+
(it) => it.kind === "option" && it.option?.label === prev.answer,
|
|
218
|
+
);
|
|
219
|
+
if (idx >= 0) this.selectedOptionIndex = idx;
|
|
220
|
+
} else if (prev.kind === "custom") {
|
|
221
|
+
const idx = this.mainListItems.findIndex((it) => it.kind === "other");
|
|
222
|
+
if (idx >= 0) this.selectedOptionIndex = idx;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private nextQuestion(): void {
|
|
227
|
+
const total = this.params.questions.length;
|
|
228
|
+
const answered = new Set(this.answers.map((a) => a.questionIndex));
|
|
229
|
+
for (let step = 1; step <= total; step++) {
|
|
230
|
+
const idx = (this.currentIndex + step) % total;
|
|
231
|
+
if (!answered.has(idx)) {
|
|
232
|
+
this.gotoQuestion(idx);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
this.answers.sort((a, b) => a.questionIndex - b.questionIndex);
|
|
237
|
+
this.onDone({ answers: this.answers, cancelled: false });
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
private cancel(): void {
|
|
241
|
+
this.onDone({ answers: this.answers, cancelled: true });
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private toggleMulti(index: number): void {
|
|
245
|
+
if (index < 0 || index >= this.currentQ.options.length) return;
|
|
246
|
+
if (this.multiChecked.has(index)) this.multiChecked.delete(index);
|
|
247
|
+
else this.multiChecked.add(index);
|
|
248
|
+
this.invalidate();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ── Input handling ─────────────────────────────────────────────────
|
|
252
|
+
|
|
253
|
+
handleInput(data: string): void {
|
|
254
|
+
if (this.keybindings.matches(data, "tui.select.cancel")) {
|
|
255
|
+
this.cancel();
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (this.inputMode) {
|
|
260
|
+
if (matchesKey(data, Key.escape)) {
|
|
261
|
+
this.inputMode = false;
|
|
262
|
+
this.editor = undefined;
|
|
263
|
+
this.refresh();
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
this.ensureEditor().handleInput(data);
|
|
267
|
+
this.tui.requestRender();
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const isMulti = !!this.currentQ.multiSelect;
|
|
272
|
+
const total = this.totalItems;
|
|
273
|
+
|
|
274
|
+
if (
|
|
275
|
+
this.keybindings.matches(data, "tui.select.up") ||
|
|
276
|
+
matchesKey(data, Key.shift("tab")) ||
|
|
277
|
+
matchesKey(data, Key.ctrl("k"))
|
|
278
|
+
) {
|
|
279
|
+
if (total > 0) {
|
|
280
|
+
this.selectedOptionIndex =
|
|
281
|
+
(this.selectedOptionIndex - 1 + total) % total;
|
|
282
|
+
this.refresh();
|
|
283
|
+
}
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (
|
|
288
|
+
this.keybindings.matches(data, "tui.select.down") ||
|
|
289
|
+
matchesKey(data, Key.tab) ||
|
|
290
|
+
matchesKey(data, Key.ctrl("j"))
|
|
291
|
+
) {
|
|
292
|
+
if (total > 0) {
|
|
293
|
+
this.selectedOptionIndex = (this.selectedOptionIndex + 1) % total;
|
|
294
|
+
this.refresh();
|
|
295
|
+
}
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (matchesKey(data, Key.left)) {
|
|
300
|
+
this.gotoQuestion(this.currentIndex - 1);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
if (matchesKey(data, Key.right)) {
|
|
304
|
+
this.gotoQuestion(this.currentIndex + 1);
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (
|
|
309
|
+
this.keybindings.matches(data, "tui.editor.deleteCharBackward") ||
|
|
310
|
+
matchesKey(data, Key.backspace)
|
|
311
|
+
) {
|
|
312
|
+
if (this.searchQuery) {
|
|
313
|
+
const chars = [...this.searchQuery];
|
|
314
|
+
chars.pop();
|
|
315
|
+
this.searchQuery = chars.join("");
|
|
316
|
+
this.selectedOptionIndex = 0;
|
|
317
|
+
this.refresh();
|
|
318
|
+
}
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (matchesKey(data, Key.escape)) {
|
|
323
|
+
if (this.searchQuery) {
|
|
324
|
+
this.searchQuery = "";
|
|
325
|
+
this.selectedOptionIndex = 0;
|
|
326
|
+
this.refresh();
|
|
327
|
+
}
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (matchesKey(data, Key.space) && isMulti) {
|
|
332
|
+
if (this.selectedItem?.kind === "option" && this.selectedItem.option) {
|
|
333
|
+
const idx = this.filteredOptions.indexOf(this.selectedItem.option);
|
|
334
|
+
if (idx >= 0) this.toggleMulti(idx);
|
|
335
|
+
this.refresh();
|
|
336
|
+
}
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const numMatch = data.match(/^[1-9]$/);
|
|
341
|
+
if (numMatch && this.filteredOptions.length > 0) {
|
|
342
|
+
const idx = Number(numMatch[0]) - 1;
|
|
343
|
+
if (idx >= 0 && idx < this.filteredOptions.length) {
|
|
344
|
+
if (isMulti) {
|
|
345
|
+
this.toggleMulti(idx);
|
|
346
|
+
this.selectedOptionIndex = Math.min(idx, this.totalItems - 1);
|
|
347
|
+
this.refresh();
|
|
348
|
+
} else {
|
|
349
|
+
const opt = this.filteredOptions[idx]!;
|
|
350
|
+
this.recordAnswer("option", opt.label, undefined, opt.preview);
|
|
351
|
+
this.nextQuestion();
|
|
352
|
+
}
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (this.keybindings.matches(data, "tui.select.confirm")) {
|
|
358
|
+
this.commitAnswer();
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (!isMulti) {
|
|
363
|
+
const printable = decodeKittyPrintable(data);
|
|
364
|
+
if (printable !== undefined) {
|
|
365
|
+
this.searchQuery += printable;
|
|
366
|
+
this.selectedOptionIndex = 0;
|
|
367
|
+
this.refresh();
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
const chars = [...data];
|
|
371
|
+
if (
|
|
372
|
+
chars.length === 1 &&
|
|
373
|
+
chars[0] &&
|
|
374
|
+
chars[0].charCodeAt(0) >= 32 &&
|
|
375
|
+
chars[0].charCodeAt(0) < 127
|
|
376
|
+
) {
|
|
377
|
+
this.searchQuery += chars[0];
|
|
378
|
+
this.selectedOptionIndex = 0;
|
|
379
|
+
this.refresh();
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// ── Rendering ──────────────────────────────────────────────────────
|
|
385
|
+
|
|
386
|
+
private renderOptions(width: number): string[] {
|
|
387
|
+
const t = this.theme;
|
|
388
|
+
const inner = Math.max(20, width - 6);
|
|
389
|
+
const isMulti = !!this.currentQ.multiSelect;
|
|
390
|
+
const items = this.mainListItems;
|
|
391
|
+
const total = items.length;
|
|
392
|
+
const chk = (i: number) =>
|
|
393
|
+
isMulti
|
|
394
|
+
? this.multiChecked.has(i)
|
|
395
|
+
? t.fg("success", "✓")
|
|
396
|
+
: t.fg("dim", "○")
|
|
397
|
+
: "";
|
|
398
|
+
|
|
399
|
+
if (total === 0) return [t.fg("warning", "No options")];
|
|
400
|
+
|
|
401
|
+
const maxVisible = Math.min(total, 12);
|
|
402
|
+
const start = Math.max(
|
|
403
|
+
0,
|
|
404
|
+
Math.min(
|
|
405
|
+
this.selectedOptionIndex - Math.floor(maxVisible / 2),
|
|
406
|
+
total - maxVisible,
|
|
407
|
+
),
|
|
408
|
+
);
|
|
409
|
+
const end = Math.min(start + maxVisible, total);
|
|
410
|
+
|
|
411
|
+
const lines: string[] = [];
|
|
412
|
+
const pad = " ";
|
|
413
|
+
|
|
414
|
+
for (let i = start; i < end; i++) {
|
|
415
|
+
const item = items[i]!;
|
|
416
|
+
const sel = i === this.selectedOptionIndex;
|
|
417
|
+
const ptr = sel ? t.fg("accent", "→") : " ";
|
|
418
|
+
|
|
419
|
+
if (item.kind === "option" && item.option) {
|
|
420
|
+
const optIdx = this.filteredOptions.indexOf(item.option);
|
|
421
|
+
const checkbox = isMulti ? ` ${chk(optIdx)}` : "";
|
|
422
|
+
const num = t.fg("dim", `${optIdx + 1}.`);
|
|
423
|
+
const label = sel
|
|
424
|
+
? t.fg("accent", t.bold(item.option.label))
|
|
425
|
+
: t.fg("text", t.bold(item.option.label));
|
|
426
|
+
lines.push(
|
|
427
|
+
truncateToWidth(`${ptr} ${num}${checkbox} ${label}`, inner, ""),
|
|
428
|
+
);
|
|
429
|
+
if (item.option.description) {
|
|
430
|
+
const wrapped = wrapTextWithAnsi(
|
|
431
|
+
item.option.description,
|
|
432
|
+
Math.max(10, inner - 6),
|
|
433
|
+
);
|
|
434
|
+
for (const w of wrapped) {
|
|
435
|
+
lines.push(truncateToWidth(`${pad}${t.fg("muted", w)}`, inner, ""));
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
} else if (item.kind === "other") {
|
|
439
|
+
const label = sel
|
|
440
|
+
? t.fg("accent", t.bold(SENTINEL_FREEFORM))
|
|
441
|
+
: t.fg("text", t.bold(SENTINEL_FREEFORM));
|
|
442
|
+
lines.push(
|
|
443
|
+
truncateToWidth(`${ptr} ${t.fg("dim", "✎")} ${label}`, inner, ""),
|
|
444
|
+
);
|
|
445
|
+
} else if (item.kind === "next") {
|
|
446
|
+
const label = sel
|
|
447
|
+
? t.fg("accent", t.bold(SENTINEL_NEXT))
|
|
448
|
+
: t.fg("text", t.bold(SENTINEL_NEXT));
|
|
449
|
+
lines.push(
|
|
450
|
+
truncateToWidth(`${ptr} ${t.fg("dim", "→")} ${label}`, inner, ""),
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (start > 0 || end < total) {
|
|
456
|
+
const count =
|
|
457
|
+
this.filteredOptions.length > 0
|
|
458
|
+
? `${this.selectedOptionIndex + 1}/${total}`
|
|
459
|
+
: `${total}`;
|
|
460
|
+
lines.push(t.fg("dim", truncateToWidth(` ${count}`, inner, "")));
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
return lines;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
private renderPreview(width: number): string[] {
|
|
467
|
+
const item = this.selectedItem;
|
|
468
|
+
if (item?.kind !== "option" || !item.option?.preview) {
|
|
469
|
+
return [this.theme.fg("dim", "No preview")];
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const mdText = item.option.preview;
|
|
473
|
+
const mdWidth = Math.max(10, width);
|
|
474
|
+
|
|
475
|
+
if (this.mdTheme) {
|
|
476
|
+
const md = new Markdown(
|
|
477
|
+
`## ${item.option.label}\n\n${mdText}`,
|
|
478
|
+
0,
|
|
479
|
+
0,
|
|
480
|
+
this.mdTheme,
|
|
481
|
+
);
|
|
482
|
+
return md.render(mdWidth);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const lines = wrapTextWithAnsi(mdText, mdWidth);
|
|
486
|
+
return lines.map((l) =>
|
|
487
|
+
truncateToWidth(this.theme.fg("muted", l), mdWidth, ""),
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
override render(width: number): string[] {
|
|
492
|
+
const inner = Math.max(20, width - 4);
|
|
493
|
+
const t = this.theme;
|
|
494
|
+
const isMulti = !!this.currentQ.multiSelect;
|
|
495
|
+
const hasPreview =
|
|
496
|
+
!isMulti &&
|
|
497
|
+
this.selectedItem?.kind === "option" &&
|
|
498
|
+
!!this.selectedItem?.option?.preview;
|
|
499
|
+
|
|
500
|
+
const useSplit = hasPreview && width >= SPLIT_PANE_MIN_WIDTH;
|
|
501
|
+
const leftWidth = useSplit ? Math.floor((width - 6) * 0.45) : inner;
|
|
502
|
+
const previewWidth = useSplit ? Math.max(20, width - leftWidth - 10) : 0;
|
|
503
|
+
|
|
504
|
+
const lines: string[] = [];
|
|
505
|
+
|
|
506
|
+
const row = (content: string): string =>
|
|
507
|
+
` ${truncateToWidth(content, Math.max(0, width - 1), "")}`;
|
|
508
|
+
|
|
509
|
+
// Tab bar
|
|
510
|
+
if (this.params.questions.length > 1) {
|
|
511
|
+
const tabParts: string[] = [];
|
|
512
|
+
for (let i = 0; i < this.params.questions.length; i++) {
|
|
513
|
+
const active = i === this.currentIndex;
|
|
514
|
+
const tag = `${i + 1}.${this.params.questions[i]?.header}`;
|
|
515
|
+
tabParts.push(active ? t.fg("accent", t.bold(tag)) : t.fg("dim", tag));
|
|
516
|
+
}
|
|
517
|
+
lines.push(row(tabParts.join(t.fg("dim", " "))));
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Header chip
|
|
521
|
+
const chip = t.fg("accent", t.bold(this.currentQ.header));
|
|
522
|
+
const prog =
|
|
523
|
+
this.params.questions.length > 1
|
|
524
|
+
? dim(t)(` ${this.currentIndex + 1}/${this.params.questions.length}`)
|
|
525
|
+
: "";
|
|
526
|
+
lines.push(row(`${chip}${prog}`));
|
|
527
|
+
|
|
528
|
+
// Question text
|
|
529
|
+
for (const w of wrapTextWithAnsi(
|
|
530
|
+
this.currentQ.question,
|
|
531
|
+
Math.max(10, inner),
|
|
532
|
+
)) {
|
|
533
|
+
lines.push(row(t.fg("text", t.bold(w))));
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// Input mode
|
|
537
|
+
if (this.inputMode) {
|
|
538
|
+
lines.push("");
|
|
539
|
+
lines.push(row(t.fg("accent", t.bold("Type your response:"))));
|
|
540
|
+
lines.push("");
|
|
541
|
+
const editorLines = this.ensureEditor().render(Math.max(0, width - 1));
|
|
542
|
+
for (const el of editorLines) {
|
|
543
|
+
lines.push(` ${truncateToWidth(el, Math.max(0, width - 1), "")}`);
|
|
544
|
+
}
|
|
545
|
+
lines.push("");
|
|
546
|
+
lines.push(row(dim(t)("enter submit • esc back • ctrl+c cancel")));
|
|
547
|
+
lines.push("");
|
|
548
|
+
return lines.map((l) => truncateToWidth(l, width, ""));
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// Search bar
|
|
552
|
+
if (!isMulti) {
|
|
553
|
+
const searchVal = this.searchQuery
|
|
554
|
+
? t.fg("text", this.searchQuery)
|
|
555
|
+
: t.fg("dim", "type to filter");
|
|
556
|
+
lines.push(row(`${t.fg("accent", "Filter:")} ${searchVal}`));
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Chat sentinel
|
|
560
|
+
const chatLabel =
|
|
561
|
+
this.selectedOptionIndex === -999
|
|
562
|
+
? t.fg("accent", t.bold(SENTINEL_CHAT))
|
|
563
|
+
: t.fg("dim", SENTINEL_CHAT);
|
|
564
|
+
lines.push(row(` ${t.fg("dim", "💬")} ${chatLabel}`));
|
|
565
|
+
|
|
566
|
+
// Options (with optional split-pane preview)
|
|
567
|
+
const optionLines = this.renderOptions(useSplit ? leftWidth : width - 4);
|
|
568
|
+
const previewLines = useSplit ? this.renderPreview(previewWidth) : [];
|
|
569
|
+
const maxOptLines = Math.max(optionLines.length, previewLines.length);
|
|
570
|
+
|
|
571
|
+
if (useSplit) {
|
|
572
|
+
const sep = t.fg("dim", SEPARATOR);
|
|
573
|
+
for (let i = 0; i < maxOptLines; i++) {
|
|
574
|
+
const left = truncateToWidth(
|
|
575
|
+
optionLines[i] ?? "",
|
|
576
|
+
leftWidth - 1,
|
|
577
|
+
"",
|
|
578
|
+
true,
|
|
579
|
+
);
|
|
580
|
+
const right = truncateToWidth(
|
|
581
|
+
previewLines[i] ?? "",
|
|
582
|
+
previewWidth - 2,
|
|
583
|
+
"",
|
|
584
|
+
);
|
|
585
|
+
const body = `${left || " ".repeat(leftWidth - 1)}${sep}${right || " ".repeat(previewWidth - 2)}`;
|
|
586
|
+
lines.push(` ${truncateToWidth(body, Math.max(0, width - 1), "")}`);
|
|
587
|
+
}
|
|
588
|
+
} else {
|
|
589
|
+
for (const line of optionLines) lines.push(row(line));
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// Footer hints
|
|
593
|
+
const navHint =
|
|
594
|
+
this.params.questions.length > 1 ? "↑↓ nav • ←→ question" : "↑↓ nav";
|
|
595
|
+
const hintParts = isMulti
|
|
596
|
+
? [
|
|
597
|
+
`${navHint} • space toggle • enter commit • esc clear`,
|
|
598
|
+
"ctrl+c cancel",
|
|
599
|
+
]
|
|
600
|
+
: [
|
|
601
|
+
`${navHint} • type filter • enter select • esc clear`,
|
|
602
|
+
"ctrl+c cancel",
|
|
603
|
+
];
|
|
604
|
+
lines.push(row(dim(t)(hintParts.join(" • "))));
|
|
605
|
+
lines.push("");
|
|
606
|
+
|
|
607
|
+
return lines.map((l) => truncateToWidth(l, width, ""));
|
|
608
|
+
}
|
|
609
|
+
}
|
package/src/rpc.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { Params } from "./schema.js";
|
|
2
|
+
import { SENTINEL_FREEFORM } from "./schema.js";
|
|
3
|
+
import type { QuestionAnswer, QuestionnaireResult } from "./types.js";
|
|
4
|
+
|
|
5
|
+
// ── RPC / non-TUI fallback ─────────────────────────────────────────────
|
|
6
|
+
// Used when ctx.hasUI is false (headless / JSON / print mode).
|
|
7
|
+
|
|
8
|
+
export async function rpcFallback(
|
|
9
|
+
ui: { select: Function; input: Function },
|
|
10
|
+
params: Params,
|
|
11
|
+
): Promise<QuestionnaireResult> {
|
|
12
|
+
const answers: QuestionAnswer[] = [];
|
|
13
|
+
let cancelled = false;
|
|
14
|
+
|
|
15
|
+
for (let i = 0; i < params.questions.length; i++) {
|
|
16
|
+
const q = params.questions[i]!;
|
|
17
|
+
const header = q.header;
|
|
18
|
+
|
|
19
|
+
if (q.multiSelect) {
|
|
20
|
+
const lines = q.options.map(
|
|
21
|
+
(o, idx) => `${idx + 1}. ${o.label} — ${o.description}`,
|
|
22
|
+
);
|
|
23
|
+
const raw = await ui.input(
|
|
24
|
+
`${header}: ${q.question}\n\n${lines.join("\n")}\n\nEnter numbers separated by commas:`,
|
|
25
|
+
"e.g. 1,3",
|
|
26
|
+
);
|
|
27
|
+
if (raw == null) {
|
|
28
|
+
cancelled = true;
|
|
29
|
+
break;
|
|
30
|
+
}
|
|
31
|
+
const indices = String(raw)
|
|
32
|
+
.split(",")
|
|
33
|
+
.map((s) => Number(s.trim()))
|
|
34
|
+
.filter((n) => n >= 1 && n <= q.options.length);
|
|
35
|
+
const selected = indices.map((n) => q.options[n - 1]?.label);
|
|
36
|
+
if (selected.length > 0) {
|
|
37
|
+
answers.push({
|
|
38
|
+
questionIndex: i,
|
|
39
|
+
question: q.question,
|
|
40
|
+
kind: "multi",
|
|
41
|
+
answer: null,
|
|
42
|
+
selected,
|
|
43
|
+
});
|
|
44
|
+
} else {
|
|
45
|
+
cancelled = true;
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
} else {
|
|
49
|
+
const items = q.options.map((o) => `${o.label} — ${o.description}`);
|
|
50
|
+
items.push(SENTINEL_FREEFORM);
|
|
51
|
+
const chosen = await ui.select(`${header}: ${q.question}`, items);
|
|
52
|
+
if (chosen == null) {
|
|
53
|
+
cancelled = true;
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
if (chosen === SENTINEL_FREEFORM) {
|
|
57
|
+
const text = await ui.input(q.question, "Type your answer...");
|
|
58
|
+
if (text == null) {
|
|
59
|
+
cancelled = true;
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
answers.push({
|
|
63
|
+
questionIndex: i,
|
|
64
|
+
question: q.question,
|
|
65
|
+
kind: "custom",
|
|
66
|
+
answer: String(text),
|
|
67
|
+
});
|
|
68
|
+
} else {
|
|
69
|
+
const opt = q.options.find(
|
|
70
|
+
(o) =>
|
|
71
|
+
chosen === o.label || `${o.label} — ${o.description}` === chosen,
|
|
72
|
+
)!;
|
|
73
|
+
answers.push({
|
|
74
|
+
questionIndex: i,
|
|
75
|
+
question: q.question,
|
|
76
|
+
kind: "option",
|
|
77
|
+
answer: opt?.label ?? String(chosen),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return { answers, cancelled };
|
|
84
|
+
}
|
package/src/schema.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { type Static, Type } from "typebox";
|
|
2
|
+
|
|
3
|
+
// ── Constants ──────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
export const MAX_QUESTIONS = 4;
|
|
6
|
+
export const MIN_OPTIONS = 2;
|
|
7
|
+
export const MAX_OPTIONS = 4;
|
|
8
|
+
export const MAX_HEADER_LENGTH = 16;
|
|
9
|
+
export const MAX_LABEL_LENGTH = 60;
|
|
10
|
+
|
|
11
|
+
export const SENTINEL_FREEFORM = "Type something.";
|
|
12
|
+
export const SENTINEL_CHAT = "Chat about this";
|
|
13
|
+
export const SENTINEL_NEXT = "Next";
|
|
14
|
+
|
|
15
|
+
export const SPLIT_PANE_MIN_WIDTH = 84;
|
|
16
|
+
export const SEPARATOR = " │ ";
|
|
17
|
+
|
|
18
|
+
// ── Schemas ────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
export const OptionSchema = Type.Object({
|
|
21
|
+
label: Type.String({
|
|
22
|
+
maxLength: MAX_LABEL_LENGTH,
|
|
23
|
+
description: `MAX ${MAX_LABEL_LENGTH} CHARACTERS. Display text for this option. Concise (1-5 words).`,
|
|
24
|
+
}),
|
|
25
|
+
description: Type.String({
|
|
26
|
+
description: "Explanation of what this option means or trade-offs.",
|
|
27
|
+
}),
|
|
28
|
+
preview: Type.Optional(
|
|
29
|
+
Type.String({
|
|
30
|
+
description:
|
|
31
|
+
"Optional markdown preview for side-by-side layout (single-select only).",
|
|
32
|
+
}),
|
|
33
|
+
),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
export const QuestionSchema = Type.Object({
|
|
37
|
+
question: Type.String({
|
|
38
|
+
description: "Clear, specific question ending with ?",
|
|
39
|
+
}),
|
|
40
|
+
header: Type.String({
|
|
41
|
+
maxLength: MAX_HEADER_LENGTH,
|
|
42
|
+
description: `MAX ${MAX_HEADER_LENGTH} CHARS — short chip/tag. E.g. "Auth method", "Approach".`,
|
|
43
|
+
}),
|
|
44
|
+
options: Type.Array(OptionSchema, {
|
|
45
|
+
minItems: MIN_OPTIONS,
|
|
46
|
+
maxItems: MAX_OPTIONS,
|
|
47
|
+
description:
|
|
48
|
+
"2-4 options. 'Type something.' and 'Chat about this' are auto-appended.",
|
|
49
|
+
}),
|
|
50
|
+
multiSelect: Type.Optional(
|
|
51
|
+
Type.Boolean({
|
|
52
|
+
default: false,
|
|
53
|
+
description:
|
|
54
|
+
"Allow multiple selections. Suppresses 'Type something.' row.",
|
|
55
|
+
}),
|
|
56
|
+
),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
export const QuestionsSchema = Type.Array(QuestionSchema, {
|
|
60
|
+
minItems: 1,
|
|
61
|
+
maxItems: MAX_QUESTIONS,
|
|
62
|
+
description: "1-4 questions",
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
export const ParamsSchema = Type.Object({ questions: QuestionsSchema });
|
|
66
|
+
|
|
67
|
+
export type OptionData = Static<typeof OptionSchema>;
|
|
68
|
+
export type QuestionData = Static<typeof QuestionSchema>;
|
|
69
|
+
export type Params = Static<typeof ParamsSchema>;
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// ── Answer & result types ──────────────────────────────────────────────
|
|
2
|
+
|
|
3
|
+
export type AnswerKind = "option" | "custom" | "chat" | "multi";
|
|
4
|
+
|
|
5
|
+
export interface QuestionAnswer {
|
|
6
|
+
questionIndex: number;
|
|
7
|
+
question: string;
|
|
8
|
+
kind: AnswerKind;
|
|
9
|
+
answer: string | null;
|
|
10
|
+
selected?: string[];
|
|
11
|
+
preview?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface QuestionnaireResult {
|
|
15
|
+
answers: QuestionAnswer[];
|
|
16
|
+
cancelled: boolean;
|
|
17
|
+
}
|