From 4d12e0182436daa69997941e3f38806fd27e1302 Mon Sep 17 00:00:00 2001 From: James Long Date: Thu, 10 Sep 2026 23:15:58 -0400 Subject: [PATCH] refactor(tui): project production subgroups through tree engine (#48399) --- .../src/routes/session/grouping/session.ts | 141 ++++++++++++++++++ packages/tui/src/routes/session/index.tsx | 7 +- packages/tui/src/routes/session/rows.ts | 132 ++++------------ .../tui/test/cli/tui/session-grouping.test.ts | 21 +++ .../tui/test/cli/tui/session-rows.test.ts | 61 ++++---- 5 files changed, 229 insertions(+), 133 deletions(-) create mode 100644 packages/tui/src/routes/session/grouping/session.ts create mode 100644 packages/tui/test/cli/tui/session-grouping.test.ts diff --git a/packages/tui/src/routes/session/grouping/session.ts b/packages/tui/src/routes/session/grouping/session.ts new file mode 100644 index 00000000000..e81901a7c2c --- /dev/null +++ b/packages/tui/src/routes/session/grouping/session.ts @@ -0,0 +1,141 @@ +import type { SessionMessageAssistant } from "@opencode/client" +import { groupEntries, mergeGroups, splitGroups, type GroupNode } from "./tree" + +export type PartRef = { + messageID: string + partID: string +} + +export type CacheUsage = { + read: number + model: SessionMessageAssistant["model"] +} + +export type SessionEntry = + | { type: "message"; messageID: string } + | { type: "compaction-queued"; inboxID: string } + | { type: "part"; ref: PartRef } + | { type: "assistant-footer"; messageID: string } + | { type: "turn-usage"; messageIDs: string[]; previousCache?: CacheUsage } + +type GroupKind = "reasoning" | "exploration" +type SessionGroup = { + type: "group" + children: readonly GroupNode[] + size: number + completed: boolean +} & ({ kind: "reasoning" } | { kind: "exploration"; pending: PartRef[] }) + +export type SessionRow = SessionEntry | SessionGroup + +export type AppendPart = + | { type: "text" } + | { type: "reasoning"; time?: { completed?: number } } + | { type: "tool"; name: string } + +export type ProjectionEntry = { + entry: SessionEntry + part?: AppendPart + 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) : [])) + return nodes.map((node, index) => { + if (node.type === "entry") return node.entry.entry + const next = nodes[index + 1] + const completed = + (next !== undefined && (next.type === "group" || next.entry.closesPrevious !== false)) || + (node.kind === "reasoning" && + node.children.every( + (child) => + child.type === "entry" && + 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: [] } + }) +} + +function unwrap(node: GroupNode): GroupNode { + 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 [] +} + +/** 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([{ type: "part", ref }], () => partPath(part)) + if (node.type === "entry") { + completePrevious(rows, index) + rows.splice(index, 0, node.entry) + return + } + const previous = rows[index - 1] + if (previous?.type === "group" && previous.kind === node.kind) { + // Permission-blocked tools remain at the end, just as the former refs/pending + // partition did. Inserting a new ref must precede those blocked tools. + const pending = previous.kind === "exploration" ? previous.pending.length : 0 + const [left, right] = splitGroups([previous], previous.size - pending) + const [merged] = mergeGroups(mergeGroups(left, [node]), right) + if (merged.type !== "group") throw new Error("Expected merged session group") + previous.children = merged.children + previous.size = merged.size + if (part.type === "reasoning") previous.completed &&= part.time?.completed !== undefined + return + } + completePrevious(rows, 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 }, + ) +} + +export function completePrevious(rows: SessionRow[], index = rows.length) { + const previous = rows[index - 1] + if (previous?.type === "group") previous.completed = true +} + +/** 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 visit = (nodes: readonly GroupNode[]): PartRef[] => + nodes.flatMap((node) => { + if (node.type === "group") return visit(node.children) + if (node.entry.type !== "part") return [] + const ref = node.entry.ref + if (pending.some((item) => item.messageID === ref.messageID && item.partID === ref.partID)) return [] + return [ref] + }) + return visit(row.children) +} + +export function partitionPending(rows: SessionRow[], pending: Set) { + rows.forEach((row) => { + if (row.type !== "group" || row.kind !== "exploration") 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) => + 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)) + }) +} + +export function hasPart(rows: SessionRow[], ref: PartRef) { + return rows.some((row) => { + if (row.type === "part") return row.ref.messageID === ref.messageID && row.ref.partID === ref.partID + if (row.type !== "group") return false + return groupRefs(row, true).some((item) => item.messageID === ref.messageID && item.partID === ref.partID) + }) +} diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index eb25cc88932..4e95c0235a0 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -110,6 +110,7 @@ import { isRecord } from "../../util/record" import { createHistoryPrepend } from "./history" import { context, use, type PendingAction } from "./render-context" import { INLINE_TOOL_ICON_WIDTH, InlineToolRow, ReasoningPart, reasoningContent, TextPart } from "./message-parts" +import { groupRefs } from "./grouping/session" export { InlineToolRow } from "./message-parts" addDefaultParsers(parsers.parsers) @@ -1442,12 +1443,14 @@ function SessionRowView(props: SessionRowViewProps) { {(row) => } - {(row) => } + {(row) => ( + + )} {(row) => ( , onSynced?: (sessionID: string) => void) { const data = useData() @@ -180,7 +164,7 @@ export function createSessionRows(sessionID: Accessor, onSynced?: (sessi (row) => row.type === "group" && row.kind === "reasoning" && - row.refs.some((item) => item.messageID === ref.messageID && item.partID === ref.partID), + groupRefs(row).some((item) => item.messageID === ref.messageID && item.partID === ref.partID), ) if (row?.type === "group" && row.kind === "reasoning") row.completed = true }), @@ -299,16 +283,15 @@ export function reduceSessionRows(messages: SessionMessageInfo[], inputs = new S const usage = turnTokens ? { steps: [] as SessionMessageAssistant[], previousTurnCache: undefined as CacheUsage | undefined } : undefined - return [ + const entries = [ ...messages.filter((message) => !pending.has(message.id)), ...pendingCompactions, ...messages.filter(isInput), - ].reduce((rows, message) => { + ].reduce((rows, message) => { if (message.type !== "assistant") { if (message.type === "synthetic" && !message.description?.trim()) return rows if (message.type === "compaction" && message.status === "completed" && usage) usage.previousTurnCache = undefined - if (!pending.has(message.id)) completePrevious(rows) - rows.push({ type: "message", messageID: message.id }) + rows.push({ entry: { type: "message", messageID: message.id }, closesPrevious: !pending.has(message.id) }) return rows } usage?.steps.push(message) @@ -316,21 +299,22 @@ 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 - append(rows, { messageID: message.id, partID }, part) + rows.push({ entry: { type: "part", ref: { messageID: message.id, partID } }, part }) }) const terminal = (message.finish && !["tool-calls", "unknown"].includes(message.finish)) || message.error if (terminal || message.retry) { - completePrevious(rows) - rows.push({ type: "assistant-footer", messageID: message.id }) + rows.push({ entry: { type: "assistant-footer", messageID: message.id } }) } if (terminal && usage) { const stepsWithUsage = usage.steps.filter(hasTokenUsage) const last = stepsWithUsage.at(-1) if (last) { rows.push({ - type: "turn-usage", - messageIDs: stepsWithUsage.map((step) => step.id), - ...(usage.previousTurnCache === undefined ? {} : { previousCache: usage.previousTurnCache }), + entry: { + type: "turn-usage", + messageIDs: stepsWithUsage.map((step) => step.id), + ...(usage.previousTurnCache === undefined ? {} : { previousCache: usage.previousTurnCache }), + }, }) usage.previousTurnCache = { read: last.tokens.cache.read, model: last.model } } @@ -338,6 +322,7 @@ export function reduceSessionRows(messages: SessionMessageInfo[], inputs = new S } return rows }, []) + return projectEntries(entries) } export function cacheReuseDrop(previous: CacheUsage | undefined, current: CacheUsage) { @@ -427,7 +412,7 @@ function rowBoundaryMessageID(row: SessionRow, messages: Map part.type === match[1])[ordinal] } - -type AppendPart = - | { type: "text" } - | { type: "reasoning"; time?: { completed?: number } } - | { type: "tool"; name: string } - -function append(rows: SessionRow[], ref: PartRef, part: AppendPart, index = rows.length) { - if (part.type === "reasoning") { - const previous = rows[index - 1] - if (previous?.type === "group" && previous.kind === "reasoning") { - previous.refs.push(ref) - previous.completed &&= part.time?.completed !== undefined - return - } - completePrevious(rows, index) - rows.splice(index, 0, { - type: "group", - kind: "reasoning", - refs: [ref], - completed: part.time?.completed !== undefined, - }) - return - } - if (part.type === "tool" && exploration(part.name)) { - const previous = rows[index - 1] - if (previous?.type === "group" && previous.kind === "exploration") { - previous.refs.push(ref) - return - } - completePrevious(rows, index) - rows.splice(index, 0, { type: "group", kind: "exploration", refs: [ref], pending: [], completed: false }) - return - } - completePrevious(rows, index) - rows.splice(index, 0, { type: "part", ref }) -} - -function completePrevious(rows: SessionRow[], index = rows.length) { - const previous = rows[index - 1] - if (previous?.type === "group") previous.completed = true -} - -function partitionPending(rows: SessionRow[], pending: Set) { - rows.forEach((row) => { - if (row.type !== "group" || row.kind !== "exploration") return - const refs = [...row.refs, ...row.pending] - row.refs = refs.filter((ref) => !pending.has(ref.partID)) - row.pending = refs.filter((ref) => pending.has(ref.partID)) - }) -} - -function exploration(name: string) { - return ["read", "glob", "grep"].includes(name.toLowerCase()) -} - -function hasPart(rows: SessionRow[], ref: PartRef) { - return rows.some((row) => { - if (row.type === "part") return row.ref.messageID === ref.messageID && row.ref.partID === ref.partID - if (row.type !== "group") return false - const refs = row.kind === "exploration" ? [...row.refs, ...row.pending] : row.refs - return refs.some((item) => item.messageID === ref.messageID && item.partID === ref.partID) - }) -} diff --git a/packages/tui/test/cli/tui/session-grouping.test.ts b/packages/tui/test/cli/tui/session-grouping.test.ts new file mode 100644 index 00000000000..da3ee07032c --- /dev/null +++ b/packages/tui/test/cli/tui/session-grouping.test.ts @@ -0,0 +1,21 @@ +import { expect, test } from "bun:test" +import { append, groupRefs, partitionPending, type SessionRow } from "../../../src/routes/session/grouping/session" + +test("a pending tool does not hide a later tool reusing its call ID in another message", () => { + const rows: SessionRow[] = [] + const blocked = { messageID: "assistant-a", partID: "call-reused" } + const later = { messageID: "assistant-b", partID: "call-reused" } + append(rows, blocked, { type: "tool", name: "read" }) + partitionPending(rows, new Set([blocked.partID])) + append(rows, later, { type: "tool", name: "read" }) + + const group = rows[0] + if (group.type !== "group" || group.kind !== "exploration") throw new Error("Expected exploration group") + expect(groupRefs(group)).toEqual([later]) + expect(group.pending).toEqual([blocked]) + expect(groupRefs(group, true)).toEqual([later, blocked]) + + partitionPending(rows, new Set()) + expect(groupRefs(group)).toEqual([later, blocked]) + expect(group.pending).toEqual([]) +}) diff --git a/packages/tui/test/cli/tui/session-rows.test.ts b/packages/tui/test/cli/tui/session-rows.test.ts index 00d9e54eabc..481da35f0ef 100644 --- a/packages/tui/test/cli/tui/session-rows.test.ts +++ b/packages/tui/test/cli/tui/session-rows.test.ts @@ -280,10 +280,11 @@ test("groups exploration parts across assistant messages until a delimiter", () kind: "exploration", pending: [], completed: true, - refs: [ - { messageID: "assistant-1", partID: "read-1" }, - { messageID: "assistant-1", partID: "glob-1" }, - { messageID: "assistant-2", partID: "grep-1" }, + size: 3, + children: [ + partChild("assistant-1", "read-1"), + partChild("assistant-1", "glob-1"), + partChild("assistant-2", "grep-1"), ], }, { type: "part", ref: { messageID: "assistant-2", partID: "text:0" } }, @@ -305,7 +306,8 @@ test("keeps non-exploration tools as individual part rows", () => { kind: "exploration", pending: [], completed: true, - refs: [{ messageID: "assistant-1", partID: "read-1" }], + size: 1, + children: [partChild("assistant-1", "read-1")], }, { type: "part", ref: { messageID: "assistant-1", partID: "reasoning:0" } }, { @@ -313,7 +315,8 @@ test("keeps non-exploration tools as individual part rows", () => { kind: "exploration", pending: [], completed: false, - refs: [{ messageID: "assistant-1", partID: "grep-1" }], + size: 1, + children: [partChild("assistant-1", "grep-1")], }, ]) }) @@ -334,14 +337,16 @@ test("assigns stable kind ordinals within an assistant message", () => { type: "group", kind: "reasoning", completed: true, - refs: [{ messageID: "assistant-1", partID: "reasoning:0" }], + size: 1, + children: [partChild("assistant-1", "reasoning:0")], }, { type: "part", ref: { messageID: "assistant-1", partID: "text:1" } }, { type: "group", kind: "reasoning", completed: false, - refs: [{ messageID: "assistant-1", partID: "reasoning:1" }], + size: 1, + children: [partChild("assistant-1", "reasoning:1")], }, ]) }) @@ -361,17 +366,16 @@ test("groups adjacent reasoning parts until a visible boundary", () => { type: "group", kind: "reasoning", completed: true, - refs: [ - { messageID: "assistant-1", partID: "reasoning:0" }, - { messageID: "assistant-1", partID: "reasoning:1" }, - ], + size: 2, + children: [partChild("assistant-1", "reasoning:0"), partChild("assistant-1", "reasoning:1")], }, { type: "part", ref: { messageID: "assistant-1", partID: "text:0" } }, { type: "group", kind: "reasoning", completed: false, - refs: [{ messageID: "assistant-1", partID: "reasoning:2" }], + size: 1, + children: [partChild("assistant-1", "reasoning:2")], }, ]) }) @@ -393,17 +397,16 @@ test("groups across empty assistant reasoning parts", () => { type: "group", kind: "reasoning", completed: true, - refs: [{ messageID: "assistant-1", partID: "reasoning:0" }], + size: 1, + children: [partChild("assistant-1", "reasoning:0")], }, { type: "group", kind: "exploration", pending: [], completed: false, - refs: [ - { messageID: "assistant-1", partID: "read-1" }, - { messageID: "assistant-2", partID: "grep-1" }, - ], + size: 2, + children: [partChild("assistant-1", "read-1"), partChild("assistant-2", "grep-1")], }, ]) }) @@ -425,7 +428,8 @@ test("completes exploration groups when another row follows", () => { kind: "exploration", pending: [], completed: true, - refs: [{ messageID: "assistant-1", partID: "read-1" }], + size: 1, + children: [partChild("assistant-1", "read-1")], }, { type: "message", messageID: "user-1" }, { @@ -433,7 +437,8 @@ test("completes exploration groups when another row follows", () => { kind: "exploration", pending: [], completed: true, - refs: [{ messageID: "assistant-2", partID: "grep-1" }], + size: 1, + children: [partChild("assistant-2", "grep-1")], }, { type: "assistant-footer", messageID: "assistant-2" }, ]) @@ -468,10 +473,8 @@ test("hides synthetic messages without descriptions", () => { kind: "exploration", pending: [], completed: false, - refs: [ - { messageID: "assistant-1", partID: "read-1" }, - { messageID: "assistant-2", partID: "grep-1" }, - ], + size: 2, + children: [partChild("assistant-1", "read-1"), partChild("assistant-2", "grep-1")], }, ]) expect(reduceSessionRows(messages, new Set(["synthetic-1"]))).toEqual(rows) @@ -496,7 +499,8 @@ test("renders synthetic messages with descriptions", () => { kind: "exploration", pending: [], completed: true, - refs: [{ messageID: "assistant-1", partID: "read-1" }], + size: 1, + children: [partChild("assistant-1", "read-1")], }, { type: "message", messageID: "synthetic-1" }, { @@ -504,11 +508,16 @@ test("renders synthetic messages with descriptions", () => { kind: "exploration", pending: [], completed: false, - refs: [{ messageID: "assistant-2", partID: "grep-1" }], + size: 1, + children: [partChild("assistant-2", "grep-1")], }, ]) }) +function partChild(messageID: string, partID: string) { + return { type: "entry" as const, entry: { type: "part" as const, ref: { messageID, partID } }, size: 1 as const } +} + test("renders a footer for a pre-output retry assistant after replay", () => { const message = assistant("assistant-retry", []) message.retry = {