@xynogen/pix-pretty 1.7.8 → 1.7.10

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/README.md CHANGED
@@ -9,8 +9,9 @@ consume. It does not register user-facing tools itself — the tool renderers
9
9
  (`pix-read`, `pix-bash`, `pix-ls`, `pix-find`, `pix-grep`, `pix-edit`,
10
10
  `pix-write`) import from it. The extension entry point (`src/index.ts`) only
11
11
  initializes the syntax-highlight theme from Pi settings, clears the highlight
12
- cache, and registers two FFF slash commands (`/fff-health`, `/fff-rescan`)
13
- once `pix-grep` has brought the FFF finder online.
12
+ cache, registers the `/pretty` icon-style switch, and registers two FFF slash
13
+ commands (`/fff-health`, `/fff-rescan`) once `pix-grep` has brought the FFF
14
+ finder online. (Activated by `pix-core`; not a standalone extension.)
14
15
 
15
16
  ### Rendering
16
17
 
@@ -20,6 +21,25 @@ once `pix-grep` has brought the FFF finder online.
20
21
  - **Diff rendering** — side-by-side split diff for edit/write
21
22
  - **Bash exit summary** — colored status, line count, truncation notice
22
23
 
24
+ ### Icon catalog (l10n-style)
25
+
26
+ Icons are treated like a localization catalog: packages never hardcode glyph
27
+ codepoints — they ask for a **semantic role** and the catalog resolves it
28
+ against one global icon mode. Reskinning, or fixing a missing-glyph (“tofu”)
29
+ problem on terminals without a Nerd Font, becomes a one-file edit here.
30
+
31
+ - **`./icon-catalog`** — `icon(key)` resolves a semantic key (`"cwd"`,
32
+ `"model"`, `"paste.image"`, `"opt.caveman"`, …) to a glyph for the active
33
+ mode. Modes: `nerd` (Nerd Font PUA, default), `unicode` (standard BMP glyphs,
34
+ no patched font needed), `ascii` (plain letters). Also `iconFor(key, mode)`,
35
+ `getIconMode()`, `setIconMode()`, `ICON_KEYS`, `ICON_MODES`.
36
+ - **`./icon-persist`** — stores the mode in `~/.pi/agent/pretty.json`;
37
+ `initIconMode()` applies it on load.
38
+ - **`/pretty`** — the single switch: an overlay that previews each mode's
39
+ glyphs live and persists the choice. One global knob governs every pix-*
40
+ package (footer, paste chips, model picker, welcome banner, optimizer cell).
41
+ Seeded from `PRETTY_ICONS` (`none`/`off` → `ascii`) when no choice is saved.
42
+
23
43
  ### Shared overlay
24
44
 
25
45
  - **Gate overlay** (`./gate-overlay`) — the one permission-dialog component
@@ -47,7 +67,9 @@ pi install npm:@xynogen/pix-pretty
47
67
  - `PRETTY_MAX_HL_CHARS` — max characters to highlight (default: 80000)
48
68
  - `PRETTY_MAX_PREVIEW_LINES` — max lines in preview output
49
69
  - `PRETTY_CACHE_LIMIT` — FFF cache size limit
50
- - `PRETTY_ICONS` — icon mode (`nerd` or `none`)
70
+ - `PRETTY_ICONS` — default icon mode when none is persisted: `nerd` (default),
71
+ `unicode`, `ascii`, or `none`/`off` (→ `ascii`). Overridden by `/pretty`.
72
+ Note: this seeds the file-icon helpers AND the semantic icon catalog.
51
73
  - `PRETTY_MAX_RENDER_LINES` — max lines in edit/write diff render (default: 150)
52
74
  - `PRETTY_FFF_DIR` — override FFF state dir (default: `~/.cache/pi/fff`)
53
75
 
@@ -66,6 +88,8 @@ The package exposes its sub-modules via `exports`:
66
88
  @xynogen/pix-pretty/highlight
