diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index edf9085ae..a05dda1b4 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -34,7 +34,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 { SyncProviderV2 } from "./context/sync-v2" +import { DataProvider } from "./context/data" import { LocalProvider, useLocal } from "./context/local" import { DialogModel } from "./component/dialog-model" import { useConnected } from "./component/use-connected" @@ -294,7 +294,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { > - + @@ -315,7 +315,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { - + diff --git a/packages/tui/src/component/dialog-model.tsx b/packages/tui/src/component/dialog-model.tsx index 15da1a65d..b540e86b2 100644 --- a/packages/tui/src/component/dialog-model.tsx +++ b/packages/tui/src/component/dialog-model.tsx @@ -1,17 +1,17 @@ import { createMemo, createSignal } from "solid-js" import { useLocal } from "../context/local" -import { useSync } from "../context/sync" -import { map, pipe, flatMap, entries, filter, sortBy, take } from "remeda" +import { map, pipe, filter, sortBy, take } from "remeda" import { DialogSelect } from "../ui/dialog-select" import { useDialog } from "../ui/dialog" import { createDialogProviderOptions, DialogProvider } from "./dialog-provider" import { DialogVariant } from "./dialog-variant" import * as fuzzysort from "fuzzysort" import { useConnected } from "./use-connected" +import { useData } from "../context/data" export function DialogModel(props: { providerID?: string }) { const local = useLocal() - const sync = useSync() + const data = useData() const dialog = useDialog() const [query, setQuery] = createSignal("") @@ -29,19 +29,21 @@ export function DialogModel(props: { providerID?: string }) { function toOptions(items: typeof favorites, category: string) { if (!showSections) return [] return items.flatMap((item) => { - const provider = sync.data.provider.find((x) => x.id === item.providerID) + const provider = data.location.provider.list()?.find((provider) => provider.id === item.providerID) if (!provider) return [] - const model = provider.models[item.modelID] + const model = data.location.model + .list() + ?.find((model) => model.providerID === item.providerID && model.id === item.modelID) if (!model) return [] return [ { key: item, value: { providerID: provider.id, modelID: model.id }, - title: model.name ?? item.modelID, + title: model.name, description: provider.name, category, disabled: provider.id === "opencode" && model.id.includes("-nano"), - footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined, + footer: model.cost[0]?.input === 0 && provider.id === "opencode" ? "Free" : undefined, onSelect: () => { onSelect(provider.id, model.id) }, @@ -59,42 +61,45 @@ export function DialogModel(props: { providerID?: string }) { ) const providerOptions = pipe( - sync.data.provider, + data.location.model.list() ?? [], + filter((model) => model.status !== "deprecated"), + filter((model) => (props.providerID ? model.providerID === props.providerID : true)), sortBy( - (provider) => provider.id !== "opencode", - (provider) => provider.name, - ), - flatMap((provider) => - pipe( - provider.models, - entries(), - filter(([_, info]) => info.status !== "deprecated"), - filter(([_, info]) => (props.providerID ? info.providerID === props.providerID : true)), - map(([model, info]) => ({ - value: { providerID: provider.id, modelID: model }, - title: info.name ?? model, - releaseDate: info.release_date, - description: favorites.some((item) => item.providerID === provider.id && item.modelID === model) - ? "(Favorite)" - : undefined, - category: connected() ? provider.name : undefined, - disabled: provider.id === "opencode" && model.includes("-nano"), - footer: info.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined, - onSelect() { - onSelect(provider.id, model) - }, - })), - filter((x) => { - if (!showSections) return true - if (favorites.some((item) => item.providerID === x.value.providerID && item.modelID === x.value.modelID)) - return false - if (recents.some((item) => item.providerID === x.value.providerID && item.modelID === x.value.modelID)) - return false - return true - }), - (options) => sortModelOptions(options, props.providerID !== undefined), - ), + (model) => model.providerID !== "opencode", + (model) => data.location.provider.list()?.find((provider) => provider.id === model.providerID)?.name ?? "", + [(model) => model.time.released, "desc"], ), + map((model) => ({ + value: { providerID: model.providerID, modelID: model.id }, + title: model.name, + releaseDate: model.time.released, + description: favorites.some((item) => item.providerID === model.providerID && item.modelID === model.id) + ? "(Favorite)" + : undefined, + category: connected() + ? data.location.provider.list()?.find((provider) => provider.id === model.providerID)?.name + : undefined, + disabled: !model.enabled || (model.providerID === "opencode" && model.id.includes("-nano")), + footer: model.cost[0]?.input === 0 && model.providerID === "opencode" ? "Free" : undefined, + onSelect() { + onSelect(model.providerID, model.id) + }, + })), + filter((option) => { + if (!showSections) return true + if ( + favorites.some( + (item) => item.providerID === option.value.providerID && item.modelID === option.value.modelID, + ) + ) + return false + if ( + recents.some((item) => item.providerID === option.value.providerID && item.modelID === option.value.modelID) + ) + return false + return true + }), + (options) => sortModelOptions(options, props.providerID !== undefined), ) const popularProviders = !connected() @@ -119,7 +124,7 @@ export function DialogModel(props: { providerID?: string }) { }) const provider = createMemo(() => - props.providerID ? sync.data.provider.find((x) => x.id === props.providerID) : null, + props.providerID ? data.location.provider.list()?.find((item) => item.id === props.providerID) : null, ) const title = createMemo(() => { @@ -172,7 +177,7 @@ export function DialogModel(props: { providerID?: string }) { ) } -export function sortModelOptions( +export function sortModelOptions( options: T[], newestFirst: boolean, ) { diff --git a/packages/tui/src/component/dialog-workspace-create.tsx b/packages/tui/src/component/dialog-workspace-create.tsx index 98c71bb00..b3e807b2f 100644 --- a/packages/tui/src/component/dialog-workspace-create.tsx +++ b/packages/tui/src/component/dialog-workspace-create.tsx @@ -132,7 +132,7 @@ export async function warpWorkspaceSession(input: { input.project.workspace.set(input.workspaceID) - await input.sync.bootstrap({ fatal: false }).catch(() => undefined) + await input.sync.bootstrap() const dir = input.project.instance.directory() || input.sync.path.directory if (dir) { diff --git a/packages/tui/src/component/dialog-workspace-list.tsx b/packages/tui/src/component/dialog-workspace-list.tsx index eab2acf7c..b37efc622 100644 --- a/packages/tui/src/component/dialog-workspace-list.tsx +++ b/packages/tui/src/component/dialog-workspace-list.tsx @@ -82,7 +82,7 @@ export function DialogWorkspaceList() { route.navigate({ type: "home" }) } await project.workspace.sync() - await sync.bootstrap({ fatal: false }).catch(() => undefined) + await sync.bootstrap() setRemoving(undefined) } diff --git a/packages/tui/src/component/prompt/autocomplete.tsx b/packages/tui/src/component/prompt/autocomplete.tsx index b543d7c38..a8a6b1288 100644 --- a/packages/tui/src/component/prompt/autocomplete.tsx +++ b/packages/tui/src/component/prompt/autocomplete.tsx @@ -9,7 +9,7 @@ import { useEditorContext } from "../../context/editor" import { useProject } from "../../context/project" import { useSDK } from "../../context/sdk" import { useSync } from "../../context/sync" -import { useSyncV2 } from "../../context/sync-v2" +import { useData } from "../../context/data" import { getScrollAcceleration } from "../../util/scroll" import { useTuiPaths } from "../../context/runtime" import { useTuiConfig } from "../../config" @@ -85,7 +85,7 @@ export function Autocomplete(props: { const editor = useEditorContext() const sdk = useSDK() const sync = useSync() - const syncV2 = useSyncV2() + const data = useData() const project = useProject() const slashes = useCommandSlashes() const modeStack = useOpencodeModeStack() @@ -273,12 +273,14 @@ export function Autocomplete(props: { } } + const references = createMemo(() => data.location.reference.list() ?? []) + const referenceMatch = createMemo(() => { if (!store.visible || store.visible === "/") return const { baseQuery } = extractLineRange(search()) const slash = baseQuery.indexOf("/") const alias = slash === -1 ? baseQuery : baseQuery.slice(0, slash) - return syncV2.data.reference.find((item) => !item.hidden && item.name === alias) + return references().find((item) => !item.hidden && item.name === alias) }) function normalizeMentionPath(filePath: string) { @@ -312,14 +314,12 @@ export function Autocomplete(props: { async (query) => { if (!store.visible || store.visible === "/") return [] if (referenceMatch()) return [] - const { lineRange, baseQuery } = extractLineRange(query ?? "") // Get files from SDK - const result = await sdk.client.v2.fs.find({ + const result = await sdk.client.find.files({ query: baseQuery, - limit: "20", - location: { workspace: project.workspace.current() }, + workspace: project.workspace.current(), }) const options: AutocompleteOption[] = [] @@ -329,14 +329,15 @@ export function Autocomplete(props: { if (!result.error && result.data) { const width = props.anchor().width - 4 options.push( - ...result.data.data.map((item): AutocompleteOption => { - const { filename, url, part } = createFilePart(item.path, lineRange) + ...result.data.map((item): AutocompleteOption => { + const { filename, url, part } = createFilePart(item, lineRange) + const isDir = item.endsWith("/") return { display: Locale.truncateMiddle(filename, width), value: filename, - isDirectory: item.type === "directory", - path: item.path, + isDirectory: isDir, + path: item, onSelect: () => { insertPart(filename, part) }, @@ -389,16 +390,15 @@ export function Autocomplete(props: { }) const agents = createMemo(() => { - const agents = sync.data.agent - return agents + return (data.location.agent.list() ?? []) .filter((agent) => !agent.hidden && agent.mode !== "primary") .map( (agent): AutocompleteOption => ({ - display: "@" + agent.name, + display: "@" + agent.id, onSelect: () => { - insertPart(agent.name, { + insertPart(agent.id, { type: "agent", - name: agent.name, + name: agent.id, source: { start: 0, end: 0, @@ -411,7 +411,7 @@ export function Autocomplete(props: { }) const referenceAliases = createMemo(() => - syncV2.data.reference + references() .filter((reference) => !reference.hidden) .map( (reference): AutocompleteOption => ({ @@ -471,8 +471,6 @@ export function Autocomplete(props: { const commandsValue = commands() const searchValue = search() - // @/... — narrow to the matched reference, files come from fff - // already ranked so there is no re-ranking here. if (store.visible === "@" && referenceMatchValue) { return referenceAliasesValue.filter((item) => item.display === `@${referenceMatchValue.name}`) } diff --git a/packages/tui/src/component/use-connected.tsx b/packages/tui/src/component/use-connected.tsx index 6a9e5f82a..47886d67a 100644 --- a/packages/tui/src/component/use-connected.tsx +++ b/packages/tui/src/component/use-connected.tsx @@ -1,9 +1,7 @@ import { createMemo } from "solid-js" -import { useSync } from "../context/sync" +import { useData } from "../context/data" export function useConnected() { - const sync = useSync() - return createMemo(() => - sync.data.provider.some((x) => x.id !== "opencode" || Object.values(x.models).some((y) => y.cost?.input !== 0)), - ) + const data = useData() + return createMemo(() => (data.location.provider.list() ?? []).some((provider) => provider.enabled !== false)) } diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx new file mode 100644 index 000000000..23df05c5f --- /dev/null +++ b/packages/tui/src/context/data.tsx @@ -0,0 +1,567 @@ +import { useEvent } from "./event" +import type { + AgentV2Info, + CommandV2Info, + Event, + LocationRef, + ModelV2Info, + PermissionSavedInfo, + PermissionV2Request, + ProviderV2Info, + QuestionV2Request, + ReferenceInfo, + SessionMessage, + SessionMessageAssistant, + SessionMessageAssistantReasoning, + SessionMessageAssistantText, + SessionMessageAssistantTool, + SessionV2Info, + SkillV2Info, +} from "@opencode-ai/sdk/v2" +import { createStore, produce } from "solid-js/store" +import { createSimpleContext } from "./helper" +import { useSDK } from "./sdk" +import { createSignal, onMount } from "solid-js" + +type LocationData = { + agent?: AgentV2Info[] + command?: CommandV2Info[] + model?: ModelV2Info[] + provider?: ProviderV2Info[] + reference?: ReferenceInfo[] + skill?: SkillV2Info[] +} + +type Data = { + session: { + info: Record + message: Record + permission: Record + question: Record + } + project: { + permission: Record + } + location: Record +} + +function locationKey(location: LocationRef) { + return JSON.stringify([location.directory, location.workspaceID]) +} + +function locationQuery(ref?: LocationRef) { + return ref ? { directory: ref.directory, workspace: ref.workspaceID } : undefined +} + +export const { use: useData, provider: DataProvider } = createSimpleContext({ + name: "Data", + init: () => { + const [store, setStore] = createStore({ + session: { + info: {}, + message: {}, + permission: {}, + question: {}, + }, + project: { + permission: {}, + }, + location: {}, + }) + + const event = useEvent() + const sdk = useSDK() + const [defaultLocation, setDefaultLocation] = createSignal({ + directory: sdk.directory ?? process.cwd(), + }) + + const message = { + update(sessionID: string, fn: (messages: SessionMessage[]) => void) { + setStore( + "session", + "message", + produce((draft) => { + fn((draft[sessionID] ??= [])) + }), + ) + }, + prepend(messages: SessionMessage[], item: SessionMessage) { + if (messages.some((existing) => existing.id === item.id)) return + messages.unshift(item) + }, + activeAssistant(messages: SessionMessage[]) { + const item = messages.find((item) => item.type === "assistant" && !item.time.completed) + return item?.type === "assistant" ? item : undefined + }, + assistant(messages: SessionMessage[], messageID: string) { + const item = messages.find((item) => item.type === "assistant" && item.id === messageID) + return item?.type === "assistant" ? item : undefined + }, + activeShell(messages: SessionMessage[], callID: string) { + const item = messages.find((item) => item.type === "shell" && item.callID === callID) + return item?.type === "shell" ? item : undefined + }, + latestTool(assistant: SessionMessageAssistant | undefined, callID?: string) { + return assistant?.content.findLast( + (item): item is SessionMessageAssistantTool => + item.type === "tool" && (callID === undefined || item.id === callID), + ) + }, + latestText(assistant: SessionMessageAssistant | undefined, textID: string) { + return assistant?.content.findLast( + (item): item is SessionMessageAssistantText => item.type === "text" && item.id === textID, + ) + }, + latestReasoning(assistant: SessionMessageAssistant | undefined, reasoningID: string) { + return assistant?.content.findLast( + (item): item is SessionMessageAssistantReasoning => item.type === "reasoning" && item.id === reasoningID, + ) + }, + } + + event.subscribe((event) => { + switch (event.type) { + case "session.next.agent.switched": + message.update(event.properties.sessionID, (draft) => { + message.prepend(draft, { + id: event.properties.messageID, + type: "agent-switched", + agent: event.properties.agent, + time: { created: event.properties.timestamp }, + }) + }) + break + case "session.next.model.switched": + message.update(event.properties.sessionID, (draft) => { + message.prepend(draft, { + id: event.properties.messageID, + type: "model-switched", + model: event.properties.model, + time: { created: event.properties.timestamp }, + }) + }) + break + case "session.next.prompted": { + message.update(event.properties.sessionID, (draft) => { + message.prepend(draft, { + id: event.properties.messageID, + type: "user", + text: event.properties.prompt.text, + files: event.properties.prompt.files, + agents: event.properties.prompt.agents, + time: { created: event.properties.timestamp }, + }) + }) + break + } + case "session.next.prompt.admitted": + break + case "session.next.prompt.promoted": + message.update(event.properties.sessionID, (draft) => { + message.prepend(draft, { + id: event.properties.messageID, + type: "user", + text: event.properties.prompt.text, + files: event.properties.prompt.files, + agents: event.properties.prompt.agents, + time: { created: event.properties.timeCreated }, + }) + }) + break + case "session.next.context.updated": + message.update(event.properties.sessionID, (draft) => { + message.prepend(draft, { + id: event.properties.messageID, + type: "system", + text: event.properties.text, + time: { created: event.properties.timestamp }, + }) + }) + break + case "session.next.synthetic": + message.update(event.properties.sessionID, (draft) => { + message.prepend(draft, { + id: event.properties.messageID, + type: "synthetic", + sessionID: event.properties.sessionID, + text: event.properties.text, + time: { created: event.properties.timestamp }, + }) + }) + break + case "session.next.shell.started": + message.update(event.properties.sessionID, (draft) => { + message.prepend(draft, { + id: event.properties.messageID, + type: "shell", + callID: event.properties.callID, + command: event.properties.command, + output: "", + time: { created: event.properties.timestamp }, + }) + }) + break + case "session.next.shell.ended": + message.update(event.properties.sessionID, (draft) => { + const match = message.activeShell(draft, event.properties.callID) + if (!match) return + match.output = event.properties.output + match.time.completed = event.properties.timestamp + }) + break + case "session.next.step.started": + message.update(event.properties.sessionID, (draft) => { + if (draft.some((message) => message.id === event.properties.assistantMessageID)) return + const currentAssistant = message.activeAssistant(draft) + if (currentAssistant) currentAssistant.time.completed = event.properties.timestamp + message.prepend(draft, { + id: event.properties.assistantMessageID, + type: "assistant", + agent: event.properties.agent, + model: event.properties.model, + content: [], + snapshot: event.properties.snapshot ? { start: event.properties.snapshot } : undefined, + time: { created: event.properties.timestamp }, + }) + }) + break + case "session.next.step.ended": + message.update(event.properties.sessionID, (draft) => { + const currentAssistant = message.assistant(draft, event.properties.assistantMessageID) + if (!currentAssistant) return + currentAssistant.time.completed = event.properties.timestamp + currentAssistant.finish = event.properties.finish + currentAssistant.cost = event.properties.cost + currentAssistant.tokens = event.properties.tokens + if (event.properties.snapshot) + currentAssistant.snapshot = { ...currentAssistant.snapshot, end: event.properties.snapshot } + }) + break + case "session.next.step.failed": + message.update(event.properties.sessionID, (draft) => { + const currentAssistant = message.assistant(draft, event.properties.assistantMessageID) + if (!currentAssistant) return + currentAssistant.time.completed = event.properties.timestamp + currentAssistant.finish = "error" + currentAssistant.error = event.properties.error + }) + break + case "session.next.text.started": + message.update(event.properties.sessionID, (draft) => { + message.assistant(draft, event.properties.assistantMessageID)?.content.push({ + type: "text", + id: event.properties.textID, + text: "", + }) + }) + break + case "session.next.text.delta": + message.update(event.properties.sessionID, (draft) => { + const match = message.latestText( + message.assistant(draft, event.properties.assistantMessageID), + event.properties.textID, + ) + if (match) match.text += event.properties.delta + }) + break + case "session.next.text.ended": + message.update(event.properties.sessionID, (draft) => { + const match = message.latestText( + message.assistant(draft, event.properties.assistantMessageID), + event.properties.textID, + ) + if (match) match.text = event.properties.text + }) + break + case "session.next.tool.input.started": + message.update(event.properties.sessionID, (draft) => { + message.assistant(draft, event.properties.assistantMessageID)?.content.push({ + type: "tool", + id: event.properties.callID, + name: event.properties.name, + time: { created: event.properties.timestamp }, + state: { status: "pending", input: "" }, + }) + }) + break + case "session.next.tool.input.delta": + message.update(event.properties.sessionID, (draft) => { + const match = message.latestTool( + message.assistant(draft, event.properties.assistantMessageID), + event.properties.callID, + ) + if (match?.state.status === "pending") match.state.input += event.properties.delta + }) + break + case "session.next.tool.input.ended": + message.update(event.properties.sessionID, (draft) => { + const match = message.latestTool( + message.assistant(draft, event.properties.assistantMessageID), + event.properties.callID, + ) + if (match?.state.status === "pending") match.state.input = event.properties.text + }) + break + case "session.next.tool.called": + message.update(event.properties.sessionID, (draft) => { + const match = message.latestTool( + message.assistant(draft, event.properties.assistantMessageID), + event.properties.callID, + ) + if (!match) return + match.time.ran = event.properties.timestamp + match.provider = event.properties.provider + match.state = { status: "running", input: event.properties.input, structured: {}, content: [] } + }) + break + case "session.next.tool.progress": + message.update(event.properties.sessionID, (draft) => { + const match = message.latestTool( + message.assistant(draft, event.properties.assistantMessageID), + event.properties.callID, + ) + if (match?.state.status !== "running") return + match.state.structured = event.properties.structured + match.state.content = [...event.properties.content] + }) + break + case "session.next.tool.success": + message.update(event.properties.sessionID, (draft) => { + const match = message.latestTool( + message.assistant(draft, event.properties.assistantMessageID), + event.properties.callID, + ) + if (match?.state.status !== "running") return + match.state = { + status: "completed", + input: match.state.input, + structured: event.properties.structured, + content: [...event.properties.content], + result: event.properties.result, + } + match.provider = { + executed: event.properties.provider.executed || match.provider?.executed === true, + metadata: match.provider?.metadata, + resultMetadata: event.properties.provider.metadata, + } + match.time.completed = event.properties.timestamp + }) + break + case "session.next.tool.failed": + message.update(event.properties.sessionID, (draft) => { + const match = message.latestTool( + message.assistant(draft, event.properties.assistantMessageID), + event.properties.callID, + ) + if (!match || (match.state.status !== "pending" && match.state.status !== "running")) return + match.state = { + status: "error", + error: event.properties.error, + input: typeof match.state.input === "string" ? {} : match.state.input, + structured: match.state.status === "running" ? match.state.structured : {}, + content: match.state.status === "running" ? match.state.content : [], + result: event.properties.result, + } + match.provider = { + executed: event.properties.provider.executed || match.provider?.executed === true, + metadata: match.provider?.metadata, + resultMetadata: event.properties.provider.metadata, + } + match.time.completed = event.properties.timestamp + }) + break + case "session.next.reasoning.started": + message.update(event.properties.sessionID, (draft) => { + message.assistant(draft, event.properties.assistantMessageID)?.content.push({ + type: "reasoning", + id: event.properties.reasoningID, + text: "", + providerMetadata: event.properties.providerMetadata, + }) + }) + break + case "session.next.reasoning.delta": + message.update(event.properties.sessionID, (draft) => { + const match = message.latestReasoning( + message.assistant(draft, event.properties.assistantMessageID), + event.properties.reasoningID, + ) + if (match) match.text += event.properties.delta + }) + break + case "session.next.reasoning.ended": + message.update(event.properties.sessionID, (draft) => { + const match = message.latestReasoning( + message.assistant(draft, event.properties.assistantMessageID), + event.properties.reasoningID, + ) + if (match) { + match.text = event.properties.text + if (event.properties.providerMetadata !== undefined) + match.providerMetadata = event.properties.providerMetadata + } + }) + break + case "session.next.retried": + case "session.next.compaction.started": + case "session.next.compaction.delta": + break + case "session.next.compaction.ended": + message.update(event.properties.sessionID, (draft) => { + message.prepend(draft, { + id: event.properties.messageID, + type: "compaction", + reason: event.properties.reason, + summary: event.properties.text, + recent: event.properties.recent, + time: { created: event.properties.timestamp }, + }) + }) + break + case "reference.updated": + void result.location.reference.refresh() + break + } + }) + + const result = { + session: { + get(sessionID: string) { + return store.session.info[sessionID] + }, + async refresh(sessionID: string) { + const result = await sdk.client.v2.session.get({ sessionID }, { throwOnError: true }) + setStore("session", "info", sessionID, result.data.data) + }, + message: { + list(sessionID: string) { + 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) + }, + }, + permission: { + list(sessionID: string) { + return store.session.permission[sessionID] + }, + async refresh(sessionID: string) { + const result = await sdk.client.v2.session.permission.list({ sessionID }, { throwOnError: true }) + setStore("session", "permission", sessionID, result.data.data) + }, + }, + question: { + list(sessionID: string) { + return store.session.question[sessionID] + }, + async refresh(sessionID: string) { + const result = await sdk.client.v2.session.question.list({ sessionID }, { throwOnError: true }) + setStore("session", "question", sessionID, result.data.data) + }, + }, + }, + project: { + permission: { + list(projectID: string) { + return store.project.permission[projectID] + }, + async refresh(projectID: string) { + const result = await sdk.client.v2.permission.saved.list({ projectID }, { throwOnError: true }) + setStore("project", "permission", projectID, result.data.data) + }, + }, + }, + location: { + default() { + return defaultLocation() + }, + async refresh(ref?: LocationRef) { + const response = await sdk.client.v2.location.get({ location: locationQuery(ref) }, { throwOnError: true }) + const location = response.data + const key = locationKey(location) + if (!store.location[key]) setStore("location", key, {}) + if (!ref) setDefaultLocation({ directory: location.directory, workspaceID: location.workspaceID }) + }, + agent: { + list(location?: LocationRef) { + return store.location[locationKey(location ?? defaultLocation())]?.agent + }, + async refresh(ref?: LocationRef) { + const result = await sdk.client.v2.agent.list({ location: locationQuery(ref) }, { throwOnError: true }) + const key = locationKey(result.data.location) + setStore("location", key, "agent", result.data.data) + }, + }, + command: { + list(location?: LocationRef) { + return store.location[locationKey(location ?? defaultLocation())]?.command + }, + async refresh(ref?: LocationRef) { + const result = await sdk.client.v2.command.list({ location: locationQuery(ref) }, { throwOnError: true }) + const key = locationKey(result.data.location) + setStore("location", key, "command", result.data.data) + }, + }, + model: { + list(location?: LocationRef) { + return store.location[locationKey(location ?? defaultLocation())]?.model + }, + async refresh(ref?: LocationRef) { + const result = await sdk.client.v2.model.list({ location: locationQuery(ref) }, { throwOnError: true }) + const key = locationKey(result.data.location) + setStore("location", key, "model", result.data.data) + }, + }, + provider: { + list(location?: LocationRef) { + return store.location[locationKey(location ?? defaultLocation())]?.provider + }, + async refresh(ref?: LocationRef) { + const result = await sdk.client.v2.provider.list({ location: locationQuery(ref) }, { throwOnError: true }) + const key = locationKey(result.data.location) + setStore("location", key, "provider", result.data.data) + }, + }, + reference: { + list(location?: LocationRef) { + return store.location[locationKey(location ?? defaultLocation())]?.reference + }, + async refresh(ref?: LocationRef) { + const result = await sdk.client.v2.reference.list({ location: locationQuery(ref) }, { throwOnError: true }) + const key = locationKey(result.data.location) + setStore("location", key, "reference", result.data.data) + }, + }, + skill: { + list(location?: LocationRef) { + return store.location[locationKey(location ?? defaultLocation())]?.skill + }, + async refresh(ref?: LocationRef) { + const result = await sdk.client.v2.skill.list({ location: locationQuery(ref) }, { throwOnError: true }) + const key = locationKey(result.data.location) + setStore("location", key, "skill", result.data.data) + }, + }, + }, + } + + onMount(() => { + void Promise.allSettled([ + result.location.refresh(), + result.location.agent.refresh(), + result.location.model.refresh(), + result.location.provider.refresh(), + result.location.reference.refresh(), + result.location.command.refresh(), + result.location.skill.refresh(), + ]) + .then((settled) => { + for (const failure of settled.filter((item) => item.status === "rejected")) + console.error("Failed to refresh default location data", failure.reason) + }) + }) + + return result + }, +}) diff --git a/packages/tui/src/context/local.tsx b/packages/tui/src/context/local.tsx index d9d0c492f..f82ea7d1b 100644 --- a/packages/tui/src/context/local.tsx +++ b/packages/tui/src/context/local.tsx @@ -12,6 +12,7 @@ import { readJson, writeJsonAtomic } from "../util/persistence" import { useTheme } from "./theme" import { useToast } from "../ui/toast" import { useRoute } from "./route" +import { useData } from "./data" export type LocalTheme = { secondary: RGBA @@ -51,15 +52,17 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ name: "Local", init: () => { const sync = useSync() + const data = useData() const sdk = useSDK() const toast = useToast() const theme = useTheme().theme const route = useRoute() const paths = useTuiPaths() + const providers = createMemo(() => data.location.provider.list() ?? []) + const models = createMemo(() => data.location.model.list() ?? []) function isModelValid(model: { providerID: string; modelID: string }) { - const provider = sync.data.provider.find((x) => x.id === model.providerID) - return !!provider?.models[model.modelID] + return models().some((item) => item.providerID === model.providerID && item.id === model.modelID && item.enabled) } function getFirstValidModel(...modelFns: (() => { providerID: string; modelID: string } | undefined)[]) { @@ -71,8 +74,18 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ } function createAgent() { - const agents = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden)) - const visibleAgents = createMemo(() => sync.data.agent.filter((x) => !x.hidden)) + const all = createMemo(() => + (data.location.agent.list() ?? []).map((agent) => ({ + ...agent, + name: agent.id, + native: false, + model: agent.model + ? { providerID: agent.model.providerID, modelID: agent.model.id, variant: agent.model.variant } + : undefined, + })), + ) + const agents = createMemo(() => all().filter((agent) => agent.mode !== "subagent" && !agent.hidden)) + const visibleAgents = createMemo(() => all().filter((agent) => !agent.hidden)) const [agentStore, setAgentStore] = createStore({ current: undefined as string | undefined, }) @@ -218,15 +231,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ } } - const provider = sync.data.provider[0] - if (!provider) return undefined - const defaultModel = sync.data.provider_default[provider.id] - const firstModel = Object.values(provider.models)[0] - const model = defaultModel ?? firstModel?.id + const model = models().find((item) => item.enabled && providers().some((provider) => provider.id === item.providerID)) if (!model) return undefined return { - providerID: provider.id, - modelID: model, + providerID: model.providerID, + modelID: model.id, } }) @@ -261,12 +270,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ reasoning: false, } } - const provider = sync.data.provider.find((x) => x.id === value.providerID) - const info = provider?.models[value.modelID] + const provider = providers().find((item) => item.id === value.providerID) + const info = models().find((item) => item.providerID === value.providerID && item.id === value.modelID) return { provider: provider?.name ?? value.providerID, model: info?.name ?? value.modelID, - reasoning: info?.capabilities?.reasoning ?? false, + reasoning: false, } }), cycle(direction: 1 | -1) { @@ -372,10 +381,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ list() { const m = currentModel() if (!m) return [] - const provider = sync.data.provider.find((x) => x.id === m.providerID) - const info = provider?.models[m.modelID] - if (!info?.variants) return [] - return Object.keys(info.variants) + const info = models().find((item) => item.providerID === m.providerID && item.id === m.modelID) + return info?.variants.map((variant) => variant.id) ?? [] }, set(value: string | undefined) { const m = currentModel() diff --git a/packages/tui/src/context/sync-v2.tsx b/packages/tui/src/context/sync-v2.tsx deleted file mode 100644 index c3102d251..000000000 --- a/packages/tui/src/context/sync-v2.tsx +++ /dev/null @@ -1,465 +0,0 @@ -import { useEvent } from "./event" -import type { - Event, - ReferenceInfo, - SessionMessage, - SessionMessageAssistant, - SessionMessageAssistantReasoning, - SessionMessageAssistantText, - SessionMessageAssistantTool, -} from "@opencode-ai/sdk/v2" -import { createStore, produce, reconcile } from "solid-js/store" -import { createSimpleContext } from "./helper" -import { useSDK } from "./sdk" -import { useProject } from "./project" -import { createEffect } from "solid-js" - -function activeAssistant(messages: SessionMessage[]) { - const index = messages.findIndex((message) => message.type === "assistant" && !message.time.completed) - if (index < 0) return - const assistant = messages[index] - return assistant?.type === "assistant" ? assistant : undefined -} - -function ownedAssistant(messages: SessionMessage[], messageID: string) { - const message = messages.find((message) => message.type === "assistant" && message.id === messageID) - return message?.type === "assistant" ? message : undefined -} - -function activeShell(messages: SessionMessage[], callID: string) { - const index = messages.findIndex((message) => message.type === "shell" && message.callID === callID) - if (index < 0) return - const shell = messages[index] - return shell?.type === "shell" ? shell : undefined -} - -function latestTool(assistant: SessionMessageAssistant | undefined, callID?: string) { - return assistant?.content.findLast( - (item): item is SessionMessageAssistantTool => item.type === "tool" && (callID === undefined || item.id === callID), - ) -} - -function latestText(assistant: SessionMessageAssistant | undefined, textID: string) { - return assistant?.content.findLast( - (item): item is SessionMessageAssistantText => item.type === "text" && item.id === textID, - ) -} - -function latestReasoning(assistant: SessionMessageAssistant | undefined, reasoningID: string) { - return assistant?.content.findLast( - (item): item is SessionMessageAssistantReasoning => item.type === "reasoning" && item.id === reasoningID, - ) -} - -function prepend(messages: SessionMessage[], message: SessionMessage) { - if (messages.some((item) => item.id === message.id)) return - messages.unshift(message) -} - -export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext({ - name: "SyncV2", - init: () => { - const [store, setStore] = createStore<{ - messages: { - [sessionID: string]: SessionMessage[] - } - reference: ReferenceInfo[] - }>({ - messages: {}, - reference: [], - }) - - const event = useEvent() - const sdk = useSDK() - const project = useProject() - const applied = new Set() - const buffering = new Map() - const syncing = new Map>() - - function duplicate(id: string) { - if (applied.has(id)) return true - applied.add(id) - if (applied.size <= 1000) return false - const oldest = applied.values().next() - if (!oldest.done) applied.delete(oldest.value) - return false - } - - function update(sessionID: string, fn: (messages: SessionMessage[]) => void) { - setStore( - "messages", - produce((draft) => { - fn((draft[sessionID] ??= [])) - }), - ) - } - - async function hydrate(sessionID: string) { - const pending: Event[] = [] - const before = JSON.parse(JSON.stringify(store.messages[sessionID] ?? [])) as SessionMessage[] - buffering.set(sessionID, pending) - try { - const response = await sdk.client.v2.session.messages({ sessionID }) - const messages = response.data?.data ?? [] - const snapshotIDs = new Set(messages.map((message) => message.id)) - setStore( - "messages", - sessionID, - reconcile([...messages, ...before.filter((message) => !snapshotIDs.has(message.id))]), - ) - buffering.delete(sessionID) - for (const event of pending) apply(event) - } catch (error) { - buffering.delete(sessionID) - throw error - } - } - - function sync(sessionID: string) { - const existing = syncing.get(sessionID) - if (existing) return existing - const result = hydrate(sessionID).finally(() => syncing.delete(sessionID)) - syncing.set(sessionID, result) - return result - } - - async function syncReferences(workspace = project.workspace.current()) { - const result = await sdk.client.v2.reference.list({ location: { workspace } }) - if (workspace !== project.workspace.current()) return - setStore("reference", reconcile(result.data?.data ?? [])) - } - - createEffect(() => { - project.workspace.current() - void syncReferences() - }) - - function apply(event: Event) { - switch (event.type) { - case "session.next.agent.switched": - update(event.properties.sessionID, (draft) => { - prepend(draft, { - id: event.properties.messageID, - type: "agent-switched", - agent: event.properties.agent, - time: { created: event.properties.timestamp }, - }) - }) - break - case "session.next.model.switched": - update(event.properties.sessionID, (draft) => { - prepend(draft, { - id: event.properties.messageID, - type: "model-switched", - model: event.properties.model, - time: { created: event.properties.timestamp }, - }) - }) - break - case "session.next.prompted": { - update(event.properties.sessionID, (draft) => { - prepend(draft, { - id: event.properties.messageID, - type: "user", - text: event.properties.prompt.text, - files: event.properties.prompt.files, - agents: event.properties.prompt.agents, - time: { created: event.properties.timestamp }, - }) - }) - break - } - case "session.next.prompt.admitted": - break - case "session.next.prompt.promoted": - update(event.properties.sessionID, (draft) => { - prepend(draft, { - id: event.properties.messageID, - type: "user", - text: event.properties.prompt.text, - files: event.properties.prompt.files, - agents: event.properties.prompt.agents, - time: { created: event.properties.timeCreated }, - }) - }) - break - case "session.next.context.updated": - update(event.properties.sessionID, (draft) => { - prepend(draft, { - id: event.properties.messageID, - type: "system", - text: event.properties.text, - time: { created: event.properties.timestamp }, - }) - }) - break - case "session.next.synthetic": - update(event.properties.sessionID, (draft) => { - prepend(draft, { - id: event.properties.messageID, - type: "synthetic", - sessionID: event.properties.sessionID, - text: event.properties.text, - time: { created: event.properties.timestamp }, - }) - }) - break - case "session.next.shell.started": - update(event.properties.sessionID, (draft) => { - prepend(draft, { - id: event.properties.messageID, - type: "shell", - callID: event.properties.callID, - command: event.properties.command, - output: "", - time: { created: event.properties.timestamp }, - }) - }) - break - case "session.next.shell.ended": - update(event.properties.sessionID, (draft) => { - const match = activeShell(draft, event.properties.callID) - if (!match) return - match.output = event.properties.output - match.time.completed = event.properties.timestamp - }) - break - case "session.next.step.started": - update(event.properties.sessionID, (draft) => { - if (draft.some((message) => message.id === event.properties.assistantMessageID)) return - const currentAssistant = activeAssistant(draft) - if (currentAssistant) currentAssistant.time.completed = event.properties.timestamp - prepend(draft, { - id: event.properties.assistantMessageID, - type: "assistant", - agent: event.properties.agent, - model: event.properties.model, - content: [], - snapshot: event.properties.snapshot ? { start: event.properties.snapshot } : undefined, - time: { created: event.properties.timestamp }, - }) - }) - break - case "session.next.step.ended": - update(event.properties.sessionID, (draft) => { - const currentAssistant = ownedAssistant(draft, event.properties.assistantMessageID) - if (!currentAssistant) return - currentAssistant.time.completed = event.properties.timestamp - currentAssistant.finish = event.properties.finish - currentAssistant.cost = event.properties.cost - currentAssistant.tokens = event.properties.tokens - if (event.properties.snapshot) - currentAssistant.snapshot = { ...currentAssistant.snapshot, end: event.properties.snapshot } - }) - break - case "session.next.step.failed": - update(event.properties.sessionID, (draft) => { - const currentAssistant = ownedAssistant(draft, event.properties.assistantMessageID) - if (!currentAssistant) return - currentAssistant.time.completed = event.properties.timestamp - currentAssistant.finish = "error" - currentAssistant.error = event.properties.error - }) - break - case "session.next.text.started": - update(event.properties.sessionID, (draft) => { - ownedAssistant(draft, event.properties.assistantMessageID)?.content.push({ - type: "text", - id: event.properties.textID, - text: "", - }) - }) - break - case "session.next.text.delta": - update(event.properties.sessionID, (draft) => { - const match = latestText( - ownedAssistant(draft, event.properties.assistantMessageID), - event.properties.textID, - ) - if (match) match.text += event.properties.delta - }) - break - case "session.next.text.ended": - update(event.properties.sessionID, (draft) => { - const match = latestText( - ownedAssistant(draft, event.properties.assistantMessageID), - event.properties.textID, - ) - if (match) match.text = event.properties.text - }) - break - case "session.next.tool.input.started": - update(event.properties.sessionID, (draft) => { - ownedAssistant(draft, event.properties.assistantMessageID)?.content.push({ - type: "tool", - id: event.properties.callID, - name: event.properties.name, - time: { created: event.properties.timestamp }, - state: { status: "pending", input: "" }, - }) - }) - break - case "session.next.tool.input.delta": - update(event.properties.sessionID, (draft) => { - const match = latestTool( - ownedAssistant(draft, event.properties.assistantMessageID), - event.properties.callID, - ) - if (match?.state.status === "pending") match.state.input += event.properties.delta - }) - break - case "session.next.tool.input.ended": - update(event.properties.sessionID, (draft) => { - const match = latestTool( - ownedAssistant(draft, event.properties.assistantMessageID), - event.properties.callID, - ) - if (match?.state.status === "pending") match.state.input = event.properties.text - }) - break - case "session.next.tool.called": - update(event.properties.sessionID, (draft) => { - const match = latestTool( - ownedAssistant(draft, event.properties.assistantMessageID), - event.properties.callID, - ) - if (!match) return - match.time.ran = event.properties.timestamp - match.provider = event.properties.provider - match.state = { status: "running", input: event.properties.input, structured: {}, content: [] } - }) - break - case "session.next.tool.progress": - update(event.properties.sessionID, (draft) => { - const match = latestTool( - ownedAssistant(draft, event.properties.assistantMessageID), - event.properties.callID, - ) - if (match?.state.status !== "running") return - match.state.structured = event.properties.structured - match.state.content = [...event.properties.content] - }) - break - case "session.next.tool.success": - update(event.properties.sessionID, (draft) => { - const match = latestTool( - ownedAssistant(draft, event.properties.assistantMessageID), - event.properties.callID, - ) - if (match?.state.status !== "running") return - match.state = { - status: "completed", - input: match.state.input, - structured: event.properties.structured, - content: [...event.properties.content], - result: event.properties.result, - } - match.provider = { - executed: event.properties.provider.executed || match.provider?.executed === true, - metadata: match.provider?.metadata, - resultMetadata: event.properties.provider.metadata, - } - match.time.completed = event.properties.timestamp - }) - break - case "session.next.tool.failed": - update(event.properties.sessionID, (draft) => { - const match = latestTool( - ownedAssistant(draft, event.properties.assistantMessageID), - event.properties.callID, - ) - if (!match || (match.state.status !== "pending" && match.state.status !== "running")) return - match.state = { - status: "error", - error: event.properties.error, - input: typeof match.state.input === "string" ? {} : match.state.input, - structured: match.state.status === "running" ? match.state.structured : {}, - content: match.state.status === "running" ? match.state.content : [], - result: event.properties.result, - } - match.provider = { - executed: event.properties.provider.executed || match.provider?.executed === true, - metadata: match.provider?.metadata, - resultMetadata: event.properties.provider.metadata, - } - match.time.completed = event.properties.timestamp - }) - break - case "session.next.reasoning.started": - update(event.properties.sessionID, (draft) => { - ownedAssistant(draft, event.properties.assistantMessageID)?.content.push({ - type: "reasoning", - id: event.properties.reasoningID, - text: "", - providerMetadata: event.properties.providerMetadata, - }) - }) - break - case "session.next.reasoning.delta": - update(event.properties.sessionID, (draft) => { - const match = latestReasoning( - ownedAssistant(draft, event.properties.assistantMessageID), - event.properties.reasoningID, - ) - if (match) match.text += event.properties.delta - }) - break - case "session.next.reasoning.ended": - update(event.properties.sessionID, (draft) => { - const match = latestReasoning( - ownedAssistant(draft, event.properties.assistantMessageID), - event.properties.reasoningID, - ) - if (match) { - match.text = event.properties.text - if (event.properties.providerMetadata !== undefined) - match.providerMetadata = event.properties.providerMetadata - } - }) - break - case "session.next.retried": - case "session.next.compaction.started": - case "session.next.compaction.delta": - break - case "session.next.compaction.ended": - update(event.properties.sessionID, (draft) => { - prepend(draft, { - id: event.properties.messageID, - type: "compaction", - reason: event.properties.reason, - summary: event.properties.text, - recent: event.properties.recent, - time: { created: event.properties.timestamp }, - }) - }) - break - case "reference.updated": - void syncReferences() - break - } - } - - event.subscribe((event) => { - if (duplicate(event.id)) return - if ("sessionID" in event.properties && typeof event.properties.sessionID === "string") - buffering.get(event.properties.sessionID)?.push(event) - apply(event) - }) - - const result = { - data: store, - session: { - message: { - sync, - fromSession(sessionID: string) { - const messages = store.messages[sessionID] - if (!messages) return [] - return messages - }, - }, - }, - } - - return result - }, -}) diff --git a/packages/tui/src/feature-plugins/builtins.ts b/packages/tui/src/feature-plugins/builtins.ts index f0ed7ab89..b67923f3c 100644 --- a/packages/tui/src/feature-plugins/builtins.ts +++ b/packages/tui/src/feature-plugins/builtins.ts @@ -10,7 +10,6 @@ import SidebarTodo from "./sidebar/todo" import DiffViewer from "./system/diff-viewer" import Notifications from "./system/notifications" import PluginManager from "./system/plugins" -import SessionV2Debug from "./system/session-v2" import WhichKey from "./system/which-key" export type BuiltinTuiPlugin = Omit & { @@ -33,6 +32,5 @@ export function createBuiltinPlugins(options: { experimentalEventSystem: boolean PluginManager, WhichKey, DiffViewer, - ...(options.experimentalEventSystem ? [SessionV2Debug] : []), ] } diff --git a/packages/tui/src/feature-plugins/home/footer.tsx b/packages/tui/src/feature-plugins/home/footer.tsx index 41bee5da5..ae6a24658 100644 --- a/packages/tui/src/feature-plugins/home/footer.tsx +++ b/packages/tui/src/feature-plugins/home/footer.tsx @@ -4,19 +4,21 @@ import { createMemo, Match, Show, Switch } from "solid-js" import { abbreviateHome } from "../../runtime" import { useTuiPaths } from "../../context/runtime" import { useHomeSessionDestination } from "../../routes/home/session-destination" +import { useData } from "../../context/data" const id = "internal:home-footer" function Directory(props: { api: TuiPluginApi }) { const theme = () => props.api.theme.current const destination = useHomeSessionDestination() + const data = useData() const paths = useTuiPaths() const dir = createMemo(() => { const selected = destination?.destination() - if (!selected || selected.type === "new") return - const out = abbreviateHome(selected.directory, paths.home) + const directory = !selected || selected.type === "new" ? data.location.default().directory : selected.directory + const out = abbreviateHome(directory, paths.home) const branch = - selected.directory === (props.api.state.path.directory || paths.cwd) ? props.api.state.vcs?.branch : undefined + directory === (props.api.state.path.directory || paths.cwd) ? props.api.state.vcs?.branch : undefined if (branch) return out + ":" + branch return out }) diff --git a/packages/tui/src/feature-plugins/system/session-v2.tsx b/packages/tui/src/feature-plugins/system/session-v2.tsx deleted file mode 100644 index 8a03ec5dd..000000000 --- a/packages/tui/src/feature-plugins/system/session-v2.tsx +++ /dev/null @@ -1,1196 +0,0 @@ -import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" -import type { BuiltinTuiPlugin } from "../builtins" -import { useSyncV2 } from "../../context/sync-v2" -import { SplitBorder } from "../../ui/border" -import { Spinner } from "../../component/spinner" -import { useTheme } from "../../context/theme" -import { useLocal } from "../../context/local" -import { reasoningSummary, useThinkingMode } from "../../context/thinking" -import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" -import { RGBA, TextAttributes, type BoxRenderable, type SyntaxStyle } from "@opentui/core" -import { useBindings } from "../../keymap" -import { Locale } from "../../util/locale" -import { useTuiPaths } from "../../context/runtime" -import { LANGUAGE_EXTENSIONS } from "../../util/filetype" -import { toolDisplayMetadata, webSearchProviderLabel } from "../../util/tool-display" -import path from "path" -import stripAnsi from "strip-ansi" -import type { - SessionMessage, - SessionMessageAgentSwitched, - SessionMessageAssistant, - SessionMessageAssistantReasoning, - SessionMessageAssistantText, - SessionMessageAssistantTool, - SessionMessageCompaction, - SessionMessageModelSwitched, - SessionMessageShell, - SessionMessageUser, - ToolFileContent, - ToolTextContent, -} from "@opencode-ai/sdk/v2" -import { createEffect, createMemo, createSignal, For, Match, Show, Switch } from "solid-js" -import { collapseToolOutput } from "../../util/collapse-tool-output" -import { setPreLayoutSiblingMargin } from "../../util/layout" - -const id = "internal:session-v2-debug" -const route = "session.v2.messages" - -function currentSessionID(api: TuiPluginApi) { - const current = api.route.current - if (current.name !== "session") return - const sessionID = current.params?.sessionID - return typeof sessionID === "string" ? sessionID : undefined -} - -function View(props: { api: TuiPluginApi; sessionID: string }) { - const sync = useSyncV2() - const dimensions = useTerminalDimensions() - const { theme, syntax, subtleSyntax } = useTheme() - const messages = createMemo(() => sync.data.messages[props.sessionID] ?? []) - const renderedMessages = createMemo(() => messages().toReversed()) - const lastAssistant = createMemo(() => renderedMessages().findLast((message) => message.type === "assistant")) - const lastUserCreated = (index: number) => - renderedMessages() - .slice(0, index) - .findLast((message) => message.type === "user")?.time.created - - createEffect(() => { - void sync.session.message.sync(props.sessionID) - }) - - useBindings(() => ({ - bindings: [ - { - key: "escape", - desc: "Back to session", - group: "Session", - cmd() { - props.api.route.navigate("session", { sessionID: props.sessionID }) - }, - }, - ], - })) - - return ( - - - - - - - - - - {(message, index) => ( - - - - - - - - - <> - - - <> - - - - - - - - - - - - - - - - - - )} - - - - - - - ) -} - -function MissingData(props: { label: string; detail: string }) { - const { theme } = useTheme() - return ( - - - MISSING DATA {props.label} - - {props.detail} - - ) -} - -function UserMessage(props: { message: SessionMessageUser; index: number }) { - const { theme } = useTheme() - const attachments = createMemo(() => [...(props.message.files ?? []), ...(props.message.agents ?? [])]) - return ( - - {props.message.text} - - - - {(file) => ( - - {file.mime} - {file.name ?? file.uri} - - )} - - - {(agent) => ( - - agent - {agent.name} - - )} - - - - - ) -} - -function ShellMessage(props: { message: SessionMessageShell }) { - const { theme } = useTheme() - const dimensions = useTerminalDimensions() - const output = createMemo(() => stripAnsi(props.message.output.trim())) - const [expanded, setExpanded] = createSignal(false) - const maxLines = 10 - const maxChars = createMemo(() => maxLines * Math.max(20, dimensions().width - 6)) - const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars())) - const limited = createMemo(() => { - if (expanded() || !collapsed().overflow) return output() - return collapsed().output - }) - return ( - setExpanded((prev) => !prev) : undefined} - > - - $ {props.message.command} - - {limited()} - - - {expanded() ? "Click to collapse" : "Click to expand"} - - - - ) -} - -function CompactionMessage(props: { message: SessionMessageCompaction }) { - const { theme } = useTheme() - return ( - - ) -} - -function AgentSwitchedMessage(props: { message: SessionMessageAgentSwitched }) { - const { theme } = useTheme() - const local = useLocal() - return ( - - - - Switched agent to - {Locale.titlecase(props.message.agent)} - - - ) -} - -function ModelSwitchedMessage(props: { message: SessionMessageModelSwitched }) { - const { theme } = useTheme() - const model = createMemo(() => { - const variant = props.message.model.variant ? `/${props.message.model.variant}` : "" - return `${props.message.model.providerID}/${props.message.model.id}${variant}` - }) - return ( - - - - Switched model to - {model()} - - - ) -} - -function UnknownMessage(props: { message: SessionMessage }) { - return -} - -function AssistantMessage(props: { - message: SessionMessageAssistant - sessionID: string - last: boolean - syntax: SyntaxStyle - subtleSyntax: SyntaxStyle - start?: number -}) { - const { theme } = useTheme() - const local = useLocal() - const duration = createMemo(() => { - if (!props.message.time.completed) return 0 - return props.message.time.completed - (props.start ?? props.message.time.created) - }) - const model = createMemo(() => { - const variant = props.message.model.variant ? `/${props.message.model.variant}` : "" - return `${props.message.model.providerID}/${props.message.model.id}${variant}` - }) - const final = createMemo(() => props.message.finish && !["tool-calls", "unknown"].includes(props.message.finish)) - return ( - <> - - {(part) => ( - - - - - - props.message.time.completed} - /> - - - - - - )} - - - - - - - {props.message.error} - - - - - - - {Locale.titlecase(props.message.agent)} - · {model()} - - · {Locale.duration(duration())} - - - - - - ) -} - -function AssistantText(props: { part: SessionMessageAssistantText; syntax: SyntaxStyle }) { - const { theme } = useTheme() - return ( - - - - - - ) -} - -function AssistantReasoning(props: { - part: SessionMessageAssistantReasoning - subtleSyntax: SyntaxStyle - completedAt: () => number | undefined -}) { - const { theme } = useTheme() - const thinking = useThinkingMode() - const [expanded, setExpanded] = createSignal(false) - const content = createMemo(() => props.part.text.replace("[REDACTED]", "").trim()) - const inMinimal = createMemo(() => thinking.mode() === "hide") - // v2 reasoning parts have no per-part `time.end` (see SessionMessageAssistantReasoning - // in the v2 SDK); we settle on parent-message completion instead. - const isDone = createMemo(() => props.completedAt() !== undefined) - const summary = createMemo(() => reasoningSummary(content())) - - const toggle = () => { - if (!inMinimal()) return - setExpanded((prev) => !prev) - } - - return ( - - - - - - - - - - - - - ) -} - -function ReasoningHeader(props: { toggleable: boolean; open: boolean; done: boolean; title: string | null }) { - const { theme } = useTheme() - const fg = () => - props.open - ? RGBA.fromValues(theme.warning.r, theme.warning.g, theme.warning.b, theme.thinkingOpacity) - : theme.warning - - return ( - - - - {props.title ? "Thinking: " + props.title : "Thinking"} - - - - - - {props.open ? "- " : "+ "} - - Thought - - : - {props.title} - - - - - ) -} - -function AssistantTool(props: { part: SessionMessageAssistantTool; sessionID: string }) { - const input = createMemo(() => toolInputRecord(props.part.state.input)) - const toolprops = { - get input() { - return input() - }, - get metadata() { - return toolDisplayMetadata(props.part.state) - }, - get output() { - return props.part.state.status === "pending" ? undefined : toolOutput(props.part.state.content) - }, - sessionID: props.sessionID, - part: props.part, - } - return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ) -} - -type ToolProps = { - input: Record - metadata: Record - output?: string - sessionID: string - part: SessionMessageAssistantTool -} - -function GenericTool(props: ToolProps) { - const { theme } = useTheme() - const dimensions = useTerminalDimensions() - const output = createMemo(() => props.output?.trim() ?? "") - const [expanded, setExpanded] = createSignal(false) - const maxLines = 3 - const maxChars = createMemo(() => maxLines * Math.max(20, dimensions().width - 6)) - const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars())) - const limited = createMemo(() => { - if (expanded() || !collapsed().overflow) return output() - return collapsed().output - }) - return ( - - {props.part.name} {input(props.input)} - - } - > - setExpanded((prev) => !prev) : undefined} - > - - {limited()} - - {expanded() ? "Click to collapse" : "Click to expand"} - - - - - ) -} - -function InlineTool(props: { - icon: string - complete: unknown - pending: string - spinner?: boolean - children: JSX.Element - part: SessionMessageAssistantTool -}) { - const { theme } = useTheme() - const renderer = useRenderer() - const [hover, setHover] = createSignal(false) - const [showError, setShowError] = createSignal(false) - const error = createMemo(() => (props.part.state.status === "error" ? props.part.state.error.message : undefined)) - const complete = createMemo(() => !!props.complete) - const denied = createMemo(() => { - const message = error() - if (!message) return false - return ( - message.includes("QuestionRejectedError") || - message.includes("rejected permission") || - message.includes("specified a rule") || - message.includes("user dismissed") - ) - }) - const fg = createMemo(() => { - if (error()) return theme.error - if (complete()) return theme.textMuted - return theme.text - }) - const attributes = createMemo(() => (denied() ? TextAttributes.STRIKETHROUGH : undefined)) - return ( - error() && setHover(true)} - onMouseOut={() => setHover(false)} - onMouseUp={() => { - if (!error()) return - if (renderer.getSelection()?.getSelectedText()) return - setShowError((prev) => !prev) - }} - ref={(el: BoxRenderable) => { - setPreLayoutSiblingMargin(el, (previous) => (previous?.id.startsWith("text-") ? 1 : 0)) - }} - > - - - - - - - - {props.icon} - - - - - ~ - - - - - - - - - - {props.children} - - - - - {props.pending} - - - - - - - {error()} - - - - - ) -} - -function BlockTool(props: { - title: string - children: JSX.Element - part?: SessionMessageAssistantTool - onClick?: () => void - 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.message : undefined)) - return ( - props.onClick && setHover(true)} - onMouseOut={() => setHover(false)} - onMouseUp={() => { - if (renderer.getSelection()?.getSelectedText()) return - props.onClick?.() - }} - flexShrink={0} - > - - {props.title} - - } - > - {props.title.replace(/^# /, "")} - - {props.children} - - {error()} - - - ) -} - -function Bash(props: ToolProps) { - const { theme } = useTheme() - const dimensions = useTerminalDimensions() - const output = createMemo(() => stripAnsi((stringValue(props.metadata.output) ?? props.output ?? "").trim())) - const command = createMemo(() => stringValue(props.input.command) ?? pendingInput(props.part)) - const title = createMemo(() => `# ${stringValue(props.input.description) ?? "Shell"}`) - const [expanded, setExpanded] = createSignal(false) - const maxLines = 10 - const maxChars = createMemo(() => maxLines * Math.max(20, dimensions().width - 6)) - const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars())) - const limited = createMemo(() => { - if (expanded() || !collapsed().overflow) return output() - return collapsed().output - }) - return ( - - - setExpanded((prev) => !prev) : undefined} - > - - $ {command()} - {limited()} - - {expanded() ? "Click to collapse" : "Click to expand"} - - - - - - - {command()} - - - - ) -} - -function Glob(props: ToolProps) { - const normalizePath = usePathNormalizer() - return ( - - Glob "{stringValue(props.input.pattern) ?? pendingInput(props.part)}"{" "} - in {normalizePath(stringValue(props.input.path))} - - {(count) => ( - <> - ({count()} {count() === 1 ? "match" : "matches"}) - - )} - - - ) -} - -function Read(props: ToolProps) { - const normalizePath = usePathNormalizer() - const { theme } = useTheme() - const loaded = createMemo(() => - arrayValue(props.metadata.loaded).filter((item): item is string => typeof item === "string"), - ) - return ( - <> - - Read {normalizePath(stringValue(props.input.filePath) ?? pendingInput(props.part))}{" "} - {input(props.input, ["filePath"])} - - - {(filepath) => ( - - - ↳ Loaded {normalizePath(filepath)} - - - )} - - - ) -} - -function Grep(props: ToolProps) { - const normalizePath = usePathNormalizer() - return ( - - Grep "{stringValue(props.input.pattern) ?? pendingInput(props.part)}"{" "} - in {normalizePath(stringValue(props.input.path))} - - {(matches) => ( - <> - ({matches()} {matches() === 1 ? "match" : "matches"}) - - )} - - - ) -} - -function WebFetch(props: ToolProps) { - return ( - - WebFetch {stringValue(props.input.url) ?? pendingInput(props.part)} - - ) -} - -function WebSearch(props: ToolProps) { - const label = createMemo(() => webSearchProviderLabel(props.metadata.provider)) - return ( - - {label()} "{stringValue(props.input.query) ?? pendingInput(props.part)}"{" "} - {(results) => <>({results()} results)} - - ) -} - -function Write(props: ToolProps) { - const normalizePath = usePathNormalizer() - const { theme, syntax } = useTheme() - const filePath = createMemo(() => stringValue(props.input.filePath) ?? "") - const content = createMemo(() => stringValue(props.input.content) ?? "") - return ( - - - - - - - - - - - - Write {normalizePath(filePath())} - - - - ) -} - -function Edit(props: ToolProps) { - const normalizePath = usePathNormalizer() - const { theme, syntax } = useTheme() - const dimensions = useTerminalDimensions() - const filePath = createMemo(() => stringValue(props.input.filePath) ?? "") - const diff = createMemo(() => stringValue(props.metadata.diff)) - return ( - - - {(diff) => ( - - - 120 ? "split" : "unified"} - filetype={filetype(filePath())} - syntaxStyle={syntax()} - showLineNumbers={true} - width="100%" - wrapMode="word" - 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} - /> - - - - )} - - - - Edit {normalizePath(filePath())} {input({ replaceAll: props.input.replaceAll })} - - - - ) -} - -function ApplyPatch(props: ToolProps) { - const normalizePath = usePathNormalizer() - const { theme, syntax } = useTheme() - const dimensions = useTerminalDimensions() - const files = createMemo(() => arrayValue(props.metadata.files).flatMap((item) => (isRecord(item) ? [item] : []))) - const fileTitle = (file: Record) => { - const type = stringValue(file.type) - const relativePath = stringValue(file.relativePath) ?? stringValue(file.filePath) ?? "patch" - if (type === "delete") return "# Deleted " + relativePath - if (type === "add") return "# Created " + relativePath - if (type === "move") return "# Moved " + normalizePath(stringValue(file.filePath)) + " → " + relativePath - return "← Patched " + relativePath - } - return ( - - 0}> - - {(file) => ( - - - -{numberValue(file.deletions) ?? 0} line{numberValue(file.deletions) === 1 ? "" : "s"} - - } - > - {(patch) => ( - - 120 ? "split" : "unified"} - filetype={filetype(stringValue(file.filePath) ?? stringValue(file.relativePath))} - syntaxStyle={syntax()} - showLineNumbers={true} - width="100%" - wrapMode="word" - 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} - /> - - )} - - - )} - - - - - Patch - - - - ) -} - -function TodoWrite(props: ToolProps) { - const { theme } = useTheme() - const todos = createMemo(() => arrayValue(props.input.todos).flatMap((item) => (isRecord(item) ? [item] : []))) - return ( - - 0 && props.part.state.status === "completed"}> - - - - {(todo) => ( - - {todoIcon(stringValue(todo.status))} {stringValue(todo.content)} - - )} - - - - - - - Updating todos... - - - - ) -} - -function Question(props: ToolProps) { - const { theme } = useTheme() - const questions = createMemo(() => - arrayValue(props.input.questions).flatMap((item) => (isRecord(item) ? [item] : [])), - ) - const answers = createMemo(() => arrayValue(props.metadata.answers)) - return ( - - 0}> - - - - {(question, index) => ( - - {stringValue(question.question)} - {formatAnswer(answers()[index()])} - - )} - - - - - - - Asked {questions().length} question{questions().length === 1 ? "" : "s"} - - - - ) -} - -function Skill(props: ToolProps) { - return ( - - Skill "{stringValue(props.input.name) ?? pendingInput(props.part)}" - - ) -} - -function Task(props: ToolProps) { - const content = createMemo(() => { - const description = stringValue(props.input.description) - if (!description) return pendingInput(props.part) - return `${Locale.titlecase(stringValue(props.input.subagent_type) ?? "General")} Task — ${description}` - }) - return ( - - {content()} - - ) -} - -function Diagnostics(props: { diagnostics: unknown; filePath: string }) { - const normalizePath = usePathNormalizer() - const { theme } = useTheme() - const errors = createMemo(() => { - if (!isRecord(props.diagnostics)) return [] - const value = props.diagnostics[normalizePath(props.filePath)] ?? props.diagnostics[props.filePath] - return arrayValue(value) - .flatMap((item) => (isRecord(item) ? [item] : [])) - .filter((diagnostic) => diagnostic.severity === 1) - .slice(0, 3) - }) - return ( - - - - {(diagnostic) => Error {stringValue(diagnostic.message)}} - - - - ) -} - -function toolOutput(content?: Array) { - return (content ?? []) - .map((item) => { - if (item.type === "text") return item.text.trim() - const source = item.uri - return `[file ${item.name ?? source}]` - }) - .filter(Boolean) - .join("\n") -} - -function toolInputRecord(input: string | Record) { - if (typeof input === "string") return {} - return input -} - -function pendingInput(part: SessionMessageAssistantTool) { - if (part.state.status !== "pending") return "" - return part.state.input.trim() -} - -function toolComplete(part: SessionMessageAssistantTool) { - if (part.state.status === "pending") return pendingInput(part) - return part.state.status === "completed" || part.state.status === "error" || part.state.status === "running" -} - -function stringValue(value: unknown) { - return typeof value === "string" ? value : undefined -} - -function numberValue(value: unknown) { - return typeof value === "number" ? value : undefined -} - -function arrayValue(value: unknown): unknown[] { - return Array.isArray(value) ? value : [] -} - -function isRecord(value: unknown): value is Record { - return !!value && typeof value === "object" && !Array.isArray(value) -} - -function input(input: Record, omit?: string[]) { - const primitives = Object.entries(input).filter(([key, value]) => { - if (omit?.includes(key)) return false - return typeof value === "string" || typeof value === "number" || typeof value === "boolean" - }) - if (primitives.length === 0) return "" - return `[${primitives.map(([key, value]) => `${key}=${value}`).join(", ")}]` -} - -function usePathNormalizer() { - const cwd = useTuiPaths().cwd - return (input?: string) => normalizePath(input, cwd) -} - -function normalizePath(input: string | undefined, cwd: string) { - if (!input) return "" - const absolute = path.isAbsolute(input) ? input : path.resolve(cwd, input) - const relative = path.relative(cwd, absolute) - if (!relative) return "." - if (!relative.startsWith("..")) return relative - return absolute -} - -function filetype(input?: string) { - if (!input) return "none" - const language = LANGUAGE_EXTENSIONS[path.extname(input)] - if (["typescriptreact", "javascriptreact", "javascript"].includes(language)) return "typescript" - return language -} - -function todoIcon(status?: string) { - if (status === "completed") return "✓" - if (status === "in_progress") return "~" - if (status === "cancelled") return "✕" - return "☐" -} - -function formatAnswer(answer: unknown) { - if (!Array.isArray(answer)) return "(no answer)" - if (answer.length === 0) return "(no answer)" - return answer.filter((item): item is string => typeof item === "string").join(", ") -} - -const tui: TuiPlugin = async (api) => { - api.route.register([ - { - name: route, - render(input) { - const sessionID = input.params?.sessionID - if (typeof sessionID !== "string") { - return Missing sessionID - } - return - }, - }, - ]) - - api.keymap.registerLayer({ - commands: [ - { - name: route, - title: "View v2 session messages", - category: "Debug", - namespace: "palette", - suggested: () => api.route.current.name === "session", - enabled: () => api.route.current.name === "session", - run() { - const sessionID = currentSessionID(api) - if (!sessionID) return - api.route.navigate(route, { sessionID }) - api.ui.dialog.clear() - }, - }, - ], - }) -} - -const plugin: BuiltinTuiPlugin = { - id, - tui, -} - -export default plugin diff --git a/packages/tui/src/routes/home/session-destination.tsx b/packages/tui/src/routes/home/session-destination.tsx index 352010840..1e4c65032 100644 --- a/packages/tui/src/routes/home/session-destination.tsx +++ b/packages/tui/src/routes/home/session-destination.tsx @@ -7,8 +7,7 @@ import { type ParentProps, type Setter, } from "solid-js" -import { useSync } from "../../context/sync" -import { useTuiPaths } from "../../context/runtime" +import { useData } from "../../context/data" export type HomeSessionDestination = { type: "directory"; directory: string; subdirectory: boolean } | { type: "new" } @@ -21,11 +20,10 @@ type Context = { const HomeSessionDestinationContext = createContext() export function HomeSessionDestinationProvider(props: ParentProps) { - const sync = useSync() - const paths = useTuiPaths() + const data = useData() const [selected, setDestination] = createSignal() const destination = createMemo( - () => selected() ?? { type: "directory", directory: sync.path.directory || paths.cwd, subdirectory: false }, + () => selected() ?? { type: "directory", directory: data.location.default().directory, subdirectory: false }, ) return ( boolean, timeout = 2000) { + const start = Date.now() + while (!fn()) { + if (Date.now() - start > timeout) throw new Error("timed out waiting for condition") + await Bun.sleep(10) + } +} + +function global(payload: Event): GlobalEvent { + return { directory, project: "proj_test", payload } +} + +function emitEvent(events: ReturnType, payload: Event) { + events.emit(global(payload)) +} + +test("refreshes resources into reactive getters", async () => { + const location = { + directory, + project: { id: "proj_test", directory }, + } + const calls = createFetch((url) => { + if (url.pathname === "/api/session/ses_test") + return json({ + data: { + id: "ses_test", + projectID: "proj_test", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 0, updated: 0 }, + title: "Test session", + location: { directory }, + }, + }) + if (url.pathname === "/api/agent") + return json({ + location, + data: [ + { id: "build", request: { headers: {}, body: {} }, mode: "primary", hidden: false, permissions: [] }, + ], + }) + return undefined + }) + const events = createEventSource() + let data!: ReturnType + let ready!: () => void + const mounted = new Promise((resolve) => { + ready = resolve + }) + + function Probe() { + data = useData() + onMount(ready) + return + } + + const app = await testRender(() => ( + + + + + + + + + + )) + + try { + await mounted + expect(data.location.default()).toEqual({ directory }) + expect(data.session.get("ses_test")).toBeUndefined() + expect(data.location.agent.list(location)).toBeUndefined() + + await data.session.refresh("ses_test") + await data.location.agent.refresh() + + expect(data.session.get("ses_test")?.title).toBe("Test session") + expect(data.location.default()).toEqual({ directory, workspaceID: undefined }) + expect(data.location.agent.list(location)?.map((agent) => agent.id)).toEqual(["build"]) + } finally { + app.renderer.destroy() + } +}) + +test("refreshes references after updates", async () => { + const events = createEventSource() + let requests = 0 + const calls = createFetch((url) => { + if (url.pathname !== "/api/reference") return + requests++ + return json({ + location: { directory, project: { id: "proj_test", directory } }, + data: requests === 1 ? [] : [{ name: "docs", path: "/docs", source: { type: "local", path: "/docs" } }], + }) + }) + let data!: ReturnType + let ready!: () => void + const mounted = new Promise((resolve) => { + ready = resolve + }) + + function Probe() { + data = useData() + onMount(ready) + return + } + + const app = await testRender(() => ( + + + + + + + + + + )) + + try { + await mounted + await wait(() => requests === 1) + emitEvent(events, { id: "evt_reference_1", type: "reference.updated", properties: {} }) + await wait(() => data.location.reference.list()?.length === 1) + expect(data.location.reference.list()?.[0]?.name).toBe("docs") + } finally { + app.renderer.destroy() + } +}) + +test("settles pending tools when a live failure arrives", async () => { + const events = createEventSource() + const calls = createFetch() + let sync!: ReturnType + let ready!: () => void + const mounted = new Promise((resolve) => { + ready = resolve + }) + + function Probe() { + sync = useData() + onMount(ready) + return + } + + const app = await testRender(() => ( + + + + + + + + + + )) + + try { + await mounted + emitEvent(events, { + id: "evt_agent_1", + type: "session.next.agent.switched", + properties: { sessionID: "session-1", messageID: "msg_agent_1", timestamp: 0, agent: "build" }, + }) + emitEvent(events, { + id: "evt_model_1", + type: "session.next.model.switched", + properties: { + sessionID: "session-1", + messageID: "msg_model_1", + timestamp: 0, + model: { id: "model-1", providerID: "provider-1" }, + }, + }) + emitEvent(events, { + id: "evt_step_started_1", + type: "session.next.step.started", + properties: { + sessionID: "session-1", + assistantMessageID: "msg_explicit_assistant_9", + timestamp: 1, + agent: "build", + model: { id: "model-1", providerID: "provider-1" }, + }, + }) + emitEvent(events, { + id: "evt_input_1", + type: "session.next.tool.input.started", + properties: { + sessionID: "session-1", + assistantMessageID: "msg_explicit_assistant_9", + timestamp: 2, + callID: "call-1", + name: "bash", + }, + }) + emitEvent(events, { + id: "evt_called_1", + type: "session.next.tool.called", + properties: { + sessionID: "session-1", + timestamp: 2, + assistantMessageID: "msg_explicit_assistant_9", + callID: "call-1", + tool: "bash", + input: {}, + provider: { executed: false, metadata: { fake: { call: true } } }, + }, + }) + emitEvent(events, { + id: "evt_failed_1", + type: "session.next.tool.failed", + properties: { + sessionID: "session-1", + timestamp: 3, + assistantMessageID: "msg_explicit_assistant_9", + callID: "call-1", + error: { type: "unknown", message: "aborted" }, + provider: { executed: false, metadata: { fake: { result: true } } }, + }, + }) + + await wait(() => { + const assistant = sync.session.message.list("session-1")?.[0] + return ( + assistant?.type === "assistant" && + assistant.content[0]?.type === "tool" && + assistant.content[0].state.status === "error" + ) + }) + + const assistant = sync.session.message.list("session-1")?.[0] + expect(assistant?.type).toBe("assistant") + if (assistant?.type !== "assistant") return + expect(assistant.id).toBe("msg_explicit_assistant_9") + const tool = assistant.content[0] + expect(tool?.type).toBe("tool") + if (tool?.type !== "tool") return + expect(tool.state.status).toBe("error") + if (tool.state.status !== "error") return + expect(tool.state.error).toEqual({ type: "unknown", message: "aborted" }) + expect(tool.state.input).toEqual({}) + expect(tool.state.structured).toEqual({}) + expect(tool.state.content).toEqual([]) + expect(tool.provider).toEqual({ + executed: false, + metadata: { fake: { call: true } }, + resultMetadata: { fake: { result: true } }, + }) + expect((sync.session.message.list("session-1") ?? []).map((message) => message.type)).toEqual([ + "assistant", + "model-switched", + "agent-switched", + ]) + } finally { + app.renderer.destroy() + } +}) + +test("renders admitted prompts only after promotion", async () => { + const events = createEventSource() + const calls = createFetch() + let sync!: ReturnType + let ready!: () => void + const mounted = new Promise((resolve) => { + ready = resolve + }) + + function Probe() { + sync = useData() + onMount(ready) + return + } + + const app = await testRender(() => ( + + + + + + + + + + )) + + try { + await mounted + emitEvent(events, { + id: "evt_admitted_1", + type: "session.next.prompt.admitted", + properties: { + sessionID: "session-1", + messageID: "msg_user_1", + timestamp: 0, + prompt: { text: "hello" }, + delivery: "steer", + }, + }) + expect(sync.session.message.list("session-1") ?? []).toEqual([]) + + emitEvent(events, { + id: "evt_promoted_1", + type: "session.next.prompt.promoted", + properties: { + sessionID: "session-1", + messageID: "msg_user_1", + timestamp: 1, + prompt: { text: "hello" }, + timeCreated: 0, + }, + }) + + await wait(() => sync.session.message.list("session-1")?.length === 1) + const message = sync.session.message.list("session-1")?.[0] + expect(message?.type).toBe("user") + if (message?.type !== "user") return + expect(message).toMatchObject({ id: "msg_user_1", text: "hello" }) + } finally { + app.renderer.destroy() + } +}) + +test("renders a promoted prompt when admission was missed", async () => { + const events = createEventSource() + const calls = createFetch() + let sync!: ReturnType + let ready!: () => void + const mounted = new Promise((resolve) => { + ready = resolve + }) + + function Probe() { + sync = useData() + onMount(ready) + return + } + + const app = await testRender(() => ( + + + + + + + + + + )) + + try { + await mounted + emitEvent(events, { + id: "evt_promoted_1", + type: "session.next.prompt.promoted", + properties: { + sessionID: "session-1", + messageID: "msg_user_1", + timestamp: 1, + prompt: { text: "hello" }, + timeCreated: 0, + }, + }) + + await wait(() => sync.session.message.list("session-1")?.length === 1) + expect(sync.session.message.list("session-1")?.[0]?.id).toBe("msg_user_1") + } finally { + app.renderer.destroy() + } +}) + +test("projects live context updates with their message ID", async () => { + const events = createEventSource() + const calls = createFetch() + let sync!: ReturnType + let ready!: () => void + const mounted = new Promise((resolve) => { + ready = resolve + }) + + function Probe() { + sync = useData() + onMount(ready) + return + } + + const app = await testRender(() => ( + + + + + + + + + + )) + + try { + await mounted + emitEvent(events, { + id: "evt_context_1", + type: "session.next.context.updated", + properties: { + sessionID: "session-1", + messageID: "msg_context_1", + timestamp: 1, + text: "Updated context", + }, + }) + + await wait(() => sync.session.message.list("session-1")?.length === 1) + expect(sync.session.message.list("session-1")?.[0]).toMatchObject({ + id: "msg_context_1", + type: "system", + text: "Updated context", + }) + } finally { + app.renderer.destroy() + } +}) diff --git a/packages/tui/test/cli/tui/sync-v2.test.tsx b/packages/tui/test/cli/tui/sync-v2.test.tsx deleted file mode 100644 index 7d6ba5f83..000000000 --- a/packages/tui/test/cli/tui/sync-v2.test.tsx +++ /dev/null @@ -1,619 +0,0 @@ -/** @jsxImportSource @opentui/solid */ -import { expect, test } from "bun:test" -import { testRender } from "@opentui/solid" -import type { Event, GlobalEvent } from "@opencode-ai/sdk/v2" -import { onMount } from "solid-js" -import { ProjectProvider } from "../../../src/context/project" -import { SDKProvider } from "../../../src/context/sdk" -import { SyncProviderV2, useSyncV2 } from "../../../src/context/sync-v2" -import { createEventSource, createFetch, directory, json } from "../../fixture/tui-sdk" -import { TestTuiContexts } from "../../fixture/tui-environment" - -async function wait(fn: () => boolean, timeout = 2000) { - const start = Date.now() - while (!fn()) { - if (Date.now() - start > timeout) throw new Error("timed out waiting for condition") - await Bun.sleep(10) - } -} - -function global(payload: Event): GlobalEvent { - return { directory, project: "proj_test", payload } -} - -function emitTwice(events: ReturnType, payload: Event) { - const event = global(payload) - events.emit(event) - events.emit(event) -} - -test("sync v2 refreshes references after updates", async () => { - const events = createEventSource() - let requests = 0 - const calls = createFetch((url) => { - if (url.pathname !== "/api/reference") return - requests++ - return json({ - location: { directory, project: { id: "proj_test", directory } }, - data: requests === 1 ? [] : [{ name: "docs", path: "/docs", source: { type: "local", path: "/docs" } }], - }) - }) - let sync!: ReturnType - let ready!: () => void - const mounted = new Promise((resolve) => { - ready = resolve - }) - - function Probe() { - sync = useSyncV2() - onMount(ready) - return - } - - const app = await testRender(() => ( - - - - - - - - - - )) - - try { - await mounted - await wait(() => requests === 1) - events.emit(global({ id: "evt_reference_1", type: "reference.updated", properties: {} })) - await wait(() => sync.data.reference.length === 1) - expect(sync.data.reference[0]?.name).toBe("docs") - } finally { - app.renderer.destroy() - } -}) - -test("sync v2 settles pending tools when a live failure arrives", async () => { - const events = createEventSource() - const calls = createFetch() - let sync!: ReturnType - let ready!: () => void - const mounted = new Promise((resolve) => { - ready = resolve - }) - - function Probe() { - sync = useSyncV2() - onMount(ready) - return - } - - const app = await testRender(() => ( - - - - - - - - - - )) - - try { - await mounted - emitTwice(events, { - id: "evt_agent_1", - type: "session.next.agent.switched", - properties: { sessionID: "session-1", messageID: "msg_agent_1", timestamp: 0, agent: "build" }, - }) - emitTwice(events, { - id: "evt_model_1", - type: "session.next.model.switched", - properties: { - sessionID: "session-1", - messageID: "msg_model_1", - timestamp: 0, - model: { id: "model-1", providerID: "provider-1" }, - }, - }) - emitTwice(events, { - id: "evt_step_started_1", - type: "session.next.step.started", - properties: { - sessionID: "session-1", - assistantMessageID: "msg_explicit_assistant_9", - timestamp: 1, - agent: "build", - model: { id: "model-1", providerID: "provider-1" }, - }, - }) - emitTwice(events, { - id: "evt_input_1", - type: "session.next.tool.input.started", - properties: { - sessionID: "session-1", - assistantMessageID: "msg_explicit_assistant_9", - timestamp: 2, - callID: "call-1", - name: "bash", - }, - }) - emitTwice(events, { - id: "evt_called_1", - type: "session.next.tool.called", - properties: { - sessionID: "session-1", - timestamp: 2, - assistantMessageID: "msg_explicit_assistant_9", - callID: "call-1", - tool: "bash", - input: {}, - provider: { executed: false, metadata: { fake: { call: true } } }, - }, - }) - emitTwice(events, { - id: "evt_failed_1", - type: "session.next.tool.failed", - properties: { - sessionID: "session-1", - timestamp: 3, - assistantMessageID: "msg_explicit_assistant_9", - callID: "call-1", - error: { type: "unknown", message: "aborted" }, - provider: { executed: false, metadata: { fake: { result: true } } }, - }, - }) - - await wait(() => { - const assistant = sync.session.message.fromSession("session-1")[0] - return ( - assistant?.type === "assistant" && - assistant.content[0]?.type === "tool" && - assistant.content[0].state.status === "error" - ) - }) - - const assistant = sync.session.message.fromSession("session-1")[0] - expect(assistant?.type).toBe("assistant") - if (assistant?.type !== "assistant") return - expect(assistant.id).toBe("msg_explicit_assistant_9") - const tool = assistant.content[0] - expect(tool?.type).toBe("tool") - if (tool?.type !== "tool") return - expect(tool.state.status).toBe("error") - if (tool.state.status !== "error") return - expect(tool.state.error).toEqual({ type: "unknown", message: "aborted" }) - expect(tool.state.input).toEqual({}) - expect(tool.state.structured).toEqual({}) - expect(tool.state.content).toEqual([]) - expect(tool.provider).toEqual({ - executed: false, - metadata: { fake: { call: true } }, - resultMetadata: { fake: { result: true } }, - }) - expect(sync.session.message.fromSession("session-1").map((message) => message.type)).toEqual([ - "assistant", - "model-switched", - "agent-switched", - ]) - } finally { - app.renderer.destroy() - } -}) - -test("sync v2 renders admitted prompts only after promotion", async () => { - const events = createEventSource() - const calls = createFetch() - let sync!: ReturnType - let ready!: () => void - const mounted = new Promise((resolve) => { - ready = resolve - }) - - function Probe() { - sync = useSyncV2() - onMount(ready) - return - } - - const app = await testRender(() => ( - - - - - - - - - - )) - - try { - await mounted - emitTwice(events, { - id: "evt_admitted_1", - type: "session.next.prompt.admitted", - properties: { - sessionID: "session-1", - messageID: "msg_user_1", - timestamp: 0, - prompt: { text: "hello" }, - delivery: "steer", - }, - }) - expect(sync.session.message.fromSession("session-1")).toEqual([]) - - emitTwice(events, { - id: "evt_promoted_1", - type: "session.next.prompt.promoted", - properties: { - sessionID: "session-1", - messageID: "msg_user_1", - timestamp: 1, - prompt: { text: "hello" }, - timeCreated: 0, - }, - }) - - await wait(() => sync.session.message.fromSession("session-1").length === 1) - const message = sync.session.message.fromSession("session-1")[0] - expect(message?.type).toBe("user") - if (message?.type !== "user") return - expect(message).toMatchObject({ id: "msg_user_1", text: "hello" }) - } finally { - app.renderer.destroy() - } -}) - -test("sync v2 renders a promoted prompt when admission was missed", async () => { - const events = createEventSource() - const calls = createFetch() - let sync!: ReturnType - let ready!: () => void - const mounted = new Promise((resolve) => { - ready = resolve - }) - - function Probe() { - sync = useSyncV2() - onMount(ready) - return - } - - const app = await testRender(() => ( - - - - - - - - - - )) - - try { - await mounted - emitTwice(events, { - id: "evt_promoted_1", - type: "session.next.prompt.promoted", - properties: { - sessionID: "session-1", - messageID: "msg_user_1", - timestamp: 1, - prompt: { text: "hello" }, - timeCreated: 0, - }, - }) - - await wait(() => sync.session.message.fromSession("session-1").length === 1) - expect(sync.session.message.fromSession("session-1")[0]?.id).toBe("msg_user_1") - } finally { - app.renderer.destroy() - } -}) - -test("sync v2 projects live context updates with their message ID", async () => { - const events = createEventSource() - const calls = createFetch() - let sync!: ReturnType - let ready!: () => void - const mounted = new Promise((resolve) => { - ready = resolve - }) - - function Probe() { - sync = useSyncV2() - onMount(ready) - return - } - - const app = await testRender(() => ( - - - - - - - - - - )) - - try { - await mounted - emitTwice(events, { - id: "evt_context_1", - type: "session.next.context.updated", - properties: { - sessionID: "session-1", - messageID: "msg_context_1", - timestamp: 1, - text: "Updated context", - }, - }) - - await wait(() => sync.session.message.fromSession("session-1").length === 1) - expect(sync.session.message.fromSession("session-1")[0]).toMatchObject({ - id: "msg_context_1", - type: "system", - text: "Updated context", - }) - } finally { - app.renderer.destroy() - } -}) - -test("sync v2 preserves live events while snapshot hydration is in flight", async () => { - const events = createEventSource() - const response = Promise.withResolvers() - const calls = createFetch((url) => { - if (url.pathname === "/api/session/session-1/message") return response.promise - return undefined - }) - let sync!: ReturnType - let ready!: () => void - const mounted = new Promise((resolve) => { - ready = resolve - }) - - function Probe() { - sync = useSyncV2() - onMount(ready) - return - } - - const app = await testRender(() => ( - - - - - - - - - - )) - - try { - await mounted - const hydration = sync.session.message.sync("session-1") - emitTwice(events, { - id: "evt_agent_1", - type: "session.next.agent.switched", - properties: { sessionID: "session-1", messageID: "msg_agent_1", timestamp: 0, agent: "build" }, - }) - response.resolve(json({ data: [] })) - await hydration - - expect(sync.session.message.fromSession("session-1").map((message) => [message.id, message.type])).toEqual([ - ["msg_agent_1", "agent-switched"], - ]) - } finally { - app.renderer.destroy() - } -}) - -test("sync v2 replaces stale cached rows while preserving in-flight live rows", async () => { - const events = createEventSource() - const response = Promise.withResolvers() - const calls = createFetch((url) => { - if (url.pathname === "/api/session/session-1/message") return response.promise - return undefined - }) - let sync!: ReturnType - let ready!: () => void - const mounted = new Promise((resolve) => { - ready = resolve - }) - - function Probe() { - sync = useSyncV2() - onMount(ready) - return - } - - const app = await testRender(() => ( - - - - - - - - - - )) - - try { - await mounted - emitTwice(events, { - id: "evt_promoted_1", - type: "session.next.prompt.promoted", - properties: { - sessionID: "session-1", - messageID: "msg_user_1", - timestamp: 1, - prompt: { text: "stale" }, - timeCreated: 0, - }, - }) - await wait(() => sync.session.message.fromSession("session-1")[0]?.id === "msg_user_1") - const hydration = sync.session.message.sync("session-1") - emitTwice(events, { - id: "evt_agent_1", - type: "session.next.agent.switched", - properties: { sessionID: "session-1", messageID: "msg_agent_1", timestamp: 2, agent: "build" }, - }) - await wait(() => sync.session.message.fromSession("session-1")[0]?.id === "msg_agent_1") - response.resolve( - json({ - data: [{ id: "msg_user_1", type: "user", text: "fresh", time: { created: 0 } }], - }), - ) - await hydration - - expect(sync.session.message.fromSession("session-1").map((message) => [message.id, message.type])).toEqual([ - ["msg_agent_1", "agent-switched"], - ["msg_user_1", "user"], - ]) - expect(sync.session.message.fromSession("session-1")[1]).toMatchObject({ text: "fresh" }) - } finally { - app.renderer.destroy() - } -}) - -test("sync v2 preserves snapshot order and metadata for in-flight updates", async () => { - const events = createEventSource() - const response = Promise.withResolvers() - const calls = createFetch((url) => { - if (url.pathname === "/api/session/session-1/message") return response.promise - return undefined - }) - let sync!: ReturnType - let ready!: () => void - const mounted = new Promise((resolve) => { - ready = resolve - }) - - function Probe() { - sync = useSyncV2() - onMount(ready) - return - } - - const app = await testRender(() => ( - - - - - - - - - - )) - - try { - await mounted - emitTwice(events, { - id: "evt_step_older", - type: "session.next.step.started", - properties: { - sessionID: "session-1", - assistantMessageID: "msg_assistant_older", - timestamp: 0, - agent: "build", - model: { id: "model", providerID: "provider" }, - }, - }) - emitTwice(events, { - id: "evt_step_1", - type: "session.next.step.started", - properties: { - sessionID: "session-1", - assistantMessageID: "msg_assistant_old", - timestamp: 1, - agent: "build", - model: { id: "model", providerID: "provider" }, - }, - }) - await wait(() => sync.session.message.fromSession("session-1")[0]?.id === "msg_assistant_old") - const hydration = sync.session.message.sync("session-1") - emitTwice(events, { - id: "evt_text_1", - type: "session.next.text.started", - properties: { - sessionID: "session-1", - assistantMessageID: "msg_assistant_old", - timestamp: 2, - textID: "text-1", - }, - }) - emitTwice(events, { - id: "evt_text_older", - type: "session.next.text.started", - properties: { - sessionID: "session-1", - assistantMessageID: "msg_assistant_older", - timestamp: 2, - textID: "text-older", - }, - }) - await wait(() => { - const messages = sync.session.message.fromSession("session-1") - return messages.every((message) => message.type !== "assistant" || message.content[0]?.type === "text") - }) - response.resolve( - json({ - data: [ - { - id: "msg_assistant_new", - type: "assistant", - agent: "build", - model: { id: "model", providerID: "provider" }, - content: [], - time: { created: 3 }, - }, - { - id: "msg_assistant_old", - type: "assistant", - metadata: { source: "snapshot" }, - agent: "build", - model: { id: "model", providerID: "provider" }, - content: [], - time: { created: 1 }, - }, - ], - }), - ) - await hydration - emitTwice(events, { - id: "evt_step_late_duplicate", - type: "session.next.step.started", - properties: { - sessionID: "session-1", - assistantMessageID: "msg_assistant_old", - timestamp: 1, - agent: "build", - model: { id: "model", providerID: "provider" }, - }, - }) - - expect(sync.session.message.fromSession("session-1").map((message) => message.id)).toEqual([ - "msg_assistant_new", - "msg_assistant_old", - "msg_assistant_older", - ]) - expect(JSON.parse(JSON.stringify(sync.session.message.fromSession("session-1")[1]))).toMatchObject({ - metadata: { source: "snapshot" }, - content: [{ type: "text", id: "text-1", text: "" }], - }) - expect(JSON.parse(JSON.stringify(sync.session.message.fromSession("session-1")[2]))).toMatchObject({ - content: [{ type: "text", id: "text-older", text: "" }], - }) - } finally { - app.renderer.destroy() - } -}) diff --git a/packages/tui/test/fixture/tui-sdk.ts b/packages/tui/test/fixture/tui-sdk.ts index d18228fca..9866b9132 100644 --- a/packages/tui/test/fixture/tui-sdk.ts +++ b/packages/tui/test/fixture/tui-sdk.ts @@ -59,6 +59,13 @@ export function createFetch(override?: FetchHandler) { if (url.pathname === "/config/providers") return json({ providers: {}, default: {} }) if (url.pathname === "/experimental/console") return json({ consoleManagedProviders: [], switchableOrgCount: 0 }) if (url.pathname === "/path") return json({ home: "", state: "", config: "", worktree, directory }) + if (url.pathname === "/api/location") + return json({ directory, project: { id: "proj_test", directory: worktree } }) + if (["/api/agent", "/api/model", "/api/provider", "/api/command", "/api/skill"].includes(url.pathname)) + return json({ + location: { directory, project: { id: "proj_test", directory: worktree } }, + data: [], + }) if (url.pathname === "/project/current") return json({ id: "proj_test" }) if (url.pathname === "/api/reference") return json({ location: { directory, project: { id: "proj_test", directory } }, data: [] })