@xynogen/pix-core 0.1.1 → 0.1.3

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-core",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
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,8 @@
42
42
  "access": "public"
43
43
  },
44
44
  "dependencies": {
45
+ "@xynogen/pix-data": "^0.1.0",
46
+ "@xynogen/pix-skills": "^0.1.1",
45
47
  "typebox": "^1.1.38"
46
48
  },
47
49
  "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/ask.ts";
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);
package/src/lib/data.ts CHANGED
@@ -1,241 +1,33 @@
1
1
  /**
2
2
  * data.ts — model data layer (shim)
3
3
  *
4
- * Re-exports shared data from pix-data (github.com/xynogen/pix-data).
5
- * Cache lives at ~/.cache/pi/ and is shared across all Pi extensions.
4
+ * Thin re-export of the shared data layer from @xynogen/pix-data
5
+ * (github.com/xynogen/pix-mono/tree/main/packages/pix-data). Cache lives at
6
+ * ~/.cache/pi/ and is shared across all Pi extensions — pix-data warms it on
7
+ * session start; this extension reads from it.
6
8
  *
7
9
  * Consumers in this extension dir:
8
10
  * footer.ts — lookupModelsDev, lookupBenchmark, ModelsDevModel
9
11
  * models.ts — lookupModelsDev, lookupBenchmark
10
12
  */
11
13
 
