@xynogen/pix-pretty 1.7.7 → 1.7.9

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
@@ -1,38 +1,59 @@
1
1
  # pix-pretty
2
2
 
3
- Complete rendering and formatting solution for Pi Coding Agent with syntax highlighting, file icons, tree views, FFF search, paste chip formatting, and reasoning tag cleanup.
4
-
5
- ## Features
6
-
7
- ### Tool Output Rendering
8
-
9
- - **Syntax highlighting** - Uses `cli-highlight` (highlight.js-backed) for code blocks
10
- - **File icons** - Visual file type indicators in ls/find output
11
- - **Tree views** - Hierarchical directory display
12
- - **FFF search** - Fast full-text search integration with `@ff-labs/fff-node`
13
- - **Diff rendering** - Enhanced git diff and edit/write tool output
14
- - **Image metadata** - Display image dimensions and format info
15
- - **Bash exit summary** - Command status and timing info
16
-
17
- ### Paste Chip Formatting
18
-
19
- - **Image path collapsing** - Converts `/tmp/pi-clipboard-abc.png` `[paste image #1]`
20
- - **Text paste markers** - Long pasted text `[paste text +42 lines]`
21
- - **Atomic deletion** - Delete entire paste markers as single units
22
- - **Type-aware labels** - Visual distinction between image and text pastes
23
-
24
- ### Permission Dialog Overlay
25
-
26
- - **Shared gate overlay** - `showOverlay(ui, config)` (export `./gate-overlay`) is the one permission-dialog component used by both `pix-gate` and `pix-sudo`. Two modes: `mode:"confirm"` shows a SelectList only; `mode:"sudo"` shows a SelectList then a masked password stage. Returns `{ action: "approved" | "denied" | "timeout", password? }`. All dialogs are padded (`Box` `paddingX=2`, `paddingY=1`). The simpler `./confirm` export stays for plain boolean Yes/No prompts — `gate-overlay` is its richer multi-choice, password-capable sibling.
27
-
28
- ### Reasoning Tag Rendering
29
-
30
- - **Live streaming** - Splits `<think>`/`<thinking>` regions into native Pi `thinking` content blocks token-by-token during streaming
31
- - **Finalized cleanup** - On `message_end`, re-splits every affected text block for persistence (the finalized message bypasses the live rebuild)
32
- - **Partial-tag safety** - Strips trailing half-streamed tags (e.g. `<thin`) so they never flash as literal text
33
- - **Visual distinction** - Uses Pi's native `thinking` block rendering (dim + italic via `thinkingText` theme token) — no ANSI injection, no markdown blockquote shim
34
-
35
- ## Installation
3
+ Rendering and formatting library for Pi Coding Agent with syntax highlighting, file icons, tree views, FFF search integration, and gate-dialog overlay.
4
+
5
+ ## What it does
6
+
7
+ This package is a **library + a small extension** that other pix packages
8
+ consume. It does not register user-facing tools itself — the tool renderers
9
+ (`pix-read`, `pix-bash`, `pix-ls`, `pix-find`, `pix-grep`, `pix-edit`,
10
+ `pix-write`) import from it. The extension entry point (`src/index.ts`) only
11
+ initializes the syntax-highlight theme from Pi settings, clears the highlight
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.)
15
+
16
+ ### Rendering
17
+
18
+ - **Syntax highlighting** — `cli-highlight` (highlight.js-backed)
19
+ - **File icons** type-aware icons in ls/find output
20
+ - **Tree views** hierarchical directory display for ls
21
+ - **Diff rendering** — side-by-side split diff for edit/write
22
+ - **Bash exit summary** colored status, line count, truncation notice
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
+
43
+ ### Shared overlay
44
+
45
+ - **Gate overlay** (`./gate-overlay`) — the one permission-dialog component
46
+ shared by `pix-gate` and `pix-sudo`. Two modes: `confirm` (SelectList) and
47
+ `sudo` (SelectList + masked password). Returns
48
+ `{ action: "approved" | "denied" | "timeout", password? }`. Padded with
49
+ `Box` `paddingX=2`, `paddingY=1`. The simpler `./confirm` export is the
50
+ plain boolean Yes/No dialog.
51
+
52
+ UI features that used to live here have moved to [`pix-display`](packages/pix-display):
53
+ paste chip rendering and reasoning-tag (`<think>`/`<thinking>`) → native
54
+ `thinking` content blocks.
55
+
56
+ ## Install
36
57
 
