@xynogen/pix-core 0.2.4 → 0.3.1

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.
Files changed (38) hide show
  1. package/README.md +24 -21
  2. package/package.json +11 -17
  3. package/skills/ask-user/SKILL.md +0 -48
  4. package/src/commands/agent-sop/agent-sop.ts +0 -58
  5. package/src/commands/clear/clear.ts +0 -32
  6. package/src/commands/diff/diff.ts +0 -32
  7. package/src/commands/models/models.test.ts +0 -95
  8. package/src/commands/models/models.ts +0 -367
  9. package/src/commands/models/patch-builtin.test.ts +0 -66
  10. package/src/commands/models/patch-builtin.ts +0 -120
  11. package/src/commands/tools.test.ts +0 -15
  12. package/src/commands/update/update.test.ts +0 -112
  13. package/src/commands/update/update.ts +0 -271
  14. package/src/index.ts +0 -45
  15. package/src/lib/data.ts +0 -33
  16. package/src/nudge/capability.test.ts +0 -258
  17. package/src/nudge/capability.ts +0 -189
  18. package/src/nudge/index.ts +0 -17
  19. package/src/nudge/tools.test.ts +0 -157
  20. package/src/nudge/tools.ts +0 -212
  21. package/src/tool/ask/ask.test.ts +0 -243
  22. package/src/tool/ask/components.ts +0 -55
  23. package/src/tool/ask/helpers.ts +0 -77
  24. package/src/tool/ask/index.ts +0 -130
  25. package/src/tool/ask/questionnaire.ts +0 -693
  26. package/src/tool/ask/rpc.ts +0 -84
  27. package/src/tool/ask/schema.ts +0 -69
  28. package/src/tool/ask/single-select-layout.test.ts +0 -124
  29. package/src/tool/ask/single-select-layout.ts +0 -237
  30. package/src/tool/ask/types.ts +0 -17
  31. package/src/tool/todo/todo.test.ts +0 -646
  32. package/src/tool/todo/todo.ts +0 -218
  33. package/src/tool/toolbox/toolbox.test.ts +0 -314
  34. package/src/tool/toolbox/toolbox.ts +0 -570
  35. package/src/ui/diagnostics.ts +0 -145
  36. package/src/ui/footer.ts +0 -513
  37. package/src/ui/welcome.test.ts +0 -124
  38. package/src/ui/welcome.ts +0 -369