12
- import { existsSync, readFileSync } from "node:fs";
13
- import { mkdir, readFile, writeFile } from "node:fs/promises";
14
- import { homedir } from "node:os";
15
- import { dirname, join } from "node:path";
16
-
17
- // ── Types ─────────────────────────────────────────────────────────────────────
18
-
19
- export interface ModelsDevModel {
20
- id: string;
21
- name?: string;
22
- reasoning?: boolean;
23
- modalities?: { input?: string[]; output?: string[] };
24
- limit?: { context?: number; output?: number };
25
- cost?: {
26
- input?: number;
27
- output?: number;
28
- cache_read?: number;
29
- cache_write?: number;
30
- };
31
- }
32
-
33
- export type ModelsDevApi = Record<
34
- string,
35
- { models?: Record<string, ModelsDevModel> }
36
- >;
37
-
38
- export interface BenchmarkEntry {
39
- rank: number;
40
- model: string;
41
- creator: string;
42
- sourceType?: string;
43
- overallScore: number | null;
44
- categoryScores?: Record<string, number | null>;
45
- inputPrice: number | null;
46
- outputPrice: number | null;
47
- }
48
-
49
- interface BenchmarkResponse {
50
- lastUpdated?: string;
51
- mode?: string;
52
- models: BenchmarkEntry[];
53
- }
54
-
55
- // ── DataSource ─────────────────────────────────────────────────────────────────
56
-
57
- interface DataSourceOptions<T> {
58
- url: string | (() => string);
59
- headers?: () => Record<string, string> | undefined;
60
- cachePath: string;
61
- ttlMs?: number;
62
- timeoutMs?: number;
63
- parse: (raw: unknown) => T;
64
- parseCache: (data: unknown) => T;
65
- empty: T;
66
- label: string;
67
- skip?: () => boolean;
68
- }
69
-
70
- class DataSource<T> {
71
- private _mem: T | null = null;
72
- private _inflight: Promise<T> | null = null;
73
- private readonly opts: Required<DataSourceOptions<T>>;
74
-
75
- constructor(opts: DataSourceOptions<T>) {
76
- this.opts = {
77
- ttlMs: 24 * 60 * 60 * 1000,
78
- timeoutMs: 10_000,
79
- headers: () => undefined,
80
- skip: () => false,
81
- ...opts,
82
- };
83
- }
84
-
85
- async get(): Promise<T> {
86
- if (this._inflight) return this._inflight;
87
- this._inflight = this._load().finally(() => {
88
- this._inflight = null;
89
- });
90
- return this._inflight;
91
- }
92
-
93
- getCached(): T {
94
- if (this._mem) return this._mem;
95
- try {
96
- if (existsSync(this.opts.cachePath)) {
97
- const raw = JSON.parse(readFileSync(this.opts.cachePath, "utf-8")) as {
98
- data: unknown;
99
- };
100
- this._mem = this.opts.parseCache(raw.data);
101
- return this._mem;
102
- }
103
- } catch {
104
- // No cache file or parse error — return empty
105
- }
106
- return this.opts.empty;
107
- }
108
-
109
- private async _load(): Promise<T> {
110
- if (this.opts.skip()) {
111
- this._mem = this.opts.empty;
112
- return this.opts.empty;
113
- }
114
- const cached = await this._readCache();
115
- if (cached !== undefined && Date.now() - cached.ts < this.opts.ttlMs) {
116
- const val = this.opts.parseCache(cached.data);
117
- this._mem = val;
118
- return val;
119
- }
120
- try {
121
- const url =
122
- typeof this.opts.url === "function" ? this.opts.url() : this.opts.url;
123
- const controller = new AbortController();
124
- const timer = setTimeout(() => controller.abort(), this.opts.timeoutMs);
125
- const response = await fetch(url, {
126
- signal: controller.signal,
127
- headers: this.opts.headers(),
128
- }).finally(() => clearTimeout(timer));
129
- if (!response.ok)
130
- throw new Error(`${this.opts.label} fetch failed: ${response.status}`);
131
- const raw = await response.json();
132
- const val = this.opts.parse(raw);
133
- this._mem = val;
134
- void this._writeCache(raw);
135
- return val;
136
- } catch (error) {
137
- const msg = error instanceof Error ? error.message : String(error);
138
- if (cached !== undefined) {
139
- console.warn(
140
- `${this.opts.label} fetch failed, using stale cache: ${msg}`,
141
- );
142
- const val = this.opts.parseCache(cached.data);
143
- this._mem = val;
144
- return val;
145
- }
146
- console.warn(`${this.opts.label} unavailable: ${msg}`);
147
- return this.opts.empty;
148
- }
149
- }
150
-
151
- private async _readCache(): Promise<
152
- { ts: number; data: unknown } | undefined
153
- > {
154
- try {
155
- const raw = await readFile(this.opts.cachePath, "utf8");
156
- const parsed = JSON.parse(raw) as { ts: number; data: unknown };
157
- if (typeof parsed.ts !== "number") return undefined;
158
- return parsed;
159
- } catch {
160
- return undefined;
161
- }
162
- }
163
-
164
- private async _writeCache(data: unknown): Promise<void> {
165
- try {
166
- await mkdir(dirname(this.opts.cachePath), { recursive: true });
167
- await writeFile(
168
- this.opts.cachePath,
169
- JSON.stringify({ ts: Date.now(), data }),
170
- );
171
- } catch {
172
- // Write failure is non-fatal
173
- }
174
- }
175
- }
176
-
177
- // ── Cache dir ─────────────────────────────────────────────────────────────────
178
-
179
- const CACHE_DIR = join(
180
- process.env.XDG_CACHE_HOME || join(homedir(), ".cache"),
181
- "pi",
182
- );
183
-
184
- // ── Data sources (shared cache with pix-data) ─────────────────────────────────
185
-
186
- const modelsDev = new DataSource<ModelsDevApi>({
187
- label: "models.dev",
188
- url: "https://models.dev/api.json",
189
- cachePath: join(CACHE_DIR, "models.json"),
190
- parse: (raw) => raw as ModelsDevApi,
191
- parseCache: (data) => (data as ModelsDevApi) ?? {},
192
- empty: {},
193
- });
194
-
195
- const benchmark = new DataSource<BenchmarkEntry[]>({
196
- label: "benchlm",
197
- url: "https://benchlm.ai/api/data/leaderboard",
198
- cachePath: join(CACHE_DIR, "benchlm.json"),
199
- parse: (raw) => (raw as BenchmarkResponse).models ?? [],
200
- parseCache: (data) => (data as BenchmarkResponse)?.models ?? [],
201
- empty: [],
202
- });
203
-
204
- // ── Lookup helpers ─────────────────────────────────────────────────────────────
205
-
206
- export function lookupModelsDev(
207
- provider: string,
208
- id: string,
209
- ): ModelsDevModel | undefined {
210
- const data = modelsDev.getCached();
211
- const canonical = id.includes("/") ? id.slice(id.lastIndexOf("/") + 1) : id;
212
- const exact = data[provider]?.models?.[canonical];
213
- if (exact) return exact;
214
- for (const p of Object.keys(data)) {
215
- const hit = data[p]?.models?.[canonical];
216
- if (hit) return hit;
217
- }
218
- return undefined;
219
- }
220
-
221
- function normBench(s: string): string {
222
- return s
223
- .toLowerCase()
224
- .replace(/[-_.]+/g, " ")
225
- .replace(/\s+/g, " ")
226
- .trim();
227
- }
228
-
229
- export function lookupBenchmark(modelName: string): BenchmarkEntry | undefined {
230
- const entries = benchmark.getCached();
231
- const needle = normBench(modelName);
232
- return (
233
- entries.find((e) => normBench(e.model) === needle) ??
234
- entries.find((e) => normBench(e.model).includes(needle)) ??
235
- entries.find((e) => needle.includes(normBench(e.model)))
236
- );
237
- }
14
+ export {
15
+ benchmark,
16
+ buildModelsDevIndex,
17
+ CACHE_DIR,
18
+ DataSource,
19
+ fetchModelsDevIndex,
20
+ lookupBenchmark,
21
+ lookupInIndex,
22
+ lookupModelsDev,
23
+ modelsDev,
24
+ } from "@xynogen/pix-data";
25
+ export type {
26
+ BenchmarkEntry,
27
+ ModelsDevApi,
28
+ ModelsDevModel,
29
+ } from "@xynogen/pix-data";
238
30
 
