@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 +87 -64
- package/package.json +4 -2
- package/src/README.md +25 -55
- package/src/commands/pretty.ts +135 -0
- package/src/icon-catalog.test.ts +65 -0
- package/src/icon-catalog.ts +142 -0
- package/src/icon-persist.test.ts +47 -0
- package/src/icon-persist.ts +67 -0
- package/src/index.ts +13 -8
package/README.md
CHANGED
|
@@ -1,38 +1,59 @@
|
|
|
1
1
|
# pix-pretty
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
##
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
-
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
|
|
13
|
-
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
- **
|
|
20
|
-
- **
|
|
21
|
-
- **
|
|
22
|
-
- **
|
|
23
|
-
|
|
24
|
-
###
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
78
|
+
The package exposes its sub-modules via `exports`:
|
|
57
79
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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.
|
|
4
|
-
"description": "Enhanced tool output rendering with syntax highlighting, file icons, tree views,
|
|
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
|
|
1
|
+
# pix-pretty/src
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
##
|
|
13
|
+
## Behavioral decisions that survive the rewrite
|
|
9
14
|
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
15
|
-
|
|
16
|
-
(highlight.js-backed, synchronous).
|
|
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()`
|
|
21
|
-
|
|
27
|
+
`getPiPrettyFffDir()` resolves to `$XDG_CACHE_HOME/pi/fff` (default
|
|
28
|
+
`~/.cache/pi/fff`), overridable with `PRETTY_FFF_DIR`.
|
|
22
29
|
|
|
23
|
-
|
|
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
|
-
|
|
32
|
+
- **Paste chip formatting** → [`@xynogen/pix-display`](../pix-display)
|
|
33
|
+
- **Reasoning tag (`<think>`/`<thinking>`) rendering** →
|
|
34
|
+
[`@xynogen/pix-display`](../pix-display)
|
|
28
35
|
|
|
29
|
-
-
|
|
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
|
-
*
|
|
2
|
+
* pix-pretty — Pretty terminal output for pi built-in tools.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
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
|
-
*/
|