package/src/ui/footer.ts DELETED
@@ -1,513 +0,0 @@
1
- /**
2
- * Footer extension — pure-prompt style.
3
- *
4
- * Layout:
5
- * [MODE] | ~/cwd (branch *±⇡n⇣n) | ⇡in ⇣out [Rcache] [ctx%/ctxk] [$cost] | model [· thinking] [· ctxK · $in/$out] [| status…] [| N t/s]
6
- *
7
- * - Branch shown with zsh-style dirty/ahead/behind markers.
8
- * - TPS: live during stream, holds 5s after turn ends, then clears.
9
- * - Model spec (ctx · cost) sourced from ~/.cache/pi/models-dev.json.
10
- * - Extension statuses surfaced via footerData.getExtensionStatuses();
11
- * "plan" is rendered as the leftmost segment, others appended after model.
12
- */
13
-
14
- import { execFile } from "node:child_process";
15
- import { promisify } from "node:util";
16
- import type {
17
- AssistantMessage,
18
- AssistantMessageEvent,
19
- } from "@earendil-works/pi-ai";
20
- import type {
21
- ExtensionAPI,
22
- ReadonlyFooterDataProvider,
23
- } from "@earendil-works/pi-coding-agent";
24
- import { truncateToWidth } from "@earendil-works/pi-tui";
25
- import type { ModelsDevModel } from "../lib/data";
26
- import { lookupBenchmark, lookupModelsDev } from "../lib/data";
27
-
28
- // ─── Pure formatting helpers ─────────────────────────────────────────
29
-
30
- type Theme = {
31
- fg(color: string, text: string): string;
32
- };
33
-
34
- const execFileAsync = promisify(execFile);
35
- const GIT_POLL_MS = 2_000;
36
-
37
- /** Compact token formatter for cumulative session totals. */
38
- const fmtToken = (n: number): string =>
39
- n < 1_000
40
- ? `${n}`
41
- : n < 1_000_000
42
- ? `${(n / 1_000).toFixed(1)}K`
43
- : `${(n / 1_000_000).toFixed(2)}M`;
44
-
45
- const shortCwd = (cwd: string): string => {
46
- const base = cwd.split("/").filter(Boolean).pop();
47
- return base ?? cwd;
48
- };
49
-
50
- function fmtCost(entry: ModelsDevModel | undefined): string {
51
- const costIn = entry?.cost?.input ?? 0;
52
- const costOut = entry?.cost?.output ?? 0;
53
- if (costIn === 0 && costOut === 0) return "free";
54
- const fmt = (n: number) =>
55
- Number.isInteger(n) ? `${n}` : n.toFixed(2).replace(/\.?0+$/, "");
56
- return `$ ${fmt(costIn)}/${fmt(costOut)}`;
57
- }
58
-
59
- // ────────────────────────────────────────────────────────────────────
60
-
61
- interface GitStatus {
62
- dirty: boolean;
63
- staged: number;
64
- untracked: number;
65
- unstaged: number;
66
- ahead: number;
67
- behind: number;
68
- }
69
-
70
- async function getGitStatus(cwd: string): Promise<GitStatus | null> {
71
- try {
72
- const { stdout } = await execFileAsync(
73
- "git",
74
- ["status", "--porcelain=v1", "--branch", "--untracked-files=normal"],
75
- { cwd, timeout: 2_000, maxBuffer: 1024 * 1024 },
76
- );
77
- let staged = 0,
78
- unstaged = 0,
79
- untracked = 0,
80
- ahead = 0,
81
- behind = 0;
82
- for (const line of stdout.split("\n")) {
83
- if (!line) continue;
84
- if (line.startsWith("## ")) {
85
- const m = line.match(
86
- /\[ahead (\d+)(?:, behind (\d+))?\]|\[behind (\d+)\]/,
87
- );
88
- if (m) {
89
- if (m[1]) ahead = parseInt(m[1], 10);
90
- if (m[2]) behind = parseInt(m[2], 10);
91
- if (m[3]) behind = parseInt(m[3], 10);
92
- }
93
- continue;
94
- }
95
- if (line.startsWith("??")) {
96
- untracked += 1;
97
- continue;
98
- }
99
- const idx = line[0],
100
- wt = line[1];
101
- if (idx && idx !== " " && idx !== "?") staged += 1;
102
- if (wt && wt !== " " && wt !== "?") unstaged += 1;
103
- }
104
- return {
105
- dirty: unstaged + untracked > 0,
106
- staged,
107
- untracked,
108
- unstaged,
109
- ahead,
110
- behind,
111
- };
112
- } catch {
113
- return null;
114
- }
115
- }
116
-
117
- // ─── Footer segment builders ─────────────────────────────────────────
118
-
119
- interface SessionTotals {
120
- input: number;
121
- output: number;
122
- cacheRead: number;
123
- cost: number;
124
- }
125
-
126
- function computeSessionTotals(entries: Iterable<unknown>): SessionTotals {
127
- let input = 0,
128
- output = 0,
129
- cacheRead = 0,
130
- cost = 0;
131
- for (const e of entries as Iterable<{
132
- type: string;
133
- message?: { role: string; usage?: AssistantMessage["usage"] };
134
- }>) {
135
- if (
136
- e.type === "message" &&
137
- e.message?.role === "assistant" &&
138
- e.message.usage
139
- ) {
140
- const u = e.message.usage;
141
- input += u.input;
142
- output += u.output;
143
- cacheRead += u.cacheRead;
144
- cost += u.cost.total;
145
- }
146
- }
147
- return { input, output, cacheRead, cost };
148
- }
149
-
150
- /** Tokens block (in/out + cache/cost). Always returns a string; caller decides visibility. */
151
- function renderTokens(
152
- totals: SessionTotals,
153
- theme: Theme,
154
- dim = false,
155
- ): string {
156
- let s = `⇡ ${fmtToken(totals.input)} ⇣ ${fmtToken(totals.output)}`;
157
- if (totals.cost > 0) s += ` $${totals.cost.toFixed(3)}`;
158
- return theme.fg(dim ? "dim" : "muted", s);
159
- }
160
-
161
- /** Context usage block: "used/total (pct%)". Always shown when available. */
162
- function renderCtxUsage(
163
- usage: { percent?: number | null; contextWindow?: number } | undefined,
164
- theme: Theme,
165
- ): string {
166
- if (usage?.percent == null || !usage?.contextWindow) return "";
167
- const pct = Math.round(usage.percent);
168
- const used = Math.round((usage.percent / 100) * usage.contextWindow);
169
- const pctColor = pct >= 80 ? "error" : pct >= 50 ? "warning" : "success";
170
- return (
171
- theme.fg("success", fmtToken(used)) +
172
- theme.fg("muted", `/${fmtToken(usage.contextWindow)} `) +
173
- theme.fg(pctColor, `(${pct}%)`)
174
- );
175
- }
176
-
177
- /** Branch + dirty/ahead/behind markers. */
178
- function renderBranch(
179
- branch: string | null,
180
- gs: GitStatus | null,
181
- theme: Theme,
182
- ): { branchSeg: string; markersSeg: string } {
183
- if (!branch) return { branchSeg: "", markersSeg: "" };
184
- const dirty = gs?.dirty ?? false;
185
- const branchSeg = ` ${theme.fg("muted", branch) + (dirty ? theme.fg("error", "*") : "")}`;
186
- const markers: string[] = [];
187
- if (gs) {
188
- if (gs.staged > 0) markers.push(theme.fg("success", `+${gs.staged}`));
189
- if (gs.unstaged > 0) markers.push(theme.fg("error", `✗${gs.unstaged}`));
190
- if (gs.untracked > 0) markers.push(theme.fg("warning", `?${gs.untracked}`));
191
- if (gs.ahead > 0) markers.push(theme.fg("accent", `⇡${gs.ahead}`));
192
- if (gs.behind > 0) markers.push(theme.fg("accent", `⇣${gs.behind}`));
193
- }
194
- return { branchSeg, markersSeg: markers.join(" ") };
195
- }
196
-
197
- /** "<modelId> [· thinking] [· ctxK · $in/$out]" */
198
- function renderModel(
199
- model: { id?: string; provider?: string; name?: string } | undefined,
200
- thinking: string,
201
- theme: Theme,
202
- ): string {
203
- const rawId = model?.id ?? "?";
204
- const id = rawId.replace(/^[a-z]+\//i, "");
205
- const provider = model?.provider ?? "";
206
- let out = theme.fg("muted", "󰚩 ") + theme.fg("accent", id);
207
- const THINK_ABBR: Record<string, string> = {
208
- minimal: "min",
209
- low: "low",
210
- medium: "med",
211
- high: "high",
212
- xhigh: "xhigh",
213
- off: "off",
214
- };
215
- if (thinking) {
216
- const abbr = THINK_ABBR[thinking] ?? thinking.slice(0, 3);
217
- out += theme.fg("muted", " · ") + theme.fg("warning", abbr);
218
- }
219
- if (provider && id !== "?") {
220
- const dev = lookupModelsDev(provider, id);
221
- out += theme.fg("muted", ` · ${fmtCost(dev)}`);
222
- }
223
- const bench = lookupBenchmark(model?.name ?? id);
224
- if (bench) {
225
- const score = bench.overallScore ?? "?";
226
- const scoreColor =
227
- bench.overallScore == null
228
- ? "muted"
229
- : bench.overallScore >= 90
230
- ? "success"
231
- : bench.overallScore >= 75
232
- ? "warning"
233
- : "error";
234
- out += theme.fg("muted", " · ⚡") + theme.fg(scoreColor, `${score}`);
235
- }
236
- return out;
237
- }
238
-
239
- /** Strip ANSI to inspect raw text, keep original colored string for output. */
240
- const stripAnsi = (s: string): string => s.replace(/\x1b\[[0-9;]*m/g, "");
241
-
242
- /** Replace verbose status text with icon + value. */
243
- function compactStatus(key: string, value: string, theme: Theme): string {
244
- const raw = stripAnsi(value);
245
- switch (key) {
246
- case "pi-lens-lsp": {
247
- const m = raw.match(/LSP Active \((\d+)\)/);
248
- if (m) return theme.fg("success", `󰘦 ${m[1]}`);
249
- if (/LSP Inactive/.test(raw)) return theme.fg("error", "󰘦 off");
250
- return value;
251
- }
252
- case "mcp": {
253
- const m = raw.match(/(\d+)\/(\d+)\s+servers/);
254
- if (m) return theme.fg("muted", `󰒍 ${m[1]}/${m[2]}`);
255
- return value;
256
- }
257
- case "caveman": {
258
- const m = raw.match(/caveman level:\s*(\S+)/);
259
- if (m) return theme.fg("muted", `🪨 ${m[1]}`);
260
- return value;
261
- }
262
- default:
263
- return value;
264
- }
265
- }
266
-
267
- /** Pull mode out of extension statuses; return (modePart, otherParts joined). */
268
- function renderStatuses(
269
- statuses: ReadonlyMap<string, string>,
270
- sep: string,
271
- theme: Theme,
272
- ): { modePart: string; otherPart: string } {
273
- const mode = statuses.get("plan") ?? statuses.get("phase");
274
- const ORDER = ["mcp", "pi-lens-lsp", "caveman"];
275
- const seen = new Set<string>(["plan", "phase"]);
276
- const others: string[] = [];
277
- for (const k of ORDER) {
278
- const v = statuses.get(k);
279
- seen.add(k);
280
- if (!v) continue;
281
- others.push(compactStatus(k, v, theme));
282
- }
283
- for (const [k, v] of statuses) {
284
- if (seen.has(k) || !v) continue;
285
- others.push(compactStatus(k, v, theme));
286
- }
287
- return {
288
- modePart: mode ? `${mode}${sep}` : "",
289
- otherPart: others.length ? sep + others.join(sep) : "",
290
- };
291
- }
292
-
293
- // ─── Extension ────────────────────────────────────────────────────────
294
-
295
- export default function (pi: ExtensionAPI) {
296
- let liveTps: string | null = null;
297
- let tpsTimer: ReturnType<typeof setTimeout> | null = null;
298
- // Token visibility state machine: "on" → (4s) → "dim" → (4s) → "off".
299
- type TokensState = "on" | "dim" | "off";
300
- let tokensState: TokensState = "off";
301
- let tokensTimer: ReturnType<typeof setTimeout> | null = null;
302
- let requestRender: (() => void) | null = null;
303
-
304
- const clearTimer = (t: ReturnType<typeof setTimeout> | null) => {
305
- if (t) clearTimeout(t);
306
- };
307
-
308
- let gitStatus: GitStatus | null = null;
309
- let gitTimer: ReturnType<typeof setInterval> | null = null;
310
-
311
- // ── TPS tracking ──────────────────────────────────────────────
312
-
313
- interface StreamState {
314
- start: number;
315
- output: number;
316
- }
317
- const activeStreams = new Map<string, StreamState>();
318
- let tpsTicker: ReturnType<typeof setInterval> | null = null;
319
-
320
- const recomputeTps = () => {
321
- let total = 0;
322
- let earliest = Infinity;
323
- for (const s of activeStreams.values()) {
324
- total += s.output;
325
- if (s.start < earliest) earliest = s.start;
326
- }
327
- if (total <= 0 || earliest === Infinity) return;
328
- const elapsed = (Date.now() - earliest) / 1000;
329
- if (elapsed < 0.1) return;
330
- const next = `${Math.round(total / elapsed)} t/s`;
331
- if (next !== liveTps) {
332
- liveTps = next;
333
- requestRender?.();
334
- }
335
- };
336
-
337
- const startTpsTicker = () => {
338
- if (!tpsTicker) tpsTicker = setInterval(recomputeTps, 100);
339
- };
340
- const stopTpsTicker = () => {
341
- if (tpsTicker) {
342
- clearInterval(tpsTicker);
343
- tpsTicker = null;
344
- }
345
- };
346
-
347
- // AssistantMessage.id is not in the published d.ts but exists at runtime;
348
- // upstream type bug, hence the casts in this section.
349
- pi.on("message_start", async (event) => {
350
- if (event.message.role !== "assistant") return;
351
- activeStreams.set((event.message as unknown as { id: string }).id, {
352
- start: Date.now(),
353
- output: 0,
354
- });
355
- startTpsTicker();
356
- clearTimer(tokensTimer);
357
- tokensTimer = null;
358
- if (tokensState !== "on") {
359
- tokensState = "on";
360
- requestRender?.();
361
- }
362
- });
363
-
364
- pi.on("message_update", async (event) => {
365
- if (event.message.role !== "assistant") return;
366
- const id = (event.message as unknown as { id: string }).id;
367
- const s = activeStreams.get(id);
368
- if (!s) return;
369
- const ame = event.assistantMessageEvent as AssistantMessageEvent & {
370
- partial?: { usage?: { output?: number } };
371
- };
372
- const out =
373
- ame.partial?.usage?.output ??
374
- (event.message as unknown as { usage?: { output?: number } }).usage
375
- ?.output ??
376
- 0;
377
- if (out > s.output) s.output = out;
378
- });
379
-
380
- pi.on("message_end", async (event) => {
381
- if (event.message.role !== "assistant") return;
382
- const id = (event.message as unknown as { id: string }).id;
383
- const s = activeStreams.get(id);
384
- const finalOut =
385
- (event.message as unknown as { usage?: { output?: number } }).usage
386
- ?.output ?? 0;
387
- if (s && finalOut > s.output) s.output = finalOut;
388
- recomputeTps();
389
- activeStreams.delete(id);
390
- if (activeStreams.size === 0) stopTpsTicker();
391
- });
392
-
393
- const scheduleTpsClear = () => {
394
- if (tpsTimer) clearTimeout(tpsTimer);
395
- tpsTimer = setTimeout(() => {
396
- liveTps = null;
397
- tpsTimer = null;
398
- requestRender?.();
399
- }, 4_000);
400
- };
401
-
402
- const scheduleTokensDecay = () => {
403
- clearTimer(tokensTimer);
404
- tokensTimer = setTimeout(() => {
405
- tokensState = "dim";
406
- requestRender?.();
407
- tokensTimer = setTimeout(() => {
408
- tokensState = "off";
409
- tokensTimer = null;
410
- requestRender?.();
411
- }, 4_000);
412
- }, 4_000);
413
- };
414
-
415
- pi.on("agent_end", () => {
416
- stopTpsTicker();
417
- activeStreams.clear();
418
- scheduleTpsClear();
419
- scheduleTokensDecay();
420
- });
421
-
422
- // ── Git status polling ───────────────────────────────────────
423
-
424
- const refreshGit = async (cwd: string) => {
425
- const next = await getGitStatus(cwd);
426
- const changed = JSON.stringify(next) !== JSON.stringify(gitStatus);
427
- gitStatus = next;
428
- if (changed) requestRender?.();
429
- };
430
-
431
- pi.on("tool_execution_end", async (_event, ctx) => {
432
- await refreshGit(ctx.cwd);
433
- });
434
-
435
- // ── Footer registration ──────────────────────────────────────
436
-
437
- pi.on("session_start", (_event, ctx) => {
438
- void refreshGit(ctx.cwd);
439
- if (gitTimer) clearInterval(gitTimer);
440
- gitTimer = setInterval(() => {
441
- void refreshGit(ctx.cwd);
442
- }, GIT_POLL_MS);
443
-
444
- ctx.ui.setFooter(
445
- (tui, theme: Theme, footerData: ReadonlyFooterDataProvider) => {
446
- requestRender = () => tui.requestRender();
447
- const unsub = footerData.onBranchChange(() => {
448
- void refreshGit(ctx.cwd);
449
- tui.requestRender();
450
- });
451
-
452
- return {
453
- dispose() {
454
- unsub();
455
- requestRender = null;
456
- },
457
- invalidate() {},
458
- render(width: number): string[] {
459
- const sep = theme.fg("muted", " | ");
460
-
461
- const totals = computeSessionTotals(ctx.sessionManager.getBranch());
462
- const tokens =
463
- tokensState === "off"
464
- ? ""
465
- : renderTokens(totals, theme, tokensState === "dim");
466
- const ctxUsage = renderCtxUsage(ctx.getContextUsage?.(), theme);
467
- const model = renderModel(
468
- ctx.model,
469
- pi.getThinkingLevel?.() ?? "",
470
- theme,
471
- );
472
- const { branchSeg, markersSeg } = renderBranch(
473
- footerData.getGitBranch(),
474
- gitStatus,
475
- theme,
476
- );
477
- const { modePart, otherPart } = renderStatuses(
478
- footerData.getExtensionStatuses(),
479
- sep,
480
- theme,
481
- );
482
-
483
- const loc =
484
- theme.fg("muted", "󰉋 ") +
485
- theme.fg("accent", shortCwd(ctx.cwd)) +
486
- branchSeg;
487
- const markersPart = markersSeg ? sep + markersSeg : "";
488
- const tpsPart = liveTps ? sep + theme.fg("accent", liveTps) : "";
489
-
490
- const tokensPart = tokens ? sep + tokens : "";
491
- const ctxPart = ctxUsage ? sep + ctxUsage : "";
492
- const line = `${modePart}${loc}${markersPart}${ctxPart}${sep}${model}${otherPart}${tokensPart}${tpsPart}`;
493
- return [truncateToWidth(line, width)];
494
- },
495
- };
496
- },
497
- );
498
- });
499
-
500
- pi.on("session_shutdown", () => {
501
- if (gitTimer) {
502
- clearInterval(gitTimer);
503
- gitTimer = null;
504
- }
505
- if (tpsTimer) {
506
- clearTimeout(tpsTimer);
507
- tpsTimer = null;
508
- }
509
- clearTimer(tokensTimer);
510
- tokensTimer = null;
511
- stopTpsTicker();
512
- });
513
- }
@@ -1,124 +0,0 @@
1
- import { describe, expect, it } from "bun:test";
2
- import {
3
- type CheckResult,
4
- LABEL_WIDTH,
5
- LOGO_ROWS,
6
- PI_IGNORE_RULES,
7
- renderCheck,
8
- shortCwd,
9
- statusIcon,
10
- summariseTools,
11
- type Theme,
12
- } from "./welcome.ts";
13
-
14
- const theme: Theme = {
15
- fg: (_color, text) => text,
16
- bold: (text) => text,
17
- };
18
-
19
- describe("shortCwd", () => {
20
- it("replaces home prefix with ~", () => {
21
- expect(shortCwd("/home/user/projects/foo", "/home/user")).toBe(
22
- "~/projects/foo",
23
- );
24
- });
25
-
26
- it("returns path unchanged when not under home", () => {
27
- expect(shortCwd("/tmp/foo", "/home/user")).toBe("/tmp/foo");
28
- });
29
-
30
- it("handles exact home match", () => {
31
- expect(shortCwd("/home/user", "/home/user")).toBe("~");
32
- });
33
-
34
- it("uses empty string home gracefully", () => {
35
- expect(shortCwd("/tmp/foo", "")).toBe("/tmp/foo");
36
- });
37
- });
38
-
39
- describe("statusIcon", () => {
40
- it("returns ○ for pending", () => {
41
- expect(statusIcon(theme, "pending")).toContain("○");
42
- });
43
- it("returns ✓ for ok", () => {
44
- expect(statusIcon(theme, "ok")).toContain("✓");
45
- });
46
- it("returns ⚠ for warn", () => {
47
- expect(statusIcon(theme, "warn")).toContain("⚠");
48
- });
49
- it("returns ✗ for error", () => {
50
- expect(statusIcon(theme, "error")).toContain("✗");
51
- });
52
- });
53
-
54
- describe("renderCheck", () => {
55
- it("includes icon, label, and detail", () => {
56
- const check: CheckResult = { label: "PIx", status: "ok", detail: "0.76.0" };
57
- const rendered = renderCheck(theme, check);
58
- expect(rendered).toContain("✓");
59
- expect(rendered).toContain("PIx");
60
- expect(rendered).toContain("0.76.0");
61
- });
62
-
63
- it("pads label to LABEL_WIDTH", () => {
64
- const check: CheckResult = { label: "PIx", status: "ok", detail: "x" };
65
- const rendered = renderCheck(theme, check);
66
- expect(rendered).toContain("PIx".padEnd(LABEL_WIDTH));
67
- });
68
-
69
- it("renders without detail when absent", () => {
70
- const check: CheckResult = { label: "Auth", status: "warn" };
71
- const rendered = renderCheck(theme, check);
72
- expect(rendered).toContain("⚠");
73
- expect(rendered).toContain("Auth");
74
- });
75
- });
76
-
77
- describe("LOGO_ROWS", () => {
78
- it("has 6 rows", () => expect(LOGO_ROWS.length).toBe(6));
79
- it("starts with empty tag", () => expect(LOGO_ROWS[0][1]).toBe(""));
80
- it("has heading, model, cwd, ready tags", () => {
81
- const tags = LOGO_ROWS.map((r) => r[1]);
82
- expect(tags).toContain("heading");
83
- expect(tags).toContain("model");
84
- expect(tags).toContain("cwd");
85
- expect(tags).toContain("ready");
86
- });
87
- });
88
-
89
- describe("summariseTools", () => {
90
- it("warns when no tools are active", () => {
91
- const r = summariseTools([]);
92
- expect(r.status).toBe("warn");
93
- expect(r.detail).toBe("none active");
94
- });
95
-
96
- it("counts builtin-only tools without ext suffix", () => {
97
- const r = summariseTools([
98
- { sourceInfo: { source: "builtin" } },
99
- { sourceInfo: { source: "builtin" } },
100
- ]);
101
- expect(r.status).toBe("ok");
102
- expect(r.detail).toBe("2 loaded");
103
- });
104
-
105
- it("breaks out extension tools in the detail", () => {
106
- const r = summariseTools([
107
- { sourceInfo: { source: "builtin" } },
108
- { sourceInfo: { source: "my-extension" } },
109
- { sourceInfo: { source: "sdk" } },
110
- ]);
111
- expect(r.detail).toBe("3 loaded (+2 ext)");
112
- });
113
-
114
- it("treats missing sourceInfo as non-builtin", () => {
115
- const r = summariseTools([{}, { sourceInfo: {} }]);
116
- expect(r.detail).toBe("2 loaded (+2 ext)");
117
- });
118
- });
119
-
120
- describe("PI_IGNORE_RULES", () => {
121
- it("includes both rules", () => {
122
- expect(PI_IGNORE_RULES).toEqual([".ai/", ".pi-lens/"]);
123
- });
124
- });