@mohndoe/pi-atlas 0.1.1

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.
Files changed (66) hide show
  1. package/.pi/extensions/guardrails.json +10 -0
  2. package/.pi/extensions/guardrails.v0.json +8 -0
  3. package/AGENTS.md +13 -0
  4. package/CONTEXT.md +119 -0
  5. package/LICENSE +21 -0
  6. package/README.md +40 -0
  7. package/bun.lock +325 -0
  8. package/docs/ARCHITECTURE.md +66 -0
  9. package/docs/adr/0001-global-session-project-map.md +9 -0
  10. package/docs/adr/0002-precomputed-summaries.md +9 -0
  11. package/docs/agents/domain.md +42 -0
  12. package/docs/agents/issue-tracker.md +22 -0
  13. package/docs/agents/triage-labels.md +14 -0
  14. package/package.json +49 -0
  15. package/src/__tests__/cache.test.ts +388 -0
  16. package/src/__tests__/components.fixtures.ts +54 -0
  17. package/src/__tests__/compute.fixtures.ts +49 -0
  18. package/src/__tests__/compute.test.ts +336 -0
  19. package/src/__tests__/e2e.test.ts +182 -0
  20. package/src/__tests__/format.test.ts +232 -0
  21. package/src/__tests__/parser.test.ts +1396 -0
  22. package/src/cache.ts +178 -0
  23. package/src/colorPalette.ts +119 -0
  24. package/src/components/BarChart.ts +288 -0
  25. package/src/components/Dashboard.ts +222 -0
  26. package/src/components/Header.ts +40 -0
  27. package/src/components/KpiCards.ts +104 -0
  28. package/src/components/LoadingView.ts +38 -0
  29. package/src/components/MarqueeText.ts +79 -0
  30. package/src/components/RangeSelector.ts +63 -0
  31. package/src/components/RankedBarList.ts +71 -0
  32. package/src/components/SortedTable.ts +221 -0
  33. package/src/components/StatCard.ts +64 -0
  34. package/src/components/TabBar.ts +59 -0
  35. package/src/components/UsageRow.ts +55 -0
  36. package/src/components/__tests__/Bar.test.ts +66 -0
  37. package/src/components/__tests__/BarChart.test.ts +224 -0
  38. package/src/components/__tests__/Dashboard.test.ts +452 -0
  39. package/src/components/__tests__/KpiCards.test.ts +83 -0
  40. package/src/components/__tests__/LoadingView.test.ts +26 -0
  41. package/src/components/__tests__/MarqueeText.test.ts +75 -0
  42. package/src/components/__tests__/RangeSelector.test.ts +34 -0
  43. package/src/components/__tests__/RankedBarList.test.ts +110 -0
  44. package/src/components/__tests__/SortedTable.integration.test.ts +228 -0
  45. package/src/components/__tests__/SortedTable.test.ts +723 -0
  46. package/src/components/__tests__/TabBar.test.ts +62 -0
  47. package/src/components/__tests__/cells.test.ts +193 -0
  48. package/src/components/cells.ts +108 -0
  49. package/src/components/shared/Bar.ts +22 -0
  50. package/src/components/shared/GridRow.ts +22 -0
  51. package/src/compute.ts +210 -0
  52. package/src/format.ts +219 -0
  53. package/src/index.ts +88 -0
  54. package/src/parser.ts +363 -0
  55. package/src/tabs/Languages.ts +102 -0
  56. package/src/tabs/Models.ts +108 -0
  57. package/src/tabs/Overview.ts +152 -0
  58. package/src/tabs/Projects.ts +92 -0
  59. package/src/tabs/Usage.ts +181 -0
  60. package/src/tabs/__tests__/Languages.test.ts +158 -0
  61. package/src/tabs/__tests__/Models.test.ts +143 -0
  62. package/src/tabs/__tests__/Overview.test.ts +92 -0
  63. package/src/tabs/__tests__/Projects.test.ts +142 -0
  64. package/src/tabs/__tests__/Usage.test.ts +174 -0
  65. package/src/types.ts +99 -0
  66. package/tsconfig.json +30 -0
