feat(tui): improve v2 session rendering

This commit is contained in:
Dax Raad
2026-06-25 21:16:40 -04:00
parent c55b5f5279
commit 0dfcd7352a
18 changed files with 918 additions and 358 deletions
+2
View File
@@ -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:",
+4
View File
@@ -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),
@@ -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"))
+1
View File
@@ -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",
+45 -11
View File
@@ -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<Patch.Hunk, { readonly type: "add" | "delete" }> & { readonly target: LocationMutation.Target })
| (Extract<Patch.Hunk, { readonly type: "add" | "delete" }> & {
readonly target: LocationMutation.Target
readonly before: string
readonly after: string
})
| (Extract<Patch.Hunk, { readonly type: "update" }> & {
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,
}
}
+22 -1
View File
@@ -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
})
},
}),
@@ -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: ["*"] },
+9
View File
@@ -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: ["*"] }])
+1
View File
@@ -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:",
+47 -8
View File
@@ -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<DataEvent, { type: T }> }
type LocationData = {
agent?: AgentV2Info[]
command?: CommandV2Info[]
@@ -82,6 +86,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
})
const sdk = useSDK()
const events = createGlobalEmitter<DataEventMap>()
const [defaultLocation, setDefaultLocation] = createSignal<LocationRef>({
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: {
+344 -126
View File
@@ -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<BoxRenderable>()
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<typeof useTuiConfig>
@@ -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()}
>
<box height={1} />
<For each={sessionMessages()}>
{(message, index) => {
const reverted = () => session()?.revert?.messageID
return (
<Switch>
<Match when={message.id === reverted()}>
<RevertMessage
count={sessionMessages().filter(
(item) => item.id >= message.id && item.type === "user",
).length}
/>
</Match>
<Match when={reverted() && message.id >= reverted()!}>
<></>
</Match>
<Match when={true}>
<SessionMessageView
message={message}
first={index() === 0}
last={index() === sessionMessages().length - 1}
/>
</Match>
</Switch>
)
}}
<For each={rows}>
{(row) => <SessionRowView row={row} messages={messages} />}
</For>
</scrollbox>
<box flexShrink={0}>
@@ -929,17 +917,47 @@ export function Session() {
)
}
function SessionMessageView(props: { message: SessionMessage; first: boolean; last: boolean }) {
function SessionRowView(props: { row: SessionRow; messages: () => SessionMessage[] }) {
return (
<box marginTop={1} flexShrink={0}>
<Switch>
<Match when={props.row.type === "message" ? props.row : undefined}>
{(row) => (
<Show when={props.messages().find((message) => message.id === row().messageID)}>
{(message) => <SessionMessageView message={message()} />}
</Show>
)}
</Match>
<Match when={props.row.type === "part" ? props.row : undefined}>
{(row) => <SessionPartView partRef={row().ref} messages={props.messages} />}
</Match>
<Match when={props.row.type === "group" ? props.row : undefined}>
{(row) => <SessionGroupView refs={row().refs} completed={row().completed} messages={props.messages} />}
</Match>
<Match when={props.row.type === "assistant-footer" ? props.row : undefined}>
{(row) => (
<Show when={props.messages().find((message) => message.id === row().messageID)}>
{(message) => (
<Show when={message().type === "assistant"}>
<AssistantFooter message={message() as SessionMessageAssistant} />
</Show>
)}
</Show>
)}
</Match>
</Switch>
</box>
)
}
function SessionMessageView(props: { message: SessionMessage }) {
return (
<Switch>
<Match when={props.message.type === "user"}>
<UserMessage message={props.message as SessionMessageUser} index={props.first ? 0 : 1} />
</Match>
<Match when={props.message.type === "assistant"}>
<AssistantMessage message={props.message as SessionMessageAssistant} last={props.last} />
<UserMessage message={props.message as SessionMessageUser} />
</Match>
<Match when={props.message.type === "shell"}>
<box paddingLeft={3} marginTop={1}>
<box paddingLeft={3}>
<text>{props.message.type === "shell" ? `$ ${props.message.command}\n${props.message.output}` : ""}</text>
</box>
</Match>
@@ -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 (
<Show when={part()}>
{(item) => (
<Switch>
<Match when={item().type === "text"}>
<TextPart part={item() as SessionMessageAssistantText} last={false} />
</Match>
<Match when={item().type === "reasoning"}>
<ReasoningPart
part={item() as SessionMessageAssistantReasoning}
message={message() as SessionMessageAssistant}
last={false}
/>
</Match>
<Match when={item().type === "tool"}>
<ToolPart part={item() as SessionMessageAssistantTool} />
</Match>
</Switch>
)}
</Show>
)
}
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<Record<string, number>>((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 (
<Show when={parts().length > 0}>
<Show
when={ctx.groupExploration()}
fallback={<For each={parts()}>{(part) => <ToolPart part={part} />}</For>}
>
<InlineToolRow
icon="✱"
color={theme.textMuted}
complete={props.completed}
pending={label()}
spinner={!props.completed}
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
setExpanded((value) => !value)
}}
>
{label()}
</InlineToolRow>
<Show when={expanded()}>
<For each={parts()}>{(part) => <ToolPart part={part} />}</For>
</Show>
</Show>
</Show>
)
}
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 (
<>
<Show when={props.message.error}>
<box
border={["left"]}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
backgroundColor={theme.backgroundPanel}
customBorderChars={SplitBorder.customBorderChars}
borderColor={theme.error}
>
<text fg={theme.textMuted}>{errorMessage(props.message.error)}</text>
</box>
</Show>
<box paddingLeft={3} marginTop={props.message.error ? 1 : 0}>
<text>
<span style={{ fg: props.message.error ? theme.textMuted : local.agent.color(props.message.agent) }}>
{Locale.titlecase(props.message.agent)}
</span>
<span style={{ fg: theme.textMuted }}> · {model()}</span>
<Show when={duration()}>
<span style={{ fg: theme.textMuted }}> · {Locale.duration(duration())}</span>
</Show>
</text>
</box>
</>
)
}
function SessionSwitchMessageV2(props: { message: SessionMessage }) {
const { theme } = useTheme()
const text = () => {
@@ -980,7 +1121,6 @@ function CompactionMessage() {
const { theme } = useTheme()
return (
<box
marginTop={1}
border={["top"]}
title=" Compaction "
titleAlignment="center"
@@ -997,7 +1137,6 @@ function RevertMessage(props: { count: number }) {
const [hover, setHover] = createSignal(false)
return (
<box
ref={(element: BoxRenderable) => 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: {
<Show when={props.message.text.trim() || files().length}>
<box
id={props.message.id}
ref={(el: BoxRenderable) => alwaysSeparate.add(el)}
border={["left"]}
borderColor={color()}
customBorderChars={SplitBorder.customBorderChars}
marginTop={props.index === 0 ? 0 : 1}
>
<box
onMouseOver={() => {
@@ -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<SessionMessageAssistantTool[][]>(
(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 (
<>
<For each={props.message.content}>
@@ -1128,19 +1295,24 @@ function AssistantMessage(props: { message: SessionMessageAssistant; last: boole
/>
</Match>
<Match when={content.type === "tool"}>
<ToolPart part={content as SessionMessageAssistantTool} />
<Show when={exploration().get((content as SessionMessageAssistantTool).id)?.first !== false}>
<Show
when={exploration().get((content as SessionMessageAssistantTool).id)}
fallback={<ToolPart part={content as SessionMessageAssistantTool} />}
>
{(summary) => <ExplorationSummary {...summary()} />}
</Show>
</Show>
</Match>
</Switch>
)}
</For>
<Show when={props.message.error}>
<box
ref={(el: BoxRenderable) => 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
</Show>
<Switch>
<Match when={props.last || final() || props.message.error}>
<box ref={(el: BoxRenderable) => alwaysSeparate.add(el)} paddingLeft={3}>
<text marginTop={1}>
<box paddingLeft={3}>
<text>
<span style={{ fg: props.message.error ? theme.textMuted : local.agent.color(props.message.agent) }}>
{Locale.titlecase(props.message.agent)}
</span>
@@ -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 (
<box flexDirection="column">
<InlineToolRow
icon="✱"
color={theme.textMuted}
complete={!props.active}
pending="Exploring"
spinner={props.active}
>
{props.active ? "Exploring" : "Explored"}
</InlineToolRow>
<For each={props.parts}>
{(part, index) => (
<box paddingLeft={5}>
<text fg={theme.textMuted}>
{index() === props.parts.length - 1 ? "└" : "├"} {label(part)}
</text>
</box>
)}
</For>
</box>
)
}
const INLINE_TOOL_ICON_WIDTH = 2
function ReasoningPart(props: {
@@ -1201,9 +1407,7 @@ function ReasoningPart(props: {
return (
<Show when={content()}>
<box
ref={(el: BoxRenderable) => 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 (
<Show when={props.part.text.trim()}>
<box ref={(el: BoxRenderable) => alwaysSeparate.add(el)} paddingLeft={3} marginTop={1} flexShrink={0}>
<box paddingLeft={3} flexShrink={0}>
<markdown
syntaxStyle={syntax()}
streaming={true}
@@ -1307,6 +1511,12 @@ function ToolPart(props: { part: SessionMessageAssistantTool }) {
// Hide tool if showDetails is false and tool completed successfully
const shouldHide = createMemo(() => {
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
})
}}
>
<Switch>
<Match when={props.spinner}>
@@ -1599,12 +1797,10 @@ function BlockTool(props: {
const error = createMemo(() => (props.part?.state.status === "error" ? props.part.state.error.message : undefined))
return (
<box
ref={(el: BoxRenderable) => 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 (
<Switch>
<Match when={stringValue(props.metadata.output) !== undefined}>
<BlockTool
title={title()}
part={props.part}
onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}
>
<box gap={1}>
<Show when={isRunning()} fallback={<text fg={theme.text}>$ {stringValue(props.input.command)}</text>}>
<Spinner color={theme.text}>{stringValue(props.input.command)}</Spinner>
</Show>
<Show when={output()}>
<text fg={theme.text}>{limited()}</text>
</Show>
<Show when={collapsed().overflow}>
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
</Show>
</box>
</BlockTool>
</Match>
<Match when={true}>
<InlineTool icon="$" pending="Writing command..." complete={stringValue(props.input.command)} part={props.part}>
{stringValue(props.input.command)}
</InlineTool>
</Match>
</Switch>
<BlockTool part={props.part} onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}>
<box gap={1}>
<Show when={command()} fallback={<Spinner color={theme.text}>Writing command...</Spinner>}>
<Show when={isRunning()} fallback={<text fg={theme.textMuted}>$ {command()}</text>}>
<Spinner color={theme.text}>{command()}</Spinner>
</Show>
</Show>
<Show when={output()}>
<text fg={theme.text}>{limited()}</text>
</Show>
<Show when={collapsed().overflow}>
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
</Show>
</box>
</BlockTool>
)
}
@@ -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))}
</InlineTool>
<For each={loaded()}>
{(filepath) => (
@@ -1819,7 +1990,6 @@ function Task(props: ToolProps) {
return (
<InlineTool
icon={props.part.state.status === "completed" ? "✓" : "│"}
separate={true}
spinner={props.part.state.status === "running"}
complete={description()}
pending="Delegating..."
@@ -1909,8 +2079,11 @@ function Edit(props: ToolProps) {
}
function ApplyPatch(props: ToolProps) {
const { theme } = useTheme()
const files = createMemo(() => {
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 (
<Switch>
<Match when={files().length > 0}>
<For each={files()}>
{(file) => (
<BlockTool
title={`${file.type === "add" ? "# Created" : file.type === "delete" ? "# Deleted" : "← Patched"} ${file.resource}`}
part={props.part}
>
<text fg={file.type === "delete" ? theme.diffRemoved : theme.textMuted}>{file.resource}</text>
</BlockTool>
)}
</For>
<box flexDirection="column" gap={1}>
<For each={files()}>
{(file) => (
<BlockTool
title={`${file.type === "add" ? "# Created" : file.type === "delete" ? "# Deleted" : "← Patched"} ${pathFormatter.format(file.relativePath)}`}
part={props.part}
>
<box paddingLeft={1}>
<diff
diff={file.patch}
view={view()}
filetype={filetype(file.relativePath)}
syntaxStyle={syntax()}
showLineNumbers={true}
width="100%"
wrapMode={ctx.diffWrapMode()}
fg={theme.text}
addedBg={theme.diffAddedBg}
removedBg={theme.diffRemovedBg}
contextBg={theme.diffContextBg}
addedSignColor={theme.diffHighlightAdded}
removedSignColor={theme.diffHighlightRemoved}
lineNumberFg={theme.diffLineNumber}
lineNumberBg={theme.diffContextBg}
addedLineNumberBg={theme.diffAddedLineNumberBg}
removedLineNumberBg={theme.diffRemovedLineNumberBg}
/>
</box>
</BlockTool>
)}
</For>
</box>
</Match>
<Match when={applied().length > 0}>
<box flexDirection="column" gap={1}>
<For each={applied()}>
{(file) => (
<BlockTool
title={`${file.type === "add" ? "# Created" : file.type === "delete" ? "# Deleted" : "← Patched"} ${pathFormatter.format(file.resource)}`}
part={props.part}
>
<text fg={file.type === "delete" ? theme.diffRemoved : theme.textMuted}>{file.resource}</text>
</BlockTool>
)}
</For>
</box>
</Match>
<Match when={true}>
<InlineTool icon="%" pending="Preparing patch..." failure="Patch failed" complete={false} part={props.part}>
@@ -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) }]
})
}
@@ -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: (
<Show when={command}>
<box paddingLeft={1}>
@@ -381,12 +379,14 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director
<text fg={theme.warning}>{"△"}</text>
<text fg={theme.text}>Permission required</text>
</box>
<box flexDirection="row" gap={1} paddingLeft={2} flexShrink={0}>
<text fg={theme.textMuted} flexShrink={0}>
{current.icon}
</text>
<text fg={theme.text}>{current.title}</text>
</box>
<Show when={current.title}>
<box flexDirection="row" gap={1} paddingLeft={2} flexShrink={0}>
<text fg={theme.textMuted} flexShrink={0}>
{current.icon}
</text>
<text fg={theme.text}>{current.title}</text>
</box>
</Show>
</box>
)
+163
View File
@@ -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<string>) {
const data = useData()
const [rows, setRows] = createStore<SessionRow[]>([])
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<SessionRow[]>((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)
})
}
-25
View File
@@ -1,25 +0,0 @@
import type { BaseRenderable, BoxRenderable } from "@opentui/core"
const previousByParent = new WeakMap<
BaseRenderable,
{ frameID: number; previous: WeakMap<BaseRenderable, BaseRenderable | undefined> }
>()
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<BaseRenderable, BaseRenderable | undefined>()
parent.getChildren().forEach((child, index, children) => previous.set(child, children[index - 1]))
previousByParent.set(parent, { frameID, previous })
return previous
}
@@ -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]
+133
View File
@@ -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<typeof useData>
function Probe() {
data = useData()
return <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</SDKProvider>
</TestTuiContexts>
))
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<typeof useData>
function Probe() {
data = useData()
return <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</SDKProvider>
</TestTuiContexts>
))
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()
}
@@ -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 (
<box
ref={(el: BoxRenderable) => alwaysSeparate.add(el)}
marginTop={1}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
gap={1}
>
<box gap={1}>
<text>$ ls</text>
<text>file.ts</text>
</box>
</box>
)
}
function UserMessage() {
return (
<box ref={(el: BoxRenderable) => alwaysSeparate.add(el)}>
<box paddingTop={1} paddingBottom={1} paddingLeft={2}>
<text>Check whether the next tool remains separated.</text>
</box>
</box>
)
}
function Fixture(props: { errorExpanded?: boolean; before?: "shell" | "user" }) {
function Fixture(props: { errorExpanded?: boolean }) {
return (
<box flexDirection="column" width={72}>
<box flexDirection="column">
{props.before === "shell" && <ShellOutput />}
{props.before === "user" && <UserMessage />}
<For each={tools}>
{(item) => (
<InlineToolRow
@@ -105,94 +73,6 @@ function Fixture(props: { errorExpanded?: boolean; before?: "shell" | "user" })
)
}
function TaskRowsFixture() {
return (
<box flexDirection="column" width={72}>
<InlineToolRow icon="✱" complete={true} pending="">
Grep "Task" (2 matches)
</InlineToolRow>
<InlineToolRow icon="⠙" complete={true} pending="" separate={true}>
Explore Task Inspect active task spacing
</InlineToolRow>
<InlineToolRow icon="✓" complete={true} pending="" separate={true}>
{"General Task — Confirm completed task spacing\n↳ 1 toolcall · 501ms"}
</InlineToolRow>
<InlineToolRow icon="→" complete={true} pending="">
Read src/cli/cmd/tui/routes/session/index.tsx
</InlineToolRow>
</box>
)
}
function LoadedReadBeforeTaskFixture() {
return (
<box flexDirection="column" width={72}>
<InlineToolRow icon="→" complete={true} pending="">
Read src/cli/cmd/tui/routes/session/index.tsx
</InlineToolRow>
<box paddingLeft={3}>
<text paddingLeft={3}> Loaded src/cli/cmd/tui/routes/session/tools.tsx</text>
</box>
<InlineToolRow icon="✓" complete={true} pending="" separate={true}>
{"Explore Task — Inspect active task spacing\n↳ 1 toolcall · 501ms"}
</InlineToolRow>
</box>
)
}
function AssistantSummaryBeforeInlineFixture() {
return (
<box flexDirection="column" width={72}>
<box ref={(el: BoxRenderable) => alwaysSeparate.add(el)} paddingLeft={3}>
<text>Build · Little Frank · 53.1s</text>
</box>
<InlineToolRow icon="✓" complete={true} pending="">
{"Build Task — Review changes\n↳ 48 toolcalls · 1m 40s"}
</InlineToolRow>
</box>
)
}
function AssistantErrorBeforeInlineFixture() {
return (
<box flexDirection="column" width={72}>
<box
ref={(el: BoxRenderable) => alwaysSeparate.add(el)}
border={["left"]}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
>
<text>Managed inference requires an active Member plan</text>
</box>
<InlineToolRow icon="✓" complete={true} pending="">
{"Build Task — Review changes\n↳ 48 toolcalls · 1m 40s"}
</InlineToolRow>
</box>
)
}
function StickyScrollFixture(props: { separated: boolean; scroll: (scroll: ScrollBoxRenderable) => void }) {
return (
<scrollbox ref={props.scroll} stickyScroll={true} stickyStart="bottom" height={3} width={72}>
<box height={1}>
<text>First row</text>
</box>
<box height={1}>
<text>Second row</text>
</box>
<Show when={props.separated}>
<box ref={(el: BoxRenderable) => alwaysSeparate.add(el)}>
<text>Assistant text</text>
</box>
</Show>
<InlineToolRow icon="→" complete={true} pending="">
Read src/cli/cmd/tui/routes/session/index.tsx
</InlineToolRow>
</scrollbox>
)
}
function FailedPendingToolFixture() {
return (
<InlineToolRow icon="%" complete={false} pending="Preparing patch..." failed={true} failure="Patch failed">
@@ -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(() => <Fixture errorExpanded />, { width: 72, height: 12 })).toMatchSnapshot()
})
test("keeps separation after a shell output block", async () => {
expect(await renderFrame(() => <Fixture before="shell" />, { width: 72, height: 16 })).toMatchSnapshot()
})
test("keeps separation after a padded user message", async () => {
expect(await renderFrame(() => <Fixture before="user" />, { width: 72, height: 14 })).toMatchSnapshot()
})
test("separates after a multi-line task row", async () => {
expect(await renderFrame(() => <TaskRowsFixture />, { width: 72, height: 10 })).toMatchSnapshot()
})
test("separates a task row from a preceding inline detail", async () => {
expect(await renderFrame(() => <LoadedReadBeforeTaskFixture />, { width: 72, height: 8 })).toMatchSnapshot()
})
test("separates an inline row from the previous assistant summary", async () => {
expect(await renderFrame(() => <AssistantSummaryBeforeInlineFixture />, { width: 72, height: 5 })).toMatchSnapshot()
})
test("separates an inline row from the previous assistant error", async () => {
expect(await renderFrame(() => <AssistantErrorBeforeInlineFixture />, { 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(
() => <StickyScrollFixture separated={separated()} scroll={(value) => (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))
})
})
@@ -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: "" }
}