mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-12 20:06:24 +00:00
Compare commits
2
Commits
v2
...
tree-navigation
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b087728684 | ||
|
|
4073d07a69 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user