diff --git a/packages/tui/src/context/session-tabs.tsx b/packages/tui/src/context/session-tabs.tsx index fe816abcf96f..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,8 +41,8 @@ type PersistedState = { cwd: Record } -type ScrollAnchor = { - messageID: string +export type ScrollAnchor = { + target: AnchorTarget screenY: number } @@ -90,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) @@ -202,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 @@ -346,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 @@ -393,9 +402,16 @@ 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 && 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 new file mode 100644 index 000000000000..a21d76f75dac --- /dev/null +++ b/packages/tui/src/routes/session/anchor-view.tsx @@ -0,0 +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 { SessionEntry, SessionNode } from "./grouping/session" +import { entryRef } from "./anchors" +import { use } from "./render-context" + +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 + node: Accessor +}) { + const ctx = use() + createEffect(() => { + const entry = props.entry() + 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; children: JSX.Element; marginTop?: number }) { + const [node, setNode] = createSignal() + useEntryAnchor({ entry: () => props.entry, node }) + return ( + + {props.children} + + ) +} + +export function GroupAnchor(props: { groupID: string | undefined; active: boolean; children: JSX.Element }) { + const ctx = use() + const [node, setNode] = createSignal() + createEffect(() => { + 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} + + ) +} 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 new file mode 100644 index 000000000000..a1be0324a444 --- /dev/null +++ b/packages/tui/src/routes/session/group-view.tsx @@ -0,0 +1,321 @@ +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 { groupID } from "./anchors" +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 + level: number + completed: boolean + pending: readonly PartRef[] + pendingOutside?: boolean + imagesOutside?: boolean +} + +export function SessionGroupView(props: Renderers & { row: SessionGroup }) { + return ( + + ) +} + +function Group(props: GroupProps) { + // Keep kind-specific hover/title state isolated during reconciliation. + return ( + + {(_kind) => } + + ) +} + +function GroupContent(props: GroupProps) { + const ctx = use() + const theme = useTheme() + const renderer = useRenderer() + 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 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" + ? [] + : 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 + const key = id() + if (key) ctx.setGroupExpanded(key, !expanded()) + } + const children = (mode: "normal" | "thought" | "tool") => ( + + ) + + return ( + 0 : tools().length > 0)} + > + + 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(() => { + return entries().find( + (entry) => + entry.type === "part" && entry.ref.messageID === ref.messageID && entry.ref.partID === ref.partID, + ) + }) + return ( + {(item) => {props.entry(item())}} + ) + }} + + + + ) +} + +function Children(props: GroupProps & { nodes: readonly SessionNode[]; mode: "normal" | "thought" | "tool" }) { + return ( + + {(node, 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; 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..37f15ff83bdb 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" @@ -100,18 +100,21 @@ import { findMessageBoundary, messageNavigationSlack } from "./message-navigatio import { stringWidth } from "../../util/string-width" import { useArgs } from "../../context/args" import { withTimestampedFallback } from "@opencode/util/session-title-fallback" -import { useSessionTabs } from "../../context/session-tabs" +import { useSessionTabs, type ScrollAnchor } 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 { useEntryAnchor } from "./anchor-view" +import { containsAnchor, createTimelineAnchors } from "./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 = createTimelineAnchors() const [navigationMessage, setNavigationMessage] = createSignal() const [navigationSlack, setNavigationSlack] = createSignal(0) const [firstJump, setFirstJump] = createSignal<() => void>() @@ -353,7 +356,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) => { @@ -451,7 +454,14 @@ export function Session(props: { } 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) @@ -472,20 +482,31 @@ 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: ScrollAnchor | undefined + let anchor: ScrollAnchor | undefined + for (const child of mounted) { + const item = { + target: child.target, + screenY: child.node.y - scroll.viewport.y, + } first ??= item - if (item.screenY <= 0) anchor = item + 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 if (anchor) sessionTabs.setScrollAnchor(sessionID, anchor) @@ -493,7 +514,7 @@ 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) => containsAnchor(row, anchor.target)) : -1 if (!anchor || index === -1) { scroll.scrollTo(scroll.scrollHeight) setAwayFromBottom(false) @@ -505,7 +526,7 @@ export function Session(props: { scroll.stickyScroll = false const restore = () => afterLayout(() => { - const boundary = scroll.getRenderable(anchor.messageID) + const boundary = anchors.get(anchor.target) if (!boundary) { sessionTabs.setScrollAnchor(sessionID, undefined) scroll.stickyScroll = true @@ -513,7 +534,7 @@ export function Session(props: { setAwayFromBottom(false) return } - const contentY = scroll.scrollTop + boundary.y - scroll.viewport.y + 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) { @@ -522,6 +543,18 @@ export function Session(props: { restore() return } + if (target > maximum) { + setNavigationSlack( + messageNavigationSlack({ + top: target, + viewportHeight: scroll.viewport.height, + scrollHeight: scroll.scrollHeight, + currentSlack: scroll.getRenderable(NAVIGATION_SLACK_ID)?.height ?? 0, + }), + ) + restore() + return + } scroll.scrollTo(target) updateAwayFromBottom() }) @@ -614,7 +647,7 @@ export function Session(props: { ensureAllRows(() => { const target = findMessageBoundary({ direction, - children: scroll.getChildren(), + children: anchors.messagePositions(), messages: messages(), scrollTop: scroll.scrollTop, viewportY: scroll.viewport.y, @@ -623,7 +656,7 @@ export function Session(props: { }) if (target) { - alignMessage(target.id, target.top) + jumpToMessage(target.id) dialog.clear() return } @@ -636,9 +669,9 @@ export function Session(props: { const jumpToMessage = (messageID: string) => ensureAllRows(() => { - const child = scroll.getRenderable(messageID) + const child = anchors.forMessage(messageID) if (!child) return - const y = scroll.scrollTop + child.y - scroll.viewport.y + 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))) }) @@ -1242,6 +1275,12 @@ export function Session(props: { return ( sessionTabs.groupExpanded(sessionID, groupID), + setGroupExpanded: (groupID, expanded) => { + sessionTabs.setGroupExpanded(sessionID, groupID, expanded) + afterLayout(saveScrollAnchor) + }, get width() { return contentWidth() }, @@ -1292,7 +1331,7 @@ export function Session(props: { foregroundColor: theme.border.default, }, }} - stickyScroll={!navigationMessage()} + stickyScroll={!navigationMessage() && !navigationSlack()} stickyStart="bottom" flexGrow={1} scrollAcceleration={scrollAcceleration()} @@ -1428,56 +1467,66 @@ type SessionRowViewProps = { } function SessionRowView(props: SessionRowViewProps) { + const [target, setTarget] = createSignal() + useEntryAnchor({ + entry: () => (props.row.type === "group" ? undefined : props.row), + node: target, + }) 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 +1751,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 +1781,7 @@ function SessionPartView(props: { partRef: PartRef; message: (messageID: string) /> - + )} @@ -1736,198 +1789,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 +3413,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-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..3a461f1d726b 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 { createTimelineAnchors } from "./anchors" export type PendingAction = "steer" | "queue" | "cancel" @@ -16,6 +17,9 @@ export const context = createContext<{ */ terminal: { width: number; height: number } sessionID: string + 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 new file mode 100644 index 000000000000..491d84a6780b --- /dev/null +++ b/packages/tui/test/cli/tui/group-view.test.tsx @@ -0,0 +1,184 @@ +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 { 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("retains nested expansion state and registers exact headers and parts", async () => { + addDefaultParsers(parsers.parsers) + const anchors = createTimelineAnchors() + const [expanded, setExpanded] = createStore>({}) + 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( + () => ( + + + + expanded[id], + setGroupExpanded: (id, value) => setExpanded(id, value), + thinkingMode: () => "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() + 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)).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(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(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) + setExpanded(outerID, false) + await app.renderOnce() + expect(app.captureCharFrame()).not.toContain("Target B") + expect(anchors.get(b)).toBeUndefined() + expect(anchors.get(outer)).toBeDefined() + expect(expanded[innerID]).toBe(true) + setExpanded(outerID, true) + 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)?.node.y).toBe(target?.y) + 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") + 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 { + app.renderer.destroy() + } + 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..bbcdd59f6f3f 100644 --- a/packages/tui/test/context/session-tabs.test.tsx +++ b/packages/tui/test/context/session-tabs.test.tsx @@ -592,13 +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 }) + 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() } @@ -612,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 new file mode 100644 index 000000000000..6ab157ba9b8d --- /dev/null +++ b/packages/tui/test/session-group-navigation.test.tsx @@ -0,0 +1,249 @@ +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, 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 () => ({}), + }, + 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 + ) + }) + 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, +)