diff --git a/src/tui/log.tsx b/src/tui/log.tsx index 8fc86f1..7e582d0 100644 --- a/src/tui/log.tsx +++ b/src/tui/log.tsx @@ -148,7 +148,9 @@ function LogScreen({ // biome-ignore lint/suspicious/noArrayIndexKey: the absolute file line is the stable record identity. diff --git a/tests/tui.test.tsx b/tests/tui.test.tsx index e17fd06..61ac167 100644 --- a/tests/tui.test.tsx +++ b/tests/tui.test.tsx @@ -127,6 +127,157 @@ test("log follows, searches whole file, isolates input and escapes back to selec } }); +test("horizontal log panning preserves record rows and live follow", async () => { + const directory = await mkdtemp(join(tmpdir(), "aw-tui-")); + const { source, sessions } = monitorFixture(); + sessions[0]!.log = join(directory, "events.log"); + const records = Array.from({ length: 26 }, (_, index) => `line-${index + 1}`); + records[4] = JSON.stringify({ + type: "event", + metadata: "m".repeat(60), + text: "short", + }); + records[5] = `${"a".repeat(60)}active-anchor`; + records[6] = `${"b".repeat(60)}active-next`; + await writeFile(sessions[0]!.log, `${records.join("\n")}\n`); + const view = render(); + try { + await until(() => view.lastFrame()!.includes("invocation 1")); + view.stdin.write("l"); + await until(() => + view.lastFrame()!.includes("LIVE FOLLOW · Readable · lines 3–26/26"), + ); + const initialRows = view.lastFrame()!.split("\n"); + const anchorRow = initialRows.findIndex((row) => + row.includes("active-anchor"), + ); + const nextRow = initialRows.findIndex((row) => row.includes("active-next")); + assert.ok(anchorRow >= 0); + assert.ok(initialRows[anchorRow]!.startsWith("a".repeat(60))); + assert.equal(nextRow, anchorRow + 1); + assert.ok(initialRows[anchorRow - 1]!.startsWith("event: short")); + + view.stdin.write("\u001b[C"); + view.stdin.write("\u001b[C"); + view.stdin.write("\u001b[C"); + await settle(); + + const pannedRows = view.lastFrame()!.split("\n"); + assert.equal( + pannedRows.findIndex((row) => row.includes("active-anchor")), + anchorRow, + ); + assert.equal( + pannedRows.findIndex((row) => row.includes("active-next")), + nextRow, + ); + assert.ok(pannedRows[anchorRow]!.startsWith("active-anchor")); + assert.equal(pannedRows[anchorRow - 1]!.trim(), ""); + assert.match(view.lastFrame()!, /LIVE FOLLOW · Readable · lines 3–26\/26/); + + view.stdin.write("R"); + await settle(); + assert.match(view.lastFrame()!, /LIVE FOLLOW · Raw · lines 3–26\/26/); + const rawRows = view.lastFrame()!.split("\n"); + assert.ok(rawRows[anchorRow - 1]!.startsWith("m".repeat(20))); + assert.ok(rawRows[anchorRow]!.startsWith("active-anchor")); + assert.equal( + rawRows.findIndex((row) => row.includes("active-anchor")), + anchorRow, + ); + assert.equal( + rawRows.findIndex((row) => row.includes("active-next")), + nextRow, + ); + + await appendFile(sessions[0]!.log, `${"z".repeat(60)}live-after-pan\n`); + await until(() => + view.lastFrame()!.includes("LIVE FOLLOW · Raw · lines 4–27/27"), + ); + assert.match(view.lastFrame()!, /live-after-pan/); + const rowAfterAppend = view + .lastFrame()! + .split("\n") + .findIndex((row) => row.includes("active-anchor")); + + view.stdin.write("\u001b[D"); + view.stdin.write("\u001b[D"); + view.stdin.write("\u001b[D"); + await settle(); + assert.equal( + view + .lastFrame()! + .split("\n") + .findIndex((row) => row.includes("active-anchor")), + rowAfterAppend, + ); + assert.equal( + view + .lastFrame()! + .split("\n") + .findIndex((row) => row.includes("active-next")), + rowAfterAppend + 1, + ); + assert.ok( + view.lastFrame()!.split("\n")[rowAfterAppend]!.startsWith("a".repeat(60)), + ); + assert.match(view.lastFrame()!, /LIVE FOLLOW · Raw · lines 4–27\/27/); + } finally { + view.unmount(); + await rm(directory, { recursive: true, force: true }); + } +}); + +test("completed log panning preserves a manually selected line range", async () => { + const directory = await mkdtemp(join(tmpdir(), "aw-tui-")); + const { run, sessions, source } = monitorFixture(); + run.outcome = "completed"; + run.executions![0]!.outcome = "completed"; + sessions[0]!.outcome = "completed"; + sessions[0]!.log = join(directory, "completed.log"); + const records = Array.from({ length: 30 }, (_, index) => `line-${index + 1}`); + records[3] = "short"; + records[4] = `${"b".repeat(20)}completed-anchor`; + await writeFile(sessions[0]!.log, `${records.join("\n")}\n`); + const view = render(); + try { + await until(() => view.lastFrame()!.includes("Implement feature")); + view.stdin.write("\r"); + await until(() => view.lastFrame()!.includes("l log: implementation")); + view.stdin.write("l"); + await until(() => + view.lastFrame()!.includes("LIVE FOLLOW · Readable · lines 7–30/30"), + ); + for (let index = 0; index < 5; index++) view.stdin.write("\u001b[A"); + await until(() => + view.lastFrame()!.includes("SCROLLING · Readable · lines 2–25/30"), + ); + const anchorRow = view + .lastFrame()! + .split("\n") + .findIndex((row) => row.includes("completed-anchor")); + assert.ok(anchorRow >= 0); + + view.stdin.write("\u001b[C"); + await settle(); + + assert.equal( + view + .lastFrame()! + .split("\n") + .findIndex((row) => row.includes("completed-anchor")), + anchorRow, + ); + assert.ok( + view.lastFrame()!.split("\n")[anchorRow]!.startsWith("completed-anchor"), + ); + assert.match(view.lastFrame()!, /SCROLLING · Readable · lines 2–25\/30/); + } finally { + view.unmount(); + await rm(directory, { recursive: true, force: true }); + } +}); + test("selection survives new rows and stale selected-run responses", async () => { const { source, run, sessions } = monitorFixture(); let runs = [