Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 40 additions & 15 deletions apps/tui-v2/src/ui/transcript.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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"

Expand Down Expand Up @@ -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<number | undefined>()
const [windowAnchor, setWindowAnchor] = createSignal<string | undefined>()
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
Expand All @@ -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
Expand Down Expand Up @@ -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
}
Expand All @@ -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
Expand Down Expand Up @@ -215,12 +240,12 @@ export const Transcript = (props: TranscriptProps): JSX.Element => {
{`Show earlier messages (${window().earlier} groups)`}
</text>
</Show>
<For each={visibleGroups()}>
{(group) => (
<box id={`transcript-group:${group.id}`} width="100%" flexDirection="column" flexShrink={0}>
<For each={[...visibleGroups().keys()]}>
{(id) => (
<box id={`transcript-group:${id}`} width="100%" flexDirection="column" flexShrink={0}>
<GroupView
group={group}
active={props.active && isLastItem(group, lastItemId())}
group={visibleGroups().get(id)!}
active={props.active && isLastItem(visibleGroups().get(id)!, lastItemId())}
animate={animate}
frame={frame}
isExpanded={isExpanded}
Expand Down
6 changes: 6 additions & 0 deletions apps/tui-v2/src/ui/transcript/window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@ import type { TranscriptGroup } from "./presenter"

export const transcriptWindowSize = 250

export const transcriptAnchorId = (group: TranscriptGroup): string => {
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
Expand Down
68 changes: 61 additions & 7 deletions apps/tui-v2/test/fixtures/transcript-window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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")
Expand All @@ -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/)
Expand Down Expand Up @@ -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}`,
Expand All @@ -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} `
Expand All @@ -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()
}
23 changes: 22 additions & 1 deletion apps/tui-v2/test/ui/transcript/window.test.ts
Original file line number Diff line number Diff line change
@@ -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 })
Expand Down Expand Up @@ -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)
}
}
})
Loading