From bbbac2507b38242382a6c88f5126c4007079f4cf Mon Sep 17 00:00:00 2001 From: James Long Date: Wed, 23 Sep 2026 16:54:42 -0400 Subject: [PATCH] feat(tui): add transcript verbosity levels (#50941) --- packages/tui/src/component/dialog-config.tsx | 8 + packages/tui/src/config/index.tsx | 3 + packages/tui/src/config/keybind.ts | 1 + .../src/routes/session/activity-summary.ts | 93 +++++++ .../tui/src/routes/session/group-view.tsx | 179 ++++++++++--- .../src/routes/session/grouping/session.ts | 80 ++++-- packages/tui/src/routes/session/index.tsx | 34 ++- .../tui/src/routes/session/mount-budget.ts | 14 +- .../tui/src/routes/session/render-context.tsx | 4 +- packages/tui/src/routes/session/rows.ts | 29 ++- packages/tui/test/cli/tui/group-view.test.tsx | 165 ++++++++---- .../tui/test/cli/tui/mount-budget.test.ts | 36 ++- .../test/cli/tui/session-verbosity.test.ts | 240 ++++++++++++++++++ packages/tui/test/config-v2.test.tsx | 8 + .../tui/test/session-mount-budget.test.tsx | 10 +- 15 files changed, 772 insertions(+), 132 deletions(-) create mode 100644 packages/tui/src/routes/session/activity-summary.ts create mode 100644 packages/tui/test/cli/tui/session-verbosity.test.ts diff --git a/packages/tui/src/component/dialog-config.tsx b/packages/tui/src/component/dialog-config.tsx index 1ff4b94d837..f25f8dce656 100644 --- a/packages/tui/src/component/dialog-config.tsx +++ b/packages/tui/src/component/dialog-config.tsx @@ -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", diff --git a/packages/tui/src/config/index.tsx b/packages/tui/src/config/index.tsx index f9b91b98a13..7275d3d87e1 100644 --- a/packages/tui/src/config/index.tsx +++ b/packages/tui/src/config/index.tsx @@ -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", }), diff --git a/packages/tui/src/config/keybind.ts b/packages/tui/src/config/keybind.ts index 0e4f8d4f35e..a4b285fc8bf 100644 --- a/packages/tui/src/config/keybind.ts +++ b/packages/tui/src/config/keybind.ts @@ -131,6 +131,7 @@ export const Definitions = { "session.queued_prompts": keybind("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"), diff --git a/packages/tui/src/routes/session/activity-summary.ts b/packages/tui/src/routes/session/activity-summary.ts new file mode 100644 index 00000000000..7471eababa8 --- /dev/null +++ b/packages/tui/src/routes/session/activity-summary.ts @@ -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, + 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") +} diff --git a/packages/tui/src/routes/session/group-view.tsx b/packages/tui/src/routes/session/group-view.tsx index b11cbb8441a..1632b624ad3 100644 --- a/packages/tui/src/routes/session/group-view.tsx +++ b/packages/tui/src/routes/session/group-view.tsx @@ -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 ( - {(_kind) => } + {(kind) => ( + }> + + + + + + + + )} ) } @@ -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>((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") => ( ) @@ -192,25 +194,132 @@ function GroupContent(props: GroupProps) { - - - {(ref) => { - const leaf = createMemo(() => { - return entries().find( - (entry) => - entry.type === "part" && entry.ref.messageID === ref.messageID && entry.ref.partID === ref.partID, - ) - }) - return ( - {(item) => {props.entry(item())}} - ) - }} - + + + ) +} + +/** 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 ( + + {/* Until something finishes there is nothing to summarize; show the live items, spaced like Medium. */} + + + + } + > + setHover(true)} + onMouseOut={() => setHover(false)} + onMouseUp={disclosure.toggle} + > + {summary().label} + + + + + + + + + + ) +} + +/** 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 ( + 0}> + setHover(true)} + onMouseOut={() => setHover(false)} + onMouseUp={disclosure.toggle} + > + Instructions: {files()} {files() === 1 ? "file" : "files"} + + + + + ) } +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) { + 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 ( + + + {(ref) => { + const leaf = createMemo(() => + props.entries.find( + (entry) => + entry.type === "part" && entry.ref.messageID === ref.messageID && entry.ref.partID === ref.partID, + ), + ) + return {(item) => {props.entry(item())}} + }} + + + ) +} + function Children(props: GroupProps & { nodes: readonly SessionNode[]; mode: "normal" | "thought" | "tool" }) { return ( diff --git a/packages/tui/src/routes/session/grouping/session.ts b/packages/tui/src/routes/session/grouping/session.ts index 2d80118e267..d2f2f8fd33f 100644 --- a/packages/tui/src/routes/session/grouping/session.ts +++ b/packages/tui/src/routes/session/grouping/session.ts @@ -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 export type SessionGroup = { type: "group" children: readonly GroupNode[] 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, 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): 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 [] +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([{ type: "part", ref }], () => partPath(part)) +export function append( + rows: SessionRow[], + ref: PartRef, + part: AppendPart, + index = rows.length, + verbosity = defaultVerbosity, +) { + const [node] = groupEntries([{ 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[]): 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) { 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) => - 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) => + 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)) }) } diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index f5776499d71..a2e0d1ab942 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -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: { sessionTabs.groupExpanded(sessionID, groupID), + groupExpanded, setGroupExpanded: (groupID, expanded) => { sessionTabs.setGroupExpanded(sessionID, groupID, expanded) afterLayout(saveScrollAnchor) diff --git a/packages/tui/src/routes/session/mount-budget.ts b/packages/tui/src/routes/session/mount-budget.ts index 5cff6dc4299..033495617cf 100644 --- a/packages/tui/src/routes/session/mount-budget.ts +++ b/packages/tui/src/routes/session/mount-budget.ts @@ -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) => boolean + }, ) { if (row.type !== "group") return 1 const visit = (node: Extract, 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) } diff --git a/packages/tui/src/routes/session/render-context.tsx b/packages/tui/src/routes/session/render-context.tsx index 166760a67f1..e5712bc572e 100644 --- a/packages/tui/src/routes/session/render-context.tsx +++ b/packages/tui/src/routes/session/render-context.tsx @@ -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 - 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" diff --git a/packages/tui/src/routes/session/rows.ts b/packages/tui/src/routes/session/rows.ts index e6d81781d9d..8160e4ae5fc 100644 --- a/packages/tui/src/routes/session/rows.ts +++ b/packages/tui/src/routes/session/rows.ts @@ -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, onSynced?: (sessi const [rows, setRows] = createStore([]) 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, 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, 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, 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, onSynced?: (sessi return rows } -export function reduceSessionRows(messages: SessionMessageInfo[], inputs = new Set(), turnTokens = false) { +export function reduceSessionRows( + messages: SessionMessageInfo[], + inputs = new Set(), + 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) { diff --git a/packages/tui/test/cli/tui/group-view.test.tsx b/packages/tui/test/cli/tui/group-view.test.tsx index 1cd99d8ed28..49532e9e5f3 100644 --- a/packages/tui/test/cli/tui/group-view.test.tsx +++ b/packages/tui/test/cli/tui/group-view.test.tsx @@ -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( - () => ( - - - - 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, - }} - > - - messages.get(id)} - images={() => Image previews} - entry={(entry) => - entry.type === "part" && entry.ref.messageID === "b" ? ( - (target = node)}>Target B - ) : ( - A wrapped entry with enough text to occupy more than one terminal line - ) - } - /> - - - - - - ), - { 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" ? ( + (target = node)}>Target B + ) : ( + A wrapped entry with enough text to occupy more than one terminal line + ), + }) 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) => {entry.type === "part" ? `Shell ${entry.ref.partID}` : ""}, + }) + 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 + config: ReturnType + expanded: (id: string) => boolean + setExpanded: (id: string, value: boolean) => void + message: (id: string) => SessionMessageInfo | undefined + entry: (entry: SessionEntry) => JSX.Element +}) { + return testRender( + () => ( + + + + "hide", + markdownMode: () => "rendered", + groupExploration: () => true, + legacyTurns: () => false, + diffWrapMode: () => "word", + models: () => [], + messageIndex: () => undefined, + config: input.config, + mutatePending: async () => true, + pendingDelivery: () => undefined, + }} + > + + Image previews} + entry={input.entry} + /> + + + + + + ), + { width: 40, height: 24 }, + ) +} diff --git a/packages/tui/test/cli/tui/mount-budget.test.ts b/packages/tui/test/cli/tui/mount-budget.test.ts index 1761f62d2a8..b544e44f8a5 100644 --- a/packages/tui/test/cli/tui/mount-budget.test.ts +++ b/packages/tui/test/cli/tui/mount-budget.test.ts @@ -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) +}) diff --git a/packages/tui/test/cli/tui/session-verbosity.test.ts b/packages/tui/test/cli/tui/session-verbosity.test.ts new file mode 100644 index 00000000000..c3a6a05c767 --- /dev/null +++ b/packages/tui/test/cli/tui/session-verbosity.test.ts @@ -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") +}) diff --git a/packages/tui/test/config-v2.test.tsx b/packages/tui/test/config-v2.test.tsx index e035716cee2..a67d1d0277e 100644 --- a/packages/tui/test/config-v2.test.tsx +++ b/packages/tui/test/config-v2.test.tsx @@ -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", diff --git a/packages/tui/test/session-mount-budget.test.tsx b/packages/tui/test/session-mount-budget.test.tsx index eb03223f07f..6afd8f9f988 100644 --- a/packages/tui/test/session-mount-budget.test.tsx +++ b/packages/tui/test/session-mount-budget.test.tsx @@ -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)