@xynogen/pix-pretty 1.8.1 → 1.10.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/README.md CHANGED
@@ -11,7 +11,7 @@ consume. It does not register user-facing tools itself — the tool renderers
11
11
  initializes the syntax-highlight theme from Pi settings, clears the highlight
12
12
  cache, seeds the icon mode from `pix.json`, and registers two FFF slash
13
13
  commands (`/fff-health`, `/fff-rescan`) once `pix-grep` has brought the FFF
14
- finder online. The `/pix` settings command lives in `pix-data`.
14
+ finder online. The `/pix` settings command lives in `pix-runtime`.
15
15
  (Activated by `pix-core`; not a standalone extension.)
16
16
 
17
17
  ### Rendering
@@ -36,7 +36,7 @@ problem on terminals without a Nerd Font, becomes a one-file edit here.
36
36
  `getIconMode()`, `setIconMode()`, `ICON_KEYS`, `ICON_MODES`.
37
37
  - **`./icon-persist`** — reads/writes the icon mode via `pix.json`
38
38
  (`pretty.icons`); `initIconMode()` applies it on load.
39
- - **`/pix`** (in `pix-data`) — unified settings overlay that includes the icon
39
+ - **`/pix`** (in `pix-runtime`) — unified settings overlay that includes the icon
40
40
  mode switch. One global knob governs every pix-* package (footer, paste
41
41
  chips, model picker, welcome banner, optimizer cell). Seeded from
42
42
  `PRETTY_ICONS` (`none`/`off` → `ascii`) when no choice is saved.
@@ -62,9 +62,9 @@ pi install npm:@xynogen/pix-pretty
62
62
 
63
63
  ## Configuration
64
64
 
65
- Configuration is read from **`~/.pi/agent/pix.json`** (the unified config file hosted by `@xynogen/pix-data/pix-config`). The `pretty` section of that file sets the defaults for theme, icon mode, and preview lines. Environment variables still override `pix.json` values.
65
+ Configuration is read from **`~/.pi/agent/pix.json`** (the unified config file owned by `@xynogen/pix-runtime/config`). The `pretty` section of that file sets the defaults for theme, icon mode, and preview lines. Environment variables still override `pix.json` values.
66
66
 
67
- > **Note:** `pix-config.ts` and `collapse.ts` previously shipped with `pix-pretty` they have moved to `pix-data` (`@xynogen/pix-data/pix-config` and `@xynogen/pix-data/collapse`). Update any direct imports.
67
+ > **Note:** config and collapse helpers previously shipped with `pix-pretty`; they now live in `pix-runtime` (`@xynogen/pix-runtime/config` and `@xynogen/pix-runtime/collapse`). Update any direct imports.
68
68
 
69
69
  ### `pix.json` — `pretty` section
70
70
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-pretty",
3
- "version": "1.8.1",
3
+ "version": "1.10.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",
@@ -21,6 +21,7 @@
21
21
  "./fff": "./src/fff.ts",
22
22
  "./types": "./src/types.ts",
23
23
  "./utils": "./src/utils.ts",
24
+ "./widget-format": "./src/widget-format.ts",
24
25
  "./resize": "./src/resize.ts",
25
26
  "./context": "./src/tools/context.ts",
26
27
  "./gate-overlay": "./src/gate-overlay.ts",
@@ -39,6 +39,32 @@ describe("icon-catalog", () => {
39
39
  }
40
40
  });
41
41
 
