@xynogen/pix-core 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/package.json +50 -40
- package/src/commands/agent-sop/agent-sop.ts +58 -0
- package/src/commands/clear/clear.ts +1 -1
- package/src/commands/copy-all/copy-all.ts +21 -6
- package/src/commands/diff/diff.ts +45 -13
- package/src/commands/models/models.test.ts +2 -2
- package/src/commands/models/models.ts +1 -1
- package/src/commands/update/update.test.ts +8 -8
- package/src/commands/update/update.ts +4 -4
- package/src/index.ts +11 -9
- package/src/lib/data.ts +1 -1
- package/src/nudge/capability.test.ts +19 -13
- package/src/nudge/capability.ts +17 -9
- package/src/nudge/index.ts +1 -1
- package/src/nudge/tools.test.ts +21 -9
- package/src/nudge/tools.ts +0 -2
- package/src/tool/ask/ask.test.ts +31 -20
- package/src/tool/ask/components.ts +55 -0
- package/src/tool/ask/helpers.ts +77 -0
- package/src/tool/ask/index.ts +130 -0
- package/src/tool/ask/questionnaire.ts +693 -0
- package/src/tool/ask/rpc.ts +84 -0
- package/src/tool/ask/schema.ts +69 -0
- package/src/tool/ask/single-select-layout.test.ts +21 -5
- package/src/tool/ask/single-select-layout.ts +48 -14
- package/src/tool/ask/types.ts +17 -0
- package/src/tool/todo/todo.ts +24 -37
- package/src/tool/toolbox/toolbox.test.ts +2 -2
- package/src/tool/toolbox/toolbox.ts +9 -2
- package/src/ui/diagnostics.ts +3 -6
- package/src/ui/footer.ts +3 -4
- package/src/ui/welcome.test.ts +6 -6
- package/src/ui/welcome.ts +5 -2
- package/src/tool/ask/ask.ts +0 -1081
package/src/nudge/tools.test.ts
CHANGED
|
@@ -114,15 +114,23 @@ describe("classifyCompound", () => {
|
|
|
114
114
|
});
|
|
115
115
|
|
|
116
116
|
describe("nudgeReason", () => {
|
|
117
|
-
|
|
118
|
-
const msg = nudgeReason(
|
|
117
|
+
test("active tool: point straight at it, no toolbox", () => {
|
|
118
|
+
const msg = nudgeReason(
|
|
119
|
+
"Searching file contents via bash grep/rg.",
|
|
120
|
+
"grep",
|
|
121
|
+
true,
|
|
122
|
+
);
|
|
119
123
|
expect(msg).toContain("Use `grep` instead");
|
|
120
124
|
expect(msg).toContain("function definitions");
|
|
121
125
|
expect(msg).not.toContain("toolbox");
|
|
122
126
|
});
|
|
123
127
|
|
|
124
|
-
|
|
125
|
-
const msg = nudgeReason(
|
|
128
|
+
test("gated tool: route through toolbox enable, not a direct call", () => {
|
|
129
|
+
const msg = nudgeReason(
|
|
130
|
+
"Listing a directory via bash ls/tree.",
|
|
131
|
+
"ls",
|
|
132
|
+
false,
|
|
133
|
+
);
|
|
126
134
|
expect(msg).toContain('toolbox(action:"enable", name:"ls")');
|
|
127
135
|
expect(msg).toContain("prompt-hidden");
|
|
128
136
|
expect(msg).toContain("function definitions");
|
|
@@ -131,13 +139,17 @@ describe("nudgeReason", () => {
|
|
|
131
139
|
});
|
|
132
140
|
|
|
133
141
|
test("gated find tool names itself in the enable hint", () => {
|
|
134
|
-
expect(
|
|
135
|
-
|
|
136
|
-
);
|
|
142
|
+
expect(
|
|
143
|
+
nudgeReason("Locating files via bash find/fd.", "find", false),
|
|
144
|
+
).toContain('toolbox(action:"enable", name:"find")');
|
|
137
145
|
});
|
|
138
146
|
|
|
139
|
-
|
|
140
|
-
const msg = nudgeReason(
|
|
147
|
+
test("is a single short line — no inventory dump, no newlines", () => {
|
|
148
|
+
const msg = nudgeReason(
|
|
149
|
+
"Listing a directory via bash ls/tree.",
|
|
150
|
+
"ls",
|
|
151
|
+
false,
|
|
152
|
+
);
|
|
141
153
|
expect(msg).not.toContain("\n");
|
|
142
154
|
expect(msg).not.toContain("Available tools");
|
|
143
155
|
expect(msg.length).toBeLessThan(400);
|
package/src/nudge/tools.ts
CHANGED
|
@@ -28,7 +28,6 @@ import {
|
|
|
28
28
|
isToolCallEventType,
|
|
29
29
|
} from "@earendil-works/pi-coding-agent";
|
|
30
30
|
|
|
31
|
-
|
|
32
31
|
/** Categories that map a raw shell command to a dedicated Pi tool. */
|
|
33
32
|
type Category = "read" | "ls" | "grep" | "find" | "edit";
|
|
34
33
|
|
|
@@ -116,7 +115,6 @@ export function splitSegments(command: string): Segment[] {
|
|
|
116
115
|
const re = /(\|\||&&|[|;&\n])/g;
|
|
117
116
|
let last = 0;
|
|
118
117
|
let m: RegExpExecArray | null;
|
|
119
|
-
// biome-ignore lint/suspicious/noAssignInExpressions: standard regex walk
|
|
120
118
|
while ((m = re.exec(command)) !== null) {
|
|
121
119
|
out.push({ text: command.slice(last, m.index), followedBy: m[1] });
|
|
122
120
|
last = m.index + m[1].length;
|
package/src/tool/ask/ask.test.ts
CHANGED
|
@@ -7,17 +7,21 @@
|
|
|
7
7
|
|
|
8
8
|
import { describe, expect, test } from "bun:test";
|
|
9
9
|
import {
|
|
10
|
-
type OptionData,
|
|
11
|
-
type QuestionData,
|
|
12
10
|
buildResponseText,
|
|
13
11
|
formatAnswerScalar,
|
|
14
12
|
hasAnyPreview,
|
|
13
|
+
type OptionData,
|
|
14
|
+
type QuestionData,
|
|
15
15
|
sentinelsFor,
|
|
16
|
-
} from "./
|
|
16
|
+
} from "./index.ts";
|
|
17
17
|
|
|
18
18
|
// ── Fixtures ──────────────────────────────────────────────────────────
|
|
19
19
|
|
|
20
|
-
const opt = (
|
|
20
|
+
const opt = (
|
|
21
|
+
label: string,
|
|
22
|
+
description = "Test option",
|
|
23
|
+
preview?: string,
|
|
24
|
+
): OptionData => ({
|
|
21
25
|
label,
|
|
22
26
|
description,
|
|
23
27
|
...(preview ? { preview } : {}),
|
|
@@ -55,10 +59,7 @@ const qWithPreview: QuestionData = {
|
|
|
55
59
|
const qSingleNoPreview: QuestionData = {
|
|
56
60
|
question: "Color?",
|
|
57
61
|
header: "Color",
|
|
58
|
-
options: [
|
|
59
|
-
opt("Red", "Ruby red"),
|
|
60
|
-
opt("Blue", "Ocean blue"),
|
|
61
|
-
],
|
|
62
|
+
options: [opt("Red", "Ruby red"), opt("Blue", "Ocean blue")],
|
|
62
63
|
};
|
|
63
64
|
|
|
64
65
|
// ── hasAnyPreview ─────────────────────────────────────────────────────
|
|
@@ -85,8 +86,8 @@ describe("sentinelsFor", () => {
|
|
|
85
86
|
test('single-select without preview appends "Type something."', () => {
|
|
86
87
|
const r = sentinelsFor(qSingleNoPreview);
|
|
87
88
|
expect(r).toHaveLength(1);
|
|
88
|
-
expect(r[0]
|
|
89
|
-
expect(r[0]
|
|
89
|
+
expect(r[0]?.kind).toBe("other");
|
|
90
|
+
expect(r[0]?.label).toBe("Type something.");
|
|
90
91
|
});
|
|
91
92
|
|
|
92
93
|
test('single-select with preview appends nothing (only "Chat about this" is separate)', () => {
|
|
@@ -97,8 +98,8 @@ describe("sentinelsFor", () => {
|
|
|
97
98
|
test('multi-select appends "Next"', () => {
|
|
98
99
|
const r = sentinelsFor(qMulti);
|
|
99
100
|
expect(r).toHaveLength(1);
|
|
100
|
-
expect(r[0]
|
|
101
|
-
expect(r[0]
|
|
101
|
+
expect(r[0]?.kind).toBe("next");
|
|
102
|
+
expect(r[0]?.label).toBe("Next");
|
|
102
103
|
});
|
|
103
104
|
|
|
104
105
|
test("multi-select never appends Type something.", () => {
|
|
@@ -109,14 +110,14 @@ describe("sentinelsFor", () => {
|
|
|
109
110
|
test("empty options still gets freeform sentinel (no preview = single-select)", () => {
|
|
110
111
|
const r = sentinelsFor({ question: "?", header: "X", options: [] });
|
|
111
112
|
expect(r).toHaveLength(1);
|
|
112
|
-
expect(r[0]
|
|
113
|
+
expect(r[0]?.kind).toBe("other");
|
|
113
114
|
});
|
|
114
115
|
});
|
|
115
116
|
|
|
116
117
|
// ── formatAnswerScalar ────────────────────────────────────────────────
|
|
117
118
|
|
|
118
119
|
describe("formatAnswerScalar", () => {
|
|
119
|
-
test(
|
|
120
|
+
test("option kind returns the answer string", () => {
|
|
120
121
|
const a = {
|
|
121
122
|
questionIndex: 0,
|
|
122
123
|
question: "Q",
|
|
@@ -126,7 +127,7 @@ describe("formatAnswerScalar", () => {
|
|
|
126
127
|
expect(formatAnswerScalar(a)).toBe("REST");
|
|
127
128
|
});
|
|
128
129
|
|
|
129
|
-
test(
|
|
130
|
+
test("multi kind joins selected with comma", () => {
|
|
130
131
|
const a = {
|
|
131
132
|
questionIndex: 0,
|
|
132
133
|
question: "Q",
|
|
@@ -137,7 +138,7 @@ describe("formatAnswerScalar", () => {
|
|
|
137
138
|
expect(formatAnswerScalar(a)).toBe("Auth, Search");
|
|
138
139
|
});
|
|
139
140
|
|
|
140
|
-
test(
|
|
141
|
+
test("custom kind returns the typed text", () => {
|
|
141
142
|
const a = {
|
|
142
143
|
questionIndex: 0,
|
|
143
144
|
question: "Q",
|
|
@@ -147,7 +148,7 @@ describe("formatAnswerScalar", () => {
|
|
|
147
148
|
expect(formatAnswerScalar(a)).toBe("my custom answer");
|
|
148
149
|
});
|
|
149
150
|
|
|
150
|
-
test(
|
|
151
|
+
test("chat kind returns (chat)", () => {
|
|
151
152
|
const a = {
|
|
152
153
|
questionIndex: 0,
|
|
153
154
|
question: "Q",
|
|
@@ -163,7 +164,12 @@ describe("formatAnswerScalar", () => {
|
|
|
163
164
|
describe("buildResponseText", () => {
|
|
164
165
|
test("formats single answer", () => {
|
|
165
166
|
const answers = [
|
|
166
|
-
{
|
|
167
|
+
{
|
|
168
|
+
questionIndex: 0,
|
|
169
|
+
question: "Which approach?",
|
|
170
|
+
kind: "option" as const,
|
|
171
|
+
answer: "REST",
|
|
172
|
+
},
|
|
167
173
|
];
|
|
168
174
|
const text = buildResponseText(answers, [qSingle]);
|
|
169
175
|
expect(text).toContain("REST");
|
|
@@ -202,7 +208,12 @@ describe("buildResponseText", () => {
|
|
|
202
208
|
test("formats multiple answers", () => {
|
|
203
209
|
const qs = [qSingle, qMulti];
|
|
204
210
|
const answers = [
|
|
205
|
-
{
|
|
211
|
+
{
|
|
212
|
+
questionIndex: 0,
|
|
213
|
+
question: "Which approach?",
|
|
214
|
+
kind: "option" as const,
|
|
215
|
+
answer: "GraphQL",
|
|
216
|
+
},
|
|
206
217
|
{
|
|
207
218
|
questionIndex: 1,
|
|
208
219
|
question: "Which features?",
|
|
@@ -226,7 +237,7 @@ describe("buildResponseText", () => {
|
|
|
226
237
|
|
|
227
238
|
describe("registerAsk", () => {
|
|
228
239
|
test("exports a default function", async () => {
|
|
229
|
-
const mod = await import("./
|
|
240
|
+
const mod = await import("./index.ts");
|
|
230
241
|
expect(typeof mod.default).toBe("function");
|
|
231
242
|
});
|
|
232
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
|
+
}
|
|
@@ -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 };
|
|
@@ -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
|
+
}
|