@xynogen/pix-pretty 1.8.1 → 1.11.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.11.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",
package/src/config.ts CHANGED
@@ -20,6 +20,15 @@ const pc = config(prettySection);
20
20
 
21
21
  export const MAX_HL_CHARS = pixOrEnvInt("PRETTY_MAX_HL_CHARS", pc.maxHighlightChars, 80_000);
22
22
 
23
+ // Per-LINE guard. MAX_HL_CHARS caps the whole block, but cli-highlight's
24
+ // highlight.js tokenizer backtracks catastrophically on a single very long
25
+ // line (a multi-KB JSON string value, a minified one-liner) and freezes the
26
+ // render thread. JSON can't be hard-wrapped without splitting values mid-token
27
+ // (which breaks highlighting), so a block containing any line past this width
28
+ // is returned plain instead. ponytail: fixed threshold; make it a pix.json knob
29
+ // only if a real file legitimately needs highlighted lines wider than this.
30
+ export const MAX_HL_LINE_CHARS = envInt("PRETTY_MAX_HL_LINE_CHARS", 2_000);
31
+
23
32
  export const MAX_PREVIEW_LINES = pixOrEnvInt("PRETTY_MAX_PREVIEW_LINES", pc.maxPreviewLines, 80);
24
33
 
25
34
  export const CACHE_LIMIT = pixOrEnvInt("PRETTY_CACHE_LIMIT", pc.cacheLimit, 128);
@@ -23,4 +23,14 @@ describe("active-theme syntax highlighting", () => {
23
23
  await hlBlock("const value = 1", "typescript", theme("30;40;50"));
24
24
  expect(_cache.size).toBe(2);
25
25
  });
26
+
27
+ test("bails to plain (never highlights) when any line exceeds the per-line guard", async () => {
28
+ // Regression: a single multi-KB JSON string value made cli-highlight's
29
+ // tokenizer backtrack and froze the render thread. The guard returns the
30
+ // block unhighlighted (no ANSI, not cached) instead of tokenizing it.
31
+ const mega = JSON.stringify({ blurb: "x".repeat(5000) });
32
+ const out = await hlBlock(mega, "json", theme("10;20;30"));
33
+ expect(out.join("\n")).toBe(mega); // untouched, no ANSI escapes injected
34
+ expect(_cache.size).toBe(0); // not cached — it never went through highlight()
35
+ });
26
36
  });
package/src/highlight.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { normalizeShikiContrast } from "./ansi.js";
2
- import { CACHE_LIMIT, MAX_HL_CHARS } from "./config.js";
2
+ import { CACHE_LIMIT, MAX_HL_CHARS, MAX_HL_LINE_CHARS } from "./config.js";
3
3
  import type { BundledLanguage, FgTheme } from "./types.js";
4
4
 
5
5
  // Engine: cli-highlight (highlight.js-backed, synchronous ANSI output).
@@ -139,6 +139,13 @@ export async function hlBlock(
139
139
  ): Promise<string[]> {
140
140
  if (!code) return [""];
141
141
  if (!language || code.length > MAX_HL_CHARS) return code.split("\n");
142
+ // A single mega-line makes highlight.js backtrack catastrophically and freezes
143
+ // the render thread — bail to plain before it reaches cli-highlight. Cheap
144
+ // scan: split is already needed for the plain fallback and the cache miss path.
145
+ const rawLines = code.split("\n");
146
+ for (const line of rawLines) {
147
+ if (line.length > MAX_HL_LINE_CHARS) return rawLines;
148
+ }
142
149
 
143
150
  const hljsLang = toHljsLang(language);
144
151
  if (!hljsLang) return code.split("\n");
@@ -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.test.ts CHANGED
@@ -4,10 +4,12 @@ import { MAX_PREVIEW_LINES } from "./config.js";
4
4
  import type { FgTheme } from "./types.js";
5
5
  import {
6
6
  formatCollapsedToolRow,
7
+ formatJson,
7
8
  hideCollapsedToolCall,
8
9
  pluralize,
9
10
  renderCollapsedToolRow,
10
11
  renderDimPreview,
12
+ ruleFrame,
11
13
  setResultDetails,
12
14
  } from "./utils.js";
13
15
 
@@ -17,6 +19,23 @@ function plain(text: string): string {
17
19
  return text.replace(ANSI, "");
18
20
  }
19
21
 
22
+ describe("ruleFrame", () => {
23
+ it("wraps body with a rule top and bottom, then footer below the close", () => {
24
+ const out = ruleFrame(["a", "b"], ["… +3 more"], 10);
25
+ expect(out).toHaveLength(5);
26
+ expect(plain(out[0]!)).toBe("─".repeat(10)); // top rule
27
+ expect(out.slice(1, 3)).toEqual(["a", "b"]); // body
28
+ expect(plain(out[3]!)).toBe("─".repeat(10)); // bottom rule closes the block
29
+ expect(plain(out[4]!)).toBe("… +3 more"); // footer after the close
30
+ });
31
+
32
+ it("closes the block even with no footer", () => {
33
+ const out = ruleFrame(["only"], [], 4);
34
+ expect(plain(out[0]!)).toBe("────");
35
+ expect(plain(out.at(-1)!)).toBe("────");
36
+ });
37
+ });
38
+
20
39
  // Minimal theme: fg() passes text through untouched.
21
40
  const theme: FgTheme = { fg: (_key, text) => text };
22
41
 
@@ -36,6 +55,55 @@ describe("pluralize", () => {
36
55
  });
37
56
  });
