diff --git a/packages/core/src/public-event-manifest.ts b/packages/core/src/public-event-manifest.ts index 99c208d906..11b84f7905 100644 --- a/packages/core/src/public-event-manifest.ts +++ b/packages/core/src/public-event-manifest.ts @@ -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) diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 888052411a..6283ab305d 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -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(), diff --git a/packages/core/src/session/runner/model.ts b/packages/core/src/session/runner/model.ts index b864df8ba3..11addb07bb 100644 --- a/packages/core/src/session/runner/model.ts +++ b/packages/core/src/session/runner/model.ts @@ -132,7 +132,7 @@ export const fromCatalogModel = ( credential?: Credential.Value, ): Effect.Effect => { const resolved = - credential?.metadata === undefined + credential?.type !== "key" || credential.metadata === undefined ? model : produce(model, (draft) => { Object.assign(draft.request.body, credential.metadata) diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index 85c7054213..b89a167891 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -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 diff --git a/packages/core/test/session-runner-model.test.ts b/packages/core/test/session-runner-model.test.ts index 94669b24be..49bbce95a3 100644 --- a/packages/core/test/session-runner-model.test.ts +++ b/packages/core/test/session-runner-model.test.ts @@ -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( diff --git a/packages/opencode/src/cli/cmd/run/scrollback.writer.tsx b/packages/opencode/src/cli/cmd/run/scrollback.writer.tsx index 43176ded79..be814dbbfa 100644 --- a/packages/opencode/src/cli/cmd/run/scrollback.writer.tsx +++ b/packages/opencode/src/cli/cmd/run/scrollback.writer.tsx @@ -338,7 +338,6 @@ export function turnSummaryWriter(input: { agent: string; model: string; duratio () => ( - {input.agent} {" "} diff --git a/packages/opencode/src/cli/cmd/run/turn-summary.ts b/packages/opencode/src/cli/cmd/run/turn-summary.ts index 95284dfa0c..aadac67afc 100644 --- a/packages/opencode/src/cli/cmd/run/turn-summary.ts +++ b/packages/opencode/src/cli/cmd/run/turn-summary.ts @@ -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: { diff --git a/packages/opencode/test/cli/run/scrollback.surface.test.ts b/packages/opencode/test/cli/run/scrollback.surface.test.ts index 52ff5a354d..7406d36cbb 100644 --- a/packages/opencode/test/cli/run/scrollback.surface.test.ts +++ b/packages/opencode/test/cli/run/scrollback.surface.test.ts @@ -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) } diff --git a/packages/opencode/test/cli/run/session-replay.test.ts b/packages/opencode/test/cli/run/session-replay.test.ts index e3356d1831..7f0f272d63 100644 --- a/packages/opencode/test/cli/run/session-replay.test.ts +++ b/packages/opencode/test/cli/run/session-replay.test.ts @@ -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", }), ]) diff --git a/packages/server/src/handlers/event.ts b/packages/server/src/handlers/event.ts index 8001fb8748..590e84a4cd 100644 --- a/packages/server/src/handlers/event.ts +++ b/packages/server/src/handlers/event.ts @@ -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)(event.data), + } + : event return { _tag: "Event", event: "message", id: undefined, - data: JSON.stringify(data), + data: JSON.stringify(encoded), } } diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 92a4094976..19473b7031 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -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; 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; 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; 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: () => { diff --git a/packages/tui/src/component/dialog-model.tsx b/packages/tui/src/component/dialog-model.tsx index ca2608ce46..065338f1cd 100644 --- a/packages/tui/src/component/dialog-model.tsx +++ b/packages/tui/src/component/dialog-model.tsx @@ -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 } diff --git a/packages/tui/src/component/dialog-session-list.tsx b/packages/tui/src/component/dialog-session-list.tsx index 2965b3692e..df08e32b93 100644 --- a/packages/tui/src/component/dialog-session-list.tsx +++ b/packages/tui/src/component/dialog-session-list.tsx @@ -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() 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[number]>) { - const workspace = project.workspace.get(session.workspaceID!) - const list = () => dialog.replace(() => ) - 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(() => ( - { - 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>) { - return sessionsList - .filter((x) => x.parentID === undefined) - .toSorted((a, b) => b.time.updated - a.time.updated) - .map((x) => x.id) - } - - const [browseOrder] = createSignal(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(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 - ? () => - : slot !== undefined - ? () => {slot} - : 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 : () => {slot}, } } - 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 ( { - 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(() => ) - }, + onTrigger: () => unavailable("Renaming"), }, ]} footerHints={quickSwitchFooterHints()} diff --git a/packages/tui/src/component/dialog-variant.tsx b/packages/tui/src/component/dialog-variant.tsx index cbff7cd4fe..1ca224254d 100644 --- a/packages/tui/src/component/dialog-variant.tsx +++ b/packages/tui/src/component/dialog-variant.tsx @@ -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 ( options={options()} title={"Select variant"} - current={local.model.variant.selected()} + current={local.model.variant.current()} flat={true} /> ) diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 011f64d0d8..b65ddc9ad6 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -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(() => diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 0cd8d22060..b24ee9a267 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -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 => { + 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(), diff --git a/packages/tui/src/context/local.tsx b/packages/tui/src/context/local.tsx index fbea43e4f7..57ed0e54a8 100644 --- a/packages/tui/src/context/local.tsx +++ b/packages/tui/src/context/local.tsx @@ -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]) diff --git a/packages/tui/src/routes/session/dialog-message.tsx b/packages/tui/src/routes/session/dialog-message.tsx index b7d0184206..b1216b9775 100644 --- a/packages/tui/src/routes/session/dialog-message.tsx +++ b/packages/tui/src/routes/session/dialog-message.tsx @@ -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 ( { - 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() }, }, diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index d1a41e4a90..7ecb682fbd 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -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() -type RetryAction = Extract["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 - sync: ReturnType + models: () => ModelV2Info[] tui: ReturnType }>() @@ -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(() => ) - }, + slash: { name: "rename" }, + run: () => unavailable("Renaming"), }, { title: "Jump to message", value: "session.timeline", category: "Session", - slash: { - name: "timeline", - }, - run: () => { - dialog.replace(() => ( - { - 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(() => ( - { - 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>(() => []) - - 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() { > - {(message, index) => ( - - )} + {(message, index) => { + const reverted = () => session()?.revert?.messageID + return ( + + + item.id >= message.id && item.type === "user", + ).length} + /> + + = reverted()!}> + <> + + + + + + ) + }} 0}> 0}> @@ -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 ( - + - + @@ -1291,118 +950,13 @@ function SessionMessageV2View(props: { message: SessionMessageV2; first: boolean - + ) } -function SessionUserMessageV2(props: { message: SessionMessageUserV2; first: boolean }) { - const { theme } = useTheme() - return ( - - {props.message.text} - - - - {(file) => ( - - {file.name ?? file.uri} ({file.mime}) - - )} - - - - - ) -} - -function SessionAssistantMessageV2(props: { message: SessionMessageAssistantV2; last: boolean }) { - const { theme, syntax } = useTheme() - const local = useLocal() - return ( - <> - - {(content) => ( - - - - - - - - - {content.type === "reasoning" ? content.text.trim() : ""} - - - - } - /> - - - )} - - - - {props.message.error?.message} - - - - - - - {Locale.titlecase(props.message.agent)} · {props.message.model.providerID}/{props.message.model.id} - - - - - ) -} - -function SessionToolMessageV2(props: { - content: Extract -}) { - 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 ( - - - {props.content.state.status === "pending" || props.content.state.status === "running" ? "∙" : "✓"}{" "} - {props.content.name} - - {(value) => {value()}} - - ) -} - -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()} } -function SessionNoticeMessageV2(props: { message: SessionMessageV2 }) { +function SessionNoticeMessageV2(props: { message: SessionMessage }) { const { theme } = useTheme() return ( @@ -1422,48 +976,64 @@ function SessionNoticeMessageV2(props: { message: SessionMessageV2 }) { ) } -const MIME_BADGE: Record = { - "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 ( + + ) } + +function RevertMessage(props: { count: number }) { + const { theme } = useTheme() + const dialog = useDialog() + const renderer = useRenderer() + const toast = useToast() + const [hover, setHover] = createSignal(false) + return ( + 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} + > + + {props.count} message{props.count === 1 ? "" : "s"} reverted + Redo is not implemented for V2 sessions yet + + + ) +} + 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 ( - <> - + alwaysSeparate.add(el)} @@ -1479,16 +1049,19 @@ function UserMessage(props: { onMouseOut={() => { setHover(false) }} - onMouseUp={props.onMouseUp} + onMouseUp={() => { + if (renderer.getSelection()?.getSelectedText()) return + dialog.replace(() => ) + }} paddingTop={1} paddingBottom={1} paddingLeft={2} backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel} flexShrink={0} > - {text()} + {props.message.text} - + {(file) => { const directory = file.mime === "application/x-directory" @@ -1497,52 +1070,34 @@ function UserMessage(props: { {directory ? " Directory " : " File "} - {file.filename} + {file.name ?? file.uri} ) }} - - - - {Locale.todayTimeOrDateTime(props.message.time.created)} - - - - } - > + - QUEUED + {Locale.todayTimeOrDateTime(props.message.time.created)} - - - - - + ) } -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 ( <> - - {(part, index) => { - const component = createMemo(() => PART_MAPPING[part.type as keyof typeof PART_MAPPING]) - return ( - - + {(content, index) => ( + + + - - ) - }} + + + + + + + + + )} - x.type === "tool" && x.tool === "task")}> - - - {childShortcut()} - view subagents - - x.type === "tool" && - x.tool === "task" && - x.state.status === "running" && - x.state.metadata?.background !== true, - ) - } - > - · - {backgroundShortcut()} - background - - - - - + alwaysSeparate.add(el)} border={["left"]} @@ -1616,27 +1149,16 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las - + alwaysSeparate.add(el)} paddingLeft={3}> - - ▣{" "} - {" "} - {Locale.titlecase(props.message.mode)} + + {Locale.titlecase(props.message.agent)} + · {model()} · {Locale.duration(duration())} - - · interrupted - @@ -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 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 ( alwaysSeparate.add(el)} @@ -2186,27 +1707,27 @@ function Write(props: ToolProps) { return ( - + - + - Write {pathFormatter.format(stringValue(props.input.filePath))} + Write {pathFormatter.format(stringValue(props.input.path))} @@ -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) { - Read {pathFormatter.format(stringValue(props.input.filePath))} {input(props.input, ["filePath"])} + Read {pathFormatter.format(stringValue(props.input.path))} {input(props.input, ["path"])} {(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 ( { - 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, + )} ) } @@ -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 ( - + - + - - Edit {pathFormatter.format(stringValue(props.input.filePath))} {input({ replaceAll: props.input.replaceAll })} + + Edit {pathFormatter.format(stringValue(props.input.path))} {input({ replaceAll: props.input.replaceAll })} @@ -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 ( - - - - ) - } - - 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 ( 0}> {(file) => ( - - - -{file.deletions} line{file.deletions !== 1 ? "s" : ""} - - } - > - - - + + {file.resource} )} @@ -2673,6 +2082,36 @@ function recordValue(value: unknown): Record | undefined { return value as Record } +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) => { diff --git a/packages/tui/src/routes/session/permission.tsx b/packages/tui/src/routes/session/permission.tsx index 766ed3c116..1e20ffac60 100644 --- a/packages/tui/src/routes/session/permission.tsx +++ b/packages/tui/src/routes/session/permission.tsx @@ -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={ - - + + This will allow the following patterns until OpenCode is restarted - + {(pattern) => ( {"- "} @@ -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? { - 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? {(() => { 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(), }) }} /> diff --git a/packages/tui/src/routes/session/question.tsx b/packages/tui/src/routes/session/question.tsx index 191d0a936a..ded2c6db4f 100644 --- a/packages/tui/src/routes/session/question.tsx +++ b/packages/tui/src/routes/session/question.tsx @@ -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(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 } diff --git a/packages/tui/src/routes/session/sidebar.tsx b/packages/tui/src/routes/session/sidebar.tsx index 0c5d2b3139..baf70cd576 100644 --- a/packages/tui/src/routes/session/sidebar.tsx +++ b/packages/tui/src/routes/session/sidebar.tsx @@ -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 }) { {props.sessionID} - + } + fallback={ + + } > {(item) => ( 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() { - {subagentInfo().label} + {subagentInfo()} - 0}> - - ({subagentInfo().index} of {subagentInfo().total}) - - {(item) => ( @@ -95,10 +83,10 @@ export function SubagentFooter() { 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} > Parent {parentShortcut()} diff --git a/packages/tui/test/cli/tui/__snapshots__/inline-tool-wrap-snapshot.test.tsx.snap b/packages/tui/test/cli/tui/__snapshots__/inline-tool-wrap-snapshot.test.tsx.snap index 46c48ef325..4b9ec9ccce 100644 --- a/packages/tui/test/cli/tui/__snapshots__/inline-tool-wrap-snapshot.test.tsx.snap +++ b/packages/tui/test/cli/tui/__snapshots__/inline-tool-wrap-snapshot.test.tsx.snap @@ -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" diff --git a/packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx b/packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx index 8ba730906a..e1ad5dc70c 100644 --- a/packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx +++ b/packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx @@ -144,7 +144,7 @@ function AssistantSummaryBeforeInlineFixture() { return ( alwaysSeparate.add(el)} paddingLeft={3}> - ▣ Build · Little Frank · 53.1s + Build · Little Frank · 53.1s {"Build Task — Review changes\n↳ 48 toolcalls · 1m 40s"}