feat(tui): improve v2 session rendering
This commit is contained in:
@@ -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:",
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: ["*"] },
|
||||
|
||||
@@ -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: ["*"] }])
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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: "" }
|
||||
}
|
||||
Reference in New Issue
Block a user