38
57
 
58
+ describe("formatJson", () => {
59
+ it("reindents a JSON string into a multiline block", () => {
60
+ expect(formatJson('{"a":1,"b":2}')).toBe('{\n "a": 1,\n "b": 2\n}');
61
+ });
62
+
63
+ it("reindents an object value", () => {
64
+ expect(formatJson({ a: 1 })).toBe('{\n "a": 1\n}');
65
+ });
66
+
67
+ it("falls back to the raw string for non-JSON input", () => {
68
+ expect(formatJson("not json")).toBe("not json");
69
+ });
70
+
71
+ it("reindenting a mega JSON one-liner breaks it into short, still-valid lines", () => {
72
+ // A JSON one-liner is the pathological render case. Reindenting alone splits
73
+ // it into short lines; it must NOT be hard-wrapped (that would split string
74
+ // values mid-token) so the block stays valid JSON for syntax highlighting.
75
+ const obj = { results: Array.from({ length: 300 }, (_, i) => ({ i, name: `item-${i}` })) };
76
+ const mega = JSON.stringify(obj); // one long line
77
+ const out = formatJson(mega, { wrapWidth: 80, maxLines: 9999 });
78
+ expect(out.split("\n").length).toBeGreaterThan(300); // broken into many lines
79
+ expect(() => JSON.parse(out)).not.toThrow(); // still valid JSON → highlightable
80
+ });
81
+
82
+ it("hard-wraps a NON-JSON mega-line but leaves JSON untouched", () => {
83
+ // A genuine non-JSON one-liner (multi-KB plain string) still gets wrapped so
84
+ // the TUI never measures a single huge line.
85
+ const plain = "x".repeat(7806); // not JSON
86
+ const wrapped = formatJson(plain, { wrapWidth: 80, maxLines: 9999 });
87
+ const lines = wrapped.split("\n");
88
+ expect(Math.max(...lines.map((l) => l.length))).toBeLessThanOrEqual(80);
89
+ expect(wrapped.replace(/\n/g, "").length).toBe(plain.length); // lossless
90
+ });
91
+
92
+ it("caps line count with a `+N more` footer", () => {
93
+ const obj = Object.fromEntries(Array.from({ length: 200 }, (_, i) => [`k${i}`, i]));
94
+ const out = formatJson(obj, { maxLines: 10 });
95
+ const lines = out.split("\n");
96
+ expect(lines.length).toBe(11); // 10 + footer
97
+ expect(lines.at(-1)).toMatch(/^… \+\d+ more$/);
98
+ });
99
+
100
+ it("applies a hard char ceiling as a last-resort guard", () => {
101
+ const out = formatJson({ blob: "y".repeat(5000) }, { maxChars: 100, maxLines: 999 });
102
+ expect(out.length).toBeLessThanOrEqual(100);
103
+ expect(out.endsWith("…")).toBe(true);
104
+ });
105
+ });
106
+
39
107
  describe("collapsed tool rows", () => {
40
108
  const rowTheme = { fg: (_key: string, text: string) => text, bold: (text: string) => text };
41
109
 
@@ -119,6 +187,24 @@ describe("renderDimPreview", () => {
119
187
  expect(out).toContain("body");
120
188
  });
121
189
 
190
+ it("frames the body with a rule top and bottom, header above the top rule", () => {
191
+ const out = plain(renderDimPreview("a\nb", theme, { frame: true, header: "2 files" }));
192
+ const lines = out.split("\n");
193
+ // header, top rule, a, b, bottom rule
194
+ expect(lines[0]).toContain("2 files");
195
+ expect(lines[1]).toMatch(/^─+$/); // top rule
196
+ expect(lines.at(-1)).toMatch(/^─+$/); // bottom rule closes the block
197
+ });
198
+
199
+ it("frames overflow footer below the bottom rule", () => {
200
+ const body = Array.from({ length: MAX_PREVIEW_LINES + 2 }, (_, i) => `L${i}`);
201
+ const out = plain(renderDimPreview(body.join("\n"), theme, { frame: true }));
202
+ const lines = out.split("\n");
203
+ // overflow marker is the LAST line, below the closing rule
204
+ expect(lines.at(-1)).toContain("… 2 more lines");
205
+ expect(lines.at(-2)).toMatch(/^─+$/); // bottom rule sits above the footer
206
+ });
207
+
122
208
  it("highlights matched keyword with non-dim styling", () => {
123
209
  const raw = renderDimPreview("foo bar foo", theme, { highlight: "foo" });
124
210
  // matched 'foo' wrapped in yellow/bold ANSI (not produced by stub fg)
package/src/utils.ts CHANGED
@@ -52,6 +52,77 @@ export function pluralize(count: number, noun: string, plural?: string): string
52
52
  return `${count} ${count === 1 ? noun : (plural ?? `${noun}s`)}`;
53
53
  }
54
54
 
55
+ export interface FormatJsonOptions {
56
+ /** Hard char ceiling for the whole block; a longer block is clipped with an ellipsis. */
57
+ maxChars?: number;
58
+ /** Line ceiling; excess lines are dropped for a `… +N more` footer. Default MAX_PREVIEW_LINES. */
59
+ maxLines?: number;
60
+ /**
61
+ * Fallback per-line hard-wrap for NON-JSON input only. When the value parses
62
+ * as JSON, reindenting already breaks any mega-line into short lines AND the
63
+ * result stays valid JSON, so callers can still syntax-highlight it — wrapping
64
+ * it would split string values mid-token and defeat highlighting. This only
65
+ * bites a genuine non-JSON one-liner (a multi-KB plain string) the TUI can't
66
+ * wrap on its own. 0 disables.
67
+ */
68
+ wrapWidth?: number;
69
+ }
70
+
71
+ // Hard-wrap a single line into `width`-char chunks. Preserves all characters
72
+ // (this is wrapping, not truncation) so the data stays complete.
73
+ function hardWrapLine(line: string, width: number): string[] {
74
+ if (width <= 0 || line.length <= width) return [line];
75
+ const out: string[] = [];
76
+ for (let i = 0; i < line.length; i += width) out.push(line.slice(i, i + width));
77
+ return out;
78
+ }
79
+
80
+ /**
81
+ * Pretty-print a JSON-ish value for terminal display and bound its cost.
82
+ *
83
+ * Splitting an object into short lines is itself a win: the TUI measures/wraps
84
+ * per line, so one huge line is the pathological case; a re-serialized object
85
+ * is many cheap lines even when it has more total chars.
86
+ *
87
+ * Bounds applied in order: parse+reindent → (non-JSON only) hard-wrap long
88
+ * lines → line cap (`… +N more`) → char cap. This shapes only the DISPLAY
89
+ * string; callers keep the untruncated payload for the model. The formatted
90
+ * JSON stays valid so callers can syntax-highlight it. Pure and host-agnostic.
91
+ */
92
+ export function formatJson(value: unknown, options: FormatJsonOptions = {}): string {
93
+ const { maxChars, maxLines = MAX_PREVIEW_LINES, wrapWidth = 0 } = options;
94
+
95
+ let text: string;
96
+ let isJson = true;
97
+ try {
98
+ const parsed = typeof value === "string" ? JSON.parse(value) : value;
99
+ text = JSON.stringify(parsed, null, 2);
100
+ } catch {
101
+ // Not JSON (or a circular object) — fall back to a plain string, still bounded.
102
+ text = typeof value === "string" ? value : String(value);
103
+ isJson = false;
104
+ }
105
+
106
+ let lines = text.split("\n");
107
+ // Only hard-wrap non-JSON: wrapping reindented JSON would split string values
108
+ // mid-token and invalidate the block for downstream highlighting. JSON is
109
+ // already short-lined after reindent, so it never needs this guard.
110
+ if (wrapWidth > 0 && !isJson) lines = lines.flatMap((line) => hardWrapLine(line, wrapWidth));
111
+
112
+ if (lines.length > maxLines) {
113
+ const hidden = lines.length - maxLines;
114
+ lines = [...lines.slice(0, maxLines), `… +${hidden} more`];
115
+ }
116
+
117
+ let out = lines.join("\n");
118
+ if (maxChars !== undefined && out.length > maxChars) {
119
+ // Char cap is a last-resort guard (e.g. many wrapped lines under the line
120
+ // cap still exceeding the budget).
121
+ out = `${out.slice(0, Math.max(0, maxChars - 1))}…`;
122
+ }
123
+ return out;
124
+ }
125
+
55
126
  export type CollapsedToolStatus = "success" | "error" | "warning";
56
127
 
57
128
  type CollapsedToolTheme = {
@@ -103,9 +174,16 @@ export function hideCollapsedToolCall(
103
174
 
104
175
  export type DimPreviewOptions = {
105
176
  maxLines?: number;
177
+ /** Header line shown above the body (or above the top rule when framed).
178
+ * Pass the tool's SEMANTIC count here (e.g. `pluralize(matchCount, "match")`)
179
+ * — the same value the collapsed summary row uses — so top and collapsed
180
+ * never disagree. Do not recount the body: notices/blank lines make a body
181
+ * line-count diverge from the semantic count. */
106
182
  header?: string;
107
183
  /** Pattern whose matches are highlighted (green bold) inside dim lines. */
108
184
  highlight?: string;
185
+ /** Wrap the body in a top/bottom rule frame (header above, overflow below), like bash/read/mcp. */
186
+ frame?: boolean;
109
187
  };
110
188
 
111
189
  function dimLineWithHighlight(line: string, theme: FgTheme, pattern?: string): string {
@@ -136,14 +214,25 @@ export function renderDimPreview(
136
214
  const highlight = opts.highlight;
137
215
  const output = normalizeLineEndings(text).trim() || "done";
138
216
  const lines = output.split("\n");
139
- const preview = lines
217
+ const body = lines
140
218
  .slice(0, maxLines)
141
219
  .map((line) => ` ${dimLineWithHighlight(line, theme, highlight)}`);
142
- if (opts.header) preview.unshift(` ${theme.fg("dim", opts.header)}`);
143
- if (lines.length > maxLines) {
144
- const more = pluralize(lines.length - maxLines, "more line");
145
- preview.push(` ${theme.fg("dim", `… ${more}`)}`);
220
+ const header = opts.header ? ` ${theme.fg("dim", opts.header)}` : undefined;
221
+ const overflow =
222
+ lines.length > maxLines
223
+ ? ` ${theme.fg("dim", `… ${pluralize(lines.length - maxLines, "more line")}`)}`
224
+ : undefined;
225
+
226
+ if (opts.frame) {
227
+ // Same layout as bash/read/mcp: header above the top rule, body between the
228
+ // rules, overflow footer below the bottom rule.
229
+ const out = [...(header ? [header] : []), ...ruleFrame(body, overflow ? [overflow] : [])];
230
+ return fillToolBackground(out.join("\n"));
146
231
  }
232
+
233
+ const preview = body;
234
+ if (header) preview.unshift(header);
235
+ if (overflow) preview.push(overflow);
147
236
  return fillToolBackground(preview.join("\n"));
148
237
  }
149
238
 
@@ -220,6 +309,21 @@ export function rule(w: number): string {
220
309
  return `${FG_RULE}${"─".repeat(w)}${RST}`;
221
310
  }
222
311
 
312
+ /**
313
+ * Frame tool output the way bash/read/sudo do: a top rule, the body lines, a
314
+ * bottom rule, then any footer lines (e.g. `… +N more`) below the close. The
315
+ * single source of the "rule top, rule bottom" invariant so every tool's result
316
+ * block is framed identically — MCP and the shell tools share this.
317
+ */
318
+ export function ruleFrame(
319
+ bodyLines: string[],
320
+ footerLines: string[] = [],
321
+ width?: number,
322
+ ): string[] {
323
+ const r = rule(width ?? termW());
324
+ return [r, ...bodyLines, r, ...footerLines];
325
+ }
326
+
223
327
  export function lnum(n: number, w: number): string {
224
328
  const v = String(n);
225
329
  return `${FG_LNUM}${" ".repeat(Math.max(0, w - v.length))}${v}${RST}`;
@@ -230,9 +334,10 @@ export function lnum(n: number, w: number): string {
230
334
  // ---------------------------------------------------------------------------
231
335
 
232
336
  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`;
337
+ if (bytes < 1024) return `${bytes} B`;
338
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`;
339
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
340
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GiB`;
236
341
  }
237
342
 
238
343
  // ---------------------------------------------------------------------------
@@ -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
+ }