@xynogen/pix-core 0.2.3 → 0.3.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 +11 -17
- package/skills/ask-user/SKILL.md +0 -48
- package/src/commands/agent-sop/agent-sop.ts +0 -58
- package/src/commands/clear/clear.ts +0 -32
- package/src/commands/diff/diff.ts +0 -32
- package/src/commands/models/models.test.ts +0 -95
- package/src/commands/models/models.ts +0 -367
- package/src/commands/models/patch-builtin.test.ts +0 -66
- package/src/commands/models/patch-builtin.ts +0 -120
- package/src/commands/tools.test.ts +0 -15
- package/src/commands/update/update.test.ts +0 -112
- package/src/commands/update/update.ts +0 -271
- package/src/index.ts +0 -45
- package/src/lib/data.ts +0 -33
- package/src/nudge/capability.test.ts +0 -258
- package/src/nudge/capability.ts +0 -189
- package/src/nudge/index.ts +0 -17
- package/src/nudge/tools.test.ts +0 -157
- package/src/nudge/tools.ts +0 -212
- package/src/tool/ask/ask.test.ts +0 -243
- package/src/tool/ask/components.ts +0 -55
- package/src/tool/ask/helpers.ts +0 -77
- package/src/tool/ask/index.ts +0 -130
- package/src/tool/ask/questionnaire.ts +0 -693
- package/src/tool/ask/rpc.ts +0 -84
- package/src/tool/ask/schema.ts +0 -69
- package/src/tool/ask/single-select-layout.test.ts +0 -124
- package/src/tool/ask/single-select-layout.ts +0 -237
- package/src/tool/ask/types.ts +0 -17
- package/src/tool/todo/todo.test.ts +0 -646
- package/src/tool/todo/todo.ts +0 -218
- package/src/tool/toolbox/toolbox.test.ts +0 -314
- package/src/tool/toolbox/toolbox.ts +0 -570
- package/src/ui/diagnostics.ts +0 -145
- package/src/ui/footer.ts +0 -512
- package/src/ui/welcome.test.ts +0 -124
- package/src/ui/welcome.ts +0 -369
package/src/nudge/tools.ts
DELETED
|
@@ -1,212 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* tools-nudge.ts — nudge the model toward dedicated tools over raw bash
|
|
3
|
-
*
|
|
4
|
-
* Hooks `tool_call` for the built-in `bash` tool. When a bash command merely
|
|
5
|
-
* reimplements a first-class tool (read/ls/grep/find/edit/write), it blocks the
|
|
6
|
-
* call ONCE per command-category per session with a guidance reason. The model
|
|
7
|
-
* retries using the proper tool — improving token usage and enforcing stricter,
|
|
8
|
-
* more predictable behavior. After the first nudge for a category, subsequent
|
|
9
|
-
* bash calls in that category are allowed (no nag loop). Bash stays available
|
|
10
|
-
* for everything else (pipes, compound commands, real shell work).
|
|
11
|
-
*
|
|
12
|
-
* IMPORTANT: some target tools (e.g. `ls`, `find`) are GATED from the system
|
|
13
|
-
* prompt — registered but prompt-hidden until the model discovers them via
|
|
14
|
-
* toolbox. They ARE still in the function-calling definitions (always callable).
|
|
15
|
-
* The nudge checks `pi.getActiveTools()` first: if the tool is prompt-visible,
|
|
16
|
-
* recommend it directly; if it is gated, tell the model to enable it via toolbox
|
|
17
|
-
* but note that all tools are always callable via function definitions.
|
|
18
|
-
*
|
|
19
|
-
* The nudge is deliberately SURGICAL: it names only the single tool that maps
|
|
20
|
-
* to the command just blocked. It does NOT dump a full tool inventory — that
|
|
21
|
-
* floods the model with gated tools it can't call and is the opposite of the
|
|
22
|
-
* lazy-gate's purpose (a lean prompt). Point at the one right tool; don't list
|
|
23
|
-
* the menu.
|
|
24
|
-
*/
|
|
25
|
-
|
|
26
|
-
import {
|
|
27
|
-
type ExtensionAPI,
|
|
28
|
-
isToolCallEventType,
|
|
29
|
-
} from "@earendil-works/pi-coding-agent";
|
|
30
|
-
|
|
31
|
-
/** Categories that map a raw shell command to a dedicated Pi tool. */
|
|
32
|
-
type Category = "read" | "ls" | "grep" | "find" | "edit";
|
|
33
|
-
|
|
34
|
-
interface Rule {
|
|
35
|
-
category: Category;
|
|
36
|
-
/** Match the *leading* command word(s). */
|
|
37
|
-
test: (cmd: string) => boolean;
|
|
38
|
-
tool: string;
|
|
39
|
-
reason: string;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/** First bare word of a command segment (strips env-var prefixes like FOO=bar). */
|
|
43
|
-
const leadWord = (segment: string): string => {
|
|
44
|
-
const toks = segment.trim().split(/\s+/);
|
|
45
|
-
let i = 0;
|
|
46
|
-
while (i < toks.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(toks[i])) i++;
|
|
47
|
-
return (toks[i] ?? "").replace(/^\\/, ""); // unalias e.g. \grep
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
const RULES: Rule[] = [
|
|
51
|
-
{
|
|
52
|
-
category: "read",
|
|
53
|
-
test: (c) => /^(cat|head|tail|less|more|bat)$/.test(leadWord(c)),
|
|
54
|
-
tool: "read",
|
|
55
|
-
reason: "Reading a file via bash cat/head/tail.",
|
|
56
|
-
},
|
|
57
|
-
{
|
|
58
|
-
category: "ls",
|
|
59
|
-
test: (c) => /^(ls|tree|exa|eza)$/.test(leadWord(c)),
|
|
60
|
-
tool: "ls",
|
|
61
|
-
reason: "Listing a directory via bash ls/tree.",
|
|
62
|
-
},
|
|
63
|
-
{
|
|
64
|
-
category: "grep",
|
|
65
|
-
test: (c) => /^(grep|rg|ag|ack)$/.test(leadWord(c)),
|
|
66
|
-
tool: "grep",
|
|
67
|
-
reason: "Searching file contents via bash grep/rg.",
|
|
68
|
-
},
|
|
69
|
-
{
|
|
70
|
-
category: "find",
|
|
71
|
-
test: (c) => /^(find|fd|fdfind)$/.test(leadWord(c)),
|
|
72
|
-
tool: "find",
|
|
73
|
-
reason: "Locating files via bash find/fd.",
|
|
74
|
-
},
|
|
75
|
-
{
|
|
76
|
-
category: "edit",
|
|
77
|
-
test: (c) => /^sed$/.test(leadWord(c)) && /\s-i\b/.test(c),
|
|
78
|
-
tool: "edit",
|
|
79
|
-
reason: "Editing a file in place via bash `sed -i`.",
|
|
80
|
-
},
|
|
81
|
-
];
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* Pick a rule only when the command is a *simple* single-command invocation
|
|
85
|
-
* standing in for a tool. Skip anything with pipes, redirects, command
|
|
86
|
-
* chaining, or subshells — those are legitimate shell work bash should handle.
|
|
87
|
-
*/
|
|
88
|
-
export function classify(command: string): Rule | undefined {
|
|
89
|
-
const cmd = command.trim();
|
|
90
|
-
if (!cmd) return undefined;
|
|
91
|
-
// Bail on compound / piped / redirected commands — real shell work.
|
|
92
|
-
if (/[|&;]|\$\(|`|<|>|\n/.test(cmd)) return undefined;
|
|
93
|
-
return RULES.find((r) => r.test(cmd));
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* A segment of a compound command, paired with the operator that *follows* it.
|
|
98
|
-
* The trailing operator distinguishes a stand-in (`cat x ||`, `cat x ;`) from a
|
|
99
|
-
* genuine pipe producer (`cat x |`) whose output feeds a downstream consumer.
|
|
100
|
-
*/
|
|
101
|
-
interface Segment {
|
|
102
|
-
text: string;
|
|
103
|
-
/** Operator immediately after this segment: "|", ";", "&&", "||", "&", or "". */
|
|
104
|
-
followedBy: string;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* Split a command line into segments on shell control operators, recording the
|
|
109
|
-
* operator that follows each segment. Subshells and redirects are NOT split —
|
|
110
|
-
* a segment containing `$(...)`, backticks, or a redirect is treated as opaque
|
|
111
|
-
* shell work and will fail the single-command `classify` check below.
|
|
112
|
-
*/
|
|
113
|
-
export function splitSegments(command: string): Segment[] {
|
|
114
|
-
const out: Segment[] = [];
|
|
115
|
-
const re = /(\|\||&&|[|;&\n])/g;
|
|
116
|
-
let last = 0;
|
|
117
|
-
let m: RegExpExecArray | null;
|
|
118
|
-
while ((m = re.exec(command)) !== null) {
|
|
119
|
-
out.push({ text: command.slice(last, m.index), followedBy: m[1] });
|
|
120
|
-
last = m.index + m[1].length;
|
|
121
|
-
}
|
|
122
|
-
out.push({ text: command.slice(last), followedBy: "" });
|
|
123
|
-
return out.filter((s) => s.text.trim().length > 0);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* Classify a possibly-compound command. Returns the FIRST rule matching a
|
|
128
|
-
* segment that is a *pure tool stand-in*: a simple single-command invocation
|
|
129
|
-
* whose output is not piped into a downstream consumer.
|
|
130
|
-
*
|
|
131
|
-
* - `cat x || ls y` → nudge (read) — chaining-dodge, not real shell work.
|
|
132
|
-
* - `ls; pwd` → nudge (ls) — sequential stand-in alongside other cmd.
|
|
133
|
-
* - `cat x | jq .` → undefined — `cat` feeds a pipe; legitimate.
|
|
134
|
-
* - `grep x y > out` → undefined — redirect; opaque per-segment.
|
|
135
|
-
* - `git log | cat` → undefined — no segment is a leading stand-in.
|
|
136
|
-
*/
|
|
137
|
-
export function classifyCompound(command: string): Rule | undefined {
|
|
138
|
-
const cmd = command.trim();
|
|
139
|
-
if (!cmd) return undefined;
|
|
140
|
-
|
|
141
|
-
// Fast path: a truly simple command (no operators/subshell/redirect).
|
|
142
|
-
const simple = classify(cmd);
|
|
143
|
-
if (simple) return simple;
|
|
144
|
-
|
|
145
|
-
// Otherwise inspect each segment of the compound command. A segment is a
|
|
146
|
-
// legitimate part of a pipeline — not a stand-in — when it either feeds a
|
|
147
|
-
// pipe (producer) or is fed by one (consumer). Only segments outside any
|
|
148
|
-
// pipe relationship are candidate tool stand-ins.
|
|
149
|
-
const segs = splitSegments(cmd);
|
|
150
|
-
for (let i = 0; i < segs.length; i++) {
|
|
151
|
-
const seg = segs[i];
|
|
152
|
-
if (seg.followedBy === "|") continue; // producer feeding a pipe
|
|
153
|
-
if (i > 0 && segs[i - 1].followedBy === "|") continue; // consumer of a pipe
|
|
154
|
-
// Per-segment we reuse the strict single-command classifier, which itself
|
|
155
|
-
// rejects redirects/subshells/nested operators inside the segment.
|
|
156
|
-
const rule = classify(seg.text);
|
|
157
|
-
if (rule) return rule;
|
|
158
|
-
}
|
|
159
|
-
return undefined;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/**
|
|
163
|
-
* Build the one-line guidance for a nudge. When the target tool is active, point
|
|
164
|
-
* straight at it. When it's gated out, point at toolbox to enable it — never
|
|
165
|
-
* imply a gated tool is callable now. Pure + exported for unit testing.
|
|
166
|
-
*/
|
|
167
|
-
export function nudgeReason(
|
|
168
|
-
baseReason: string,
|
|
169
|
-
tool: string,
|
|
170
|
-
toolActive: boolean,
|
|
171
|
-
): string {
|
|
172
|
-
const how = toolActive
|
|
173
|
-
? `Use \`${tool}\` instead — it's in the function definitions, call it directly.`
|
|
174
|
-
: `\`${tool}\` is prompt-hidden — enable it via toolbox(action:"enable", name:"${tool}") to make it known, then call it; bash is fine until then. (All tools are always callable via function definitions.)`;
|
|
175
|
-
return `${baseReason} ${how} (Fires once per category; bash stays available for pipes & compound commands.)`;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
export default function registerToolsNudge(pi: ExtensionAPI): void {
|
|
179
|
-
// Categories already nudged this session — block once, then allow.
|
|
180
|
-
const nudged = new Set<Category>();
|
|
181
|
-
|
|
182
|
-
/** Is `name` in the host's current active set (i.e. callable right now)? */
|
|
183
|
-
function isActive(name: string): boolean {
|
|
184
|
-
try {
|
|
185
|
-
// getActiveTools() returns string[] (tool NAMES), not ToolInfo[].
|
|
186
|
-
return (pi.getActiveTools() ?? []).includes(name);
|
|
187
|
-
} catch {
|
|
188
|
-
// If we can't tell, assume active so we don't suppress a valid nudge.
|
|
189
|
-
return true;
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
pi.on("tool_call", async (event) => {
|
|
194
|
-
if (!isToolCallEventType("bash", event)) return;
|
|
195
|
-
const command = event.input.command;
|
|
196
|
-
if (typeof command !== "string") return;
|
|
197
|
-
|
|
198
|
-
const rule = classifyCompound(command);
|
|
199
|
-
if (!rule) return;
|
|
200
|
-
if (nudged.has(rule.category)) return; // already taught — allow.
|
|
201
|
-
|
|
202
|
-
nudged.add(rule.category);
|
|
203
|
-
|
|
204
|
-
// One short, targeted line: name only the tool that maps to THIS command,
|
|
205
|
-
// and route through toolbox when it's gated rather than implying it's
|
|
206
|
-
// callable now. No full-inventory dump — that's what confused the model.
|
|
207
|
-
return {
|
|
208
|
-
block: true,
|
|
209
|
-
reason: nudgeReason(rule.reason, rule.tool, isActive(rule.tool)),
|
|
210
|
-
};
|
|
211
|
-
});
|
|
212
|
-
}
|
package/src/tool/ask/ask.test.ts
DELETED
|
@@ -1,243 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,55 +0,0 @@
|
|
|
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/tool/ask/helpers.ts
DELETED
|
@@ -1,77 +0,0 @@
|
|
|
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 };
|