mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
feat(tui): add transcript verbosity levels (#50941)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user