feat(tui): add transcript verbosity levels (#50941)

This commit is contained in:
James Long
2026-09-23 16:54:42 -04:00
committed by GitHub
parent 7f93d0018c
commit bbbac2507b
15 changed files with 772 additions and 132 deletions
@@ -84,6 +84,14 @@ export const settings: Setting[] = [
values: ["none", "auto"],
keywords: ["transcript", "messages", "reads", "searches"],
},
{
title: "Transcript verbosity",
category: "Session",
path: ["session", "verbosity"],
default: "medium",
values: ["low", "medium", "high"],
keywords: ["detail", "activity", "summary", "transcript"],
},
{
title: "Transcript images",
category: "Session",
+3
View File
@@ -154,6 +154,9 @@ export const Info = Schema.Struct({
grouping: Schema.optional(Schema.Literals(["auto", "none"])).annotate({
description: "Group related transcript items automatically or render each item separately",
}),
verbosity: Schema.optional(Schema.Literals(["low", "medium", "high"])).annotate({
description: "Transcript detail level: low summarizes each run of tools and thoughts, high opens exploration and instruction groups",
}),
image_preview: Schema.optional(Schema.Boolean).annotate({
description: "Show user attachment and tool-result images in the session transcript",
}),
+1
View File
@@ -131,6 +131,7 @@ export const Definitions = {
"session.queued_prompts": keybind("<leader>q", "Manage queued prompts"),
"queued_prompt.delete": keybind("ctrl+d", "Delete queued prompt"),
"session.toggle.exploration_grouping": keybind("none", "Toggle related tool call grouping"),
"session.verbosity.cycle": keybind("none", "Cycle transcript verbosity"),
"session.child.first": keybind("down", "Toggle subagent picker"),
"session.child.next": keybind("right", "Go to next child session"),
"session.child.previous": keybind("left", "Go to previous child session"),
@@ -0,0 +1,93 @@
import type {
SessionMessageAssistant,
SessionMessageAssistantReasoning,
SessionMessageAssistantTool,
SessionMessageInfo,
} from "@opencode/client"
import { canonicalToolName, executeCalls } from "../../util/tool-display"
import { visitEntries } from "./anchor-view"
import { instructionPaths, type PartRef, type SessionEntry, type SessionNode } from "./grouping/session"
import { reasoningContent } from "./message-parts"
import { resolvePart } from "./rows"
type Item = {
message: SessionMessageAssistant
part: SessionMessageAssistantReasoning | SessionMessageAssistantTool
}
/** Summary for an activity group's subtree; permission-blocked tools are left out. */
export function summarizeActivity(
node: Extract<SessionNode, { type: "group" }>,
message: (messageID: string) => SessionMessageInfo | undefined,
pending: readonly PartRef[] = [],
) {
const entries: SessionEntry[] = []
visitEntries(node.children, (entry) => entries.push(entry))
const items = entries.flatMap((entry) => {
if (entry.type !== "part") return []
if (pending.some((ref) => ref.messageID === entry.ref.messageID && ref.partID === entry.ref.partID)) return []
const item = message(entry.ref.messageID)
if (item?.type !== "assistant") return []
const part = resolvePart(item, entry.ref.partID)
return part?.type === "reasoning" || part?.type === "tool" ? [{ message: item, part }] : []
})
const files = new Set(
entries.flatMap((entry) => (entry.type === "message" ? instructionPaths(message(entry.messageID)) : [])),
)
return activitySummary(items, files.size)
}
/**
* Low verbosity's activity summary, e.g. "3 commands, 1 edit, 2 thoughts, 4 reads".
* The label counts only finished work; running items are reported through `active`.
* Code-mode `execute` counts its finished nested calls rather than itself.
* Instructions count distinct loaded files, matching the instruction subgroup.
*/
export function activitySummary(items: readonly Item[], instructions: number) {
const counts = { command: 0, edit: 0, thought: 0, read: 0, tool: 0, instruction: instructions }
items.forEach((item) => {
if (item.part.type === "reasoning") {
// Redacted-only reasoning renders nothing, so it isn't a visible thought.
if (!isActive(item) && reasoningContent(item.part)) counts.thought++
return
}
const name = canonicalToolName(item.part.name)
if (name === "execute") {
executeCalls(item.part.state.status === "streaming" ? undefined : item.part.state.metadata?.toolCalls).forEach(
(call) => {
if (call.status === "running") return
if (canonicalToolName(call.tool) === "read") counts.read++
else counts.tool++
},
)
return
}
if (isActive(item)) return
if (name === "shell") counts.command++
else if (name === "read") counts.read++
else if (name === "edit" || name === "write" || name === "patch") counts.edit++
else counts.tool++
})
return {
label: Object.entries(counts)
.filter(([, count]) => count > 0)
.map(([name, count]) => `${count} ${name}${count === 1 ? "" : "s"}`)
.join(", "),
active: items.some(isActive),
failed: items.some(failed),
}
}
function isActive(item: Item) {
if (item.part.type === "reasoning")
return item.part.time?.completed === undefined && item.message.time.completed === undefined
return item.part.state.status === "streaming" || item.part.state.status === "running"
}
function failed(item: Item) {
if (item.part.type !== "tool") return false
if (item.part.state.status === "error") return true
if (canonicalToolName(item.part.name) !== "execute" || item.part.state.status === "streaming") return false
const metadata = item.part.state.metadata
return metadata?.error === true || executeCalls(metadata?.toolCalls).some((call) => call.status === "error")
}
+144 -35
View File
@@ -8,7 +8,8 @@ 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 { instructionPaths, type PartRef, type SessionEntry, type SessionGroup, type SessionNode } from "./grouping/session"
import { summarizeActivity } from "./activity-summary"
import { InlineToolRow, reasoningContent, toolDisplay } from "./message-parts"
import { use } from "./render-context"
import { resolvePart } from "./rows"
@@ -36,7 +37,7 @@ export function SessionGroupView(props: Renderers & { row: SessionGroup }) {
node={props.row}
level={0}
completed={props.row.completed}
pending={props.row.kind === "exploration" ? props.row.pending : []}
pending={props.row.kind === "exploration" || props.row.kind === "activity" ? props.row.pending : []}
/>
)
}
@@ -45,7 +46,16 @@ function Group(props: GroupProps) {
// Keep kind-specific hover/title state isolated during reconciliation.
return (
<Show when={props.node.kind} keyed>
{(_kind) => <GroupContent {...props} />}
{(kind) => (
<Switch fallback={<GroupContent {...props} />}>
<Match when={kind === "activity"}>
<ActivityGroup {...props} />
</Match>
<Match when={kind === "instructions"}>
<InstructionsGroup {...props} />
</Match>
</Switch>
)}
</Show>
)
}
@@ -54,17 +64,11 @@ 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 disclosure = useDisclosure(props)
const id = disclosure.id
const expanded = disclosure.expanded
const [hover, setHover] = createSignal(false)
const entries = createMemo(() => {
const result: SessionEntry[] = []
visitEntries(props.node.children, (entry) => result.push(entry))
return result
})
const entries = createMemo(() => descendants(props.node))
const refs = createMemo(() =>
entries().flatMap((entry) => (entry.type === "part" && !isPending(entry, props.pending) ? [entry.ref] : [])),
)
@@ -112,20 +116,18 @@ function GroupContent(props: GroupProps) {
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
const name =
tool === "grep" || tool === "glob" || tool === "websearch" ? "search" : tool === "webfetch" ? "fetch" : 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`}`,
([name, count]) =>
`${count} ${count === 1 ? name : name === "search" || name === "fetch" ? `${name}es` : `${name}s`}`,
)
return `${completed() ? "Explored" : "Exploring"} — ${names.join(", ")}`
return `${completed() ? "Explored" : "Exploring"}: ${names.join(", ")}`
})
const toggle = () => {
if (renderer.getSelection()?.getSelectedText()) return
const key = id()
if (key) ctx.setGroupExpanded(key, !expanded())
}
const toggle = disclosure.toggle
const children = (mode: "normal" | "thought" | "tool") => (
<Children {...props} nodes={props.node.children} mode={mode} />
)
@@ -192,25 +194,132 @@ function GroupContent(props: GroupProps) {
</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>
<PendingEntries {...props} entries={entries()} />
</GroupAnchor>
)
}
/** Low verbosity: one summary for a run of tools, thoughts and instruction loads. */
function ActivityGroup(props: GroupProps) {
const theme = useTheme()
const disclosure = useDisclosure(props)
const [hover, setHover] = createSignal(false)
const entries = createMemo(() => descendants(props.node))
const summary = createMemo(() => summarizeActivity(props.node, props.message, props.pending))
return (
<GroupAnchor groupID={disclosure.id()} active={summary().label !== ""}>
{/* Until something finishes there is nothing to summarize; show the live items, spaced like Medium. */}
<Show
when={summary().label}
fallback={
<box flexDirection="column" gap={1}>
<Children {...props} nodes={props.node.children} mode="normal" />
</box>
}
>
<InlineToolRow
icon={summary().failed ? "✗" : disclosure.expanded() ? "−" : "+"}
iconColor={summary().failed ? theme.text.feedback.error.base : undefined}
color={hover() ? theme.text.base : theme.text.muted}
complete={true}
pending={summary().label}
spinner={!disclosure.expanded() && summary().active}
onMouseOver={() => setHover(true)}
onMouseOut={() => setHover(false)}
onMouseUp={disclosure.toggle}
>
{summary().label}
</InlineToolRow>
<Show when={disclosure.expanded()}>
<box flexDirection="column" gap={1} marginTop={1}>
<Children {...props} nodes={props.node.children} mode="normal" />
</box>
</Show>
</Show>
<PendingEntries {...props} entries={entries()} />
</GroupAnchor>
)
}
/** Consecutive instruction loads, summarized by the number of distinct files. */
function InstructionsGroup(props: GroupProps) {
const theme = useTheme()
const disclosure = useDisclosure(props)
const [hover, setHover] = createSignal(false)
const files = createMemo(
() =>
new Set(
descendants(props.node).flatMap((entry) =>
entry.type === "message" ? instructionPaths(props.message(entry.messageID)) : [],
),
).size,
)
return (
<GroupAnchor groupID={disclosure.id()} active={files() > 0}>
<InlineToolRow
icon="◈"
color={hover() ? theme.text.base : theme.text.muted}
complete={true}
pending=""
onMouseOver={() => setHover(true)}
onMouseOut={() => setHover(false)}
onMouseUp={disclosure.toggle}
>
Instructions: {files()} {files() === 1 ? "file" : "files"}
</InlineToolRow>
<Show when={disclosure.expanded()}>
<box flexDirection="column" gap={1} marginTop={1}>
<Children {...props} nodes={props.node.children} mode="normal" />
</box>
</Show>
</GroupAnchor>
)
}
function useDisclosure(props: GroupProps) {
const ctx = use()
const renderer = useRenderer()
const id = createMemo(() => groupID(props.node, props.level))
const expanded = () => {
const key = id()
return key ? ctx.groupExpanded(key, props.node.kind) : false
}
return {
id,
expanded,
toggle() {
if (renderer.getSelection()?.getSelectedText()) return
const key = id()
if (key) ctx.setGroupExpanded(key, !expanded())
},
}
}
function descendants(node: Extract<SessionNode, { type: "group" }>) {
const result: SessionEntry[] = []
visitEntries(node.children, (entry) => result.push(entry))
return result
}
/** Permission-blocked tools stay visible below the root group, even when collapsed. */
function PendingEntries(props: GroupProps & { entries: readonly SessionEntry[] }) {
return (
<Show when={!props.pendingOutside}>
<For each={props.pending}>
{(ref) => {
const leaf = createMemo(() =>
props.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>
)
}
function Children(props: GroupProps & { nodes: readonly SessionNode[]; mode: "normal" | "thought" | "tool" }) {
return (
<For each={props.nodes}>
@@ -1,4 +1,4 @@
import type { SessionMessageAssistant } from "@opencode/client"
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode/client"
import { groupEntries, mergeGroups, splitGroups, type GroupNode } from "./tree"
export type PartRef = {
@@ -18,14 +18,18 @@ export type SessionEntry =
| { type: "assistant-footer"; messageID: string }
| { type: "turn-usage"; messageIDs: string[]; previousCache?: CacheUsage }
export type GroupKind = "reasoning" | "exploration"
export type GroupKind = "activity" | "reasoning" | "exploration" | "instructions"
export type SessionNode = GroupNode<SessionEntry, GroupKind>
export type SessionGroup = {
type: "group"
children: readonly GroupNode<SessionEntry, GroupKind>[]
size: number
completed: boolean
} & ({ kind: "reasoning" } | { kind: "exploration"; pending: PartRef[] })
} & ({ kind: "reasoning" | "instructions" } | { kind: "exploration" | "activity"; pending: PartRef[] })
/** Experimental transcript detail; undefined selects the default production rules. */
export type Verbosity = "low" | "medium" | "high"
export const defaultVerbosity: Verbosity = "medium"
export type SessionRow = SessionEntry | SessionGroup
@@ -37,12 +41,13 @@ export type AppendPart =
export type ProjectionEntry = {
entry: SessionEntry
part?: AppendPart
path?: readonly GroupKind[]
closesPrevious?: boolean
}
/** Hydrate a fresh history batch in one pass rather than merging one leaf at a time. */
export function projectEntries(entries: ProjectionEntry[]): SessionRow[] {
const nodes = groupEntries(entries, (item) => (item.part ? partPath(item.part) : []))
const nodes = groupEntries(entries, (item) => item.path ?? [])
return nodes.map((node, index) => {
if (node.type === "entry") return node.entry.entry
const next = nodes[index + 1]
@@ -55,25 +60,59 @@ export function projectEntries(entries: ProjectionEntry[]): SessionRow[] {
child.entry.part?.type === "reasoning" &&
child.entry.part.time?.completed !== undefined,
))
const group = { ...node, children: node.children.map(unwrap), completed }
return node.kind === "reasoning" ? { ...group, kind: "reasoning" } : { ...group, kind: "exploration", pending: [] }
return sessionGroup({ ...node, children: node.children.map(unwrap) }, completed)
})
}
function sessionGroup(node: Extract<SessionNode, { type: "group" }>, completed: boolean): SessionGroup {
if (node.kind === "exploration" || node.kind === "activity")
return { ...node, kind: node.kind, pending: [], completed }
return { ...node, kind: node.kind, completed }
}
function unwrap(node: GroupNode<ProjectionEntry, GroupKind>): GroupNode<SessionEntry, GroupKind> {
if (node.type === "entry") return { ...node, entry: node.entry.entry }
return { ...node, children: node.children.map(unwrap) }
}
function partPath(part: AppendPart): readonly GroupKind[] {
if (part.type === "reasoning") return ["reasoning"]
if (part.type === "tool" && ["read", "glob", "grep"].includes(part.name.toLowerCase())) return ["exploration"]
return []
const explorationTools = new Set(["read", "glob", "grep", "webfetch", "websearch"])
/**
* Grouping path for an assistant part. Adjacent thoughts group, as do reads, searches
* and web fetches. Low wraps every run of tools and thoughts in one activity summary.
*/
export function partPath(part: AppendPart, verbosity: Verbosity): readonly GroupKind[] {
const activity: GroupKind[] = verbosity === "low" && part.type !== "text" ? ["activity"] : []
if (part.type === "reasoning") return [...activity, "reasoning"]
if (part.type === "tool" && explorationTools.has(part.name.toLowerCase())) return [...activity, "exploration"]
return activity
}
/** Instruction loads group; other messages stand alone. */
export function messagePath(message: SessionMessageInfo, verbosity: Verbosity): readonly GroupKind[] {
if (instructionPaths(message).length === 0) return []
return verbosity === "low" ? ["activity", "instructions"] : ["instructions"]
}
/** Files loaded by an instruction message; one load can carry several. */
export function instructionPaths(message: SessionMessageInfo | undefined): string[] {
if (message?.type !== "synthetic") return []
const instruction = message.metadata?.instruction
if (typeof instruction !== "object" || instruction === null || Array.isArray(instruction)) return []
return Array.isArray(instruction.paths)
? instruction.paths.filter((path): path is string => typeof path === "string")
: []
}
/** Production rules only: keep lifecycle/status decisions outside the tree engine. */
export function append(rows: SessionRow[], ref: PartRef, part: AppendPart, index = rows.length) {
const [node] = groupEntries<SessionEntry, GroupKind>([{ type: "part", ref }], () => partPath(part))
export function append(
rows: SessionRow[],
ref: PartRef,
part: AppendPart,
index = rows.length,
verbosity = defaultVerbosity,
) {
const [node] = groupEntries<SessionEntry, GroupKind>([{ type: "part", ref }], () => partPath(part, verbosity))
if (node.type === "entry") {
completePrevious(rows, index)
rows.splice(index, 0, node.entry)
@@ -96,9 +135,7 @@ export function append(rows: SessionRow[], ref: PartRef, part: AppendPart, index
rows.splice(
index,
0,
node.kind === "reasoning"
? { ...node, kind: "reasoning", completed: part.type === "reasoning" && part.time?.completed !== undefined }
: { ...node, kind: "exploration", pending: [], completed: false },
sessionGroup(node, node.kind === "reasoning" && part.type === "reasoning" && part.time?.completed !== undefined),
)
}
@@ -109,7 +146,7 @@ export function completePrevious(rows: SessionRow[], index = rows.length) {
/** Part references for an existing production subgroup, not a flat timeline. */
export function groupRefs(row: SessionGroup, includePending = false): PartRef[] {
const pending = !includePending && row.kind === "exploration" ? row.pending : []
const pending = !includePending && (row.kind === "exploration" || row.kind === "activity") ? row.pending : []
const visit = (nodes: readonly GroupNode<SessionEntry, GroupKind>[]): PartRef[] =>
nodes.flatMap((node) => {
if (node.type === "group") return visit(node.children)
@@ -123,12 +160,15 @@ export function groupRefs(row: SessionGroup, includePending = false): PartRef[]
export function partitionPending(rows: SessionRow[], pending: Set<string>) {
rows.forEach((row) => {
if (row.type !== "group" || row.kind !== "exploration") return
if (row.type !== "group" || (row.kind !== "exploration" && row.kind !== "activity")) return
// The production exploration rule creates direct part children. Preserve the
// existing stable partition order when permissions are admitted or dismissed.
const blocked = (node: GroupNode<SessionEntry, GroupKind>) =>
node.type === "entry" && node.entry.type === "part" && pending.has(node.entry.ref.partID)
row.children = [...row.children.filter((node) => !blocked(node)), ...row.children.filter(blocked)]
// Activity groups render blocked tools outside the summary without reordering.
if (row.kind === "exploration") {
const blocked = (node: GroupNode<SessionEntry, GroupKind>) =>
node.type === "entry" && node.entry.type === "part" && pending.has(node.entry.ref.partID)
row.children = [...row.children.filter((node) => !blocked(node)), ...row.children.filter(blocked)]
}
row.pending = groupRefs(row, true).filter((ref) => pending.has(ref.partID))
})
}
+30 -4
View File
@@ -116,11 +116,12 @@ import { isRecord } from "../../util/record"
import { createHistoryPrepend } from "./history"
import { context, use, type PendingAction } from "./render-context"
import { INLINE_TOOL_ICON_WIDTH, InlineToolRow, ReasoningPart, TextPart, toolDisplay } from "./message-parts"
import type { SessionEntry } from "./grouping/session"
import { defaultVerbosity, type GroupKind, type SessionEntry } from "./grouping/session"
import { SessionGroupView } from "./group-view"
import { useEntryAnchor } from "./anchor-view"
import { containsAnchor, createTimelineAnchors } from "./anchors"
import { rowsAfter, rowsBefore, rowWeight } from "./mount-budget"
import { summarizeActivity } from "./activity-summary"
export { InlineToolRow } from "./message-parts"
export { toolDisplay } from "./message-parts"
@@ -234,6 +235,13 @@ export function Session(props: {
const markdownMode = createMemo(() => config.session?.markdown ?? "rendered")
const diffWrapMode = createMemo(() => config.diffs?.wrap ?? "word")
const groupExploration = createMemo(() => config.session?.grouping !== "none")
const verbosity = createMemo(() => config.session?.verbosity ?? defaultVerbosity)
// High opens exploration and instruction summaries by default; everything else starts collapsed.
const groupExpanded = (groupID: string, kind: GroupKind) =>
sessionTabs.groupExpanded(sessionID, groupID) ??
(verbosity() === "high" && (kind === "exploration" || kind === "instructions"))
const groupedKind = (kind: GroupKind) =>
kind === "reasoning" ? thinkingMode() === "hide" : kind === "exploration" ? groupExploration() : true
Keymap.createLayer(() => ({
priority: 10,
@@ -395,8 +403,11 @@ export function Session(props: {
const weights = createMemo(() =>
rows.map((row) =>
rowWeight(row, {
expanded: (groupID) => sessionTabs.groupExpanded(sessionID, groupID) ?? false,
grouped: (kind) => (kind === "reasoning" ? thinkingMode() === "hide" : groupExploration()),
expanded: groupExpanded,
grouped: groupedKind,
summarized: (node) =>
node.kind !== "activity" ||
summarizeActivity(node, (messageID) => data.session.message.get(route.sessionID, messageID)).label !== "",
}),
),
)
@@ -1045,6 +1056,21 @@ export function Session(props: {
dialog.clear()
},
},
{
title: `Transcript verbosity: ${Locale.titlecase(verbosity())}`,
id: "session.verbosity.cycle",
group: "Session",
run: () => {
const levels = ["low", "medium", "high"] as const
const next = levels[(levels.indexOf(verbosity()) + 1) % levels.length]
void configState
.update((draft) => {
draft.session = { ...draft.session, verbosity: next }
})
.catch(toast.error)
dialog.clear()
},
},
{
title: "Jump to last user message",
id: "session.messages_last_user",
@@ -1302,7 +1328,7 @@ export function Session(props: {
<context.Provider
value={{
anchors,
groupExpanded: (groupID) => sessionTabs.groupExpanded(sessionID, groupID),
groupExpanded,
setGroupExpanded: (groupID, expanded) => {
sessionTabs.setGroupExpanded(sessionID, groupID, expanded)
afterLayout(saveScrollAnchor)
@@ -9,14 +9,22 @@ import type { GroupKind, SessionNode, SessionRow } from "./grouping/session"
*/
export function rowWeight(
row: SessionRow,
input: { expanded: (groupID: string) => boolean; grouped: (kind: GroupKind) => boolean },
input: {
expanded: (groupID: string, kind: GroupKind) => boolean
grouped: (kind: GroupKind) => boolean
/** False when a collapsed group has nothing to summarize and renders its children instead. */
summarized: (node: Extract<SessionNode, { type: "group" }>) => boolean
},
) {
if (row.type !== "group") return 1
const visit = (node: Extract<SessionNode, { type: "group" }>, level: number): number => {
if (!input.grouped(node.kind)) return node.size
const children = () =>
node.children.reduce((total, child) => total + (child.type === "group" ? visit(child, level + 1) : 1), 0)
const id = groupID(node, level)
if (!id || !input.expanded(id)) return 1
return node.children.reduce((total, child) => total + (child.type === "group" ? visit(child, level + 1) : 1), 1)
if (!input.summarized(node)) return children()
if (!id || !input.expanded(id, node.kind)) return 1
return 1 + children()
}
return visit(row, 0)
}
@@ -4,6 +4,7 @@ import type { SessionInbox } from "@opencode/schema/session-inbox"
import type { useConfig } from "../../config"
import type { ThinkingMode } from "../../context/thinking"
import type { createTimelineAnchors } from "./anchors"
import type { GroupKind } from "./grouping/session"
export type PendingAction = "steer" | "queue" | "cancel"
@@ -18,7 +19,8 @@ export const context = createContext<{
terminal: { width: number; height: number }
sessionID: string
anchors: ReturnType<typeof createTimelineAnchors>
groupExpanded: (groupID: string) => boolean | undefined
/** Saved disclosure, falling back to the verbosity default for the group kind. */
groupExpanded: (groupID: string, kind: GroupKind) => boolean
setGroupExpanded: (groupID: string, expanded: boolean) => void
thinkingMode: () => ThinkingMode
markdownMode: () => "source" | "rendered"
+24 -5
View File
@@ -9,13 +9,17 @@ import {
completePrevious,
groupRefs,
hasPart,
messagePath,
partitionPending,
partPath,
projectEntries,
type AppendPart,
type CacheUsage,
type PartRef,
type ProjectionEntry,
type SessionRow,
type Verbosity,
defaultVerbosity,
} from "./grouping/session"
export type { CacheUsage, PartRef, SessionRow } from "./grouping/session"
@@ -46,6 +50,7 @@ export function createSessionRows(sessionID: Accessor<string>, onSynced?: (sessi
const [rows, setRows] = createStore<SessionRow[]>([])
const revertBoundary = () => data.session.get(sessionID())?.revert?.messageID
const turnTokens = () => Boolean(config.data.debug?.turn_tokens)
const verbosity = () => config.data.session?.verbosity ?? defaultVerbosity
function reduce() {
const messages = data.session.message.list(sessionID())
@@ -60,6 +65,7 @@ export function createSessionRows(sessionID: Accessor<string>, onSynced?: (sessi
boundary ? visible.filter((message) => message.id < boundary) : visible,
inputs,
turnTokens(),
verbosity(),
)
partitionPending(rows, pendingPermissions())
const position = rows.findIndex((row) => row.type === "message" && inputs.has(row.messageID))
@@ -165,7 +171,7 @@ export function createSessionRows(sessionID: Accessor<string>, onSynced?: (sessi
),
)
createEffect(on(turnTokens, () => setRows(reconcile(reduce())), { defer: true }))
createEffect(on([turnTokens, verbosity], () => setRows(reconcile(reduce())), { defer: true }))
const appendMessage = (messageID: string) =>
setRows(
@@ -184,7 +190,7 @@ export function createSessionRows(sessionID: Accessor<string>, onSynced?: (sessi
setRows(
produce((draft) => {
if (!hasPart(draft, ref)) {
append(draft, ref, part, queuedStart(draft))
append(draft, ref, part, queuedStart(draft), verbosity())
return
}
if (part.type !== "reasoning" || part.time?.completed === undefined) return
@@ -304,7 +310,12 @@ export function createSessionRows(sessionID: Accessor<string>, onSynced?: (sessi
return rows
}
export function reduceSessionRows(messages: SessionMessageInfo[], inputs = new Set<string>(), turnTokens = false) {
export function reduceSessionRows(
messages: SessionMessageInfo[],
inputs = new Set<string>(),
turnTokens = false,
verbosity: Verbosity = defaultVerbosity,
) {
const isInput = (message: SessionMessageInfo) => inputs.has(message.id)
const pendingCompactions = messages.filter((message) => message.type === "compaction" && message.status === "running")
const pending = new Set([...pendingCompactions.map((message) => message.id), ...inputs])
@@ -341,7 +352,11 @@ export function reduceSessionRows(messages: SessionMessageInfo[], inputs = new S
}
if (message.type === "synthetic" && !message.description?.trim()) return rows
if (message.type === "compaction" && message.status === "completed" && usage) usage.previousTurnCache = undefined
rows.push({ entry: { type: "message", messageID: message.id }, closesPrevious: !pending.has(message.id) })
rows.push({
entry: { type: "message", messageID: message.id },
path: messagePath(message, verbosity),
closesPrevious: !pending.has(message.id),
})
return rows
}
usage?.steps.push(message)
@@ -349,7 +364,11 @@ export function reduceSessionRows(messages: SessionMessageInfo[], inputs = new S
message.content.forEach((part) => {
const partID = part.type === "tool" ? part.id : `${part.type}:${ordinals[part.type]++}`
if ((part.type === "text" || part.type === "reasoning") && !part.text.trim()) return
rows.push({ entry: { type: "part", ref: { messageID: message.id, partID } }, part })
rows.push({
entry: { type: "part", ref: { messageID: message.id, partID } },
part,
path: partPath(part, verbosity),
})
})
const terminal = (message.finish && !["tool-calls", "unknown"].includes(message.finish)) || message.error
if (terminal || message.retry) {
+117 -48
View File
@@ -1,15 +1,16 @@
import { expect, test } from "bun:test"
import { testRender } from "@opentui/solid"
import type { JSX } from "solid-js"
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 type { SessionMessageAssistant, SessionMessageInfo } 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 type { SessionEntry, SessionGroup } from "../../../src/routes/session/grouping/session"
import { emptyThemeSource } from "../../fixture/fixture"
import { TestTuiContexts } from "../../fixture/tui-environment"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
@@ -60,52 +61,20 @@ test("retains nested expansion state and registers exact headers and parts", asy
],
})
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,
legacyTurns: () => false,
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 },
)
const app = await mount({
row,
anchors,
config,
expanded: (id) => expanded[id],
setExpanded: (id, value) => setExpanded(id, value),
message: (id) => messages.get(id),
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>
),
})
app.renderer.start()
const outerID = groupID(row, 0)
const inner = row.children[0]
@@ -183,3 +152,103 @@ test("retains nested expansion state and registers exact headers and parts", asy
}
expect(anchors.list()).toEqual([])
})
test("a low activity group with nothing finished spaces its live items", async () => {
const config = createTuiResolvedConfig({ animations: false })
const shell = (id: string) => ({
type: "tool" as const,
id,
name: "shell",
time: { created: 0 },
state: { status: "running" as const, input: {}, metadata: {} },
})
const message: SessionMessageAssistant = {
id: "a",
type: "assistant",
agent: "build",
model: { providerID: "fixture", id: "fixture" },
time: { created: 0 },
content: [shell("one"), shell("two")],
}
const app = await mount({
row: {
type: "group",
kind: "activity",
size: 2,
completed: false,
pending: [],
children: ["one", "two"].map((partID) => ({
type: "entry" as const,
size: 1,
entry: { type: "part" as const, ref: { messageID: "a", partID } },
})),
},
anchors: createTimelineAnchors(),
config,
expanded: () => false,
setExpanded: () => {},
message: () => message,
entry: (entry) => <text>{entry.type === "part" ? `Shell ${entry.ref.partID}` : ""}</text>,
})
try {
app.renderer.start()
await app.waitForFrame((frame) => frame.includes("Shell two"))
const lines = app.captureCharFrame().split("\n")
const one = lines.findIndex((line) => line.includes("Shell one"))
const two = lines.findIndex((line) => line.includes("Shell two"))
expect(two - one).toBe(2)
} finally {
app.renderer.destroy()
}
})
function mount(input: {
row: SessionGroup
anchors: ReturnType<typeof createTimelineAnchors>
config: ReturnType<typeof createTuiResolvedConfig>
expanded: (id: string) => boolean
setExpanded: (id: string, value: boolean) => void
message: (id: string) => SessionMessageInfo | undefined
entry: (entry: SessionEntry) => JSX.Element
}) {
return testRender(
() => (
<TestTuiContexts>
<ConfigProvider config={input.config}>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<context.Provider
value={{
width: 40,
terminal: { width: 40, height: 24 },
sessionID: "fixture",
anchors: input.anchors,
groupExpanded: input.expanded,
setGroupExpanded: input.setExpanded,
thinkingMode: () => "hide",
markdownMode: () => "rendered",
groupExploration: () => true,
legacyTurns: () => false,
diffWrapMode: () => "word",
models: () => [],
messageIndex: () => undefined,
config: input.config,
mutatePending: async () => true,
pendingDelivery: () => undefined,
}}
>
<box paddingTop={2}>
<SessionGroupView
row={input.row}
message={input.message}
images={() => <text>Image previews</text>}
entry={input.entry}
/>
</box>
</context.Provider>
</ThemeProvider>
</ConfigProvider>
</TestTuiContexts>
),
{ width: 40, height: 24 },
)
}
+25 -11
View File
@@ -1,6 +1,6 @@
import { expect, test } from "bun:test"
import { groupID } from "../../../src/routes/session/anchors"
import type { SessionEntry, SessionRow } from "../../../src/routes/session/grouping/session"
import type { GroupKind, SessionEntry, SessionRow } from "../../../src/routes/session/grouping/session"
import { groupEntries } from "../../../src/routes/session/grouping/tree"
import { rowsAfter, rowsBefore, rowWeight } from "../../../src/routes/session/mount-budget"
@@ -9,18 +9,18 @@ const read = (index: number): SessionEntry => ({
ref: { messageID: `m${index}`, partID: `read-${index}` },
})
function group(size: number, path: readonly ("exploration" | "reasoning")[] = ["exploration"]): SessionRow {
function group(size: number, path: readonly GroupKind[] = ["exploration"]): SessionRow {
const [node] = groupEntries(
Array.from({ length: size }, (_, index) => read(index)),
() => path,
)
if (node.type !== "group") throw new Error("Expected group")
return node.kind === "reasoning"
? { ...node, kind: "reasoning", completed: true }
: { ...node, kind: "exploration", pending: [], completed: true }
return node.kind === "reasoning" || node.kind === "instructions"
? { ...node, kind: node.kind, completed: true }
: { ...node, kind: node.kind, pending: [], completed: true }
}
const collapsed = { expanded: () => false, grouped: () => true }
const collapsed = { expanded: () => false, grouped: () => true, summarized: () => true }
test("with every group collapsed each row costs one, matching the former row-count budget", () => {
const rows: SessionRow[] = [
@@ -46,7 +46,7 @@ test("an expanded group costs its header plus rendered children", () => {
const row = group(200)
if (row.type !== "group") throw new Error("Expected group")
const id = groupID(row, 0)
expect(rowWeight(row, { expanded: (key) => key === id, grouped: () => true })).toBe(201)
expect(rowWeight(row, { expanded: (key) => key === id, grouped: () => true, summarized: () => true })).toBe(201)
})
test("a collapsed inner group costs one even when its parent is expanded", () => {
@@ -54,14 +54,14 @@ test("a collapsed inner group costs one even when its parent is expanded", () =>
if (row.type !== "group" || row.children[0].type !== "group") throw new Error("Expected nested group")
const outer = groupID(row, 0)
const inner = groupID(row.children[0], 1)
expect(rowWeight(row, { expanded: (key) => key === outer, grouped: () => true })).toBe(2)
expect(rowWeight(row, { expanded: (key) => key === outer || key === inner, grouped: () => true })).toBe(52)
expect(rowWeight(row, { expanded: (key) => key === outer, grouped: () => true, summarized: () => true })).toBe(2)
expect(rowWeight(row, { expanded: (key) => key === outer || key === inner, grouped: () => true, summarized: () => true })).toBe(52)
// A saved expanded inner group under a collapsed parent mounts nothing.
expect(rowWeight(row, { expanded: (key) => key === inner, grouped: () => true })).toBe(1)
expect(rowWeight(row, { expanded: (key) => key === inner, grouped: () => true, summarized: () => true })).toBe(1)
})
test("an ungrouped kind renders every leaf", () => {
expect(rowWeight(group(12, ["reasoning"]), { expanded: () => false, grouped: (kind) => kind !== "reasoning" })).toBe(
expect(rowWeight(group(12, ["reasoning"]), { expanded: () => false, grouped: (kind) => kind !== "reasoning", summarized: () => true })).toBe(
12,
)
})
@@ -72,3 +72,17 @@ test("a heavy tail group leaves fewer older rows mounted", () => {
expect(rowsBefore(weights, weights.length, 40)).toBe(100)
expect(rowsAfter(weights, 90, 60)).toBe(101)
})
test("an activity group with nothing to summarize costs its rendered children", () => {
const input = {
expanded: () => false,
grouped: () => true,
summarized: (node: { kind: GroupKind }) => node.kind !== "activity",
}
// Summarized, a collapsed activity costs one header row.
expect(rowWeight(group(3, ["activity"]), { ...input, summarized: () => true })).toBe(1)
// Nothing finished: its three direct tools render.
expect(rowWeight(group(3, ["activity"]), input)).toBe(3)
// Nothing finished: it renders one collapsed exploration header.
expect(rowWeight(group(3, ["activity", "exploration"]), input)).toBe(1)
})
@@ -0,0 +1,240 @@
import { expect, test } from "bun:test"
import type { SessionMessageAssistant, SessionMessageAssistantTool, SessionMessageInfo } from "@opencode/client"
import { activitySummary, summarizeActivity } from "../../../src/routes/session/activity-summary"
import {
append,
groupRefs,
messagePath,
partitionPending,
partPath,
type SessionRow,
} from "../../../src/routes/session/grouping/session"
import { reduceSessionRows } from "../../../src/routes/session/rows"
const model = { id: "model", providerID: "provider" }
const tool = (
id: string,
name: string,
status: "completed" | "running" | "error" = "completed",
): SessionMessageAssistantTool => ({
type: "tool",
id,
name,
time: { created: 1, ...(status === "running" ? {} : { completed: 2 }) },
state:
status === "running"
? { status, input: {}, metadata: {} }
: status === "error"
? { status, input: {}, error: { type: "Fixture", message: "failed" } }
: { status, input: {}, content: [{ type: "text", text: "ok" }], metadata: {} },
})
const assistant = (
id: string,
content: SessionMessageAssistant["content"],
finish?: "stop",
): SessionMessageAssistant => ({
type: "assistant",
id,
agent: "build",
model,
time: { created: 1, completed: 2 },
...(finish ? { finish } : {}),
content,
})
const instruction = (id: string, paths: string[]): SessionMessageInfo => ({
type: "synthetic",
id,
text: "Instructions",
description: `Loaded ${paths.join(", ")}`,
metadata: { instruction: { paths } },
time: { created: 1 },
})
test("reasoning and exploration group at every level; other tools stand alone", () => {
for (const verbosity of ["medium", "high"] as const) {
expect(partPath({ type: "reasoning" }, verbosity)).toEqual(["reasoning"])
expect(partPath({ type: "tool", name: "read" }, verbosity)).toEqual(["exploration"])
}
})
test("medium and high add web tools to exploration and group instruction loads", () => {
for (const verbosity of ["medium", "high"] as const) {
expect(partPath({ type: "tool", name: "webfetch" }, verbosity)).toEqual(["exploration"])
expect(partPath({ type: "tool", name: "websearch" }, verbosity)).toEqual(["exploration"])
expect(partPath({ type: "tool", name: "shell" }, verbosity)).toEqual([])
expect(messagePath(instruction("i", ["AGENTS.md"]), verbosity)).toEqual(["instructions"])
}
})
test("low wraps tools, thoughts and instruction loads in activity; text and other messages break it", () => {
expect(partPath({ type: "reasoning" }, "low")).toEqual(["activity", "reasoning"])
expect(partPath({ type: "tool", name: "read" }, "low")).toEqual(["activity", "exploration"])
expect(partPath({ type: "tool", name: "shell" }, "low")).toEqual(["activity"])
expect(partPath({ type: "text" }, "low")).toEqual([])
expect(messagePath(instruction("i", ["AGENTS.md"]), "low")).toEqual(["activity", "instructions"])
expect(messagePath({ type: "user", id: "u", text: "hi", time: { created: 1 } }, "low")).toEqual([])
// Shell/subagent completion notices are ordinary synthetic messages without instruction metadata.
expect(
messagePath(
{
type: "synthetic",
id: "s",
text: "done",
description: "bun test",
metadata: { source: "shell" },
time: { created: 1 },
},
"low",
),
).toEqual([])
})
test("low hydration nests subgroups inside one activity group per run", () => {
const messages: SessionMessageInfo[] = [
{ type: "user", id: "u", text: "Go", time: { created: 0 } },
assistant("a", [
{ type: "reasoning", text: "Plan", time: { created: 1, completed: 2 } },
tool("r1", "read"),
tool("r2", "grep"),
]),
instruction("i", ["AGENTS.md"]),
assistant("b", [tool("sh", "shell"), tool("r3", "read"), { type: "text", text: "Done" }], "stop"),
]
const rows = reduceSessionRows(messages, new Set(), false, "low")
expect(rows.map((row) => (row.type === "group" ? `${row.kind}:${row.size}` : row.type))).toEqual([
"message",
"activity:6",
"part",
"assistant-footer",
])
const activity = rows[1]
if (activity.type !== "group") throw new Error("Expected activity")
expect(activity.children.map((child) => (child.type === "group" ? child.kind : child.entry.type))).toEqual([
"reasoning",
"exploration",
"instructions",
"part",
"exploration",
])
expect(activity.completed).toBe(true)
})
test("medium hydration keeps production shape plus instruction groups", () => {
const messages: SessionMessageInfo[] = [
assistant("a", [tool("r1", "read"), tool("w1", "webfetch")]),
instruction("i1", ["AGENTS.md"]),
instruction("i2", ["packages/tui/AGENTS.md"]),
assistant("b", [tool("sh", "shell")]),
]
const rows = reduceSessionRows(messages, new Set(), false, "medium")
expect(rows.map((row) => (row.type === "group" ? `${row.kind}:${row.size}` : row.type))).toEqual([
"exploration:2",
"instructions:2",
"part",
])
})
test("live appends under low merge into the open activity group", () => {
const rows: SessionRow[] = []
append(rows, { messageID: "a", partID: "reasoning:0" }, { type: "reasoning" }, rows.length, "low")
append(rows, { messageID: "a", partID: "r1" }, { type: "tool", name: "read" }, rows.length, "low")
append(rows, { messageID: "b", partID: "sh" }, { type: "tool", name: "shell" }, rows.length, "low")
append(rows, { messageID: "b", partID: "r2" }, { type: "tool", name: "read" }, rows.length, "low")
expect(rows).toHaveLength(1)
const activity = rows[0]
if (activity.type !== "group") throw new Error("Expected activity")
expect(activity.kind).toBe("activity")
expect(activity.size).toBe(4)
expect(groupRefs(activity).map((ref) => ref.partID)).toEqual(["reasoning:0", "r1", "sh", "r2"])
append(rows, { messageID: "b", partID: "text:0" }, { type: "text" }, rows.length, "low")
expect(rows).toHaveLength(2)
expect(activity.completed).toBe(true)
})
test("permission-blocked tools inside an activity group are tracked as pending without reordering", () => {
const rows = reduceSessionRows(
[assistant("a", [tool("r1", "read"), tool("sh", "shell", "running"), tool("r2", "read")])],
new Set(),
false,
"low",
)
partitionPending(rows, new Set(["sh"]))
const activity = rows[0]
if (activity.type !== "group" || activity.kind !== "activity") throw new Error("Expected activity")
expect(activity.pending).toEqual([{ messageID: "a", partID: "sh" }])
expect(groupRefs(activity).map((ref) => ref.partID)).toEqual(["r1", "r2"])
expect(groupRefs(activity, true).map((ref) => ref.partID)).toEqual(["r1", "sh", "r2"])
})
test("activity summary counts finished work by category", () => {
const message = assistant("a", [])
const items = [
{ message, part: { type: "reasoning" as const, text: "t", time: { created: 1, completed: 2 } } },
{ message, part: tool("s1", "shell") },
{ message, part: tool("s2", "bash") },
{ message, part: tool("e1", "edit") },
{ message, part: tool("p1", "apply_patch") },
{ message, part: tool("r1", "read") },
{ message, part: tool("g1", "grep") },
]
expect(activitySummary(items, 2)).toEqual({
label: "2 commands, 2 edits, 1 thought, 1 read, 1 tool, 2 instructions",
active: false,
failed: false,
})
})
test("running work is active but not counted, and failures are reported", () => {
const open = { ...assistant("a", []), time: { created: 1 } }
const summary = activitySummary(
[
{ message: open, part: { type: "reasoning" as const, text: "t", time: { created: 1 } } },
{ message: open, part: tool("r1", "read", "running") },
{ message: open, part: tool("r2", "read") },
{ message: open, part: tool("x", "custom.check", "error") },
],
0,
)
expect(summary).toEqual({ label: "1 read, 1 tool", active: true, failed: true })
})
test("execute counts its finished nested calls instead of itself", () => {
const message = assistant("a", [])
const execute: SessionMessageAssistantTool = {
...tool("x", "execute"),
state: {
status: "completed",
input: {},
content: [{ type: "text", text: "done" }],
metadata: {
toolCalls: [
{ tool: "read", status: "completed" },
{ tool: "read", status: "completed" },
{ tool: "custom.lookup", status: "error" },
{ tool: "custom.slow", status: "running" },
],
},
},
}
expect(activitySummary([{ message, part: execute }], 0)).toEqual({
label: "2 reads, 1 tool",
active: false,
failed: true,
})
})
test("activity summary counts distinct instruction files and skips redacted thoughts", () => {
const messages: SessionMessageInfo[] = [
assistant("a", [
{ type: "reasoning", text: "[REDACTED]", time: { created: 1, completed: 2 } },
{ type: "reasoning", text: "Plan", time: { created: 1, completed: 2 } },
tool("r1", "read"),
]),
instruction("i1", ["AGENTS.md", "src/AGENTS.md"]),
instruction("i2", ["src/AGENTS.md", "docs/AGENTS.md"]),
]
const activity = reduceSessionRows(messages, new Set(), false, "low")[0]
if (activity.type !== "group") throw new Error("Expected activity")
const message = (id: string) => messages.find((item) => item.id === id)
expect(summarizeActivity(activity, message).label).toBe("1 thought, 1 read, 3 instructions")
})
+8
View File
@@ -114,6 +114,14 @@ test("shows the TPS default in session settings", () => {
expect(setting?.default).toBe(true)
})
test("shows transcript verbosity in session settings", () => {
expect(settings.find((setting) => setting.path.join(".") === "session.verbosity")).toMatchObject({
category: "Session",
default: "medium",
values: ["low", "medium", "high"],
})
})
test("names tool grouping explicitly in settings", () => {
expect(settings.find((setting) => setting.path.join(".") === "session.grouping")).toMatchObject({
title: "Tool grouping",
@@ -89,7 +89,7 @@ test("an expanded group spends the mounting budget that older rows used", async
? node
: node.getChildren().map(find).find(Boolean)
try {
await setup.waitForFrame((frame) => frame.includes("Explored — 100 reads"))
await setup.waitForFrame((frame) => frame.includes("Explored: 100 reads"))
await setup.waitForVisualIdle({ quietFrames: 3 })
const scroll = find(setup.renderer.root)
if (!scroll) throw new Error("Missing transcript scrollbox")
@@ -102,7 +102,7 @@ test("an expanded group spends the mounting budget that older rows used", async
const header = setup
.captureCharFrame()
.split("\n")
.findIndex((line) => line.includes("Explored — 100 reads"))
.findIndex((line) => line.includes("Explored: 100 reads"))
await setup.mockMouse.click(6, header)
await setup.waitForFrame((frame) => frame.includes("Read 99.ts"))
await setup.waitForVisualIdle({ quietFrames: 3 })
@@ -112,18 +112,18 @@ test("an expanded group spends the mounting budget that older rows used", async
// Navigate back to the header (the view stayed pinned to the bottom), collapse, return.
setup.mockInput.pressKey("u", { ctrl: true, shift: true })
await setup.waitForFrame((frame) => frame.includes("Explored — 100 reads"))
await setup.waitForFrame((frame) => frame.includes("Explored: 100 reads"))
await setup.waitForVisualIdle({ quietFrames: 3 })
await setup.mockMouse.click(
6,
setup
.captureCharFrame()
.split("\n")
.findIndex((line) => line.includes("Explored — 100 reads")),
.findIndex((line) => line.includes("Explored: 100 reads")),
)
await setup.waitForVisualIdle({ quietFrames: 3 })
setup.mockInput.pressKey("l", { ctrl: true, shift: true })
await setup.waitForFrame((frame) => !frame.includes("Read 99.ts") && frame.includes("Explored — 100 reads"))
await setup.waitForFrame((frame) => !frame.includes("Read 99.ts") && frame.includes("Explored: 100 reads"))
await setup.waitForVisualIdle({ quietFrames: 3 })
expect(mounted("user-18")).toBe(true)
expect(mounted("user-17")).toBe(false)