@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.
@@ -114,15 +114,23 @@ describe("classifyCompound", () => {
114
114
  });
115
115
 
116
116
  describe("nudgeReason", () => {
117
- test("active tool: point straight at it, no toolbox", () => {
118
- const msg = nudgeReason("Searching file contents via bash grep/rg.", "grep", true);
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
- test("gated tool: route through toolbox enable, not a direct call", () => {
125
- const msg = nudgeReason("Listing a directory via bash ls/tree.", "ls", false);
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(nudgeReason("Locating files via bash find/fd.", "find", false)).toContain(
135
- 'toolbox(action:"enable", name:"find")',
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
- test("is a single short line — no inventory dump, no newlines", () => {
140
- const msg = nudgeReason("Listing a directory via bash ls/tree.", "ls", false);
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);
@@ -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;
@@ -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 "./ask.ts";
16
+ } from "./index.ts";
17
17
 
18
18
  // ── Fixtures ──────────────────────────────────────────────────────────
19
19
 
20
- const opt = (label: string, description = "Test option", preview?: string): OptionData => ({
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]!.kind).toBe("other");
89
- expect(r[0]!.label).toBe("Type something.");
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]!.kind).toBe("next");
101
- expect(r[0]!.label).toBe("Next");
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]!.kind).toBe("other");
113
+ expect(r[0]?.kind).toBe("other");
113
114
  });
114
115
  });
115
116
 
116
117
  // ── formatAnswerScalar ────────────────────────────────────────────────
117
118
 
118
119
  describe("formatAnswerScalar", () => {
119
- test('option kind returns the answer string', () => {
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('multi kind joins selected with comma', () => {
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('custom kind returns the typed text', () => {
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('chat kind returns (chat)', () => {
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
- { questionIndex: 0, question: "Which approach?", kind: "option" as const, answer: "REST" },
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
- { questionIndex: 0, question: "Which approach?", kind: "option" as const, answer: "GraphQL" },
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("./ask.ts");
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
+ }