67
89
  @xynogen/pix-pretty/lang
68
90
  @xynogen/pix-pretty/icons
91
+ @xynogen/pix-pretty/icon-catalog
92
+ @xynogen/pix-pretty/icon-persist
69
93
  @xynogen/pix-pretty/renderers
70
94
  @xynogen/pix-pretty/fff
71
95
  @xynogen/pix-pretty/types
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-pretty",
3
- "version": "1.7.8",
3
+ "version": "1.7.10",
4
4
  "description": "Enhanced tool output rendering with syntax highlighting, file icons, tree views, diff rendering, and FFF search",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -15,6 +15,8 @@
15
15
  "./highlight": "./src/highlight.ts",
16
16
  "./lang": "./src/lang.ts",
17
17
  "./icons": "./src/icons.ts",
18
+ "./icon-catalog": "./src/icon-catalog.ts",
19
+ "./icon-persist": "./src/icon-persist.ts",
18
20
  "./renderers": "./src/renderers.ts",
19
21
  "./fff": "./src/fff.ts",
20
22
  "./types": "./src/types.ts",
@@ -0,0 +1,135 @@
1
+ /**
2
+ * pretty.ts — the `/pretty` command: switch the global icon mode.
3
+ *
4
+ * Treats icons like an l10n locale: one global mode (nerd/unicode/ascii)
5
+ * governs every pix-* package that renders via the icon catalog. This command
6
+ * is the single switch — an overlay that previews each mode's glyphs live and
7
+ * persists the choice to ~/.pi/agent/pretty.json. Headless hosts get a notify
8
+ * fallback that cycles the mode without UI.
9
+ */
10
+
11
+ import {
12
+ getIconMode,
13
+ ICON_MODES,
14
+ type IconKey,
15
+ type IconMode,
16
+ icon,
17
+ setIconMode,
18
+ } from "../icon-catalog.js";
19
+ import { saveIconMode } from "../icon-persist.js";
20
+ import { frameLines } from "../modal-frame.js";
21
+ import type { CommandContextLike, PiPrettyApi } from "../types.js";
22
+
23
+ /** Sample keys shown in the preview, one row per representative role. */
24
+ const PREVIEW: { key: IconKey; label: string }[] = [
25
+ { key: "model", label: "model" },
26
+ { key: "cwd", label: "cwd" },
27
+ { key: "lsp", label: "lsp" },
28
+ { key: "paste.image", label: "paste" },
29
+ { key: "opt.caveman", label: "optimizer" },
30
+ ];
31
+
32
+ /** Apply + persist a mode in one step. */
33
+ function applyMode(mode: IconMode): void {
34
+ setIconMode(mode);
35
+ saveIconMode(mode);
36
+ }
37
+
38
+ export function registerPrettyCommand(pi: PiPrettyApi): void {
39
+ pi.registerCommand("pretty", {
40
+ description: "pix-pretty: switch icon style (nerd / unicode / ascii)",
41
+ handler: async (_args: string, ctx: CommandContextLike) => {
42
+ const ui = ctx.ui as unknown as {
43
+ theme: {
44
+ fg(c: string, t: string): string;
45
+ bg(c: string, t: string): string;
46
+ bold(t: string): string;
47
+ };
48
+ custom?: <T>(
49
+ f: unknown,
50
+ opts?: {
51
+ overlay?: boolean;
52
+ overlayOptions?: {
53
+ anchor?: string;
54
+ width?: number;
55
+ maxHeight?: string;
56
+ };
57
+ },
58
+ ) => Promise<T>;
59
+ notify(m: string, t?: "info" | "warning" | "error"): void;
60
+ };
61
+
62
+ // Headless / no custom-UI host: cycle to the next mode + notify.
63
+ if (typeof ui.custom !== "function") {
64
+ const cur = ICON_MODES.indexOf(getIconMode());
65
+ const next = ICON_MODES[(cur + 1) % ICON_MODES.length] as IconMode;
66
+ applyMode(next);
67
+ ui.notify(`pix-pretty icons: ${next}`, "info");
68
+ return;
69
+ }
70
+
71
+ const boxW = 40;
72
+
73
+ await ui.custom<null>(
74
+ (
75
+ tui: { requestRender(): void },
76
+ theme: typeof ui.theme,
77
+ _kb: unknown,
78
+ done: (v: null) => void,
79
+ ) => {
80
+ let selected = ICON_MODES.indexOf(getIconMode());
81
+ if (selected < 0) selected = 0;
82
+
83
+ const choose = (i: number) => {
84
+ selected = (i + ICON_MODES.length) % ICON_MODES.length;
85
+ applyMode(ICON_MODES[selected] as IconMode);
86
+ };
87
+
88
+ return {
89
+ render: () => {
90
+ const rows = ICON_MODES.map((mode, i) => {
91
+ const sel = i === selected;
92
+ const cursor = sel ? theme.fg("accent", "→") : " ";
93
+ const name = theme.fg(sel ? "accent" : "text", mode.padEnd(8));
94
+ // Live preview: render the sample glyphs in THIS mode.
95
+ const prev = ICON_MODES[i] === getIconMode();
96
+ const samples = prev
97
+ ? PREVIEW.map((p) => icon(p.key)).join(" ")
98
+ : "";
99
+ return `${cursor} ${name} ${theme.fg("dim", samples)}`;
100
+ });
101
+ const lines = [
102
+ theme.fg("accent", theme.bold(" Icon style")),
103
+ "",
104
+ ...rows,
105
+ "",
106
+ theme.fg("dim", "↑↓ select · esc close"),
107
+ ];
108
+ return frameLines({
109
+ width: boxW,
110
+ lines,
111
+ color: (s) => theme.fg("accent", s),
112
+ bg: (s) => theme.bg("customMessageBg", s),
113
+ });
114
+ },
115
+ invalidate: () => {},
116
+ handleInput: (data: string) => {
117
+ if (data === "k" || data === "\u001b[A") choose(selected - 1);
118
+ else if (data === "j" || data === "\u001b[B")
119
+ choose(selected + 1);
120
+ else if (data === "\u001b" || data === "q" || data === "\r") {
121
+ done(null);
122
+ return;
123
+ } else return;
124
+ tui.requestRender();
125
+ },
126
+ };
127
+ },
128
+ {
129
+ overlay: true,
130
+ overlayOptions: { anchor: "center", width: boxW, maxHeight: "60%" },
131
+ },
132
+ );
133
+ },
134
+ });
135
+ }
@@ -0,0 +1,65 @@
1
+ import { afterEach, describe, expect, it } from "bun:test";
2
+ import {
3
+ getIconMode,
4
+ ICON_KEYS,
5
+ ICON_MODES,
6
+ icon,
7
+ iconFor,
8
+ onIconModeChange,
9
+ setIconMode,
10
+ } from "./icon-catalog.ts";
11
+
12
+ describe("icon-catalog", () => {
13
+ afterEach(() => setIconMode("nerd")); // restore default for other suites
14
+
15
+ it("exposes nerd/unicode/ascii in cycle order", () => {
16
+ expect([...ICON_MODES]).toEqual(["nerd", "unicode", "ascii"]);
17
+ });
18
+
19
+ it("resolves a key against the active mode", () => {
20
+ setIconMode("ascii");
21
+ expect(icon("cwd")).toBe("~");
22
+ setIconMode("unicode");
23
+ expect(icon("cwd")).toBe("\u2302\uFE0E");
24
+ setIconMode("nerd");
25
+ expect(icon("cwd")).toBe("\u{F024B}");
26
+ });
27
+
28
+ it("iconFor resolves without touching the active mode", () => {
29
+ setIconMode("nerd");
30
+ expect(iconFor("opt.caveman", "ascii")).toBe("Cv");
31
+ expect(getIconMode()).toBe("nerd"); // unchanged
32
+ });
33
+
34
+ it("every catalog key has a non-empty glyph in every mode", () => {
35
+ for (const mode of ICON_MODES) {
36
+ for (const key of ICON_KEYS) {
37
+ expect(iconFor(key, mode).length).toBeGreaterThan(0);
38
+ }
39
+ }
40
+ });
41
+
42
+ it("unknown key fails soft to empty string", () => {
43
+ // @ts-expect-error exercising the runtime guard
44
+ expect(icon("does.not.exist")).toBe("");
45
+ });
46
+
47
+ it("setIconMode ignores an invalid mode", () => {
48
+ setIconMode("unicode");
49
+ // @ts-expect-error invalid mode must be rejected, leaving prior value
50
+ setIconMode("bogus");
51
+ expect(getIconMode()).toBe("unicode");
52
+ });
53
+
54
+ it("notifies subscribers on an actual change, not on no-ops", () => {
55
+ setIconMode("nerd");
56
+ const seen: string[] = [];
57
+ const off = onIconModeChange((m) => seen.push(m));
58
+ setIconMode("nerd"); // no-op — must NOT fire
59
+ setIconMode("ascii"); // change — fires
60
+ setIconMode("ascii"); // no-op — must NOT fire
61
+ off();
62
+ setIconMode("unicode"); // after unsubscribe — must NOT fire
63
+ expect(seen).toEqual(["ascii"]);
64
+ });
65
+ });
@@ -0,0 +1,142 @@
1
+ /**
2
+ * icon-catalog.ts — semantic icon catalog, treated like an l10n message table.
3
+ *
4
+ * Packages must NOT hardcode glyph codepoints. Instead they ask for an icon by
5
+ * its semantic role — `icon("cwd")`, `icon("paste.image")` — and this module
6
+ * resolves it against the single active icon mode, exactly like `t("key")`
7
+ * resolves a translation against the active locale.
8
+ *
9
+ * catalog: key -> { nerd, unicode, ascii } (the "messages")
10
+ * mode: "nerd" | "unicode" | "ascii" (the "locale")
11
+ * icon(k): catalog[k][mode] (the "t(key)")
12
+ *
13
+ * One global mode governs the whole stack. It is switched via the `/pretty`
14
+ * command (see pretty-command.ts), persisted to ~/.pi/agent/pretty.json, and
15
+ * seeded from the PRETTY_ICONS env var on first load.
16
+ *
17
+ * Why a catalog instead of per-package toggles: reskinning or fixing a
18
+ * missing-glyph ("tofu") problem becomes a one-file edit here, and there is
19
+ * exactly ONE knob (the mode) rather than one env var per package.
20
+ */
21
+
22
+ /** Presentation modes, in /pretty cycle order. nerd = Nerd Font PUA glyphs. */
23
+ export type IconMode = "nerd" | "unicode" | "ascii";
24
+
25
+ /** All modes in cycle order. */
26
+ export const ICON_MODES: readonly IconMode[] = ["nerd", "unicode", "ascii"];
27
+
28
+ /** Force text (non-emoji) presentation for symbols that default to emoji. */
29
+ const VS = "\uFE0E";
30
+
31
+ /**
32
+ * The catalog. Each semantic key maps to one glyph per mode.
33
+ * - nerd: Nerd Font Private Use Area codepoint (needs a patched font).
34
+ * - unicode: standard BMP glyph that ships with virtually every monospace
35
+ * font (no Nerd Font required); +VS to force text presentation.
36
+ * - ascii: pure ASCII, renders on literally any terminal.
37
+ *
38
+ * Keys are SEMANTIC ROLES, never glyph names — consumers reference meaning.
39
+ */
40
+ const CATALOG = {
41
+ // ── footer / status segments ──────────────────────────────────────────
42
+ model: { nerd: "\u{F06A9}", unicode: `\u25C8${VS}`, ascii: "M" },
43
+ lsp: { nerd: "\u{F0626}", unicode: `\u25C9${VS}`, ascii: "LSP" },
44
+ mcp: { nerd: "\u{F048D}", unicode: `\u25D0${VS}`, ascii: "MCP" },
45
+ cwd: { nerd: "\u{F024B}", unicode: `\u2302${VS}`, ascii: "~" },
46
+ folder: { nerd: "\u{F024B}", unicode: `\u2302${VS}`, ascii: "/" },
47
+
48
+ // ── footer indicators (git status, score) ─────────────────────────────
49
+ "git.unstaged": { nerd: "\u2717", unicode: "\u2717", ascii: "x" },
50
+ "git.ahead": { nerd: "\u21E1", unicode: "\u21E1", ascii: "^" },
51
+ "git.behind": { nerd: "\u21E3", unicode: "\u21E3", ascii: "v" },
52
+ "net.in": { nerd: "\u21E1", unicode: "\u21E1", ascii: "in" },
53
+ "net.out": { nerd: "\u21E3", unicode: "\u21E3", ascii: "out" },
54
+ score: { nerd: "\u26A1", unicode: "\u26A1", ascii: "S" },
55
+
56
+ // ── misc ──────────────────────────────────────────────────────────────
57
+ ok: { nerd: "\u2713", unicode: "\u2713", ascii: "ok" },
58
+ warn: { nerd: "\u26A0", unicode: "\u26A0", ascii: "!" },
59
+ error: { nerd: "\u2717", unicode: "\u2717", ascii: "x" },
60
+
61
+ // ── welcome banner ────────────────────────────────────────────────────
62
+ ready: { nerd: "\u{F0633}", unicode: `\u2713${VS}`, ascii: "ok" },
63
+
64
+ // ── paste chips (pix-display) ─────────────────────────────────────────
65
+ "paste.image": { nerd: "\u{F02E9}", unicode: `\u25A3${VS}`, ascii: "img" },
66
+ "paste.text": { nerd: "\u{F027F}", unicode: `\u25A4${VS}`, ascii: "txt" },
67
+
68
+ // ── model picker (pix-models) ─────────────────────────────────────────
69
+ "picker.model": { nerd: "\u{F0229}", unicode: `\u25C8${VS}`, ascii: "M" },
70
+
71
+ // ── optimizer suite (pix-optimizer) ───────────────────────────────────
72
+ "opt.caveman": { nerd: "\u{F0710}", unicode: `\u2664${VS}`, ascii: "Cv" },
73
+ "opt.rtk": { nerd: "\u{F04E5}", unicode: `\u2661${VS}`, ascii: "Rk" },
74
+ "opt.toon": { nerd: "\u{F05C0}", unicode: `\u2662${VS}`, ascii: "Tn" },
75
+ "opt.ponytail": { nerd: "\u{F0190}", unicode: `\u2667${VS}`, ascii: "Pt" },
76
+ "opt.title": { nerd: "\u{F0DAB}", unicode: `\u25C8${VS}`, ascii: "*" },
77
+ } as const;
78
+
79
+ /** Every valid semantic icon key. */
80
+ export type IconKey = keyof typeof CATALOG;
81
+
82
+ /** All catalog keys (useful for /pretty previews and tests). */
83
+ export const ICON_KEYS = Object.keys(CATALOG) as IconKey[];
84
+
85
+ /**
86
+ * Active mode. Seeded from PRETTY_ICONS env (back-compat: none/off => ascii),
87
+ * then overridden by a persisted choice when the host loads pretty.json.
88
+ */
89
+ function envMode(): IconMode {
90
+ const raw = (process.env.PRETTY_ICONS ?? "").toLowerCase();
91
+ if (raw === "nerd" || raw === "unicode" || raw === "ascii") return raw;
92
+ if (raw === "none" || raw === "off") return "ascii";
93
+ return "nerd";
94
+ }
95
+
96
+ let activeMode: IconMode = envMode();
97
+
98
+ /** Current global icon mode. */
99
+ export function getIconMode(): IconMode {
100
+ return activeMode;
101
+ }
102
+
103
+ /**
104
+ * Mode-change subscribers. Most consumers resolve icon() at render time and
105
+ * need no notification, but PUSHED-status consumers (e.g. the optimizer cell,
106
+ * drawn once via setStatus) must repaint when the mode flips. They subscribe
107
+ * here; setIconMode fires every callback on an actual change.
108
+ */
109
+ type ModeListener = (mode: IconMode) => void;
110
+ const listeners = new Set<ModeListener>();
111
+
112
+ /** Subscribe to global icon-mode changes. Returns an unsubscribe fn. */
113
+ export function onIconModeChange(cb: ModeListener): () => void {
114
+ listeners.add(cb);
115
+ return () => listeners.delete(cb);
116
+ }
117
+
118
+ /**
119
+ * Set the global icon mode (does NOT persist — callers that want persistence
120
+ * use pretty-command.ts, which writes pretty.json then calls this). Fires
121
+ * subscribers only on an actual change (no-op re-sets are ignored).
122
+ */
123
+ export function setIconMode(mode: IconMode): void {
124
+ if (!ICON_MODES.includes(mode) || mode === activeMode) return;
125
+ activeMode = mode;
126
+ for (const cb of listeners) cb(mode);
127
+ }
128
+
129
+ /**
130
+ * Resolve a semantic icon key to its glyph for the active mode — the `t(key)`
131
+ * of this module. Unknown keys return "" (fail soft: never throw mid-render).
132
+ */
133
+ export function icon(key: IconKey): string {
134
+ const entry = CATALOG[key];
135
+ return entry ? entry[activeMode] : "";
136
+ }
137
+
138
+ /** Resolve a key for an explicit mode (used by /pretty previews + tests). */
139
+ export function iconFor(key: IconKey, mode: IconMode): string {
140
+ const entry = CATALOG[key];
141
+ return entry ? entry[mode] : "";
142
+ }
@@ -0,0 +1,47 @@
1
+ import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { getIconMode, setIconMode } from "./icon-catalog.ts";
6
+ import { initIconMode, loadIconMode, saveIconMode } from "./icon-persist.ts";
7
+
8
+ let tmpAgentDir: string;
9
+
10
+ beforeAll(() => {
11
+ tmpAgentDir = mkdtempSync(join(tmpdir(), "pretty-persist-test-"));
12
+ process.env.PI_CODING_AGENT_DIR = tmpAgentDir;
13
+ });
14
+
15
+ afterAll(() => {
16
+ delete process.env.PI_CODING_AGENT_DIR;
17
+ try {
18
+ rmSync(tmpAgentDir, { recursive: true });
19
+ } catch {
20
+ // already gone — ignore
21
+ }
22
+ });
23
+
24
+ describe("icon-persist", () => {
25
+ afterEach(() => setIconMode("nerd"));
26
+
27
+ it("returns undefined before anything is saved", () => {
28
+ expect(loadIconMode()).toBeUndefined();
29
+ });
30
+
31
+ it("round-trips a mode across save/load (new-session sim)", () => {
32
+ saveIconMode("unicode");
33
+ expect(loadIconMode()).toBe("unicode");
34
+ });
35
+
36
+ it("rejects an invalid persisted mode", () => {
37
+ saveIconMode("ascii");
38
+ expect(loadIconMode()).toBe("ascii");
39
+ });
40
+
41
+ it("initIconMode applies the persisted choice to the catalog", () => {
42
+ saveIconMode("ascii");
43
+ setIconMode("nerd"); // pretend env default
44
+ initIconMode();
45
+ expect(getIconMode()).toBe("ascii");
46
+ });
47
+ });
@@ -0,0 +1,67 @@
1
+ /**
2
+ * icon-persist.ts — disk persistence for the global icon mode.
3
+ *
4
+ * Stores the single icon mode in ~/.pi/agent/pretty.json so a choice made via
5
+ * /pretty survives quit/restart. Kept separate from icon-catalog.ts so the
6
+ * catalog resolver stays pure (no fs) and trivially testable.
7
+ *
8
+ * ~/.pi/agent/pretty.json -> { "icons": "unicode" }
9
+ *
10
+ * Precedence: a persisted value wins; otherwise the catalog's env-seeded
11
+ * default (PRETTY_ICONS) stands.
12
+ */
13
+
14
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
15
+ import { dirname, join } from "node:path";
16
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
17
+ import { ICON_MODES, type IconMode, setIconMode } from "./icon-catalog.js";
18
+
19
+ function statePath(): string {
20
+ return join(getAgentDir(), "pretty.json");
21
+ }
22
+
23
+ /** Read the persisted icon mode, or undefined if unset/invalid/missing. */
24
+ export function loadIconMode(): IconMode | undefined {
25
+ try {
26
+ const p = statePath();
27
+ if (!existsSync(p)) return undefined;
28
+ const raw = JSON.parse(readFileSync(p, "utf-8")) as { icons?: string };
29
+ const mode = raw?.icons;
30
+ return ICON_MODES.includes(mode as IconMode)
31
+ ? (mode as IconMode)
32
+ : undefined;
33
+ } catch {
34
+ return undefined;
35
+ }
36
+ }
37
+
38
+ /** Persist the icon mode, merging into pretty.json. */
39
+ export function saveIconMode(mode: IconMode): void {
40
+ try {
41
+ const p = statePath();
42
+ mkdirSync(dirname(p), { recursive: true });
43
+ let existing: Record<string, unknown> = {};
44
+ if (existsSync(p)) {
45
+ try {
46
+ existing = JSON.parse(readFileSync(p, "utf-8")) as Record<
47
+ string,
48
+ unknown
49
+ >;
50
+ } catch {
51
+ existing = {};
52
+ }
53
+ }
54
+ writeFileSync(p, JSON.stringify({ ...existing, icons: mode }, null, 2));
55
+ } catch (err) {
56
+ console.warn("pix-pretty: persist icon mode failed:", err);
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Apply the persisted mode (if any) to the catalog. Called once at extension
62
+ * load so the env-seeded default is overridden by the user's saved choice.
63
+ */
64
+ export function initIconMode(): void {
65
+ const saved = loadIconMode();
66
+ if (saved) setIconMode(saved);
67
+ }
package/src/index.ts CHANGED
@@ -10,9 +10,11 @@
10
10
 
11
11
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
12
12
  import { registerFffCommands } from "./commands/fff.js";
13
+ import { registerPrettyCommand } from "./commands/pretty.js";
13
14
  import { getDefaultAgentDir, setPrettyTheme } from "./config.js";
14
15
  import { fffState } from "./fff.js";
15
16
  import { clearHighlightCache } from "./highlight.js";
17
+ import { initIconMode } from "./icon-persist.js";
16
18
  import type { PiPrettyApi } from "./types.js";
17
19
 
18
20
  export default function piPrettyExtension(pi: PiPrettyApi): void {
@@ -28,6 +30,12 @@ export default function piPrettyExtension(pi: PiPrettyApi): void {
28
30
  );
29
31
  clearHighlightCache();
30
32
 
33
+ // ── Icon mode ───────────────────────────────────────────
34
+ // Seed the global icon mode from pretty.json (overrides env default), then
35
+ // register the single /pretty switch.
36
+ initIconMode();
37
+ registerPrettyCommand(pi);
38
+
31
39
  // ── FFF slash commands ──────────────────────────────────────────────
32
40
  // fffState is a module-level singleton shared with pix-grep/pix-find.
33
41
  // Commands become available once pix-grep initialises the finder.