Skip to content

Commit 408ccb6

Browse files
Merge PR #611: keep Pi /ctx-status within terminal height
Co-authored-by: Alfonso <alfonso-magic-context@users.noreply.github.com>
2 parents 96e9ac9 + de1b943 commit 408ccb6

3 files changed

Lines changed: 443 additions & 17 deletions

File tree

Lines changed: 375 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,375 @@
1+
import { describe, expect, it, mock, spyOn } from "bun:test";
2+
import { visibleWidth } from "@earendil-works/pi-tui";
3+
import { resolveProjectIdentity } from "@magic-context/core/features/magic-context/memory/project-identity";
4+
import {
5+
getOrCreateSessionMeta,
6+
updateSessionMeta,
7+
} from "@magic-context/core/features/magic-context/storage-meta";
8+
import { closeQuietly } from "@magic-context/core/shared/sqlite-helpers";
9+
import { createTestDb, fakeContext } from "../test-utils.test";
10+
import {
11+
type StatusDialogDeps,
12+
showStatusDialog,
13+
stopStatusDialogRefresh,
14+
} from "./status-dialog";
15+
16+
interface Dialog {
17+
render(width: number): string[];
18+
handleInput(data: string): void;
19+
dispose(): void;
20+
}
21+
22+
const keys = {
23+
up: "\x1b[A",
24+
down: "\x1b[B",
25+
pageUp: "\x1b[5~",
26+
pageDown: "\x1b[6~",
27+
home: "\x1b[H",
28+
end: "\x1b[F",
29+
};
30+
31+
async function withDialog(
32+
run: (
33+
dialog: Dialog,
34+
host: {
35+
terminal: { rows: number };
36+
deps: StatusDialogDeps;
37+
refresh: () => void;
38+
requestRender: ReturnType<typeof mock>;
39+
done: ReturnType<typeof mock>;
40+
clearTimer: ReturnType<typeof spyOn>;
41+
},
42+
) => void,
43+
shortSessionTtl = false,
44+
) {
45+
const db = createTestDb();
46+
const sessionId = "ses-status-scroll";
47+
if (shortSessionTtl) {
48+
// A short session TTL lets the real model select two columns at wide widths.
49+
getOrCreateSessionMeta(db, sessionId);
50+
updateSessionMeta(db, sessionId, { cacheTtl: "10m" });
51+
}
52+
const terminal = { rows: 24 };
53+
const deps: StatusDialogDeps = {
54+
db,
55+
projectIdentity: resolveProjectIdentity(process.cwd()),
56+
};
57+
const requestRender = mock(() => {});
58+
const done = mock(() => {});
59+
let tick = () => {};
60+
const timer = spyOn(globalThis, "setInterval").mockImplementation(((
61+
callback: () => void,
62+
) => {
63+
tick = callback;
64+
return 123;
65+
}) as never);
66+
const clearTimer = spyOn(globalThis, "clearInterval").mockImplementation(
67+
() => {},
68+
);
69+
try {
70+
await showStatusDialog(
71+
{ getAllTools: () => [] } as never,
72+
{
73+
...fakeContext(sessionId),
74+
getContextUsage: () => ({
75+
tokens: 40_000,
76+
percent: 20,
77+
contextWindow: 200_000,
78+
}),
79+
getSystemPrompt: () => "system prompt",
80+
ui: {
81+
async custom(factory: (...args: unknown[]) => Dialog) {
82+
const dialog = factory(
83+
{ terminal, requestRender },
84+
{
85+
fg: (_color: string, text: string) => text,
86+
bold: (text: string) => text,
87+
},
88+
{},
89+
done,
90+
);
91+
try {
92+
run(dialog, {
93+
terminal,
94+
deps,
95+
refresh: () => tick(),
96+
requestRender,
97+
done,
98+
clearTimer,
99+
});
100+
} finally {
101+
dialog.dispose();
102+
}
103+
},
104+
},
105+
} as never,
106+
deps,
107+
);
108+
} finally {
109+
timer.mockRestore();
110+
clearTimer.mockRestore();
111+
closeQuietly(db);
112+
}
113+
}
114+
115+
function assertFits(lines: string[], width: number, rows: number) {
116+
expect(lines.length).toBeLessThanOrEqual(rows);
117+
expect(lines[0]?.startsWith("╭")).toBe(true);
118+
expect(lines.at(-1)?.startsWith("╰")).toBe(true);
119+
for (const line of lines)
120+
expect(visibleWidth(line)).toBeLessThanOrEqual(width);
121+
}
122+
123+
function visibleRange(lines: string[]) {
124+
const match = lines.at(-2)?.match(/(\d+)-(\d+)\/(\d+)/);
125+
expect(match).not.toBeNull();
126+
return {
127+
first: Number(match?.[1]),
128+
last: Number(match?.[2]),
129+
total: Number(match?.[3]),
130+
};
131+
}
132+
133+
function assertBottom(dialog: Dialog, width: number, rows: number) {
134+
const lines = dialog.render(width);
135+
assertFits(lines, width, rows);
136+
const range = visibleRange(lines);
137+
expect(range.last).toBe(range.total);
138+
expect(lines.join("\n")).toContain("Injected");
139+
return lines;
140+
}
141+
142+
describe("Pi status dialog scrolling", () => {
143+
it("keeps the title, close hint and bottom border visible in a short terminal", async () => {
144+
await withDialog((dialog, { terminal }) => {
145+
const lines = dialog.render(78);
146+
assertFits(lines, 78, terminal.rows);
147+
expect(lines[1]).toContain("Magic Context Status");
148+
expect(lines.at(-2)).toContain("Esc to close");
149+
expect(lines.at(-2)).toContain("scroll");
150+
});
151+
});
152+
153+
it("fits the footer to its text width without truncating optional controls", async () => {
154+
await withDialog((dialog, { terminal }) => {
155+
for (const rows of [5, 24]) {
156+
terminal.rows = rows;
157+
for (const width of [16, 24, 40, 60]) {
158+
dialog.render(width);
159+
dialog.handleInput(keys.end);
160+
const lines = dialog.render(width);
161+
assertFits(lines, width, rows);
162+
const footer = lines.at(-2) ?? "";
163+
expect(footer).toContain("Esc to close");
164+
expect(footer).not.toContain("…");
165+
if (width === 60) {
166+
expect(footer).toContain("↑↓/PgUp/PgDn/Home/End scroll");
167+
} else if (width === 40) {
168+
expect(footer).toContain("↑↓");
169+
expect(footer).not.toContain("PgUp");
170+
} else {
171+
expect(footer).not.toContain("↑↓");
172+
}
173+
}
174+
}
175+
});
176+
});
177+
178+
it("scrolls by a row or page, reaches the last data and clamps both endpoints", async () => {
179+
await withDialog((dialog, { terminal, requestRender }) => {
180+
terminal.rows = 12;
181+
const top = dialog.render(78);
182+
expect(visibleRange(top).first).toBe(1);
183+
dialog.handleInput(keys.up);
184+
dialog.handleInput(keys.pageUp);
185+
expect(dialog.render(78)).toEqual(top);
186+
requestRender.mockClear();
187+
dialog.handleInput(keys.down);
188+
expect(requestRender).toHaveBeenCalled();
189+
const down = dialog.render(78);
190+
expect(visibleRange(down).first).toBe(2);
191+
expect(down[0]).toBe(top[0]);
192+
expect(down[1]).toBe(top[1]);
193+
expect(down.at(-1)).toBe(top.at(-1));
194+
expect(down.slice(2, -3)).toEqual(top.slice(3, -2));
195+
dialog.handleInput(keys.up);
196+
expect(dialog.render(78)).toEqual(top);
197+
dialog.handleInput(keys.pageDown);
198+
expect(visibleRange(dialog.render(78)).first).toBe(9);
199+
dialog.handleInput(keys.pageUp);
200+
expect(dialog.render(78)).toEqual(top);
201+
dialog.handleInput(keys.end);
202+
const bottom = assertBottom(dialog, 78, terminal.rows);
203+
dialog.handleInput(keys.down);
204+
dialog.handleInput(keys.pageDown);
205+
expect(dialog.render(78)).toEqual(bottom);
206+
dialog.handleInput(keys.home);
207+
expect(dialog.render(78)).toEqual(top);
208+
dialog.handleInput("d");
209+
expect(dialog.render(78)).toEqual(top);
210+
});
211+
});
212+
213+
it("keeps every body row reachable through the viewport", async () => {
214+
await withDialog((dialog, { terminal }) => {
215+
terminal.rows = 100;
216+
const full = dialog.render(78);
217+
const expectedBody = full.slice(2, -2);
218+
terminal.rows = 5;
219+
const seen: string[] = [];
220+
for (let i = 0; i < expectedBody.length; i++) {
221+
const lines = dialog.render(78);
222+
assertFits(lines, 78, terminal.rows);
223+
seen.push(lines[2] ?? "");
224+
dialog.handleInput(keys.down);
225+
}
226+
expect(seen).toEqual(expectedBody);
227+
});
228+
});
229+
230+
it("retains the complete presentation and ignores scrolling when content fits", async () => {
231+
await withDialog((dialog, { terminal }) => {
232+
terminal.rows = 100;
233+
const full = dialog.render(78);
234+
assertFits(full, 78, terminal.rows);
235+
expect(full.join("\n")).toContain("Context Details");
236+
expect(full.join("\n")).toContain("Injected");
237+
expect(full.at(-2)).toContain("Esc to close");
238+
expect(full.at(-2)).not.toContain("scroll");
239+
for (const key of Object.values(keys)) {
240+
dialog.handleInput(key);
241+
expect(dialog.render(78)).toEqual(full);
242+
}
243+
});
244+
});
245+
246+
it("clamps after height changes and real single/two-column transitions", async () => {
247+
await withDialog((dialog, { terminal }) => {
248+
terminal.rows = 100;
249+
const single = dialog.render(40);
250+
const double = dialog.render(110);
251+
expect(single.some((line) => /Tags\s+Reductions/.test(line))).toBe(false);
252+
expect(double.some((line) => /Tags\s+Reductions/.test(line))).toBe(true);
253+
terminal.rows = 12;
254+
dialog.render(40);
255+
dialog.handleInput(keys.end);
256+
assertBottom(dialog, 40, terminal.rows);
257+
// The two-column body is shorter, so the old offset must be clamped.
258+
const doubleBottom = assertBottom(dialog, 110, terminal.rows);
259+
dialog.handleInput(keys.end);
260+
expect(dialog.render(110)).toEqual(doubleBottom);
261+
assertFits(dialog.render(40), 40, terminal.rows);
262+
dialog.handleInput(keys.end);
263+
assertBottom(dialog, 40, terminal.rows);
264+
terminal.rows = 24;
265+
assertBottom(dialog, 40, terminal.rows);
266+
terminal.rows = 100;
267+
expect(dialog.render(40)).toEqual(single);
268+
terminal.rows = 12;
269+
expect(visibleRange(dialog.render(40)).first).toBe(1);
270+
}, true);
271+
});
272+
273+
it("preserves refresh position and clamps when refreshed content shrinks", async () => {
274+
await withDialog((dialog, { deps, terminal, refresh, requestRender }) => {
275+
dialog.render(78);
276+
dialog.handleInput(keys.pageDown);
277+
const before = dialog.render(78);
278+
refresh();
279+
expect(requestRender).toHaveBeenCalled();
280+
expect(dialog.render(78)).toEqual(before);
281+
// Adding/removing a real Status section changes the body length.
282+
deps.configGeneration = 1;
283+
deps.configAdoptedAt = 1730000000000;
284+
refresh();
285+
const grown = dialog.render(78);
286+
expect(visibleRange(grown).first).toBe(visibleRange(before).first);
287+
expect(visibleRange(grown).total).toBeGreaterThan(
288+
visibleRange(before).total,
289+
);
290+
dialog.handleInput(keys.end);
291+
const grownBottom = assertBottom(dialog, 78, terminal.rows);
292+
delete deps.configGeneration;
293+
refresh();
294+
const shrunk = assertBottom(dialog, 78, terminal.rows);
295+
expect(visibleRange(shrunk).total).toBeLessThan(
296+
visibleRange(grownBottom).total,
297+
);
298+
dialog.handleInput(keys.end);
299+
expect(dialog.render(78)).toEqual(shrunk);
300+
});
301+
});
302+
303+
it("fits narrow and short terminals at both scroll endpoints", async () => {
304+
await withDialog((dialog, { terminal }) => {
305+
for (const width of [24, 40, 78, 110]) {
306+
for (const rows of [5, 12, 24, 60]) {
307+
terminal.rows = rows;
308+
dialog.handleInput(keys.home);
309+
const top = dialog.render(width);
310+
assertFits(top, width, rows);
311+
expect(top.at(-2)).toContain("Esc to close");
312+
dialog.handleInput(keys.end);
313+
const bottom = dialog.render(width);
314+
assertFits(bottom, width, rows);
315+
expect(bottom.at(-2)).toContain("Esc to close");
316+
}
317+
}
318+
});
319+
});
320+
321+
it("degrades safely when there is not enough space for the full frame", async () => {
322+
await withDialog((dialog, { terminal }) => {
323+
for (const rows of [0, 1, 2, 3, 4, 5]) {
324+
terminal.rows = rows;
325+
for (const width of [0, 1, 2, 3, 4, 12, 16, 24]) {
326+
const lines = dialog.render(width);
327+
expect(lines.length).toBeLessThanOrEqual(rows);
328+
for (const line of lines)
329+
expect(visibleWidth(line)).toBeLessThanOrEqual(width);
330+
if (rows >= 2 && width >= (rows < 5 ? 12 : 16))
331+
expect(lines.join("\n")).toContain("Esc to close");
332+
}
333+
}
334+
});
335+
});
336+
337+
it("preserves close keys and releases its refresh timer exactly once", async () => {
338+
for (const key of ["\x1b", "\r", "\x03"]) {
339+
await withDialog(
340+
(dialog, { done, clearTimer, refresh, requestRender }) => {
341+
dialog.render(78);
342+
dialog.handleInput(keys.end);
343+
dialog.handleInput(key);
344+
expect(done).toHaveBeenCalledTimes(1);
345+
expect(clearTimer).toHaveBeenCalledTimes(1);
346+
expect(clearTimer).toHaveBeenCalledWith(123);
347+
requestRender.mockClear();
348+
refresh();
349+
dialog.handleInput(keys.down);
350+
expect(requestRender).not.toHaveBeenCalled();
351+
dialog.handleInput(key);
352+
stopStatusDialogRefresh();
353+
dialog.dispose();
354+
expect(done).toHaveBeenCalledTimes(1);
355+
expect(clearTimer).toHaveBeenCalledTimes(1);
356+
},
357+
);
358+
}
359+
});
360+
361+
it("shutdown closes a scrolled dialog and prevents later refreshes", async () => {
362+
await withDialog((dialog, { done, clearTimer, refresh, requestRender }) => {
363+
dialog.render(78);
364+
dialog.handleInput(keys.end);
365+
stopStatusDialogRefresh();
366+
expect(done).toHaveBeenCalledTimes(1);
367+
expect(clearTimer).toHaveBeenCalledTimes(1);
368+
requestRender.mockClear();
369+
refresh();
370+
expect(requestRender).not.toHaveBeenCalled();
371+
stopStatusDialogRefresh();
372+
expect(done).toHaveBeenCalledTimes(1);
373+
});
374+
});
375+
});

0 commit comments

Comments
 (0)