@xynogen/pix-core 0.1.5 → 0.1.7

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.5",
3
+ "version": "0.1.7",
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",
@@ -24,6 +24,7 @@ import {
24
24
  visibleWidth,
25
25
  } from "@earendil-works/pi-tui";
26
26
  import { lookupBenchmark, lookupModelsDev } from "../../lib/data";
27
+ import { patchOutBuiltinModelCommand } from "./patch-builtin";
27
28
 
28
29
  // ─── Pure logic (exported for tests) ─────────────────────────────────────────
29
30
 
@@ -352,6 +353,10 @@ export async function showEnhancedPicker(
352
353
  }
353
354
 
354
355
  export default function modelPickerExtension(pi: ExtensionAPI) {
356
+ // Remove Pi's built-in /model so only the enhanced /models picker remains.
357
+ // Self-healing: re-applies on every load, so a Pi upgrade can't restore it.
358
+ patchOutBuiltinModelCommand();
359
+
355
360
  const handler = async (_args: unknown, ctx: ExtensionContext) => {
356
361
  await showEnhancedPicker(pi, ctx);
357
362
  };
@@ -0,0 +1,66 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ // Pure replacement tested in isolation (the exported fn resolves the host
7
+ // package, which isn't present in the test sandbox).
8
+ const MODEL_COMMAND_LINE =
9
+ '{ name: "model", description: "Select model (opens selector UI)" },';
10
+
11
+ function escapeRegExp(text: string): string {
12
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
13
+ }
14
+
15
+ function patchSource(source: string): string {
16
+ if (!source.includes(MODEL_COMMAND_LINE)) return source;
17
+ return source.replace(
18
+ new RegExp(`[ \\t]*${escapeRegExp(MODEL_COMMAND_LINE)}\\n?`),
19
+ "",
20
+ );
21
+ }
22
+
23
+ const UNPATCHED = `export const BUILTIN_SLASH_COMMANDS = [
24
+ { name: "settings", description: "Open settings menu" },
25
+ { name: "model", description: "Select model (opens selector UI)" },
26
+ { name: "login", description: "Configure provider authentication" },
27
+ ];
28
+ `;
29
+
30
+ describe("patch-builtin /model removal", () => {
31
+ it("removes the built-in /model line and keeps neighbors", () => {
32
+ const out = patchSource(UNPATCHED);
33
+ expect(out).not.toContain('name: "model"');
34
+ expect(out).toContain('name: "settings"');
35
+ expect(out).toContain('name: "login"');
36
+ });
37
+
38
+ it("is idempotent — second pass is a no-op", () => {
39
+ const once = patchSource(UNPATCHED);
40
+ const twice = patchSource(once);
41
+ expect(twice).toBe(once);
42
+ });
43
+
44
+ it("leaves an already-clean file untouched", () => {
45
+ const clean = `export const X = [\n { name: "login" },\n];\n`;
46
+ expect(patchSource(clean)).toBe(clean);
47
+ });
48
+
49
+ it("does not strip the plural /models entry", () => {
50
+ const withPlural = `[
51
+ { name: "models", description: "Enhanced picker" },
52
+ { name: "model", description: "Select model (opens selector UI)" },
53
+ ]`;
54
+ const out = patchSource(withPlural);
55
+ expect(out).toContain('name: "models"');
56
+ expect(out).not.toContain('{ name: "model", description');
57
+ });
58
+
59
+ it("round-trips through disk", () => {
60
+ const dir = mkdtempSync(join(tmpdir(), "pix-patch-"));
61
+ const file = join(dir, "slash-commands.js");
62
+ writeFileSync(file, UNPATCHED, "utf8");
63
+ writeFileSync(file, patchSource(readFileSync(file, "utf8")), "utf8");
64
+ expect(readFileSync(file, "utf8")).not.toContain('name: "model"');
65
+ });
66
+ });
@@ -0,0 +1,60 @@
1
+ /**
2
+ * patch-builtin.ts — strip Pi's built-in /model slash command at load time.
3
+ *
4
+ * Built-in commands can't be removed via the extension API, so we edit Pi's
5
+ * compiled slash-commands.js directly. Done on every load: idempotent and
6
+ * self-healing across Pi upgrades, so no manual repatch is ever needed.
7
+ */
8
+
9
+ import { readFileSync, writeFileSync } from "node:fs";
10
+ import { createRequire } from "node:module";
11
+ import { dirname, resolve } from "node:path";
12
+
13
+ const HOST_PACKAGE = "@earendil-works/pi-coding-agent";
14
+ const MODEL_COMMAND_LINE =
15
+ '{ name: "model", description: "Select model (opens selector UI)" },';
16
+
17
+ /** Locate the host's compiled slash-commands.js, or null if it can't be found. */
18
+ function findSlashCommandsFile(): string | null {
19
+ try {
20
+ const require = createRequire(import.meta.url);
21
+ const entry = require.resolve(HOST_PACKAGE);
22
+ return resolve(dirname(entry), "core", "slash-commands.js");
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Remove the built-in /model command line from Pi's slash-commands.js.
30
+ * Idempotent: returns silently if the file is missing or already patched.
31
+ */
32
+ export function patchOutBuiltinModelCommand(): void {
33
+ const file = findSlashCommandsFile();
34
+ if (!file) return;
35
+
36
+ let source: string;
37
+ try {
38
+ source = readFileSync(file, "utf8");
39
+ } catch {
40
+ return; // file not present (different Pi layout) — nothing to do
41
+ }
42
+
43
+ if (!source.includes(MODEL_COMMAND_LINE)) return; // already patched
44
+
45
+ const patched = source.replace(
46
+ new RegExp(`[ \\t]*${escapeRegExp(MODEL_COMMAND_LINE)}\\n?`),
47
+ "",
48
+ );
49
+ if (patched === source) return;
50
+
51
+ try {
52
+ writeFileSync(file, patched, "utf8");
53
+ } catch {
54
+ // Read-only install — leave /model in place rather than crash.
55
+ }
56
+ }
57
+
58
+ function escapeRegExp(text: string): string {
59
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
60
+ }
@@ -1,5 +1,12 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import registerTodo from "./todo.ts";
2
+ import registerTodo, { renderTodoLines, type TodoItem } from "./todo.ts";
3
+
4
+ // Stub theme tags each fragment with its color/bold so assertions can verify
5
+ // which status got which tint, without depending on real ANSI codes.
6
+ const tagTheme = {
7
+ fg: (color: string, text: string) => `[${color}]${text}[/]`,
8
+ bold: (text: string) => `<b>${text}</b>`,
9
+ };
3
10
 
4
11
  // ─── Helpers ────────────────────────────────────────────────────────────────
5
12
 
@@ -600,3 +607,40 @@ describe("restore", () => {
600
607
  expect(text(result)).toBe("(no todos)");
601
608
  });
602
609
  });
610
+
611
+ describe("renderTodoLines (colored TUI render)", () => {
612
+ const items: TodoItem[] = [
613
+ { id: 1, text: "alpha", status: "done" },
614
+ { id: 2, text: "bravo", status: "in_progress" },
615
+ { id: 3, text: "charlie", status: "pending" },
616
+ { id: 4, text: "delta", status: "blocked" },
617
+ ];
618
+
619
+ test("empty list renders muted placeholder", () => {
620
+ expect(renderTodoLines([], tagTheme)).toBe("[muted](no todos)[/]");
621
+ });
622
+
623
+ test("tints each glyph by status", () => {
624
+ const out = renderTodoLines(items, tagTheme);
625
+ expect(out).toContain("[success]●[/]"); // done
626
+ expect(out).toContain("[accent]◐[/]"); // in_progress
627
+ expect(out).toContain("[muted]○[/]"); // pending
628
+ expect(out).toContain("[error]⊘[/]"); // blocked
629
+ });
630
+
631
+ test("highlights the in-progress row bold + accent", () => {
632
+ const out = renderTodoLines(items, tagTheme);
633
+ expect(out).toContain("<b>[accent]2. bravo[/]</b>");
634
+ });
635
+
636
+ test("dims completed rows and uses text color for active-but-not-running", () => {
637
+ const out = renderTodoLines(items, tagTheme);
638
+ expect(out).toContain("[muted]1. alpha[/]"); // done body muted
639
+ expect(out).toContain("[text]3. charlie[/]"); // pending body text
640
+ });
641
+
642
+ test("shows the done/total count header", () => {
643
+ const out = renderTodoLines(items, tagTheme);
644
+ expect(out).toContain("[muted]Todos 1/4 done:[/]");
645
+ });
646
+ });
@@ -10,6 +10,7 @@
10
10
  */
11
11
 
12
12
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
+ import { Text } from "@earendil-works/pi-tui";
13
14
  import { Type } from "typebox";
14
15
 
15
16
  export type TodoStatus = "pending" | "in_progress" | "done" | "blocked";
@@ -27,6 +28,38 @@ const TODO_GLYPH: Record<TodoStatus, string> = {
27
28
  blocked: "⊘",
28
29
  };
29
30
 
31
+ /** Theme color key per status — drives both glyph and (for active) row tint. */
32
+ const TODO_COLOR: Record<TodoStatus, string> = {
33
+ pending: "muted",
34
+ in_progress: "accent",
35
+ done: "success",
36
+ blocked: "error",
37
+ };
38
+
39
+ export type TodoTheme = {
40
+ fg: (color: string, text: string) => string;
41
+ bold: (text: string) => string;
42
+ };
43
+
44
+ /** Colored checklist for the TUI: glyphs tinted by status, active row bold. */
45
+ export function renderTodoLines(items: TodoItem[], theme: TodoTheme): string {
46
+ if (!items.length) return theme.fg("muted", "(no todos)");
47
+ const done = items.filter((t) => t.status === "done").length;
48
+ const head = theme.fg("muted", `Todos ${done}/${items.length} done:`);
49
+ const lines = items.map((t) => {
50
+ const color = TODO_COLOR[t.status];
51
+ const glyph = theme.fg(color, TODO_GLYPH[t.status]);
52
+ const body = `${t.id}. ${t.text}`;
53
+ // Highlight the in-flight task so the eye lands on it first.
54
+ const label =
55
+ t.status === "in_progress"
56
+ ? theme.bold(theme.fg("accent", body))
57
+ : theme.fg(t.status === "done" ? "muted" : "text", body);
58
+ return `${glyph} ${label}`;
59
+ });
60
+ return `${head}\n${lines.join("\n")}`;
61
+ }
62
+
30
63
  const parseItems = (raw: string): string[] =>
31
64
  raw
32
65
  .split("\n")
@@ -102,6 +135,10 @@ export default function registerTodo(pi: ExtensionAPI): void {
102
135
  }),
103
136
  ),
104
137
  }),
138
+ renderResult(_result, _options, theme) {
139
+ return new Text(renderTodoLines(todos, theme as TodoTheme), 0, 0);
140
+ },
141
+
105
142
  async execute(_id, params) {
106
143
  // AgentToolResult now requires a `details` field. These todo results have
107
144
  // no structured details, so emit `undefined` via small local helpers.