diff --git a/apps/tui-v2/src/ui/transcript.tsx b/apps/tui-v2/src/ui/transcript.tsx index 2e5b7676..9930b9fd 100644 --- a/apps/tui-v2/src/ui/transcript.tsx +++ b/apps/tui-v2/src/ui/transcript.tsx @@ -1,7 +1,7 @@ import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid" import { CliRenderEvents, type KeyEvent, type ScrollBoxRenderable } from "@opentui/core" import { Effect, Schedule } from "effect" -import { For, Show, createEffect, createMemo, createSignal, on, onCleanup, type Accessor, type JSX } from "solid-js" +import { For, Show, createEffect, createMemo, createSignal, onCleanup, type Accessor, type JSX } from "solid-js" import type { TranscriptItem } from "../client/model" import { TranscriptWidth } from "./styled" import type { TranscriptGroup } from "./transcript/presenter" @@ -14,7 +14,7 @@ import { navigableIdsFor, scrollForKey, } from "./transcript/navigation" -import { selectedGroupIndex, transcriptWindow, transcriptWindowSize } from "./transcript/window" +import { selectedGroupIndex, transcriptAnchorId, transcriptWindow, transcriptWindowSize } from "./transcript/window" import { colors } from "./theme" import { AnimationViewport } from "./transcript/visibility" @@ -52,9 +52,26 @@ export const Transcript = (props: TranscriptProps): JSX.Element => { const animate = createMemo(() => props.animate !== false) const focused = createMemo(() => props.focused === true) const groups = createTranscriptProjection(() => props.items) - const [windowStart, setWindowStart] = createSignal() + const [windowAnchor, setWindowAnchor] = createSignal() + const windowStart = createMemo(() => { + const anchor = windowAnchor() + if (anchor === undefined) return undefined + const index = selectedGroupIndex({ groups: groups(), id: anchor }) + return index < 0 ? undefined : index + }) + const setWindowStart = (start: number | undefined) => { + const group = start === undefined ? undefined : groups()[start] + setWindowAnchor(group === undefined ? undefined : transcriptAnchorId(group)) + } const window = createMemo(() => transcriptWindow({ count: groups().length, start: windowStart() })) - const visibleGroups = createMemo(() => groups().slice(window().start, window().end)) + const visibleGroups = createMemo( + () => + new Map( + groups() + .slice(window().start, window().end) + .map((group) => [group.id, group]), + ), + ) let pendingScroll: "top" | "bottom" | { readonly selectedId: string } | undefined const applyWindowScroll = () => { if (scroll === undefined) return @@ -75,12 +92,12 @@ export const Transcript = (props: TranscriptProps): JSX.Element => { renderer.on(CliRenderEvents.FRAME, applyWindowScroll) onCleanup(() => renderer.off(CliRenderEvents.FRAME, applyWindowScroll)) const showWindow = (start: number | undefined, target: typeof pendingScroll) => { + const previous = window() pendingScroll = target setWindowStart(start) + if (previous.start === window().start && previous.end === window().end) applyWindowScroll() renderer.requestRender() } - const firstItemId = createMemo(() => props.items[0]?.id) - createEffect(on(firstItemId, () => showWindow(undefined, "bottom"), { defer: true })) const showEarlier = () => showWindow(Math.max(0, window().start - transcriptWindowSize), "bottom") const showNewer = () => { const start = window().end @@ -156,9 +173,13 @@ export const Transcript = (props: TranscriptProps): JSX.Element => { useKeyboard((event: KeyEvent) => { if (!focused() || scroll === undefined || event.eventType === "release" || event.defaultPrevented) return const key = event.name.toLowerCase().replace(/[ _-]/g, "") - if (["home", "pageup"].includes(key) && scroll.scrollTop <= 0 && window().earlier > 0) { - if (key === "home") showWindow(0, "top") - else showEarlier() + if (key === "home") { + showWindow(0, "top") + event.preventDefault() + return + } + if (["pageup", "up", "arrowup"].includes(key) && scroll.scrollTop <= 0 && window().earlier > 0) { + showEarlier() event.preventDefault() return } @@ -167,7 +188,11 @@ export const Transcript = (props: TranscriptProps): JSX.Element => { event.preventDefault() return } - if (key === "pagedown" && scroll.scrollTop + scroll.viewport.height >= scroll.scrollHeight && window().newer > 0) { + if ( + ["pagedown", "down", "arrowdown"].includes(key) && + scroll.scrollTop + scroll.viewport.height >= scroll.scrollHeight && + window().newer > 0 + ) { showNewer() event.preventDefault() return @@ -215,12 +240,12 @@ export const Transcript = (props: TranscriptProps): JSX.Element => { {`Show earlier messages (${window().earlier} groups)`} - - {(group) => ( - + + {(id) => ( + { + if (group.kind === "item") return group.item.id + if (group.kind === "children") return group.items[0]!.id + return `tool-child:${group.items[0]!.item.id}` +} + export const transcriptWindow = (input: { readonly count: number; readonly start: number | undefined }) => { const start = input.start === undefined || input.count <= transcriptWindowSize diff --git a/apps/tui-v2/test/fixtures/transcript-window.ts b/apps/tui-v2/test/fixtures/transcript-window.ts index 1e2ed2c8..360acda2 100644 --- a/apps/tui-v2/test/fixtures/transcript-window.ts +++ b/apps/tui-v2/test/fixtures/transcript-window.ts @@ -47,7 +47,7 @@ try { assert.match(screen.captureCharFrame(), /file-9999\.ts/) assert.equal(scroll.content.findDescendantById("transcript-newer"), undefined) - screen.mockInput.pressKey("HOME") + scroll.scrollTo({ x: 0, y: 0 }) await screen.flush() assert.match(screen.captureCharFrame(), /Show earlier messages \(9750 groups\)/) const earlier = scroll.content.findDescendantById("transcript-earlier") @@ -69,8 +69,6 @@ try { await screen.flush() assert.equal(mounted()[0]?.id, "transcript-group:item-9750") - screen.mockInput.pressKey("HOME") - await screen.flush() screen.mockInput.pressKey("HOME") await screen.flush() assert.equal(mounted()[0]?.id, "transcript-group:item-0") @@ -93,6 +91,20 @@ try { assert.equal(scroll.scrollTop, beforeScroll) bounded() + setItems((previous) => [{ id: "older", kind: "assistant", title: "Rika", text: "Retained older page" }, ...previous]) + await screen.flush() + assert.deepEqual( + mounted().map((child) => child.id), + beforeAppend, + ) + assert.equal(scroll.scrollTop, beforeScroll) + assert.ok(scroll.content.findDescendantById("transcript-earlier") !== undefined) + screen.mockInput.pressKey("HOME") + await screen.flush() + assert.match(screen.captureCharFrame(), /Retained older page/) + setItems((previous) => previous.slice(1)) + await screen.flush() + screen.mockInput.pressKey("END") await screen.flush() assert.match(screen.captureCharFrame(), /Streaming latest/) @@ -201,7 +213,7 @@ try { let serial = 8 for (const kind of ["child", "tool"] as const) { setItems([ - ...Array.from({ length: 100 }, (_, index) => ({ + ...Array.from({ length: 251 }, (_, index) => ({ id: `${kind}-${index}`, kind, title: kind === "child" ? `Reviewer-${index}` : `bash command-${index}`, @@ -216,20 +228,23 @@ try { })), ]) await screen.flush() - assert.equal(mounted().some((node) => node.id.includes(`${kind}-0`)), false) + assert.equal( + mounted().some((node) => node.id.includes(`${kind}-0`)), + false, + ) if (kind === "tool") { setNavigation({ serial: serial++, action: "next" }) await screen.flush() setNavigation({ serial: serial++, action: "toggle" }) await screen.flush() } - for (let index = 0; index < 100; index += 1) { + for (let index = 0; index < 251; index += 1) { setNavigation({ serial: serial++, action: "next" }) await screen.flush() const label = kind === "child" ? `Reviewer-${index} ` : `command-${index} ` assert.ok(screen.captureCharFrame().includes(label), `selected ${kind} ${index} must be visible`) } - for (let index = 98; index >= 0; index -= 1) { + for (let index = 249; index >= 0; index -= 1) { setNavigation({ serial: serial++, action: "previous" }) await screen.flush() const label = kind === "child" ? `Reviewer-${index} ` : `command-${index} ` @@ -240,6 +255,45 @@ try { assert.match(screen.captureCharFrame(), /Result-0/) bounded() } + + const settled = Array.from( + { length: 4 }, + (_, index): TranscriptItem => ({ + id: `settled-${index}`, + kind: "tool", + title: "bash", + text: "$ check\nSame result", + status: "idle", + }), + ) + setItems(settled.slice(0, 3)) + await screen.flush() + setNavigation({ serial: serial++, action: "next" }) + await screen.flush() + setNavigation({ serial: serial++, action: "toggle" }) + await screen.flush() + const firstHeader = scroll.content.findDescendantById("transcript-header:tool-child:settled-0") + assert.ok(firstHeader !== undefined) + setItems(settled) + await screen.flush() + assert.equal(scroll.content.findDescendantById("transcript-header:tool-child:settled-0"), firstHeader) + for (const item of settled) { + assert.ok(scroll.content.findDescendantById(`transcript-header:tool-child:${item.id}`) !== undefined) + } + screen.resize(70, 18) + await screen.flush() + assert.equal(scroll.content.findDescendantById("transcript-header:tool-child:settled-0"), firstHeader) + setNavigation({ serial: serial++, action: "toggle" }) + await screen.flush() + assert.equal(scroll.content.findDescendantById("transcript-header:tool-child:settled-0"), undefined) + setItems(settled.map((item) => ({ ...item }))) + await screen.flush() + assert.equal(scroll.content.findDescendantById("transcript-header:tool-child:settled-0"), undefined) + setNavigation({ serial: serial++, action: "toggle" }) + await screen.flush() + for (const item of settled) { + assert.ok(scroll.content.findDescendantById(`transcript-header:tool-child:${item.id}`) !== undefined) + } } finally { screen.renderer.destroy() } diff --git a/apps/tui-v2/test/ui/transcript/window.test.ts b/apps/tui-v2/test/ui/transcript/window.test.ts index 8da41003..f11ae1d6 100644 --- a/apps/tui-v2/test/ui/transcript/window.test.ts +++ b/apps/tui-v2/test/ui/transcript/window.test.ts @@ -1,5 +1,6 @@ import { expect, test } from "vitest" -import { transcriptWindow } from "../../../src/ui/transcript/window" +import { selectedGroupIndex, transcriptAnchorId, transcriptWindow } from "../../../src/ui/transcript/window" +import { buildTranscriptGroups } from "../../../src/ui/transcript/presenter" test("short transcripts need no paging controls or phantom geometry", () => { expect(transcriptWindow({ count: 0, start: undefined })).toEqual({ start: 0, end: 0, earlier: 0, newer: 0 }) @@ -28,3 +29,23 @@ test("successive windows cover every group exactly once", () => { } expect(visited).toEqual(Array.from({ length: 10_013 }, (_, index) => index)) }) + +test("oversized first families keep every member independently of the mounted group budget", () => { + for (const kind of ["tool", "child"] as const) { + const items = Array.from({ length: 501 }, (_, index) => ({ + id: `${kind}-${index}`, + kind, + title: kind === "tool" ? "bash" : "Reviewer", + text: "result", + })) + const groups = buildTranscriptGroups(items) + expect(groups).toHaveLength(1) + expect(transcriptWindow({ count: groups.length, start: 0 })).toEqual({ start: 0, end: 1, earlier: 0, newer: 0 }) + const id = transcriptAnchorId(groups[0]!) + const prepended = buildTranscriptGroups([{ ...items[0]!, id: "older" }, ...items]) + expect(selectedGroupIndex({ groups: prepended, id })).toBe(0) + for (const item of items) { + expect(selectedGroupIndex({ groups, id: kind === "tool" ? `tool-child:${item.id}` : item.id })).toBe(0) + } + } +})