42
+ it("status family keeps historical nerd glyphs and gains ascii tokens", () => {
43
+ // nerd mode must equal the pre-catalog literals so mixed-glyph rows and
44
+ // existing snapshot assertions stay aligned.
45
+ expect(iconFor("status.ok", "nerd")).toBe("\u2713");
46
+ expect(iconFor("status.error", "nerd")).toBe("\u2717");
47
+ expect(iconFor("status.warn", "nerd")).toBe("\u26A0");
48
+ expect(iconFor("status.pending", "nerd")).toBe("\u25CB");
49
+ expect(iconFor("status.running", "nerd")).toBe("\u25D0");
50
+ expect(iconFor("status.active", "nerd")).toBe("\u25CF");
51
+ expect(iconFor("status.done", "nerd")).toBe("\u25CF");
52
+ expect(iconFor("status.blocked", "nerd")).toBe("\u2298");
53
+ // ascii mode must be tofu-free (letters/punctuation only).
54
+ for (const key of [
55
+ "status.ok",
56
+ "status.error",
57
+ "status.warn",
58
+ "status.pending",
59
+ "status.running",
60
+ "status.active",
61
+ "status.done",
62
+ "status.blocked",
63
+ ] as const) {
64
+ expect(iconFor(key, "ascii")).toMatch(/^[\x20-\x7e]+$/);
65
+ }
66
+ });
67
+
42
68
  it("unknown key fails soft to empty string", () => {
43
69
  // @ts-expect-error exercising the runtime guard
44
70
  expect(icon("does.not.exist")).toBe("");
@@ -58,6 +58,20 @@ const CATALOG = {
58
58
  warn: { nerd: "\u26A0", unicode: "\u26A0", ascii: "!" },
59
59
  error: { nerd: "\u2717", unicode: "\u2717", ascii: "x" },
60
60
 
61
+ // ── shared status glyphs (checklists, panels, markers) ────────────────
62
+ // nerd/unicode keep the historical literal so mixed-glyph rows stay
63
+ // aligned; ascii mode swaps in tofu-free tokens. `⚡` (energetic
64
+ // warning/killed/denied) intentionally stays a local literal — it is not
65
+ // part of this set.
66
+ "status.ok": { nerd: "\u2713", unicode: `\u2713${VS}`, ascii: "ok" },
67
+ "status.error": { nerd: "\u2717", unicode: `\u2717${VS}`, ascii: "x" },
68
+ "status.warn": { nerd: "\u26A0", unicode: `\u26A0${VS}`, ascii: "!" },
69
+ "status.pending": { nerd: "\u25CB", unicode: `\u25CB${VS}`, ascii: "o" },
70
+ "status.running": { nerd: "\u25D0", unicode: `\u25D0${VS}`, ascii: "*" },
71
+ "status.active": { nerd: "\u25CF", unicode: `\u25CF${VS}`, ascii: "*" },
72
+ "status.done": { nerd: "\u25CF", unicode: `\u25CF${VS}`, ascii: "x" },
73
+ "status.blocked": { nerd: "\u2298", unicode: `\u2298${VS}`, ascii: "!" },
74
+
61
75
  // ── welcome banner ────────────────────────────────────────────────────
62
76
  ready: { nerd: "\u{F0633}", unicode: `\u2713${VS}`, ascii: "ok" },
63
77
 
package/src/progress.ts CHANGED
@@ -14,6 +14,7 @@
14
14
  */
15
15
 
16
16
  import { frameModal, MIN_MODAL_HEIGHT, modalWidth, terminalModalHeight } from "./modal-frame.js";
17
+ import { SPINNER } from "./widget-format.js";
17
18
 
18
19
  interface ProgressTheme {
19
20
  fg(color: string, text: string): string;
@@ -46,7 +47,6 @@ export interface ProgressHandle {
46
47
  close(): void;
47
48
  }
48
49
 
49
- const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
50
50
  // 120ms: smooth enough to read as motion, slow enough to barely touch the
51
51
  // render queue. The overlay owns input so this isn't competing with echo.
52
52
  const SPINNER_INTERVAL_MS = 120;
package/src/utils.ts CHANGED
@@ -230,9 +230,10 @@ export function lnum(n: number, w: number): string {
230
230
  // ---------------------------------------------------------------------------
231
231
 
232
232
  export function humanSize(bytes: number): string {
233
- if (bytes < 1024) return `${bytes}B`;
234
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
235
- return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
233
+ if (bytes < 1024) return `${bytes} B`;
234
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`;
235
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
236
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GiB`;
236
237
  }
237
238
 
238
239
  // ---------------------------------------------------------------------------
@@ -0,0 +1,114 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ describeActivity,
4
+ fmtTokenCount,
5
+ formatContext,
6
+ formatMs,
7
+ formatSpeed,
8
+ formatTokens,
9
+ formatToolUses,
10
+ formatTurns,
11
+ getSessionContextPercent,
12
+ getSessionContextUsage,
13
+ SPINNER,
14
+ truncateLine,
15
+ } from "./widget-format.ts";
16
+
17
+ const stripAnsi = (text: string) => text.replace(/\x1b\[[0-9;]*m/g, "");
18
+
19
+ describe("widget formatters", () => {
20
+ test("SPINNER has frames to cycle", () => {
21
+ expect(SPINNER.length).toBeGreaterThan(1);
22
+ });
23
+
24
+ test("fmtTokenCount scales with magnitude", () => {
25
+ expect(fmtTokenCount(500)).toBe("500");
26
+ expect(fmtTokenCount(30_100)).toBe("30.1K");
27
+ expect(fmtTokenCount(1_000_000)).toBe("1.00M");
28
+ });
29
+
30
+ test("formatTokens uses ' token' / 'k token' / 'M token' variants", () => {
31
+ expect(stripAnsi(formatTokens(500))).toContain("500 token");
32
+ expect(stripAnsi(formatTokens(12_400))).toContain("12.4k token");
33
+ expect(stripAnsi(formatTokens(2_500_000))).toContain("2.5M token");
34
+ });
35
+
36
+ test("formatMs renders seconds to one decimal", () => {
37
+ expect(formatMs(2_100)).toBe("2.1s");
38
+ });
39
+
40
+ test("formatSpeed returns empty when there is no work", () => {
41
+ expect(formatSpeed(0, 1_000)).toBe("");
42
+ expect(formatSpeed(100, 0)).toBe("");
43
+ expect(stripAnsi(formatSpeed(200, 2_000))).toBe("100 t/s");
44
+ });
45
+
46
+ test("formatContext shows used/window/percent, or empty when unknown", () => {
47
+ expect(formatContext(null)).toBe("");
48
+ expect(formatContext({ tokens: null, contextWindow: null, percent: null })).toBe("");
49
+ expect(
50
+ stripAnsi(formatContext({ tokens: 30_100, contextWindow: 1_000_000, percent: 3 })),
51
+ ).toContain("30.1K/1.00M (3%)");
52
+ expect(stripAnsi(formatContext({ tokens: null, contextWindow: null, percent: 42 }))).toContain(
53
+ "42% ctx",
54
+ );
55
+ });
56
+
57
+ test("formatTurns and formatToolUses render counts", () => {
58
+ expect(stripAnsi(formatTurns(3))).toContain("3");
59
+ expect(stripAnsi(formatTurns(3, 10))).toContain("3\u226410");
60
+ expect(stripAnsi(formatToolUses(5))).toContain("5");
61
+ });
62
+
63
+ test("truncateLine tail-anchors the latest non-empty line to len", () => {
64
+ expect(truncateLine("short", 32)).toBe("short");
65
+ expect(truncateLine("a\nb\nlatest", 32)).toBe("latest");
66
+ expect(truncateLine("0123456789", 4)).toBe("\u20266789");
67
+ });
68
+
69
+ test("describeActivity groups active tools, tails text (default 32), else thinking", () => {
70
+ const two = new Map<string, string>([
71
+ ["0", "read"],
72
+ ["1", "read"],
73
+ ]);
74
+ expect(describeActivity(two)).toBe("reading 2\u00d7\u2026");
75
+ expect(describeActivity(new Map(), "line one\nlatest line")).toBe("latest line");
76
+ expect(describeActivity(new Map())).toBe("thinking\u2026");
77
+ });
78
+
79
+ test("describeActivity honors an explicit tailLen", () => {
80
+ expect(describeActivity(new Map(), "0123456789", 4)).toBe("\u20266789");
81
+ });
82
+
83
+ test("getSessionContextUsage reads stats and tolerates throwing sessions", () => {
84
+ const session = {
85
+ getSessionStats: () => ({
86
+ tokens: { input: 0, output: 0, cacheWrite: 0 },
87
+ contextUsage: { tokens: 10, contextWindow: 100, percent: 10 },
88
+ }),
89
+ };
90
+ expect(getSessionContextUsage(session)).toEqual({
91
+ tokens: 10,
92
+ contextWindow: 100,
93
+ percent: 10,
94
+ });
95
+ expect(getSessionContextUsage(undefined)).toBeNull();
96
+ const throwing = {
97
+ getSessionStats: () => {
98
+ throw new Error("no stats");
99
+ },
100
+ };
101
+ expect(getSessionContextUsage(throwing)).toBeNull();
102
+ });
103
+
104
+ test("getSessionContextPercent returns just the percent, or null", () => {
105
+ const session = {
106
+ getSessionStats: () => ({
107
+ tokens: { input: 0, output: 0, cacheWrite: 0 },
108
+ contextUsage: { tokens: 10, contextWindow: 100, percent: 42 },
109
+ }),
110
+ };
111
+ expect(getSessionContextPercent(session)).toBe(42);
112
+ expect(getSessionContextPercent(undefined)).toBeNull();
113
+ });
114
+ });
@@ -0,0 +1,168 @@
1
+ /**
2
+ * widget-format.ts — pure, shared live-widget formatting helpers.
3
+ *
4
+ * These are the token/context/turn/tool/speed formatters plus the session
5
+ * context-usage readers used by pix-subagent's agent widget and pix-commands'
6
+ * /btw widget. They are pure (no Theme, no Pi host) so both packages can import
7
+ * them from this sanctioned shared layer instead of duplicating the code.
8
+ *
9
+ * icon() is imported locally (this module lives inside pix-pretty).
10
+ */
11
+
12
+ import { icon } from "./icon-catalog.ts";
13
+
14
+ // ── Braille spinner ──────────────────────────────────────────────────────────
15
+
16
+ export const SPINNER = [
17
+ "\u280b",
18
+ "\u2819",
19
+ "\u2839",
20
+ "\u2838",
21
+ "\u283c",
22
+ "\u2834",
23
+ "\u2826",
24
+ "\u2827",
25
+ "\u2807",
26
+ "\u280f",
27
+ ];
28
+
29
+ // ── Session-stats shapes + readers ─────────────────────────────────────────────
30
+
31
+ /** Minimal shape we read from upstream `getSessionStats()`. */
32
+ export type SessionStatsLike = {
33
+ tokens: { input: number; output: number; cacheWrite: number };
34
+ contextUsage?: { tokens?: number | null; contextWindow?: number; percent: number | null };
35
+ };
36
+ export type SessionLike = { getSessionStats(): SessionStatsLike };
37
+
38
+ /** Context usage snapshot: estimated used tokens, window size, percent. */
39
+ export type ContextUsageLike = {
40
+ tokens: number | null;
41
+ contextWindow: number | null;
42
+ percent: number | null;
43
+ };
44
+
45
+ /** Full context usage, or null when unavailable. */
46
+ export function getSessionContextUsage(session: SessionLike | undefined): ContextUsageLike | null {
47
+ if (!session) return null;
48
+ try {
49
+ const cu = session.getSessionStats().contextUsage;
50
+ if (!cu) return null;
51
+ return {
52
+ tokens: cu.tokens ?? null,
53
+ contextWindow: cu.contextWindow ?? null,
54
+ percent: cu.percent ?? null,
55
+ };
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Context-window utilization (0–100), or null when unavailable
63
+ * (no model contextWindow, or post-compaction before the next response).
64
+ */
65
+ export function getSessionContextPercent(session: SessionLike | undefined): number | null {
66
+ return getSessionContextUsage(session)?.percent ?? null;
67
+ }
68
+
69
+ // ── Formatters ─────────────────────────────────────────────────────────────────
70
+
71
+ export function formatTokens(count: number): string {
72
+ const t = icon("tokens");
73
+ if (count >= 1_000_000) return `${t} ${(count / 1_000_000).toFixed(1)}M token`;
74
+ if (count >= 1_000) return `${t} ${(count / 1_000).toFixed(1)}k token`;
75
+ return `${t} ${count} token`;
76
+ }
77
+
78
+ /** Compact token count: 500 → "500", 30_100 → "30.1K", 1_000_000 → "1.00M". */
79
+ export function fmtTokenCount(n: number): string {
80
+ if (n < 1_000) return `${n}`;
81
+ if (n < 1_000_000) return `${(n / 1_000).toFixed(1)}K`;
82
+ return `${(n / 1_000_000).toFixed(2)}M`;
83
+ }
84
+
85
+ /**
86
+ * Format context-window utilization: "󰉿 30.1K/1.00M (3%)".
87
+ * Falls back to "󰉿 3% ctx" when the window size is unknown.
88
+ * Returns "" when percent is null/unavailable (caller should skip the segment).
89
+ */
90
+ export function formatContext(usage: ContextUsageLike | null | undefined): string {
91
+ if (usage?.percent == null) return "";
92
+ const t = icon("tokens");
93
+ const pct = Math.round(usage.percent);
94
+ if (!usage.contextWindow) return `${t} ${pct}% ctx`;
95
+ const used = usage.tokens ?? Math.round((usage.percent / 100) * usage.contextWindow);
96
+ return `${t} ${fmtTokenCount(used)}/${fmtTokenCount(usage.contextWindow)} (${pct}%)`;
97
+ }
98
+
99
+ export function formatTurns(turnCount: number, maxTurns?: number | null): string {
100
+ const t = icon("turns");
101
+ return maxTurns != null ? `${t} ${turnCount}≤${maxTurns}` : `${t} ${turnCount}`;
102
+ }
103
+
104
+ export function formatToolUses(count: number): string {
105
+ return `${icon("tools")} ${count}`;
106
+ }
107
+
108
+ export function formatMs(ms: number): string {
109
+ return `${(ms / 1000).toFixed(1)}s`;
110
+ }
111
+
112
+ /**
113
+ * Output tokens per second over a duration. "" when either input is
114
+ * non-positive (no work / zero elapsed) so callers can skip the segment.
115
+ */
116
+ export function formatSpeed(outputTokens: number, durationMs: number): string {
117
+ if (outputTokens <= 0 || durationMs <= 0) return "";
118
+ return `${Math.round(outputTokens / (durationMs / 1000))} t/s`;
119
+ }
120
+
121
+ // ── Activity description ─────────────────────────────────────────────────────
122
+
123
+ export const TOOL_DISPLAY: Record<string, string> = {
124
+ read: "reading",
125
+ bash: "running command",
126
+ edit: "editing",
127
+ write: "writing",
128
+ grep: "searching",
129
+ find: "finding files",
130
+ ls: "listing",
131
+ };
132
+
133
+ /**
134
+ * Live tail of agent output: latest non-empty line, tail-anchored to `len`
135
+ * chars (keeps the moving edge, not the stale first line).
136
+ */
137
+ export function truncateLine(text: string, len = 32): string {
138
+ const lines = text.split("\n").filter((l) => l.trim());
139
+ const line = (lines.at(-1) ?? "").trim();
140
+ if (line.length <= len) return line;
141
+ return `\u2026${line.slice(-len)}`;
142
+ }
143
+
144
+ /**
145
+ * One-line description of what an agent/job is doing: grouped active tools,
146
+ * else a tail (`tailLen` chars, default 32) of the streaming answer text, else
147
+ * "thinking…".
148
+ */
149
+ export function describeActivity(
150
+ activeTools: Map<string, string>,
151
+ responseText?: string,
152
+ tailLen = 32,
153
+ ): string {
154
+ if (activeTools.size > 0) {
155
+ const groups = new Map<string, number>();
156
+ for (const toolName of activeTools.values()) {
157
+ const action = TOOL_DISPLAY[toolName] ?? toolName;
158
+ groups.set(action, (groups.get(action) ?? 0) + 1);
159
+ }
160
+ const parts: string[] = [];
161
+ for (const [action, count] of groups) {
162
+ parts.push(count > 1 ? `${action} ${count}\u00d7` : action);
163
+ }
164
+ return `${parts.join(", ")}\u2026`;
165
+ }
166
+ if (responseText?.trim()) return truncateLine(responseText, tailLen);
167
+ return "thinking\u2026";
168
+ }