@xynogen/pix-core 0.1.1 → 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 +2 -1
- package/src/commands/agent-sop/agent-sop.ts +58 -0
- package/src/index.ts +3 -1
- package/src/nudge/capability.test.ts +14 -5
- package/src/nudge/capability.ts +14 -8
- package/src/tool/ask/ask.test.ts +2 -2
- 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/{ask.ts → questionnaire.ts} +60 -473
- package/src/tool/ask/rpc.ts +84 -0
- package/src/tool/ask/schema.ts +69 -0
- package/src/tool/ask/types.ts +17 -0
- package/src/tool/toolbox/toolbox.ts +9 -1
- package/src/ui/diagnostics.ts +3 -6
- package/src/ui/welcome.ts +5 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xynogen/pix-core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Pi extension — core UI/UX bundle (welcome banner, footer, model picker, self-update)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
"access": "public"
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
+
"@xynogen/pix-skills": "^0.1.1",
|
|
45
46
|
"typebox": "^1.1.38"
|
|
46
47
|
},
|
|
47
48
|
"peerDependencies": {
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-sop — inject AGENT.md (from pix-skills) into system prompt
|
|
3
|
+
*
|
|
4
|
+
* Reads AGENT.md from the @xynogen/pix-skills package and appends it to the
|
|
5
|
+
* system prompt on every agent start via `before_agent_start`.
|
|
6
|
+
*
|
|
7
|
+
* This is the "register skill" mechanism for the agent operating spec — no
|
|
8
|
+
* static SKILL.md file needed. The content becomes part of the model's
|
|
9
|
+
* standing instructions.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
13
|
+
import { createRequire } from "node:module";
|
|
14
|
+
import { resolve } from "node:path";
|
|
15
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
|
|
17
|
+
/** Resolve the absolute path to AGENT.md inside @xynogen/pix-skills. */
|
|
18
|
+
function resolveAgentMdPath(): string | null {
|
|
19
|
+
try {
|
|
20
|
+
const require = createRequire(import.meta.url);
|
|
21
|
+
const pkgJson = require.resolve("@xynogen/pix-skills/package.json");
|
|
22
|
+
return resolve(pkgJson, "..", "AGENT.md");
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Read and return AGENT.md content, or null if unavailable. */
|
|
29
|
+
function loadAgentMd(): string | null {
|
|
30
|
+
const p = resolveAgentMdPath();
|
|
31
|
+
if (!p || !existsSync(p)) return null;
|
|
32
|
+
try {
|
|
33
|
+
return readFileSync(p, "utf-8");
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export default function registerAgentSop(pi: ExtensionAPI): void {
|
|
40
|
+
// Load once at startup (content is static per session).
|
|
41
|
+
const agentMdContent = loadAgentMd();
|
|
42
|
+
|
|
43
|
+
if (!agentMdContent) {
|
|
44
|
+
// Silent skip — pix-skills might not be installed.
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
pi.on("before_agent_start", async (event) => {
|
|
49
|
+
// Skip if already injected (idempotent check via a simple marker).
|
|
50
|
+
if (event.systemPrompt.includes("pix-agent-sop")) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
systemPrompt: `${event.systemPrompt}\n\n<pix-agent-sop>\n${agentMdContent}\n</pix-agent-sop>`,
|
|
56
|
+
};
|
|
57
|
+
});
|
|
58
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
19
|
+
import registerAgentSop from "./commands/agent-sop/agent-sop.ts";
|
|
19
20
|
import registerClear from "./commands/clear/clear.ts";
|
|
20
21
|
import registerCopyAll from "./commands/copy-all/copy-all.ts";
|
|
21
22
|
import registerDiff from "./commands/diff/diff.ts";
|
|
@@ -24,7 +25,7 @@ import registerModels from "./commands/models/models.ts";
|
|
|
24
25
|
import registerUpdate from "./commands/update/update.ts";
|
|
25
26
|
import registerYeet from "./commands/yeet/yeet.ts";
|
|
26
27
|
import registerNudges from "./nudge/index.ts";
|
|
27
|
-
import registerAsk from "./tool/ask/
|
|
28
|
+
import registerAsk from "./tool/ask/index.ts";
|
|
28
29
|
import registerTodo from "./tool/todo/todo.ts";
|
|
29
30
|
import registerToolbox from "./tool/toolbox/toolbox.ts";
|
|
30
31
|
import registerDiagnostics from "./ui/diagnostics.ts";
|
|
@@ -32,6 +33,7 @@ import registerFooter from "./ui/footer.ts";
|
|
|
32
33
|
import registerWelcome from "./ui/welcome.ts";
|
|
33
34
|
|
|
34
35
|
export default function (pi: ExtensionAPI): void {
|
|
36
|
+
registerAgentSop(pi);
|
|
35
37
|
registerWelcome(pi);
|
|
36
38
|
registerFooter(pi);
|
|
37
39
|
registerDiagnostics(pi);
|
|
@@ -45,8 +45,14 @@ describe("CAPABILITY_REMINDER", () => {
|
|
|
45
45
|
expect(CAPABILITY_REMINDER.toLowerCase()).toContain("improvis");
|
|
46
46
|
});
|
|
47
47
|
|
|
48
|
-
test("
|
|
49
|
-
expect(CAPABILITY_REMINDER).toContain("
|
|
48
|
+
test("nudges model to call skill() when a skill matches", () => {
|
|
49
|
+
expect(CAPABILITY_REMINDER).toContain("skill()");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("points at /toolbox slash command for discovery (not a function call)", () => {
|
|
53
|
+
expect(CAPABILITY_REMINDER).toContain("/toolbox");
|
|
54
|
+
// must NOT imply toolbox is a callable function
|
|
55
|
+
expect(CAPABILITY_REMINDER).not.toContain("toolbox(");
|
|
50
56
|
expect(CAPABILITY_REMINDER).toContain("function definitions");
|
|
51
57
|
});
|
|
52
58
|
});
|
|
@@ -123,10 +129,13 @@ describe("buildOrientation", () => {
|
|
|
123
129
|
expect(out).toContain("2 skills");
|
|
124
130
|
});
|
|
125
131
|
|
|
126
|
-
test("explains how to
|
|
132
|
+
test("explains how to use skill() and /toolbox for discovery", () => {
|
|
127
133
|
const out = buildOrientation([tool("read", "builtin")], []);
|
|
128
|
-
expect(out).toContain("
|
|
129
|
-
expect(out
|
|
134
|
+
expect(out).toContain("skill()");
|
|
135
|
+
expect(out).toContain("/toolbox");
|
|
136
|
+
// toolbox must NOT appear as a function call
|
|
137
|
+
expect(out).not.toContain("toolbox(");
|
|
138
|
+
expect(out.toLowerCase()).toMatch(/discover|enable/);
|
|
130
139
|
});
|
|
131
140
|
|
|
132
141
|
test("calls out gated tools and points at toolbox to enable them", () => {
|
package/src/nudge/capability.ts
CHANGED
|
@@ -9,11 +9,15 @@
|
|
|
9
9
|
* Two modes:
|
|
10
10
|
* 1. FIRST prompt of the session — an orientation block: a high-level
|
|
11
11
|
* description of WHAT is available (counts of tools / MCP tools / skills)
|
|
12
|
-
* and HOW to explore it
|
|
13
|
-
*
|
|
14
|
-
* (
|
|
12
|
+
* and HOW to explore it. We deliberately do NOT dump the whole inventory
|
|
13
|
+
* every turn — the model should call skill() for skills and use /toolbox
|
|
14
|
+
* (slash command, user-facing) to discover/enable gated tools.
|
|
15
15
|
* 2. EVERY subsequent prompt — the terse one-line CAPABILITY_REMINDER, a
|
|
16
|
-
* cheap (~40 tok) reinforcement that
|
|
16
|
+
* cheap (~40 tok) reinforcement that steers toward skill() and /toolbox.
|
|
17
|
+
*
|
|
18
|
+
* NOTE: `toolbox` is a slash command only (/toolbox) — NOT a model-callable
|
|
19
|
+
* function tool. The model cannot call toolbox() in function definitions.
|
|
20
|
+
* The `skill` tool IS model-callable: skill() lists/loads bundled skills.
|
|
17
21
|
*/
|
|
18
22
|
|
|
19
23
|
import type {
|
|
@@ -26,10 +30,11 @@ type LoadedSkill = NonNullable<BuildSystemPromptOptions["skills"]>[number];
|
|
|
26
30
|
|
|
27
31
|
/** The standing per-turn reminder. Kept terse — it ships on every turn. */
|
|
28
32
|
export const CAPABILITY_REMINDER =
|
|
29
|
-
"Reminder —
|
|
33
|
+
"Reminder — check knowledge resources " +
|
|
30
34
|
"(skills/tools/MCP/web/user) before improvising. " +
|
|
31
|
-
"
|
|
32
|
-
"
|
|
35
|
+
"Matching skill? Call skill() first. " +
|
|
36
|
+
"Use /toolbox to discover/enable gated tools. " +
|
|
37
|
+
"All tools callable via function definitions.";
|
|
33
38
|
|
|
34
39
|
/** Count model-invocable skills (excludes user-only /skill:name entries). */
|
|
35
40
|
export function countInvocableSkills(
|
|
@@ -107,7 +112,8 @@ export function buildOrientation(
|
|
|
107
112
|
if (gateLine) lines.push(gateLine);
|
|
108
113
|
lines.push(
|
|
109
114
|
"Don't improvise what a capability covers — ask the user, search the web, or pull docs first.",
|
|
110
|
-
"
|
|
115
|
+
"`skill()` lists/loads bundled skills — call it when a skill matches your task. " +
|
|
116
|
+
"/toolbox (slash command) discovers and enables gated tools.",
|
|
111
117
|
);
|
|
112
118
|
if (skillNames.length) {
|
|
113
119
|
lines.push(`Skills: ${skillNames.join(", ")}.`);
|
package/src/tool/ask/ask.test.ts
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
type OptionData,
|
|
14
14
|
type QuestionData,
|
|
15
15
|
sentinelsFor,
|
|
16
|
-
} from "./
|
|
16
|
+
} from "./index.ts";
|
|
17
17
|
|
|
18
18
|
// ── Fixtures ──────────────────────────────────────────────────────────
|
|
19
19
|
|
|
@@ -237,7 +237,7 @@ describe("buildResponseText", () => {
|
|
|
237
237
|
|
|
238
238
|
describe("registerAsk", () => {
|
|
239
239
|
test("exports a default function", async () => {
|
|
240
|
-
const mod = await import("./
|
|
240
|
+
const mod = await import("./index.ts");
|
|
241
241
|
expect(typeof mod.default).toBe("function");
|
|
242
242
|
});
|
|
243
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
|
+
}
|