e266782604
Earlier rounds shrank the SessionList by the dialog's row count to keep the picker inside the viewport, but the SessionList could only claw back whole session rows and bottomed out at zero entries. On a narrow terminal with a long session title the dialog still wrapped past what the SessionList could free, the picker overflowed the viewport, and the TUI committed the header into native scrollback. The picker now hosts the SessionList inside a single contentSlot Container. Opening the delete confirmation swaps the dialog INTO that slot (replacing the SessionList); closing it swaps the SessionList back. The dialog therefore competes only with the SessionList's rendered budget, not with the SessionList AND the picker chrome, so the picker frame stays bounded by terminalRows even when the dialog wraps to many rows. SessionList's external-reserve plumbing is no longer needed and is removed. Addresses PR #3285 second-round review feedback.
167 lines
6.0 KiB
TypeScript
167 lines
6.0 KiB
TypeScript
import { beforeAll, describe, expect, it } from "bun:test";
|
|
import { SessionSelectorComponent } from "@oh-my-pi/pi-coding-agent/modes/components/session-selector";
|
|
import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
|
|
import type { SessionInfo } from "@oh-my-pi/pi-coding-agent/session/session-listing";
|
|
import { TUI } from "@oh-my-pi/pi-tui";
|
|
import { StressRenderScheduler } from "../../../../tui/test/render-stress-scheduler";
|
|
import { VirtualTerminal } from "../../../../tui/test/virtual-terminal";
|
|
|
|
beforeAll(() => {
|
|
initTheme();
|
|
});
|
|
|
|
function makeSessions(count: number): SessionInfo[] {
|
|
return Array.from({ length: count }, (_, i) => ({
|
|
path: `/work/SESSION_${i}.jsonl`,
|
|
id: `id-${i}`,
|
|
cwd: "/work",
|
|
title: `SESSION_${i}`,
|
|
created: new Date("2024-01-01T00:00:00Z"),
|
|
modified: new Date("2024-01-02T00:00:00Z"),
|
|
messageCount: 1,
|
|
size: 1024,
|
|
firstMessage: `body content ${i}`,
|
|
allMessagesText: `body content ${i}`,
|
|
}));
|
|
}
|
|
|
|
describe("issue #3283: /resume picker scrolls down after deleting a session", () => {
|
|
it("keeps the picker header pinned at the same viewport row before and after a delete", async () => {
|
|
const term = new VirtualTerminal(80, 24, 4096);
|
|
const scheduler = new StressRenderScheduler();
|
|
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
|
|
const selector = new SessionSelectorComponent(
|
|
makeSessions(20),
|
|
() => {},
|
|
() => {},
|
|
() => {},
|
|
{
|
|
getTerminalRows: () => term.rows,
|
|
onDelete: async () => true,
|
|
},
|
|
);
|
|
selector.setOnRequestRender(() => tui.requestRender());
|
|
tui.addChild(selector);
|
|
tui.setFocus(selector);
|
|
|
|
try {
|
|
tui.start();
|
|
await scheduler.drain(term);
|
|
|
|
const headerRowBefore = term.getViewport().findIndex(row => Bun.stripANSI(row).includes("Resume Session"));
|
|
expect(headerRowBefore).toBeGreaterThanOrEqual(0);
|
|
|
|
// Press Delete (CSI 3 ~) to open the confirmation dialog, then
|
|
// Enter to accept "Yes".
|
|
selector.handleInput("\x1b[3~");
|
|
tui.requestRender();
|
|
await scheduler.drain(term);
|
|
selector.handleInput("\n");
|
|
// onDelete is async; let its microtasks flush before draining renders.
|
|
for (let i = 0; i < 8; i++) await Promise.resolve();
|
|
await scheduler.drain(term);
|
|
const viewport = term.getViewport().map(row => Bun.stripANSI(row).trimEnd());
|
|
const headerRowAfter = viewport.findIndex(row => row.includes("Resume Session"));
|
|
|
|
// Regression: dialog growing the frame and then shrinking must
|
|
// not push the picker header further down into the viewport
|
|
// (committed scrollback rows from the dialog frame).
|
|
expect(headerRowAfter).toBeGreaterThanOrEqual(0);
|
|
expect(headerRowAfter).toBe(headerRowBefore);
|
|
} finally {
|
|
tui.stop();
|
|
await term.flush();
|
|
}
|
|
});
|
|
it("keeps the picker header pinned even when the delete dialog is canceled", async () => {
|
|
const term = new VirtualTerminal(80, 24, 4096);
|
|
const scheduler = new StressRenderScheduler();
|
|
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
|
|
const selector = new SessionSelectorComponent(
|
|
makeSessions(20),
|
|
() => {},
|
|
() => {},
|
|
() => {},
|
|
{
|
|
getTerminalRows: () => term.rows,
|
|
onDelete: async () => true,
|
|
},
|
|
);
|
|
selector.setOnRequestRender(() => tui.requestRender());
|
|
tui.addChild(selector);
|
|
tui.setFocus(selector);
|
|
|
|
try {
|
|
tui.start();
|
|
await scheduler.drain(term);
|
|
const headerRowBefore = term.getViewport().findIndex(row => Bun.stripANSI(row).includes("Resume Session"));
|
|
expect(headerRowBefore).toBeGreaterThanOrEqual(0);
|
|
|
|
// Open dialog, then Esc to cancel without deleting.
|
|
selector.handleInput("\x1b[3~");
|
|
tui.requestRender();
|
|
await scheduler.drain(term);
|
|
selector.handleInput("\x1b");
|
|
await scheduler.drain(term);
|
|
|
|
const viewport = term.getViewport().map(row => Bun.stripANSI(row).trimEnd());
|
|
const headerRowAfter = viewport.findIndex(row => row.includes("Resume Session"));
|
|
expect(headerRowAfter).toBe(headerRowBefore);
|
|
// Dialog gone, no scroll-down artefact.
|
|
expect(viewport.some(row => row.includes("Delete session?"))).toBe(false);
|
|
} finally {
|
|
tui.stop();
|
|
await term.flush();
|
|
}
|
|
});
|
|
|
|
it("keeps the picker frame bounded on a narrow terminal where the dialog title wraps", () => {
|
|
// Direct structural contract: even on a small terminal with a
|
|
// session name that wraps the dialog past any plausible fixed
|
|
// reserve, the picker's total rendered output stays within the
|
|
// terminal height. The dialog REPLACES the SessionList inside
|
|
// the picker frame, so its rows compete only with the
|
|
// SessionList's rendered budget — not with both the SessionList
|
|
// AND picker chrome (PR #3285 second review round). Without the
|
|
// structural fix the dialog could push the picker past the
|
|
// viewport once the SessionList reserve could not shrink any
|
|
// further, and the TUI committed the header into scrollback.
|
|
const longName = "a-very-very-very-very-long-session-title-that-must-wrap-on-a-narrow-terminal";
|
|
const sessions: SessionInfo[] = [
|
|
{
|
|
path: `/work/${longName}.jsonl`,
|
|
id: "id-long",
|
|
cwd: "/work",
|
|
title: longName,
|
|
created: new Date("2024-01-01T00:00:00Z"),
|
|
modified: new Date("2024-01-02T00:00:00Z"),
|
|
messageCount: 1,
|
|
size: 1024,
|
|
firstMessage: longName,
|
|
allMessagesText: longName,
|
|
},
|
|
...makeSessions(10),
|
|
];
|
|
|
|
const NARROW_WIDTH = 30;
|
|
const TERMINAL_ROWS = 24;
|
|
const selector = new SessionSelectorComponent(
|
|
sessions,
|
|
() => {},
|
|
() => {},
|
|
() => {},
|
|
{ getTerminalRows: () => TERMINAL_ROWS, onDelete: async () => true },
|
|
);
|
|
|
|
// Baseline: picker fits the viewport before the dialog opens.
|
|
expect(selector.render(NARROW_WIDTH).length).toBeLessThanOrEqual(TERMINAL_ROWS);
|
|
|
|
// Open the delete confirmation; the dialog takes the SessionList
|
|
// slot inside the picker frame. The picker's rendered total must
|
|
// STILL fit the viewport even though the dialog wraps past the
|
|
// previous 12-row reserve guess.
|
|
selector.handleInput("\x1b[3~");
|
|
expect(selector.render(NARROW_WIDTH).length).toBeLessThanOrEqual(TERMINAL_ROWS);
|
|
});
|
|
});
|