@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
@@ -1,570 +0,0 @@
1
- /**
2
- * toolbox.ts — /toolbox command for user-driven tool gating
3
- *
4
- * Registers a `/toolbox` slash command that opens a TUI picker listing every
5
- * registered tool (built-in and MCP). The user can toggle tools on/off —
6
- * this controls which tools are described in the system prompt via
7
- * pi.setActiveTools(). All tools remain callable via function definitions
8
- * regardless of prompt visibility.
9
- *
10
- * Also supports headless usage:
11
- * /toolbox enable <names> — enable tool(s) by name
12
- * /toolbox disable <names> — disable tool(s) by name
13
- * /toolbox list [query] — text search (no picker)
14
- */
15
-
16
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
17
- import { dirname, join } from "node:path";
18
- import type {
19
- ExtensionAPI,
20
- ExtensionContext,
21
- Theme,
22
- ToolInfo,
23
- } from "@earendil-works/pi-coding-agent";
24
- import { DynamicBorder, getAgentDir } from "@earendil-works/pi-coding-agent";
25
- import {
26
- Container,
27
- decodeKittyPrintable,
28
- fuzzyFilter,
29
- Input,
30
- Key,
31
- type KeybindingsManager,
32
- matchesKey,
33
- type SelectItem,
34
- SelectList,
35
- Text,
36
- type TUI,
37
- visibleWidth,
38
- } from "@earendil-works/pi-tui";
39
-
40
- // ─── Constants ──────────────────────────────────────────────────────────────
41
-
42
- /** Tools that can never be disabled — always prompt-visible. */
43
- export const CORE_TOOLS: ReadonlySet<string> = new Set([
44
- "bash",
45
- "edit",
46
- "read",
47
- "write",
48
- ]);
49
-
50
- // ─── Types ──────────────────────────────────────────────────────────────────
51
-
52
- export interface ToolRow {
53
- name: string;
54
- description: string;
55
- mcp: boolean;
56
- source?: string;
57
- }
58
-
59
- /** Callbacks for toggleTool / renderList — test seam. */
60
- export interface ToggleOps {
61
- isActive: (name: string) => boolean;
62
- onActivate: (name: string) => boolean;
63
- onDeactivate: (name: string) => boolean;
64
- }
65
-
66
- // ─── Helpers ────────────────────────────────────────────────────────────────
67
-
68
- function isMcpTool(info: ToolInfo): boolean {
69
- return /mcp/i.test(info.sourceInfo?.source ?? "");
70
- }
71
-
72
- export function buildRows(tools: ToolInfo[]): ToolRow[] {
73
- return tools
74
- .filter((t) => !CORE_TOOLS.has(t.name))
75
- .map((t) => ({
76
- name: t.name,
77
- description: firstSentence(t.description ?? ""),
78
- mcp: isMcpTool(t),
79
- source: t.sourceInfo?.source,
80
- }))
81
- .sort((a, b) => a.name.localeCompare(b.name));
82
- }
83
-
84
- const firstSentence = (desc: string): string => {
85
- const clean = (desc ?? "").replace(/\s+/g, " ").trim();
86
- const m = clean.match(/^.*?[.!?](?=\s|$)/);
87
- return (m ? m[0] : clean).slice(0, 120);
88
- };
89
-
90
- export function parseTargets(raw: string): string[] {
91
- const seen = new Set<string>();
92
- const out: string[] = [];
93
- for (const t of raw.split(/[\s,]+/)) {
94
- const name = t.trim();
95
- if (!name || seen.has(name)) continue;
96
- seen.add(name);
97
- out.push(name);
98
- }
99
- return out;
100
- }
101
-
102
- export function renderList(
103
- rows: ToolRow[],
104
- isActive: (name: string) => boolean,
105
- query?: string,
106
- ): string {
107
- const filtered = query
108
- ? rows.filter(
109
- (r) =>
110
- r.name.toLowerCase().includes(query.toLowerCase()) ||
111
- r.description.toLowerCase().includes(query.toLowerCase()),
112
- )
113
- : rows;
114
-
115
- if (!filtered.length) {
116
- return query ? `No tools matched "${query}".` : "No tools registered.";
117
- }
118
-
119
- const lines = filtered.map((r) => {
120
- const status = isActive(r.name) ? "✓ active" : "# gated";
121
- const kind = r.mcp ? "MCP" : "tool";
122
- return `${status} ${r.name} [${kind}] ${r.description}`;
123
- });
124
- return lines.join("\n");
125
- }
126
-
127
- export function toggleTool(
128
- action: "enable" | "disable",
129
- name: string,
130
- rows: ToolRow[],
131
- ops: ToggleOps,
132
- ): string {
133
- const row = rows.find((r) => r.name === name);
134
- if (!row) return `Unknown tool "${name}".`;
135
-
136
- if (action === "enable") {
137
- const did = ops.onActivate(name);
138
- return did
139
- ? `Enabled ${name} — now prompt-visible.`
140
- : `${name} is already active.`;
141
- }
142
- const did = ops.onDeactivate(name);
143
- return did
144
- ? `Disabled ${name} — hidden from prompt.`
145
- : `${name} is already gated.`;
146
- }
147
-
148
- // ─── Persistence ───────────────────────────────────────────────────────────
149
-
150
- interface ToolboxState {
151
- enabledTools: string[];
152
- }
153
-
154
- function getStatePath(): string {
155
- return join(getAgentDir(), "toolbox.json");
156
- }
157
-
158
- // ─── State ──────────────────────────────────────────────────────────────────
159
-
160
- function createState(pi: ExtensionAPI) {
161
- let enabledTools = new Set<string>();
162
- let initialized = false;
163
-
164
- function persist(): void {
165
- // Write to session so state survives branch navigation within a session
166
- try {
167
- pi.appendEntry<ToolboxState>("toolbox-config", {
168
- enabledTools: [...enabledTools],
169
- });
170
- } catch (err) {
171
- console.warn("toolbox: persist failed:", err);
172
- }
173
- // Write to disk so state survives across completely new sessions
174
- try {
175
- const sp = getStatePath();
176
- mkdirSync(dirname(sp), { recursive: true });
177
- writeFileSync(
178
- sp,
179
- JSON.stringify({ enabledTools: [...enabledTools] }, null, 2),
180
- "utf-8",
181
- );
182
- } catch (err) {
183
- console.warn("toolbox: file persist failed:", err);
184
- }
185
- }
186
-
187
- /** Load previously persisted enabled tool names from disk. */
188
- function loadFromFile(): string[] | undefined {
189
- try {
190
- const sp = getStatePath();
191
- if (!existsSync(sp)) return undefined;
192
- const raw = JSON.parse(readFileSync(sp, "utf-8")) as ToolboxState;
193
- if (Array.isArray(raw?.enabledTools)) return raw.enabledTools;
194
- } catch {
195
- // File doesn't exist, is corrupt, or we're in a test env without getAgentDir
196
- }
197
- return undefined;
198
- }
199
-
200
- function restoreFromBranch(ctx: ExtensionContext): void {
201
- // Prefer file-based persistence (survives across sessions).
202
- // Fall back to session entries (survives branch navigation within a session).
203
- // Fall back to full enable (first run).
204
- const fileSaved = loadFromFile();
205
- if (fileSaved) {
206
- const validNames = new Set((pi.getAllTools() ?? []).map((t) => t.name));
207
- enabledTools = new Set(
208
- fileSaved.filter((n) => validNames.has(n) || CORE_TOOLS.has(n)),
209
- );
210
- for (const ct of CORE_TOOLS) enabledTools.add(ct);
211
- initialized = true;
212
- apply();
213
- return;
214
- }
215
-
216
- // Fall back to plain init if sessionManager is unavailable (e.g. tests / headless)
217
- if (!ctx?.sessionManager) {
218
- ensureInit();
219
- return;
220
- }
221
-
222
- // getEntries() returns ALL entries in the session file — unlike getBranch()
223
- // which only walks ancestors. Custom entries appended via appendCustomEntry
224
- // are children of the leaf, not ancestors.
225
- const allEntries = ctx.sessionManager.getEntries();
226
- let saved: string[] | undefined;
227
-
228
- for (const entry of allEntries) {
229
- if (entry.type === "custom" && entry.customType === "toolbox-config") {
230
- const data = entry.data as ToolboxState | undefined;
231
- if (data?.enabledTools) saved = data.enabledTools;
232
- }
233
- }
234
-
235
- if (saved) {
236
- const validNames = new Set((pi.getAllTools() ?? []).map((t) => t.name));
237
- enabledTools = new Set(
238
- saved.filter((n) => validNames.has(n) || CORE_TOOLS.has(n)),
239
- );
240
- for (const ct of CORE_TOOLS) enabledTools.add(ct);
241
- } else {
242
- const names = (pi.getAllTools() ?? []).map((t) => t.name);
243
- enabledTools = new Set(names);
244
- }
245
- initialized = true;
246
- apply();
247
- // Persist to disk on first init so future sessions pick it up
248
- persist();
249
- }
250
-
251
- function ensureInit(): void {
252
- if (initialized) return;
253
- let names: string[] = [];
254
- try {
255
- names = (pi.getAllTools() ?? []).map((t) => t.name);
256
- } catch (err) {
257
- console.warn("toolbox: getAllTools failed:", err);
258
- }
259
- if (!names.length) return;
260
- enabledTools = new Set(names);
261
- initialized = true;
262
- apply();
263
- persist();
264
- }
265
-
266
- function apply(): void {
267
- if (!initialized) return;
268
- try {
269
- pi.setActiveTools([...enabledTools]);
270
- } catch (err) {
271
- console.warn("toolbox: setActiveTools failed:", err);
272
- }
273
- }
274
-
275
- function isActive(name: string): boolean {
276
- return enabledTools.has(name);
277
- }
278
-
279
- function onActivate(name: string): boolean {
280
- if (!initialized) return false;
281
- if (enabledTools.has(name)) return false;
282
- enabledTools.add(name);
283
- apply();
284
- persist();
285
- return true;
286
- }
287
-
288
- function onDeactivate(name: string): boolean {
289
- if (!initialized) return false;
290
- if (CORE_TOOLS.has(name)) return false;
291
- const did = enabledTools.delete(name);
292
- if (did) {
293
- apply();
294
- persist();
295
- }
296
- return did;
297
- }
298
-
299
- return {
300
- ensureInit,
301
- restoreFromBranch,
302
- isActive,
303
- onActivate,
304
- onDeactivate,
305
- };
306
- }
307
-
308
- // ─── Registration ───────────────────────────────────────────────────────────
309
-
310
- export default function registerToolbox(pi: ExtensionAPI): void {
311
- const state = createState(pi);
312
-
313
- // Defer init until tools are registered — session_start fires after all extensions load.
314
- // Try to restore persisted state; fall back to full init if no config found.
315
- pi.on("session_start", async (_event, ctx) => {
316
- state.restoreFromBranch(ctx);
317
- });
318
-
319
- // Re-restore when navigating branch history
320
- pi.on("session_tree", async (_event, ctx) => {
321
- state.restoreFromBranch(ctx);
322
- });
323
-
324
- function getRows(): ToolRow[] {
325
- try {
326
- return buildRows(pi.getAllTools() ?? []);
327
- } catch {
328
- return [];
329
- }
330
- }
331
-
332
- const ops: ToggleOps = {
333
- isActive: state.isActive,
334
- onActivate: state.onActivate,
335
- onDeactivate: state.onDeactivate,
336
- };
337
-
338
- async function showPicker(ctx: {
339
- ui: {
340
- custom: <T>(f: unknown) => Promise<T>;
341
- notify: (m: string, t?: "info" | "warning" | "error") => void;
342
- };
343
- }): Promise<void> {
344
- await ctx.ui.custom<null>(
345
- (
346
- tui: TUI,
347
- theme: Theme,
348
- _kb: KeybindingsManager,
349
- done: (r: null) => void,
350
- ) => {
351
- const accent = "accent";
352
- const mute = (s: string) => theme.fg("muted", s);
353
- const container = new Container();
354
-
355
- type RowState = "active" | "gated";
356
- const stateOf = (name: string): RowState =>
357
- ops.isActive(name) ? "active" : "gated";
358
-
359
- const labelFor = (r: ToolRow): string => {
360
- const active = stateOf(r.name) === "active";
361
- const marker = active ? " " : theme.fg("warning", "#");
362
- const name = active
363
- ? theme.fg("success", r.name)
364
- : theme.fg("dim", r.name);
365
- const kind = mute(`[${r.mcp ? "MCP" : "tool"}]`);
366
- return `${marker} ${name} ${kind}`;
367
- };
368
-
369
- const descFor = (r: ToolRow): string => {
370
- const active = stateOf(r.name) === "active";
371
- const tag = active
372
- ? theme.fg("success", "active")
373
- : theme.fg("warning", "gated");
374
- return `${tag} ${mute("·")} ${r.description || "(no description)"}`;
375
- };
376
-
377
- const rows = getRows();
378
- const byValue = new Map<string, ToolRow>();
379
- const toItem = (r: ToolRow): SelectItem => {
380
- byValue.set(r.name, r);
381
- return {
382
- value: r.name,
383
- label: labelFor(r),
384
- description: descFor(r),
385
- };
386
- };
387
-
388
- const allItems = rows.map(toItem);
389
- const widest = allItems.reduce(
390
- (w, it) => Math.max(w, visibleWidth(it.label)),
391
- 0,
392
- );
393
-
394
- const list = new SelectList(
395
- allItems,
396
- Math.min(allItems.length, 14),
397
- {
398
- selectedPrefix: (t: string) => theme.fg(accent, t),
399
- selectedText: (t: string) => theme.fg(accent, t),
400
- description: (t: string) => t,
401
- scrollInfo: (t: string) => theme.fg("dim", t),
402
- noMatch: (t: string) => theme.fg("warning", t),
403
- },
404
- {
405
- minPrimaryColumnWidth: widest + 2,
406
- maxPrimaryColumnWidth: widest + 2,
407
- },
408
- );
409
-
410
- const internal = list as unknown as {
411
- items: SelectItem[];
412
- filteredItems: SelectItem[];
413
- selectedIndex: number;
414
- };
415
-
416
- const search = new Input();
417
- const statusLine = new Text("");
418
-
419
- const refreshLabels = () => {
420
- for (const it of internal.items) {
421
- const r = byValue.get(it.value);
422
- if (!r) continue;
423
- it.label = labelFor(r);
424
- it.description = descFor(r);
425
- }
426
- list.invalidate();
427
- container.invalidate();
428
- tui.requestRender?.();
429
- };
430
-
431
- const doToggle = (action: "enable" | "disable") => {
432
- const sel = list.getSelectedItem();
433
- if (!sel) return;
434
- const msg = toggleTool(action, sel.value, rows, ops);
435
- statusLine.setText(theme.fg("muted", msg));
436
- refreshLabels();
437
- };
438
-
439
- const flipSelected = () => {
440
- const sel = list.getSelectedItem();
441
- if (!sel) return;
442
- if (stateOf(sel.value) === "active") doToggle("disable");
443
- else doToggle("enable");
444
- };
445
-
446
- const applyFilter = (q: string) => {
447
- const query = q.trim();
448
- internal.filteredItems =
449
- query.length === 0
450
- ? internal.items
451
- : fuzzyFilter(
452
- internal.items,
453
- query,
454
- (it: SelectItem) => `${it.value} ${it.description ?? ""}`,
455
- );
456
- internal.selectedIndex = 0;
457
- list.invalidate();
458
- container.invalidate();
459
- };
460
-
461
- list.onSelect = () => done(null);
462
- list.onCancel = () => done(null);
463
- search.onEscape = () => done(null);
464
-
465
- container.addChild(
466
- new DynamicBorder((s: string) => theme.fg(accent, s)),
467
- );
468
- container.addChild(
469
- new Text(theme.fg(accent, theme.bold("🧰 Toolbox"))),
470
- );
471
- container.addChild(new Text(theme.fg("muted", "Search:")));
472
- container.addChild(search);
473
- container.addChild(list);
474
- container.addChild(statusLine);
475
- container.addChild(
476
- new Text(
477
- theme.fg(
478
- "dim",
479
- "↑↓ navigate · e enable · d disable · space toggle · esc close",
480
- ),
481
- ),
482
- );
483
- container.addChild(
484
- new DynamicBorder((s: string) => theme.fg(accent, s)),
485
- );
486
-
487
- return {
488
- render(w: number) {
489
- return container.render(w);
490
- },
491
- invalidate() {
492
- container.invalidate();
493
- },
494
- handleInput(data: string) {
495
- if (matchesKey(data, Key.up) || matchesKey(data, Key.down)) {
496
- list.handleInput?.(data);
497
- } else if (
498
- matchesKey(data, Key.enter) ||
499
- matchesKey(data, Key.escape)
500
- ) {
501
- done(null);
502
- return;
503
- } else if (
504
- matchesKey(data, Key.space) ||
505
- matchesKey(data, Key.tab)
506
- ) {
507
- flipSelected();
508
- } else {
509
- const printable = decodeKittyPrintable(data);
510
- if (printable !== undefined) {
511
- if (printable === "e") {
512
- doToggle("enable");
513
- } else if (printable === "d") {
514
- doToggle("disable");
515
- } else {
516
- search.handleInput?.(data);
517
- applyFilter(search.getValue?.() ?? "");
518
- }
519
- } else {
520
- search.handleInput?.(data);
521
- applyFilter(search.getValue?.() ?? "");
522
- }
523
- }
524
- container.invalidate();
525
- tui.requestRender?.();
526
- },
527
- };
528
- },
529
- );
530
- }
531
-
532
- pi.registerCommand("toolbox", {
533
- description:
534
- "Toggle tools on/off. ↑↓ navigate, e/d enable/disable, space toggle. " +
535
- "Headless: /toolbox enable|disable <names>, /toolbox list [query]",
536
- handler: async (args, ctx) => {
537
- const raw = (args ?? "").trim();
538
- const verb = raw.split(/\s+/, 1)[0]?.toLowerCase();
539
-
540
- if (verb === "enable" || verb === "disable") {
541
- const targets = parseTargets(raw.slice(verb.length).trim());
542
- if (!targets.length) {
543
- ctx.ui.notify(
544
- `/toolbox ${verb} needs a tool name, e.g. /toolbox ${verb} grep`,
545
- "warning",
546
- );
547
- return;
548
- }
549
- const rows = getRows();
550
- const msg = targets
551
- .map((t) => toggleTool(verb, t, rows, ops))
552
- .join("\n");
553
- ctx.ui.notify(msg, "info");
554
- return;
555
- }
556
-
557
- if (verb === "list") {
558
- const query = raw.slice(verb.length).trim() || undefined;
559
- ctx.ui.notify(renderList(getRows(), ops.isActive, query), "info");
560
- return;
561
- }
562
-
563
- if (typeof ctx.ui.custom === "function") {
564
- await showPicker(ctx as unknown as Parameters<typeof showPicker>[0]);
565
- } else {
566
- ctx.ui.notify(renderList(getRows(), ops.isActive), "info");
567
- }
568
- },
569
- });
570
- }
@@ -1,145 +0,0 @@
1
- /**
2
- * diagnostics.ts — Lightweight diagnostic reporter (pi-lens replacement)
3
- *
4
- * Renders LSP diagnostics from recently touched files with a collapsed view:
5
- * - Header shows total counts across all files (●4E !1W)
6
- * - Body shows top 3 diagnostics only
7
- * - "+N more" line if diagnostics exceed 3
8
- *
9
- * Registers widget with id "pi-lens" to override external pi-lens widget.
10
- */
11
-
12
- import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
13
- import { truncateToWidth } from "@earendil-works/pi-tui";
14
-
15
- // ─── Types ────────────────────────────────────────────────────────────────────
16
-
17
- interface Diagnostic {
18
- severity: "error" | "warning" | "information" | "hint";
19
- message: string;
20
- line?: number;
21
- col?: number;
22
- source?: string;
23
- code?: string | number;
24
- uri?: string;
25
- }
26
-
27
- interface FileRecord {
28
- filePath: string;
29
- diagnostics: Diagnostic[];
30
- touchedAt: number;
31
- }
32
-
33
- // ─── Module state ─────────────────────────────────────────────────────────────
34
-
35
- const files = new Map<string, FileRecord>();
36
- let requestRenderFn: (() => void) | null = null;
37
-
38
- // ─── Public API ───────────────────────────────────────────────────────────────
39
-
40
- export function clearDiagnosticState(): void {
41
- files.clear();
42
- }
43
-
44
- function requestRender(): void {
45
- requestRenderFn?.();
46
- }
47
-
48
- // ─── Diagnostic collection ────────────────────────────────────────────────────
49
-
50
- /**
51
- * Track that a file was touched. In this simplified version, we don't query
52
- * LSP diagnostics directly (that requires a full LSP client). Instead, we
53
- * register the file and show a placeholder/summary. Future enhancement: hook
54
- * into pi-lens's diagnostic events or build LSP integration.
55
- */
56
- function recordFileTouched(filePath: string): void {
57
- const rec: FileRecord = {
58
- filePath,
59
- diagnostics: [], // Empty for now - we'd populate from LSP in full version
60
- touchedAt: Date.now(),
61
- };
62
- files.set(filePath, rec);
63
- requestRender();
64
- }
65
-
66
- // ─── Render ───────────────────────────────────────────────────────────────────
67
-
68
- function renderWidget(width: number, theme: Theme): string[] {
69
- const w = Math.max(1, width || 80);
70
-
71
- const cyan = (s: string) => theme.fg("accent", s);
72
- const dim = (s: string) => theme.fg("muted", s);
73
- const green = (s: string) => theme.fg("success", s);
74
-
75
- const lines: string[] = [];
76
-
77
- // Show a compact summary. This widget overrides pi-lens's verbose output.
78
- // For detailed diagnostics, users can run /lens-booboo or /lsp-diagnostics.
79
- const filesCount = files.size;
80
-
81
- if (filesCount === 0) {
82
- // No files touched yet this session
83
- return [];
84
- }
85
-
86
- const recentFiles = [...files.values()]
87
- .sort((a, b) => b.touchedAt - a.touchedAt)
88
- .slice(0, 3)
89
- .map((f) => f.filePath.split("/").pop() ?? f.filePath);
90
-
91
- const filesList = recentFiles.join(", ");
92
- const summary =
93
- filesCount <= 3
94
- ? `${green("✓")} ${filesList}`
95
- : `${green("✓")} ${filesList} +${filesCount - 3} more`;
96
-
97
- const header = ` ${cyan("pix-lens")} ${summary} ${dim("(/lens-booboo for details)")}`;
98
- lines.push(fitLine(header, w));
99
-
100
- return lines;
101
- }
102
-
103
- function fitLine(s: string, maxWidth: number, ellipsis = "…"): string {
104
- return truncateToWidth(s, Math.max(0, maxWidth), ellipsis);
105
- }
106
-
107
- // ─── Extension ────────────────────────────────────────────────────────────────
108
-
109
- export default function (pi: ExtensionAPI) {
110
- pi.on("session_start", (_event, ctx) => {
111
- clearDiagnosticState();
112
-
113
- // Register widget
114
- if (!ctx.ui.setWidget) return;
115
- ctx.ui.setWidget(
116
- "pi-lens",
117
- (tui, theme: Theme) => {
118
- requestRenderFn = () => tui.requestRender();
119
- return {
120
- render: (width: number) => renderWidget(width, theme),
121
- dispose() {
122
- requestRenderFn = null;
123
- },
124
- invalidate() {},
125
- };
126
- },
127
- { placement: "belowEditor" },
128
- );
129
- });
130
-
131
- // Track files after write/edit
132
- pi.on("tool_result", async (event, _ctx) => {
133
- if (event.toolName === "write" || event.toolName === "edit") {
134
- const filePath = (event.input as { path?: string })?.path;
135
- if (typeof filePath === "string") {
136
- recordFileTouched(filePath);
137
- }
138
- }
139
- });
140
-
141
- pi.on("session_shutdown", () => {
142
- clearDiagnosticState();
143
- requestRenderFn = null;
144
- });
145
- }