@@ -0,0 +1,723 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
2
+ import { makeMockTUI, makeTheme } from "../../__tests__/components.fixtures";
3
+
4
+ import { cell } from "../cells";
5
+ import { type ColumnDef, SortedTable } from "../SortedTable";
6
+
7
+ const CURSOR = SortedTable.DEFAULT_CURSOR_CHAR;
8
+ const mockTui = makeMockTUI();
9
+
10
+ describe("SortedTable", () => {
11
+ const columns = [
12
+ { header: cell.header("Language"), width: 20 },
13
+ { header: cell.header("Lines"), width: 10 },
14
+ { header: cell.header("Edits"), width: 10 },
15
+ ];
16
+
17
+ const rows = [
18
+ [cell.text("TypeScript"), cell.text("1500"), cell.text("45")],
19
+ [cell.text("Python"), cell.text("800"), cell.text("20")],
20
+ [cell.text("JSON"), cell.text("300"), cell.text("5")],
21
+ ];
22
+
23
+ function textRows(stringRows: string[][]): typeof rows {
24
+ return stringRows.map((r) => r.map((s) => cell.text(s)));
25
+ }
26
+
27
+ it("renders header row with column names", () => {
28
+ const table = new SortedTable({ columns, rows, maxHeight: 10, tui: mockTui }, makeTheme());
29
+
30
+ const lines = table.render(80);
31
+ expect(lines.length).toBeGreaterThanOrEqual(1);
32
+ const header = lines[0];
33
+ expect(header).toContain("Language");
34
+ expect(header).toContain("Lines");
35
+ expect(header).toContain("Edits");
36
+ });
37
+
38
+ it("renders data rows", () => {
39
+ const table = new SortedTable({ columns, rows, maxHeight: 10, tui: mockTui }, makeTheme());
40
+ const lines = table.render(80);
41
+ // Skip header (index 0), check first two data rows
42
+ expect(lines.length).toBeGreaterThanOrEqual(3);
43
+ expect(lines[1]).toContain("TypeScript");
44
+ expect(lines[2]).toContain("Python");
45
+ expect(lines[3]).toContain("JSON");
46
+ });
47
+
48
+ it("renders within width", () => {
49
+ const table = new SortedTable({ columns, rows, maxHeight: 10, tui: mockTui }, makeTheme());
50
+ const lines = table.render(50);
51
+ for (const line of lines) {
52
+ const visLen = line.replace(/\x1b\[[0-9;]*m/g, "").length;
53
+ expect(visLen).toBeLessThanOrEqual(50);
54
+ }
55
+ });
56
+
57
+ it("shows all rows when they fit within maxHeight", () => {
58
+ const table = new SortedTable({ columns, rows, maxHeight: 10, tui: mockTui }, makeTheme());
59
+ const lines = table.render(80);
60
+ // 1 header + 3 data rows = 4 lines (all fit in 10)
61
+ expect(lines.length).toBe(4);
62
+ });
63
+
64
+ it("limits visible rows to maxHeight", () => {
65
+ const manyRows = Array.from({ length: 20 }, (_, i) => [
66
+ cell.text(`Lang${i}`),
67
+ cell.text(String(i * 100)),
68
+ cell.text(String(i * 10)),
69
+ ]);
70
+ const table = new SortedTable(
71
+ { columns, rows: manyRows, maxHeight: 6, tui: mockTui },
72
+ makeTheme(),
73
+ ); // 1 header + 5 data
74
+ const lines = table.render(80);
75
+ expect(lines.length).toBe(6);
76
+ });
77
+
78
+ it("handles empty rows", () => {
79
+ const table = new SortedTable({ columns, rows: [], maxHeight: 10, tui: mockTui }, makeTheme());
80
+ const lines = table.render(80);
81
+ // Should have at least a header, maybe an empty message
82
+ expect(lines.length).toBeGreaterThanOrEqual(1);
83
+ expect(lines[0]).toContain("Language");
84
+ });
85
+
86
+ it("scrolls down with handleInput", () => {
87
+ const manyRows = Array.from({ length: 20 }, (_, i) => [
88
+ cell.text(`Lang${i}`),
89
+ cell.text(String(i * 100)),
90
+ cell.text(String(i * 10)),
91
+ ]);
92
+ const table = new SortedTable(
93
+ { columns, rows: manyRows, maxHeight: 6, tui: mockTui },
94
+ makeTheme(),
95
+ ); // 5 data rows visible
96
+
97
+ // Initial: cursor at 0, rows 0-4 visible
98
+ let lines = table.render(80);
99
+ expect(lines[1]).toContain("Lang0");
100
+ expect(lines[lines.length - 1]).toContain("Lang4");
101
+
102
+ // Move cursor down past visible area (cursor 0→5, viewport follows at 5th down)
103
+ for (let i = 0; i < 5; i++) table.handleInput("\x1b[B");
104
+ lines = table.render(80);
105
+ // Viewport now shows rows 1-5, cursor row 5 highlighted
106
+ expect(lines[1]).toContain("Lang1");
107
+ expect(lines[lines.length - 1]).toContain("Lang5");
108
+ });
109
+
110
+ it("scrolls up with handleInput", () => {
111
+ const manyRows = Array.from({ length: 20 }, (_, i) => [
112
+ cell.text(`Lang${i}`),
113
+ cell.text(String(i * 100)),
114
+ cell.text(String(i * 10)),
115
+ ]);
116
+ const table = new SortedTable(
117
+ { columns, rows: manyRows, maxHeight: 6, tui: mockTui },
118
+ makeTheme(),
119
+ );
120
+
121
+ // Move cursor down past visible area (cursor 0→6, viewport scrolls to 2)
122
+ for (let i = 0; i < 6; i++) table.handleInput("\x1b[B");
123
+ let lines = table.render(80);
124
+ // Viewport shows rows 2-6
125
+ expect(lines[1]).toContain("Lang2");
126
+
127
+ // Move up until viewport scrolls back (cursor 6→1 takes 5 ups)
128
+ for (let i = 0; i < 5; i++) table.handleInput("\x1b[A");
129
+ lines = table.render(80);
130
+ expect(lines[1]).toContain("Lang1");
131
+ });
132
+
133
+ it("does not move cursor past start", () => {
134
+ const manyRows = Array.from({ length: 5 }, (_, i) => [
135
+ cell.text(`Lang${i}`),
136
+ cell.text("100"),
137
+ cell.text("10"),
138
+ ]);
139
+ const table = new SortedTable(
140
+ { columns, rows: manyRows, maxHeight: 10, tui: mockTui },
141
+ makeTheme(),
142
+ );
143
+ // Cursor at 0, pressing up should not move it
144
+ table.handleInput("\x1b[A");
145
+ const lines = table.render(80);
146
+ // First data row (Lang0) should still be visible and highlighted
147
+ expect(lines[1]).toContain("Lang0");
148
+ });
149
+
150
+ it("does not move cursor past end", () => {
151
+ const manyRows = Array.from({ length: 5 }, (_, i) => [
152
+ cell.text(`Lang${i}`),
153
+ cell.text("100"),
154
+ cell.text("10"),
155
+ ]);
156
+ const table = new SortedTable(
157
+ { columns, rows: manyRows, maxHeight: 6, tui: mockTui },
158
+ makeTheme(),
159
+ );
160
+ // Move cursor all the way down (4 presses = last row)
161
+ for (let i = 0; i < 10; i++) table.handleInput("\x1b[B");
162
+ const lines = table.render(80);
163
+ // Last row visible
164
+ expect(lines[lines.length - 1]).toContain("Lang4");
165
+ // Cursor should be at last row (Lang4)
166
+ });
167
+
168
+ it("invalidates render cache", () => {
169
+ const table = new SortedTable({ columns, rows, maxHeight: 10, tui: mockTui }, makeTheme());
170
+ table.render(80);
171
+ table.invalidate();
172
+ const lines = table.render(60);
173
+ for (const line of lines) {
174
+ const visLen = line.replace(/\x1b\[[0-9;]*m/g, "").length;
175
+ expect(visLen).toBeLessThanOrEqual(60);
176
+ }
177
+ });
178
+
179
+ it("renders rows continuously respecting scroll offset", () => {
180
+ const manyRows = Array.from({ length: 20 }, (_, i) => [
181
+ cell.text(`Lang${i}`),
182
+ cell.text("100"),
183
+ cell.text("10"),
184
+ ]);
185
+ const table = new SortedTable(
186
+ { columns, rows: manyRows, maxHeight: 6, tui: mockTui },
187
+ makeTheme(),
188
+ );
189
+
190
+ for (let i = 0; i < 5; i++) table.handleInput("\x1b[B");
191
+ const lines = table.render(80);
192
+ // scrollOffset=1, first visible data row should be Lang1
193
+ expect(lines[1]).toContain("Lang1");
194
+ });
195
+
196
+ // --- Flexible width tests ---
197
+
198
+ it("renders each line at exactly the specified width (visible chars)", () => {
199
+ const table = new SortedTable({ columns, rows, maxHeight: 10, tui: mockTui }, makeTheme());
200
+ const lines = table.render(80);
201
+ for (const line of lines) {
202
+ const visLen = line.replace(/\x1b\[[0-9;]*m/g, "").length;
203
+ expect(visLen).toBe(80);
204
+ }
205
+ });
206
+
207
+ it("resolves percentage columns relative to content width", () => {
208
+ const pctCols: ColumnDef[] = [{ header: cell.header("Col"), width: "50%" }];
209
+ // 1 column, 0 gaps → contentWidth = 20
210
+ // 50% of 20 = 10
211
+ const table = new SortedTable(
212
+ { columns: pctCols, rows: [[cell.text("hello")]], maxHeight: 10, tui: mockTui },
213
+ makeTheme(),
214
+ );
215
+ const lines = table.render(20);
216
+ for (const line of lines) {
217
+ const visLen = line.replace(/\x1b\[[0-9;]*m/g, "").length;
218
+ expect(visLen).toBe(20);
219
+ }
220
+ });
221
+
222
+ it("fill column takes remaining space after fixed columns", () => {
223
+ const fillCols: ColumnDef[] = [
224
+ { header: cell.header("A"), width: 5 },
225
+ { header: cell.header("B"), width: "fill" },
226
+ ];
227
+ const table = new SortedTable(
228
+ { columns: fillCols, rows: [[cell.text("x"), cell.text("y")]], maxHeight: 10, tui: mockTui },
229
+ makeTheme(),
230
+ );
231
+ const lines = table.render(20);
232
+ for (const line of lines) {
233
+ const visLen = line.replace(/\x1b\[[0-9;]*m/g, "").length;
234
+ expect(visLen).toBe(20);
235
+ }
236
+ // Make sure the fill column got substantial width (not just 1)
237
+ expect(lines[1]).toMatch(/x {4,}/);
238
+ });
239
+
240
+ it("fill column collapses to 1 char min when no space remains", () => {
241
+ const tightCols: ColumnDef[] = [
242
+ { header: cell.header("A"), width: 18 },
243
+ { header: cell.header("B"), width: "fill" },
244
+ ];
245
+ const table = new SortedTable(
246
+ {
247
+ columns: tightCols,
248
+ rows: [[cell.text("aaa"), cell.text("bbb")]],
249
+ maxHeight: 10,
250
+ tui: mockTui,
251
+ },
252
+ makeTheme(),
253
+ );
254
+ const lines = table.render(20);
255
+ for (const line of lines) {
256
+ const visLen = line.replace(/\x1b\[[0-9;]*m/g, "").length;
257
+ expect(visLen).toBe(20);
258
+ }
259
+ });
260
+
261
+ it("throws when more than one fill column is specified", () => {
262
+ const badCols: ColumnDef[] = [
263
+ { header: cell.header("A"), width: "fill" },
264
+ { header: cell.header("B"), width: "fill" },
265
+ ];
266
+ expect(
267
+ () =>
268
+ new SortedTable({ columns: badCols, rows: [], maxHeight: 10, tui: mockTui }, makeTheme()),
269
+ ).toThrow("Cannot have more than one fill column");
270
+ });
271
+
272
+ it("throws on invalid width string (not number, N%, or fill)", () => {
273
+ const badCols: ColumnDef[] = [{ header: cell.header("A"), width: "abc" }];
274
+ expect(
275
+ () =>
276
+ new SortedTable({ columns: badCols, rows: [], maxHeight: 10, tui: mockTui }, makeTheme()),
277
+ ).toThrow('Invalid column width: "abc"');
278
+ });
279
+
280
+ it("handles mix of fixed, percentage, and fill columns", () => {
281
+ const mixedCols: ColumnDef[] = [
282
+ { header: cell.header("Fixed"), width: 10 },
283
+ { header: cell.header("Pct"), width: "25%" },
284
+ { header: cell.header("Fill"), width: "fill" },
285
+ ];
286
+ const table = new SortedTable(
287
+ {
288
+ columns: mixedCols,
289
+ rows: [[cell.text("a"), cell.text("b"), cell.text("c")]],
290
+ maxHeight: 10,
291
+ tui: mockTui,
292
+ },
293
+ makeTheme(),
294
+ );
295
+ const lines = table.render(60);
296
+ for (const line of lines) {
297
+ const visLen = line.replace(/\x1b\[[0-9;]*m/g, "").length;
298
+ expect(visLen).toBe(60);
299
+ }
300
+ });
301
+
302
+ const strip = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, "");
303
+
304
+ describe("sort indicators", () => {
305
+ it("shows ▲ on the sorted column header when direction is asc", () => {
306
+ const table = new SortedTable(
307
+ { columns, rows, maxHeight: 10, sort: { column: 0, direction: "asc" }, tui: mockTui },
308
+ makeTheme(),
309
+ );
310
+ const lines = table.render(80);
311
+ const header = lines[0];
312
+ expect(header).toContain("Language ▲");
313
+ expect(header).toContain("Lines");
314
+ expect(header).toContain("Edits");
315
+ });
316
+
317
+ it("shows ▼ on the sorted column header when direction is desc", () => {
318
+ const table = new SortedTable(
319
+ { columns, rows, maxHeight: 10, sort: { column: 1, direction: "desc" }, tui: mockTui },
320
+ makeTheme(),
321
+ );
322
+ const lines = table.render(80);
323
+ const header = lines[0];
324
+ expect(header).toContain("Lines ▼");
325
+ expect(header).toContain("Language");
326
+ expect(header).toContain("Edits");
327
+ });
328
+
329
+ it("uses column width computed on raw header before appending triangle", () => {
330
+ const tightCols = [
331
+ { header: cell.header("Language"), width: 10 },
332
+ { header: cell.header("Lines"), width: 10 },
333
+ { header: cell.header("Edits"), width: 10 },
334
+ ];
335
+ const table = new SortedTable(
336
+ {
337
+ columns: tightCols,
338
+ rows,
339
+ maxHeight: 10,
340
+ sort: { column: 0, direction: "asc" },
341
+ tui: mockTui,
342
+ },
343
+ makeTheme(),
344
+ );
345
+ const lines = table.render(80);
346
+ const header = lines[0];
347
+ // cell.header("Language") at width 10 with sortDirection="asc" → "Language ▲" = 10 chars
348
+ expect(header).toContain("Language ▲");
349
+ expect(header).not.toContain("Langua ▲");
350
+ });
351
+
352
+ it("shortens header text when header plus triangle exceeds column width", () => {
353
+ const tightCols = [
354
+ { header: cell.header("VeryLongName"), width: 10 },
355
+ { header: cell.header("Lines"), width: 10 },
356
+ { header: cell.header("Edits"), width: 10 },
357
+ ];
358
+ const table = new SortedTable(
359
+ {
360
+ columns: tightCols,
361
+ rows,
362
+ maxHeight: 10,
363
+ sort: { column: 0, direction: "asc" },
364
+ tui: mockTui,
365
+ },
366
+ makeTheme(),
367
+ );
368
+ const lines = table.render(80);
369
+ const header = lines[0]!;
370
+ // "VeryLongName" is 12 chars, width=10, " ▲" takes 2 → max raw = 8
371
+ expect(strip(header)).toContain("VeryLong ▲");
372
+ expect(strip(header)).not.toContain("VeryLongName");
373
+ });
374
+
375
+ it("shows no triangle when sort is omitted", () => {
376
+ const table = new SortedTable({ columns, rows, maxHeight: 10, tui: mockTui }, makeTheme());
377
+ const lines = table.render(80);
378
+ const header = lines[0];
379
+ expect(header).not.toContain("▲");
380
+ expect(header).not.toContain("▼");
381
+ expect(header).toContain("Language");
382
+ });
383
+ });
384
+
385
+ describe("cursor navigation", () => {
386
+ function highlightTheme() {
387
+ return makeTheme({
388
+ bg: (color: string, text: string) => (color === "selectedBg" ? `[[H]]${text}[[/H]]` : text),
389
+ });
390
+ }
391
+
392
+ it("highlights the first row with selectedBg background by default", () => {
393
+ const table = new SortedTable(
394
+ { columns, rows, maxHeight: 10, tui: mockTui },
395
+ highlightTheme(),
396
+ );
397
+ const lines = table.render(80);
398
+
399
+ // Header unaffected
400
+ expect(lines[0]).toContain("Language");
401
+ expect(lines[0]).not.toContain("[[H]]");
402
+
403
+ // First data row has highlight
404
+ expect(lines[1]).toContain("[[H]]");
405
+ expect(lines[1]).toContain("TypeScript");
406
+
407
+ // Second row is not highlighted
408
+ expect(lines[2]).not.toContain("[[H]]");
409
+ });
410
+
411
+ it("down arrow moves highlight to the next row", () => {
412
+ const table = new SortedTable(
413
+ { columns, rows, maxHeight: 10, tui: mockTui },
414
+ highlightTheme(),
415
+ );
416
+
417
+ table.handleInput("\x1b[B");
418
+ const lines = table.render(80);
419
+
420
+ // First row no longer highlighted
421
+ expect(lines[1]).not.toContain("[[H]]");
422
+ // Second row now highlighted
423
+ expect(lines[2]).toContain("[[H]]");
424
+ expect(lines[2]).toContain("Python");
425
+ // Third row not highlighted
426
+ expect(lines[3]).not.toContain("[[H]]");
427
+ });
428
+
429
+ it("up arrow moves highlight to the previous row", () => {
430
+ const table = new SortedTable(
431
+ { columns, rows, maxHeight: 10, tui: mockTui },
432
+ highlightTheme(),
433
+ );
434
+
435
+ // Move down twice then back up once
436
+ table.handleInput("\x1b[B");
437
+ table.handleInput("\x1b[B");
438
+ table.handleInput("\x1b[A");
439
+ const lines = table.render(80);
440
+
441
+ // Second row highlighted (moved down to row 3, back up to row 2)
442
+ expect(lines[2]).toContain("[[H]]");
443
+ expect(lines[2]).toContain("Python");
444
+ // First and third rows not highlighted
445
+ expect(lines[1]).not.toContain("[[H]]");
446
+ expect(lines[3]).not.toContain("[[H]]");
447
+ });
448
+
449
+ it("cursor does not go above first row", () => {
450
+ const table = new SortedTable(
451
+ { columns, rows, maxHeight: 10, tui: mockTui },
452
+ highlightTheme(),
453
+ );
454
+
455
+ // Already at row 0, pressing up multiple times should keep highlight on row 0
456
+ table.handleInput("\x1b[A");
457
+ table.handleInput("\x1b[A");
458
+ table.handleInput("\x1b[A");
459
+ const lines = table.render(80);
460
+
461
+ expect(lines[1]).toContain("[[H]]");
462
+ expect(lines[1]).toContain("TypeScript");
463
+ });
464
+
465
+ it("cursor does not go past last row", () => {
466
+ const table = new SortedTable(
467
+ { columns, rows, maxHeight: 10, tui: mockTui },
468
+ highlightTheme(),
469
+ );
470
+
471
+ // Move to last row (index 2), then try to go further
472
+ for (let i = 0; i < 5; i++) table.handleInput("\x1b[B");
473
+ const lines = table.render(80);
474
+
475
+ // Last row (JSON) is highlighted
476
+ expect(lines[3]).toContain("[[H]]");
477
+ expect(lines[3]).toContain("JSON");
478
+ });
479
+
480
+ it("handles empty rows gracefully with no highlight", () => {
481
+ const table = new SortedTable(
482
+ { columns, rows: [], maxHeight: 10, tui: mockTui },
483
+ highlightTheme(),
484
+ );
485
+ const lines = table.render(80);
486
+
487
+ // Header only, no data rows
488
+ expect(lines.length).toBe(1);
489
+ // No highlight markers anywhere
490
+ expect(lines[0]).not.toContain("[[H]]");
491
+
492
+ // Inputs should not crash
493
+ table.handleInput("\x1b[B");
494
+ table.handleInput("\x1b[A");
495
+ });
496
+
497
+ it("shows cursor triangle on focused row and alignment on others", () => {
498
+ const table = new SortedTable({ columns, rows, maxHeight: 10, tui: mockTui }, makeTheme());
499
+
500
+ // Default: first row focused
501
+ let lines = table.render(80);
502
+ expect(lines[1]!.startsWith(CURSOR + " ")).toBe(true);
503
+ expect(lines[2]!.startsWith(" ")).toBe(true);
504
+ expect(lines[3]!.startsWith(" ")).toBe(true);
505
+
506
+ // Move down: second row focused
507
+ table.handleInput("\x1b[B");
508
+ lines = table.render(80);
509
+ expect(lines[1]!.startsWith(" ")).toBe(true);
510
+ expect(lines[2]!.startsWith(CURSOR + " ")).toBe(true);
511
+ expect(lines[3]!.startsWith(" ")).toBe(true);
512
+ });
513
+
514
+ it("hides cursor when disabled", () => {
515
+ const table = new SortedTable(
516
+ { columns, rows, maxHeight: 10, cursor: { enabled: false }, tui: mockTui },
517
+ makeTheme(),
518
+ );
519
+ const lines = table.render(80);
520
+
521
+ // No cursor prefix on any row
522
+ for (const line of lines) {
523
+ expect(line.startsWith(CURSOR + " ")).toBe(false);
524
+ }
525
+ // Header should not be padded either
526
+ expect(lines[0]!.startsWith(" ")).toBe(false);
527
+ });
528
+
529
+ it("uses custom cursor char", () => {
530
+ const table = new SortedTable(
531
+ { columns, rows, maxHeight: 10, cursor: { char: "▸" }, tui: mockTui },
532
+ makeTheme(),
533
+ );
534
+ const lines = table.render(80);
535
+
536
+ expect(lines[1]!.startsWith("▸ ")).toBe(true);
537
+ expect(lines[2]!.startsWith(" ")).toBe(true);
538
+ });
539
+ });
540
+
541
+ describe("marquee", () => {
542
+ const strip = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, "");
543
+ let mockTui: ReturnType<typeof makeMockTUI>;
544
+
545
+ beforeEach(() => {
546
+ vi.useFakeTimers();
547
+ mockTui = makeMockTUI();
548
+ });
549
+
550
+ afterEach(() => {
551
+ vi.useRealTimers();
552
+ });
553
+
554
+ it("scrolls overflowing text on focused row", () => {
555
+ const cols: ColumnDef[] = [{ header: cell.header("Name"), width: 5 }];
556
+ const rows = [[cell.marquee("Hello World!", mockTui)]];
557
+ const table = new SortedTable(
558
+ { columns: cols, rows, maxHeight: 10, tui: mockTui },
559
+ makeTheme(),
560
+ );
561
+
562
+ // tick=0, offset=0 → "Hello" (first 5 chars)
563
+ let lines = table.render(20);
564
+ expect(strip(lines[1]!)).toContain(`${CURSOR} Hello`);
565
+
566
+ // Advance 150ms = 1 timer tick → offset=1 → "ello "
567
+ vi.advanceTimersByTime(150);
568
+ lines = table.render(20);
569
+ expect(strip(lines[1]!)).toContain(`${CURSOR} ello`);
570
+ });
571
+
572
+ it("wraps around when reaching end of content", () => {
573
+ const cols: ColumnDef[] = [{ header: cell.header("Col"), width: 3 }];
574
+ const rows = [[cell.marquee("ABCDEF", mockTui)]];
575
+ const table = new SortedTable(
576
+ { columns: cols, rows, maxHeight: 10, tui: mockTui },
577
+ makeTheme(),
578
+ );
579
+
580
+ // tick=0, offset=0 → "ABC"
581
+ let lines = table.render(20);
582
+ expect(strip(lines[1]!)).toContain(`${CURSOR} ABC`);
583
+
584
+ // Advance 600ms = 4 timer ticks → offset=4
585
+ // With 6 chars + 5-space gap = 11 virtual chars, offset=4 → "EF " (3 chars for width 3)
586
+ vi.advanceTimersByTime(600);
587
+ lines = table.render(20);
588
+ expect(strip(lines[1]!)).toContain(`${CURSOR} EF`);
589
+ });
590
+
591
+ it("resets marquee when cursor moves to a different row", () => {
592
+ const cols: ColumnDef[] = [{ header: cell.header("Name"), width: 5 }];
593
+ const rows = [
594
+ [cell.marquee("Hello World!", mockTui)],
595
+ [cell.marquee("Another Long", mockTui)],
596
+ ];
597
+ const table = new SortedTable(
598
+ { columns: cols, rows, maxHeight: 10, tui: mockTui },
599
+ makeTheme(),
600
+ );
601
+
602
+ // Advance ticks on row 0
603
+ table.render(20);
604
+ vi.advanceTimersByTime(150); // tick=3, offset=1 → "ello "
605
+ let lines = table.render(20);
606
+ expect(strip(lines[1]!)).toContain(`${CURSOR} ello`);
607
+
608
+ // Move to row 1 — tick resets (cells call invalidate on handleInput)
609
+ table.handleInput("\x1b[B");
610
+ vi.advanceTimersByTime(0);
611
+ lines = table.render(20);
612
+ // Row 1 is now focused, marquee starts from beginning
613
+ expect(strip(lines[2]!)).toContain(`${CURSOR} Anoth`);
614
+ });
615
+
616
+ it("non-marquee columns truncate on focused row while marquee columns scroll", () => {
617
+ const cols: ColumnDef[] = [
618
+ { header: cell.header("Label"), width: 10 },
619
+ { header: cell.header("Fixed"), width: 4 },
620
+ ];
621
+ const rows = [[cell.marquee("ABCDEFGHIJKLMNOP", mockTui), cell.text("XYZ")]];
622
+ const table = new SortedTable(
623
+ { columns: cols, rows, maxHeight: 10, tui: mockTui },
624
+ makeTheme(),
625
+ );
626
+
627
+ // tick=0: marquee shows "ABCDEFGHIJ"
628
+ let lines = table.render(30);
629
+ const r1 = strip(lines[1]!);
630
+ expect(r1).toContain(`${CURSOR} ABCDEFGHIJ`);
631
+ expect(r1).toContain("XYZ");
632
+
633
+ // Advance 150ms = 1 tick → offset=1 → "BCDEFGHIJK"
634
+ vi.advanceTimersByTime(150);
635
+ lines = table.render(30);
636
+ const r1b = strip(lines[1]!);
637
+ expect(r1b).toContain(`${CURSOR} BCDEFGHIJK`);
638
+ // Fixed column content never changes
639
+ expect(r1b).toContain("XYZ");
640
+ // The marquee column changed
641
+ expect(r1b).not.toContain("ABCDEFGHIJ");
642
+ });
643
+
644
+ it("shows ellipsis on unfocused marquee columns, scrolling on focused", () => {
645
+ const cols: ColumnDef[] = [{ header: cell.header("Name"), width: 5 }];
646
+ const rows = [
647
+ [cell.marquee("Longish Name", mockTui)],
648
+ [cell.marquee("Long Text Here!", mockTui)],
649
+ ];
650
+ const table = new SortedTable(
651
+ { columns: cols, rows, maxHeight: 10, tui: mockTui },
652
+ makeTheme(),
653
+ );
654
+
655
+ // Focused row 0 — marquee starts at "Longi" (no ellipsis when focused)
656
+ let lines = table.render(20);
657
+ expect(strip(lines[1]!)).toContain(`${CURSOR} Longi`);
658
+
659
+ // Move cursor to row 1 — row 0 becomes unfocused, shows ellipsis
660
+ table.handleInput("\x1b[B");
661
+ vi.advanceTimersByTime(0);
662
+ lines = table.render(20);
663
+ const unfocused = strip(lines[1]!); // row 0 unfocused
664
+ expect(unfocused).toContain(" Long…"); // truncated with ellipsis
665
+ expect(unfocused).not.toContain("ongis"); // would appear if marquee was active
666
+
667
+ // Focused row 1 shows "Long " at offset 0 (exactly 5 chars)
668
+ expect(strip(lines[2]!)).toContain(`${CURSOR} Long`);
669
+
670
+ // Advance ticks — unfocused row stays same (with ellipsis), focused advances
671
+ vi.advanceTimersByTime(300); // 2 ticks, offset=2
672
+ lines = table.render(20);
673
+ expect(strip(lines[1]!)).toContain(" Long…"); // unchanged
674
+ expect(strip(lines[2]!)).toContain(`${CURSOR} ng T`); // focused advanced
675
+ });
676
+
677
+ it("starts marquee after resize from wide to narrow", () => {
678
+ const cols: ColumnDef[] = [{ header: cell.header("Col"), width: "fill" }];
679
+ const rows = [[cell.marquee("Hello World!!!!!", mockTui)]]; // 16 chars, overflows when fill < 16
680
+ const table = new SortedTable(
681
+ { columns: cols, rows, maxHeight: 10, tui: mockTui },
682
+ makeTheme(),
683
+ );
684
+
685
+ // Width=80: fill column = 80, text fits → no marquee needed
686
+ let lines = table.render(80);
687
+ expect(strip(lines[1]!)).toContain("Hello World!!!!!");
688
+
689
+ // Width=15: fill = 15 < 16 → marquee at offset 0
690
+ // Note: invalidate() is called on all cells via table.invalidate() when
691
+ // the render width changes. Since we create cells with marquee,
692
+ // they manage their own lifecycle.
693
+ lines = table.render(15);
694
+ expect(strip(lines[1]!)).toContain(`${CURSOR} Hello World!!`);
695
+
696
+ // Advance 150ms = 1 tick → offset=1 → "ello World!!!!"
697
+ vi.advanceTimersByTime(150);
698
+ lines = table.render(15);
699
+ expect(strip(lines[1]!)).toContain(`${CURSOR} ello World!!!`);
700
+ });
701
+
702
+ it("clears marquee timers on invalidate", () => {
703
+ const cols: ColumnDef[] = [{ header: cell.header("Name"), width: 5 }];
704
+ const rows = [[cell.marquee("Hello World!", mockTui)]];
705
+ const table = new SortedTable(
706
+ { columns: cols, rows, maxHeight: 10, tui: mockTui },
707
+ makeTheme(),
708
+ );
709
+
710
+ // Render with focus on row 0 + content overflow → marquee timer starts
711
+ table.render(20);
712
+ expect(vi.getTimerCount()).toBe(1);
713
+
714
+ // Invalidate propagates to all cells → MarqueeCell clears interval
715
+ table.invalidate();
716
+ expect(vi.getTimerCount()).toBe(0);
717
+
718
+ // Re-render works cleanly (no stale state)
719
+ const lines = table.render(20);
720
+ expect(strip(lines[1]!)).toContain(`${CURSOR} Hello`);
721
+ });
722
+ });
723
+ });