feat(tui): migrate session views to v2 data

This commit is contained in:
Dax Raad
2026-06-24 19:20:36 -04:00
parent 55ef266313
commit 4086b77eb2
25 changed files with 560 additions and 1284 deletions
+5 -1
View File
@@ -1,3 +1,7 @@
export * as PublicEventManifest from "./public-event-manifest"
export { ServerDefinitions as Definitions } from "@opencode-ai/schema/event-manifest"
import { Event } from "@opencode-ai/schema/event"
import { EventManifest } from "@opencode-ai/schema/event-manifest"
export const Definitions = EventManifest.ServerDefinitions
export const Latest = Event.latest(Definitions)
+7 -1
View File
@@ -361,7 +361,13 @@ export const layer = Layer.effect(
})
}),
switchModel: Effect.fn("V2Session.switchModel")(function* (input) {
yield* result.get(input.sessionID)
const session = yield* result.get(input.sessionID)
if (
session.model?.providerID === input.model.providerID &&
session.model.id === input.model.id &&
(session.model.variant ?? "default") === (input.model.variant ?? "default")
)
return
yield* events.publish(SessionEvent.ModelSwitched, {
sessionID: input.sessionID,
messageID: SessionMessage.ID.create(),
+1 -1
View File
@@ -132,7 +132,7 @@ export const fromCatalogModel = (
credential?: Credential.Value,
): Effect.Effect<Model, UnsupportedApiError> => {
const resolved =
credential?.metadata === undefined
credential?.type !== "key" || credential.metadata === undefined
? model
: produce(model, (draft) => {
Object.assign(draft.request.body, credential.metadata)
+20 -2
View File
@@ -375,7 +375,7 @@ describe("SessionV2.create", () => {
}),
)
it.effect("persists repeated switches as distinct durable Session events", () =>
it.effect("ignores a model switch when the selected model is unchanged", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const created = yield* session.create({ location })
@@ -387,11 +387,29 @@ describe("SessionV2.create", () => {
const { db } = yield* Database.Service
expect(
yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, created.id)).all().pipe(Effect.orDie),
).toHaveLength(3)
).toHaveLength(2)
expect(yield* session.get(created.id)).toMatchObject({ model })
}),
)
it.effect("treats an omitted variant as the default variant", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const model = ModelV2.Ref.make({ id: ModelV2.ID.make("sonnet"), providerID: ProviderV2.ID.anthropic })
const created = yield* session.create({ location, model })
yield* session.switchModel({
sessionID: created.id,
model: ModelV2.Ref.make({ ...model, variant: ModelV2.VariantID.make("default") }),
})
const { db } = yield* Database.Service
expect(
yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, created.id)).all().pipe(Effect.orDie),
).toHaveLength(1)
}),
)
it.effect("rejects a model switch for a missing Session", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
@@ -4,6 +4,7 @@ import { LLMClient } from "@opencode-ai/llm/route"
import { DateTime, Effect } from "effect"
import { Headers } from "effect/unstable/http"
import { Credential } from "@opencode-ai/core/credential"
import { Integration } from "@opencode-ai/core/integration"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ProjectV2 } from "@opencode-ai/core/project"
@@ -291,6 +292,27 @@ describe("SessionRunnerModel", () => {
}),
)
it.effect("does not project OAuth account metadata into the request body", () =>
Effect.gen(function* () {
const resolved = yield* SessionRunnerModel.fromCatalogModel(
ModelV2.Info.make({
...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
request: { headers: {}, body: {} },
}),
Credential.OAuth.make({
type: "oauth",
methodID: Integration.MethodID.make("device"),
access: "secret",
refresh: "refresh",
expires: Date.now() + 60_000,
metadata: { server: "https://console.example", orgID: "org_123" },
}),
)
expect(resolved.route.defaults.http?.body).toEqual({})
}),
)
it.effect("rejects catalog APIs without a native route", () =>
Effect.gen(function* () {
const failure = yield* SessionRunnerModel.fromCatalogModel(
@@ -338,7 +338,6 @@ export function turnSummaryWriter(input: { agent: string; model: string; duratio
() => (
<box width="100%" height={1}>
<text wrapMode="none" truncate>
<span style={{ fg: input.theme.block.highlight }}> </span>
<span style={{ fg: input.theme.block.text }}>{input.agent}</span>
<span style={{ fg: input.theme.block.muted }}>
{" "}
@@ -10,7 +10,7 @@ export function turnSummaryCommit(input: {
}): StreamCommit {
return {
kind: "system",
text: `${input.agent} · ${input.model} · ${input.duration}`,
text: `${input.agent} · ${input.model} · ${input.duration}`,
phase: "final",
source: "system",
summary: {
@@ -119,7 +119,7 @@ test("turn summary starts at the left edge", async () => {
const commits = claim(out.renderer)
try {
expect(renderRows(commits.at(-1)!)[0]).toBe("Build · Little Frank · 2.2s")
expect(renderRows(commits.at(-1)!)[0]).toBe("Build · Little Frank · 2.2s")
} finally {
destroy(commits)
}
@@ -279,7 +279,7 @@ describe("run session replay", () => {
}),
expect.objectContaining({
kind: "system",
text: "Build · gpt-5 · 2.8s",
text: "Build · gpt-5 · 2.8s",
phase: "final",
source: "system",
messageID: "msg-1",
@@ -314,7 +314,7 @@ describe("run session replay", () => {
expect(out.commits.at(-1)).toEqual(
expect.objectContaining({
kind: "system",
text: "Build · Little Frank · 2.8s",
text: "Build · Little Frank · 2.8s",
summary: {
agent: "Build",
model: "Little Frank",
@@ -346,7 +346,7 @@ describe("run session replay", () => {
expect(out.commits.filter((commit) => commit.summary)).toEqual([
expect.objectContaining({
kind: "system",
text: "Build · gpt-5 · 2.0s",
text: "Build · gpt-5 · 2.0s",
messageID: "msg-step-2",
}),
])
+11 -2
View File
@@ -1,16 +1,25 @@
import { EventV2 } from "@opencode-ai/core/event"
import { Effect, Stream } from "effect"
import { PublicEventManifest } from "@opencode-ai/core/public-event-manifest"
import { Effect, Schema, Stream } from "effect"
import { HttpServerResponse } from "effect/unstable/http"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import * as Sse from "effect/unstable/encoding/Sse"
import { Api } from "../api"
function eventData(data: unknown): Sse.Event {
const event = data as EventV2.Payload
const definition = PublicEventManifest.Latest.get(event.type)
const encoded = definition
? {
...event,
data: Schema.encodeUnknownSync(definition.data as Schema.Codec<unknown, unknown, never, never>)(event.data),
}
: event
return {
_tag: "Event",
event: "message",
id: undefined,
data: JSON.stringify(data),
data: JSON.stringify(encoded),
}
}
+4 -5
View File
@@ -33,7 +33,7 @@ import { useEvent } from "./context/event"
import { SDKProvider, useSDK } from "./context/sdk"
import { StartupLoading } from "./component/startup-loading"
import { SyncProvider, useSync } from "./context/sync"
import { DataProvider } from "./context/data"
import { DataProvider, useData } from "./context/data"
import { LocationProvider } from "./context/location"
import { LocalProvider, useLocal } from "./context/local"
import { DialogModel } from "./component/dialog-model"
@@ -371,6 +371,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
const themeState = useTheme()
const { theme, mode, setMode, locked, lock, unlock } = themeState
const sync = useSync()
const data = useData()
const project = useProject()
const exit = useExit()
const promptRef = usePromptRef()
@@ -495,9 +496,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
createEffect(() => {
// When using -c, session list is loaded in blocking phase, so we can navigate at "partial"
if (continued || sync.status === "loading" || !args.continue) return
const match = sync.data.session
.toSorted((a, b) => b.time.updated - a.time.updated)
.find((x) => x.parentID === undefined)?.id
const match = data.session.list().find((session) => !session.parentID)?.id
if (match) {
continued = true
if (args.fork) {
@@ -553,7 +552,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
name: "session.list",
title: "Switch session",
category: "Session",
suggested: sync.data.session.length > 0,
suggested: data.session.list().length > 0,
slashName: "sessions",
slashAliases: ["resume", "continue"],
run: () => {
+2 -2
View File
@@ -110,8 +110,8 @@ export function DialogModel(props: { providerID?: string }) {
function onSelect(providerID: string, modelID: string) {
local.model.set({ providerID, modelID }, { recent: true })
const list = local.model.variant.list()
const cur = local.model.variant.selected()
if (cur === "default" || (cur && list.includes(cur))) {
const cur = local.model.variant.current()
if (cur && list.includes(cur)) {
dialog.clear()
return
}
@@ -1,149 +1,53 @@
import { createMemo, createResource, onMount } from "solid-js"
import path from "path"
import type { SessionV2Info } from "@opencode-ai/sdk/v2"
import { useDialog } from "../ui/dialog"
import { DialogSelect } from "../ui/dialog-select"
import { useRoute } from "../context/route"
import { useSync } from "../context/sync"
import { createMemo, createResource, createSignal, onMount } from "solid-js"
import path from "path"
import { useData } from "../context/data"
import { Locale } from "../util/locale"
import { useProject } from "../context/project"
import { useTheme } from "../context/theme"
import { useSDK } from "../context/sdk"
import { useLocal } from "../context/local"
import { DialogSessionRename } from "./dialog-session-rename"
import { createDebouncedSignal } from "../util/signal"
import { useToast } from "../ui/toast"
import { openWorkspaceSelect, type WorkspaceSelection, warpWorkspaceSession } from "./dialog-workspace-create"
import { Spinner } from "./spinner"
import { errorMessage } from "../util/error"
import { DialogSessionDeleteFailed } from "./dialog-session-delete-failed"
import { useCommandShortcut } from "../keymap"
export function DialogSessionList() {
const dialog = useDialog()
const route = useRoute()
const sync = useSync()
const data = useData()
const project = useProject()
const { theme } = useTheme()
const sdk = useSDK()
const local = useLocal()
const toast = useToast()
const [toDelete, setToDelete] = createSignal<string>()
const [search, setSearch] = createDebouncedSignal("", 150)
const deleteHint = useCommandShortcut("session.delete")
const quickSwitch1 = useCommandShortcut("session.quick_switch.1")
const quickSwitch9 = useCommandShortcut("session.quick_switch.9")
const [searchResults, { refetch }] = createResource(
() => ({ query: search(), filter: sync.session.query() }),
async (input) => {
if (!input.query) return undefined
const result = await sdk.client.session.list({ search: input.query, limit: 30, ...input.filter })
return result.data ?? []
},
)
const [searchResults] = createResource(search, async (query) => {
if (!query) return
const response = await sdk.client.v2.session.list(
{ search: query, limit: 50, order: "desc" },
{ throwOnError: true },
)
return { query, sessions: response.data.data }
})
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
const sessions = createMemo(() => searchResults() ?? sync.data.session)
function recover(session: NonNullable<ReturnType<typeof sessions>[number]>) {
const workspace = project.workspace.get(session.workspaceID!)
const list = () => dialog.replace(() => <DialogSessionList />)
const warp = async (selection: WorkspaceSelection) => {
const workspaceID = await (async () => {
if (selection.type === "none") return null
if (selection.type === "existing") return selection.workspaceID
let result
try {
result = await sdk.client.experimental.workspace.create({ type: selection.workspaceType, branch: null })
} catch (err) {
toast.show({
title: "Failed to create workspace",
message: errorMessage(err),
variant: "error",
})
return
}
const workspace = result?.data
if (!workspace) {
toast.show({
title: "Failed to create workspace",
message: errorMessage(result?.error ?? "no response"),
variant: "error",
})
return
}
await project.workspace.sync()
return workspace.id
})()
if (workspaceID === undefined) return
await warpWorkspaceSession({
dialog,
sdk,
sync,
project,
toast,
sourceWorkspaceID: session.workspaceID,
workspaceID,
sessionID: session.id,
copyChanges: false,
done: list,
})
}
dialog.replace(() => (
<DialogSessionDeleteFailed
session={session.title}
workspace={workspace?.name ?? session.workspaceID!}
onDone={list}
onDelete={async () => {
const current = currentSessionID()
const info = current ? sync.data.session.find((item) => item.id === current) : undefined
const result = await sdk.client.experimental.workspace.remove({ id: session.workspaceID! })
if (result.error) {
toast.show({
variant: "error",
title: "Failed to delete workspace",
message: errorMessage(result.error),
})
return false
}
await project.workspace.sync()
await sync.session.refresh()
if (search()) await refetch()
if (info?.workspaceID === session.workspaceID) {
route.navigate({ type: "home" })
}
return true
}}
onRestore={() => {
void openWorkspaceSelect({
dialog,
sdk,
sync,
project,
toast,
onSelect: (selection) => {
void warp(selection)
},
})
return false
}}
/>
))
}
function orderByRecency(sessionsList: NonNullable<ReturnType<typeof sessions>>) {
return sessionsList
.filter((x) => x.parentID === undefined)
.toSorted((a, b) => b.time.updated - a.time.updated)
.map((x) => x.id)
}
const [browseOrder] = createSignal<string[]>(orderByRecency(sync.data.session))
const sessions = createMemo(() => {
const query = search()
if (!query) return data.session.list()
const result = searchResults()
return result?.query === query ? result.sessions : []
})
const quickSwitchHint = createMemo(() => {
const first = quickSwitch1()
const last = quickSwitch9()
if (!first || !last) return undefined
if (!first || !last) return
return quickSwitchRange(first, last)
})
const quickSwitchFooterHints = createMemo(() => {
@@ -153,65 +57,38 @@ export function DialogSessionList() {
const options = createMemo(() => {
const today = new Date().toDateString()
const sessionMap = new Map(
sessions()
.filter((x) => x.parentID === undefined)
.map((x) => [x.id, x]),
)
const searchResult = searchResults()
const displayOrder = searchResult ? orderByRecency(searchResult) : browseOrder()
const pinned = local.session.pinned().filter((id) => sessionMap.has(id))
const sessionMap = new Map(sessions().filter((session) => !session.parentID).map((session) => [session.id, session]))
const pinned = local.session.pinned().filter((sessionID) => sessionMap.has(sessionID))
const pinnedSet = new Set(pinned)
const slotByID = new Map<string, number>(local.session.slots().map((id, i) => [id, i + 1]))
const slotByID = new Map(local.session.slots().map((sessionID, index) => [sessionID, index + 1]))
function buildOption(id: string, category: string) {
const x = sessionMap.get(id)
if (!x) return undefined
const directory = x.path
? x.directory.endsWith(x.path)
? x.directory.slice(0, -x.path.length).replace(/\/$/, "")
: undefined
: x.directory
const footer =
directory && directory !== project.data.project.mainDir ? Locale.truncate(path.basename(directory), 20) : ""
const isDeleting = toDelete() === x.id
const status = sync.data.session_status?.[x.id]
const isWorking = status?.type === "busy" || status?.type === "retry"
const slot = slotByID.get(x.id)
const gutter = isWorking
? () => <Spinner />
: slot !== undefined
? () => <text fg={theme.accent}>{slot}</text>
: undefined
const option = (session: SessionV2Info, category: string) => {
const directory = session.location.directory
const footer = directory !== project.data.project.mainDir ? Locale.truncate(path.basename(directory), 20) : ""
const slot = slotByID.get(session.id)
return {
title: isDeleting ? `Press ${deleteHint()} again to confirm` : x.title,
bg: isDeleting ? theme.error : undefined,
value: x.id,
title: session.title,
value: session.id,
category,
footer,
gutter,
gutter: slot === undefined ? undefined : () => <text fg={theme.accent}>{slot}</text>,
}
}
const remaining = displayOrder
.filter((id) => !pinnedSet.has(id))
.map((id) => {
const x = sessionMap.get(id)
if (!x) return undefined
const label = new Date(x.time.updated).toDateString()
return buildOption(id, label === today ? "Today" : label)
const remaining = sessions()
.filter((session) => !session.parentID && !pinnedSet.has(session.id))
.map((session) => {
const date = new Date(session.time.updated).toDateString()
return option(session, date === today ? "Today" : date)
})
.filter((x) => x !== undefined)
return [...pinned.map((id) => buildOption(id, "Pinned")).filter((x) => x !== undefined), ...remaining]
return [...pinned.map((sessionID) => option(sessionMap.get(sessionID)!, "Pinned")), ...remaining]
})
onMount(() => {
dialog.setSize("large")
})
onMount(() => dialog.setSize("large"))
const unavailable = (feature: string) =>
toast.show({ message: `${feature} is not implemented for V2 sessions yet`, variant: "error", duration: 5000 })
return (
<DialogSelect
@@ -220,78 +97,25 @@ export function DialogSessionList() {
skipFilter={true}
current={currentSessionID()}
onFilter={setSearch}
onMove={() => {
setToDelete(undefined)
}}
onSelect={(option) => {
route.navigate({
type: "session",
sessionID: option.value,
})
route.navigate({ type: "session", sessionID: option.value })
dialog.clear()
}}
actions={[
{
command: "session.pin.toggle",
title: "pin/unpin",
onTrigger: (option: { value: string }) => {
local.session.togglePin(option.value)
},
onTrigger: (option: { value: string }) => local.session.togglePin(option.value),
},
{
command: "session.delete",
title: "delete",
onTrigger: async (option) => {
if (toDelete() === option.value) {
const session = sessions().find((item) => item.id === option.value)
const status = session?.workspaceID ? project.workspace.status(session.workspaceID) : undefined
try {
const result = await sdk.client.session.delete({
sessionID: option.value,
})
if (result.error) {
if (session?.workspaceID) {
recover(session)
} else {
toast.show({
variant: "error",
title: "Failed to delete session",
message: errorMessage(result.error),
})
}
setToDelete(undefined)
return
}
} catch (err) {
if (session?.workspaceID) {
recover(session)
} else {
toast.show({
variant: "error",
title: "Failed to delete session",
message: errorMessage(err),
})
}
setToDelete(undefined)
return
}
if (status && status !== "connected") {
await sync.session.refresh()
}
if (search()) await refetch()
setToDelete(undefined)
return
}
setToDelete(option.value)
},
onTrigger: () => unavailable("Deleting"),
},
{
command: "session.rename",
title: "rename",
onTrigger: async (option) => {
dialog.replace(() => <DialogSessionRename session={option.value} />)
},
onTrigger: () => unavailable("Renaming"),
},
]}
footerHints={quickSwitchFooterHints()}
+10 -20
View File
@@ -7,32 +7,22 @@ export function DialogVariant() {
const local = useLocal()
const dialog = useDialog()
const options = createMemo(() => {
return [
{
value: "default",
title: "Default",
onSelect: () => {
dialog.clear()
local.model.variant.set(undefined)
},
const options = createMemo(() =>
local.model.variant.list().map((variant) => ({
value: variant,
title: variant,
onSelect: () => {
dialog.clear()
local.model.variant.set(variant)
},
...local.model.variant.list().map((variant) => ({
value: variant,
title: variant,
onSelect: () => {
dialog.clear()
local.model.variant.set(variant)
},
})),
]
})
})),
)
return (
<DialogSelect<string>
options={options()}
title={"Select variant"}
current={local.model.variant.selected()}
current={local.model.variant.current()}
flat={true}
/>
)
+10 -18
View File
@@ -307,28 +307,20 @@ export function Prompt(props: PromptProps) {
),
)
// Initialize agent/model/variant from last user message when session changes
// Initialize agent/model/variant from the durable V2 Session state.
let syncedSessionID: string | undefined
createEffect(() => {
const sessionID = props.sessionID
const msg = lastUserMessage()
if (sessionID !== syncedSessionID) {
if (!sessionID || !msg) return
syncedSessionID = sessionID
// Only set agent if it's a primary agent (not a subagent)
const isPrimaryAgent = local.agent.list().some((agent) => agent.id === msg.agent)
if (msg.agent && isPrimaryAgent) {
// Keep command line --agent if specified.
if (!args.agent) local.agent.set(msg.agent)
if (msg.model) {
local.model.set(msg.model)
local.model.variant.set(msg.model.variant)
}
}
if (!sessionID || sessionID === syncedSessionID || !local.model.ready) return
const session = data.session.get(sessionID)
if (!session) return
const agent = session.agent && local.agent.list().find((agent) => agent.id === session.agent)
if (agent && !args.agent) local.agent.set(agent.id)
if (session.model) {
local.model.set({ providerID: session.model.providerID, modelID: session.model.id })
local.model.variant.set(session.model.variant)
}
syncedSessionID = sessionID
})
const promptCommands = createMemo(() =>
+33 -2
View File
@@ -415,6 +415,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
const result = {
session: {
list() {
return Object.values(store.session.info).toSorted((a, b) => b.time.updated - a.time.updated)
},
get(sessionID: string) {
return store.session.info[sessionID]
},
@@ -427,8 +430,25 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
return store.session.message[sessionID]
},
async refresh(sessionID: string) {
const result = await sdk.client.v2.session.messages({ sessionID }, { throwOnError: true })
setStore("session", "message", sessionID, result.data.data)
setStore("session", "message", sessionID, [])
const load = async (cursor?: string, messages: SessionMessage[] = []): Promise<SessionMessage[]> => {
const result = await sdk.client.v2.session.messages(
cursor ? { sessionID, limit: 200, cursor } : { sessionID, limit: 200, order: "desc" },
{ throwOnError: true },
)
const next = [...messages, ...result.data.data]
return result.data.cursor.next ? load(result.data.cursor.next, next) : next
}
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),
)
},
},
permission: {
@@ -550,6 +570,17 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
onMount(() => {
void Promise.allSettled([
sdk.client.v2.session
.list({ limit: 50, order: "desc" }, { throwOnError: true })
.then((response) =>
setStore(
"session",
"info",
produce((draft) => {
for (const session of response.data.data) draft[session.id] = session
}),
),
),
result.location.refresh(),
result.location.agent.refresh(),
result.location.integration.refresh(),
+9 -4
View File
@@ -364,13 +364,18 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const m = currentModel()
if (!m) return undefined
const key = `${m.providerID}/${m.modelID}`
return modelStore.variant[key]
return modelStore.variant[key] ?? "default"
},
current() {
const v = this.selected()
if (!v) return undefined
if (!this.list().includes(v)) return undefined
return v
if (v !== "default" && this.list().includes(v)) return v
const m = currentModel()!
return (
data.location.model
.list()
?.find((item) => item.providerID === m.providerID && item.id === m.modelID)?.request.variant ?? "default"
)
},
list() {
const m = currentModel()
@@ -397,7 +402,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
const index = variants.indexOf(current)
if (index === -1 || index === variants.length - 1) {
this.set(undefined)
this.set(variants[0])
return
}
this.set(variants[index + 1])
@@ -1,22 +1,16 @@
import { createMemo } from "solid-js"
import { useSync } from "../../context/sync"
import { useData } from "../../context/data"
import { DialogSelect } from "../../ui/dialog-select"
import { useSDK } from "../../context/sdk"
import { useRoute } from "../../context/route"
import { useClipboard } from "../../context/clipboard"
import type { PromptInfo } from "../../component/prompt/history"
import { stripPromptPartIDs as strip } from "../../prompt/part"
import { useToast } from "../../ui/toast"
export function DialogMessage(props: {
messageID: string
sessionID: string
setPrompt?: (prompt: PromptInfo) => void
}) {
const sync = useSync()
const sdk = useSDK()
const message = createMemo(() => sync.data.message[props.sessionID]?.find((x) => x.id === props.messageID))
const route = useRoute()
export function DialogMessage(props: { messageID: string; sessionID: string; setPrompt?: unknown }) {
const data = useData()
const clipboard = useClipboard()
const toast = useToast()
const message = createMemo(() =>
data.session.message.list(props.sessionID)?.find((message) => message.id === props.messageID),
)
return (
<DialogSelect
@@ -27,29 +21,7 @@ export function DialogMessage(props: {
value: "session.revert",
description: "undo messages and file changes",
onSelect: (dialog) => {
const msg = message()
if (!msg) return
void sdk.client.session.revert({
sessionID: props.sessionID,
messageID: msg.id,
})
if (props.setPrompt) {
const parts = sync.data.part[msg.id]
const promptInfo = parts.reduce(
(agg, part) => {
if (part.type === "text") {
if (!part.synthetic) agg.input += part.text
}
if (part.type === "file") agg.parts.push(strip(part))
return agg
},
{ input: "", parts: [] as PromptInfo["parts"] },
)
props.setPrompt(promptInfo)
}
toast.show({ message: "Reverting is not implemented for V2 sessions yet", variant: "error", duration: 5000 })
dialog.clear()
},
},
@@ -58,17 +30,19 @@ export function DialogMessage(props: {
value: "message.copy",
description: "message text to clipboard",
onSelect: async (dialog) => {
const msg = message()
if (!msg) return
const parts = sync.data.part[msg.id]
const text = parts.reduce((agg, part) => {
if (part.type === "text" && !part.synthetic) {
agg += part.text
}
return agg
}, "")
const value = message()
if (!value) return
const text =
value.type === "user"
? value.text
: value.type === "assistant"
? value.content
.filter((content) => content.type === "text")
.map((content) => content.text)
.join("\n")
: "text" in value
? value.text
: ""
await clipboard.write?.(text)
dialog.clear()
},
@@ -77,29 +51,8 @@ export function DialogMessage(props: {
title: "Fork",
value: "session.fork",
description: "create a new session",
onSelect: async (dialog) => {
const result = await sdk.client.session.fork({
sessionID: props.sessionID,
messageID: props.messageID,
})
const msg = message()
const prompt = msg
? sync.data.part[msg.id].reduce(
(agg, part) => {
if (part.type === "text") {
if (!part.synthetic) agg.input += part.text
}
if (part.type === "file") agg.parts.push(part)
return agg
},
{ input: "", parts: [] as PromptInfo["parts"] },
)
: undefined
route.navigate({
sessionID: result.data!.id,
type: "session",
prompt,
})
onSelect: (dialog) => {
toast.show({ message: "Forking is not implemented for V2 sessions yet", variant: "error", duration: 5000 })
dialog.clear()
},
},
+277 -838
View File
@@ -11,35 +11,28 @@ import {
onMount,
Show,
Switch,
untrack,
useContext,
} from "solid-js"
import { Dynamic } from "solid-js/web"
import path from "node:path"
import { mkdir, writeFile } from "node:fs/promises"
import { useRoute, useRouteData } from "../../context/route"
import { useProject } from "../../context/project"
import { useSync } from "../../context/sync"
import { useData } from "../../context/data"
import { useEvent } from "../../context/event"
import { SplitBorder } from "../../ui/border"
import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime"
import { Spinner } from "../../component/spinner"
import { createSyntaxStyleMemo, generateSubtleSyntax, selectedForeground, useTheme } from "../../context/theme"
import { createSyntaxStyleMemo, generateSubtleSyntax, useTheme } from "../../context/theme"
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core"
import { Prompt, type PromptRef } from "../../component/prompt"
import type {
AssistantMessage,
Part,
Provider,
ToolPart,
UserMessage,
TextPart,
ReasoningPart,
SessionMessage as SessionMessageV2,
SessionMessageAssistant as SessionMessageAssistantV2,
SessionMessageUser as SessionMessageUserV2,
SessionStatus,
ModelV2Info,
SessionMessage,
SessionMessageAssistant,
SessionMessageAssistantReasoning,
SessionMessageAssistantText,
SessionMessageAssistantTool,
SessionMessageUser,
SessionV2Info,
} from "@opencode-ai/sdk/v2"
import { useLocal } from "../../context/local"
import { Locale } from "../../util/locale"
@@ -49,14 +42,8 @@ import { useSDK } from "../../context/sdk"
import { useEditorContext } from "../../context/editor"
import { openEditor } from "../../editor"
import { useDialog } from "../../ui/dialog"
import { DialogAlert } from "../../ui/dialog-alert"
import { TodoItem } from "../../component/todo-item"
import { DialogMessage } from "./dialog-message"
import type { PromptInfo } from "../../component/prompt/history"
import { DialogConfirm } from "../../ui/dialog-confirm"
import { DialogTimeline } from "./dialog-timeline"
import { DialogForkFromTimeline } from "./dialog-fork-from-timeline"
import { DialogSessionRename } from "../../component/dialog-session-rename"
import { Sidebar } from "./sidebar"
import { SubagentFooter } from "./subagent-footer.tsx"
import { filetype } from "../../util/filetype"
@@ -71,8 +58,6 @@ import { normalizePath } from "../../util/path"
import { PermissionPrompt } from "./permission"
import { QuestionPrompt } from "./question"
import { DialogExportOptions } from "../../ui/dialog-export-options"
import * as Model from "../../util/model"
import { formatTranscript } from "../../util/transcript"
import { sessionEpilogue } from "../../util/presentation"
import { setPreLayoutSiblingMargin } from "../../util/layout"
import { useTuiConfig } from "../../config"
@@ -81,42 +66,14 @@ import { nextThinkingMode, reasoningSummary, useThinkingMode, type ThinkingMode
import { getScrollAcceleration } from "../../util/scroll"
import { collapseToolOutput } from "../../util/collapse-tool-output"
import { usePluginRuntime } from "../../plugin/runtime"
import { DialogRetryAction } from "../../component/dialog-retry-action"
import { getRevertDiffFiles } from "../../util/revert-diff"
import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut, useOpencodeKeymap } from "../../keymap"
import { OPENCODE_BASE_MODE, useBindings } from "../../keymap"
import { usePathFormatter } from "../../context/path-format"
import { LocationProvider } from "../../context/location"
addDefaultParsers(parsers.parsers)
const GO_UPSELL_FREE_TIER_LAST_SEEN_AT = "go_upsell_last_seen_at"
const GO_UPSELL_FREE_TIER_DONT_SHOW = "go_upsell_dont_show"
const GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT = "go_upsell_account_rate_limit_last_seen_at"
const GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW = "go_upsell_account_rate_limit_dont_show"
const GO_UPSELL_WINDOW = 86_400_000 // 24 hrs
const GO_UPSELL_PROVIDERS = new Set(["opencode", "opencode-go"])
export const alwaysSeparate = new WeakSet<BoxRenderable>()
type RetryAction = Extract<SessionStatus, { type: "retry" }>["action"]
function goUpsellKeys(action: RetryAction) {
if (!action) return
if (!GO_UPSELL_PROVIDERS.has(action.provider)) return
if (action.reason === "free_tier_limit") {
return {
lastSeenAt: GO_UPSELL_FREE_TIER_LAST_SEEN_AT,
dontShow: GO_UPSELL_FREE_TIER_DONT_SHOW,
}
}
if (action.reason === "account_rate_limit") {
return {
lastSeenAt: GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT,
dontShow: GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW,
}
}
}
const sessionBindingCommands = [
"session.share",
"session.rename",
@@ -141,6 +98,7 @@ const sessionBindingCommands = [
"messages.copy",
"session.copy",
"session.export",
"session.background",
"session.child.first",
"session.parent",
"session.child.next",
@@ -168,8 +126,7 @@ const context = createContext<{
showDetails: () => boolean
showGenericToolOutput: () => boolean
diffWrapMode: () => "word" | "none"
providers: () => ReadonlyMap<string, Provider>
sync: ReturnType<typeof useSync>
models: () => ModelV2Info[]
tui: ReturnType<typeof useTuiConfig>
}>()
@@ -189,9 +146,7 @@ export function Session() {
const pluginRuntime = usePluginRuntime()
const route = useRouteData("session")
const { navigate } = useRoute()
const sync = useSync()
const data = useData()
const event = useEvent()
const project = useProject()
const paths = useTuiPaths()
const tuiConfig = useTuiConfig()
@@ -207,45 +162,26 @@ export function Session() {
setEpilogue(sessionEpilogue({ title, sessionID: session()?.id }))
})
onCleanup(() => setEpilogue())
const children = createMemo(() => {
const parentID = session()?.parentID ?? session()?.id
return sync.data.session
.filter((x) => x.parentID === parentID || x.id === parentID)
.toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
})
const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
const foregroundTasks = createMemo(() =>
sync.data.capabilities.experimentalBackgroundSubagents
? messages().flatMap((message) =>
(sync.data.part[message.id] ?? []).filter(
(part): part is ToolPart =>
part.type === "tool" &&
part.tool === "task" &&
part.state.status === "running" &&
part.state.metadata?.background !== true,
),
)
: [],
)
const messages = sessionMessages
const permissions = createMemo(() => {
if (session()?.parentID) return []
return children().flatMap((x) => sync.data.permission[x.id] ?? [])
return data.session.permission.list(route.sessionID) ?? []
})
const questions = createMemo(() => {
if (session()?.parentID) return []
return children().flatMap((x) => sync.data.question[x.id] ?? [])
return data.session.question.list(route.sessionID) ?? []
})
const visible = createMemo(() => !session()?.parentID && permissions().length === 0 && questions().length === 0)
const disabled = createMemo(() => permissions().length > 0 || questions().length > 0)
const pending = createMemo(() => {
const completed = messages().findLast((x) => x.role === "assistant" && x.time.completed)?.id
return messages().findLast((x) => x.role === "assistant" && !x.time.completed && (!completed || x.id > completed))
const completed = messages().findLast((x) => x.type === "assistant" && x.time.completed)?.id
return messages().findLast((x) => x.type === "assistant" && !x.time.completed && (!completed || x.id > completed))
?.id
})
const lastAssistant = createMemo(() => {
return messages().findLast((x) => x.role === "assistant")
return messages().findLast((x) => x.type === "assistant")
})
const dimensions = useTerminalDimensions()
@@ -272,7 +208,7 @@ export function Session() {
})
const showTimestamps = createMemo(() => timestamps() === "show")
const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() ? 42 : 0) - 4)
const providers = createMemo(() => Model.index(sync.data.provider))
const models = createMemo(() => data.location.model.list(location()) ?? [])
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
const toast = useToast()
@@ -282,7 +218,6 @@ export function Session() {
createEffect(() => {
const sessionID = route.sessionID
void (async () => {
const previousWorkspace = untrack(() => project.workspace.current())
await Promise.all([
data.session.refresh(sessionID),
data.session.message.refresh(sessionID),
@@ -300,17 +235,7 @@ export function Session() {
return
}
if (info.location.workspaceID !== previousWorkspace) {
project.workspace.set(info.location.workspaceID)
// Sync all the data for this workspace. Note that this
// workspace may not exist anymore which is why this is not
// fatal. If it doesn't we still want to show the session
// (which will be non-interactive)
try {
await sync.bootstrap({ fatal: false })
} catch {}
}
project.workspace.set(info.location.workspaceID)
editor.reconnect(info.location.directory)
if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000)
})().catch((error) => {
@@ -324,23 +249,6 @@ export function Session() {
})
})
let lastSwitch: string | undefined = undefined
event.on("message.part.updated", (evt) => {
const part = evt.properties.part
if (part.type !== "tool") return
if (part.sessionID !== route.sessionID) return
if (part.state.status !== "completed") return
if (part.id === lastSwitch) return
if (part.tool === "plan_exit") {
local.agent.set("build")
lastSwitch = part.id
} else if (part.tool === "plan_enter") {
local.agent.set("plan")
lastSwitch = part.id
}
})
let seeded = false
let scroll: ScrollBoxRenderable
let prompt: PromptRef | undefined
@@ -351,29 +259,12 @@ export function Session() {
seeded = true
r.set(route.prompt)
}
const keymap = useOpencodeKeymap()
const dialog = useDialog()
const renderer = useRenderer()
event.on("session.status", (evt) => {
if (evt.properties.sessionID !== route.sessionID) return
if (evt.properties.status.type !== "retry") return
if (!evt.properties.status.action) return
if (dialog.stack.length > 0) return
const keys = goUpsellKeys(evt.properties.status.action)
if (!keys) return
const seen = kv.get(keys.lastSeenAt)
if (typeof seen === "number" && Date.now() - seen < GO_UPSELL_WINDOW) return
if (kv.get(keys.dontShow)) return
void DialogRetryAction.show(dialog, evt.properties.status.action).then((dontShowAgain) => {
if (dontShowAgain) kv.set(keys.dontShow, true)
kv.set(keys.lastSeenAt, Date.now())
})
})
const unavailable = (feature: string) => {
toast.show({ message: `${feature} is not implemented for V2 sessions yet`, variant: "error", duration: 5000 })
dialog.clear()
}
// Helper: Find next visible message boundary in direction
const findNextVisibleMessage = (direction: "next" | "prev"): string | null => {
@@ -388,11 +279,8 @@ export function Session() {
const message = messagesList.find((m) => m.id === c.id)
if (!message) return false
// Check if message has valid non-synthetic, non-ignored text parts
const parts = sync.data.part[message.id]
if (!parts || !Array.isArray(parts)) return false
return parts.some((part) => part && part.type === "text" && !part.synthetic && !part.ignored)
if (message.type === "user") return Boolean(message.text.trim())
return message.type === "assistant" && message.content.some((content) => content.type === "text" && content.text.trim())
})
.sort((a, b) => a.y - b.y)
@@ -428,79 +316,20 @@ export function Session() {
}, 50)
}
const local = useLocal()
function enterChild(sessionID: string) {
navigate({
type: "session",
sessionID,
})
const status = sync.data.session_status[sessionID]
if (status?.type === "retry") void DialogAlert.show(dialog, "Retry Error", status.message)
}
function moveFirstChild() {
if (children().length === 1) return
const next = children().find((x) => !!x.parentID)
if (next) enterChild(next.id)
}
function moveChild(direction: number) {
if (children().length === 1) return
const sessions = children().filter((x) => !!x.parentID)
let next = sessions.findIndex((x) => x.id === session()?.id) - direction
if (next >= sessions.length) next = 0
if (next < 0) next = sessions.length - 1
if (sessions[next]) enterChild(sessions[next].id)
}
function childSessionHandler(func: () => void) {
return () => {
if (!session()?.parentID || dialog.stack.length > 0) return
func()
}
}
const sessionCommandList = createMemo(() => [
{
title: session()?.share?.url ? "Copy share link" : "Share session",
value: "session.share",
suggested: route.type === "session",
category: "Session",
enabled: sync.data.config.share !== "disabled",
slash: {
name: "share",
},
run: async () => {
const copy = (url: string) =>
clipboard
.write?.(url)
.then(() => toast.show({ message: "Share URL copied to clipboard!", variant: "success" }))
.catch(() => toast.show({ message: "Failed to copy URL to clipboard", variant: "error" }))
slash: { name: "share" },
run: () => {
const url = session()?.share?.url
if (url) {
await copy(url)
dialog.clear()
return
}
if (!kv.get("share_consent", false)) {
const ok = await DialogConfirm.show(dialog, "Share Session", "Are you sure you want to share it?")
if (ok !== true) return
kv.set("share_consent", true)
}
await sdk.client.session
.share({
sessionID: route.sessionID,
})
.then((res) => copy(res.data!.share!.url))
.catch((error) => {
toast.show({
message: error instanceof Error ? error.message : "Failed to share session",
variant: "error",
})
})
if (!url) return unavailable("Sharing")
void clipboard
.write?.(url)
.then(() => toast.show({ message: "Share URL copied to clipboard!", variant: "success" }))
.catch(() => toast.show({ message: "Failed to copy URL to clipboard", variant: "error" }))
dialog.clear()
},
},
@@ -508,56 +337,22 @@ export function Session() {
title: "Rename session",
value: "session.rename",
category: "Session",
slash: {
name: "rename",
},
run: () => {
dialog.replace(() => <DialogSessionRename session={route.sessionID} />)
},
slash: { name: "rename" },
run: () => unavailable("Renaming"),
},
{
title: "Jump to message",
value: "session.timeline",
category: "Session",
slash: {
name: "timeline",
},
run: () => {
dialog.replace(() => (
<DialogTimeline
onMove={(messageID) => {
const child = scroll.getChildren().find((child) => {
return child.id === messageID
})
if (child) scroll.scrollBy(child.y - scroll.y - 1)
}}
sessionID={route.sessionID}
setPrompt={(promptInfo) => prompt?.set(promptInfo)}
/>
))
},
slash: { name: "timeline" },
run: () => unavailable("The message timeline"),
},
{
title: "Fork session",
value: "session.fork",
category: "Session",
slash: {
name: "fork",
},
run: () => {
dialog.replace(() => (
<DialogForkFromTimeline
onMove={(messageID) => {
if (!messageID) return
const child = scroll.getChildren().find((child) => {
return child.id === messageID
})
if (child) scroll.scrollBy(child.y - scroll.y - 1)
}}
sessionID={route.sessionID}
/>
))
},
slash: { name: "fork" },
run: () => unavailable("Forking"),
},
{
title: "Compact session",
@@ -568,20 +363,7 @@ export function Session() {
aliases: ["summarize"],
},
run: () => {
const selectedModel = local.model.current()
if (!selectedModel) {
toast.show({
variant: "warning",
message: "Connect a provider to summarize this session",
duration: 3000,
})
return
}
void sdk.client.session.summarize({
sessionID: route.sessionID,
modelID: selectedModel.modelID,
providerID: selectedModel.providerID,
})
void sdk.client.v2.session.compact({ sessionID: route.sessionID })
dialog.clear()
},
},
@@ -590,86 +372,23 @@ export function Session() {
value: "session.unshare",
category: "Session",
enabled: !!session()?.share?.url,
slash: {
name: "unshare",
},
run: async () => {
await sdk.client.session
.unshare({
sessionID: route.sessionID,
})
.then(() => toast.show({ message: "Session unshared successfully", variant: "success" }))
.catch((error) => {
toast.show({
message: error instanceof Error ? error.message : "Failed to unshare session",
variant: "error",
})
})
dialog.clear()
},
slash: { name: "unshare" },
run: () => unavailable("Unsharing"),
},
{
title: "Undo previous message",
value: "session.undo",
category: "Session",
slash: {
name: "undo",
},
run: async () => {
const status = sync.data.session_status?.[route.sessionID]
if (status?.type !== "idle") await sdk.client.session.abort({ sessionID: route.sessionID }).catch(() => {})
const revert = session()?.revert?.messageID
const message = messages().findLast((x) => (!revert || x.id < revert) && x.role === "user")
if (!message) return
void sdk.client.session
.revert({
sessionID: route.sessionID,
messageID: message.id,
})
.then(() => {
toBottom()
})
const parts = sync.data.part[message.id]
prompt?.set(
parts.reduce(
(agg, part) => {
if (part.type === "text") {
if (!part.synthetic) agg.input += part.text
}
if (part.type === "file") agg.parts.push(part)
return agg
},
{ input: "", parts: [] as PromptInfo["parts"] },
),
)
dialog.clear()
},
slash: { name: "undo" },
run: () => unavailable("Undo"),
},
{
title: "Redo",
value: "session.redo",
category: "Session",
enabled: !!session()?.revert?.messageID,
slash: {
name: "redo",
},
run: () => {
dialog.clear()
const messageID = session()?.revert?.messageID
if (!messageID) return
const message = messages().find((x) => x.role === "user" && x.id > messageID)
if (!message) {
void sdk.client.session.unrevert({
sessionID: route.sessionID,
})
prompt?.set({ input: "", parts: [] })
return
}
void sdk.client.session.revert({
sessionID: route.sessionID,
messageID: message.id,
})
},
slash: { name: "redo" },
run: () => unavailable("Redo"),
},
{
title: sidebarVisible() ? "Hide sidebar" : "Show sidebar",
@@ -836,22 +555,14 @@ export function Session() {
category: "Session",
hidden: true,
run: () => {
const messages = sync.data.message[route.sessionID]
const messages = sessionMessages()
if (!messages || !messages.length) return
// Find the most recent user message with non-ignored, non-synthetic text parts
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i]
if (!message || message.role !== "user") continue
const parts = sync.data.part[message.id]
if (!parts || !Array.isArray(parts)) continue
const hasValidTextPart = parts.some(
(part) => part && part.type === "text" && !part.synthetic && !part.ignored,
)
if (hasValidTextPart) {
if (!message || message.type !== "user" || !message.text.trim()) continue
{
const child = scroll.getChildren().find((child) => {
return child.id === message.id
})
@@ -882,7 +593,7 @@ export function Session() {
run: () => {
const revertID = session()?.revert?.messageID
const lastAssistantMessage = messages().findLast(
(msg) => msg.role === "assistant" && (!revertID || msg.id < revertID),
(msg): msg is SessionMessageAssistant => msg.type === "assistant" && (!revertID || msg.id < revertID),
)
if (!lastAssistantMessage) {
toast.show({ message: "No assistant messages found", variant: "error" })
@@ -890,8 +601,7 @@ export function Session() {
return
}
const parts = sync.data.part[lastAssistantMessage.id] ?? []
const textParts = parts.filter((part) => part.type === "text")
const textParts = lastAssistantMessage.content.filter((part) => part.type === "text")
if (textParts.length === 0) {
toast.show({ message: "No text parts found in last assistant message", variant: "error" })
dialog.clear()
@@ -929,17 +639,7 @@ export function Session() {
try {
const sessionData = session()
if (!sessionData) return
const sessionMessages = messages()
const transcript = formatTranscript(
sessionData,
sessionMessages.map((msg) => ({ info: msg, parts: sync.data.part[msg.id] ?? [] })),
{
thinking: showThinking(),
toolDetails: showDetails(),
assistantMetadata: showAssistantMetadata(),
providers: sync.data.provider,
},
)
const transcript = formatSessionTranscript(sessionData, messages(), showThinking(), showDetails())
await clipboard.write?.(transcript)
toast.show({ message: "Session transcript copied to clipboard!", variant: "success" })
} catch {
@@ -959,8 +659,6 @@ export function Session() {
try {
const sessionData = session()
if (!sessionData) return
const sessionMessages = messages()
const defaultFilename = `session-${sessionData.id.slice(0, 8)}.md`
const options = await DialogExportOptions.show(
@@ -974,16 +672,7 @@ export function Session() {
if (options === null) return
const transcript = formatTranscript(
sessionData,
sessionMessages.map((msg) => ({ info: msg, parts: sync.data.part[msg.id] ?? [] })),
{
thinking: options.thinking,
toolDetails: options.toolDetails,
assistantMetadata: options.assistantMetadata,
providers: sync.data.provider,
},
)
const transcript = formatSessionTranscript(sessionData, messages(), options.thinking, options.toolDetails)
if (options.openWithoutSaving) {
// Just open in editor without saving
@@ -1028,24 +717,14 @@ export function Session() {
value: "session.background",
category: "Session",
hidden: true,
enabled: foregroundTasks().length > 0,
run: () => {
void sdk.client.experimental.session.background({
sessionID: route.sessionID,
workspace: project.workspace.current(),
})
dialog.clear()
},
run: () => unavailable("Backgrounding subagents"),
},
{
title: "Go to child session",
value: "session.child.first",
category: "Session",
hidden: true,
run: () => {
dialog.clear()
moveFirstChild()
},
run: () => unavailable("Child session discovery"),
},
{
title: "Go to parent session",
@@ -1053,7 +732,7 @@ export function Session() {
category: "Session",
hidden: true,
enabled: !!session()?.parentID,
run: childSessionHandler(() => {
run: () => {
const parentID = session()?.parentID
if (parentID) {
navigate({
@@ -1062,7 +741,7 @@ export function Session() {
})
}
dialog.clear()
}),
},
},
{
title: "Next child session",
@@ -1070,10 +749,7 @@ export function Session() {
category: "Session",
hidden: true,
enabled: !!session()?.parentID,
run: childSessionHandler(() => {
dialog.clear()
moveChild(1)
}),
run: () => unavailable("Sibling session navigation"),
},
{
title: "Previous child session",
@@ -1081,10 +757,7 @@ export function Session() {
category: "Session",
hidden: true,
enabled: !!session()?.parentID,
run: childSessionHandler(() => {
dialog.clear()
moveChild(-1)
}),
run: () => unavailable("Sibling session navigation"),
},
])
@@ -1117,36 +790,6 @@ export function Session() {
bindings: tuiConfig.keybinds.gather("session", sessionBindingCommands),
}))
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
enabled: foregroundTasks().length > 0,
priority: 1,
bindings: tuiConfig.keybinds.get("session.background"),
}))
const revertInfo = createMemo(() => session()?.revert)
const revertMessageID = createMemo(() => revertInfo()?.messageID)
const revertDiffFiles = createMemo<ReturnType<typeof getRevertDiffFiles>>(() => [])
const revertRevertedMessages = createMemo(() => {
const messageID = revertMessageID()
if (!messageID) return []
return messages().filter((x) => x.id >= messageID && x.role === "user")
})
const revert = createMemo(() => {
const info = revertInfo()
if (!info) return
if (!info.messageID) return
return {
messageID: info.messageID,
reverted: revertRevertedMessages(),
diff: undefined,
diffFiles: revertDiffFiles(),
}
})
// snap to bottom when session changes
createEffect(on(() => route.sessionID, toBottom))
@@ -1165,8 +808,7 @@ export function Session() {
showDetails,
showGenericToolOutput,
diffWrapMode,
providers,
sync,
models,
tui: tuiConfig,
}}
>
@@ -1193,26 +835,43 @@ export function Session() {
>
<box height={1} />
<For each={sessionMessages()}>
{(message, index) => (
<SessionMessageV2View
message={message}
first={index() === 0}
last={index() === sessionMessages().length - 1}
/>
)}
{(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>
</scrollbox>
<box flexShrink={0}>
<Show when={permissions().length > 0}>
<PermissionPrompt
request={permissions()[0]}
directory={sync.session.get(permissions()[0].sessionID)?.directory}
directory={session()?.location.directory}
/>
</Show>
<Show when={permissions().length === 0 && questions().length > 0}>
<QuestionPrompt
request={questions()[0]}
directory={sync.session.get(questions()[0].sessionID)?.directory}
directory={session()?.location.directory}
/>
</Show>
<Show when={session()?.parentID}>
@@ -1270,14 +929,14 @@ export function Session() {
)
}
function SessionMessageV2View(props: { message: SessionMessageV2; first: boolean; last: boolean }) {
function SessionMessageView(props: { message: SessionMessage; first: boolean; last: boolean }) {
return (
<Switch>
<Match when={props.message.type === "user"}>
<SessionUserMessageV2 message={props.message as SessionMessageUserV2} first={props.first} />
<UserMessage message={props.message as SessionMessageUser} index={props.first ? 0 : 1} />
</Match>
<Match when={props.message.type === "assistant"}>
<SessionAssistantMessageV2 message={props.message as SessionMessageAssistantV2} last={props.last} />
<AssistantMessage message={props.message as SessionMessageAssistant} last={props.last} />
</Match>
<Match when={props.message.type === "shell"}>
<box paddingLeft={3} marginTop={1}>
@@ -1291,118 +950,13 @@ function SessionMessageV2View(props: { message: SessionMessageV2; first: boolean
<SessionNoticeMessageV2 message={props.message} />
</Match>
<Match when={props.message.type === "compaction"}>
<box marginTop={1} border={["top"]} title=" Compaction " titleAlignment="center" />
<CompactionMessage />
</Match>
</Switch>
)
}
function SessionUserMessageV2(props: { message: SessionMessageUserV2; first: boolean }) {
const { theme } = useTheme()
return (
<box
id={props.message.id}
border={["left"]}
borderColor={theme.primary}
customBorderChars={SplitBorder.customBorderChars}
marginTop={props.first ? 0 : 1}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
backgroundColor={theme.backgroundPanel}
>
<text fg={theme.text}>{props.message.text}</text>
<Show when={props.message.files?.length}>
<box flexDirection="row" paddingTop={1} gap={1} flexWrap="wrap">
<For each={props.message.files}>
{(file) => (
<text fg={theme.textMuted}>
{file.name ?? file.uri} <span style={{ fg: theme.textMuted }}>({file.mime})</span>
</text>
)}
</For>
</box>
</Show>
</box>
)
}
function SessionAssistantMessageV2(props: { message: SessionMessageAssistantV2; last: boolean }) {
const { theme, syntax } = useTheme()
const local = useLocal()
return (
<>
<For each={props.message.content}>
{(content) => (
<Switch>
<Match when={content.type === "text" && content.text.trim()}>
<box paddingLeft={3} marginTop={1} flexShrink={0}>
<markdown
syntaxStyle={syntax()}
streaming={true}
internalBlockMode="top-level"
content={content.type === "text" ? content.text.trim() : ""}
tableOptions={{ style: "grid" }}
fg={theme.markdownText}
bg={theme.background}
/>
</box>
</Match>
<Match when={content.type === "reasoning" && content.text.trim()}>
<box paddingLeft={3} marginTop={1}>
<text fg={theme.textMuted}>{content.type === "reasoning" ? content.text.trim() : ""}</text>
</box>
</Match>
<Match when={content.type === "tool"}>
<SessionToolMessageV2
content={content as Extract<SessionMessageAssistantV2["content"][number], { type: "tool" }>}
/>
</Match>
</Switch>
)}
</For>
<Show when={props.message.error}>
<box border={["left"]} borderColor={theme.error} paddingLeft={2} marginTop={1}>
<text fg={theme.error}>{props.message.error?.message}</text>
</box>
</Show>
<Show when={props.last || props.message.time.completed}>
<box paddingLeft={3} marginTop={1}>
<text fg={theme.textMuted}>
<span style={{ fg: local.agent.color(props.message.agent) }}> </span>
{Locale.titlecase(props.message.agent)} · {props.message.model.providerID}/{props.message.model.id}
</text>
</box>
</Show>
</>
)
}
function SessionToolMessageV2(props: {
content: Extract<SessionMessageAssistantV2["content"][number], { type: "tool" }>
}) {
const { theme } = useTheme()
const output = createMemo(() =>
props.content.state.status === "error"
? props.content.state.error.message
: props.content.state.status === "pending"
? ""
: props.content.state.content
.flatMap((item) => (item.type === "text" ? [item.text] : [item.name ?? item.uri]))
.join("\n"),
)
return (
<box paddingLeft={3} marginTop={1}>
<text fg={theme.textMuted}>
{props.content.state.status === "pending" || props.content.state.status === "running" ? "∙" : "✓"}{" "}
{props.content.name}
</text>
<Show when={output()}>{(value) => <text fg={theme.textMuted}>{value()}</text>}</Show>
</box>
)
}
function SessionSwitchMessageV2(props: { message: SessionMessageV2 }) {
function SessionSwitchMessageV2(props: { message: SessionMessage }) {
const { theme } = useTheme()
const text = () => {
if (props.message.type === "agent-switched") return `Switched agent to ${props.message.agent}`
@@ -1413,7 +967,7 @@ function SessionSwitchMessageV2(props: { message: SessionMessageV2 }) {
return <text fg={theme.textMuted}>{text()}</text>
}
function SessionNoticeMessageV2(props: { message: SessionMessageV2 }) {
function SessionNoticeMessageV2(props: { message: SessionMessage }) {
const { theme } = useTheme()
return (
<text fg={theme.textMuted}>
@@ -1422,48 +976,64 @@ function SessionNoticeMessageV2(props: { message: SessionMessageV2 }) {
)
}
const MIME_BADGE: Record<string, string> = {
"text/plain": "txt",
"image/png": "img",
"image/jpeg": "img",
"image/gif": "img",
"image/webp": "img",
"application/pdf": "pdf",
"application/x-directory": "dir",
function CompactionMessage() {
const { theme } = useTheme()
return (
<box
marginTop={1}
border={["top"]}
title=" Compaction "
titleAlignment="center"
borderColor={theme.borderActive}
/>
)
}
function RevertMessage(props: { count: number }) {
const { theme } = useTheme()
const dialog = useDialog()
const renderer = useRenderer()
const toast = useToast()
const [hover, setHover] = createSignal(false)
return (
<box
ref={(element: BoxRenderable) => alwaysSeparate.add(element)}
onMouseOver={() => setHover(true)}
onMouseOut={() => setHover(false)}
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
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}
borderColor={theme.backgroundPanel}
>
<box paddingTop={1} paddingBottom={1} paddingLeft={2} backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel}>
<text fg={theme.textMuted}>{props.count} message{props.count === 1 ? "" : "s"} reverted</text>
<text fg={theme.textMuted}>Redo is not implemented for V2 sessions yet</text>
</box>
</box>
)
}
function UserMessage(props: {
message: UserMessage
parts: Part[]
onMouseUp: () => void
message: SessionMessageUser
index: number
pending?: string
}) {
const ctx = use()
const local = useLocal()
const text = createMemo(() => {
const texts = props.parts
.map((x) => {
if (x.type === "text" && !x.synthetic) {
return x.text
}
return null
})
.filter(Boolean)
return texts.join("\n\n")
})
const files = createMemo(() => props.parts.flatMap((x) => (x.type === "file" ? [x] : [])))
const files = createMemo(() => props.message.files ?? [])
const { theme } = useTheme()
const [hover, setHover] = createSignal(false)
const queued = createMemo(() => props.pending && props.message.id > props.pending)
const color = createMemo(() => local.agent.color(props.message.agent))
const queuedFg = createMemo(() => selectedForeground(theme, color()))
const metadataVisible = createMemo(() => queued() || ctx.showTimestamps())
const compaction = createMemo(() => props.parts.find((x) => x.type === "compaction"))
const color = createMemo(() => local.agent.color(useData().session.get(ctx.sessionID)?.agent ?? "build"))
const dialog = useDialog()
const renderer = useRenderer()
return (
<>
<Show when={text()}>
<Show when={props.message.text.trim() || files().length}>
<box
id={props.message.id}
ref={(el: BoxRenderable) => alwaysSeparate.add(el)}
@@ -1479,16 +1049,19 @@ function UserMessage(props: {
onMouseOut={() => {
setHover(false)
}}
onMouseUp={props.onMouseUp}
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
dialog.replace(() => <DialogMessage messageID={props.message.id} sessionID={ctx.sessionID} />)
}}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel}
flexShrink={0}
>
<text fg={theme.text}>{text()}</text>
<text fg={theme.text}>{props.message.text}</text>
<Show when={files().length}>
<box flexDirection="row" paddingBottom={metadataVisible() ? 1 : 0} paddingTop={1} gap={1} flexWrap="wrap">
<box flexDirection="row" paddingBottom={ctx.showTimestamps() ? 1 : 0} paddingTop={1} gap={1} flexWrap="wrap">
<For each={files()}>
{(file) => {
const directory = file.mime === "application/x-directory"
@@ -1497,52 +1070,34 @@ function UserMessage(props: {
<span style={{ bg: theme.secondary, fg: theme.background }}>
{directory ? " Directory " : " File "}
</span>
<span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> {file.filename} </span>
<span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> {file.name ?? file.uri} </span>
</text>
)
}}
</For>
</box>
</Show>
<Show
when={queued()}
fallback={
<Show when={ctx.showTimestamps()}>
<text fg={theme.textMuted}>
<span style={{ fg: theme.textMuted }}>
{Locale.todayTimeOrDateTime(props.message.time.created)}
</span>
</text>
</Show>
}
>
<Show when={ctx.showTimestamps()}>
<text fg={theme.textMuted}>
<span style={{ bg: color(), fg: queuedFg(), bold: true }}> QUEUED </span>
<span style={{ fg: theme.textMuted }}>{Locale.todayTimeOrDateTime(props.message.time.created)}</span>
</text>
</Show>
</box>
</box>
</Show>
<Show when={compaction()}>
<box
marginTop={1}
border={["top"]}
title=" Compaction "
titleAlignment="center"
borderColor={theme.borderActive}
/>
</Show>
</>
</Show>
)
}
function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; last: boolean }) {
function AssistantMessage(props: { message: SessionMessageAssistant; last: boolean }) {
const ctx = use()
const local = useLocal()
const { theme } = useTheme()
const sync = useSync()
const messages = createMemo(() => sync.data.message[props.message.sessionID] ?? [])
const model = createMemo(() => Model.name(ctx.providers(), props.message.providerID, props.message.modelID))
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 final = createMemo(() => {
return props.message.finish && !["tool-calls", "unknown"].includes(props.message.finish)
@@ -1551,56 +1106,34 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
const duration = createMemo(() => {
if (!final()) return 0
if (!props.message.time.completed) return 0
const user = messages().find((x) => x.role === "user" && x.id === props.message.parentID)
if (!user || !user.time) return 0
return props.message.time.completed - user.time.created
return props.message.time.completed - props.message.time.created
})
const childShortcut = useCommandShortcut("session.child.first")
const backgroundShortcut = useCommandShortcut("session.background")
return (
<>
<For each={props.parts}>
{(part, index) => {
const component = createMemo(() => PART_MAPPING[part.type as keyof typeof PART_MAPPING])
return (
<Show when={component()}>
<Dynamic
last={index() === props.parts.length - 1}
component={component()}
part={part as any}
message={props.message}
<For each={props.message.content}>
{(content, index) => (
<Switch>
<Match when={content.type === "text"}>
<TextPart
part={content as SessionMessageAssistantText}
last={index() === props.message.content.length - 1}
/>
</Show>
)
}}
</Match>
<Match when={content.type === "reasoning"}>
<ReasoningPart
part={content as SessionMessageAssistantReasoning}
message={props.message}
last={index() === props.message.content.length - 1}
/>
</Match>
<Match when={content.type === "tool"}>
<ToolPart part={content as SessionMessageAssistantTool} />
</Match>
</Switch>
)}
</For>
<Show when={props.parts.some((x) => x.type === "tool" && x.tool === "task")}>
<box paddingTop={1} paddingLeft={3}>
<text fg={theme.text}>
{childShortcut()}
<span style={{ fg: theme.textMuted }}> view subagents</span>
<Show
when={
sync.data.capabilities.experimentalBackgroundSubagents &&
props.parts.some(
(x) =>
x.type === "tool" &&
x.tool === "task" &&
x.state.status === "running" &&
x.state.metadata?.background !== true,
)
}
>
<span style={{ fg: theme.textMuted }}> · </span>
{backgroundShortcut()}
<span style={{ fg: theme.textMuted }}> background</span>
</Show>
</text>
</box>
</Show>
<Show when={props.message.error && props.message.error.name !== "MessageAbortedError"}>
<Show when={props.message.error}>
<box
ref={(el: BoxRenderable) => alwaysSeparate.add(el)}
border={["left"]}
@@ -1616,27 +1149,16 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
</box>
</Show>
<Switch>
<Match when={props.last || final() || props.message.error?.name === "MessageAbortedError"}>
<Match when={props.last || final() || props.message.error}>
<box ref={(el: BoxRenderable) => alwaysSeparate.add(el)} paddingLeft={3}>
<text marginTop={1}>
<span
style={{
fg:
props.message.error?.name === "MessageAbortedError"
? theme.textMuted
: local.agent.color(props.message.agent),
}}
>
{" "}
</span>{" "}
<span style={{ fg: theme.text }}>{Locale.titlecase(props.message.mode)}</span>
<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>
<Show when={props.message.error?.name === "MessageAbortedError"}>
<span style={{ fg: theme.textMuted }}> · interrupted</span>
</Show>
</text>
</box>
</Match>
@@ -1645,15 +1167,13 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
)
}
const PART_MAPPING = {
text: TextPart,
tool: ToolPart,
reasoning: ReasoningPart,
}
const INLINE_TOOL_ICON_WIDTH = 2
function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: AssistantMessage }) {
function ReasoningPart(props: {
last: boolean
part: SessionMessageAssistantReasoning
message: SessionMessageAssistant
}) {
const { theme } = useTheme()
const ctx = use()
// Collapsed by default in hide mode: a single line throughout, so the
@@ -1664,13 +1184,11 @@ function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: Ass
// OpenRouter encrypts some reasoning blocks; drop the placeholder.
return props.part.text.replace("[REDACTED]", "").trim()
})
// Reasoning is finalized when the server sets `time.end` (see processor.ts).
// Flips independently of the parent message completing.
const isDone = createMemo(() => props.part.time.end !== undefined)
const isDone = createMemo(() => props.message.time.completed !== undefined)
const inMinimal = createMemo(() => ctx.thinkingMode() === "hide")
const duration = createMemo(() => {
const end = props.part.time.end
return end === undefined ? 0 : Math.max(0, end - props.part.time.start)
const end = props.message.time.completed
return end === undefined ? 0 : Math.max(0, end - props.message.time.created)
})
const summary = createMemo(() => reasoningSummary(content()))
const syntax = createSyntaxStyleMemo(() => generateSubtleSyntax(theme))
@@ -1760,7 +1278,7 @@ function ReasoningHeader(props: {
)
}
function TextPart(props: { last: boolean; part: TextPart; message: AssistantMessage }) {
function TextPart(props: { last: boolean; part: SessionMessageAssistantText }) {
const ctx = use()
const { theme, syntax } = useTheme()
return (
@@ -1783,9 +1301,9 @@ function TextPart(props: { last: boolean; part: TextPart; message: AssistantMess
// Pending messages moved to individual tool pending functions
function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMessage }) {
function ToolPart(props: { part: SessionMessageAssistantTool }) {
const ctx = use()
const display = createMemo(() => toolDisplay(props.part.tool))
const display = createMemo(() => toolDisplay(props.part.name))
// Hide tool if showDetails is false and tool completed successfully
const shouldHide = createMemo(() => {
@@ -1796,16 +1314,19 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess
const toolprops = {
get metadata() {
return props.part.state.status === "pending" ? {} : (props.part.state.metadata ?? {})
return props.part.state.status === "pending" ? {} : props.part.state.structured
},
get input() {
return props.part.state.input ?? {}
return typeof props.part.state.input === "string" ? {} : props.part.state.input
},
get output() {
return props.part.state.status === "completed" ? props.part.state.output : undefined
if (props.part.state.status === "pending") return undefined
return props.part.state.content
.flatMap((content) => (content.type === "text" ? [content.text] : [content.name ?? content.uri]))
.join("\n")
},
get tool() {
return props.part.tool
return props.part.name
},
get part() {
return props.part
@@ -1867,7 +1388,7 @@ type ToolProps = {
metadata: Record<string, unknown>
tool: string
output?: string
part: ToolPart
part: SessionMessageAssistantTool
}
function GenericTool(props: ToolProps) {
const { theme } = useTheme()
@@ -1917,23 +1438,23 @@ function InlineTool(props: {
spinner?: boolean
separate?: boolean
children: JSX.Element
part: ToolPart
part: SessionMessageAssistantTool
onClick?: () => void
}) {
const { theme } = useTheme()
const ctx = use()
const sync = useSync()
const data = useData()
const renderer = useRenderer()
const [hover, setHover] = createSignal(false)
const [errorExpanded, setErrorExpanded] = createSignal(false)
const permission = createMemo(() => {
const callID = sync.data.permission[ctx.sessionID]?.at(0)?.tool?.callID
const callID = data.session.permission.list(ctx.sessionID)?.at(0)?.source?.callID
if (!callID) return false
return callID === props.part.callID
return callID === props.part.id
})
const error = createMemo(() => (props.part.state.status === "error" ? props.part.state.error : undefined))
const error = createMemo(() => (props.part.state.status === "error" ? props.part.state.error.message : undefined))
const denied = createMemo(
() =>
@@ -2069,13 +1590,13 @@ function BlockTool(props: {
title?: string
children: JSX.Element
onClick?: () => void
part?: ToolPart
part?: SessionMessageAssistantTool
spinner?: boolean
}) {
const { theme } = useTheme()
const renderer = useRenderer()
const [hover, setHover] = createSignal(false)
const error = createMemo(() => (props.part?.state.status === "error" ? props.part.state.error : undefined))
const error = createMemo(() => (props.part?.state.status === "error" ? props.part.state.error.message : undefined))
return (
<box
ref={(el: BoxRenderable) => alwaysSeparate.add(el)}
@@ -2186,27 +1707,27 @@ function Write(props: ToolProps) {
return (
<Switch>
<Match when={props.metadata.diagnostics !== undefined}>
<BlockTool title={"# Wrote " + pathFormatter.format(stringValue(props.input.filePath))} part={props.part}>
<BlockTool title={"# Wrote " + pathFormatter.format(stringValue(props.input.path))} part={props.part}>
<line_number fg={theme.textMuted} minWidth={3} paddingRight={1}>
<code
conceal={false}
fg={theme.text}
filetype={filetype(stringValue(props.input.filePath))}
filetype={filetype(stringValue(props.input.path))}
syntaxStyle={syntax()}
content={code()}
/>
</line_number>
<Diagnostics diagnostics={props.metadata.diagnostics} filePath={stringValue(props.input.filePath) ?? ""} />
<Diagnostics diagnostics={props.metadata.diagnostics} filePath={stringValue(props.input.path) ?? ""} />
</BlockTool>
</Match>
<Match when={true}>
<InlineTool
icon="←"
pending="Preparing write..."
complete={stringValue(props.input.filePath)}
complete={stringValue(props.input.path)}
part={props.part}
>
Write {pathFormatter.format(stringValue(props.input.filePath))}
Write {pathFormatter.format(stringValue(props.input.path))}
</InlineTool>
</Match>
</Switch>
@@ -2232,7 +1753,6 @@ function Read(props: ToolProps) {
const isRunning = createMemo(() => props.part.state.status === "running")
const loaded = createMemo(() => {
if (props.part.state.status !== "completed") return []
if (props.part.state.time.compacted) return []
const value = props.metadata.loaded
if (!value || !Array.isArray(value)) return []
return value.filter((p): p is string => typeof p === "string")
@@ -2242,11 +1762,11 @@ function Read(props: ToolProps) {
<InlineTool
icon="→"
pending="Reading file..."
complete={stringValue(props.input.filePath)}
complete={stringValue(props.input.path)}
spinner={isRunning()}
part={props.part}
>
Read {pathFormatter.format(stringValue(props.input.filePath))} {input(props.input, ["filePath"])}
Read {pathFormatter.format(stringValue(props.input.path))} {input(props.input, ["path"])}
</InlineTool>
<For each={loaded()}>
{(filepath) => (
@@ -2292,99 +1812,28 @@ function WebSearch(props: ToolProps) {
}
function Task(props: ToolProps) {
const { theme } = useTheme()
const { navigate } = useRoute()
const sync = useSync()
const dialog = useDialog()
onMount(() => {
const sessionID = stringValue(props.metadata.sessionId)
if (sessionID && !sync.data.message[sessionID]?.length) void sync.session.sync(sessionID)
})
const sessionID = createMemo(() => stringValue(props.metadata.sessionId))
const messages = createMemo(() => sync.data.message[sessionID() ?? ""] ?? [])
const tools = createMemo(() => {
return messages().flatMap((msg) =>
(sync.data.part[msg.id] ?? [])
.filter((part): part is ToolPart => part.type === "tool")
.map((part) => ({ tool: part.tool, state: part.state })),
)
})
const current = createMemo(() =>
tools().findLast((x) => (x.state.status === "running" || x.state.status === "completed") && x.state.title),
)
const status = createMemo(() => sync.data.session_status[sessionID() ?? ""])
const isRunning = createMemo(() => {
const value = status()
return (
props.part.state.status === "running" ||
(props.metadata.background === true && value !== undefined && value.type !== "idle")
)
})
const retry = createMemo(() => {
const value = status()
if (value?.type !== "retry") return
return value
})
const duration = createMemo(() => {
const first = messages().find((x) => x.role === "user")?.time.created
const assistant = messages().findLast((x) => x.role === "assistant")?.time.completed
if (!first || !assistant) return 0
return assistant - first
})
const content = createMemo(() => {
const description = stringValue(props.input.description)
if (!description) return ""
let content = [
formatSubagentTitle(
Locale.titlecase(stringValue(props.input.subagent_type) ?? "General"),
description,
props.metadata.background === true,
),
]
const retrying = retry()
if (isRunning() && retrying) {
content.push(`${formatSubagentRetry(retrying.attempt, Locale.truncate(retrying.message, 80))}`)
} else if (isRunning() && tools().length > 0) {
if (current()) {
const state = current()!.state
const title = state.status === "running" || state.status === "completed" ? state.title : undefined
content.push(`${Locale.titlecase(current()!.tool)} ${title}`)
} else content.push(`${formatSubagentToolcalls(tools().length)}`)
}
if (!isRunning() && props.part.state.status === "completed") {
content.push(`${formatCompletedSubagentDetail(tools().length, Locale.duration(duration()))}`)
}
return content.join("\n")
})
const sessionID = createMemo(() => stringValue(props.metadata.sessionID) ?? stringValue(props.metadata.sessionId))
const description = createMemo(() => stringValue(props.input.description))
return (
<InlineTool
icon={props.part.state.status === "completed" ? "✓" : "│"}
separate={true}
color={retry() ? theme.error : undefined}
spinner={isRunning()}
complete={stringValue(props.input.description)}
spinner={props.part.state.status === "running"}
complete={description()}
pending="Delegating..."
part={props.part}
onClick={() => {
if (sessionID()) {
navigate({ type: "session", sessionID: sessionID()! })
}
const status = retry()
if (status) void DialogAlert.show(dialog, "Retry Error", status.message)
const id = sessionID()
if (id) navigate({ type: "session", sessionID: id })
}}
>
{content()}
{formatSubagentTitle(
Locale.titlecase(stringValue(props.input.subagent_type) ?? "General"),
description() ?? "Task",
props.metadata.background === true,
)}
</InlineTool>
)
}
@@ -2418,14 +1867,14 @@ function Edit(props: ToolProps) {
return ctx.width > 120 ? "split" : "unified"
})
const ft = createMemo(() => filetype(stringValue(props.input.filePath)))
const ft = createMemo(() => filetype(stringValue(props.input.path)))
const diffContent = createMemo(() => stringValue(props.metadata.diff) ?? "")
return (
<Switch>
<Match when={stringValue(props.metadata.diff) !== undefined}>
<BlockTool title={"← Edit " + pathFormatter.format(stringValue(props.input.filePath))} part={props.part}>
<BlockTool title={"← Edit " + pathFormatter.format(stringValue(props.input.path))} part={props.part}>
<box paddingLeft={1}>
<diff
diff={diffContent()}
@@ -2447,12 +1896,12 @@ function Edit(props: ToolProps) {
removedLineNumberBg={theme.diffRemovedLineNumberBg}
/>
</box>
<Diagnostics diagnostics={props.metadata.diagnostics} filePath={stringValue(props.input.filePath) ?? ""} />
<Diagnostics diagnostics={props.metadata.diagnostics} filePath={stringValue(props.input.path) ?? ""} />
</BlockTool>
</Match>
<Match when={true}>
<InlineTool icon="←" pending="Preparing edit..." complete={stringValue(props.input.filePath)} part={props.part}>
Edit {pathFormatter.format(stringValue(props.input.filePath))} {input({ replaceAll: props.input.replaceAll })}
<InlineTool icon="←" pending="Preparing edit..." complete={stringValue(props.input.path)} part={props.part}>
Edit {pathFormatter.format(stringValue(props.input.path))} {input({ replaceAll: props.input.replaceAll })}
</InlineTool>
</Match>
</Switch>
@@ -2460,68 +1909,28 @@ function Edit(props: ToolProps) {
}
function ApplyPatch(props: ToolProps) {
const ctx = use()
const { theme, syntax } = useTheme()
const pathFormatter = usePathFormatter()
const files = createMemo(() => parseApplyPatchFiles(props.metadata.files))
const view = createMemo(() => {
const diffStyle = ctx.tui.diff_style
if (diffStyle === "stacked") return "unified"
return ctx.width > 120 ? "split" : "unified"
const { theme } = useTheme()
const files = createMemo(() => {
const applied = props.metadata.applied
if (!Array.isArray(applied)) return []
return applied.flatMap((value) => {
const item = recordValue(value)
const type = stringValue(item?.type)
const resource = stringValue(item?.resource)
return type && resource ? [{ type, resource }] : []
})
})
function Diff(p: { diff: string; filePath: string }) {
return (
<box paddingLeft={1}>
<diff
diff={p.diff}
view={view()}
filetype={filetype(p.filePath)}
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>
)
}
function title(file: { type: string; relativePath: string; filePath: string; deletions: number }) {
if (file.type === "delete") return "# Deleted " + file.relativePath
if (file.type === "add") return "# Created " + file.relativePath
if (file.type === "move") return "# Moved " + pathFormatter.format(file.filePath) + " → " + file.relativePath
return "← Patched " + file.relativePath
}
return (
<Switch>
<Match when={files().length > 0}>
<For each={files()}>
{(file) => (
<BlockTool title={title(file)} part={props.part}>
<Show
when={file.type !== "delete"}
fallback={
<text fg={theme.diffRemoved}>
-{file.deletions} line{file.deletions !== 1 ? "s" : ""}
</text>
}
>
<Diff diff={file.patch} filePath={file.filePath} />
<Diagnostics diagnostics={props.metadata.diagnostics} filePath={file.movePath ?? file.filePath} />
</Show>
<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>
@@ -2673,6 +2082,36 @@ function recordValue(value: unknown): Record<string, unknown> | undefined {
return value as Record<string, unknown>
}
function formatSessionTranscript(
session: SessionV2Info,
messages: SessionMessage[],
thinking: boolean,
toolDetails: boolean,
) {
const body = messages.flatMap((message) => {
if (message.type === "user") return [`## User\n\n${message.text}`]
if (message.type === "shell") return [`## Shell\n\n\`\`\`\n$ ${message.command}\n${message.output}\n\`\`\``]
if (message.type !== "assistant") return []
const content = message.content.flatMap((item) => {
if (item.type === "text") return [item.text]
if (item.type === "reasoning") return thinking ? [`_Thinking:_\n\n${item.text}`] : []
if (!toolDetails) return [`**Tool: ${item.name}**`]
const input = typeof item.state.input === "string" ? item.state.input : JSON.stringify(item.state.input, null, 2)
const output =
item.state.status === "error"
? item.state.error.message
: item.state.status === "pending"
? ""
: item.state.content
.flatMap((entry) => (entry.type === "text" ? [entry.text] : [entry.name ?? entry.uri]))
.join("\n")
return [`**Tool: ${item.name}**\n\n**Input:**\n\`\`\`json\n${input}\n\`\`\`\n\n${output}`]
})
return [`## Assistant\n\n${content.join("\n\n")}`]
})
return `# ${session.title}\n\n**Session ID:** ${session.id}\n**Created:** ${new Date(session.time.created).toLocaleString()}\n**Updated:** ${new Date(session.time.updated).toLocaleString()}\n\n---\n\n${body.join("\n\n---\n\n")}\n`
}
export function parseApplyPatchFiles(value: unknown) {
if (!Array.isArray(value)) return []
return value.flatMap((item) => {
+35 -40
View File
@@ -4,11 +4,10 @@ import { createMemo, For, Match, Show, Switch } from "solid-js"
import { Portal, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import type { TextareaRenderable } from "@opentui/core"
import { useTheme, selectedForeground } from "../../context/theme"
import type { PermissionRequest } from "@opencode-ai/sdk/v2"
import type { PermissionV2Request } from "@opencode-ai/sdk/v2"
import { useSDK } from "../../context/sdk"
import { SplitBorder } from "../../ui/border"
import { useSync } from "../../context/sync"
import { useProject } from "../../context/project"
import { useData } from "../../context/data"
import { filetype } from "../../util/filetype"
import { Locale } from "../../util/locale"
import { webSearchProviderLabel } from "../../util/tool-display"
@@ -19,7 +18,7 @@ import { usePathFormatter } from "../../context/path-format"
type PermissionStage = "permission" | "always" | "reject"
function EditBody(props: { request: PermissionRequest }) {
function EditBody(props: { request: PermissionV2Request }) {
const themeState = useTheme()
const theme = themeState.theme
const syntax = themeState.syntax
@@ -27,8 +26,7 @@ function EditBody(props: { request: PermissionRequest }) {
const dimensions = useTerminalDimensions()
const filepath = createMemo(() => {
const value = props.request.metadata?.filepath
return typeof value === "string" ? value : ""
return props.request.resources[0] ?? ""
})
const diff = createMemo(() => {
const value = props.request.metadata?.diff
@@ -108,26 +106,24 @@ function TextBody(props: { title: string; description?: string; icon?: string })
)
}
export function PermissionPrompt(props: { request: PermissionRequest; directory?: string }) {
export function PermissionPrompt(props: { request: PermissionV2Request; directory?: string }) {
const sdk = useSDK()
const project = useProject()
const sync = useSync()
const data = useData()
const [store, setStore] = createStore({
stage: "permission" as PermissionStage,
})
const pathFormatter = usePathFormatter()
const session = createMemo(() => sync.data.session.find((s) => s.id === props.request.sessionID))
const session = createMemo(() => data.session.get(props.request.sessionID))
const input = createMemo(() => {
const tool = props.request.tool
const tool = props.request.source
if (!tool) return {}
const parts = sync.data.part[tool.messageID] ?? []
for (const part of parts) {
if (part.type === "tool" && part.callID === tool.callID && part.state.status !== "pending") {
return part.state.input ?? {}
}
}
const message = data.session
.message.list(props.request.sessionID)
?.find((message) => message.type === "assistant" && message.id === tool.messageID)
if (message?.type !== "assistant") return {}
const part = message.content.find((part) => part.type === "tool" && part.id === tool.callID)
if (part?.type === "tool" && part.state.status !== "pending") return part.state.input
return {}
})
@@ -140,14 +136,14 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
title="Always allow"
body={
<Switch>
<Match when={props.request.always.length === 1 && props.request.always[0] === "*"}>
<TextBody title={"This will allow " + props.request.permission + " until OpenCode is restarted."} />
<Match when={props.request.save?.length === 1 && props.request.save[0] === "*"}>
<TextBody title={"This will allow " + props.request.action + " until OpenCode is restarted."} />
</Match>
<Match when={true}>
<box paddingLeft={1} gap={1}>
<text fg={theme.textMuted}>This will allow the following patterns until OpenCode is restarted</text>
<box>
<For each={props.request.always}>
<For each={props.request.save ?? []}>
{(pattern) => (
<text fg={theme.text}>
{"- "}
@@ -165,11 +161,10 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
onSelect={(option) => {
setStore("stage", "permission")
if (option === "cancel") return
void sdk.client.permission.reply({
void sdk.client.v2.session.permission.reply({
sessionID: props.request.sessionID,
reply: "always",
requestID: props.request.id,
directory: props.directory,
workspace: project.workspace.current(),
})
}}
/>
@@ -177,12 +172,11 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
<Match when={store.stage === "reject"}>
<RejectPrompt
onConfirm={(message) => {
void sdk.client.permission.reply({
void sdk.client.v2.session.permission.reply({
sessionID: props.request.sessionID,
reply: "reject",
requestID: props.request.id,
directory: props.directory,
message: message || undefined,
workspace: project.workspace.current(),
})
}}
onCancel={() => {
@@ -193,12 +187,11 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
<Match when={store.stage === "permission"}>
{(() => {
const info = () => {
const permission = props.request.permission
const permission = props.request.action
const data = input()
if (permission === "edit") {
const raw = props.request.metadata?.filepath
const filepath = typeof raw === "string" ? raw : ""
const filepath = props.request.resources[0] ?? ""
return {
icon: "→",
title: `Edit ${pathFormatter.format(filepath)}`,
@@ -207,7 +200,7 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
}
if (permission === "read") {
const raw = data.filePath
const raw = data.path
const filePath = typeof raw === "string" ? raw : ""
return {
icon: "→",
@@ -333,13 +326,13 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
const meta = props.request.metadata ?? {}
const parent = typeof meta["parentDir"] === "string" ? meta["parentDir"] : undefined
const filepath = typeof meta["filepath"] === "string" ? meta["filepath"] : undefined
const pattern = props.request.patterns?.[0]
const pattern = props.request.resources[0]
const derived =
typeof pattern === "string" ? (pattern.includes("*") ? dirname(pattern) : pattern) : undefined
const raw = parent ?? filepath ?? derived
const dir = pathFormatter.format(raw)
const patterns = (props.request.patterns ?? []).filter((p): p is string => typeof p === "string")
const patterns = props.request.resources.filter((p): p is string => typeof p === "string")
return {
icon: "←",
@@ -402,7 +395,11 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
title="Permission required"
header={header()}
body={current.body}
options={{ once: "Allow once", always: "Allow always", reject: "Reject" }}
options={
props.request.save?.length
? { once: "Allow once", always: "Allow always", reject: "Reject" }
: { once: "Allow once", reject: "Reject" }
}
escapeKey="reject"
fullscreen
onSelect={(option) => {
@@ -415,19 +412,17 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
setStore("stage", "reject")
return
}
void sdk.client.permission.reply({
void sdk.client.v2.session.permission.reply({
sessionID: props.request.sessionID,
reply: "reject",
requestID: props.request.id,
directory: props.directory,
workspace: project.workspace.current(),
})
return
}
void sdk.client.permission.reply({
void sdk.client.v2.session.permission.reply({
sessionID: props.request.sessionID,
reply: "once",
requestID: props.request.id,
directory: props.directory,
workspace: project.workspace.current(),
})
}}
/>
+11 -11
View File
@@ -3,7 +3,7 @@ import { createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-j
import { useRenderer } from "@opentui/solid"
import type { TextareaRenderable } from "@opentui/core"
import { selectedForeground, tint, useTheme } from "../../context/theme"
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
import type { QuestionV2Answer, QuestionV2Request } from "@opencode-ai/sdk/v2"
import { useSDK } from "../../context/sdk"
import { SplitBorder } from "../../ui/border"
import { useTuiConfig } from "../../config"
@@ -11,7 +11,7 @@ import { useBindings, useOpencodeModeStack } from "../../keymap"
const QUESTION_MODE = "question"
export function QuestionPrompt(props: { request: QuestionRequest; directory?: string }) {
export function QuestionPrompt(props: { request: QuestionV2Request; directory?: string }) {
const sdk = useSDK()
const { theme } = useTheme()
const renderer = useRenderer()
@@ -24,7 +24,7 @@ export function QuestionPrompt(props: { request: QuestionRequest; directory?: st
const [tabHover, setTabHover] = createSignal<number | "confirm" | null>(null)
const [store, setStore] = createStore({
tab: 0,
answers: [] as QuestionAnswer[],
answers: [] as QuestionV2Answer[],
custom: [] as string[],
selected: 0,
editing: false,
@@ -47,17 +47,17 @@ export function QuestionPrompt(props: { request: QuestionRequest; directory?: st
function submit() {
const answers = questions().map((_, i) => store.answers[i] ?? [])
void sdk.client.question.reply({
void sdk.client.v2.session.question.reply({
sessionID: props.request.sessionID,
requestID: props.request.id,
directory: props.directory,
answers,
questionV2Reply: { answers },
})
}
function reject() {
void sdk.client.question.reject({
void sdk.client.v2.session.question.reject({
sessionID: props.request.sessionID,
requestID: props.request.id,
directory: props.directory,
})
}
@@ -71,10 +71,10 @@ export function QuestionPrompt(props: { request: QuestionRequest; directory?: st
setStore("custom", inputs)
}
if (single()) {
void sdk.client.question.reply({
void sdk.client.v2.session.question.reply({
sessionID: props.request.sessionID,
requestID: props.request.id,
directory: props.directory,
answers: [[answer]],
questionV2Reply: { answers: [[answer]] },
})
return
}
+8 -6
View File
@@ -1,5 +1,5 @@
import { useProject } from "../../context/project"
import { useSync } from "../../context/sync"
import { useData } from "../../context/data"
import { createMemo, Show } from "solid-js"
import { useTheme } from "../../context/theme"
import { useTuiConfig } from "../../config"
@@ -12,12 +12,12 @@ import { WorkspaceLabel } from "../../component/workspace-label"
export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
const pluginRuntime = usePluginRuntime()
const project = useProject()
const sync = useSync()
const data = useData()
const { theme } = useTheme()
const tuiConfig = useTuiConfig()
const session = createMemo(() => sync.session.get(props.sessionID))
const session = createMemo(() => data.session.get(props.sessionID))
const workspace = () => {
const workspaceID = session()?.workspaceID
const workspaceID = session()?.location.workspaceID
if (!workspaceID) return
return project.workspace.get(workspaceID)
}
@@ -60,11 +60,13 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
<Show when={InstallationChannel !== "latest"}>
<text fg={theme.textMuted}>{props.sessionID}</text>
</Show>
<Show when={session()!.workspaceID}>
<Show when={session()!.location.workspaceID}>
<text fg={theme.textMuted}>
<Show
when={workspace()}
fallback={<WorkspaceLabel type="unknown" name={session()!.workspaceID!} status="error" icon />}
fallback={
<WorkspaceLabel type="unknown" name={session()!.location.workspaceID!} status="error" icon />
}
>
{(item) => (
<WorkspaceLabel
@@ -1,47 +1,40 @@
import { createMemo, createSignal, Show } from "solid-js"
import { useRouteData } from "../../context/route"
import { useSync } from "../../context/sync"
import { useData } from "../../context/data"
import { useTheme } from "../../context/theme"
import { SplitBorder } from "../../ui/border"
import type { AssistantMessage } from "@opencode-ai/sdk/v2"
import { Locale } from "../../util/locale"
import { useTerminalDimensions } from "@opentui/solid"
import { useCommandShortcut, useOpencodeKeymap } from "../../keymap"
export function SubagentFooter() {
const route = useRouteData("session")
const sync = useSync()
const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
const session = createMemo(() => sync.session.get(route.sessionID))
const data = useData()
const session = createMemo(() => data.session.get(route.sessionID))
const subagentInfo = createMemo(() => {
const s = session()
if (!s) return { label: "Subagent", index: 0, total: 0 }
if (!s) return "Subagent"
const agentMatch = s.title.match(/@(\w+) subagent/)
const label = agentMatch ? Locale.titlecase(agentMatch[1]) : "Subagent"
if (!s.parentID) return { label, index: 0, total: 0 }
const siblings = sync.data.session
.filter((x) => x.parentID === s.parentID)
.toSorted((a, b) => a.time.created - b.time.created)
const index = siblings.findIndex((x) => x.id === s.id)
return { label, index: index + 1, total: siblings.length }
return agentMatch ? Locale.titlecase(agentMatch[1]) : "Subagent"
})
const usage = createMemo(() => {
const msg = messages()
const last = msg.findLast((item): item is AssistantMessage => item.role === "assistant" && item.tokens.output > 0)
if (!last) return
const current = session()
if (!current) return
const tokens =
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
current.tokens.input +
current.tokens.output +
current.tokens.reasoning +
current.tokens.cache.read +
current.tokens.cache.write
if (tokens <= 0) return
const model = sync.data.provider.find((item) => item.id === last.providerID)?.models[last.modelID]
const model = data.location
.model.list(current.location)
?.find((model) => model.providerID === current.model?.providerID && model.id === current.model.id)
const pct = model?.limit.context ? `${Math.round((tokens / model.limit.context) * 100)}%` : undefined
const cost = session()?.cost ?? 0
const cost = current.cost
const money = new Intl.NumberFormat("en-US", {
style: "currency",
@@ -78,13 +71,8 @@ export function SubagentFooter() {
<box flexDirection="row" justifyContent="space-between" gap={1}>
<box flexDirection="row" gap={1}>
<text fg={theme.text}>
<b>{subagentInfo().label}</b>
<b>{subagentInfo()}</b>
</text>
<Show when={subagentInfo().total > 0}>
<text style={{ fg: theme.textMuted }}>
({subagentInfo().index} of {subagentInfo().total})
</text>
</Show>
<Show when={usage()}>
{(item) => (
<text fg={theme.textMuted} wrapMode="none">
@@ -95,10 +83,10 @@ export function SubagentFooter() {
</box>
<box flexDirection="row" gap={2}>
<box
onMouseOver={() => setHover("parent")}
onMouseOut={() => setHover(null)}
onMouseOver={() => setHover("parent")}
onMouseOut={() => setHover(null)}
onMouseUp={() => keymap.dispatchCommand("session.parent")}
backgroundColor={hover() === "parent" ? theme.backgroundElement : theme.backgroundPanel}
backgroundColor={hover() === "parent" ? theme.backgroundElement : theme.backgroundPanel}
>
<text fg={theme.text}>
Parent <span style={{ fg: theme.textMuted }}>{parentShortcut()}</span>
@@ -76,7 +76,7 @@ exports[`TUI inline tool wrapping separates a task row from a preceding inline d
`;
exports[`TUI inline tool wrapping separates an inline row from the previous assistant summary 1`] = `
" Build · Little Frank · 53.1s
" Build · Little Frank · 53.1s
✓ Build Task — Review changes
↳ 48 toolcalls · 1m 40s"
@@ -144,7 +144,7 @@ function AssistantSummaryBeforeInlineFixture() {
return (
<box flexDirection="column" width={72}>
<box ref={(el: BoxRenderable) => alwaysSeparate.add(el)} paddingLeft={3}>
<text> Build · Little Frank · 53.1s</text>
<text>Build · Little Frank · 53.1s</text>
</box>
<InlineToolRow icon="✓" complete={true} pending="">
{"Build Task — Review changes\n↳ 48 toolcalls · 1m 40s"}