@xynogen/pix-core 0.2.4 → 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.
Files changed (37) hide show
  1. package/package.json +11 -17
  2. package/skills/ask-user/SKILL.md +0 -48
  3. package/src/commands/agent-sop/agent-sop.ts +0 -58
  4. package/src/commands/clear/clear.ts +0 -32
  5. package/src/commands/diff/diff.ts +0 -32
  6. package/src/commands/models/models.test.ts +0 -95
  7. package/src/commands/models/models.ts +0 -367
  8. package/src/commands/models/patch-builtin.test.ts +0 -66
  9. package/src/commands/models/patch-builtin.ts +0 -120
  10. package/src/commands/tools.test.ts +0 -15
  11. package/src/commands/update/update.test.ts +0 -112
  12. package/src/commands/update/update.ts +0 -271
  13. package/src/index.ts +0 -45
  14. package/src/lib/data.ts +0 -33
  15. package/src/nudge/capability.test.ts +0 -258
  16. package/src/nudge/capability.ts +0 -189
  17. package/src/nudge/index.ts +0 -17
  18. package/src/nudge/tools.test.ts +0 -157
  19. package/src/nudge/tools.ts +0 -212
  20. package/src/tool/ask/ask.test.ts +0 -243
  21. package/src/tool/ask/components.ts +0 -55
  22. package/src/tool/ask/helpers.ts +0 -77
  23. package/src/tool/ask/index.ts +0 -130
  24. package/src/tool/ask/questionnaire.ts +0 -693
  25. package/src/tool/ask/rpc.ts +0 -84
  26. package/src/tool/ask/schema.ts +0 -69
  27. package/src/tool/ask/single-select-layout.test.ts +0 -124
  28. package/src/tool/ask/single-select-layout.ts +0 -237
  29. package/src/tool/ask/types.ts +0 -17
  30. package/src/tool/todo/todo.test.ts +0 -646
  31. package/src/tool/todo/todo.ts +0 -218
  32. package/src/tool/toolbox/toolbox.test.ts +0 -314
  33. package/src/tool/toolbox/toolbox.ts +0 -570
  34. package/src/ui/diagnostics.ts +0 -145
  35. package/src/ui/footer.ts +0 -513
  36. package/src/ui/welcome.test.ts +0 -124
  37. 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
- });