239
31
  export default function (_pi: unknown): void {
240
- // pix-data warms this cache on startup — nothing to do here
32
+ // pix-data warms this cache on startup — nothing to do here.
241
33
  }
@@ -1,8 +1,11 @@
1
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
1
3
  import { describe, expect, test } from "bun:test";
2
4
  import {
3
5
  buildOrientation,
4
6
  CAPABILITY_REMINDER,
5
7
  countInvocableSkills,
8
+ graphifyHint,
6
9
  partitionTools,
7
10
  } from "./capability.ts";
8
11
 
@@ -45,8 +48,14 @@ describe("CAPABILITY_REMINDER", () => {
45
48
  expect(CAPABILITY_REMINDER.toLowerCase()).toContain("improvis");
46
49
  });
47
50
 
48
- test("points at the toolbox tool for discovery", () => {
49
- expect(CAPABILITY_REMINDER).toContain("toolbox");
51
+ test("nudges model to call skill() when a skill matches", () => {
52
+ expect(CAPABILITY_REMINDER).toContain("skill()");
53
+ });
54
+
55
+ test("points at /toolbox slash command for discovery (not a function call)", () => {
56
+ expect(CAPABILITY_REMINDER).toContain("/toolbox");
57
+ // must NOT imply toolbox is a callable function
58
+ expect(CAPABILITY_REMINDER).not.toContain("toolbox(");
50
59
  expect(CAPABILITY_REMINDER).toContain("function definitions");
51
60
  });
52
61
  });
@@ -123,10 +132,13 @@ describe("buildOrientation", () => {
123
132
  expect(out).toContain("2 skills");
124
133
  });
125
134
 
