From 4073d07a69d598f559a901fd49ed249b087252e6 Mon Sep 17 00:00:00 2001 From: James Long Date: Fri, 11 Sep 2026 11:52:36 +0000 Subject: [PATCH 1/2] refactor(tui): render nested groups with message anchors --- packages/tui/src/context/session-tabs.tsx | 9 +- .../tui/src/routes/session/anchor-view.tsx | 95 ++++ .../tui/src/routes/session/group-view.tsx | 329 ++++++++++++++ .../src/routes/session/grouping/session.ts | 5 +- packages/tui/src/routes/session/index.tsx | 425 ++++++------------ .../tui/src/routes/session/message-anchors.ts | 51 +++ .../tui/src/routes/session/message-parts.tsx | 22 + .../tui/src/routes/session/render-context.tsx | 2 + packages/tui/test/cli/tui/group-view.test.tsx | 167 +++++++ .../tui/test/cli/tui/message-anchors.test.ts | 50 +++ .../tui/test/context/session-tabs.test.tsx | 5 + .../test/session-group-navigation.test.tsx | 154 +++++++ 12 files changed, 1027 insertions(+), 287 deletions(-) create mode 100644 packages/tui/src/routes/session/anchor-view.tsx create mode 100644 packages/tui/src/routes/session/group-view.tsx create mode 100644 packages/tui/src/routes/session/message-anchors.ts create mode 100644 packages/tui/test/cli/tui/group-view.test.tsx create mode 100644 packages/tui/test/cli/tui/message-anchors.test.ts create mode 100644 packages/tui/test/session-group-navigation.test.tsx diff --git a/packages/tui/src/context/session-tabs.tsx b/packages/tui/src/context/session-tabs.tsx index fe816abcf96f..366458c35ba5 100644 --- a/packages/tui/src/context/session-tabs.tsx +++ b/packages/tui/src/context/session-tabs.tsx @@ -43,6 +43,8 @@ type PersistedState = { type ScrollAnchor = { messageID: string screenY: number + /** Restore an entry inside its ancestors, rather than the collapsed summary. */ + reveal?: boolean } const empty = (): TabsState => ({ tabs: [], unread: {} }) @@ -393,7 +395,12 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp return } const current = scrollAnchors.get(sessionID) - if (current?.messageID === anchor.messageID && current.screenY === anchor.screenY) return + if ( + current?.messageID === anchor.messageID && + current.screenY === anchor.screenY && + current.reveal === anchor.reveal + ) + return scrollAnchors.set(sessionID, anchor) }, select(sessionID: string) { diff --git a/packages/tui/src/routes/session/anchor-view.tsx b/packages/tui/src/routes/session/anchor-view.tsx new file mode 100644 index 000000000000..a7f4f9f9336d --- /dev/null +++ b/packages/tui/src/routes/session/anchor-view.tsx @@ -0,0 +1,95 @@ +import { createEffect, createSignal, onCleanup, type Accessor } from "solid-js" +import type { BoxRenderable } from "@opentui/core" +import type { JSX } from "@opentui/solid" +import type { SessionMessageInfo } from "@opencode/client" +import type { SessionEntry, SessionNode } from "./grouping/session" +import { use } from "./render-context" + +export function entryMessageID(entry: SessionEntry) { + if (entry.type === "part") return entry.ref.messageID + if (entry.type === "message" || entry.type === "assistant-footer") return entry.messageID + if (entry.type === "turn-usage") return entry.messageIDs[0] +} + +export function visitEntries( + nodes: readonly SessionNode[], + path: readonly number[], + visit: (entry: SessionEntry, path: readonly number[]) => void, +) { + nodes.forEach((node, index) => { + const position = [...path, index] + if (node.type === "entry") visit(node.entry, position) + if (node.type === "group") visitEntries(node.children, position, visit) + }) +} + +export function useEntryAnchor(props: { + entry: Accessor + path: Accessor + target: Accessor + message: (messageID: string) => SessionMessageInfo | undefined +}) { + const ctx = use() + createEffect(() => { + const entry = props.entry() + const target = props.target() + const id = entry && entryMessageID(entry) + if (!id || !target || !isBoundary(props.message(id))) return + onCleanup(ctx.anchors.register({ messageID: id, target, path: props.path, level: props.path().length })) + }) +} + +export function EntryAnchor(props: { + entry: SessionEntry + path: readonly number[] + message: (messageID: string) => SessionMessageInfo | undefined + children: JSX.Element + marginTop?: number +}) { + const [target, setTarget] = createSignal() + useEntryAnchor({ entry: () => props.entry, path: () => props.path, target, message: props.message }) + return ( + + {props.children} + + ) +} + +export function GroupAnchor(props: { + nodes: readonly SessionNode[] + path: readonly number[] + message: (messageID: string) => SessionMessageInfo | undefined + reveal: () => boolean + children: JSX.Element +}) { + const ctx = use() + const [target, setTarget] = createSignal() + createEffect(() => { + const node = target() + if (!node) return + const seen = new Set() + visitEntries(props.nodes, props.path, (entry, path) => { + const id = entryMessageID(entry) + if (!id || seen.has(id) || !isBoundary(props.message(id))) return + seen.add(id) + onCleanup( + ctx.anchors.register({ + messageID: id, + target: node, + path: () => path, + level: props.path.length, + reveal: props.reveal, + }), + ) + }) + }) + return ( + + {props.children} + + ) +} + +function isBoundary(message: SessionMessageInfo | undefined) { + return message?.type === "assistant" || (message?.type === "user" && !!message.text.trim()) +} diff --git a/packages/tui/src/routes/session/group-view.tsx b/packages/tui/src/routes/session/group-view.tsx new file mode 100644 index 000000000000..c205bc0fe70b --- /dev/null +++ b/packages/tui/src/routes/session/group-view.tsx @@ -0,0 +1,329 @@ +import { createMemo, createSignal, For, Match, Show, Switch } from "solid-js" +import { RGBA } from "@opentui/core" +import { useRenderer, type JSX } from "@opentui/solid" +import type { SessionMessageAssistantTool, SessionMessageInfo } from "@opencode/client" +import { createSyntaxStyleMemo, useTheme, useThemes } from "../../context/theme" +import { reasoningSummary } from "../../context/thinking" +import { SplitBorder } from "../../ui/border" +import { Locale } from "../../util/locale" +import { EntryAnchor, GroupAnchor, visitEntries } from "./anchor-view" +import type { PartRef, SessionEntry, SessionGroup, SessionNode } from "./grouping/session" +import { InlineToolRow, reasoningContent, toolDisplay } from "./message-parts" +import { use } from "./render-context" +import { resolvePart } from "./rows" +import { generateThinkingSyntax } from "./thinking-syntax" + +type Renderers = { + message: (messageID: string) => SessionMessageInfo | undefined + entry: (entry: SessionEntry, images?: boolean) => JSX.Element + images: (parts: readonly SessionMessageAssistantTool[]) => JSX.Element +} + +type GroupProps = Renderers & { + node: Extract + path: readonly number[] + completed: boolean + pending: readonly PartRef[] + pendingOutside?: boolean + imagesOutside?: boolean +} + +export function SessionGroupView(props: Renderers & { row: SessionGroup; path: readonly number[] }) { + return ( + + ) +} + +function Group(props: GroupProps) { + // Reconciliation can change a group's kind in place. Each kind keeps its own + // disclosure lifetime, matching the former separate group components. + return ( + + {(_kind) => } + + ) +} + +function GroupContent(props: GroupProps) { + const ctx = use() + const theme = useTheme() + const renderer = useRenderer() + const [expanded, setExpanded] = createSignal(false) + const [hover, setHover] = createSignal(false) + const refs = createMemo(() => { + const result: PartRef[] = [] + visitEntries(props.node.children, props.path, (entry) => { + if (entry.type === "part" && !isPending(entry, props.pending)) result.push(entry.ref) + }) + return result + }) + const thoughts = createMemo(() => + props.node.kind !== "reasoning" + ? [] + : refs().flatMap((ref) => { + const message = props.message(ref.messageID) + if (message?.type !== "assistant") return [] + const part = resolvePart(message, ref.partID) + if (part?.type !== "reasoning" || !reasoningContent(part)) return [] + return [{ message, part }] + }), + ) + const tools = createMemo(() => + props.node.kind !== "exploration" + ? [] + : refs().flatMap((ref) => { + const message = props.message(ref.messageID) + if (message?.type !== "assistant") return [] + const part = resolvePart(message, ref.partID) + return part?.type === "tool" ? [part] : [] + }), + ) + const latest = createMemo((previous: string | null) => { + const item = thoughts().at(-1) + if (!item) return previous + const title = reasoningSummary(reasoningContent(item.part)).title + if (title) return title + if (item.part.time?.completed !== undefined || item.message.time.completed !== undefined) return null + return previous + }, null) + const duration = createMemo(() => + thoughts().reduce((total, item) => { + const start = item.part.time?.created + const end = item.part.time?.completed + return total + (start === undefined || end === undefined ? 0 : Math.max(0, end - start)) + }, 0), + ) + const grouped = () => (props.node.kind === "reasoning" ? ctx.thinkingMode() === "hide" : ctx.groupExploration()) + const completed = () => + props.node.kind === "reasoning" + ? props.completed + : props.completed || (tools().length > 0 && tools().every((part) => part.time.completed !== undefined)) + const label = createMemo(() => { + const counts = tools().reduce>((result, part) => { + const tool = toolDisplay(part.name) + const name = tool === "grep" || tool === "glob" ? "search" : tool + result[name] = (result[name] ?? 0) + 1 + return result + }, {}) + const names = Object.entries(counts).map( + ([name, count]) => `${count} ${count === 1 ? name : name === "search" ? "searches" : `${name}s`}`, + ) + return `${completed() ? "Explored" : "Exploring"} — ${names.join(", ")}` + }) + const toggle = () => { + if (renderer.getSelection()?.getSelectedText()) return + setExpanded((value) => !value) + } + const children = (mode: "normal" | "thought" | "tool") => ( + + ) + + return ( + { + if (!grouped() || expanded()) return false + setExpanded(true) + return true + }} + > + + 0}> + setHover(true)} + onMouseOut={() => setHover(false)} + onMouseUp={toggle} + > + {label()} + + + 0}>{children("tool")} + {props.images(tools())} + + } + > + 0}> + + setHover(true)} + onMouseOut={() => setHover(false)} + onMouseUp={toggle} + > + {props.completed ? "Thought" : latest() ? `Thinking: ${latest()}` : "Thinking"} + : {latest()} + 1}> · {thoughts().length} steps + · {Locale.duration(duration())} + + + {children("thought")} + + + + + + + {(ref) => { + const leaf = createMemo(() => { + let found: { entry: SessionEntry; path: readonly number[] } | undefined + visitEntries(props.node.children, props.path, (entry, path) => { + if (entry.type === "part" && entry.ref.messageID === ref.messageID && entry.ref.partID === ref.partID) + found ??= { entry, path } + }) + return found + }) + return ( + + {(item) => ( + + {props.entry(item().entry)} + + )} + + ) + }} + + + + ) +} + +function Children(props: GroupProps & { nodes: readonly SessionNode[]; mode: "normal" | "thought" | "tool" }) { + return ( + + {(node, index) => { + const path = () => [...props.path, index()] + return ( + + + {(node) => ( + next.type === "group" || !isPending(next.entry, props.pending)) || + (node().kind === "reasoning" && reasoningCompleted(node().children, props.message)) + } + /> + )} + + + {(node) => ( + + + {props.entry(node().entry, props.mode === "tool" ? false : undefined)} + + } + > + + + + )} + + + ) + }} + + ) +} + +function ThoughtEntry(props: { entry: SessionEntry; path: readonly number[]; message: Renderers["message"] }) { + const ctx = use() + const theme = useTheme() + const { currentSyntax: syntax } = useThemes() + const thinkingSyntax = createSyntaxStyleMemo(() => generateThinkingSyntax(syntax(), theme.text.subdued)) + const message = createMemo(() => { + if (props.entry.type !== "part") return + const item = props.message(props.entry.ref.messageID) + return item?.type === "assistant" ? item : undefined + }) + const part = createMemo(() => { + const item = message() + if (!item || props.entry.type !== "part") return + const part = resolvePart(item, props.entry.ref.partID) + return part?.type === "reasoning" ? part : undefined + }) + const content = createMemo(() => { + const item = part() + return item ? reasoningContent(item) : "" + }) + return ( + + + + + + + + ) +} + +function isPending(entry: SessionEntry, pending: readonly PartRef[]) { + return ( + entry.type === "part" && + pending.some((ref) => ref.messageID === entry.ref.messageID && ref.partID === entry.ref.partID) + ) +} + +function reasoningCompleted(nodes: readonly SessionNode[], message: Renderers["message"]): boolean { + return nodes.every((node) => { + if (node.type === "group") return reasoningCompleted(node.children, message) + if (node.entry.type !== "part") return false + const item = message(node.entry.ref.messageID) + if (item?.type !== "assistant") return false + const part = resolvePart(item, node.entry.ref.partID) + return part?.type === "reasoning" && part.time?.completed !== undefined + }) +} diff --git a/packages/tui/src/routes/session/grouping/session.ts b/packages/tui/src/routes/session/grouping/session.ts index e81901a7c2c5..2d80118e2676 100644 --- a/packages/tui/src/routes/session/grouping/session.ts +++ b/packages/tui/src/routes/session/grouping/session.ts @@ -18,8 +18,9 @@ export type SessionEntry = | { type: "assistant-footer"; messageID: string } | { type: "turn-usage"; messageIDs: string[]; previousCache?: CacheUsage } -type GroupKind = "reasoning" | "exploration" -type SessionGroup = { +export type GroupKind = "reasoning" | "exploration" +export type SessionNode = GroupNode +export type SessionGroup = { type: "group" children: readonly GroupNode[] size: number diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 4e95c0235a02..11926b5cdf49 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -23,7 +23,7 @@ import { SplitBorder } from "../../ui/border" import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime" import { Spinner, SPINNER_FRAMES } from "../../component/spinner" import { PatchDiff } from "../../component/patch-diff" -import { createSyntaxStyleMemo, ThemeContextProvider, useTheme, useThemes } from "../../context/theme" +import { ThemeContextProvider, useTheme, useThemes } from "../../context/theme" import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA, MouseEvent } from "@opentui/core" import { Prompt, type PromptRef } from "../../component/prompt" import type { @@ -75,7 +75,7 @@ import { DialogExportResult } from "../../ui/dialog-export-result" import { sessionEpilogue } from "../../util/presentation" import { useConfig } from "../../config" import { useClipboard } from "../../context/clipboard" -import { nextThinkingMode, reasoningSummary, type ThinkingMode } from "../../context/thinking" +import { nextThinkingMode, type ThinkingMode } from "../../context/thinking" import { getScrollAcceleration } from "../../util/scroll" import { collapseToolOutput } from "../../util/collapse-tool-output" import { Keymap, type KeymapCommand } from "../../context/keymap" @@ -103,15 +103,18 @@ import { withTimestampedFallback } from "@opencode/util/session-title-fallback" import { useSessionTabs } from "../../context/session-tabs" import { createSingleFlight } from "../../util/single-flight" import type { SessionInbox } from "@opencode/schema/session-inbox" -import { generateThinkingSyntax } from "./thinking-syntax" import { createDelayedPresence } from "../../util/delayed-presence" import { SessionLocationMissing } from "./location-missing" import { isRecord } from "../../util/record" import { createHistoryPrepend } from "./history" import { context, use, type PendingAction } from "./render-context" -import { INLINE_TOOL_ICON_WIDTH, InlineToolRow, ReasoningPart, reasoningContent, TextPart } from "./message-parts" -import { groupRefs } from "./grouping/session" +import { INLINE_TOOL_ICON_WIDTH, InlineToolRow, ReasoningPart, TextPart, toolDisplay } from "./message-parts" +import type { SessionEntry } from "./grouping/session" +import { SessionGroupView } from "./group-view" +import { entryMessageID, useEntryAnchor, visitEntries } from "./anchor-view" +import { createMessageAnchors } from "./message-anchors" export { InlineToolRow } from "./message-parts" +export { toolDisplay } from "./message-parts" addDefaultParsers(parsers.parsers) @@ -260,7 +263,7 @@ export function Session(props: { }, ) const boundaries = createMemo(() => messageBoundaryIDs(rows, messages())) - const boundaryIDs = createMemo(() => new Set(boundaries().filter((id) => id !== undefined))) + const anchors = createMessageAnchors() const [navigationMessage, setNavigationMessage] = createSignal() const [navigationSlack, setNavigationSlack] = createSignal(0) const [firstJump, setFirstJump] = createSignal<() => void>() @@ -269,11 +272,13 @@ export function Session(props: { const [awayFromBottom, setAwayFromBottom] = createSignal(false) const [latestHovered, setLatestHovered] = createSignal(false) let ensureAllRowsPending: (() => void)[] | undefined + let anchorRequest = 0 createEffect(() => { if (!awayFromBottom()) setLatestHovered(false) }) const clearMessageNavigation = () => { + anchorRequest++ ensureAllRowsPending?.splice(0) prependHistory.cancel() firstJump()?.() @@ -353,7 +358,7 @@ export function Session(props: { firstJump()?.() if (!scroll || scroll.isDestroyed) return scroll.verticalScrollBar.off("change", updateAwayFromBottom) - saveScrollAnchor() + saveScrollAnchor(true) }) const [prompt, setPrompt] = createSignal() const bind = (r: PromptRef | undefined) => { @@ -450,6 +455,22 @@ export function Session(props: { }) } + function revealMessage(messageID: string, continuation: (target: { y: number }) => void) { + const request = ++anchorRequest + const reveal = () => { + if (request !== anchorRequest || !scroll || scroll.isDestroyed) return + const anchor = anchors.get(messageID) + if (!anchor) return + if (anchor.reveal?.()) { + scroll.stickyScroll = false + afterLayout(reveal) + return + } + continuation(anchor.target) + } + reveal() + } + function isAwayFromBottom() { if (revealingOlderRows || revealingNewerRows || ensureAllRowsPending || navigationMessage() || firstJump()) return true @@ -472,20 +493,28 @@ export function Session(props: { saveScrollAnchor() }) } - function saveScrollAnchor() { + function saveScrollAnchor(unmounting = false) { // Initial layout must not overwrite the saved position before synchronization restores it. if (!restored) return + const mounted = anchors.list() + // Solid disposes child registrations before the route's cleanup. Keep the + // last scroll-event anchor once those children have gone away. + if (unmounting && !mounted.length) return if (!isAwayFromBottom()) { sessionTabs.setScrollAnchor(sessionID, undefined) return } - let first: { messageID: string; screenY: number } | undefined - let anchor: { messageID: string; screenY: number } | undefined - for (const child of scroll.getChildren()) { - if (!child.id || !boundaryIDs().has(child.id)) continue - const item = { messageID: child.id, screenY: child.y - scroll.viewport.y } + let first: { messageID: string; screenY: number; reveal: boolean } | undefined + let anchor: { messageID: string; screenY: number; reveal: boolean } | undefined + for (const child of mounted) { + const item = { + messageID: child.messageID, + screenY: child.target.y - scroll.viewport.y, + reveal: child.reveal === undefined, + } first ??= item - if (item.screenY <= 0) anchor = item + const inset = data.session.message.get(sessionID, item.messageID)?.type === "assistant" ? 1 : 0 + if (item.screenY <= inset && (!anchor || item.screenY > anchor.screenY)) anchor = item } anchor ??= first if (anchor) sessionTabs.setScrollAnchor(sessionID, anchor) @@ -493,7 +522,16 @@ export function Session(props: { } function restoreScrollPosition() { const anchor = sessionTabs.scrollAnchor(sessionID) - const index = anchor ? boundaries().indexOf(anchor.messageID) : -1 + const index = anchor + ? rows.findIndex((row) => { + if (row.type !== "group") return entryMessageID(row) === anchor.messageID + let found = false + visitEntries(row.children, [], (entry) => { + if (entryMessageID(entry) === anchor.messageID) found = true + }) + return found + }) + : -1 if (!anchor || index === -1) { scroll.scrollTo(scroll.scrollHeight) setAwayFromBottom(false) @@ -505,7 +543,7 @@ export function Session(props: { scroll.stickyScroll = false const restore = () => afterLayout(() => { - const boundary = scroll.getRenderable(anchor.messageID) + const boundary = anchors.get(anchor.messageID) if (!boundary) { sessionTabs.setScrollAnchor(sessionID, undefined) scroll.stickyScroll = true @@ -513,17 +551,24 @@ export function Session(props: { setAwayFromBottom(false) return } - const contentY = scroll.scrollTop + boundary.y - scroll.viewport.y - const target = contentY - anchor.screenY - const maximum = Math.max(0, scroll.scrollHeight - scroll.viewport.height) - if (target > maximum && visibleEnd() < rows.length) { - const next = Math.min(rows.length, visibleEnd() + TRANSCRIPT_BACKFILL_CHUNK) - setVisibleRowsEnd(next === rows.length ? undefined : next) - restore() + const position = (boundary: { y: number }) => { + const contentY = scroll.scrollTop + boundary.y - scroll.viewport.y + const target = contentY - anchor.screenY + const maximum = Math.max(0, scroll.scrollHeight - scroll.viewport.height) + if (target > maximum && visibleEnd() < rows.length) { + const next = Math.min(rows.length, visibleEnd() + TRANSCRIPT_BACKFILL_CHUNK) + setVisibleRowsEnd(next === rows.length ? undefined : next) + restore() + return + } + scroll.scrollTo(target) + updateAwayFromBottom() + } + if (anchor.reveal) { + revealMessage(anchor.messageID, position) return } - scroll.scrollTo(target) - updateAwayFromBottom() + position(boundary.target) }) restore() } @@ -614,7 +659,7 @@ export function Session(props: { ensureAllRows(() => { const target = findMessageBoundary({ direction, - children: scroll.getChildren(), + children: anchors.list().map((anchor) => ({ id: anchor.messageID, y: anchor.target.y })), messages: messages(), scrollTop: scroll.scrollTop, viewportY: scroll.viewport.y, @@ -623,7 +668,7 @@ export function Session(props: { }) if (target) { - alignMessage(target.id, target.top) + jumpToMessage(target.id) dialog.clear() return } @@ -636,11 +681,11 @@ export function Session(props: { const jumpToMessage = (messageID: string) => ensureAllRows(() => { - const child = scroll.getRenderable(messageID) - if (!child) return - const y = scroll.scrollTop + child.y - scroll.viewport.y - const message = data.session.message.get(route.sessionID, messageID) - alignMessage(messageID, Math.max(0, y - (message?.type === "assistant" ? 1 : 0))) + revealMessage(messageID, (child) => { + const y = scroll.scrollTop + child.y - scroll.viewport.y + const message = data.session.message.get(route.sessionID, messageID) + alignMessage(messageID, Math.max(0, y - (message?.type === "assistant" ? 1 : 0))) + }) }) function toBottom() { @@ -1242,6 +1287,7 @@ export function Session(props: { return ( ( data.session.message.get(route.sessionID, messageID)} boundaryID={boundaries()[index() + hidden()]} /> @@ -1423,61 +1470,75 @@ export function Session(props: { type SessionRowViewProps = { row: SessionRow + index: number message: (messageID: string) => SessionMessageInfo | undefined boundaryID?: string } function SessionRowView(props: SessionRowViewProps) { + const [target, setTarget] = createSignal() + useEntryAnchor({ + entry: () => (props.row.type === "group" ? undefined : props.row), + path: () => [props.index], + target, + message: props.message, + }) return ( - + - - {(row) => ( - {(message) => } - )} - - - - - - {(row) => } - - - {(row) => ( - - )} - - + {(row) => ( } + images={(parts) => } /> )} - - {(row) => ( - - {(message) => ( - - - - )} - - )} - - - {(row) => ( - - )} + + {(row) => } ) } +function SessionEntryView(props: { row: SessionEntry; message: SessionRowViewProps["message"]; images?: boolean }) { + return ( + + + {(row) => ( + {(message) => } + )} + + + + + + {(row) => } + + + {(row) => ( + + {(message) => ( + + + + )} + + )} + + + {(row) => ( + + )} + + + ) +} + function TurnTokenUsage(props: { messageIDs: string[] previousCache?: CacheUsage @@ -1702,7 +1763,11 @@ function SessionMessageView(props: { message: SessionMessageInfo }) { ) } -function SessionPartView(props: { partRef: PartRef; message: (messageID: string) => SessionMessageInfo | undefined }) { +function SessionPartView(props: { + partRef: PartRef + message: (messageID: string) => SessionMessageInfo | undefined + images?: boolean +}) { const message = createMemo(() => props.message(props.partRef.messageID)) const part = createMemo(() => { const item = message() @@ -1728,7 +1793,7 @@ function SessionPartView(props: { partRef: PartRef; message: (messageID: string) /> - + )} @@ -1736,198 +1801,6 @@ function SessionPartView(props: { partRef: PartRef; message: (messageID: string) ) } -function SessionReasoningGroupView(props: { - refs: PartRef[] - completed: boolean - message: (messageID: string) => SessionMessageInfo | undefined -}) { - const ctx = use() - const theme = useTheme() - const { currentSyntax: syntax } = useThemes() - const thinkingSyntax = createSyntaxStyleMemo(() => generateThinkingSyntax(syntax(), theme.text.subdued)) - const renderer = useRenderer() - const [expanded, setExpanded] = createSignal(false) - const [hover, setHover] = createSignal(false) - const parts = createMemo(() => - props.refs.flatMap((ref) => { - const message = props.message(ref.messageID) - if (message?.type !== "assistant") return [] - const part = resolvePart(message, ref.partID) - if (part?.type !== "reasoning" || !reasoningContent(part)) return [] - return [{ message, part }] - }), - ) - const latest = createMemo((previous: string | null) => { - const item = parts().at(-1) - if (!item) return previous - const title = reasoningSummary(reasoningContent(item.part)).title - if (title) return title - if (item.part.time?.completed !== undefined || item.message.time.completed !== undefined) return null - return previous - }, null) - const duration = createMemo(() => - parts().reduce((total, item) => { - const start = item.part.time?.created - const end = item.part.time?.completed - return total + (start === undefined || end === undefined ? 0 : Math.max(0, end - start)) - }, 0), - ) - - return ( - 0}> - {(ref) => }} - > - - setHover(true)} - onMouseOut={() => setHover(false)} - onMouseUp={() => { - if (renderer.getSelection()?.getSelectedText()) return - setExpanded((value) => !value) - }} - > - {props.completed ? "Thought" : latest() ? `Thinking: ${latest()}` : "Thinking"} - : {latest()} - 1}> · {parts().length} steps - · {Locale.duration(duration())} - - - - - {(ref) => { - const message = createMemo(() => { - const item = props.message(ref.messageID) - return item?.type === "assistant" ? item : undefined - }) - const part = createMemo(() => { - const item = message() - if (!item) return undefined - const part = resolvePart(item, ref.partID) - return part?.type === "reasoning" ? part : undefined - }) - const content = createMemo(() => { - const item = part() - return item ? reasoningContent(item) : "" - }) - return ( - - - - - - - - ) - }} - - - - - - - ) -} - -function SessionGroupView(props: { - refs: PartRef[] - pending: PartRef[] - completed: boolean - message: (messageID: string) => SessionMessageInfo | undefined -}) { - const theme = useTheme() - const ctx = use() - const renderer = useRenderer() - const [expanded, setExpanded] = createSignal(false) - const [hover, setHover] = createSignal(false) - const parts = (refs: PartRef[]) => - refs.flatMap((ref) => { - const message = props.message(ref.messageID) - if (message?.type !== "assistant") return [] - const part = resolvePart(message, ref.partID) - if (part?.type !== "tool") return [] - return [part] - }) - const grouped = createMemo(() => parts(props.refs)) - const pending = createMemo(() => parts(props.pending)) - const completed = createMemo( - () => props.completed || (grouped().length > 0 && grouped().every((part) => part.time.completed !== undefined)), - ) - const label = createMemo(() => { - const counts = grouped().reduce>((result, part) => { - const tool = toolDisplay(part.name) - const name = tool === "grep" || tool === "glob" ? "search" : tool - result[name] = (result[name] ?? 0) + 1 - return result - }, {}) - const tools = Object.entries(counts).map( - ([name, count]) => `${count} ${count === 1 ? name : name === "search" ? "searches" : `${name}s`}`, - ) - return `${completed() ? "Explored" : "Exploring"} — ${tools.join(", ")}` - }) - return ( - 0 || pending().length > 0}> - {(part) => }} - > - 0}> - setHover(true)} - onMouseOut={() => setHover(false)} - onMouseUp={() => { - if (renderer.getSelection()?.getSelectedText()) return - setExpanded((value) => !value) - }} - > - {label()} - - - 0}> - {(part) => } - - - {(part) => } - - - ) -} - function AssistantFooter(props: { message: SessionMessageAssistant }) { const ctx = use() const config = useConfig() @@ -3552,32 +3425,16 @@ function stringValue(value: unknown) { return typeof value === "string" ? value : undefined } -const toolDisplays = new Set([ - "shell", - "glob", - "read", - "grep", - "webfetch", - "websearch", - "write", - "edit", - "subagent", - "execute", - "patch", - "question", - "skill", -]) - -export function toolDisplay(tool: string) { - const normalized = canonicalToolName(tool) - return toolDisplays.has(normalized) ? normalized : "generic" -} - function recordValue(value: unknown): Record | undefined { return isRecord(value) ? value : undefined } -function formatSessionTranscript(session: SessionInfo, messages: SessionMessageInfo[], thinking: boolean, tools = true) { +function formatSessionTranscript( + session: SessionInfo, + messages: SessionMessageInfo[], + thinking: boolean, + tools = true, +) { const body = messages.flatMap((message) => { if (message.type === "user") return [`## User\n\n${message.text}`] if (message.type === "shell") diff --git a/packages/tui/src/routes/session/message-anchors.ts b/packages/tui/src/routes/session/message-anchors.ts new file mode 100644 index 000000000000..fe6e9f7dec58 --- /dev/null +++ b/packages/tui/src/routes/session/message-anchors.ts @@ -0,0 +1,51 @@ +import type { Renderable } from "@opentui/core" + +type Anchor = { + messageID: string + target: Pick + /** Logical leaf position, even when this target is a collapsed ancestor. */ + path: () => readonly number[] + level: number + /** Returns true when revealing the target requires another layout pass. */ + reveal?: () => boolean +} + +/** Mounted renderables own geometry; the registry only locates message targets. */ +export function createMessageAnchors() { + const entries = new Map>() + const get = (messageID: string) => { + const candidates = entries.get(messageID) + if (!candidates) return + return [...candidates] + .filter((anchor) => !anchor.target.isDestroyed) + .sort((a, b) => comparePaths(a.path(), b.path()) || b.level - a.level)[0] + } + return { + register(anchor: Anchor) { + const candidates = entries.get(anchor.messageID) ?? new Set() + candidates.add(anchor) + entries.set(anchor.messageID, candidates) + return () => { + candidates.delete(anchor) + if (!candidates.size && entries.get(anchor.messageID) === candidates) entries.delete(anchor.messageID) + } + }, + get, + list() { + return [...entries.keys()] + .flatMap((id) => { + const anchor = get(id) + return anchor ? [anchor] : [] + }) + .sort((a, b) => comparePaths(a.path(), b.path())) + }, + } +} + +function comparePaths(a: readonly number[], b: readonly number[]) { + for (let index = 0; index < Math.min(a.length, b.length); index++) { + const difference = a[index] - b[index] + if (difference) return difference + } + return a.length - b.length +} diff --git a/packages/tui/src/routes/session/message-parts.tsx b/packages/tui/src/routes/session/message-parts.tsx index 97ddd09ea3b3..4bafbb8d21c5 100644 --- a/packages/tui/src/routes/session/message-parts.tsx +++ b/packages/tui/src/routes/session/message-parts.tsx @@ -14,9 +14,31 @@ import { SplitBorder } from "../../ui/border" import { Locale } from "../../util/locale" import { use } from "./render-context" import { generateThinkingSyntax } from "./thinking-syntax" +import { canonicalToolName } from "../../util/tool-display" export const INLINE_TOOL_ICON_WIDTH = 2 +const toolDisplays = new Set([ + "shell", + "glob", + "read", + "grep", + "webfetch", + "websearch", + "write", + "edit", + "subagent", + "execute", + "patch", + "question", + "skill", +]) + +export function toolDisplay(tool: string) { + const normalized = canonicalToolName(tool) + return toolDisplays.has(normalized) ? normalized : "generic" +} + export function ReasoningPart(props: { last: boolean part: SessionMessageAssistantReasoning diff --git a/packages/tui/src/routes/session/render-context.tsx b/packages/tui/src/routes/session/render-context.tsx index 652f287c06f5..bb36acaa4ce3 100644 --- a/packages/tui/src/routes/session/render-context.tsx +++ b/packages/tui/src/routes/session/render-context.tsx @@ -3,6 +3,7 @@ import type { ModelInfo } from "@opencode/client" import type { SessionInbox } from "@opencode/schema/session-inbox" import type { useConfig } from "../../config" import type { ThinkingMode } from "../../context/thinking" +import type { createMessageAnchors } from "./message-anchors" export type PendingAction = "steer" | "queue" | "cancel" @@ -16,6 +17,7 @@ export const context = createContext<{ */ terminal: { width: number; height: number } sessionID: string + anchors: ReturnType thinkingMode: () => ThinkingMode markdownMode: () => "source" | "rendered" groupExploration: () => boolean diff --git a/packages/tui/test/cli/tui/group-view.test.tsx b/packages/tui/test/cli/tui/group-view.test.tsx new file mode 100644 index 000000000000..4c8ee7ff61e9 --- /dev/null +++ b/packages/tui/test/cli/tui/group-view.test.tsx @@ -0,0 +1,167 @@ +import { expect, test } from "bun:test" +import { testRender } from "@opentui/solid" +import { createStore, reconcile } from "solid-js/store" +import { addDefaultParsers, type TextRenderable } from "@opentui/core" +import parsers from "../../../src/parsers-config" +import type { SessionMessageAssistant } from "@opencode/client" +import { ConfigProvider } from "../../../src/config" +import { ThemeProvider } from "../../../src/context/theme" +import { SessionGroupView } from "../../../src/routes/session/group-view" +import { createMessageAnchors } from "../../../src/routes/session/message-anchors" +import { context } from "../../../src/routes/session/render-context" +import type { SessionGroup } from "../../../src/routes/session/grouping/session" +import { emptyThemeSource } from "../../fixture/fixture" +import { TestTuiContexts } from "../../fixture/tui-environment" +import { createTuiResolvedConfig } from "../../fixture/tui-runtime" + +test("reveals two collapsed ancestors and registers the leaf's measured position", async () => { + addDefaultParsers(parsers.parsers) + const anchors = createMessageAnchors() + const config = createTuiResolvedConfig({ animations: false }) + const messages = new Map( + ["a", "b"].map((id) => [ + id, + { + id, + type: "assistant", + agent: "build", + model: { providerID: "fixture", id: "fixture" }, + time: { created: 0, completed: 2 }, + content: [ + { + type: "tool", + id: `read-${id}`, + name: "read", + time: { created: 0, completed: 2 }, + state: { status: "completed", input: { path: id }, content: [{ type: "text", text: id }], metadata: {} }, + }, + { type: "reasoning", text: "**Reset title**\n\nReset thought body", time: { created: 0, completed: 2 } }, + ], + }, + ]), + ) + const [row, setRow] = createStore({ + type: "group", + kind: "exploration", + size: 2, + completed: true, + pending: [], + children: [ + { + type: "group", + kind: "exploration", + size: 2, + children: [ + { type: "entry", size: 1, entry: { type: "part", ref: { messageID: "a", partID: "read-a" } } }, + { type: "entry", size: 1, entry: { type: "part", ref: { messageID: "b", partID: "read-b" } } }, + ], + }, + ], + }) + let target: TextRenderable | undefined + const app = await testRender( + () => ( + + + + "hide", + markdownMode: () => "rendered", + groupExploration: () => true, + diffWrapMode: () => "word", + models: () => [], + messageIndex: () => undefined, + config, + mutatePending: async () => true, + pendingDelivery: () => undefined, + }} + > + + messages.get(id)} + images={() => Image previews} + entry={(entry) => + entry.type === "part" && entry.ref.messageID === "b" ? ( + (target = node)}>Target B + ) : ( + A wrapped entry with enough text to occupy more than one terminal line + ) + } + /> + + + + + + ), + { width: 40, height: 24 }, + ) + app.renderer.start() + try { + await app.waitForFrame((frame) => frame.includes("Explored")) + expect(app.captureCharFrame()).not.toContain("Target B") + expect(anchors.get("b")?.path()).toEqual([0, 0, 1]) + const headerY = anchors.get("b")?.target.y ?? -1 + expect(anchors.get("b")?.reveal?.()).toBe(true) + await app.renderOnce() + expect(app.captureCharFrame()).not.toContain("Target B") + expect(anchors.get("b")?.level).toBe(2) + expect(anchors.get("b")?.reveal?.()).toBe(true) + await app.renderOnce() + expect(app.captureCharFrame()).toContain("Target B") + expect(anchors.get("b")?.reveal).toBeUndefined() + expect(anchors.get("b")?.target.y).toBe(target?.y) + expect(anchors.get("b")?.target.y).toBeGreaterThan(anchors.get("a")?.target.y ?? Infinity) + expect(app.captureCharFrame().match(/Image previews/g)?.length).toBe(1) + await app.mockMouse.click(4, headerY) + await app.renderOnce() + expect(app.captureCharFrame()).not.toContain("Target B") + expect(anchors.get("b")?.level).toBe(1) + anchors.get("b")?.reveal?.() + await app.renderOnce() + setRow( + reconcile({ + type: "group", + kind: "exploration", + size: 2, + completed: true, + pending: [], + children: [ + { type: "entry", size: 1, entry: { type: "part", ref: { messageID: "a", partID: "read-a" } } }, + { type: "entry", size: 1, entry: { type: "part", ref: { messageID: "b", partID: "read-b" } } }, + ], + }), + ) + await app.renderOnce() + expect(app.captureCharFrame()).toContain("Target B") + expect(anchors.get("b")?.path()).toEqual([0, 1]) + setRow( + reconcile({ + type: "group", + kind: "reasoning", + size: 2, + completed: true, + children: [ + { type: "entry", size: 1, entry: { type: "part", ref: { messageID: "a", partID: "reasoning:0" } } }, + { type: "entry", size: 1, entry: { type: "part", ref: { messageID: "b", partID: "reasoning:0" } } }, + ], + }), + ) + await app.renderOnce() + expect(app.captureCharFrame()).toContain("Thought") + expect(app.captureCharFrame()).not.toContain("Reset thought body") + expect(anchors.get("b")?.reveal?.()).toBe(true) + await app.waitForFrame((frame) => frame.includes("Reset thought body")) + expect(app.captureCharFrame()).toContain("Reset thought body") + } finally { + app.renderer.destroy() + } + expect(anchors.list()).toEqual([]) +}) diff --git a/packages/tui/test/cli/tui/message-anchors.test.ts b/packages/tui/test/cli/tui/message-anchors.test.ts new file mode 100644 index 000000000000..b28203515458 --- /dev/null +++ b/packages/tui/test/cli/tui/message-anchors.test.ts @@ -0,0 +1,50 @@ +import { expect, test } from "bun:test" +import { createMessageAnchors } from "../../../src/routes/session/message-anchors" + +test("chooses the first logical entry rather than mount order", () => { + const anchors = createMessageAnchors() + const later = { y: 30, isDestroyed: false } + const first = { y: 10, isDestroyed: false } + anchors.register({ messageID: "a", target: later, path: () => [2], level: 1 }) + anchors.register({ messageID: "a", target: first, path: () => [0, 1], level: 2 }) + expect(anchors.get("a")?.target).toBe(first) +}) + +test("prefers a revealed leaf over its group fallback and restores the fallback on unmount", () => { + const anchors = createMessageAnchors() + const group = { y: 4, isDestroyed: false } + const leaf = { y: 12, isDestroyed: false } + anchors.register({ messageID: "a", target: group, path: () => [0, 0, 1], level: 1, reveal: () => true }) + const remove = anchors.register({ messageID: "a", target: leaf, path: () => [0, 0, 1], level: 3 }) + expect(anchors.get("a")?.target).toBe(leaf) + remove() + expect(anchors.get("a")?.target).toBe(group) + expect(anchors.get("a")?.reveal?.()).toBe(true) +}) + +test("reads current order and renderer positions after prepend, reflow and scrolling", () => { + const anchors = createMessageAnchors() + let index = 2 + const target = { y: 20, isDestroyed: false } + anchors.register({ messageID: "a", target, path: () => [index], level: 1 }) + anchors.register({ messageID: "b", target: { y: 10, isDestroyed: false }, path: () => [1], level: 1 }) + expect(anchors.list().map((anchor) => anchor.messageID)).toEqual(["b", "a"]) + index = 0 + target.y = -5 + expect(anchors.list().map((anchor) => anchor.messageID)).toEqual(["a", "b"]) + expect(anchors.get("a")?.target.y).toBe(-5) +}) + +test("cleanup removes only its own registration and destroyed renderables are ignored", () => { + const anchors = createMessageAnchors() + const old = { y: 0, isDestroyed: false } + const replacement = { y: 1, isDestroyed: false } + const remove = anchors.register({ messageID: "a", target: old, path: () => [0], level: 1 }) + anchors.register({ messageID: "a", target: replacement, path: () => [0], level: 1 }) + remove() + remove() + expect(anchors.get("a")?.target).toBe(replacement) + replacement.isDestroyed = true + expect(anchors.get("a")).toBeUndefined() + expect(anchors.list()).toEqual([]) +}) diff --git a/packages/tui/test/context/session-tabs.test.tsx b/packages/tui/test/context/session-tabs.test.tsx index 97a20cd6807c..5c1d096689bf 100644 --- a/packages/tui/test/context/session-tabs.test.tsx +++ b/packages/tui/test/context/session-tabs.test.tsx @@ -596,6 +596,11 @@ test("keeps scroll anchors for open session tabs", async () => { expect(setup.tabs.scrollAnchor("first")).toEqual({ messageID: "msg_1", screenY: -3 }) + setup.tabs.setScrollAnchor("first", { messageID: "msg_1", screenY: -3, reveal: true }) + expect(setup.tabs.scrollAnchor("first")?.reveal).toBe(true) + setup.tabs.setScrollAnchor("first", { messageID: "msg_1", screenY: -3, reveal: false }) + expect(setup.tabs.scrollAnchor("first")?.reveal).toBe(false) + setup.tabs.close("first") await wait(() => setup.tabs.tabs().every((tab) => tab.sessionID !== "first")) expect(setup.tabs.scrollAnchor("first")).toBeUndefined() diff --git a/packages/tui/test/session-group-navigation.test.tsx b/packages/tui/test/session-group-navigation.test.tsx new file mode 100644 index 000000000000..af6021e7a32e --- /dev/null +++ b/packages/tui/test/session-group-navigation.test.tsx @@ -0,0 +1,154 @@ +import { expect, test } from "bun:test" +import { createTestRenderer } from "@opentui/core/testing" +import { ScrollBoxRenderable, type Renderable } from "@opentui/core" +import { Effect, FileSystem } from "effect" +import { Global } from "@opencode/util/global" +import type { SessionMessageInfo } from "@opencode/client" +import { createEventStream, createFetch, directory, json } from "./fixture/tui-client" +import { tmpdir } from "./fixture/fixture" +import { mkdir } from "node:fs/promises" + +test.each([48, 80, 120])("navigates to grouped thoughts and restores tab positions at width %s", async (width) => { + await using state = await tmpdir() + const setup = await createTestRenderer({ width, height: 30, useThread: false, kittyKeyboard: true }) + setup.renderer.start() + const session = { + id: "ses_group_navigation", + title: "Grouped navigation", + projectID: "proj_test", + location: { directory }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 0, updated: 0 }, + } + const other = { ...session, id: "ses_other", title: "Other session" } + await mkdir(`${state.path}/test/tui`, { recursive: true }) + await Bun.write( + `${state.path}/test/tui/tabs.json`, + JSON.stringify({ + global: { tabs: [{ sessionID: session.id }, { sessionID: other.id }], unread: {} }, + cwd: {}, + }), + ) + const messages: SessionMessageInfo[] = [ + { type: "user", id: "msg_user", text: "User prompt", time: { created: 0 } }, + { + type: "assistant", + id: "msg_a", + agent: "build", + model: { providerID: "fixture", id: "fixture" }, + time: { created: 1, completed: 3 }, + content: [ + { type: "text", text: "First response" }, + { + type: "reasoning", + text: "**First title**\n\nFirst thought body with enough text to wrap differently at each tested terminal width, changing the target's measured offset.", + time: { created: 1, completed: 2 }, + }, + ], + }, + { + type: "assistant", + id: "msg_b", + agent: "build", + model: { providerID: "fixture", id: "fixture" }, + time: { created: 4, completed: 6 }, + finish: "stop", + content: [ + { type: "reasoning", text: "**Second title**\n\nSecond thought body", time: { created: 4, completed: 5 } }, + { type: "text", text: `Second response\n${"A later line of the response.\n".repeat(30)}Final marker` }, + ], + }, + ] + const calls = createFetch((url) => { + if (url.pathname === "/api/session") return json({ data: [session, other], cursor: {} }) + if (url.pathname === `/api/session/${other.id}`) return json({ data: other }) + if (url.pathname === `/api/session/${other.id}/message`) + return json({ + data: [{ type: "user", id: "msg_other", text: "Other session content", time: { created: 0 } }], + cursor: {}, + }) + if (url.pathname === `/api/session/${other.id}/inbox` || url.pathname === `/api/session/${other.id}/permission`) + return json({ data: [] }) + if (url.pathname === `/api/session/${session.id}`) return json({ data: session }) + if (url.pathname === `/api/session/${session.id}/message`) return json({ data: messages.toReversed(), cursor: {} }) + if (url.pathname === `/api/session/${session.id}/inbox` || url.pathname === `/api/session/${session.id}/permission`) + return json({ data: [] }) + }, createEventStream()) + const server = Bun.serve({ port: 0, idleTimeout: 0, fetch: (request) => calls.fetch(request) }) + const { run } = await import("../src/app") + const task = Effect.runPromise( + run({ + app: { name: "test", version: "test", channel: "test" }, + server: { endpoint: { url: server.url.toString() } }, + config: { + get: async () => ({ + animations: false, + tabs: { enabled: true, scope: "global" }, + keybinds: { "session.messages_last_user": "ctrl+shift+u", "session.message.next": "ctrl+shift+n" }, + }), + update: async () => ({}), + }, + packages: { prepare: async () => ({ directory: "" }) }, + args: { sessionID: session.id }, + terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }), + log: () => {}, + }).pipe(Effect.provide(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))), + ) + try { + await setup.waitForFrame((frame) => frame.includes("Final marker")) + expect(setup.captureCharFrame()).not.toContain("Second thought body") + setup.mockInput.pressKey("u", { ctrl: true, shift: true }) + await setup.waitForFrame((frame) => frame.includes("Thought:")) + await setup.waitForVisualIdle({ quietFrames: 3 }) + const find = (node: Renderable): ScrollBoxRenderable | undefined => + node instanceof ScrollBoxRenderable && node.getRenderable("msg_b") + ? node + : node.getChildren().map(find).find(Boolean) + const initial = find(setup.renderer.root) + if (!initial) throw new Error("Missing transcript scrollbox") + const summaryLine = setup + .captureCharFrame() + .split("\n") + .findIndex((line) => line.includes("Thought:")) + initial.scrollTo(initial.scrollTop + summaryLine - initial.viewport.y) + await setup.waitForVisualIdle({ quietFrames: 3 }) + setup.mockInput.pressKey("2", { ctrl: true }) + await setup.waitForFrame((frame) => frame.includes("Other session content")) + setup.mockInput.pressKey("1", { ctrl: true }) + await setup.waitForFrame((frame) => frame.includes("Thought:")) + await setup.waitForVisualIdle({ quietFrames: 3 }) + expect(setup.captureCharFrame()).not.toContain("Second thought body") + setup.mockInput.pressKey("u", { ctrl: true, shift: true }) + await setup.waitForVisualIdle({ quietFrames: 3 }) + setup.mockInput.pressKey("n", { ctrl: true, shift: true }) + await setup.waitForVisualIdle({ quietFrames: 3 }) + setup.mockInput.pressKey("n", { ctrl: true, shift: true }) + await setup.waitForFrame((frame) => frame.includes("Second thought body")) + await setup.waitForVisualIdle({ quietFrames: 3 }) + const scroll = find(setup.renderer.root) + if (!scroll) throw new Error("Missing transcript scrollbox") + const titleLine = setup + .captureCharFrame() + .split("\n") + .findIndex((line) => line.includes("Second title")) + expect(titleLine - scroll.viewport.y).toBe(1) + expect(scroll.scrollTop).toBeGreaterThan(0) + setup.mockInput.pressKey("2", { ctrl: true }) + await setup.waitForFrame((frame) => frame.includes("Other session content")) + setup.mockInput.pressKey("1", { ctrl: true }) + await setup.waitForFrame((frame) => frame.includes("Second thought body")) + await setup.waitForVisualIdle({ quietFrames: 3 }) + const restored = find(setup.renderer.root) + if (!restored) throw new Error("Missing restored transcript") + const restoredTitle = setup + .captureCharFrame() + .split("\n") + .findIndex((line) => line.includes("Second title")) + expect(restoredTitle - restored.viewport.y).toBe(1) + } finally { + setup.renderer.destroy() + await task + await server.stop() + } +}) From b087728684210a04c52dd28d2e1b815b1b1e556f Mon Sep 17 00:00:00 2001 From: James Long Date: Fri, 11 Sep 2026 18:37:10 +0000 Subject: [PATCH 2/2] refactor(tui): persist exact anchors and group expansion --- packages/tui/src/context/session-tabs.tsx | 31 +- .../tui/src/routes/session/anchor-view.tsx | 84 +--- packages/tui/src/routes/session/anchors.ts | 85 ++++ .../tui/src/routes/session/group-view.tsx | 68 ++-- packages/tui/src/routes/session/index.tsx | 124 +++--- .../tui/src/routes/session/message-anchors.ts | 51 --- .../tui/src/routes/session/render-context.tsx | 6 +- packages/tui/test/cli/tui/anchors.test.ts | 77 ++++ packages/tui/test/cli/tui/group-view.test.tsx | 51 ++- .../tui/test/cli/tui/message-anchors.test.ts | 50 --- .../tui/test/context/session-tabs.test.tsx | 26 +- .../test/session-group-navigation.test.tsx | 379 +++++++++++------- 12 files changed, 580 insertions(+), 452 deletions(-) create mode 100644 packages/tui/src/routes/session/anchors.ts delete mode 100644 packages/tui/src/routes/session/message-anchors.ts create mode 100644 packages/tui/test/cli/tui/anchors.test.ts delete mode 100644 packages/tui/test/cli/tui/message-anchors.test.ts diff --git a/packages/tui/src/context/session-tabs.tsx b/packages/tui/src/context/session-tabs.tsx index 366458c35ba5..419778edb2d8 100644 --- a/packages/tui/src/context/session-tabs.tsx +++ b/packages/tui/src/context/session-tabs.tsx @@ -14,6 +14,7 @@ import { useStorage } from "./storage" import { useTuiPaths } from "./runtime" import { newSessionLocation } from "../config/new-session-location" import { createSessionRetention } from "./session-retention" +import { anchorKey, type AnchorTarget } from "../routes/session/anchors" import { closeSessionTab, cycleSessionTab, @@ -40,11 +41,9 @@ type PersistedState = { cwd: Record } -type ScrollAnchor = { - messageID: string +export type ScrollAnchor = { + target: AnchorTarget screenY: number - /** Restore an entry inside its ancestors, rather than the collapsed summary. */ - reveal?: boolean } const empty = (): TabsState => ({ tabs: [], unread: {} }) @@ -92,6 +91,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp // the mark. const cancelledTabs = new Set() const scrollAnchors = new Map() + const [expandedGroups, setExpandedGroups] = createStore | undefined>>({}) const onFocus = () => setFocused(true) const onBlur = () => setFocused(false) @@ -204,7 +204,11 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp if (state().tabs.some((tab) => tab.sessionID === sessionID)) return const fallback = newTab() ? NEW_SESSION_TAB_TITLE : undefined const replaced = permanent ? undefined : previewID() - if (replaced) family(replaced).forEach((id) => scrollAnchors.delete(id)) + if (replaced) + family(replaced).forEach((id) => { + scrollAnchors.delete(id) + setExpandedGroups(id, undefined) + }) if (!permanent) setPreview(sessionID) update((draft) => { if (cancelledTabs.has(sessionID)) return @@ -348,7 +352,10 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp function remove(sessionID: string, navigate: boolean) { const target = root(sessionID) cancelledTabs.add(target) - family(target).forEach((id) => scrollAnchors.delete(id)) + family(target).forEach((id) => { + scrollAnchors.delete(id) + setExpandedGroups(id, undefined) + }) if (previewID() === target) setPreview(undefined) const closed = closeSessionTab(state().tabs, target) const selected = navigate && current() === target @@ -395,14 +402,16 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp return } const current = scrollAnchors.get(sessionID) - if ( - current?.messageID === anchor.messageID && - current.screenY === anchor.screenY && - current.reveal === anchor.reveal - ) + if (current && anchorKey(current.target) === anchorKey(anchor.target) && current.screenY === anchor.screenY) return scrollAnchors.set(sessionID, anchor) }, + groupExpanded(sessionID: string, groupID: string) { + return expandedGroups[sessionID]?.[groupID] + }, + setGroupExpanded(sessionID: string, groupID: string, expanded: boolean) { + setExpandedGroups(sessionID, (current) => ({ ...current, [groupID]: expanded })) + }, select(sessionID: string) { if (!enabled()) return route.navigate({ type: "session", sessionID: root(sessionID) }) diff --git a/packages/tui/src/routes/session/anchor-view.tsx b/packages/tui/src/routes/session/anchor-view.tsx index a7f4f9f9336d..a21d76f75dac 100644 --- a/packages/tui/src/routes/session/anchor-view.tsx +++ b/packages/tui/src/routes/session/anchor-view.tsx @@ -1,95 +1,53 @@ import { createEffect, createSignal, onCleanup, type Accessor } from "solid-js" import type { BoxRenderable } from "@opentui/core" import type { JSX } from "@opentui/solid" -import type { SessionMessageInfo } from "@opencode/client" import type { SessionEntry, SessionNode } from "./grouping/session" +import { entryRef } from "./anchors" import { use } from "./render-context" -export function entryMessageID(entry: SessionEntry) { - if (entry.type === "part") return entry.ref.messageID - if (entry.type === "message" || entry.type === "assistant-footer") return entry.messageID - if (entry.type === "turn-usage") return entry.messageIDs[0] -} - -export function visitEntries( - nodes: readonly SessionNode[], - path: readonly number[], - visit: (entry: SessionEntry, path: readonly number[]) => void, -) { - nodes.forEach((node, index) => { - const position = [...path, index] - if (node.type === "entry") visit(node.entry, position) - if (node.type === "group") visitEntries(node.children, position, visit) +export function visitEntries(nodes: readonly SessionNode[], visit: (entry: SessionEntry) => void) { + nodes.forEach((node) => { + if (node.type === "entry") visit(node.entry) + if (node.type === "group") visitEntries(node.children, visit) }) } export function useEntryAnchor(props: { entry: Accessor - path: Accessor - target: Accessor - message: (messageID: string) => SessionMessageInfo | undefined + node: Accessor }) { const ctx = use() createEffect(() => { const entry = props.entry() - const target = props.target() - const id = entry && entryMessageID(entry) - if (!id || !target || !isBoundary(props.message(id))) return - onCleanup(ctx.anchors.register({ messageID: id, target, path: props.path, level: props.path().length })) + const node = props.node() + const ref = entry && entryRef(entry) + if (!ref || !node) return + onCleanup(ctx.anchors.register({ target: { type: "part", ref }, node })) }) } -export function EntryAnchor(props: { - entry: SessionEntry - path: readonly number[] - message: (messageID: string) => SessionMessageInfo | undefined - children: JSX.Element - marginTop?: number -}) { - const [target, setTarget] = createSignal() - useEntryAnchor({ entry: () => props.entry, path: () => props.path, target, message: props.message }) +export function EntryAnchor(props: { entry: SessionEntry; children: JSX.Element; marginTop?: number }) { + const [node, setNode] = createSignal() + useEntryAnchor({ entry: () => props.entry, node }) return ( - + {props.children} ) } -export function GroupAnchor(props: { - nodes: readonly SessionNode[] - path: readonly number[] - message: (messageID: string) => SessionMessageInfo | undefined - reveal: () => boolean - children: JSX.Element -}) { +export function GroupAnchor(props: { groupID: string | undefined; active: boolean; children: JSX.Element }) { const ctx = use() - const [target, setTarget] = createSignal() + const [node, setNode] = createSignal() createEffect(() => { - const node = target() - if (!node) return - const seen = new Set() - visitEntries(props.nodes, props.path, (entry, path) => { - const id = entryMessageID(entry) - if (!id || seen.has(id) || !isBoundary(props.message(id))) return - seen.add(id) - onCleanup( - ctx.anchors.register({ - messageID: id, - target: node, - path: () => path, - level: props.path.length, - reveal: props.reveal, - }), - ) - }) + const target = node() + const groupID = props.groupID + if (!target || !groupID || !props.active) return + onCleanup(ctx.anchors.register({ target: { type: "group", groupID }, node: target })) }) return ( - + {props.children} ) } - -function isBoundary(message: SessionMessageInfo | undefined) { - return message?.type === "assistant" || (message?.type === "user" && !!message.text.trim()) -} diff --git a/packages/tui/src/routes/session/anchors.ts b/packages/tui/src/routes/session/anchors.ts new file mode 100644 index 000000000000..d4326de8d06e --- /dev/null +++ b/packages/tui/src/routes/session/anchors.ts @@ -0,0 +1,85 @@ +import type { Renderable } from "@opentui/core" +import type { PartRef, SessionEntry, SessionNode } from "./grouping/session" + +export type AnchorTarget = { type: "part"; ref: PartRef } | { type: "group"; groupID: string } + +type Anchor = { + target: AnchorTarget + node: Pick +} + +export function anchorKey(target: AnchorTarget) { + return target.type === "part" + ? JSON.stringify(["part", target.ref.messageID, target.ref.partID]) + : JSON.stringify(["group", target.groupID]) +} + +/** Whole non-assistant messages have one canonical UI body part. Derived + * footer/usage rows use the preceding entry as their scroll reference. */ +export function entryRef(entry: SessionEntry): PartRef | undefined { + // Saved identities must not follow an in-place Solid store reconciliation. + if (entry.type === "part") return { messageID: entry.ref.messageID, partID: entry.ref.partID } + if (entry.type === "message") return { messageID: entry.messageID, partID: "message" } +} + +export function groupID(node: Extract, level: number) { + const ref = firstRef(node.children) + return ref && JSON.stringify([ref.messageID, ref.partID, node.kind, level]) +} + +function firstRef(nodes: readonly SessionNode[]): PartRef | undefined { + for (const node of nodes) { + const ref = node.type === "entry" ? entryRef(node.entry) : firstRef(node.children) + if (ref) return ref + } +} + +export function containsAnchor( + node: SessionEntry | Extract, + target: AnchorTarget, + level = 0, +): boolean { + if (node.type === "group") { + if (target.type === "group" && groupID(node, level) === target.groupID) return true + return node.children.some((child) => + containsAnchor(child.type === "entry" ? child.entry : child, target, level + 1), + ) + } + const ref = entryRef(node) + return target.type === "part" && ref?.messageID === target.ref.messageID && ref.partID === target.ref.partID +} + +/** Only mounted parts and actual group headers register. Geometry stays in OpenTUI. */ +export function createTimelineAnchors() { + const entries = new Map() + const list = () => + [...entries.values()] + .filter((anchor) => !anchor.node.isDestroyed && anchor.node.height > 0) + .sort((a, b) => a.node.y - b.node.y) + return { + register(anchor: Anchor) { + const key = anchorKey(anchor.target) + entries.set(key, anchor) + return () => { + if (entries.get(key) === anchor) entries.delete(key) + } + }, + get(target: AnchorTarget) { + const anchor = entries.get(anchorKey(target)) + return anchor && !anchor.node.isDestroyed && anchor.node.height > 0 ? anchor : undefined + }, + forMessage(messageID: string) { + return list().find((anchor) => anchor.target.type === "part" && anchor.target.ref.messageID === messageID) + }, + messagePositions() { + const seen = new Set() + return list().flatMap((anchor) => { + if (anchor.target.type !== "part" || seen.has(anchor.target.ref.messageID)) return [] + const id = anchor.target.ref.messageID + seen.add(id) + return [{ id, y: anchor.node.y }] + }) + }, + list, + } +} diff --git a/packages/tui/src/routes/session/group-view.tsx b/packages/tui/src/routes/session/group-view.tsx index c205bc0fe70b..a1be0324a444 100644 --- a/packages/tui/src/routes/session/group-view.tsx +++ b/packages/tui/src/routes/session/group-view.tsx @@ -7,6 +7,7 @@ import { reasoningSummary } from "../../context/thinking" import { SplitBorder } from "../../ui/border" import { Locale } from "../../util/locale" import { EntryAnchor, GroupAnchor, visitEntries } from "./anchor-view" +import { groupID } from "./anchors" import type { PartRef, SessionEntry, SessionGroup, SessionNode } from "./grouping/session" import { InlineToolRow, reasoningContent, toolDisplay } from "./message-parts" import { use } from "./render-context" @@ -21,18 +22,19 @@ type Renderers = { type GroupProps = Renderers & { node: Extract - path: readonly number[] + level: number completed: boolean pending: readonly PartRef[] pendingOutside?: boolean imagesOutside?: boolean } -export function SessionGroupView(props: Renderers & { row: SessionGroup; path: readonly number[] }) { +export function SessionGroupView(props: Renderers & { row: SessionGroup }) { return ( @@ -40,8 +42,7 @@ export function SessionGroupView(props: Renderers & { row: SessionGroup; path: r } function Group(props: GroupProps) { - // Reconciliation can change a group's kind in place. Each kind keeps its own - // disclosure lifetime, matching the former separate group components. + // Keep kind-specific hover/title state isolated during reconciliation. return ( {(_kind) => } @@ -53,15 +54,20 @@ function GroupContent(props: GroupProps) { const ctx = use() const theme = useTheme() const renderer = useRenderer() - const [expanded, setExpanded] = createSignal(false) + const id = createMemo(() => groupID(props.node, props.level)) + const expanded = () => { + const key = id() + return key ? (ctx.groupExpanded(key) ?? false) : false + } const [hover, setHover] = createSignal(false) - const refs = createMemo(() => { - const result: PartRef[] = [] - visitEntries(props.node.children, props.path, (entry) => { - if (entry.type === "part" && !isPending(entry, props.pending)) result.push(entry.ref) - }) + const entries = createMemo(() => { + const result: SessionEntry[] = [] + visitEntries(props.node.children, (entry) => result.push(entry)) return result }) + const refs = createMemo(() => + entries().flatMap((entry) => (entry.type === "part" && !isPending(entry, props.pending) ? [entry.ref] : [])), + ) const thoughts = createMemo(() => props.node.kind !== "reasoning" ? [] @@ -117,7 +123,8 @@ function GroupContent(props: GroupProps) { }) const toggle = () => { if (renderer.getSelection()?.getSelectedText()) return - setExpanded((value) => !value) + const key = id() + if (key) ctx.setGroupExpanded(key, !expanded()) } const children = (mode: "normal" | "thought" | "tool") => ( @@ -125,14 +132,8 @@ function GroupContent(props: GroupProps) { return ( { - if (!grouped() || expanded()) return false - setExpanded(true) - return true - }} + groupID={id()} + active={grouped() && (props.node.kind === "reasoning" ? thoughts().length > 0 : tools().length > 0)} > {(ref) => { const leaf = createMemo(() => { - let found: { entry: SessionEntry; path: readonly number[] } | undefined - visitEntries(props.node.children, props.path, (entry, path) => { - if (entry.type === "part" && entry.ref.messageID === ref.messageID && entry.ref.partID === ref.partID) - found ??= { entry, path } - }) - return found + return entries().find( + (entry) => + entry.type === "part" && entry.ref.messageID === ref.messageID && entry.ref.partID === ref.partID, + ) }) return ( - - {(item) => ( - - {props.entry(item().entry)} - - )} - + {(item) => {props.entry(item())}} ) }} @@ -222,7 +215,6 @@ function Children(props: GroupProps & { nodes: readonly SessionNode[]; mode: "no return ( {(node, index) => { - const path = () => [...props.path, index()] return ( @@ -230,7 +222,7 @@ function Children(props: GroupProps & { nodes: readonly SessionNode[]; mode: "no + {props.entry(node().entry, props.mode === "tool" ? false : undefined)} } > - + )} @@ -266,7 +258,7 @@ function Children(props: GroupProps & { nodes: readonly SessionNode[]; mode: "no ) } -function ThoughtEntry(props: { entry: SessionEntry; path: readonly number[]; message: Renderers["message"] }) { +function ThoughtEntry(props: { entry: SessionEntry; message: Renderers["message"] }) { const ctx = use() const theme = useTheme() const { currentSyntax: syntax } = useThemes() @@ -288,7 +280,7 @@ function ThoughtEntry(props: { entry: SessionEntry; path: readonly number[]; mes }) return ( - + messageBoundaryIDs(rows, messages())) - const anchors = createMessageAnchors() + const anchors = createTimelineAnchors() const [navigationMessage, setNavigationMessage] = createSignal() const [navigationSlack, setNavigationSlack] = createSignal(0) const [firstJump, setFirstJump] = createSignal<() => void>() @@ -272,13 +272,11 @@ export function Session(props: { const [awayFromBottom, setAwayFromBottom] = createSignal(false) const [latestHovered, setLatestHovered] = createSignal(false) let ensureAllRowsPending: (() => void)[] | undefined - let anchorRequest = 0 createEffect(() => { if (!awayFromBottom()) setLatestHovered(false) }) const clearMessageNavigation = () => { - anchorRequest++ ensureAllRowsPending?.splice(0) prependHistory.cancel() firstJump()?.() @@ -455,24 +453,15 @@ export function Session(props: { }) } - function revealMessage(messageID: string, continuation: (target: { y: number }) => void) { - const request = ++anchorRequest - const reveal = () => { - if (request !== anchorRequest || !scroll || scroll.isDestroyed) return - const anchor = anchors.get(messageID) - if (!anchor) return - if (anchor.reveal?.()) { - scroll.stickyScroll = false - afterLayout(reveal) - return - } - continuation(anchor.target) - } - reveal() - } - function isAwayFromBottom() { - if (revealingOlderRows || revealingNewerRows || ensureAllRowsPending || navigationMessage() || firstJump()) + if ( + revealingOlderRows || + revealingNewerRows || + ensureAllRowsPending || + navigationMessage() || + navigationSlack() || + firstJump() + ) return true if (visibleEnd() < rows.length) return true return scroll.scrollTop < Math.max(0, scroll.scrollHeight - scroll.viewport.height) @@ -504,16 +493,19 @@ export function Session(props: { sessionTabs.setScrollAnchor(sessionID, undefined) return } - let first: { messageID: string; screenY: number; reveal: boolean } | undefined - let anchor: { messageID: string; screenY: number; reveal: boolean } | undefined + let first: ScrollAnchor | undefined + let anchor: ScrollAnchor | undefined for (const child of mounted) { const item = { - messageID: child.messageID, - screenY: child.target.y - scroll.viewport.y, - reveal: child.reveal === undefined, + target: child.target, + screenY: child.node.y - scroll.viewport.y, } first ??= item - const inset = data.session.message.get(sessionID, item.messageID)?.type === "assistant" ? 1 : 0 + const inset = + item.target.type === "group" || + data.session.message.get(sessionID, item.target.ref.messageID)?.type === "assistant" + ? 1 + : 0 if (item.screenY <= inset && (!anchor || item.screenY > anchor.screenY)) anchor = item } anchor ??= first @@ -522,16 +514,7 @@ export function Session(props: { } function restoreScrollPosition() { const anchor = sessionTabs.scrollAnchor(sessionID) - const index = anchor - ? rows.findIndex((row) => { - if (row.type !== "group") return entryMessageID(row) === anchor.messageID - let found = false - visitEntries(row.children, [], (entry) => { - if (entryMessageID(entry) === anchor.messageID) found = true - }) - return found - }) - : -1 + const index = anchor ? rows.findIndex((row) => containsAnchor(row, anchor.target)) : -1 if (!anchor || index === -1) { scroll.scrollTo(scroll.scrollHeight) setAwayFromBottom(false) @@ -543,7 +526,7 @@ export function Session(props: { scroll.stickyScroll = false const restore = () => afterLayout(() => { - const boundary = anchors.get(anchor.messageID) + const boundary = anchors.get(anchor.target) if (!boundary) { sessionTabs.setScrollAnchor(sessionID, undefined) scroll.stickyScroll = true @@ -551,24 +534,29 @@ export function Session(props: { setAwayFromBottom(false) return } - const position = (boundary: { y: number }) => { - const contentY = scroll.scrollTop + boundary.y - scroll.viewport.y - const target = contentY - anchor.screenY - const maximum = Math.max(0, scroll.scrollHeight - scroll.viewport.height) - if (target > maximum && visibleEnd() < rows.length) { - const next = Math.min(rows.length, visibleEnd() + TRANSCRIPT_BACKFILL_CHUNK) - setVisibleRowsEnd(next === rows.length ? undefined : next) - restore() - return - } - scroll.scrollTo(target) - updateAwayFromBottom() + const contentY = scroll.scrollTop + boundary.node.y - scroll.viewport.y + const target = contentY - anchor.screenY + const maximum = Math.max(0, scroll.scrollHeight - scroll.viewport.height) + if (target > maximum && visibleEnd() < rows.length) { + const next = Math.min(rows.length, visibleEnd() + TRANSCRIPT_BACKFILL_CHUNK) + setVisibleRowsEnd(next === rows.length ? undefined : next) + restore() + return } - if (anchor.reveal) { - revealMessage(anchor.messageID, position) + if (target > maximum) { + setNavigationSlack( + messageNavigationSlack({ + top: target, + viewportHeight: scroll.viewport.height, + scrollHeight: scroll.scrollHeight, + currentSlack: scroll.getRenderable(NAVIGATION_SLACK_ID)?.height ?? 0, + }), + ) + restore() return } - position(boundary.target) + scroll.scrollTo(target) + updateAwayFromBottom() }) restore() } @@ -659,7 +647,7 @@ export function Session(props: { ensureAllRows(() => { const target = findMessageBoundary({ direction, - children: anchors.list().map((anchor) => ({ id: anchor.messageID, y: anchor.target.y })), + children: anchors.messagePositions(), messages: messages(), scrollTop: scroll.scrollTop, viewportY: scroll.viewport.y, @@ -681,11 +669,11 @@ export function Session(props: { const jumpToMessage = (messageID: string) => ensureAllRows(() => { - revealMessage(messageID, (child) => { - const y = scroll.scrollTop + child.y - scroll.viewport.y - const message = data.session.message.get(route.sessionID, messageID) - alignMessage(messageID, Math.max(0, y - (message?.type === "assistant" ? 1 : 0))) - }) + const child = anchors.forMessage(messageID) + if (!child) return + const y = scroll.scrollTop + child.node.y - scroll.viewport.y + const message = data.session.message.get(route.sessionID, messageID) + alignMessage(messageID, Math.max(0, y - (message?.type === "assistant" ? 1 : 0))) }) function toBottom() { @@ -1288,6 +1276,11 @@ export function Session(props: { sessionTabs.groupExpanded(sessionID, groupID), + setGroupExpanded: (groupID, expanded) => { + sessionTabs.setGroupExpanded(sessionID, groupID, expanded) + afterLayout(saveScrollAnchor) + }, get width() { return contentWidth() }, @@ -1338,7 +1331,7 @@ export function Session(props: { foregroundColor: theme.border.default, }, }} - stickyScroll={!navigationMessage()} + stickyScroll={!navigationMessage() && !navigationSlack()} stickyStart="bottom" flexGrow={1} scrollAcceleration={scrollAcceleration()} @@ -1353,7 +1346,6 @@ export function Session(props: { {(row, index) => ( data.session.message.get(route.sessionID, messageID)} boundaryID={boundaries()[index() + hidden()]} /> @@ -1470,7 +1462,6 @@ export function Session(props: { type SessionRowViewProps = { row: SessionRow - index: number message: (messageID: string) => SessionMessageInfo | undefined boundaryID?: string } @@ -1479,9 +1470,7 @@ function SessionRowView(props: SessionRowViewProps) { const [target, setTarget] = createSignal() useEntryAnchor({ entry: () => (props.row.type === "group" ? undefined : props.row), - path: () => [props.index], - target, - message: props.message, + node: target, }) return ( @@ -1490,7 +1479,6 @@ function SessionRowView(props: SessionRowViewProps) { {(row) => ( } images={(parts) => } diff --git a/packages/tui/src/routes/session/message-anchors.ts b/packages/tui/src/routes/session/message-anchors.ts deleted file mode 100644 index fe6e9f7dec58..000000000000 --- a/packages/tui/src/routes/session/message-anchors.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { Renderable } from "@opentui/core" - -type Anchor = { - messageID: string - target: Pick - /** Logical leaf position, even when this target is a collapsed ancestor. */ - path: () => readonly number[] - level: number - /** Returns true when revealing the target requires another layout pass. */ - reveal?: () => boolean -} - -/** Mounted renderables own geometry; the registry only locates message targets. */ -export function createMessageAnchors() { - const entries = new Map>() - const get = (messageID: string) => { - const candidates = entries.get(messageID) - if (!candidates) return - return [...candidates] - .filter((anchor) => !anchor.target.isDestroyed) - .sort((a, b) => comparePaths(a.path(), b.path()) || b.level - a.level)[0] - } - return { - register(anchor: Anchor) { - const candidates = entries.get(anchor.messageID) ?? new Set() - candidates.add(anchor) - entries.set(anchor.messageID, candidates) - return () => { - candidates.delete(anchor) - if (!candidates.size && entries.get(anchor.messageID) === candidates) entries.delete(anchor.messageID) - } - }, - get, - list() { - return [...entries.keys()] - .flatMap((id) => { - const anchor = get(id) - return anchor ? [anchor] : [] - }) - .sort((a, b) => comparePaths(a.path(), b.path())) - }, - } -} - -function comparePaths(a: readonly number[], b: readonly number[]) { - for (let index = 0; index < Math.min(a.length, b.length); index++) { - const difference = a[index] - b[index] - if (difference) return difference - } - return a.length - b.length -} diff --git a/packages/tui/src/routes/session/render-context.tsx b/packages/tui/src/routes/session/render-context.tsx index bb36acaa4ce3..3a461f1d726b 100644 --- a/packages/tui/src/routes/session/render-context.tsx +++ b/packages/tui/src/routes/session/render-context.tsx @@ -3,7 +3,7 @@ import type { ModelInfo } from "@opencode/client" import type { SessionInbox } from "@opencode/schema/session-inbox" import type { useConfig } from "../../config" import type { ThinkingMode } from "../../context/thinking" -import type { createMessageAnchors } from "./message-anchors" +import type { createTimelineAnchors } from "./anchors" export type PendingAction = "steer" | "queue" | "cancel" @@ -17,7 +17,9 @@ export const context = createContext<{ */ terminal: { width: number; height: number } sessionID: string - anchors: ReturnType + anchors: ReturnType + groupExpanded: (groupID: string) => boolean | undefined + setGroupExpanded: (groupID: string, expanded: boolean) => void thinkingMode: () => ThinkingMode markdownMode: () => "source" | "rendered" groupExploration: () => boolean diff --git a/packages/tui/test/cli/tui/anchors.test.ts b/packages/tui/test/cli/tui/anchors.test.ts new file mode 100644 index 000000000000..984f8b2d8a26 --- /dev/null +++ b/packages/tui/test/cli/tui/anchors.test.ts @@ -0,0 +1,77 @@ +import { expect, test } from "bun:test" +import { + anchorKey, + containsAnchor, + createTimelineAnchors, + entryRef, + groupID, + type AnchorTarget, +} from "../../../src/routes/session/anchors" +import { groupEntries } from "../../../src/routes/session/grouping/tree" +import type { SessionEntry } from "../../../src/routes/session/grouping/session" + +test("part anchors identify the exact part and whole messages have a body reference", () => { + const a: AnchorTarget = { type: "part", ref: { messageID: "a", partID: "reasoning:0" } } + const b: AnchorTarget = { type: "part", ref: { messageID: "b", partID: "reasoning:0" } } + const later: AnchorTarget = { type: "part", ref: { messageID: "a", partID: "reasoning:1" } } + expect(new Set([a, b, later].map(anchorKey)).size).toBe(3) + expect(entryRef({ type: "message", messageID: "user" })).toEqual({ messageID: "user", partID: "message" }) + const entry: SessionEntry = { type: "part", ref: { messageID: "a", partID: "read" } } + const saved = entryRef(entry) + entry.ref.partID = "replacement" + expect(saved?.partID).toBe("read") +}) + +test("group IDs use the first descendant reference, kind and nesting level", () => { + const a: SessionEntry = { type: "part", ref: { messageID: "a", partID: "read" } } + const b: SessionEntry = { type: "part", ref: { messageID: "b", partID: "read" } } + const [root] = groupEntries([a], () => ["exploration", "exploration"] as const) + const [appended] = groupEntries([a, b], () => ["exploration", "exploration"] as const) + const [prepended] = groupEntries([b, a], () => ["exploration", "exploration"] as const) + if (root.type !== "group" || appended.type !== "group" || prepended.type !== "group") + throw new Error("Expected groups") + const inner = root.children[0] + if (inner.type !== "group") throw new Error("Expected inner group") + expect(groupID(root, 0)).toBe(groupID(appended, 0)) + expect(groupID(root, 0)).not.toBe(groupID(prepended, 0)) + expect(groupID(root, 0)).not.toBe(groupID(inner, 1)) + expect(groupID(root, 0)).not.toBe(groupID({ ...root, kind: "reasoning" }, 0)) + const id = groupID(inner, 1) + if (!id) throw new Error("Missing group ID") + expect(containsAnchor(root, { type: "group", groupID: id })).toBe(true) + expect(containsAnchor(root, { type: "part", ref: b.ref })).toBe(false) +}) + +test("mounted headers and parts are independent targets with current geometry", () => { + const anchors = createTimelineAnchors() + const part: AnchorTarget = { type: "part", ref: { messageID: "a", partID: "read" } } + const group: AnchorTarget = { type: "group", groupID: "group-a" } + const header = { y: 2, height: 1, isDestroyed: false } + const node = { y: 8, height: 1, isDestroyed: false } + anchors.register({ target: group, node: header }) + expect(anchors.get(part)).toBeUndefined() + const remove = anchors.register({ target: part, node }) + expect(anchors.get(group)?.node).toBe(header) + expect(anchors.get(part)?.node).toBe(node) + node.y = -4 + expect(anchors.list()[0].target).toEqual(part) + expect(anchors.messagePositions()).toEqual([{ id: "a", y: -4 }]) + remove() + expect(anchors.get(part)).toBeUndefined() + expect(anchors.get(group)?.node).toBe(header) +}) + +test("cleanup cannot remove a replacement registration", () => { + const anchors = createTimelineAnchors() + const target: AnchorTarget = { type: "part", ref: { messageID: "a", partID: "text:0" } } + const remove = anchors.register({ target, node: { y: 0, height: 1, isDestroyed: false } }) + const node = { y: 3, height: 1, isDestroyed: false } + anchors.register({ target, node }) + remove() + expect(anchors.get(target)?.node).toBe(node) + node.height = 0 + expect(anchors.get(target)).toBeUndefined() + node.height = 1 + node.isDestroyed = true + expect(anchors.get(target)).toBeUndefined() +}) diff --git a/packages/tui/test/cli/tui/group-view.test.tsx b/packages/tui/test/cli/tui/group-view.test.tsx index 4c8ee7ff61e9..491d84a6780b 100644 --- a/packages/tui/test/cli/tui/group-view.test.tsx +++ b/packages/tui/test/cli/tui/group-view.test.tsx @@ -7,16 +7,17 @@ import type { SessionMessageAssistant } from "@opencode/client" import { ConfigProvider } from "../../../src/config" import { ThemeProvider } from "../../../src/context/theme" import { SessionGroupView } from "../../../src/routes/session/group-view" -import { createMessageAnchors } from "../../../src/routes/session/message-anchors" +import { createTimelineAnchors, groupID, type AnchorTarget } from "../../../src/routes/session/anchors" import { context } from "../../../src/routes/session/render-context" import type { SessionGroup } from "../../../src/routes/session/grouping/session" import { emptyThemeSource } from "../../fixture/fixture" import { TestTuiContexts } from "../../fixture/tui-environment" import { createTuiResolvedConfig } from "../../fixture/tui-runtime" -test("reveals two collapsed ancestors and registers the leaf's measured position", async () => { +test("retains nested expansion state and registers exact headers and parts", async () => { addDefaultParsers(parsers.parsers) - const anchors = createMessageAnchors() + const anchors = createTimelineAnchors() + const [expanded, setExpanded] = createStore>({}) const config = createTuiResolvedConfig({ animations: false }) const messages = new Map( ["a", "b"].map((id) => [ @@ -70,6 +71,8 @@ test("reveals two collapsed ancestors and registers the leaf's measured position terminal: { width: 40, height: 24 }, sessionID: "fixture", anchors, + groupExpanded: (id) => expanded[id], + setGroupExpanded: (id, value) => setExpanded(id, value), thinkingMode: () => "hide", markdownMode: () => "rendered", groupExploration: () => true, @@ -84,7 +87,6 @@ test("reveals two collapsed ancestors and registers the leaf's measured position messages.get(id)} images={() => Image previews} entry={(entry) => @@ -104,27 +106,40 @@ test("reveals two collapsed ancestors and registers the leaf's measured position { width: 40, height: 24 }, ) app.renderer.start() + const outerID = groupID(row, 0) + const inner = row.children[0] + if (inner.type !== "group") throw new Error("Missing nested group") + const innerID = groupID(inner, 1) + if (!outerID || !innerID) throw new Error("Missing group IDs") + const outer: AnchorTarget = { type: "group", groupID: outerID } + const nested: AnchorTarget = { type: "group", groupID: innerID } + const a: AnchorTarget = { type: "part", ref: { messageID: "a", partID: "read-a" } } + const b: AnchorTarget = { type: "part", ref: { messageID: "b", partID: "read-b" } } try { await app.waitForFrame((frame) => frame.includes("Explored")) expect(app.captureCharFrame()).not.toContain("Target B") - expect(anchors.get("b")?.path()).toEqual([0, 0, 1]) - const headerY = anchors.get("b")?.target.y ?? -1 - expect(anchors.get("b")?.reveal?.()).toBe(true) + expect(anchors.get(b)).toBeUndefined() + expect(anchors.get(nested)).toBeUndefined() + await app.mockMouse.click(4, anchors.get(outer)?.node.y ?? -1) await app.renderOnce() expect(app.captureCharFrame()).not.toContain("Target B") - expect(anchors.get("b")?.level).toBe(2) - expect(anchors.get("b")?.reveal?.()).toBe(true) + expect(expanded[outerID]).toBe(true) + expect(anchors.get(outer)).toBeDefined() + await app.mockMouse.click(4, anchors.get(nested)?.node.y ?? -1) await app.renderOnce() expect(app.captureCharFrame()).toContain("Target B") - expect(anchors.get("b")?.reveal).toBeUndefined() - expect(anchors.get("b")?.target.y).toBe(target?.y) - expect(anchors.get("b")?.target.y).toBeGreaterThan(anchors.get("a")?.target.y ?? Infinity) + expect(expanded[innerID]).toBe(true) + expect(anchors.get(b)?.node.y).toBe(target?.y) + expect(anchors.get(b)?.node.y).toBeGreaterThan(anchors.get(a)?.node.y ?? Infinity) + expect(anchors.get(nested)).toBeDefined() expect(app.captureCharFrame().match(/Image previews/g)?.length).toBe(1) - await app.mockMouse.click(4, headerY) + setExpanded(outerID, false) await app.renderOnce() expect(app.captureCharFrame()).not.toContain("Target B") - expect(anchors.get("b")?.level).toBe(1) - anchors.get("b")?.reveal?.() + expect(anchors.get(b)).toBeUndefined() + expect(anchors.get(outer)).toBeDefined() + expect(expanded[innerID]).toBe(true) + setExpanded(outerID, true) await app.renderOnce() setRow( reconcile({ @@ -141,7 +156,7 @@ test("reveals two collapsed ancestors and registers the leaf's measured position ) await app.renderOnce() expect(app.captureCharFrame()).toContain("Target B") - expect(anchors.get("b")?.path()).toEqual([0, 1]) + expect(anchors.get(b)?.node.y).toBe(target?.y) setRow( reconcile({ type: "group", @@ -157,7 +172,9 @@ test("reveals two collapsed ancestors and registers the leaf's measured position await app.renderOnce() expect(app.captureCharFrame()).toContain("Thought") expect(app.captureCharFrame()).not.toContain("Reset thought body") - expect(anchors.get("b")?.reveal?.()).toBe(true) + const thinkingID = groupID(row, 0) + if (!thinkingID) throw new Error("Missing thinking group ID") + setExpanded(thinkingID, true) await app.waitForFrame((frame) => frame.includes("Reset thought body")) expect(app.captureCharFrame()).toContain("Reset thought body") } finally { diff --git a/packages/tui/test/cli/tui/message-anchors.test.ts b/packages/tui/test/cli/tui/message-anchors.test.ts deleted file mode 100644 index b28203515458..000000000000 --- a/packages/tui/test/cli/tui/message-anchors.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { expect, test } from "bun:test" -import { createMessageAnchors } from "../../../src/routes/session/message-anchors" - -test("chooses the first logical entry rather than mount order", () => { - const anchors = createMessageAnchors() - const later = { y: 30, isDestroyed: false } - const first = { y: 10, isDestroyed: false } - anchors.register({ messageID: "a", target: later, path: () => [2], level: 1 }) - anchors.register({ messageID: "a", target: first, path: () => [0, 1], level: 2 }) - expect(anchors.get("a")?.target).toBe(first) -}) - -test("prefers a revealed leaf over its group fallback and restores the fallback on unmount", () => { - const anchors = createMessageAnchors() - const group = { y: 4, isDestroyed: false } - const leaf = { y: 12, isDestroyed: false } - anchors.register({ messageID: "a", target: group, path: () => [0, 0, 1], level: 1, reveal: () => true }) - const remove = anchors.register({ messageID: "a", target: leaf, path: () => [0, 0, 1], level: 3 }) - expect(anchors.get("a")?.target).toBe(leaf) - remove() - expect(anchors.get("a")?.target).toBe(group) - expect(anchors.get("a")?.reveal?.()).toBe(true) -}) - -test("reads current order and renderer positions after prepend, reflow and scrolling", () => { - const anchors = createMessageAnchors() - let index = 2 - const target = { y: 20, isDestroyed: false } - anchors.register({ messageID: "a", target, path: () => [index], level: 1 }) - anchors.register({ messageID: "b", target: { y: 10, isDestroyed: false }, path: () => [1], level: 1 }) - expect(anchors.list().map((anchor) => anchor.messageID)).toEqual(["b", "a"]) - index = 0 - target.y = -5 - expect(anchors.list().map((anchor) => anchor.messageID)).toEqual(["a", "b"]) - expect(anchors.get("a")?.target.y).toBe(-5) -}) - -test("cleanup removes only its own registration and destroyed renderables are ignored", () => { - const anchors = createMessageAnchors() - const old = { y: 0, isDestroyed: false } - const replacement = { y: 1, isDestroyed: false } - const remove = anchors.register({ messageID: "a", target: old, path: () => [0], level: 1 }) - anchors.register({ messageID: "a", target: replacement, path: () => [0], level: 1 }) - remove() - remove() - expect(anchors.get("a")?.target).toBe(replacement) - replacement.isDestroyed = true - expect(anchors.get("a")).toBeUndefined() - expect(anchors.list()).toEqual([]) -}) diff --git a/packages/tui/test/context/session-tabs.test.tsx b/packages/tui/test/context/session-tabs.test.tsx index 5c1d096689bf..bbcdd59f6f3f 100644 --- a/packages/tui/test/context/session-tabs.test.tsx +++ b/packages/tui/test/context/session-tabs.test.tsx @@ -592,18 +592,21 @@ test("keeps scroll anchors for open session tabs", async () => { try { await wait(() => setup.tabs.current() === "first") await wait(() => setup.tabs.tabs().some((tab) => tab.sessionID === "first")) - setup.tabs.setScrollAnchor("first", { messageID: "msg_1", screenY: -3 }) - - expect(setup.tabs.scrollAnchor("first")).toEqual({ messageID: "msg_1", screenY: -3 }) - - setup.tabs.setScrollAnchor("first", { messageID: "msg_1", screenY: -3, reveal: true }) - expect(setup.tabs.scrollAnchor("first")?.reveal).toBe(true) - setup.tabs.setScrollAnchor("first", { messageID: "msg_1", screenY: -3, reveal: false }) - expect(setup.tabs.scrollAnchor("first")?.reveal).toBe(false) + const target = { type: "part" as const, ref: { messageID: "msg_1", partID: "text:0" } } + setup.tabs.setScrollAnchor("first", { target, screenY: -3 }) + expect(setup.tabs.scrollAnchor("first")).toEqual({ target, screenY: -3 }) + const group = { type: "group" as const, groupID: "group-1" } + setup.tabs.setScrollAnchor("first", { target: group, screenY: -3 }) + expect(setup.tabs.scrollAnchor("first")?.target).toEqual(group) + setup.tabs.setGroupExpanded("first", group.groupID, true) + expect(setup.tabs.groupExpanded("first", group.groupID)).toBe(true) + setup.tabs.setGroupExpanded("first", group.groupID, false) + expect(setup.tabs.groupExpanded("first", group.groupID)).toBe(false) setup.tabs.close("first") await wait(() => setup.tabs.tabs().every((tab) => tab.sessionID !== "first")) expect(setup.tabs.scrollAnchor("first")).toBeUndefined() + expect(setup.tabs.groupExpanded("first", group.groupID)).toBeUndefined() } finally { await setup.destroy() } @@ -617,8 +620,11 @@ test("keeps parent and subagent scroll anchors independent", async () => { try { await wait(() => setup.data.session.get("child") !== undefined) - const parent = { messageID: "msg_parent", screenY: -3 } - const child = { messageID: "msg_child", screenY: -5 } + const parent = { + target: { type: "part" as const, ref: { messageID: "msg_parent", partID: "message" } }, + screenY: -3, + } + const child = { target: { type: "part" as const, ref: { messageID: "msg_child", partID: "message" } }, screenY: -5 } setup.tabs.setScrollAnchor("root", parent) // A short subagent transcript is at the bottom, so it saves no anchor. diff --git a/packages/tui/test/session-group-navigation.test.tsx b/packages/tui/test/session-group-navigation.test.tsx index af6021e7a32e..6ab157ba9b8d 100644 --- a/packages/tui/test/session-group-navigation.test.tsx +++ b/packages/tui/test/session-group-navigation.test.tsx @@ -8,147 +8,242 @@ import { createEventStream, createFetch, directory, json } from "./fixture/tui-c import { tmpdir } from "./fixture/fixture" import { mkdir } from "node:fs/promises" -test.each([48, 80, 120])("navigates to grouped thoughts and restores tab positions at width %s", async (width) => { - await using state = await tmpdir() - const setup = await createTestRenderer({ width, height: 30, useThread: false, kittyKeyboard: true }) - setup.renderer.start() - const session = { - id: "ses_group_navigation", - title: "Grouped navigation", - projectID: "proj_test", - location: { directory }, - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - time: { created: 0, updated: 0 }, - } - const other = { ...session, id: "ses_other", title: "Other session" } - await mkdir(`${state.path}/test/tui`, { recursive: true }) - await Bun.write( - `${state.path}/test/tui/tabs.json`, - JSON.stringify({ - global: { tabs: [{ sessionID: session.id }, { sessionID: other.id }], unread: {} }, - cwd: {}, - }), - ) - const messages: SessionMessageInfo[] = [ - { type: "user", id: "msg_user", text: "User prompt", time: { created: 0 } }, - { - type: "assistant", - id: "msg_a", - agent: "build", - model: { providerID: "fixture", id: "fixture" }, - time: { created: 1, completed: 3 }, - content: [ - { type: "text", text: "First response" }, - { - type: "reasoning", - text: "**First title**\n\nFirst thought body with enough text to wrap differently at each tested terminal width, changing the target's measured offset.", - time: { created: 1, completed: 2 }, +test.each([ + [48, 30], + [80, 30], + [120, 30], + [80, 0], +])( + "restores exact part/group anchors at width %s with %s trailing lines", + async (width, lines) => { + await using state = await tmpdir() + const setup = await createTestRenderer({ width, height: 30, useThread: false, kittyKeyboard: true }) + setup.renderer.start() + const session = { + id: "ses_group_navigation", + title: "Grouped navigation", + projectID: "proj_test", + location: { directory }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 0, updated: 0 }, + } + const other = { ...session, id: "ses_other", title: "Other session" } + await mkdir(`${state.path}/test/tui`, { recursive: true }) + await Bun.write( + `${state.path}/test/tui/tabs.json`, + JSON.stringify({ + global: { tabs: [{ sessionID: session.id }, { sessionID: other.id }], unread: {} }, + cwd: {}, + }), + ) + const messages: SessionMessageInfo[] = [ + { type: "user", id: "msg_user", text: "User prompt", time: { created: 0 } }, + { + type: "assistant", + id: "msg_a", + agent: "build", + model: { providerID: "fixture", id: "fixture" }, + time: { created: 1, completed: 3 }, + content: [ + { type: "text", text: "First response" }, + { + type: "reasoning", + text: "**First title**\n\nFirst thought body with enough text to wrap differently at each tested terminal width, changing the target's measured offset.", + time: { created: 1, completed: 2 }, + }, + ], + }, + { + type: "assistant", + id: "msg_b", + agent: "build", + model: { providerID: "fixture", id: "fixture" }, + time: { created: 4, completed: 6 }, + finish: "stop", + content: [ + { type: "reasoning", text: "**Second title**\n\nSecond thought body", time: { created: 4, completed: 5 } }, + { type: "text", text: `Second response\n${"A later line of the response.\n".repeat(lines)}Final marker` }, + ], + }, + ] + const calls = createFetch((url) => { + if (url.pathname === "/api/session") return json({ data: [session, other], cursor: {} }) + if (url.pathname === `/api/session/${other.id}`) return json({ data: other }) + if (url.pathname === `/api/session/${other.id}/message`) + return json({ + data: [{ type: "user", id: "msg_other", text: "Other session content", time: { created: 0 } }], + cursor: {}, + }) + if (url.pathname === `/api/session/${other.id}/inbox` || url.pathname === `/api/session/${other.id}/permission`) + return json({ data: [] }) + if (url.pathname === `/api/session/${session.id}`) return json({ data: session }) + if (url.pathname === `/api/session/${session.id}/message`) + return json({ data: messages.toReversed(), cursor: {} }) + if ( + url.pathname === `/api/session/${session.id}/inbox` || + url.pathname === `/api/session/${session.id}/permission` + ) + return json({ data: [] }) + }, createEventStream()) + const server = Bun.serve({ port: 0, idleTimeout: 0, fetch: (request) => calls.fetch(request) }) + const { run } = await import("../src/app") + const task = Effect.runPromise( + run({ + app: { name: "test", version: "test", channel: "test" }, + server: { endpoint: { url: server.url.toString() } }, + config: { + get: async () => ({ + animations: false, + tabs: { enabled: true, scope: "global" }, + keybinds: { "session.messages_last_user": "ctrl+shift+u", "session.message.next": "ctrl+shift+n" }, + }), + update: async () => ({}), }, - ], - }, - { - type: "assistant", - id: "msg_b", - agent: "build", - model: { providerID: "fixture", id: "fixture" }, - time: { created: 4, completed: 6 }, - finish: "stop", - content: [ - { type: "reasoning", text: "**Second title**\n\nSecond thought body", time: { created: 4, completed: 5 } }, - { type: "text", text: `Second response\n${"A later line of the response.\n".repeat(30)}Final marker` }, - ], - }, - ] - const calls = createFetch((url) => { - if (url.pathname === "/api/session") return json({ data: [session, other], cursor: {} }) - if (url.pathname === `/api/session/${other.id}`) return json({ data: other }) - if (url.pathname === `/api/session/${other.id}/message`) - return json({ - data: [{ type: "user", id: "msg_other", text: "Other session content", time: { created: 0 } }], - cursor: {}, + packages: { prepare: async () => ({ directory: "" }) }, + args: { sessionID: session.id }, + terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }), + log: () => {}, + }).pipe(Effect.provide(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))), + ) + try { + await setup.waitForFrame((frame) => frame.includes("Final marker")) + expect(setup.captureCharFrame()).not.toContain("Second thought body") + setup.mockInput.pressKey("u", { ctrl: true, shift: true }) + await setup.waitForFrame((frame) => frame.includes("Thought:")) + await setup.waitForVisualIdle({ quietFrames: 3 }) + const find = (node: Renderable): ScrollBoxRenderable | undefined => + node instanceof ScrollBoxRenderable && node.getRenderable("msg_b") + ? node + : node.getChildren().map(find).find(Boolean) + const initial = find(setup.renderer.root) + if (!initial) throw new Error("Missing transcript scrollbox") + const summaryLine = setup + .captureCharFrame() + .split("\n") + .findIndex((line) => line.includes("Thought:")) + initial.scrollTo(initial.scrollTop + summaryLine - initial.viewport.y) + await setup.waitForVisualIdle({ quietFrames: 3 }) + const summaryOffset = + setup + .captureCharFrame() + .split("\n") + .findIndex((line) => line.includes("Thought:")) - initial.viewport.y + if (lines > 0) expect(summaryOffset).toBe(0) + setup.mockInput.pressKey("2", { ctrl: true }) + await setup.waitForFrame((frame) => frame.includes("Other session content")) + setup.mockInput.pressKey("1", { ctrl: true }) + await setup.waitForFrame((frame) => { + const viewport = find(setup.renderer.root) + return ( + !!viewport && + frame.split("\n").findIndex((line) => line.includes("Thought:")) - viewport.viewport.y === summaryOffset + ) }) - if (url.pathname === `/api/session/${other.id}/inbox` || url.pathname === `/api/session/${other.id}/permission`) - return json({ data: [] }) - if (url.pathname === `/api/session/${session.id}`) return json({ data: session }) - if (url.pathname === `/api/session/${session.id}/message`) return json({ data: messages.toReversed(), cursor: {} }) - if (url.pathname === `/api/session/${session.id}/inbox` || url.pathname === `/api/session/${session.id}/permission`) - return json({ data: [] }) - }, createEventStream()) - const server = Bun.serve({ port: 0, idleTimeout: 0, fetch: (request) => calls.fetch(request) }) - const { run } = await import("../src/app") - const task = Effect.runPromise( - run({ - app: { name: "test", version: "test", channel: "test" }, - server: { endpoint: { url: server.url.toString() } }, - config: { - get: async () => ({ - animations: false, - tabs: { enabled: true, scope: "global" }, - keybinds: { "session.messages_last_user": "ctrl+shift+u", "session.message.next": "ctrl+shift+n" }, - }), - update: async () => ({}), - }, - packages: { prepare: async () => ({ directory: "" }) }, - args: { sessionID: session.id }, - terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }), - log: () => {}, - }).pipe(Effect.provide(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))), - ) - try { - await setup.waitForFrame((frame) => frame.includes("Final marker")) - expect(setup.captureCharFrame()).not.toContain("Second thought body") - setup.mockInput.pressKey("u", { ctrl: true, shift: true }) - await setup.waitForFrame((frame) => frame.includes("Thought:")) - await setup.waitForVisualIdle({ quietFrames: 3 }) - const find = (node: Renderable): ScrollBoxRenderable | undefined => - node instanceof ScrollBoxRenderable && node.getRenderable("msg_b") - ? node - : node.getChildren().map(find).find(Boolean) - const initial = find(setup.renderer.root) - if (!initial) throw new Error("Missing transcript scrollbox") - const summaryLine = setup - .captureCharFrame() - .split("\n") - .findIndex((line) => line.includes("Thought:")) - initial.scrollTo(initial.scrollTop + summaryLine - initial.viewport.y) - await setup.waitForVisualIdle({ quietFrames: 3 }) - setup.mockInput.pressKey("2", { ctrl: true }) - await setup.waitForFrame((frame) => frame.includes("Other session content")) - setup.mockInput.pressKey("1", { ctrl: true }) - await setup.waitForFrame((frame) => frame.includes("Thought:")) - await setup.waitForVisualIdle({ quietFrames: 3 }) - expect(setup.captureCharFrame()).not.toContain("Second thought body") - setup.mockInput.pressKey("u", { ctrl: true, shift: true }) - await setup.waitForVisualIdle({ quietFrames: 3 }) - setup.mockInput.pressKey("n", { ctrl: true, shift: true }) - await setup.waitForVisualIdle({ quietFrames: 3 }) - setup.mockInput.pressKey("n", { ctrl: true, shift: true }) - await setup.waitForFrame((frame) => frame.includes("Second thought body")) - await setup.waitForVisualIdle({ quietFrames: 3 }) - const scroll = find(setup.renderer.root) - if (!scroll) throw new Error("Missing transcript scrollbox") - const titleLine = setup - .captureCharFrame() - .split("\n") - .findIndex((line) => line.includes("Second title")) - expect(titleLine - scroll.viewport.y).toBe(1) - expect(scroll.scrollTop).toBeGreaterThan(0) - setup.mockInput.pressKey("2", { ctrl: true }) - await setup.waitForFrame((frame) => frame.includes("Other session content")) - setup.mockInput.pressKey("1", { ctrl: true }) - await setup.waitForFrame((frame) => frame.includes("Second thought body")) - await setup.waitForVisualIdle({ quietFrames: 3 }) - const restored = find(setup.renderer.root) - if (!restored) throw new Error("Missing restored transcript") - const restoredTitle = setup - .captureCharFrame() - .split("\n") - .findIndex((line) => line.includes("Second title")) - expect(restoredTitle - restored.viewport.y).toBe(1) - } finally { - setup.renderer.destroy() - await task - await server.stop() - } -}) + await setup.waitForVisualIdle({ quietFrames: 3 }) + expect(setup.captureCharFrame()).not.toContain("Second thought body") + const summary = find(setup.renderer.root) + if (!summary) throw new Error("Missing restored summary viewport") + expect( + setup + .captureCharFrame() + .split("\n") + .findIndex((line) => line.includes("Thought:")) - summary.viewport.y, + ).toBe(summaryOffset) + setup.mockInput.pressKey("u", { ctrl: true, shift: true }) + await setup.waitForVisualIdle({ quietFrames: 3 }) + setup.mockInput.pressKey("n", { ctrl: true, shift: true }) + await setup.waitForVisualIdle({ quietFrames: 3 }) + setup.mockInput.pressKey("n", { ctrl: true, shift: true }) + await setup.waitForFrame((frame) => frame.includes("Second response")) + await setup.waitForVisualIdle({ quietFrames: 3 }) + expect(setup.captureCharFrame()).not.toContain("Second thought body") + setup.mockInput.pressKey("u", { ctrl: true, shift: true }) + await setup.waitForFrame((frame) => frame.includes("Thought:")) + await setup.waitForVisualIdle({ quietFrames: 3 }) + const header = setup + .captureCharFrame() + .split("\n") + .findIndex((line) => line.includes("Thought:")) + await setup.mockMouse.click(8, header) + await setup.waitForFrame((frame) => frame.includes("First thought body")) + setup.mockInput.pressKey("u", { ctrl: true, shift: true }) + await setup.waitForVisualIdle({ quietFrames: 3 }) + setup.mockInput.pressKey("n", { ctrl: true, shift: true }) + await setup.waitForVisualIdle({ quietFrames: 3 }) + setup.mockInput.pressKey("n", { ctrl: true, shift: true }) + await setup.waitForFrame((frame) => frame.includes("Second thought body")) + await setup.waitForVisualIdle({ quietFrames: 3 }) + const scroll = find(setup.renderer.root) + if (!scroll) throw new Error("Missing transcript scrollbox") + const titleLine = setup + .captureCharFrame() + .split("\n") + .findIndex((line) => line.includes("Second title")) + expect(titleLine - scroll.viewport.y).toBe(1) + expect(scroll.scrollTop).toBeGreaterThan(0) + setup.mockInput.pressKey("2", { ctrl: true }) + await setup.waitForFrame((frame) => frame.includes("Other session content")) + setup.mockInput.pressKey("1", { ctrl: true }) + await setup.waitForFrame((frame) => { + const viewport = find(setup.renderer.root) + return ( + !!viewport && frame.split("\n").findIndex((line) => line.includes("Second title")) - viewport.viewport.y === 1 + ) + }) + await setup.waitForVisualIdle({ quietFrames: 3 }) + const restored = find(setup.renderer.root) + if (!restored) throw new Error("Missing restored transcript") + const restoredTitle = setup + .captureCharFrame() + .split("\n") + .findIndex((line) => line.includes("Second title")) + expect(restoredTitle - restored.viewport.y).toBe(1) + if (lines === 0) return + + // Save inside A's second part, not relative to A's earlier text part. + setup.mockInput.pressKey("u", { ctrl: true, shift: true }) + await setup.waitForFrame((frame) => frame.includes("First title")) + await setup.waitForVisualIdle({ quietFrames: 3 }) + const reading = find(setup.renderer.root) + if (!reading) throw new Error("Missing reading viewport") + const firstTitle = setup + .captureCharFrame() + .split("\n") + .findIndex((line) => line.includes("First title")) + reading.scrollTo(reading.scrollTop + firstTitle - reading.viewport.y + 2) + await setup.waitForVisualIdle({ quietFrames: 3 }) + const bodyOffset = + setup + .captureCharFrame() + .split("\n") + .findIndex((line) => line.includes("First thought body")) - reading.viewport.y + expect(bodyOffset).toBe(0) + setup.mockInput.pressKey("2", { ctrl: true }) + await setup.waitForFrame((frame) => frame.includes("Other session content")) + setup.mockInput.pressKey("1", { ctrl: true }) + await setup.waitForFrame((frame) => { + const viewport = find(setup.renderer.root) + return ( + !!viewport && + frame.split("\n").findIndex((line) => line.includes("First thought body")) - viewport.viewport.y === + bodyOffset + ) + }) + await setup.waitForVisualIdle({ quietFrames: 3 }) + const final = find(setup.renderer.root) + if (!final) throw new Error("Missing final viewport") + expect( + setup + .captureCharFrame() + .split("\n") + .findIndex((line) => line.includes("First thought body")) - final.viewport.y, + ).toBe(bodyOffset) + } finally { + setup.renderer.destroy() + await task + await server.stop() + } + }, + 15000, +)