@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.
- package/.pi/extensions/guardrails.json +10 -0
- package/.pi/extensions/guardrails.v0.json +8 -0
- package/AGENTS.md +13 -0
- package/CONTEXT.md +119 -0
- package/LICENSE +21 -0
- package/README.md +40 -0
- package/bun.lock +325 -0
- package/docs/ARCHITECTURE.md +66 -0
- package/docs/adr/0001-global-session-project-map.md +9 -0
- package/docs/adr/0002-precomputed-summaries.md +9 -0
- package/docs/agents/domain.md +42 -0
- package/docs/agents/issue-tracker.md +22 -0
- package/docs/agents/triage-labels.md +14 -0
- package/package.json +49 -0
- package/src/__tests__/cache.test.ts +388 -0
- package/src/__tests__/components.fixtures.ts +54 -0
- package/src/__tests__/compute.fixtures.ts +49 -0
- package/src/__tests__/compute.test.ts +336 -0
- package/src/__tests__/e2e.test.ts +182 -0
- package/src/__tests__/format.test.ts +232 -0
- package/src/__tests__/parser.test.ts +1396 -0
- package/src/cache.ts +178 -0
- package/src/colorPalette.ts +119 -0
- package/src/components/BarChart.ts +288 -0
- package/src/components/Dashboard.ts +222 -0
- package/src/components/Header.ts +40 -0
- package/src/components/KpiCards.ts +104 -0
- package/src/components/LoadingView.ts +38 -0
- package/src/components/MarqueeText.ts +79 -0
- package/src/components/RangeSelector.ts +63 -0
- package/src/components/RankedBarList.ts +71 -0
- package/src/components/SortedTable.ts +221 -0
- package/src/components/StatCard.ts +64 -0
- package/src/components/TabBar.ts +59 -0
- package/src/components/UsageRow.ts +55 -0
- package/src/components/__tests__/Bar.test.ts +66 -0
- package/src/components/__tests__/BarChart.test.ts +224 -0
- package/src/components/__tests__/Dashboard.test.ts +452 -0
- package/src/components/__tests__/KpiCards.test.ts +83 -0
- package/src/components/__tests__/LoadingView.test.ts +26 -0
- package/src/components/__tests__/MarqueeText.test.ts +75 -0
- package/src/components/__tests__/RangeSelector.test.ts +34 -0
- package/src/components/__tests__/RankedBarList.test.ts +110 -0
- package/src/components/__tests__/SortedTable.integration.test.ts +228 -0
- package/src/components/__tests__/SortedTable.test.ts +723 -0
- package/src/components/__tests__/TabBar.test.ts +62 -0
- package/src/components/__tests__/cells.test.ts +193 -0
- package/src/components/cells.ts +108 -0
- package/src/components/shared/Bar.ts +22 -0
- package/src/components/shared/GridRow.ts +22 -0
- package/src/compute.ts +210 -0
- package/src/format.ts +219 -0
- package/src/index.ts +88 -0
- package/src/parser.ts +363 -0
- package/src/tabs/Languages.ts +102 -0
- package/src/tabs/Models.ts +108 -0
- package/src/tabs/Overview.ts +152 -0
- package/src/tabs/Projects.ts +92 -0
- package/src/tabs/Usage.ts +181 -0
- package/src/tabs/__tests__/Languages.test.ts +158 -0
- package/src/tabs/__tests__/Models.test.ts +143 -0
- package/src/tabs/__tests__/Overview.test.ts +92 -0
- package/src/tabs/__tests__/Projects.test.ts +142 -0
- package/src/tabs/__tests__/Usage.test.ts +174 -0
- package/src/types.ts +99 -0
- 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
|
+
});
|