126
- test("explains how to explore via the toolbox tool", () => {
135
+ test("explains how to use skill() and /toolbox for discovery", () => {
127
136
  const out = buildOrientation([tool("read", "builtin")], []);
128
- expect(out).toContain("toolbox(query");
129
- expect(out.toLowerCase()).toMatch(/fuzzy|search|discover/);
137
+ expect(out).toContain("skill()");
138
+ expect(out).toContain("/toolbox");
139
+ // toolbox must NOT appear as a function call
140
+ expect(out).not.toContain("toolbox(");
141
+ expect(out.toLowerCase()).toMatch(/discover|enable/);
130
142
  });
131
143
 
132
144
  test("calls out gated tools and points at toolbox to enable them", () => {
@@ -192,4 +204,32 @@ describe("buildOrientation", () => {
192
204
  const out = buildOrientation([tool("read", "builtin")], []);
193
205
  expect(out.toLowerCase()).toContain("improvis");
194
206
  });
207
+
208
+ test("frames the block as non-actionable so the model acts on the prompt", () => {
209
+ const out = buildOrientation([tool("read", "builtin")], []);
210
+ const last = out.trim().split("\n").at(-1) ?? "";
211
+ expect(last.toLowerCase()).toContain("not a task");
212
+ expect(last.toLowerCase()).toContain("do not reply");
213
+ });
214
+ });
215
+
216
+ describe("graphifyHint", () => {
217
+ const tmpDir = join(import.meta.dir, ".graphify-hint-test-tmp");
218
+
219
+ test("returns undefined when graphify-out/graph.json absent", () => {
220
+ expect(graphifyHint(tmpDir)).toBeUndefined();
221
+ });
222
+
223
+ test("returns hint string when graphify-out/graph.json exists", () => {
224
+ try {
225
+ mkdirSync(join(tmpDir, "graphify-out"), { recursive: true });
226
+ writeFileSync(join(tmpDir, "graphify-out", "graph.json"), "{}");
227
+ const hint = graphifyHint(tmpDir);
228
+ expect(hint).toBeTypeOf("string");
229
+ expect(hint).toContain("graphify");
230
+ expect(hint).toContain("graphify query");
231
+ } finally {
232
+ rmSync(tmpDir, { recursive: true, force: true });
233
+ }
234
+ });
195
235
  });
@@ -9,13 +9,19 @@
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 on demand via the `toolbox` tool. We deliberately
13
- * do NOT dump the whole inventory every turn that is what `toolbox`
14
- * (fuzzy search over names + descriptions) is for.
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 points back at toolbox.
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
 
23
+ import { existsSync } from "node:fs";
24
+ import { join } from "node:path";
19
25
  import type {
20
26
  BuildSystemPromptOptions,
21
27
  ExtensionAPI,
@@ -26,10 +32,25 @@ type LoadedSkill = NonNullable<BuildSystemPromptOptions["skills"]>[number];
26
32
 
27
33
  /** The standing per-turn reminder. Kept terse — it ships on every turn. */
28
34
  export const CAPABILITY_REMINDER =
29
- "Reminder — always check your knowledge resources " +
35
+ "Reminder — check knowledge resources " +
30
36
  "(skills/tools/MCP/web/user) before improvising. " +
31
- "`toolbox(query)` finds the right one; enable gated tools via toolbox. " +
32
- "All tools are always callable via function definitions.";
37
+ "Matching skill? Call skill() first. " +
38
+ "Use /toolbox to discover/enable gated tools. " +
39
+ "All tools callable via function definitions.";
40
+
41
+ /**
42
+ * Build the optional graphify hint line.
43
+ * Returns a string if graphify-out/graph.json exists in cwd, else undefined.
44
+ */
45
+ export function graphifyHint(cwd: string): string | undefined {
46
+ if (existsSync(join(cwd, "graphify-out", "graph.json"))) {
47
+ return (
48
+ "graphify-out/graph.json exists — for codebase questions (how does X work, " +
49
+ 'where is Y, trace Z) run `graphify query "<question>"` before reading files.'
50
+ );
51
+ }
52
+ return undefined;
53
+ }
33
54
 
34
55
  /** Count model-invocable skills (excludes user-only /skill:name entries). */
35
56
  export function countInvocableSkills(
@@ -107,11 +128,21 @@ export function buildOrientation(
107
128
  if (gateLine) lines.push(gateLine);
108
129
  lines.push(
109
130
  "Don't improvise what a capability covers — ask the user, search the web, or pull docs first.",
110
- "toolbox is the gateway: `toolbox(query)` searches tools/MCP/skills/commands; `toolbox(action:'enable'|'disable', name)` toggles a gated tool prompt-visible or gated. Empty query lists all.",
131
+ "`skill()` lists/loads bundled skills call it when a skill matches your task. " +
132
+ "/toolbox (slash command) discovers and enables gated tools.",
111
133
  );
112
134
  if (skillNames.length) {
113
135
  lines.push(`Skills: ${skillNames.join(", ")}.`);
114
136
  }
137
+ // Graphify hint — only when a graph is already built for this project
138
+ const gHint = graphifyHint(process.cwd());
139
+ if (gHint) lines.push(gHint);
140
+ // Framing — this block is orientation context, not a task. Without it the
141
+ // model can mistake the first-turn orientation for the prompt and reply
142
+ // "Ready, waiting for task" instead of acting on the user's request.
143
+ lines.push(
144
+ "(Orientation only — not a task. Act on the user's request now; do not reply to this notice.)",
145
+ );
115
146
  return lines.join("\n");
116
147
  }
117
148
 
@@ -140,7 +171,12 @@ export default function registerCapabilityNudge(pi: ExtensionAPI): void {
140
171
  }
141
172
  content = buildOrientation(tools, skills, activeToolNames);
142
173
  } else {
143
- content = CAPABILITY_REMINDER;
174
+ // Per-turn reminder — append graphify hint when graph exists
175
+ const cwd = process.cwd();
176
+ const gHint = graphifyHint(cwd);
177
+ content = gHint
178
+ ? `${CAPABILITY_REMINDER}\n${gHint}`
179
+ : CAPABILITY_REMINDER;
144
180
  }
145
181
 
146
182
  return {
@@ -13,7 +13,7 @@ import {
13
13
  type OptionData,
14
14
  type QuestionData,
15
15
  sentinelsFor,
16
- } from "./ask.ts";
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("./ask.ts");
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 };