37
58
  ```bash
38
59
  pi install npm:@xynogen/pix-pretty
@@ -42,40 +63,42 @@ pi install npm:@xynogen/pix-pretty
42
63
 
43
64
  ### Environment Variables
44
65
 
45
- **Tool rendering:**
66
+ - `PRETTY_THEME` — color theme for syntax highlighting
67
+ - `PRETTY_MAX_HL_CHARS` — max characters to highlight (default: 80000)
68
+ - `PRETTY_MAX_PREVIEW_LINES` — max lines in preview output
69
+ - `PRETTY_CACHE_LIMIT` — FFF cache size limit
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.
73
+ - `PRETTY_MAX_RENDER_LINES` — max lines in edit/write diff render (default: 150)
74
+ - `PRETTY_FFF_DIR` — override FFF state dir (default: `~/.cache/pi/fff`)
46
75
 
47
- - `PRETTY_THEME` - Color theme for syntax highlighting
48
- - `PRETTY_MAX_HL_CHARS` - Max characters to highlight (default: 80000)
49
- - `PRETTY_MAX_PREVIEW_LINES` - Max lines in preview output
50
- - `PRETTY_CACHE_LIMIT` - FFF cache size limit
51
- - `PRETTY_ICONS` - Enable/disable file icons
52
- - `PRETTY_DISABLE_TOOLS` - Comma-separated list of tools to skip rendering
53
- - `PRETTY_IMAGE_PROTOCOL` - Protocol for image display (tmux passthrough)
54
- - `PRETTY_FFF_DIR` - Override FFF state dir (default: `~/.cache/pi/fff`)
76
+ ## Public exports
55
77
 
56
- ## Architecture
78
+ The package exposes its sub-modules via `exports`:
57
79
 
58
- This package combines two rendering systems:
59
-
60
- 1. **Theme + FFF commands** (`src/index.ts`) - Initialises syntax-highlight theme from Pi settings, clears highlight cache, and registers FFF slash commands. Tool renderers live in the standalone `pix-{read,bash,ls,find,grep,edit,write}` packages — each self-registers via its own Pi extension entry point.
61
- 2. **Paste chip formatting** (`src/paste-chips.ts`) - Custom editor component for paste markers.
62
- 3. **Reasoning tag rendering** (`src/thinking.ts`) - Converts leaked `<think>`/`<thinking>` tags into native Pi `thinking` content blocks (dim + italic via `thinkingText` theme token).
63
-
64
- Both work independently but complement each other for a cohesive visual experience.
65
-
66
- ## Origin
67
-
68
- Tool rendering replaced `npm:@heyhuynhgiabuu/pi-pretty` (which was previously replaced by `npm:@heyhuynhgiabuu/pi-diff`). This package is a clean reimplementation — no code was copied directly. Developed independently; changes are not submitted back and upstream changes are not pulled in.
69
-
70
- Key divergences from upstream:
71
-
72
- 1. **Highlight engine: shiki → cli-highlight** - Simpler, no WASM, synchronous
73
- 2. **FFF state dir** - `~/.pi/agent/pi-pretty/fff` → `~/.cache/pi/fff` (XDG cache)
74
- 3. **Split diff view for edit/write tools** - Full side-by-side diff with gutter, line numbers, syntax highlighting
75
- 4. **Paste chip formatting** - Custom editor component for Pi's paste marker system (not in upstream)
76
- 5. **Reasoning tag rendering** - Converts leaked `<think>`/`<thinking>` tags into native Pi `thinking` content blocks (not in upstream)
77
-
78
- Paste chip formatting and reasoning tag rendering are original additions with no upstream equivalent.
80
+ ```
81
+ @xynogen/pix-pretty (default — extension entry)
82
+ @xynogen/pix-pretty/ansi
83
+ @xynogen/pix-pretty/confirm
84
+ @xynogen/pix-pretty/progress
85
+ @xynogen/pix-pretty/config
86
+ @xynogen/pix-pretty/diff
87
+ @xynogen/pix-pretty/diff-render
88
+ @xynogen/pix-pretty/highlight
89
+ @xynogen/pix-pretty/lang
90
+ @xynogen/pix-pretty/icons
91
+ @xynogen/pix-pretty/icon-catalog
92
+ @xynogen/pix-pretty/icon-persist
93
+ @xynogen/pix-pretty/renderers
94
+ @xynogen/pix-pretty/fff
95
+ @xynogen/pix-pretty/types
96
+ @xynogen/pix-pretty/utils
97
+ @xynogen/pix-pretty/resize
98
+ @xynogen/pix-pretty/context
99
+ @xynogen/pix-pretty/gate-overlay
100
+ @xynogen/pix-pretty/modal-frame
101
+ ```
79
102
 
80
103
  ## Full distro
81
104
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@xynogen/pix-pretty",
3
- "version": "1.7.7",
4
- "description": "Enhanced tool output rendering with syntax highlighting, file icons, tree views, FFF search, and paste chip formatting",
3
+ "version": "1.7.9",
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",
7
7
  "exports": {
@@ -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",
package/src/README.md CHANGED
@@ -1,66 +1,36 @@
1
- # pretty (vendored)
1
+ # pix-pretty/src
2
2
 
3
- Local fork of [`@heyhuynhgiabuu/pi-pretty`](https://github.com/buddingnewinsights/pi-pretty)
4
- (v0.5.1). Enhances built-in `read` / `bash` / `ls` / `find` / `grep` /
5
- `multi_grep` tool output with syntax highlighting, file icons, tree views, and
6
- FFF-backed search.
3
+ > **Historical doc — kept for reference only.**
4
+ >
5
+ > This file originally described pix-pretty as a vendored fork of
6
+ > `@heyhuynhgiabuu/pi-pretty`. That is no longer accurate: pix-pretty has
7
+ > been completely reimplemented. The current source layout, exports, and
8
+ > dependency graph live in the package-level [`README.md`](../README.md).
9
+ >
10
+ > The notes below remain only as a record of two behavioral decisions that
11
+ > differ from the original upstream; both are still in force.
7
12
 
8
- ## Why vendored
13
+ ## Behavioral decisions that survive the rewrite
9
14
 
10
- We need two behavioral changes the upstream package does not expose as config,
11
- so the source is copied here 1:1 and patched:
15
+ These two changes were made when pix-pretty was a vendored fork and are
16
+ preserved in the reimplementation:
12
17
 
13
18
  1. **Highlight engine: shiki → cli-highlight.**
14
- Upstream uses `@shikijs/cli` (`codeToANSI`, TextMate grammars + WASM).
15
- This fork uses [`cli-highlight`](https://www.npmjs.com/package/cli-highlight)
16
- (highlight.js-backed, synchronous). The `hlBlock` interface, language table,
19
+ Upstream used `@shikijs/cli` (`codeToANSI`, TextMate grammars + WASM).
20
+ The reimplementation uses [`cli-highlight`](https://www.npmjs.com/package/cli-highlight)
21
+ (highlight.js-backed, synchronous). `HLJS_LANG_ALIAS` maps shiki-style ids
22
+ (`tsx`, `jsx`, `jsonc`, `mdx`, `make`, `svelte`, `vue`) onto
23
+ highlight.js-supported ids. The `hlBlock` interface, language table,
17
24
  line-number layout, and low-contrast normalization are unchanged.
18
25
 
19
26
  2. **FFF state dir: `~/.pi/agent/pi-pretty/fff` → `~/.cache/pi/fff`.**
20
- `getPiPrettyFffDir()` now resolves to `$XDG_CACHE_HOME/pi/fff`
21
- (default `~/.cache/pi/fff`), overridable with `PRETTY_FFF_DIR`.
27
+ `getPiPrettyFffDir()` resolves to `$XDG_CACHE_HOME/pi/fff` (default
28
+ `~/.cache/pi/fff`), overridable with `PRETTY_FFF_DIR`.
22
29
 
23
- Everything else (bash exit summary, ls tree, find grouping, grep highlighting,
24
- image metadata, tmux passthrough, `/fff-health`, `/fff-rescan`, multi_grep
25
- ripgrep fallback) is byte-for-byte upstream.
30
+ ## What moved out
26
31
 
27
- ## Diff vs upstream
32
+ - **Paste chip formatting** → [`@xynogen/pix-display`](../pix-display)
33
+ - **Reasoning tag (`<think>`/`<thinking>`) rendering** →
34
+ [`@xynogen/pix-display`](../pix-display)
28
35
 
29
- - `index.ts`
30
- - imports: `@shikijs/cli` + `shiki` types → `cli-highlight` (lazy `require`)
31
- with local `BundledLanguage`/`BundledTheme = string` aliases.
32
- - `FORCE_COLOR=3` default before chalk init (shiki always emitted truecolor;
33
- chalk decides level once at load). Respects an explicit `FORCE_COLOR` /
34
- `NO_COLOR`.
35
- - `HLJS_LANG_ALIAS` maps shiki-style ids (`tsx`, `jsx`, `jsonc`, `mdx`,
36
- `make`, `svelte`, `vue`) onto highlight.js-supported ids.
37
- - `hlBlock()` calls `highlight(code, { language, ignoreIllegals: true })`
38
- instead of `await codeToANSI(...)`. Still async-typed so call sites match.
39
- - `getPiPrettyFffDir()` → `~/.cache/pi/fff`.
40
- - `fff-helpers.ts`, `multi-grep-fallback.ts` — unchanged.
41
-
42
- ## Runtime deps & module resolution
43
-
44
- `cli-highlight` and `@ff-labs/fff-node` are installed into pi's npm root
45
- (`~/.pi/agent/npm/node_modules`) by `setup.sh`. Because the extensions dir is a
46
- symlink into dotfiles and Node dereferences symlinks before resolving bare
47
- imports, `setup.sh` also creates a git-ignored
48
- `extensions/node_modules` → `~/.pi/agent/npm/node_modules` bridge next to the
49
- real source so `require("cli-highlight")` / `import("@ff-labs/fff-node")`
50
- resolve.
51
-
52
- The upstream `npm:@heyhuynhgiabuu/pi-pretty` package is uninstalled by
53
- `setup.sh` — both register the same tool names and pi does not share tool-name
54
- ownership across extensions.
55
-
56
- ## Env vars
57
-
58
- Same as upstream (`PRETTY_THEME`, `PRETTY_MAX_HL_CHARS`,
59
- `PRETTY_MAX_PREVIEW_LINES`, `PRETTY_CACHE_LIMIT`, `PRETTY_ICONS`,
60
- `PRETTY_DISABLE_TOOLS`, `PRETTY_IMAGE_PROTOCOL`) plus:
61
-
62
- - `PRETTY_FFF_DIR` — override the FFF state dir (default `~/.cache/pi/fff`).
63
-
64
- ## License
65
-
66
- MIT — upstream by huynhgiabuu. See upstream repo.
36
+ See the package-level README for the current export map.
@@ -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]!;
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]!);
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
@@ -1,15 +1,20 @@
1
1
  /**
2
- * pi-pretty — Pretty terminal output for pi built-in tools.
2
+ * pix-pretty — Pretty terminal output for pi built-in tools.
3
3
  *
4
- * Pure rendering library no Pi lifecycle hooks or extensions.
4
+ * Primarily a rendering library (highlight/diff/icons/fff, imported by the tool
5
+ * packages). This default export is also a thin Pi extension: on load it inits
6
+ * the pretty theme, clears the highlight cache, and registers the FFF slash
7
+ * commands (/fff-health, /fff-rescan). pix-core activates it for that purpose.
5
8
  * UI features (paste chips, thinking blocks) live in pix-display.
6
9
  */
7
10
 
8
11
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
9
12
  import { registerFffCommands } from "./commands/fff.js";
13
+ import { registerPrettyCommand } from "./commands/pretty.js";
10
14
  import { getDefaultAgentDir, setPrettyTheme } from "./config.js";
11
15
  import { fffState } from "./fff.js";
12
16
  import { clearHighlightCache } from "./highlight.js";
17
+ import { initIconMode } from "./icon-persist.js";
13
18
  import type { PiPrettyApi } from "./types.js";
14
19
 
15
20
  export default function piPrettyExtension(pi: PiPrettyApi): void {
@@ -25,14 +30,14 @@ export default function piPrettyExtension(pi: PiPrettyApi): void {
25
30
  );
26
31
  clearHighlightCache();
27
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
+
28
39
  // ── FFF slash commands ──────────────────────────────────────────────
29
40
  // fffState is a module-level singleton shared with pix-grep/pix-find.
30
41
  // Commands become available once pix-grep initialises the finder.
31
42
  registerFffCommands(pi, fffState);
32
43
  }
33
-
34
- /**
35
- * piPrettyExtension still exports a default function for packages that
36
- * import it as an extension (pix-core activates it for theme + FFF).
37
- * UI extensions (paste-chips, thinking) moved to pix-display.
38
- */