From 0dfcd7352a7eb8daf829e43e73992831edbd0752 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 25 Jun 2026 21:16:40 -0400 Subject: [PATCH] feat(tui): improve v2 session rendering --- bun.lock | 2 + packages/cli/src/commands/commands.ts | 4 + packages/cli/src/commands/handlers/default.ts | 4 +- packages/core/package.json | 1 + packages/core/src/tool/apply-patch.ts | 56 ++- packages/core/src/tool/edit.ts | 23 +- packages/core/test/tool-apply-patch.test.ts | 23 + packages/core/test/tool-edit.test.ts | 9 + packages/tui/package.json | 1 + packages/tui/src/context/data.tsx | 55 +- packages/tui/src/routes/session/index.tsx | 470 +++++++++++++----- .../tui/src/routes/session/permission.tsx | 16 +- packages/tui/src/routes/session/rows.ts | 163 ++++++ packages/tui/src/util/layout.ts | 25 - .../inline-tool-wrap-snapshot.test.tsx.snap | 5 - packages/tui/test/cli/tui/data.test.tsx | 133 +++++ .../tui/inline-tool-wrap-snapshot.test.tsx | 185 +------ .../tui/test/cli/tui/session-rows.test.ts | 101 ++++ 18 files changed, 918 insertions(+), 358 deletions(-) create mode 100644 packages/tui/src/routes/session/rows.ts delete mode 100644 packages/tui/src/util/layout.ts create mode 100644 packages/tui/test/cli/tui/session-rows.test.ts diff --git a/bun.lock b/bun.lock index 5dd59c634b..99a0d5ed74 100644 --- a/bun.lock +++ b/bun.lock @@ -289,6 +289,7 @@ "ai-gateway-provider": "3.1.2", "bun-pty": "0.4.8", "cross-spawn": "catalog:", + "diff": "catalog:", "drizzle-orm": "catalog:", "effect": "catalog:", "fuzzysort": "3.1.0", @@ -867,6 +868,7 @@ "@opentui/core": "catalog:", "@opentui/keymap": "catalog:", "@opentui/solid": "catalog:", + "@solid-primitives/event-bus": "1.1.2", "clipboardy": "4.0.0", "diff": "catalog:", "effect": "catalog:", diff --git a/packages/cli/src/commands/commands.ts b/packages/cli/src/commands/commands.ts index f6ed2020b9..18db4006a8 100644 --- a/packages/cli/src/commands/commands.ts +++ b/packages/cli/src/commands/commands.ts @@ -6,6 +6,10 @@ declare const OPENCODE_CLI_NAME: string | undefined export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME : "opencode", { description: "OpenCode 2.0 preview command line interface", params: { + directory: Argument.string("directory").pipe( + Argument.withDescription("Directory to start OpenCode in"), + Argument.optional, + ), standalone: Flag.boolean("standalone").pipe( Flag.withDescription("Run with a private server instead of the background service"), Flag.withDefault(false), diff --git a/packages/cli/src/commands/handlers/default.ts b/packages/cli/src/commands/handlers/default.ts index 919124c02d..3be065f03d 100644 --- a/packages/cli/src/commands/handlers/default.ts +++ b/packages/cli/src/commands/handlers/default.ts @@ -1,11 +1,13 @@ import { Commands } from "../commands" import { Runtime } from "../../framework/runtime" -import { Effect } from "effect" +import { Effect, Option } from "effect" import { Daemon } from "../../services/daemon" import { Standalone } from "../../services/standalone" export default Runtime.handler(Commands, (input) => Effect.gen(function* () { + const directory = Option.getOrUndefined(input.directory) + if (directory !== undefined) process.chdir(directory) const daemon = yield* Daemon.Service const transport = yield* (input.standalone ? Standalone.transport() : daemon.transport()) const { runTui } = yield* Effect.promise(() => import("../../tui")) diff --git a/packages/core/package.json b/packages/core/package.json index c177d94018..5d459dc84d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -103,6 +103,7 @@ "ai-gateway-provider": "3.1.2", "bun-pty": "0.4.8", "cross-spawn": "catalog:", + "diff": "catalog:", "drizzle-orm": "catalog:", "effect": "catalog:", "fuzzysort": "3.1.0", diff --git a/packages/core/src/tool/apply-patch.ts b/packages/core/src/tool/apply-patch.ts index 78e5b0f40d..05e8e59fbb 100644 --- a/packages/core/src/tool/apply-patch.ts +++ b/packages/core/src/tool/apply-patch.ts @@ -1,6 +1,8 @@ export * as ApplyPatchTool from "./apply-patch" import { ToolFailure } from "@opencode-ai/llm" +import { FileDiff } from "@opencode-ai/schema/file-diff" +import { createTwoFilesPatch, diffLines } from "diff" import { Effect, Layer, Schema } from "effect" import { FileMutation } from "../file-mutation" import { FSUtil } from "../fs-util" @@ -24,7 +26,10 @@ export const Applied = Schema.Struct({ target: Schema.String, }) -export const Output = Schema.Struct({ applied: Schema.Array(Applied) }) +export const Output = Schema.Struct({ + applied: Schema.Array(Applied), + files: Schema.Array(FileDiff.Info), +}) export type Output = typeof Output.Type export const toModelOutput = (output: Output) => @@ -36,11 +41,17 @@ export const toModelOutput = (output: Output) => ].join("\n") type Prepared = - | (Extract & { readonly target: LocationMutation.Target }) + | (Extract & { + readonly target: LocationMutation.Target + readonly before: string + readonly after: string + }) | (Extract & { readonly target: LocationMutation.Target readonly source: Uint8Array readonly content: string + readonly before: string + readonly after: string }) export const layer = Layer.effectDiscard( @@ -113,29 +124,36 @@ export const layer = Layer.effectDiscard( for (const { hunk, target } of targets) { yield* Effect.gen(function* () { if (hunk.type === "add") { - prepared.push({ ...hunk, target }) + prepared.push({ + ...hunk, + target, + before: "", + after: + hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`, + }) return } if ((yield* fs.stat(target.canonical)).type !== "File") yield* fail(hunk.path) + const source = yield* fs.readFile(target.canonical) + const original = new TextDecoder("utf-8", { ignoreBOM: true }).decode(source) + const before = original.replace(/^\uFEFF/, "") if (hunk.type === "delete") { - prepared.push({ ...hunk, target }) + prepared.push({ ...hunk, target, before, after: "" }) return } - const source = yield* fs.readFile(target.canonical) - const update = Patch.derive( - hunk.path, - hunk.chunks, - new TextDecoder("utf-8", { ignoreBOM: true }).decode(source), - ) + const update = Patch.derive(hunk.path, hunk.chunks, original) prepared.push({ ...hunk, target, source, content: Patch.joinBom(update.content, update.bom), + before, + after: update.content, }) }).pipe(Effect.mapError(() => fail(hunk.path))) } + const patchFiles = prepared.map(patchFile) yield* Effect.forEach( prepared, (change) => @@ -165,7 +183,7 @@ export const layer = Layer.effectDiscard( }).pipe(Effect.mapError(() => fail(change.path))), { discard: true }, ) - return { applied } + return { applied, files: patchFiles } }).pipe(Effect.mapError((error) => (error instanceof ToolFailure ? error : fail("patch")))) }, }), @@ -175,3 +193,19 @@ export const layer = Layer.effectDiscard( .pipe(Effect.orDie) }), ) + +function patchFile(change: Prepared): typeof FileDiff.Info.Type { + const counts = diffLines(change.before, change.after).reduce( + (result, item) => ({ + additions: result.additions + (item.added ? (item.count ?? 0) : 0), + deletions: result.deletions + (item.removed ? (item.count ?? 0) : 0), + }), + { additions: 0, deletions: 0 }, + ) + return { + file: change.target.resource, + patch: createTwoFilesPatch(change.target.resource, change.target.resource, change.before, change.after), + status: change.type === "add" ? "added" : change.type === "delete" ? "deleted" : "modified", + ...counts, + } +} diff --git a/packages/core/src/tool/edit.ts b/packages/core/src/tool/edit.ts index 9b12704a22..49ef527288 100644 --- a/packages/core/src/tool/edit.ts +++ b/packages/core/src/tool/edit.ts @@ -7,6 +7,8 @@ export * as EditTool from "./edit" import { ToolFailure } from "@opencode-ai/llm" +import { FileDiff } from "@opencode-ai/schema/file-diff" +import { createTwoFilesPatch, diffLines } from "diff" import { Effect, Layer, Schema } from "effect" import { FileMutation } from "../file-mutation" import { FSUtil } from "../fs-util" @@ -35,6 +37,7 @@ export const Output = Schema.Struct({ resource: Schema.String, existed: Schema.Boolean, replacements: Schema.Number, + files: Schema.Array(FileDiff.Info), }) export type Output = typeof Output.Type @@ -179,6 +182,13 @@ export const layer = Layer.effectDiscard( input.replaceAll === true ? source.text.replaceAll(oldString, newString) : source.text.replace(oldString, newString) + const counts = diffLines(source.text, replaced).reduce( + (result, item) => ({ + additions: result.additions + (item.added ? (item.count ?? 0) : 0), + deletions: result.deletions + (item.removed ? (item.count ?? 0) : 0), + }), + { additions: 0, deletions: 0 }, + ) const next = splitBom(replaced) const result = yield* unableToEdit( files.writeIfUnchanged({ @@ -187,7 +197,18 @@ export const layer = Layer.effectDiscard( content: joinBom(next.text, source.bom || next.bom), }), ) - return { ...result, replacements } satisfies Output + return { + ...result, + replacements, + files: [ + { + file: result.resource, + patch: createTwoFilesPatch(result.resource, result.resource, source.text, replaced), + status: "modified", + ...counts, + }, + ], + } satisfies Output }) }, }), diff --git a/packages/core/test/tool-apply-patch.test.ts b/packages/core/test/tool-apply-patch.test.ts index 74eda3378e..9b666f0917 100644 --- a/packages/core/test/tool-apply-patch.test.ts +++ b/packages/core/test/tool-apply-patch.test.ts @@ -149,6 +149,29 @@ describe("ApplyPatchTool", () => { { type: "update", resource: "update.txt" }, { type: "delete", resource: "remove.txt" }, ], + files: [ + { + file: "nested/new.txt", + status: "added", + additions: 1, + deletions: 0, + patch: expect.stringContaining("+created"), + }, + { + file: "update.txt", + status: "modified", + additions: 1, + deletions: 1, + patch: expect.stringContaining("-before\n+after"), + }, + { + file: "remove.txt", + status: "deleted", + additions: 0, + deletions: 1, + patch: expect.stringContaining("-remove"), + }, + ], }) expect(assertions).toMatchObject([ { sessionID, action: "edit", resources: ["nested/new.txt", "update.txt", "remove.txt"], save: ["*"] }, diff --git a/packages/core/test/tool-edit.test.ts b/packages/core/test/tool-edit.test.ts index d8f96a5803..be98cc70f9 100644 --- a/packages/core/test/tool-edit.test.ts +++ b/packages/core/test/tool-edit.test.ts @@ -130,6 +130,15 @@ describe("EditTool", () => { resource: "hello.txt", existed: true, replacements: 1, + files: [ + { + file: "hello.txt", + status: "modified", + additions: 1, + deletions: 1, + patch: expect.stringContaining("-before\n+after"), + }, + ], }) expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\nrest\n") expect(assertions).toMatchObject([{ sessionID, action: "edit", resources: ["hello.txt"], save: ["*"] }]) diff --git a/packages/tui/package.json b/packages/tui/package.json index 3f4b6a2d7d..cc30353437 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -54,6 +54,7 @@ "@opentui/core": "catalog:", "@opentui/keymap": "catalog:", "@opentui/solid": "catalog:", + "@solid-primitives/event-bus": "1.1.2", "clipboardy": "4.0.0", "diff": "catalog:", "effect": "catalog:", diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 17ff9625fa..3e946bfb37 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -22,9 +22,13 @@ import { createStore, produce } from "solid-js/store" import { createSimpleContext } from "./helper" import { useSDK } from "./sdk" import { createSignal, onCleanup, onMount } from "solid-js" +import { createGlobalEmitter } from "@solid-primitives/event-bus" export type DataConnectionStatus = "connecting" | "connected" | "reconnecting" +export type DataEvent = V2Event +type DataEventMap = { [T in DataEvent["type"]]: Extract } + type LocationData = { agent?: AgentV2Info[] command?: CommandV2Info[] @@ -82,6 +86,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }) const sdk = useSDK() + const events = createGlobalEmitter() const [defaultLocation, setDefaultLocation] = createSignal({ directory: sdk.directory ?? process.cwd(), }) @@ -402,6 +407,41 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }) }) break + case "permission.v2.asked": + if (store.session.permission[event.data.sessionID]?.some((request) => request.id === event.data.id)) break + setStore("session", "permission", event.data.sessionID, [ + ...(store.session.permission[event.data.sessionID] ?? []), + event.data, + ]) + break + case "permission.v2.replied": + setStore( + "session", + "permission", + event.data.sessionID, + (store.session.permission[event.data.sessionID] ?? []).filter( + (request) => request.id !== event.data.requestID, + ), + ) + break + case "question.v2.asked": + if (store.session.question[event.data.sessionID]?.some((request) => request.id === event.data.id)) break + setStore("session", "question", event.data.sessionID, [ + ...(store.session.question[event.data.sessionID] ?? []), + event.data, + ]) + break + case "question.v2.replied": + case "question.v2.rejected": + setStore( + "session", + "question", + event.data.sessionID, + (store.session.question[event.data.sessionID] ?? []).filter( + (request) => request.id !== event.data.requestID, + ), + ) + break case "reference.updated": void result.location.reference.refresh() break @@ -413,9 +453,12 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ ]) break } + events.emit(event.type, event) } const result = { + on: events.on, + listen: events.listen, connection: { status() { return store.connection.status @@ -454,14 +497,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ } const loaded = await load() const live = new Map((store.session.message[sessionID] ?? []).map((message) => [message.id, message])) - setStore( - "session", - "message", - sessionID, - [...loaded.map((message) => live.get(message.id) ?? message), ...live.values()] - .filter((message, index, messages) => messages.findIndex((item) => item.id === message.id) === index) - .toSorted((a, b) => b.time.created - a.time.created), - ) + const messages = [...loaded.map((message) => live.get(message.id) ?? message), ...live.values()] + .filter((message, index, messages) => messages.findIndex((item) => item.id === message.id) === index) + .toSorted((a, b) => b.time.created - a.time.created) + setStore("session", "message", sessionID, messages) }, }, permission: { diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 7ecb682fbd..399c1d7137 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -59,7 +59,6 @@ import { PermissionPrompt } from "./permission" import { QuestionPrompt } from "./question" import { DialogExportOptions } from "../../ui/dialog-export-options" import { sessionEpilogue } from "../../util/presentation" -import { setPreLayoutSiblingMargin } from "../../util/layout" import { useTuiConfig } from "../../config" import { useClipboard } from "../../context/clipboard" import { nextThinkingMode, reasoningSummary, useThinkingMode, type ThinkingMode } from "../../context/thinking" @@ -69,11 +68,10 @@ import { usePluginRuntime } from "../../plugin/runtime" import { OPENCODE_BASE_MODE, useBindings } from "../../keymap" import { usePathFormatter } from "../../context/path-format" import { LocationProvider } from "../../context/location" +import { createSessionRows, type PartRef, type SessionRow } from "./rows" addDefaultParsers(parsers.parsers) -export const alwaysSeparate = new WeakSet() - const sessionBindingCommands = [ "session.share", "session.rename", @@ -90,6 +88,7 @@ const sessionBindingCommands = [ "session.toggle.actions", "session.toggle.scrollbar", "session.toggle.generic_tool_output", + "session.toggle.exploration_grouping", "session.first", "session.last", "session.messages_last_user", @@ -125,6 +124,7 @@ const context = createContext<{ showTimestamps: () => boolean showDetails: () => boolean showGenericToolOutput: () => boolean + groupExploration: () => boolean diffWrapMode: () => "word" | "none" models: () => ModelV2Info[] tui: ReturnType @@ -198,6 +198,7 @@ export function Session() { const [diffWrapMode] = kv.signal<"word" | "none">("diff_wrap_mode", "word") const [_animationsEnabled, _setAnimationsEnabled] = kv.signal("animations_enabled", true) const [showGenericToolOutput, setShowGenericToolOutput] = kv.signal("generic_tool_output_visibility", false) + const [groupExploration, setGroupExploration] = kv.signal("exploration_grouping", true) const wide = createMemo(() => dimensions().width > 120) const sidebarVisible = createMemo(() => { @@ -214,13 +215,13 @@ export function Session() { const toast = useToast() const sdk = useSDK() const editor = useEditorContext() + const rows = createSessionRows(() => route.sessionID) createEffect(() => { const sessionID = route.sessionID void (async () => { await Promise.all([ data.session.refresh(sessionID), - data.session.message.refresh(sessionID), data.session.permission.refresh(sessionID), data.session.question.refresh(sessionID), ]) @@ -469,6 +470,15 @@ export function Session() { dialog.clear() }, }, + { + title: groupExploration() ? "Show exploration tools individually" : "Group exploration tools", + value: "session.toggle.exploration_grouping", + category: "Session", + run: () => { + setGroupExploration((prev) => !prev) + dialog.clear() + }, + }, { title: "Page up", value: "session.page.up", @@ -807,6 +817,7 @@ export function Session() { showTimestamps, showDetails, showGenericToolOutput, + groupExploration, diffWrapMode, models, tui: tuiConfig, @@ -834,31 +845,8 @@ export function Session() { scrollAcceleration={scrollAcceleration()} > - - {(message, index) => { - const reverted = () => session()?.revert?.messageID - return ( - - - item.id >= message.id && item.type === "user", - ).length} - /> - - = reverted()!}> - <> - - - - - - ) - }} + + {(row) => } @@ -929,17 +917,47 @@ export function Session() { ) } -function SessionMessageView(props: { message: SessionMessage; first: boolean; last: boolean }) { +function SessionRowView(props: { row: SessionRow; messages: () => SessionMessage[] }) { + return ( + + + + {(row) => ( + message.id === row().messageID)}> + {(message) => } + + )} + + + {(row) => } + + + {(row) => } + + + {(row) => ( + message.id === row().messageID)}> + {(message) => ( + + + + )} + + )} + + + + ) +} + +function SessionMessageView(props: { message: SessionMessage }) { return ( - - - - + - + {props.message.type === "shell" ? `$ ${props.message.command}\n${props.message.output}` : ""} @@ -956,6 +974,129 @@ function SessionMessageView(props: { message: SessionMessage; first: boolean; la ) } +function SessionPartView(props: { partRef: PartRef; messages: () => SessionMessage[] }) { + const message = createMemo(() => props.messages().find((message) => message.id === props.partRef.messageID)) + const part = createMemo(() => { + const item = message() + if (item?.type !== "assistant") return + return item.content.find((part) => part.id === props.partRef.partID) + }) + return ( + + {(item) => ( + + + + + + + + + + + + )} + + ) +} + +function SessionGroupView(props: { refs: PartRef[]; completed: boolean; messages: () => SessionMessage[] }) { + const { theme } = useTheme() + const ctx = use() + const renderer = useRenderer() + const [expanded, setExpanded] = createSignal(false) + const parts = createMemo(() => + props.refs.flatMap((ref) => { + const message = props.messages().find((message) => message.id === ref.messageID) + if (message?.type !== "assistant") return [] + const part = message.content.find((part) => part.id === ref.partID) + if (part?.type !== "tool" || part.state.status === "error") return [] + return [part] + }), + ) + const label = createMemo(() => { + const counts = parts().reduce>((result, part) => { + const name = toolDisplay(part.name) + result[name] = (result[name] ?? 0) + 1 + return result + }, {}) + const tools = Object.entries(counts).map(([name, count]) => `${count} ${name}${count === 1 ? "" : "s"}`) + return `${props.completed ? "Explored" : "Exploring"} — ${tools.join(", ")}` + }) + return ( + 0}> + {(part) => }} + > + { + if (renderer.getSelection()?.getSelectedText()) return + setExpanded((value) => !value) + }} + > + {label()} + + + {(part) => } + + + + ) +} + +function AssistantFooter(props: { message: SessionMessageAssistant }) { + const ctx = use() + const local = useLocal() + const { theme } = useTheme() + const model = createMemo( + () => + ctx.models().find( + (model) => model.providerID === props.message.model.providerID && model.id === props.message.model.id, + )?.name ?? `${props.message.model.providerID}/${props.message.model.id}`, + ) + const duration = createMemo(() => + props.message.time.completed ? props.message.time.completed - props.message.time.created : 0, + ) + return ( + <> + + + {errorMessage(props.message.error)} + + + + + + {Locale.titlecase(props.message.agent)} + + · {model()} + + · {Locale.duration(duration())} + + + + + ) +} + function SessionSwitchMessageV2(props: { message: SessionMessage }) { const { theme } = useTheme() const text = () => { @@ -980,7 +1121,6 @@ function CompactionMessage() { const { theme } = useTheme() return ( alwaysSeparate.add(element)} onMouseOver={() => setHover(true)} onMouseOut={() => setHover(false)} onMouseUp={() => { @@ -1005,7 +1144,6 @@ function RevertMessage(props: { count: number }) { toast.show({ message: "Redo is not implemented for V2 sessions yet", variant: "error", duration: 5000 }) dialog.clear() }} - marginTop={1} flexShrink={0} border={["left"]} customBorderChars={SplitBorder.customBorderChars} @@ -1019,10 +1157,7 @@ function RevertMessage(props: { count: number }) { ) } -function UserMessage(props: { - message: SessionMessageUser - index: number -}) { +function UserMessage(props: { message: SessionMessageUser }) { const ctx = use() const local = useLocal() const files = createMemo(() => props.message.files ?? []) @@ -1036,11 +1171,9 @@ function UserMessage(props: { alwaysSeparate.add(el)} border={["left"]} borderColor={color()} customBorderChars={SplitBorder.customBorderChars} - marginTop={props.index === 0 ? 0 : 1} > { @@ -1109,6 +1242,40 @@ function AssistantMessage(props: { message: SessionMessageAssistant; last: boole return props.message.time.completed - props.message.time.created }) + const exploration = createMemo(() => { + const grouped = new Map< + string, + { first: boolean; parts: SessionMessageAssistantTool[]; active: boolean } + >() + if (!ctx.groupExploration()) return grouped + const runs = props.message.content + .map((part) => + part.type === "tool" && + ["read", "glob", "grep"].includes(toolDisplay(part.name)) && + part.state.status !== "pending" && + part.state.status !== "error" + ? part + : undefined, + ) + .reduce( + (runs, part) => { + if (part) runs[runs.length - 1].push(part) + if (!part && runs[runs.length - 1].length) runs.push([]) + return runs + }, + [[]], + ) + .filter((run) => run.length > 0) + for (const run of runs) { + const summary = { + parts: run, + active: run.some((part) => part.state.status !== "completed"), + } + run.forEach((part, index) => grouped.set(part.id, { ...summary, first: index === 0 })) + } + return grouped + }) + return ( <> @@ -1128,19 +1295,24 @@ function AssistantMessage(props: { message: SessionMessageAssistant; last: boole /> - + + } + > + {(summary) => } + + )} alwaysSeparate.add(el)} border={["left"]} paddingTop={1} paddingBottom={1} paddingLeft={2} - marginTop={1} backgroundColor={theme.backgroundPanel} customBorderChars={SplitBorder.customBorderChars} borderColor={theme.error} @@ -1150,8 +1322,8 @@ function AssistantMessage(props: { message: SessionMessageAssistant; last: boole - alwaysSeparate.add(el)} paddingLeft={3}> - + + {Locale.titlecase(props.message.agent)} @@ -1167,6 +1339,40 @@ function AssistantMessage(props: { message: SessionMessageAssistant; last: boole ) } +function ExplorationSummary(props: { parts: SessionMessageAssistantTool[]; active: boolean }) { + const { theme } = useTheme() + const pathFormatter = usePathFormatter() + const label = (part: SessionMessageAssistantTool) => { + const input = typeof part.state.input === "string" ? {} : part.state.input + const tool = toolDisplay(part.name) + if (tool === "read") return `Read ${pathFormatter.format(stringValue(input.path))}` + if (tool === "glob") return `Glob "${stringValue(input.pattern)}"` + return `Grep "${stringValue(input.pattern)}"` + } + return ( + + + {props.active ? "Exploring" : "Explored"} + + + {(part, index) => ( + + + {index() === props.parts.length - 1 ? "└" : "├"} {label(part)} + + + )} + + + ) +} + const INLINE_TOOL_ICON_WIDTH = 2 function ReasoningPart(props: { @@ -1201,9 +1407,7 @@ function ReasoningPart(props: { return ( alwaysSeparate.add(el)} paddingLeft={3} - marginTop={1} flexDirection="column" flexShrink={0} > @@ -1283,7 +1487,7 @@ function TextPart(props: { last: boolean; part: SessionMessageAssistantText }) { const { theme, syntax } = useTheme() return ( - alwaysSeparate.add(el)} paddingLeft={3} marginTop={1} flexShrink={0}> + { + if ( + ctx.groupExploration() && + props.part.state.status === "error" && + ["read", "glob", "grep"].includes(display()) + ) + return true if (ctx.showDetails()) return false if (props.part.state.status !== "completed") return false return true @@ -1436,7 +1646,6 @@ function InlineTool(props: { pending: string failure?: string spinner?: boolean - separate?: boolean children: JSX.Element part: SessionMessageAssistantTool onClick?: () => void @@ -1489,7 +1698,6 @@ function InlineTool(props: { pending={props.pending} failure={props.failure} spinner={props.spinner} - separate={props.separate} onMouseOver={() => clickable() && setHover(true)} onMouseOut={() => setHover(false)} onMouseUp={() => { @@ -1519,7 +1727,6 @@ export function InlineToolRow(props: { pending: string failure?: string spinner?: boolean - separate?: boolean children: JSX.Element onMouseOver?: () => void onMouseOut?: () => void @@ -1531,15 +1738,6 @@ export function InlineToolRow(props: { onMouseOver={props.onMouseOver} onMouseOut={props.onMouseOut} onMouseUp={props.onMouseUp} - ref={(el: BoxRenderable) => { - if (props.separate) alwaysSeparate.add(el) - setPreLayoutSiblingMargin(el, (previous) => { - return props.separate || - (previous instanceof BoxRenderable && (previous.height > 1 || alwaysSeparate.has(previous))) - ? 1 - : 0 - }) - }} > @@ -1599,12 +1797,10 @@ function BlockTool(props: { const error = createMemo(() => (props.part?.state.status === "error" ? props.part.state.error.message : undefined)) return ( alwaysSeparate.add(el)} border={["left"]} paddingTop={1} paddingBottom={1} paddingLeft={2} - marginTop={1} gap={1} backgroundColor={hover() ? theme.backgroundMenu : theme.backgroundPanel} customBorderChars={SplitBorder.customBorderChars} @@ -1640,9 +1836,9 @@ function BlockTool(props: { function Shell(props: ToolProps) { const { theme } = useTheme() - const pathFormatter = usePathFormatter() const ctx = use() const isRunning = createMemo(() => props.part.state.status === "running") + const command = createMemo(() => stringValue(props.input.command)) const output = createMemo(() => stripAnsi(stringValue(props.metadata.output)?.trim() ?? "")) const [expanded, setExpanded] = createSignal(false) const maxLines = 10 @@ -1653,47 +1849,22 @@ function Shell(props: ToolProps) { return collapsed().output }) - const workdirDisplay = createMemo(() => { - const workdir = stringValue(props.input.workdir) - if (!workdir || workdir === ".") return undefined - const formatted = pathFormatter.format(workdir) - if (formatted === ".") return undefined - return formatted - }) - - const title = createMemo(() => { - const wd = workdirDisplay() - if (!wd) return - return `# Running in ${wd}` - }) - return ( - - - setExpanded((prev) => !prev) : undefined} - > - - $ {stringValue(props.input.command)}}> - {stringValue(props.input.command)} - - - {limited()} - - - {expanded() ? "Click to collapse" : "Click to expand"} - - - - - - - {stringValue(props.input.command)} - - - + setExpanded((prev) => !prev) : undefined}> + + Writing command...}> + $ {command()}}> + {command()} + + + + {limited()} + + + {expanded() ? "Click to collapse" : "Click to expand"} + + + ) } @@ -1766,7 +1937,7 @@ function Read(props: ToolProps) { spinner={isRunning()} part={props.part} > - Read {pathFormatter.format(stringValue(props.input.path))} {input(props.input, ["path"])} + Read {pathFormatter.format(stringValue(props.input.path))} {(filepath) => ( @@ -1819,7 +1990,6 @@ function Task(props: ToolProps) { return ( { + const ctx = use() + const { theme, syntax } = useTheme() + const pathFormatter = usePathFormatter() + const files = createMemo(() => parseApplyPatchFiles(props.metadata.files)) + const applied = createMemo(() => { const applied = props.metadata.applied if (!Array.isArray(applied)) return [] return applied.flatMap((value) => { @@ -1920,20 +2093,60 @@ function ApplyPatch(props: ToolProps) { return type && resource ? [{ type, resource }] : [] }) }) + const view = createMemo(() => { + if (ctx.tui.diff_style === "stacked") return "unified" + return ctx.width > 120 ? "split" : "unified" + }) return ( 0}> - - {(file) => ( - - {file.resource} - - )} - + + + {(file) => ( + + + + + + )} + + + + 0}> + + + {(file) => ( + + {file.resource} + + )} + + @@ -2117,13 +2330,18 @@ export function parseApplyPatchFiles(value: unknown) { return value.flatMap((item) => { const file = recordValue(item) if (!file) return [] - const type = stringValue(file.type) - const relativePath = stringValue(file.relativePath) - const filePath = stringValue(file.filePath) + const status = stringValue(file.status) + const type = + stringValue(file.type) ?? + (status === "added" ? "add" : status === "deleted" ? "delete" : status === "modified" ? "update" : undefined) + const relativePath = stringValue(file.file) ?? stringValue(file.relativePath) + const filePath = stringValue(file.filePath) ?? relativePath const patch = stringValue(file.patch) + const additions = numberValue(file.additions) const deletions = numberValue(file.deletions) - if (!type || !relativePath || !filePath || patch === undefined || deletions === undefined) return [] - return [{ type, relativePath, filePath, patch, deletions, movePath: stringValue(file.movePath) }] + if (!type || !relativePath || !filePath || patch === undefined || additions === undefined || deletions === undefined) + return [] + return [{ type, relativePath, filePath, patch, additions, deletions, movePath: stringValue(file.movePath) }] }) } diff --git a/packages/tui/src/routes/session/permission.tsx b/packages/tui/src/routes/session/permission.tsx index 1e20ffac60..0c5e9c4bee 100644 --- a/packages/tui/src/routes/session/permission.tsx +++ b/packages/tui/src/routes/session/permission.tsx @@ -264,8 +264,6 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director if (permission === "bash") { const command = typeof data.command === "string" ? data.command : "" return { - icon: "#", - title: "Shell command", body: ( @@ -381,12 +379,14 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director {"△"} Permission required - - - {current.icon} - - {current.title} - + + + + {current.icon} + + {current.title} + + ) diff --git a/packages/tui/src/routes/session/rows.ts b/packages/tui/src/routes/session/rows.ts new file mode 100644 index 0000000000..86ab41f95d --- /dev/null +++ b/packages/tui/src/routes/session/rows.ts @@ -0,0 +1,163 @@ +import type { SessionMessage, SessionMessageAssistant } from "@opencode-ai/sdk/v2" +import { createEffect, on, onCleanup, type Accessor } from "solid-js" +import { createStore, produce, reconcile } from "solid-js/store" +import { useData } from "../../context/data" + +export type PartRef = { + messageID: string + partID: string +} + +export type SessionRow = + | { type: "message"; messageID: string } + | { type: "part"; ref: PartRef } + | { type: "group"; kind: "exploration"; refs: PartRef[]; completed: boolean } + | { type: "assistant-footer"; messageID: string } + +export function createSessionRows(sessionID: Accessor) { + const data = useData() + const [rows, setRows] = createStore([]) + + createEffect( + on(sessionID, (id) => { + setRows(reconcile(reduceSessionRows(data.session.message.list(id) ?? []))) + void data.session.message.refresh(id).then( + () => { + if (sessionID() !== id) return + setRows(reconcile(reduceSessionRows(data.session.message.list(id) ?? []))) + }, + () => undefined, + ) + }), + ) + + const appendMessage = (messageID: string) => + setRows( + produce((draft) => { + if (draft.some((row) => row.type === "message" && row.messageID === messageID)) return + completePrevious(draft) + draft.push({ type: "message", messageID }) + }), + ) + + const appendPart = (ref: PartRef, name?: string) => + setRows( + produce((draft) => { + if (hasPart(draft, ref)) return + if (name && exploration(name)) { + const previous = draft.at(-1) + if (previous?.type === "group" && previous.kind === "exploration") { + previous.refs.push(ref) + return + } + completePrevious(draft) + draft.push({ type: "group", kind: "exploration", refs: [ref], completed: false }) + return + } + completePrevious(draft) + draft.push({ type: "part", ref }) + }), + ) + + const appendFooter = (messageID: string) => + setRows( + produce((draft) => { + if (draft.some((row) => row.type === "assistant-footer" && row.messageID === messageID)) return + completePrevious(draft) + draft.push({ type: "assistant-footer", messageID }) + }), + ) + + const message = (event: { data: { sessionID: string; messageID: string } }) => { + if (event.data.sessionID === sessionID()) appendMessage(event.data.messageID) + } + const subscriptions = [ + data.on("session.next.prompted", message), + data.on("session.next.context.updated", message), + data.on("session.next.synthetic", message), + data.on("session.next.shell.started", message), + data.on("session.next.agent.switched", message), + data.on("session.next.model.switched", message), + data.on("session.next.compaction.ended", message), + data.on("session.next.text.delta", (event) => { + if (event.data.sessionID === sessionID()) + appendPart({ messageID: event.data.assistantMessageID, partID: event.data.textID }) + }), + data.on("session.next.text.ended", (event) => { + if (event.data.sessionID === sessionID() && event.data.text.trim()) + appendPart({ messageID: event.data.assistantMessageID, partID: event.data.textID }) + }), + data.on("session.next.reasoning.delta", (event) => { + if (event.data.sessionID === sessionID()) + appendPart({ messageID: event.data.assistantMessageID, partID: event.data.reasoningID }) + }), + data.on("session.next.reasoning.ended", (event) => { + if (event.data.sessionID === sessionID() && event.data.text.trim()) + appendPart({ messageID: event.data.assistantMessageID, partID: event.data.reasoningID }) + }), + data.on("session.next.tool.input.started", (event) => { + if (event.data.sessionID === sessionID()) + appendPart({ messageID: event.data.assistantMessageID, partID: event.data.callID }, event.data.name) + }), + data.on("session.next.step.ended", (event) => { + if (event.data.sessionID !== sessionID() || ["tool-calls", "unknown"].includes(event.data.finish)) return + appendFooter(event.data.assistantMessageID) + }), + data.on("session.next.step.failed", (event) => { + if (event.data.sessionID === sessionID()) appendFooter(event.data.assistantMessageID) + }), + ] + onCleanup(() => subscriptions.forEach((unsubscribe) => unsubscribe())) + + return rows +} + +export function reduceSessionRows(messages: SessionMessage[]) { + return messages.toReversed().reduce((rows, message) => { + if (message.type !== "assistant") { + rows.push({ type: "message", messageID: message.id }) + return rows + } + message.content.forEach((part) => { + if ((part.type === "text" || part.type === "reasoning") && !part.text.trim()) return + append(rows, { messageID: message.id, partID: part.id }, part) + }) + if ((message.finish && !["tool-calls", "unknown"].includes(message.finish)) || message.error) + rows.push({ type: "assistant-footer", messageID: message.id }) + return rows + }, []) +} + +function append(rows: SessionRow[], ref: PartRef, part: SessionMessageAssistant["content"][number]) { + if (part.type === "tool") { + if (exploration(part.name)) { + const previous = rows.at(-1) + if (previous?.type === "group" && previous.kind === "exploration") { + previous.refs.push(ref) + return + } + completePrevious(rows) + rows.push({ type: "group", kind: "exploration", refs: [ref], completed: false }) + return + } + } + completePrevious(rows) + rows.push({ type: "part", ref }) +} + +function completePrevious(rows: SessionRow[]) { + const previous = rows.at(-1) + if (previous?.type === "group") previous.completed = true +} + +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 + return row.refs.some((item) => item.messageID === ref.messageID && item.partID === ref.partID) + }) +} diff --git a/packages/tui/src/util/layout.ts b/packages/tui/src/util/layout.ts deleted file mode 100644 index e5e4410a8a..0000000000 --- a/packages/tui/src/util/layout.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { BaseRenderable, BoxRenderable } from "@opentui/core" - -const previousByParent = new WeakMap< - BaseRenderable, - { frameID: number; previous: WeakMap } ->() - -export function setPreLayoutSiblingMargin(el: BoxRenderable, margin: (previous?: BaseRenderable) => number) { - // Run before Yoga layout so scroll geometry matches the rendered frame. - el.onLifecyclePass = () => { - const parent = el.parent - if (!parent) return - const cached = previousByParent.get(parent) - const previous = cached?.frameID === el.ctx.frameId ? cached.previous : previousSiblings(parent, el.ctx.frameId) - const value = margin(previous.get(el)) - if (el.marginTop !== value) el.marginTop = value - } -} - -function previousSiblings(parent: BaseRenderable, frameID: number) { - const previous = new WeakMap() - parent.getChildren().forEach((child, index, children) => previous.set(child, children[index - 1])) - previousByParent.set(parent, { frameID, previous }) - return previous -} diff --git a/packages/tui/test/cli/tui/__snapshots__/inline-tool-wrap-snapshot.test.tsx.snap b/packages/tui/test/cli/tui/__snapshots__/inline-tool-wrap-snapshot.test.tsx.snap index 4b9ec9ccce..ab31e7cd03 100644 --- a/packages/tui/test/cli/tui/__snapshots__/inline-tool-wrap-snapshot.test.tsx.snap +++ b/packages/tui/test/cli/tui/__snapshots__/inline-tool-wrap-snapshot.test.tsx.snap @@ -3,7 +3,6 @@ exports[`TUI inline tool wrapping snapshots consecutive grep, glob, and read rows at a narrow width 1`] = ` " ✱ Grep "OPENCODE.*DB|database|sqlite|drizzle|dev.*db|data. *dir|xdg|APPDATA" in packages/opencode/src (151 matches) - ✱ Glob "**/*db*" in packages/opencode (6 matches) → Read packages/opencode/src/storage/db.ts [offset=1, limit=130] → Read packages/opencode/src/index.ts [offset=1, limit=100] @@ -14,12 +13,10 @@ exports[`TUI inline tool wrapping snapshots consecutive grep, glob, and read row exports[`TUI inline tool wrapping snapshots expanded tool errors under the tool text 1`] = ` " ✱ Grep "OPENCODE.*DB|database|sqlite|drizzle|dev.*db|data. *dir|xdg|APPDATA" in packages/opencode/src (151 matches) - ✱ Glob "**/*db*" in packages/opencode (6 matches) → Read packages/opencode/src/storage/db.ts [offset=1, limit=130] → Read packages/opencode/src/index.ts [offset=1, limit=100] No LSP server available for this file type. - ✱ Grep "export const OPENCODE_DB|OPENCODE_DB|OPENCODE_DEV|Global\\. Path\\.data|data =" in packages/opencode/src (115 matches)" `; @@ -33,7 +30,6 @@ exports[`TUI inline tool wrapping keeps separation after a shell output block 1` ✱ Grep "OPENCODE.*DB|database|sqlite|drizzle|dev.*db|data. *dir|xdg|APPDATA" in packages/opencode/src (151 matches) - ✱ Glob "**/*db*" in packages/opencode (6 matches) → Read packages/opencode/src/storage/db.ts [offset=1, limit=130] → Read packages/opencode/src/index.ts [offset=1, limit=100] @@ -48,7 +44,6 @@ exports[`TUI inline tool wrapping keeps separation after a padded user message 1 ✱ Grep "OPENCODE.*DB|database|sqlite|drizzle|dev.*db|data. *dir|xdg|APPDATA" in packages/opencode/src (151 matches) - ✱ Glob "**/*db*" in packages/opencode (6 matches) → Read packages/opencode/src/storage/db.ts [offset=1, limit=130] → Read packages/opencode/src/index.ts [offset=1, limit=100] diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index f6b19bac20..0bfd45f65b 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -309,6 +309,134 @@ test("refreshes references after updates", async () => { } }) +test("adds and dismisses permission requests from live events", async () => { + const events = createEventSource() + const calls = createFetch(undefined, events) + let data!: ReturnType + + function Probe() { + data = useData() + return + } + + const app = await testRender(() => ( + + + + + + + + + + )) + + try { + await wait(() => data.connection.status() === "connected") + emitEvent(events, { + id: "evt_permission_asked_1", + type: "permission.v2.asked", + properties: { + id: "per_1", + sessionID: "ses_1", + action: "bash", + resources: ["bun test"], + }, + }) + emitEvent(events, { + id: "evt_permission_asked_2", + type: "permission.v2.asked", + properties: { + id: "per_2", + sessionID: "ses_1", + action: "read", + resources: [".env"], + }, + }) + await wait(() => data.session.permission.list("ses_1")?.length === 2) + + emitEvent(events, { + id: "evt_permission_replied_1", + type: "permission.v2.replied", + properties: { sessionID: "ses_1", requestID: "per_1", reply: "once" }, + }) + await wait(() => data.session.permission.list("ses_1")?.length === 1) + expect(data.session.permission.list("ses_1")?.[0]?.id).toBe("per_2") + + emitEvent(events, { + id: "evt_permission_replied_2", + type: "permission.v2.replied", + properties: { sessionID: "ses_1", requestID: "per_2", reply: "reject" }, + }) + await wait(() => data.session.permission.list("ses_1")?.length === 0) + } finally { + app.renderer.destroy() + } +}) + +test("adds and dismisses question requests from live events", async () => { + const events = createEventSource() + const calls = createFetch(undefined, events) + let data!: ReturnType + + function Probe() { + data = useData() + return + } + + const app = await testRender(() => ( + + + + + + + + + + )) + + try { + await wait(() => data.connection.status() === "connected") + emitEvent(events, { + id: "evt_question_asked_1", + type: "question.v2.asked", + properties: { + id: "que_1", + sessionID: "ses_1", + questions: [{ question: "Which option?", header: "Option", options: [], multiple: false }], + }, + }) + emitEvent(events, { + id: "evt_question_asked_2", + type: "question.v2.asked", + properties: { + id: "que_2", + sessionID: "ses_1", + questions: [{ question: "Which environment?", header: "Environment", options: [], multiple: false }], + }, + }) + await wait(() => data.session.question.list("ses_1")?.length === 2) + + emitEvent(events, { + id: "evt_question_replied_1", + type: "question.v2.replied", + properties: { sessionID: "ses_1", requestID: "que_1", answers: [["First"]] }, + }) + await wait(() => data.session.question.list("ses_1")?.length === 1) + expect(data.session.question.list("ses_1")?.[0]?.id).toBe("que_2") + + emitEvent(events, { + id: "evt_question_rejected_2", + type: "question.v2.rejected", + properties: { sessionID: "ses_1", requestID: "que_2" }, + }) + await wait(() => data.session.question.list("ses_1")?.length === 0) + } finally { + app.renderer.destroy() + } +}) + test("settles pending tools when a live failure arrives", async () => { const events = createEventSource() const calls = createFetch(undefined, events) @@ -467,6 +595,8 @@ test("renders admitted prompts only after they become model-visible", async () = try { await mounted + const received: string[] = [] + const unsubscribe = sync.listen((event) => received.push(event.name)) emitEvent(events, { id: "evt_admitted_1", type: "session.next.prompt.admitted", @@ -493,10 +623,13 @@ test("renders admitted prompts only after they become model-visible", async () = }) await wait(() => sync.session.message.list("session-1")?.length === 1) + expect(received.slice(-2)).toEqual(["session.next.prompt.admitted", "session.next.prompted"]) + unsubscribe() const message = sync.session.message.list("session-1")?.[0] expect(message?.type).toBe("user") if (message?.type !== "user") return expect(message).toMatchObject({ id: "msg_user_1", text: "hello" }) + expect(received).toHaveLength(3) } finally { app.renderer.destroy() } diff --git a/packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx b/packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx index e1ad5dc70c..dee4c406ff 100644 --- a/packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx +++ b/packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx @@ -1,6 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test" -import { createSignal, For, Show } from "solid-js" -import type { BoxRenderable, ScrollBoxRenderable } from "@opentui/core" +import { For } from "solid-js" import { testRender, type JSX } from "@opentui/solid" import { formatCompletedSubagentDetail, @@ -13,7 +12,6 @@ import { parseQuestionAnswers, parseQuestions, parseTodos, - alwaysSeparate, toolDisplay, } from "../../../src/routes/session" @@ -52,40 +50,10 @@ const tools: readonly ToolFixture[] = [ }, ] as const -function ShellOutput() { - return ( - alwaysSeparate.add(el)} - marginTop={1} - paddingTop={1} - paddingBottom={1} - paddingLeft={2} - gap={1} - > - - $ ls - file.ts - - - ) -} - -function UserMessage() { - return ( - alwaysSeparate.add(el)}> - - Check whether the next tool remains separated. - - - ) -} - -function Fixture(props: { errorExpanded?: boolean; before?: "shell" | "user" }) { +function Fixture(props: { errorExpanded?: boolean }) { return ( - {props.before === "shell" && } - {props.before === "user" && } {(item) => ( - - Grep "Task" (2 matches) - - - Explore Task — Inspect active task spacing - - - {"General Task — Confirm completed task spacing\n↳ 1 toolcall · 501ms"} - - - Read src/cli/cmd/tui/routes/session/index.tsx - - - ) -} - -function LoadedReadBeforeTaskFixture() { - return ( - - - Read src/cli/cmd/tui/routes/session/index.tsx - - - ↳ Loaded src/cli/cmd/tui/routes/session/tools.tsx - - - {"Explore Task — Inspect active task spacing\n↳ 1 toolcall · 501ms"} - - - ) -} - -function AssistantSummaryBeforeInlineFixture() { - return ( - - alwaysSeparate.add(el)} paddingLeft={3}> - Build · Little Frank · 53.1s - - - {"Build Task — Review changes\n↳ 48 toolcalls · 1m 40s"} - - - ) -} - -function AssistantErrorBeforeInlineFixture() { - return ( - - alwaysSeparate.add(el)} - border={["left"]} - paddingTop={1} - paddingBottom={1} - paddingLeft={2} - > - Managed inference requires an active Member plan - - - {"Build Task — Review changes\n↳ 48 toolcalls · 1m 40s"} - - - ) -} - -function StickyScrollFixture(props: { separated: boolean; scroll: (scroll: ScrollBoxRenderable) => void }) { - return ( - - - First row - - - Second row - - - alwaysSeparate.add(el)}> - Assistant text - - - - Read src/cli/cmd/tui/routes/session/index.tsx - - - ) -} - function FailedPendingToolFixture() { return ( @@ -245,10 +125,18 @@ describe("TUI inline tool wrapping", () => { parseApplyPatchFiles([ null, { type: "add" }, - { type: "add", relativePath: "a.ts", filePath: "a.ts", patch: "diff", deletions: 0 }, + { file: "a.ts", patch: "diff", additions: 1, deletions: 0, status: "added" }, ]), ).toEqual([ - { type: "add", relativePath: "a.ts", filePath: "a.ts", patch: "diff", deletions: 0, movePath: undefined }, + { + type: "add", + relativePath: "a.ts", + filePath: "a.ts", + patch: "diff", + additions: 1, + deletions: 0, + movePath: undefined, + }, ]) expect(parseTodos([null, { status: "pending" }, { status: "pending", content: "Safe" }])).toEqual([ { status: "pending", content: "Safe" }, @@ -299,53 +187,4 @@ describe("TUI inline tool wrapping", () => { expect(await renderFrame(() => , { width: 72, height: 12 })).toMatchSnapshot() }) - test("keeps separation after a shell output block", async () => { - expect(await renderFrame(() => , { width: 72, height: 16 })).toMatchSnapshot() - }) - - test("keeps separation after a padded user message", async () => { - expect(await renderFrame(() => , { width: 72, height: 14 })).toMatchSnapshot() - }) - - test("separates after a multi-line task row", async () => { - expect(await renderFrame(() => , { width: 72, height: 10 })).toMatchSnapshot() - }) - - test("separates a task row from a preceding inline detail", async () => { - expect(await renderFrame(() => , { width: 72, height: 8 })).toMatchSnapshot() - }) - - test("separates an inline row from the previous assistant summary", async () => { - expect(await renderFrame(() => , { width: 72, height: 5 })).toMatchSnapshot() - }) - - test("separates an inline row from the previous assistant error", async () => { - expect(await renderFrame(() => , { width: 72, height: 7 })).toMatchSnapshot() - }) - - test("updates sticky-bottom geometry when a text separator mounts and unmounts", async () => { - const [separated, setSeparated] = createSignal(false) - let scroll: ScrollBoxRenderable | undefined - testSetup = await testRender( - () => (scroll = value)} />, - { - width: 72, - height: 3, - }, - ) - - await testSetup.renderOnce() - expect(scroll?.scrollHeight).toBe(3) - expect(scroll?.scrollTop).toBe(Math.max(0, scroll!.scrollHeight - scroll!.viewport.height)) - - setSeparated(true) - await testSetup.renderOnce() - expect(scroll?.scrollHeight).toBe(5) - expect(scroll?.scrollTop).toBe(Math.max(0, scroll!.scrollHeight - scroll!.viewport.height)) - - setSeparated(false) - await testSetup.renderOnce() - expect(scroll?.scrollHeight).toBe(3) - expect(scroll?.scrollTop).toBe(Math.max(0, scroll!.scrollHeight - scroll!.viewport.height)) - }) }) diff --git a/packages/tui/test/cli/tui/session-rows.test.ts b/packages/tui/test/cli/tui/session-rows.test.ts new file mode 100644 index 0000000000..7f45943ea9 --- /dev/null +++ b/packages/tui/test/cli/tui/session-rows.test.ts @@ -0,0 +1,101 @@ +import { expect, test } from "bun:test" +import type { SessionMessage, SessionMessageAssistant } from "@opencode-ai/sdk/v2" +import { reduceSessionRows } from "../../../src/routes/session/rows" + +test("groups exploration parts across assistant messages until a delimiter", () => { + const messages: SessionMessage[] = [ + assistant("assistant-2", [ + { type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 5 } }, + { type: "text", id: "text-2", text: "Done" }, + ]), + assistant("assistant-1", [ + { type: "text", id: "text-1", text: "Looking" }, + { type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 2 } }, + { type: "tool", id: "glob-1", name: "glob", state: pending(), time: { created: 3 } }, + ]), + { type: "user", id: "user-1", text: "Explore", time: { created: 0 } }, + ] + + expect(reduceSessionRows(messages)).toEqual([ + { type: "message", messageID: "user-1" }, + { type: "part", ref: { messageID: "assistant-1", partID: "text-1" } }, + { + type: "group", + kind: "exploration", + completed: true, + refs: [ + { messageID: "assistant-1", partID: "read-1" }, + { messageID: "assistant-1", partID: "glob-1" }, + { messageID: "assistant-2", partID: "grep-1" }, + ], + }, + { type: "part", ref: { messageID: "assistant-2", partID: "text-2" } }, + ]) +}) + +test("keeps non-exploration tools as individual part rows", () => { + const messages: SessionMessage[] = [ + assistant("assistant-1", [ + { type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 1 } }, + { type: "tool", id: "bash-1", name: "bash", state: pending(), time: { created: 2 } }, + { type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 3 } }, + ]), + ] + + expect(reduceSessionRows(messages)).toEqual([ + { + type: "group", + kind: "exploration", + completed: true, + refs: [{ messageID: "assistant-1", partID: "read-1" }], + }, + { type: "part", ref: { messageID: "assistant-1", partID: "bash-1" } }, + { + type: "group", + kind: "exploration", + completed: false, + refs: [{ messageID: "assistant-1", partID: "grep-1" }], + }, + ]) +}) + +test("groups across empty assistant reasoning parts", () => { + const messages: SessionMessage[] = [ + assistant("assistant-2", [ + { type: "reasoning", id: "reasoning-2", text: "" }, + { type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 3 } }, + ]), + assistant("assistant-1", [ + { type: "reasoning", id: "reasoning-1", text: "Looking" }, + { type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 2 } }, + ]), + ] + + expect(reduceSessionRows(messages)).toEqual([ + { type: "part", ref: { messageID: "assistant-1", partID: "reasoning-1" } }, + { + type: "group", + kind: "exploration", + completed: false, + refs: [ + { messageID: "assistant-1", partID: "read-1" }, + { messageID: "assistant-2", partID: "grep-1" }, + ], + }, + ]) +}) + +function assistant(id: string, content: SessionMessageAssistant["content"]): SessionMessageAssistant { + return { + type: "assistant", + id, + agent: "build", + model: { id: "model", providerID: "provider" }, + content, + time: { created: 1 }, + } +} + +function pending() { + return { status: "pending" as const, input: "" } +}