Compare commits

...
2 Commits
12 changed files with 1156 additions and 288 deletions
+21 -5
View File
@@ -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<string, TabsState>
}
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<string>()
const scrollAnchors = new Map<string, ScrollAnchor>()
const [expandedGroups, setExpandedGroups] = createStore<Record<string, Record<string, boolean> | 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) })
@@ -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<SessionEntry | undefined>
node: Accessor<BoxRenderable | undefined>
}) {
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<BoxRenderable>()
useEntryAnchor({ entry: () => props.entry, node })
return (
<box ref={setNode} marginTop={props.marginTop} flexShrink={0}>
{props.children}
</box>
)
}
export function GroupAnchor(props: { groupID: string | undefined; active: boolean; children: JSX.Element }) {
const ctx = use()
const [node, setNode] = createSignal<BoxRenderable>()
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 (
<box ref={setNode} flexDirection="column" flexShrink={0}>
{props.children}
</box>
)
}
@@ -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<Renderable, "y" | "height" | "isDestroyed">
}
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<SessionNode, { type: "group" }>, 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<SessionNode, { type: "group" }>,
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<string, Anchor>()
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<string>()
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,
}
}
@@ -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<SessionNode, { type: "group" }>
level: number
completed: boolean
pending: readonly PartRef[]
pendingOutside?: boolean
imagesOutside?: boolean
}
export function SessionGroupView(props: Renderers & { row: SessionGroup }) {
return (
<Group
{...props}
node={props.row}
level={0}
completed={props.row.completed}
pending={props.row.kind === "exploration" ? props.row.pending : []}
/>
)
}
function Group(props: GroupProps) {
// Keep kind-specific hover/title state isolated during reconciliation.
return (
<Show when={props.node.kind} keyed>
{(_kind) => <GroupContent {...props} />}
</Show>
)
}
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<Record<string, number>>((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") => (
<Children {...props} nodes={props.node.children} mode={mode} />
)
return (
<GroupAnchor
groupID={id()}
active={grouped() && (props.node.kind === "reasoning" ? thoughts().length > 0 : tools().length > 0)}
>
<Show
when={props.node.kind === "reasoning"}
fallback={
<Show when={grouped()} fallback={children("normal")}>
<Show when={tools().length > 0}>
<InlineToolRow
icon={completed() ? "→" : "✱"}
color={hover() ? theme.text.default : theme.text.subdued}
complete={completed()}
pending={label()}
spinner={!completed()}
onMouseOver={() => setHover(true)}
onMouseOut={() => setHover(false)}
onMouseUp={toggle}
>
{label()}
</InlineToolRow>
</Show>
<Show when={expanded() && tools().length > 0}>{children("tool")}</Show>
<Show when={!props.imagesOutside}>{props.images(tools())}</Show>
</Show>
}
>
<Show when={thoughts().length > 0}>
<Show when={grouped()} fallback={children("normal")}>
<InlineToolRow
icon={expanded() ? "-" : "+"}
color={
!props.completed
? theme.text.default
: hover() || expanded()
? theme.text.feedback.warning.default
: RGBA.fromValues(
theme.text.feedback.warning.default.r,
theme.text.feedback.warning.default.g,
theme.text.feedback.warning.default.b,
0.6,
)
}
complete={props.completed}
pending={latest() ? `Thinking: ${latest()}` : "Thinking"}
spinner={!props.completed}
onMouseOver={() => setHover(true)}
onMouseOut={() => setHover(false)}
onMouseUp={toggle}
>
{props.completed ? "Thought" : latest() ? `Thinking: ${latest()}` : "Thinking"}
<Show when={props.completed && !expanded() && latest()}>: {latest()}</Show>
<Show when={props.completed && thoughts().length > 1}> · {thoughts().length} steps</Show>
<Show when={props.completed && duration()}> · {Locale.duration(duration())}</Show>
</InlineToolRow>
<Show when={expanded()}>
<box paddingLeft={3}>{children("thought")}</box>
</Show>
</Show>
</Show>
</Show>
<Show when={!props.pendingOutside}>
<For each={props.pending}>
{(ref) => {
const leaf = createMemo(() => {
return entries().find(
(entry) =>
entry.type === "part" && entry.ref.messageID === ref.messageID && entry.ref.partID === ref.partID,
)
})
return (
<Show when={leaf()}>{(item) => <EntryAnchor entry={item()}>{props.entry(item())}</EntryAnchor>}</Show>
)
}}
</For>
</Show>
</GroupAnchor>
)
}
function Children(props: GroupProps & { nodes: readonly SessionNode[]; mode: "normal" | "thought" | "tool" }) {
return (
<For each={props.nodes}>
{(node, index) => {
return (
<Switch>
<Match when={node.type === "group" ? node : undefined}>
{(node) => (
<Group
{...props}
node={node()}
level={props.level + 1}
pendingOutside
imagesOutside={props.imagesOutside || props.mode === "tool"}
completed={
props.completed ||
props.nodes
.slice(index() + 1)
.some((next) => next.type === "group" || !isPending(next.entry, props.pending)) ||
(node().kind === "reasoning" && reasoningCompleted(node().children, props.message))
}
/>
)}
</Match>
<Match when={node.type === "entry" ? node : undefined}>
{(node) => (
<Show when={!isPending(node().entry, props.pending)}>
<Show
when={props.mode === "thought"}
fallback={
<EntryAnchor entry={node().entry}>
{props.entry(node().entry, props.mode === "tool" ? false : undefined)}
</EntryAnchor>
}
>
<ThoughtEntry entry={node().entry} message={props.message} />
</Show>
</Show>
)}
</Match>
</Switch>
)
}}
</For>
)
}
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 (
<Show when={content()}>
<EntryAnchor entry={props.entry} marginTop={1}>
<box
border={["left"]}
customBorderChars={SplitBorder.customBorderChars}
borderColor={theme.raise(theme.background.surface.offset)}
paddingLeft={1}
>
<code
filetype="markdown"
drawUnstyledText={false}
streaming={part()?.time?.completed === undefined && message()?.time.completed === undefined}
syntaxStyle={thinkingSyntax()}
content={content()}
conceal={ctx.markdownMode() === "rendered"}
fg={theme.text.subdued}
/>
</box>
</EntryAnchor>
</Show>
)
}
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
})
}
@@ -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<SessionEntry, GroupKind>
export type SessionGroup = {
type: "group"
children: readonly GroupNode<SessionEntry, GroupKind>[]
size: number
+121 -276
View File
@@ -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<string>()
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<PromptRef>()
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 (
<context.Provider
value={{
anchors,
groupExpanded: (groupID) => 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<BoxRenderable>()
useEntryAnchor({
entry: () => (props.row.type === "group" ? undefined : props.row),
node: target,
})
return (
<box id={sessionRowID(props.row, props.boundaryID)} marginTop={1} flexShrink={0}>
<box ref={setTarget} id={sessionRowID(props.row, props.boundaryID)} marginTop={1} flexShrink={0}>
<Switch>
<Match when={props.row.type === "message" ? props.row : undefined}>
{(row) => (
<Show when={props.message(row().messageID)}>{(message) => <SessionMessageView message={message()} />}</Show>
)}
</Match>
<Match when={props.row.type === "compaction-queued"}>
<CompactionQueued />
</Match>
<Match when={props.row.type === "part" ? props.row : undefined}>
{(row) => <SessionPartView partRef={row().ref} message={props.message} />}
</Match>
<Match when={props.row.type === "group" && props.row.kind === "reasoning" ? props.row : undefined}>
{(row) => (
<SessionReasoningGroupView refs={groupRefs(row())} completed={row().completed} message={props.message} />
)}
</Match>
<Match when={props.row.type === "group" && props.row.kind === "exploration" ? props.row : undefined}>
<Match when={props.row.type === "group" ? props.row : undefined}>
{(row) => (
<SessionGroupView
refs={groupRefs(row())}
pending={row().pending}
completed={row().completed}
row={row()}
message={props.message}
entry={(entry, images) => <SessionEntryView row={entry} message={props.message} images={images} />}
images={(parts) => <ToolImages parts={parts} />}
/>
)}
</Match>
<Match when={props.row.type === "assistant-footer" ? props.row : undefined}>
{(row) => (
<Show when={props.message(row().messageID)}>
{(message) => (
<Show when={message().type === "assistant"}>
<AssistantFooter message={message() as SessionMessageAssistant} />
</Show>
)}
</Show>
)}
</Match>
<Match when={props.row.type === "turn-usage" ? props.row : undefined}>
{(row) => (
<TurnTokenUsage messageIDs={row().messageIDs} previousCache={row().previousCache} message={props.message} />
)}
<Match when={props.row.type !== "group" ? props.row : undefined}>
{(row) => <SessionEntryView row={row()} message={props.message} />}
</Match>
</Switch>
</box>
)
}
function SessionEntryView(props: { row: SessionEntry; message: SessionRowViewProps["message"]; images?: boolean }) {
return (
<Switch>
<Match when={props.row.type === "message" ? props.row : undefined}>
{(row) => (
<Show when={props.message(row().messageID)}>{(message) => <SessionMessageView message={message()} />}</Show>
)}
</Match>
<Match when={props.row.type === "compaction-queued"}>
<CompactionQueued />
</Match>
<Match when={props.row.type === "part" ? props.row : undefined}>
{(row) => <SessionPartView partRef={row().ref} message={props.message} images={props.images} />}
</Match>
<Match when={props.row.type === "assistant-footer" ? props.row : undefined}>
{(row) => (
<Show when={props.message(row().messageID)}>
{(message) => (
<Show when={message().type === "assistant"}>
<AssistantFooter message={message() as SessionMessageAssistant} />
</Show>
)}
</Show>
)}
</Match>
<Match when={props.row.type === "turn-usage" ? props.row : undefined}>
{(row) => (
<TurnTokenUsage messageIDs={row().messageIDs} previousCache={row().previousCache} message={props.message} />
)}
</Match>
</Switch>
)
}
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)
/>
</Match>
<Match when={item().type === "tool"}>
<ToolPart part={item() as SessionMessageAssistantTool} />
<ToolPart part={item() as SessionMessageAssistantTool} images={props.images} />
</Match>
</Switch>
)}
@@ -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 (
<Show when={parts().length > 0}>
<Show
when={ctx.thinkingMode() === "hide"}
fallback={<For each={props.refs}>{(ref) => <SessionPartView partRef={ref} message={props.message} />}</For>}
>
<box flexDirection="column" flexShrink={0}>
<InlineToolRow
icon={expanded() ? "-" : "+"}
color={
!props.completed
? theme.text.default
: hover() || expanded()
? theme.text.feedback.warning.default
: RGBA.fromValues(
theme.text.feedback.warning.default.r,
theme.text.feedback.warning.default.g,
theme.text.feedback.warning.default.b,
0.6,
)
}
complete={props.completed}
pending={latest() ? `Thinking: ${latest()}` : "Thinking"}
spinner={!props.completed}
onMouseOver={() => setHover(true)}
onMouseOut={() => setHover(false)}
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
setExpanded((value) => !value)
}}
>
{props.completed ? "Thought" : latest() ? `Thinking: ${latest()}` : "Thinking"}
<Show when={props.completed && !expanded() && latest()}>: {latest()}</Show>
<Show when={props.completed && parts().length > 1}> · {parts().length} steps</Show>
<Show when={props.completed && duration()}> · {Locale.duration(duration())}</Show>
</InlineToolRow>
<Show when={expanded()}>
<box paddingLeft={3}>
<For each={props.refs}>
{(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 (
<Show when={content()}>
<box marginTop={1}>
<box
border={["left"]}
customBorderChars={SplitBorder.customBorderChars}
borderColor={theme.raise(theme.background.surface.offset)}
paddingLeft={1}
>
<code
filetype="markdown"
drawUnstyledText={false}
streaming={part()?.time?.completed === undefined && message()?.time.completed === undefined}
syntaxStyle={thinkingSyntax()}
content={content()}
conceal={ctx.markdownMode() === "rendered"}
fg={theme.text.subdued}
/>
</box>
</box>
</Show>
)
}}
</For>
</box>
</Show>
</box>
</Show>
</Show>
)
}
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<Record<string, number>>((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 (
<Show when={grouped().length > 0 || pending().length > 0}>
<Show
when={ctx.groupExploration()}
fallback={<For each={[...grouped(), ...pending()]}>{(part) => <ToolPart part={part} />}</For>}
>
<Show when={grouped().length > 0}>
<InlineToolRow
icon={completed() ? "→" : "✱"}
color={hover() ? theme.text.default : theme.text.subdued}
complete={completed()}
pending={label()}
spinner={!completed()}
onMouseOver={() => setHover(true)}
onMouseOut={() => setHover(false)}
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
setExpanded((value) => !value)
}}
>
{label()}
</InlineToolRow>
</Show>
<Show when={expanded() && grouped().length > 0}>
<For each={grouped()}>{(part) => <ToolPart part={part} images={false} />}</For>
</Show>
<ToolImages parts={grouped()} />
<For each={pending()}>{(part) => <ToolPart part={part} />}</For>
</Show>
</Show>
)
}
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<string, unknown> | 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")
@@ -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
@@ -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<typeof createTimelineAnchors>
groupExpanded: (groupID: string) => boolean | undefined
setGroupExpanded: (groupID: string, expanded: boolean) => void
thinkingMode: () => ThinkingMode
markdownMode: () => "source" | "rendered"
groupExploration: () => boolean
+77
View File
@@ -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()
})
@@ -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<Record<string, boolean>>({})
const config = createTuiResolvedConfig({ animations: false })
const messages = new Map<string, SessionMessageAssistant>(
["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<SessionGroup>({
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(
() => (
<TestTuiContexts>
<ConfigProvider config={config}>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<context.Provider
value={{
width: 40,
terminal: { width: 40, height: 24 },
sessionID: "fixture",
anchors,
groupExpanded: (id) => 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,
}}
>
<box paddingTop={2}>
<SessionGroupView
row={row}
message={(id) => messages.get(id)}
images={() => <text>Image previews</text>}
entry={(entry) =>
entry.type === "part" && entry.ref.messageID === "b" ? (
<text ref={(node) => (target = node)}>Target B</text>
) : (
<text>A wrapped entry with enough text to occupy more than one terminal line</text>
)
}
/>
</box>
</context.Provider>
</ThemeProvider>
</ConfigProvider>
</TestTuiContexts>
),
{ 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([])
})
@@ -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.
@@ -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,
)