@xynogen/pix-pretty 1.18.4 → 1.20.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/src/utils.test.ts DELETED
@@ -1,517 +0,0 @@
1
- import { describe, expect, it } from "bun:test";
2
-
3
- import { MAX_PREVIEW_LINES } from "./config.js";
4
- import type { FgTheme } from "./types.js";
5
- import {
6
- dotJoin,
7
- fillToolBackground,
8
- formatCollapsedToolRow,
9
- formatJson,
10
- hideCollapsedToolCall,
11
- padIcon,
12
- pluralize,
13
- renderCollapsedToolRow,
14
- renderDimPreview,
15
- ruleFrame,
16
- sectionRule,
17
- setResultDetails,
18
- termW,
19
- viewportText,
20
- } from "./utils.js";
21
-
22
- class MockTextComponent {
23
- private text = "";
24
-
25
- setText(value: string): void {
26
- this.text = value;
27
- }
28
-
29
- render(): string[] {
30
- return this.text.split("\n");
31
- }
32
-
33
- invalidate(): void {}
34
- }
35
-
36
- // Counting variant: records how often the inner component is re-fitted, so a
37
- // regression that recomputes every frame (the pre-memo CPU bug) fails loudly.
38
- class CountingTextComponent {
39
- static setCalls = 0;
40
- static renderCalls = 0;
41
- private text = "";
42
- setText(value: string): void {
43
- CountingTextComponent.setCalls++;
44
- this.text = value;
45
- }
46
- render(): string[] {
47
- CountingTextComponent.renderCalls++;
48
- return this.text.split("\n");
49
- }
50
- invalidate(): void {}
51
- }
52
-
53
- describe("viewportText", () => {
54
- it("trims a pre-filled row to Pi's narrower fullscreen viewport", () => {
55
- const text = viewportText(MockTextComponent);
56
- text.setText(fillToolBackground("tool", "", 10));
57
-
58
- expect(plain(text.render(9)[0]!)).toBe("tool".padEnd(9));
59
- expect(plain(text.render(10)[0]!)).toBe("tool".padEnd(10));
60
- });
61
-
62
- it("memoizes: repeat render at same width does not re-fit the inner component", () => {
63
- CountingTextComponent.setCalls = 0;
64
- const text = viewportText(CountingTextComponent);
65
- text.setText("line one\nline two");
66
-
67
- text.render(20);
68
- text.render(20);
69
- text.render(20);
70
- // One fit for the width; repeats hit the cache. Pre-memo this was 3.
71
- expect(CountingTextComponent.setCalls).toBe(1);
72
- });
73
-
74
- it("re-fits when width changes, and again when it returns", () => {
75
- CountingTextComponent.setCalls = 0;
76
- const text = viewportText(CountingTextComponent);
77
- text.setText("abcdefghij");
78
-
79
- text.render(20);
80
- text.render(5); // width changed → re-fit
81
- text.render(5); // same → cached
82
- text.render(20); // changed back → re-fit
83
- expect(CountingTextComponent.setCalls).toBe(3);
84
- });
85
-
86
- it("re-fits after setText changes the content", () => {
87
- CountingTextComponent.setCalls = 0;
88
- const text = viewportText(CountingTextComponent);
89
- text.setText("first");
90
- text.render(20);
91
- text.render(20); // cached
92
- text.setText("second"); // invalidates memo
93
- text.render(20); // re-fit
94
- expect(CountingTextComponent.setCalls).toBe(2);
95
- });
96
-
97
- it("setText with identical value is a no-op (no memo invalidation)", () => {
98
- CountingTextComponent.setCalls = 0;
99
- const text = viewportText(CountingTextComponent);
100
- text.setText("same");
101
- text.render(20);
102
- text.setText("same"); // identical → must not invalidate
103
- text.render(20); // still cached
104
- expect(CountingTextComponent.setCalls).toBe(1);
105
- });
106
-
107
- it("idle frames (same text+width) do not re-call the inner render", () => {
108
- CountingTextComponent.renderCalls = 0;
109
- const text = viewportText(CountingTextComponent);
110
- text.setText("line one\nline two");
111
- text.render(20);
112
- text.render(20);
113
- text.render(20);
114
- // One inner render for the width; spinner ticks reuse the memoized output.
115
- expect(CountingTextComponent.renderCalls).toBe(1);
116
- });
117
-
118
- it("re-calls inner render after content changes", () => {
119
- CountingTextComponent.renderCalls = 0;
120
- const text = viewportText(CountingTextComponent);
121
- text.setText("first");
122
- text.render(20);
123
- text.setText("second"); // content changed → render output stale
124
- text.render(20);
125
- expect(CountingTextComponent.renderCalls).toBe(2);
126
- });
127
-
128
- it("invalidate() drops the render memo so a same-width frame recomputes (theme change)", () => {
129
- CountingTextComponent.renderCalls = 0;
130
- const text = viewportText(CountingTextComponent);
131
- text.setText("body");
132
- text.render(20);
133
- text.render(20); // memo hit
134
- expect(CountingTextComponent.renderCalls).toBe(1);
135
- text.invalidate(); // theme/style changed → output stale even at same width
136
- text.render(20); // must recompute, not serve pre-invalidate output
137
- expect(CountingTextComponent.renderCalls).toBe(2);
138
- });
139
-
140
- it("streaming append reuses already-fitted lines and fits only the new tail", () => {
141
- const text = viewportText(MockTextComponent);
142
- text.setText("aaaaaaaaaa\nbbbbbbbbbb");
143
- expect(text.render(5).map(plain)).toEqual(["aaaaa", "bbbbb"]);
144
- // Append (streaming). Old lines fit identically; new line appears.
145
- text.setText("aaaaaaaaaa\nbbbbbbbbbb\ncccccccccc");
146
- expect(text.render(5).map(plain)).toEqual(["aaaaa", "bbbbb", "ccccc"]);
147
- });
148
-
149
- it("width change re-fits all lines (line cache is width-scoped, no stale serve)", () => {
150
- const text = viewportText(MockTextComponent);
151
- text.setText("abcdefghij");
152
- expect(text.render(5).map(plain)).toEqual(["abcde"]);
153
- // Must NOT serve the stale 5-wide fit for the same raw line at a new width.
154
- expect(text.render(8).map(plain)).toEqual(["abcdefgh"]);
155
- });
156
- });
157
-
158
- // Strip ANSI escapes so assertions test content, not color codes.
159
- const ANSI = /\x1b\[[0-9;]*m/g;
160
- function plain(text: string): string {
161
- return text.replace(ANSI, "");
162
- }
163
-
164
- describe("termW", () => {
165
- // termW() caches and invalidates on a stdout 'resize' event. Set columns then
166
- // emit resize so the next call re-reads.
167
- function setCols(cols: number): void {
168
- (process.stdout as { columns?: number }).columns = cols;
169
- process.stdout.emit("resize");
170
- }
171
-
172
- it("returns the true terminal width with no upper clamp (ultrawide)", () => {
173
- const orig = process.stdout.columns;
174
- try {
175
- setCols(384); // wider than the old 210 cap
176
- expect(termW()).toBe(384);
177
- } finally {
178
- setCols(orig ?? 80);
179
- }
180
- });
181
-
182
- it("floors width at 1 for a degenerate column count", () => {
183
- const orig = process.stdout.columns;
184
- try {
185
- setCols(0); // falsy — falls through resolution chain, never < 1
186
- expect(termW()).toBeGreaterThanOrEqual(1);
187
- } finally {
188
- setCols(orig ?? 80);
189
- }
190
- });
191
- });
192
-
193
- describe("ruleFrame", () => {
194
- it("wraps body with a rule top and bottom, then footer below the close", () => {
195
- const out = ruleFrame(["a", "b"], ["… +3 more"], 10);
196
- expect(out).toHaveLength(5);
197
- expect(plain(out[0]!)).toBe("─".repeat(10)); // top rule
198
- expect(out.slice(1, 3)).toEqual(["a", "b"]); // body
199
- expect(plain(out[3]!)).toBe("─".repeat(10)); // bottom rule closes the block
200
- expect(plain(out[4]!)).toBe("… +3 more"); // footer after the close
201
- });
202
-
203
- it("closes the block even with no footer", () => {
204
- const out = ruleFrame(["only"], [], 4);
205
- expect(plain(out[0]!)).toBe("────");
206
- expect(plain(out.at(-1)!)).toBe("────");
207
- });
208
-
209
- it("paints both rules via the supplied paint fn, neutral by default", () => {
210
- const green = (s: string) => `<G>${s}</G>`;
211
- const ok = ruleFrame(["x"], [], 4, green);
212
- expect(ok[0]).toBe("<G>────</G>");
213
- expect(ok.at(-1)).toBe("<G>────</G>"); // both top and bottom rule painted
214
- const neutral = ruleFrame(["x"], [], 4);
215
- expect(neutral[0]).toContain("50;50;50"); // FG_RULE default tint
216
- });
217
- });
218
-
219
- describe("dotJoin", () => {
220
- it("joins non-empty parts with a middot, dropping falsy pieces", () => {
221
- expect(dotJoin(["a", "b", "c"])).toBe("a · b · c");
222
- expect(dotJoin(["a", "", null, undefined, false, "b"])).toBe("a · b");
223
- expect(dotJoin(["only"])).toBe("only");
224
- expect(dotJoin([])).toBe("");
225
- });
226
-
227
- it("paints the separator when a paint fn is supplied", () => {
228
- expect(dotJoin(["a", "b"], (s) => `<${s}>`)).toBe("a< · >b");
229
- });
230
- });
231
-
232
- // Minimal theme: fg() passes text through untouched.
233
- const theme: FgTheme = { fg: (_key, text) => text };
234
-
235
- describe("pluralize", () => {
236
- it("uses singular for count of 1", () => {
237
- expect(pluralize(1, "match", "matches")).toBe("1 match");
238
- });
239
-
240
- it("uses plural for count != 1", () => {
241
- expect(pluralize(0, "match", "matches")).toBe("0 matches");
242
- expect(pluralize(2, "match", "matches")).toBe("2 matches");
243
- });
244
-
245
- it("defaults plural to noun + s", () => {
246
- expect(pluralize(1, "line")).toBe("1 line");
247
- expect(pluralize(3, "line")).toBe("3 lines");
248
- });
249
- });
250
-
251
- describe("formatJson", () => {
252
- it("reindents a JSON string into a multiline block", () => {
253
- expect(formatJson('{"a":1,"b":2}')).toBe('{\n "a": 1,\n "b": 2\n}');
254
- });
255
-
256
- it("reindents an object value", () => {
257
- expect(formatJson({ a: 1 })).toBe('{\n "a": 1\n}');
258
- });
259
-
260
- it("falls back to the raw string for non-JSON input", () => {
261
- expect(formatJson("not json")).toBe("not json");
262
- });
263
-
264
- it("reindenting a mega JSON one-liner breaks it into short, still-valid lines", () => {
265
- // A JSON one-liner is the pathological render case. Reindenting alone splits
266
- // it into short lines; it must NOT be hard-wrapped (that would split string
267
- // values mid-token) so the block stays valid JSON for syntax highlighting.
268
- const obj = { results: Array.from({ length: 300 }, (_, i) => ({ i, name: `item-${i}` })) };
269
- const mega = JSON.stringify(obj); // one long line
270
- const out = formatJson(mega, { wrapWidth: 80, maxLines: 9999 });
271
- expect(out.split("\n").length).toBeGreaterThan(300); // broken into many lines
272
- expect(() => JSON.parse(out)).not.toThrow(); // still valid JSON → highlightable
273
- });
274
-
275
- it("hard-wraps a NON-JSON mega-line but leaves JSON untouched", () => {
276
- // A genuine non-JSON one-liner (multi-KB plain string) still gets wrapped so
277
- // the TUI never measures a single huge line.
278
- const plain = "x".repeat(7806); // not JSON
279
- const wrapped = formatJson(plain, { wrapWidth: 80, maxLines: 9999 });
280
- const lines = wrapped.split("\n");
281
- expect(Math.max(...lines.map((l) => l.length))).toBeLessThanOrEqual(80);
282
- expect(wrapped.replace(/\n/g, "").length).toBe(plain.length); // lossless
283
- });
284
-
285
- it("caps line count with a `+N more` footer", () => {
286
- const obj = Object.fromEntries(Array.from({ length: 200 }, (_, i) => [`k${i}`, i]));
287
- const out = formatJson(obj, { maxLines: 10 });
288
- const lines = out.split("\n");
289
- expect(lines.length).toBe(11); // 10 + footer
290
- expect(lines.at(-1)).toMatch(/^… \+\d+ more$/);
291
- });
292
-
293
- it("applies a hard char ceiling as a last-resort guard", () => {
294
- const out = formatJson({ blob: "y".repeat(5000) }, { maxChars: 100, maxLines: 999 });
295
- expect(out.length).toBeLessThanOrEqual(100);
296
- expect(out.endsWith("…")).toBe(true);
297
- });
298
- });
299
-
300
- describe("collapsed tool rows", () => {
301
- const rowTheme = { fg: (_key: string, text: string) => text, bold: (text: string) => text };
302
-
303
- it("uses dim for the target and muted for tertiary metadata", () => {
304
- const taggedTheme = {
305
- fg: (key: string, text: string) => `<${key}>${text}</${key}>`,
306
- bold: (text: string) => text,
307
- };
308
- expect(formatCollapsedToolRow(taggedTheme, "read", "src/a.ts", "12 lines")).toContain(
309
- "<dim>src/a.ts</dim> <muted>·</muted> <muted>12 lines</muted>",
310
- );
311
- });
312
-
313
- it("renders a consistent status, tool, target, and metadata row", () => {
314
- // The status marker is width-normalized to 2 cells (padIcon) so wide glyphs
315
- // align with narrow ones; a 1-cell `✓` therefore carries one pad space.
316
- expect(formatCollapsedToolRow(rowTheme, "read", "src/a.ts", "12 lines")).toBe(
317
- "✓ read src/a.ts · 12 lines",
318
- );
319
- const rendered = plain(renderCollapsedToolRow(rowTheme, "read", "src/a.ts", "12 lines"));
320
- expect(rendered).toStartWith("✓ read src/a.ts · 12 lines");
321
- });
322
-
323
- it("padIcon normalizes markers to a fixed cell width (per pi-tui visibleWidth)", () => {
324
- // pi-tui's width table drives the actual TUI column math, so padIcon trusts
325
- // it: `✓`/`✗`/`⚠` measure 1 cell and gain a pad space; `⚡` measures 2 and
326
- // is left as-is. All markers then occupy the same 2-cell column.
327
- expect(padIcon("✓")).toBe("✓ ");
328
- expect(padIcon("✗")).toBe("✗ ");
329
- expect(padIcon("⚠")).toBe("⚠ ");
330
- expect(padIcon("⚡")).toBe("⚡"); // already 2 cells — unchanged
331
- expect(padIcon("x", 4)).toBe("x "); // explicit width
332
- expect(padIcon("⚡", 1)).toBe("⚡"); // never truncated below its own width
333
- });
334
-
335
- it("hides only collapsed, non-expanded call rows", () => {
336
- let value = "unchanged";
337
- expect(hideCollapsedToolCall({ collapsed: true }, false, (text) => (value = text))).toBe(true);
338
- expect(value).toBe("");
339
- expect(hideCollapsedToolCall({ collapsed: true }, true, () => {})).toBe(false);
340
- });
341
- });
342
-
343
- describe("setResultDetails", () => {
344
- it("preserves upstream metadata while adding renderer details", () => {
345
- const result = {
346
- content: [{ type: "text" as const, text: "output" }],
347
- details: {
348
- truncation: { truncated: true, totalLines: 500 },
349
- fullOutputPath: "/tmp/full.log",
350
- },
351
- };
352
-
353
- setResultDetails(result, { _type: "bashResult", exitCode: 0 });
354
-
355
- expect(result.details as Record<string, unknown>).toEqual({
356
- truncation: { truncated: true, totalLines: 500 },
357
- fullOutputPath: "/tmp/full.log",
358
- _type: "bashResult",
359
- exitCode: 0,
360
- });
361
- });
362
- });
363
-
364
- describe("renderDimPreview", () => {
365
- it("renders 'done' for empty input", () => {
366
- expect(plain(renderDimPreview("", theme))).toContain("done");
367
- });
368
-
369
- it("shows every line when under the cap", () => {
370
- const out = plain(renderDimPreview("a\nb\nc", theme));
371
- expect(out).toContain("a");
372
- expect(out).toContain("b");
373
- expect(out).toContain("c");
374
- expect(out).not.toContain("more line");
375
- });
376
-
377
- it("does not add overflow marker at exactly the cap", () => {
378
- const body = Array.from({ length: MAX_PREVIEW_LINES }, (_, i) => `L${i}`);
379
- const out = plain(renderDimPreview(body.join("\n"), theme));
380
- expect(out).not.toContain("more line");
381
- });
382
-
383
- it("adds singular overflow marker for 1 extra line", () => {
384
- const body = Array.from({ length: MAX_PREVIEW_LINES + 1 }, (_, i) => `L${i}`);
385
- const out = plain(renderDimPreview(body.join("\n"), theme));
386
- expect(out).toContain("… 1 more line");
387
- expect(out).not.toContain("more lines");
388
- });
389
-
390
- it("adds plural overflow marker for many extra lines", () => {
391
- const body = Array.from({ length: MAX_PREVIEW_LINES + 3 }, (_, i) => `L${i}`);
392
- const out = plain(renderDimPreview(body.join("\n"), theme));
393
- expect(out).toContain("… 3 more lines");
394
- });
395
-
396
- it("respects a custom maxLines", () => {
397
- const out = plain(renderDimPreview("a\nb\nc\nd", theme, { maxLines: 2 }));
398
- expect(out).toContain("… 2 more lines");
399
- });
400
-
401
- it("prepends a header line when given", () => {
402
- const out = plain(renderDimPreview("body", theme, { header: "5 matches" }));
403
- expect(out).toContain("5 matches");
404
- expect(out).toContain("body");
405
- });
406
-
407
- it("frames the body with a rule top and bottom, dropping the redundant header", () => {
408
- const out = plain(renderDimPreview("a\nb", theme, { frame: true, header: "2 files" }));
409
- const lines = out.split("\n");
410
- // No floating header in framed mode — the collapsed row carries the count.
411
- expect(out).not.toContain("2 files");
412
- expect(lines[0]).toMatch(/^─+$/); // top rule is the first line
413
- expect(lines.at(-1)).toMatch(/^─+$/); // bottom rule closes the block
414
- });
415
-
416
- it("paints the frame rules when a paint fn is given", () => {
417
- const tag: FgTheme = { fg: (k, v) => `<${k}>${v}` };
418
- const out = renderDimPreview("a\nb", tag, {
419
- frame: true,
420
- paint: (s: string) => tag.fg("success", s),
421
- });
422
- expect(out).toContain("<success>─");
423
- });
424
-
425
- it("frames overflow footer below the bottom rule", () => {
426
- const body = Array.from({ length: MAX_PREVIEW_LINES + 2 }, (_, i) => `L${i}`);
427
- const out = plain(renderDimPreview(body.join("\n"), theme, { frame: true }));
428
- const lines = out.split("\n");
429
- // overflow marker is the LAST line, below the closing rule
430
- expect(lines.at(-1)).toContain("… 2 more lines");
431
- expect(lines.at(-2)).toMatch(/^─+$/); // bottom rule sits above the footer
432
- });
433
-
434
- it("highlights matched keyword with non-dim styling", () => {
435
- const raw = renderDimPreview("foo bar foo", theme, { highlight: "foo" });
436
- // matched 'foo' wrapped in yellow/bold ANSI (not produced by stub fg)
437
- expect(raw).toContain("\x1b[");
438
- expect(plain(raw)).toContain("foo bar foo");
439
- });
440
-
441
- it("treats regex metacharacters as literal highlight text", () => {
442
- const raw = renderDimPreview("call(foo)", theme, { highlight: "(" });
443
- expect(plain(raw)).toContain("call(foo)");
444
- expect(raw).toContain("\x1b[");
445
- });
446
-
447
- it("highlights every regex match, not just a literal substring", () => {
448
- // /te.t/ must light up both 'test' and 'text' — a literal indexOf can't.
449
- const raw = renderDimPreview("test text", theme, { highlight: /te.t/g });
450
- // Two bold-open codes = two highlighted hits.
451
- expect(raw.split("\x1b[1m").length - 1).toBe(2);
452
- expect(plain(raw)).toContain("test text");
453
- });
454
-
455
- it("does not loop on a zero-width regex match", () => {
456
- // /x*/ matches empty everywhere — must terminate and keep content intact.
457
- const raw = renderDimPreview("abc", theme, { highlight: /x*/g });
458
- expect(plain(raw)).toContain("abc");
459
- });
460
-
461
- it("skips highlighting a line that already carries ANSI (no escape corruption)", () => {
462
- // Pre-colored input: a match inside an escape would corrupt it, so the
463
- // whole line is dimmed instead — no BOLD hit is injected.
464
- const preColored = "\x1b[31mtest\x1b[0m done";
465
- const raw = renderDimPreview(preColored, theme, { highlight: "test" });
466
- expect(raw).not.toContain("\x1b[1m"); // no bold hit
467
- expect(raw).toContain("\x1b[31m"); // original ANSI preserved
468
- });
469
-
470
- it("renders an === label === separator as a section divider", () => {
471
- const raw = renderDimPreview("=== branch ===\nmain", theme, {});
472
- // Label survives; the raw === markers are gone (replaced by a rule).
473
- expect(plain(raw)).toContain("branch");
474
- expect(plain(raw)).not.toContain("===");
475
- expect(plain(raw)).toContain("─"); // divider glyph
476
- expect(plain(raw)).toContain("main"); // ordinary lines untouched
477
- });
478
- });
479
-
480
- describe("sectionRule", () => {
481
- // Tagging theme so we can assert which role each fragment uses.
482
- const tag: FgTheme = { fg: (key, text) => `<${key}>${text}</${key}>` };
483
-
484
- it("left-aligns the label after a short lead rule, all muted", () => {
485
- const out = sectionRule("=== versions ===", tag, 40) ?? "";
486
- expect(out).not.toBeNull();
487
- // One muted span wrapping the whole divider; label starts after 4 dashes.
488
- expect(out).toBe("<muted>──── versions ──────────────────────────</muted>");
489
- });
490
-
491
- it("wraps an over-long label snugly with 2 dashes each side", () => {
492
- const out = sectionRule("=== a very long section label here ===", tag, 20) ?? "";
493
- expect(out).toBe("<muted>── a very long section label here ──</muted>");
494
- });
495
-
496
- it("accepts extra whitespace and 2+ equals signs", () => {
497
- expect(sectionRule("== dirty? ==", tag, 40)).toContain("──── dirty? ");
498
- expect(sectionRule("===== a b c =====", tag, 40)).toContain("──── a b c ");
499
- });
500
-
501
- it("returns null for a non-separator line", () => {
502
- expect(sectionRule("just a normal line", tag, 20)).toBeNull();
503
- expect(sectionRule("=== no closing", tag, 20)).toBeNull();
504
- // Must span the whole line — leading text before the === disqualifies it.
505
- expect(sectionRule("plain === middle === text", tag, 20)).toBeNull();
506
- });
507
-
508
- it("returns null when the line already carries ANSI", () => {
509
- expect(sectionRule("\x1b[31m=== x ===\x1b[0m", tag, 20)).toBeNull();
510
- });
511
-
512
- it("fills the full requested width so it aligns with the tool frame", () => {
513
- const out = sectionRule("=== x ===", tag, 400) ?? "";
514
- const visible = out.replace(/<\/?[a-z]+>/g, "");
515
- expect([...visible].length).toBe(400);
516
- });
517
- });
@@ -1,127 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import {
3
- describeActivity,
4
- fmtTokenCount,
5
- formatContext,
6
- formatDuration,
7
- formatMs,
8
- formatSpeed,
9
- formatTokens,
10
- formatToolUses,
11
- formatTurns,
12
- getSessionContextPercent,
13
- getSessionContextUsage,
14
- SPINNER,
15
- truncateLine,
16
- } from "./widget-format.ts";
17
-
18
- const stripAnsi = (text: string) => text.replace(/\x1b\[[0-9;]*m/g, "");
19
-
20
- describe("widget formatters", () => {
21
- test("SPINNER has frames to cycle", () => {
22
- expect(SPINNER.length).toBeGreaterThan(1);
23
- });
24
-
25
- test("fmtTokenCount scales with magnitude", () => {
26
- expect(fmtTokenCount(500)).toBe("500");
27
- expect(fmtTokenCount(30_100)).toBe("30.1K");
28
- expect(fmtTokenCount(1_000_000)).toBe("1.00M");
29
- });
30
-
31
- test("formatTokens uses ' token' / 'k token' / 'M token' variants", () => {
32
- expect(stripAnsi(formatTokens(500))).toContain("500 token");
33
- expect(stripAnsi(formatTokens(12_400))).toContain("12.4k token");
34
- expect(stripAnsi(formatTokens(2_500_000))).toContain("2.5M token");
35
- });
36
-
37
- test("formatMs renders seconds to one decimal", () => {
38
- expect(formatMs(2_100)).toBe("2.1s");
39
- });
40
-
41
- test("formatDuration keeps 3 presentations via style param", () => {
42
- expect(formatDuration(420, "bash")).toBe("420ms");
43
- expect(formatDuration(2_450, "bash")).toBe("2.5s");
44
- expect(formatDuration(12_400, "bash")).toBe("12s");
45
- expect(formatDuration(450, "btw")).toBe("450ms");
46
- expect(formatDuration(2_100, "btw")).toBe("2.1s");
47
- expect(formatDuration(12_400, "btw")).toBe("12s");
48
- expect(formatDuration(65_000, "btw")).toBe("1m 5s");
49
- expect(formatDuration(2_100)).toBe("2.1s");
50
- expect(formatDuration(2_100, "ms")).toBe(formatMs(2_100));
51
- });
52
-
53
- test("formatSpeed returns empty when there is no work", () => {
54
- expect(formatSpeed(0, 1_000)).toBe("");
55
- expect(formatSpeed(100, 0)).toBe("");
56
- expect(stripAnsi(formatSpeed(200, 2_000))).toBe("100 t/s");
57
- });
58
-
59
- test("formatContext shows used/window/percent, or empty when unknown", () => {
60
- expect(formatContext(null)).toBe("");
61
- expect(formatContext({ tokens: null, contextWindow: null, percent: null })).toBe("");
62
- expect(
63
- stripAnsi(formatContext({ tokens: 30_100, contextWindow: 1_000_000, percent: 3 })),
64
- ).toContain("30.1K/1.00M (3%)");
65
- expect(stripAnsi(formatContext({ tokens: null, contextWindow: null, percent: 42 }))).toContain(
66
- "42% ctx",
67
- );
68
- });
69
-
70
- test("formatTurns and formatToolUses render counts", () => {
71
- expect(stripAnsi(formatTurns(3))).toContain("3");
72
- expect(stripAnsi(formatTurns(3, 10))).toContain("3\u226410");
73
- expect(stripAnsi(formatToolUses(5))).toContain("5");
74
- });
75
-
76
- test("truncateLine tail-anchors the latest non-empty line to len", () => {
77
- expect(truncateLine("short", 32)).toBe("short");
78
- expect(truncateLine("a\nb\nlatest", 32)).toBe("latest");
79
- expect(truncateLine("0123456789", 4)).toBe("\u20266789");
80
- });
81
-
82
- test("describeActivity groups active tools, tails text (default 32), else thinking", () => {
83
- const two = new Map<string, string>([
84
- ["0", "read"],
85
- ["1", "read"],
86
- ]);
87
- expect(describeActivity(two)).toBe("reading 2\u00d7\u2026");
88
- expect(describeActivity(new Map(), "line one\nlatest line")).toBe("latest line");
89
- expect(describeActivity(new Map())).toBe("thinking\u2026");
90
- });
91
-
92
- test("describeActivity honors an explicit tailLen", () => {
93
- expect(describeActivity(new Map(), "0123456789", 4)).toBe("\u20266789");
94
- });
95
-
96
- test("getSessionContextUsage reads stats and tolerates throwing sessions", () => {
97
- const session = {
98
- getSessionStats: () => ({
99
- tokens: { input: 0, output: 0, cacheWrite: 0 },
100
- contextUsage: { tokens: 10, contextWindow: 100, percent: 10 },
101
- }),
102
- };
103
- expect(getSessionContextUsage(session)).toEqual({
104
- tokens: 10,
105
- contextWindow: 100,
106
- percent: 10,
107
- });
108
- expect(getSessionContextUsage(undefined)).toBeNull();
109
- const throwing = {
110
- getSessionStats: () => {
111
- throw new Error("no stats");
112
- },
113
- };
114
- expect(getSessionContextUsage(throwing)).toBeNull();
115
- });
116
-
117
- test("getSessionContextPercent returns just the percent, or null", () => {
118
- const session = {
119
- getSessionStats: () => ({
120
- tokens: { input: 0, output: 0, cacheWrite: 0 },
121
- contextUsage: { tokens: 10, contextWindow: 100, percent: 42 },
122
- }),
123
- };
124
- expect(getSessionContextPercent(session)).toBe(42);
125
- expect(getSessionContextPercent(undefined)).toBeNull();
126
- });
127
- });