@xynogen/pix-pretty 1.20.0 → 1.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-pretty",
3
- "version": "1.20.0",
3
+ "version": "1.21.0",
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",
@@ -61,10 +61,10 @@
61
61
  "access": "public"
62
62
  },
63
63
  "dependencies": {
64
- "@xynogen/pix-runtime": "^0.8.0",
64
+ "@xynogen/pix-runtime": "^0.8.4",
65
65
  "chalk": "^4.1.2",
66
66
  "cli-highlight": "^2.1.11",
67
- "@ff-labs/fff-node": "^0.5.2",
67
+ "@ff-labs/fff-node": "^0.10.6",
68
68
  "diff": "^8.0.3"
69
69
  },
70
70
  "peerDependencies": {
package/src/confirm.ts CHANGED
@@ -43,6 +43,9 @@ export interface ConfirmUI {
43
43
  }
44
44
 
45
45
  export interface ConfirmOptions {
46
+ /** Optional leading glyph for the title row, e.g. `icon("update")`. Rendered
47
+ * as "<icon> <title>" so callers pass a resolved icon + plain text. */
48
+ icon?: string;
46
49
  /** Title shown bold at the top (e.g. "Update Pi & extensions?"). */
47
50
  title: string;
48
51
  /** Optional body lines rendered under the title. */
@@ -133,7 +136,9 @@ export function confirmOverlay(ui: ConfirmUI, opts: ConfirmOptions): Promise<boo
133
136
  width: mw,
134
137
  maxHeight: terminalModalHeight(tui.terminal?.rows),
135
138
  minHeight: MIN_PERMISSION_MODAL_HEIGHT,
136
- header: [theme.fg(accent, theme.bold(opts.title))],
139
+ header: [
140
+ theme.fg(accent, theme.bold(opts.icon ? `${opts.icon} ${opts.title}` : opts.title)),
141
+ ],
137
142
  body: (opts.body ?? []).map((line) => theme.fg("text", line)),
138
143
  footer,
139
144
  bodyOffset: pager.bodyOffset,
@@ -49,6 +49,13 @@ export interface OverlayChoice {
49
49
  interface BaseConfig {
50
50
  /** Accent colour token (e.g. "error", "warning", "accent"). Default "accent". */
51
51
  accent?: string;
52
+ /**
53
+ * Optional leading glyph for the title row, e.g. `icon("lock")`. Kept
54
+ * separate from `title` so call sites pass a resolved icon and plain text
55
+ * rather than splicing them together; the overlay renders "<icon> <title>"
56
+ * in the accent color.
57
+ */
58
+ icon?: string;
52
59
  /** Title shown bold at the top. */
53
60
  title: string;
54
61
  /** Optional body lines under the title. */
@@ -174,7 +181,8 @@ function buildSections(opts: {
174
181
  width,
175
182
  } = opts;
176
183
  const inner = width - 4; // CHROME = 2 border + 2 padding
177
- const header = [theme.fg(accent, theme.bold(config.title))];
184
+ const titleText = config.icon ? `${config.icon} ${config.title}` : config.title;
185
+ const header = [theme.fg(accent, theme.bold(titleText))];
178
186
  const body = (config.body ?? []).map((line) => {
179
187
  if (line.startsWith("Warning:")) return theme.fg("warning", line);
180
188
  if (line.startsWith("(") && line.endsWith(")")) return theme.fg("muted", line);
@@ -228,7 +236,8 @@ function buildSections(opts: {
228
236
  * ```ts
229
237
  * const result = await showOverlay(ui, {
230
238
  * mode: "confirm",
231
- * title: "⚠️ DANGEROUS",
239
+ * icon: icon("warn"),
240
+ * title: "Dangerous",
232
241
  * body: ["rm -rf /tmp/work"],
233
242
  * accent: "warning",
234
243
  * timeoutMs: 30_000,
@@ -243,7 +252,8 @@ function buildSections(opts: {
243
252
  * ```ts
244
253
  * const result = await showOverlay(ui, {
245
254
  * mode: "sudo",
246
- * title: "🔐 ROOT COMMAND REQUEST",
255
+ * icon: icon("lock"),
256
+ * title: "Root Command Request",
247
257
  * body: ["Intent: install package", "Command: apt install foo"],
248
258
  * accent: "error",
249
259
  * });
@@ -1,165 +1,21 @@
1
1
  /**
2
- * icon-catalog.ts — semantic icon catalog, treated like an l10n message table.
2
+ * icon-catalog.ts — re-export shim.
3
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 `/pix`
14
- * settings command (in pix-data), persisted to `~/.pi/agent/pix.json`
15
- * (`pretty.icons`), and 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 /pix settings 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
- afk: { nerd: "\u{F0310}", unicode: `\u2328${VS}`, ascii: "kbd" },
48
-
49
- // ── footer indicators (git status, score) ─────────────────────────────
50
- "git.unstaged": { nerd: "\u2717", unicode: "\u2717", ascii: "x" },
51
- "git.ahead": { nerd: "\u21E1", unicode: "\u21E1", ascii: "^" },
52
- "git.behind": { nerd: "\u21E3", unicode: "\u21E3", ascii: "v" },
53
- "net.in": { nerd: "\u21E1", unicode: "\u21E1", ascii: "in" },
54
- "net.out": { nerd: "\u21E3", unicode: "\u21E3", ascii: "out" },
55
- score: { nerd: "\u26A1", unicode: "\u26A1", ascii: "S" },
56
-
57
- // ── misc ──────────────────────────────────────────────────────────────
58
- ok: { nerd: "\u2713", unicode: "\u2713", ascii: "ok" },
59
- warn: { nerd: "\u26A0", unicode: "\u26A0", ascii: "!" },
60
- error: { nerd: "\u2717", unicode: "\u2717", ascii: "x" },
61
-
62
- // ── shared status glyphs (checklists, panels, markers) ────────────────
63
- // nerd/unicode keep the historical literal so mixed-glyph rows stay
64
- // aligned; ascii mode swaps in tofu-free tokens. `⚡` (energetic
65
- // warning/killed/denied) intentionally stays a local literal — it is not
66
- // part of this set.
67
- "status.ok": { nerd: "\u2713", unicode: `\u2713${VS}`, ascii: "ok" },
68
- "status.error": { nerd: "\u2717", unicode: `\u2717${VS}`, ascii: "x" },
69
- // `⚠` is East-Asian wide (2 cells); consumers that place it in an aligned
70
- // marker column must normalize width via `padIcon` (pix-pretty/utils).
71
- "status.warn": { nerd: "\u26A0", unicode: `\u26A0${VS}`, ascii: "!" },
72
- "status.pending": { nerd: "\u25CB", unicode: `\u25CB${VS}`, ascii: "o" },
73
- "status.running": { nerd: "\u25D0", unicode: `\u25D0${VS}`, ascii: "*" },
74
- "status.active": { nerd: "\u25CF", unicode: `\u25CF${VS}`, ascii: "*" },
75
- "status.done": { nerd: "\u25CF", unicode: `\u25CF${VS}`, ascii: "x" },
76
- "status.blocked": { nerd: "\u2298", unicode: `\u2298${VS}`, ascii: "!" },
77
-
78
- // ── welcome banner ────────────────────────────────────────────────────
79
- ready: { nerd: "\u{F0633}", unicode: `\u2713${VS}`, ascii: "ok" },
80
-
81
- // ── paste chips (pix-display) ─────────────────────────────────────────
82
- "paste.image": { nerd: "\u{F02E9}", unicode: `\u25A3${VS}`, ascii: "img" },
83
- "paste.text": { nerd: "\u{F027F}", unicode: `\u25A4${VS}`, ascii: "txt" },
84
-
85
- // ── model picker (pix-models) ─────────────────────────────────────────
86
- "picker.model": { nerd: "\u{F0229}", unicode: `\u25C8${VS}`, ascii: "M" },
87
-
88
- // ── optimizer suite (pix-optimizer) ───────────────────────────────────
89
- "opt.caveman": { nerd: "\u{F0710}", unicode: `\u2664${VS}`, ascii: "Cv" },
90
- "opt.rtk": { nerd: "\u{F04E5}", unicode: `\u2661${VS}`, ascii: "Rk" },
91
- "opt.toon": { nerd: "\u{F05C0}", unicode: `\u2662${VS}`, ascii: "Tn" },
92
- "opt.ponytail": { nerd: "\u{F0190}", unicode: `\u2667${VS}`, ascii: "Pt" },
93
- "opt.title": { nerd: "\u{F0DAB}", unicode: `\u25C8${VS}`, ascii: "*" },
94
-
95
- // ── subagent widget (pix-subagent) ────────────────────────────────────
96
- agent: { nerd: "\u{F0BA0}", unicode: `\u2699${VS}`, ascii: "@" },
97
- turns: { nerd: "\u{F006A}", unicode: `\u21BB${VS}`, ascii: "~" },
98
- tools: { nerd: "\u{F1064}", unicode: `\u2692${VS}`, ascii: "T" },
99
- tokens: { nerd: "\u{F027F}", unicode: `\u25A4${VS}`, ascii: "tk" },
100
- } as const;
101
-
102
- /** Every valid semantic icon key. */
103
- export type IconKey = keyof typeof CATALOG;
104
-
105
- /** All catalog keys (useful for /pix previews and tests). */
106
- export const ICON_KEYS = Object.keys(CATALOG) as IconKey[];
107
-
108
- /**
109
- * Active mode. Seeded from PRETTY_ICONS env (back-compat: none/off => ascii),
110
- * then overridden by a persisted choice when the host loads pix.json.
111
- */
112
- function envMode(): IconMode {
113
- const raw = (process.env.PRETTY_ICONS ?? "").toLowerCase();
114
- if (raw === "nerd" || raw === "unicode" || raw === "ascii") return raw;
115
- if (raw === "none" || raw === "off") return "ascii";
116
- return "nerd";
117
- }
118
-
119
- let activeMode: IconMode = envMode();
120
-
121
- /** Current global icon mode. */
122
- export function getIconMode(): IconMode {
123
- return activeMode;
124
- }
125
-
126
- /**
127
- * Mode-change subscribers. Most consumers resolve icon() at render time and
128
- * need no notification, but PUSHED-status consumers (e.g. the optimizer cell,
129
- * drawn once via setStatus) must repaint when the mode flips. They subscribe
130
- * here; setIconMode fires every callback on an actual change.
131
- */
132
- type ModeListener = (mode: IconMode) => void;
133
- const listeners = new Set<ModeListener>();
134
-
135
- /** Subscribe to global icon-mode changes. Returns an unsubscribe fn. */
136
- export function onIconModeChange(cb: ModeListener): () => void {
137
- listeners.add(cb);
138
- return () => listeners.delete(cb);
139
- }
140
-
141
- /**
142
- * Set the global icon mode (does NOT persist — callers that want persistence
143
- * use the /pix command, which writes pix.json then calls this). Fires
144
- * subscribers only on an actual change (no-op re-sets are ignored).
145
- */
146
- export function setIconMode(mode: IconMode): void {
147
- if (!ICON_MODES.includes(mode) || mode === activeMode) return;
148
- activeMode = mode;
149
- for (const cb of listeners) cb(mode);
150
- }
151
-
152
- /**
153
- * Resolve a semantic icon key to its glyph for the active mode — the `t(key)`
154
- * of this module. Unknown keys return "" (fail soft: never throw mid-render).
4
+ * The semantic icon catalog now lives in `@xynogen/pix-runtime` (a lower layer)
5
+ * so that pix-runtime's own UI the `/pix` settings overlay can resolve
6
+ * icons without a circular dependency on pix-pretty. This file re-exports the
7
+ * whole surface unchanged, so every existing `@xynogen/pix-pretty/icon-catalog`
8
+ * import keeps working.
155
9
  */
156
- export function icon(key: IconKey): string {
157
- const entry = CATALOG[key];
158
- return entry ? entry[activeMode] : "";
159
- }
160
10
 
161
- /** Resolve a key for an explicit mode (used by /pix previews + tests). */
162
- export function iconFor(key: IconKey, mode: IconMode): string {
163
- const entry = CATALOG[key];
164
- return entry ? entry[mode] : "";
165
- }
11
+ export {
12
+ getIconMode,
13
+ ICON_KEYS,
14
+ ICON_MODES,
15
+ type IconKey,
16
+ type IconMode,
17
+ icon,
18
+ iconFor,
19
+ onIconModeChange,
20
+ setIconMode,
21
+ } from "@xynogen/pix-runtime/icon-catalog";
@@ -77,6 +77,15 @@ export interface FrameOptions {
77
77
  fg?: (s: string) => string;
78
78
  /** Optional pre-styled string rendered as the first content row (tab bar etc.) */
79
79
  top?: string;
80
+ /**
81
+ * Plain title text embedded into the top border, e.g. `╭─ pix settings ─────╮`.
82
+ * Styled with `titleColor` (falls back to `color`). Kept as plain text so the
83
+ * frame owns the spacing/dashes; pass an already-short string (it's truncated
84
+ * to fit). Distinct from `top`, which is a full pinned content row.
85
+ */
86
+ title?: string;
87
+ /** Color function for the embedded `title` text. Defaults to `color`. */
88
+ titleColor?: (s: string) => string;
80
89
  /**
81
90
  * Wrap over-wide content instead of cutting its tail. Default true.
82
91
  *
@@ -140,6 +149,44 @@ export function fitModalLines(lines: string[], inner: number, wrap = true): FitR
140
149
  return { rows, truncated };
141
150
  }
142
151
 
152
+ // ── Column layout ───────────────────────────────────────────────────────────
153
+
154
+ export interface JoinColumnsOptions {
155
+ /** Left column width in cells. Rows are truncated/padded to exactly this. */
156
+ leftWidth: number;
157
+ /** Right column width in cells. Rows are truncated to this (no right-pad). */
158
+ rightWidth: number;
159
+ /** Spaces between the columns when no `sep` is given. Default 2. */
160
+ gap?: number;
161
+ /**
162
+ * Pre-styled separator string placed between the columns (e.g. a themed
163
+ * `" │ "`). Its own visible width is used as-is — pass `gap` OR `sep`, not
164
+ * both. When omitted the columns are separated by `gap` spaces.
165
+ */
166
+ sep?: string;
167
+ }
168
+
169
+ /**
170
+ * Stitch two column bodies into side-by-side rows (a list + preview pane, say).
171
+ * Left cells are truncated then space-padded to `leftWidth` by *visible* width
172
+ * (ANSI-aware), so styled text aligns; right cells are only truncated. The
173
+ * result has `max(left.length, right.length)` rows — the shorter column is
174
+ * padded with blanks. Pure: no wrapping, no color; callers style the cells and
175
+ * the optional `sep`.
176
+ */
177
+ export function joinColumns(left: string[], right: string[], opts: JoinColumnsOptions): string[] {
178
+ const divider = opts.sep ?? " ".repeat(Math.max(0, opts.gap ?? 2));
179
+ const height = Math.max(left.length, right.length);
180
+ const rows: string[] = [];
181
+ for (let i = 0; i < height; i++) {
182
+ const l = truncateToWidth(left[i] ?? "", opts.leftWidth);
183
+ const lPad = l + " ".repeat(Math.max(0, opts.leftWidth - visibleWidth(l)));
184
+ const r = truncateToWidth(right[i] ?? "", opts.rightWidth);
185
+ rows.push(`${lPad}${divider}${r}`);
186
+ }
187
+ return rows;
188
+ }
189
+
143
190
  /**
144
191
  * Render a rounded modal box.
145
192
  *
@@ -165,6 +212,20 @@ export function frameLines(opts: FrameOptions): string[] {
165
212
  const inner = width - CHROME;
166
213
  const dashes = "─".repeat(width - 2);
167
214
  const wrap = opts.wrap ?? true;
215
+
216
+ // Top border: bare `╭───╮`, or a titled `╭─ Title ──╮` when `title` is set.
217
+ // The title sits after one lead dash with a padding space on each side; the
218
+ // remaining run fills with dashes so the border stays exactly `width` wide.
219
+ const topBorder = ((): string => {
220
+ // Full width = ╭ + lead dash + " " + label + " " + tail dashes + ╮.
221
+ // Fixed chrome around the label is 5 cols (2 corners, 1 lead dash, 2 pads).
222
+ const span = width - 5;
223
+ if (!opts.title || span < 3) return color(`╭${dashes}╮`);
224
+ const paint = opts.titleColor ?? color;
225
+ const label = truncateToWidth(opts.title, span - 1, ELLIPSIS); // keep ≥1 tail dash
226
+ const tail = "─".repeat(Math.max(1, span - visibleWidth(label)));
227
+ return `${color("╭─ ")}${paint(label)}${color(` ${tail}╮`)}`;
228
+ })();
168
229
  // Expand before framing so a long command wraps instead of losing its tail.
169
230
  const { rows: lines } = fitModalLines(opts.lines, inner, wrap);
170
231
 
@@ -196,7 +257,7 @@ export function frameLines(opts: FrameOptions): string[] {
196
257
  return bg(`${color("│")} ${body} ${color("│")}`);
197
258
  };
198
259
 
199
- const out: string[] = [bg(color(`╭${dashes}╮`))];
260
+ const out: string[] = [bg(topBorder)];
200
261
  if (top !== undefined) out.push(row(top));
201
262
  for (const line of lines) out.push(row(line));
202
263
  out.push(bg(color(`╰${dashes}╯`)));
@@ -475,7 +536,16 @@ export function frameModal(opts: ModalFrameOptions): ModalFrameResult {
475
536
  wrap,
476
537
  ).rows.slice(0, Math.max(1, cap - 2));
477
538
  return {
478
- lines: frameLines({ width, lines: diag, color, bg, fg, wrap: false }),
539
+ lines: frameLines({
540
+ width,
541
+ lines: diag,
542
+ color,
543
+ bg,
544
+ fg,
545
+ title: opts.title,
546
+ titleColor: opts.titleColor,
547
+ wrap: false,
548
+ }),
479
549
  bodyOffset: 0,
480
550
  maxBodyOffset: 0,
481
551
  visibleBodyLines: 0,
@@ -520,7 +590,17 @@ export function frameModal(opts: ModalFrameOptions): ModalFrameResult {
520
590
 
521
591
  return {
522
592
  // Already fitted above — pass wrap:false so rows are not re-expanded.
523
- lines: frameLines({ width, lines, color, bg, fg, top, wrap: false }),
593
+ lines: frameLines({
594
+ width,
595
+ lines,
596
+ color,
597
+ bg,
598
+ fg,
599
+ top,
600
+ title: opts.title,
601
+ titleColor: opts.titleColor,
602
+ wrap: false,
603
+ }),
524
604
  bodyOffset: offset,
525
605
  maxBodyOffset,
526
606
  visibleBodyLines,