refactor(tui): use v2 client transport

This commit is contained in:
Dax Raad 2026-06-26 14:57:01 -04:00
commit 8b682c42b6
26 changed files with 428 additions and 1507 deletions

View file

@ -76,7 +76,7 @@ import {
useOpencodeKeymap,
} from "./keymap"
import type { EventSource } from "./context/sdk"
import type { OpencodeClient } from "@opencode-ai/sdk/v2"
import { DialogVariant } from "./component/dialog-variant"
import { createTuiAttention } from "./attention"
import * as TuiAudio from "./audio"
@ -134,14 +134,10 @@ const appBindingCommands = [
] as const
export type TuiInput = {
url: string
client: OpencodeClient
args: Args
config: TuiConfig.Resolved
onSnapshot?: () => Promise<string[]>
directory?: string
fetch?: typeof fetch
headers?: RequestInit["headers"]
events?: EventSource
pluginHost: TuiPluginHost
}
@ -293,13 +289,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
>
<TuiConfigProvider config={input.config}>
<PluginRuntimeProvider value={pluginRuntime}>
<SDKProvider
url={input.url}
directory={input.directory}
fetch={input.fetch}
headers={input.headers}
events={input.events}
>
<SDKProvider client={input.client}>
<ProjectProvider>
<SyncProvider>
<DataProvider>
@ -394,6 +384,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
event,
sdk,
sync,
data,
theme: themeState,
toast,
renderer,

View file

@ -75,7 +75,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
async (projectID, info): Promise<ProjectDirectory[] | undefined> => {
try {
await sdk.client.v2.projectCopy.refresh(
{ projectID, location: { directory: sdk.directory } },
{ projectID, location: { directory: projectContext.instance.directory() || paths.cwd } },
{ throwOnError: true },
)
const directories = await sdk.client.project.directories({ projectID }, { throwOnError: true })
@ -224,7 +224,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
const result = await sdk.client.v2.projectCopy
.remove({
projectID: props.projectID,
location: { directory: sdk.directory },
location: { directory: projectContext.instance.directory() || paths.cwd },
directory: selected.directory,
force: false,
})
@ -246,7 +246,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
const forced = await sdk.client.v2.projectCopy
.remove({
projectID: props.projectID,
location: { directory: sdk.directory },
location: { directory: projectContext.instance.directory() || paths.cwd },
directory: selected.directory,
force: true,
})

View file

@ -13,6 +13,7 @@ import { useLocal } from "../context/local"
import { createDebouncedSignal } from "../util/signal"
import { useToast } from "../ui/toast"
import { useCommandShortcut } from "../keymap"
import { Spinner } from "./spinner"
export function DialogSessionList() {
const dialog = useDialog()
@ -71,7 +72,12 @@ export function DialogSessionList() {
value: session.id,
category,
footer,
gutter: slot === undefined ? undefined : () => <text fg={theme.accent}>{slot}</text>,
gutter:
data.session.status(session.id) === "running"
? () => <Spinner />
: slot === undefined
? undefined
: () => <text fg={theme.accent}>{slot}</text>,
}
}

View file

@ -54,7 +54,7 @@ async function loadWorkspaceAdapters(input: {
sync: ReturnType<typeof useSync>
toast: ReturnType<typeof useToast>
}) {
const dir = input.sync.path.directory || input.sdk.directory
const dir = input.sync.path.directory || process.cwd()
try {
const response = await input.sdk.client.experimental.workspace.adapter.list({ directory: dir })
if (response.error) throw response.error

View file

@ -40,12 +40,10 @@ import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import type { AssistantMessage, FilePart, UserMessage } from "@opencode-ai/sdk/v2"
import { Locale } from "../../util/locale"
import { errorMessage } from "../../util/error"
import { formatDuration } from "../../util/format"
import { createColors, createFrames } from "../../ui/spinner"
import { useDialog } from "../../ui/dialog"
import { DialogIntegration } from "../dialog-integration"
import { useConnected } from "../use-connected"
import { DialogAlert } from "../../ui/dialog-alert"
import { useToast } from "../../ui/toast"
import { useKV } from "../../context/kv"
import { createFadeIn } from "../../util/signal"
@ -155,10 +153,11 @@ export function Prompt(props: PromptProps) {
const route = useRoute()
const project = useProject()
const sync = useSync()
const data = useData()
const tuiConfig = useTuiConfig()
const dialog = useDialog()
const toast = useToast()
const status = createMemo(() => sync.data.session_status?.[props.sessionID ?? ""] ?? { type: "idle" })
const status = createMemo(() => data.session.status(props.sessionID ?? ""))
const history = usePromptHistory()
const stash = usePromptStash()
const keymap = useOpencodeKeymap()
@ -207,7 +206,6 @@ export function Prompt(props: PromptProps) {
const [auto, setAuto] = createSignal<AutocompleteRef>()
const workspace = usePromptWorkspace(props.sessionID)
const move = usePromptMove({ projectID: project.project, sessionID: () => props.sessionID })
const data = useData()
const [cursorVersion, setCursorVersion] = createSignal(0)
const currentProviderLabel = createMemo(() => local.model.parsed().provider)
const connected = useConnected()
@ -385,7 +383,7 @@ export function Prompt(props: PromptProps) {
name: "session.interrupt",
category: "Session",
hidden: true,
enabled: status().type !== "idle",
enabled: status() === "running",
run: () => {
if (auto()?.visible) return
if (!input.focused) return
@ -1343,7 +1341,7 @@ export function Prompt(props: PromptProps) {
const spinnerDef = createMemo(() => {
const agent =
status().type !== "idle"
status() === "running"
? (local.agent.list().find((agent) => agent.id === lastUserMessage()?.agent) ?? local.agent.current())
: local.agent.current()
const color = agent ? local.agent.color(agent.id) : theme.border
@ -1529,77 +1527,12 @@ export function Prompt(props: PromptProps) {
</box>
<box width="100%" flexDirection="row" justifyContent="space-between">
<Switch>
<Match when={status().type !== "idle"}>
<box
flexDirection="row"
gap={1}
flexGrow={1}
justifyContent={status().type === "retry" ? "space-between" : "flex-start"}
>
<box flexShrink={0} flexDirection="row" gap={1}>
<box marginLeft={1}>
<Show when={kv.get("animations_enabled", true)} fallback={<text fg={theme.textMuted}>[]</text>}>
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
</Show>
</box>
<box flexDirection="row" gap={1} flexShrink={0}>
{(() => {
const retry = createMemo(() => {
const s = status()
if (s.type !== "retry") return
return s
})
const message = createMemo(() => {
const r = retry()
if (!r) return
if (r.message.includes("exceeded your current quota") && r.message.includes("gemini"))
return "gemini is way too hot right now"
if (r.message.length > 80) return r.message.slice(0, 80) + "..."
return r.message
})
const isTruncated = createMemo(() => {
const r = retry()
if (!r) return false
return r.message.length > 120
})
const [seconds, setSeconds] = createSignal(0)
onMount(() => {
const timer = setInterval(() => {
const next = retry()?.next
if (next) setSeconds(Math.round((next - Date.now()) / 1000))
}, 1000)
onCleanup(() => {
clearInterval(timer)
})
})
const handleMessageClick = () => {
const r = retry()
if (!r) return
if (isTruncated()) {
void DialogAlert.show(dialog, "Retry Error", r.message)
}
}
const retryText = () => {
const r = retry()
if (!r) return ""
const baseMessage = message()
const truncatedHint = isTruncated() ? " (click to expand)" : ""
const duration = formatDuration(seconds())
const retryInfo = ` [retrying ${duration ? `in ${duration} ` : ""}attempt #${r.attempt}]`
return baseMessage + truncatedHint + retryInfo
}
return (
<Show when={retry()}>
<box onMouseUp={handleMessageClick}>
<text fg={theme.error}>{retryText()}</text>
</box>
</Show>
)
})()}
</box>
<Match when={status() === "running"}>
<box flexDirection="row" gap={1} flexGrow={1} justifyContent="flex-start">
<box marginLeft={1}>
<Show when={kv.get("animations_enabled", true)} fallback={<text fg={theme.textMuted}>[]</text>}>
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
</Show>
</box>
<text fg={store.interrupt > 0 ? theme.primary : theme.text}>
esc{" "}
@ -1661,41 +1594,39 @@ export function Prompt(props: PromptProps) {
</Match>
<Match when={true}>{props.hint ?? <text />}</Match>
</Switch>
<Show when={status().type !== "retry"}>
<box gap={2} flexDirection="row">
<Show when={editorContextLabelState() !== "none" ? editorFileLabelDisplay() : undefined}>
{(file) => (
<text fg={editorContextLabelState() === "pending" ? theme.secondary : theme.textMuted}>{file()}</text>
)}
</Show>
<Switch>
<Match when={store.mode === "normal"}>
<Switch>
<Match when={usage()}>
{(item) => (
<text fg={theme.textMuted} wrapMode="none">
{[item().context, item().cost].filter(Boolean).join(" · ")}
</text>
)}
</Match>
<Match when={true}>
<text fg={theme.text}>
{agentShortcut()} <span style={{ fg: theme.textMuted }}>agents</span>
<box gap={2} flexDirection="row">
<Show when={editorContextLabelState() !== "none" ? editorFileLabelDisplay() : undefined}>
{(file) => (
<text fg={editorContextLabelState() === "pending" ? theme.secondary : theme.textMuted}>{file()}</text>
)}
</Show>
<Switch>
<Match when={store.mode === "normal"}>
<Switch>
<Match when={usage()}>
{(item) => (
<text fg={theme.textMuted} wrapMode="none">
{[item().context, item().cost].filter(Boolean).join(" · ")}
</text>
</Match>
</Switch>
<text fg={theme.text}>
{paletteShortcut()} <span style={{ fg: theme.textMuted }}>commands</span>
</text>
</Match>
<Match when={store.mode === "shell"}>
<text fg={theme.text}>
esc <span style={{ fg: theme.textMuted }}>exit shell mode</span>
</text>
</Match>
</Switch>
</box>
</Show>
)}
</Match>
<Match when={true}>
<text fg={theme.text}>
{agentShortcut()} <span style={{ fg: theme.textMuted }}>agents</span>
</text>
</Match>
</Switch>
<text fg={theme.text}>
{paletteShortcut()} <span style={{ fg: theme.textMuted }}>commands</span>
</text>
</Match>
<Match when={store.mode === "shell"}>
<text fg={theme.text}>
esc <span style={{ fg: theme.textMuted }}>exit shell mode</span>
</text>
</Match>
</Switch>
</box>
</box>
</box>
<Autocomplete

View file

@ -40,7 +40,7 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
const result = await sdk.client.v2.projectCopy.create(
{
projectID,
location: { directory: sdk.directory },
location: { directory: project.instance.directory() || paths.cwd },
strategy: "git_worktree",
directory: path.join(paths.worktree, projectID.slice(0, 6)),
name: generated.data.name,

View file

@ -25,6 +25,7 @@ import { createSignal, onCleanup, onMount } from "solid-js"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
export type DataConnectionStatus = "connecting" | "connected" | "reconnecting"
export type DataSessionStatus = "idle" | "running"
export type DataEvent = V2Event
type DataEventMap = { [T in DataEvent["type"]]: Extract<DataEvent, { type: T }> }
@ -47,6 +48,7 @@ type Data = {
}
session: {
info: Record<string, SessionV2Info>
status: Record<string, DataSessionStatus>
message: Record<string, SessionMessage[]>
permission: Record<string, PermissionV2Request[]>
question: Record<string, QuestionV2Request[]>
@ -75,6 +77,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
},
session: {
info: {},
status: {},
message: {},
permission: {},
question: {},
@ -88,7 +91,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
const sdk = useSDK()
const events = createGlobalEmitter<DataEventMap>()
const [defaultLocation, setDefaultLocation] = createSignal<LocationRef>({
directory: sdk.directory ?? process.cwd(),
directory: process.cwd(),
})
const messageIndex = new Map<string, Map<string, number>>()
@ -179,6 +182,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
})
break
case "session.next.prompted": {
setStore("session", "status", event.data.sessionID, "running")
message.update(event.data.sessionID, (draft, index) => {
message.append(draft, index, {
id: event.data.messageID,
@ -215,6 +219,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
})
break
case "session.next.shell.started":
setStore("session", "status", event.data.sessionID, "running")
message.update(event.data.sessionID, (draft, index) => {
message.append(draft, index, {
id: event.data.messageID,
@ -227,6 +232,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
})
break
case "session.next.shell.ended":
setStore("session", "status", event.data.sessionID, "idle")
message.update(event.data.sessionID, (draft, index) => {
const match = message.activeShell(draft, event.data.callID)
if (!match) return
@ -235,6 +241,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
})
break
case "session.next.step.started":
setStore("session", "status", event.data.sessionID, "running")
message.update(event.data.sessionID, (draft, index) => {
if (index.has(event.data.assistantMessageID)) return
const currentAssistant = message.activeAssistant(draft)
@ -251,6 +258,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
})
break
case "session.next.step.ended":
setStore("session", "status", event.data.sessionID, event.data.finish === "tool-calls" ? "running" : "idle")
message.update(event.data.sessionID, (draft, index) => {
const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID)
if (!currentAssistant) return
@ -263,6 +271,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
})
break
case "session.next.step.failed":
setStore("session", "status", event.data.sessionID, "idle")
message.update(event.data.sessionID, (draft, index) => {
const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID)
if (!currentAssistant) return
@ -406,6 +415,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
break
case "session.next.retried":
case "session.next.compaction.started":
setStore("session", "status", event.data.sessionID, "running")
break
case "session.next.compaction.delta":
break
case "session.next.compaction.ended":
@ -490,6 +501,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
get(sessionID: string) {
return store.session.info[sessionID]
},
status(sessionID: string) {
return store.session.status[sessionID] ?? "idle"
},
async refresh(sessionID: string) {
const result = await sdk.client.v2.session.get({ sessionID }, { throwOnError: true })
setStore("session", "info", sessionID, result.data.data)
@ -653,6 +667,13 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
}),
),
),
sdk.client.v2.session.active({ throwOnError: true }).then((response) =>
setStore(
"session",
"status",
Object.fromEntries(Object.keys(response.data.data).map((sessionID) => [sessionID, "running" as const])),
),
),
result.location.refresh(),
result.location.agent.refresh(),
result.location.integration.refresh(),

View file

@ -16,7 +16,7 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex
state: "",
config: "",
worktree: "",
directory: sdk.directory ?? "",
directory: process.cwd(),
} satisfies Path
const [store, setStore] = createStore({

View file

@ -1,37 +1,12 @@
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
import type { GlobalEvent, OpencodeClient } from "@opencode-ai/sdk/v2"
import { Flag } from "@opencode-ai/core/flag/flag"
import { createSimpleContext } from "./helper"
import { batch, onCleanup, onMount } from "solid-js"
export type EventSource = {
subscribe: (handler: (event: GlobalEvent) => void) => Promise<() => void>
}
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
name: "SDK",
init: (props: {
url: string
directory?: string
fetch?: typeof fetch
headers?: RequestInit["headers"]
events?: EventSource
}) => {
init: (props: { client: OpencodeClient }) => {
const abort = new AbortController()
let sse: AbortController | undefined
function createSDK() {
return createOpencodeClient({
baseUrl: props.url,
signal: abort.signal,
directory: props.directory,
fetch: props.fetch,
headers: props.headers,
})
}
let sdk = createSDK()
const handlers = new Set<(event: GlobalEvent) => void>()
const emitter = {
emit(_type: "event", event: GlobalEvent) {
@ -57,21 +32,15 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
queue = []
timer = undefined
last = Date.now()
// Batch all event emissions so all store updates result in a single render
batch(() => {
for (const event of events) {
emitter.emit("event", event)
}
for (const event of events) emitter.emit("event", event)
})
}
const handleEvent = (event: GlobalEvent) => {
queue.push(event)
const elapsed = Date.now() - last
if (timer) return
// If we just flushed recently (within 16ms), batch this with future events
// Otherwise, process immediately to avoid latency
if (elapsed < 16) {
timer = setTimeout(flush, 16)
return
@ -79,73 +48,42 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
flush()
}
function startSSE() {
sse?.abort()
const ctrl = new AbortController()
sse = ctrl
;(async () => {
onMount(() => {
void (async () => {
let attempt = 0
while (true) {
if (abort.signal.aborted || ctrl.signal.aborted) break
const events = await sdk.global.event({
signal: ctrl.signal,
while (!abort.signal.aborted) {
const events = await props.client.global.event({
signal: abort.signal,
sseMaxRetryAttempts: 0,
})
if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
// Start syncing workspaces, it's important to do this after
// we've started listening to events
await sdk.sync.start().catch(() => {})
}
if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) await props.client.sync.start().catch(() => {})
for await (const event of events.stream) {
if (ctrl.signal.aborted) break
if (abort.signal.aborted) break
handleEvent(event)
}
if (timer) clearTimeout(timer)
if (queue.length > 0) flush()
attempt += 1
if (abort.signal.aborted || ctrl.signal.aborted) break
// Exponential backoff
const backoff = Math.min(retryDelay * 2 ** (attempt - 1), maxRetryDelay)
await new Promise((resolve) => setTimeout(resolve, backoff))
if (abort.signal.aborted) break
await new Promise((resolve) =>
setTimeout(resolve, Math.min(retryDelay * 2 ** (attempt - 1), maxRetryDelay)),
)
}
})().catch(() => {})
}
onMount(async () => {
if (props.events) {
const unsub = await props.events.subscribe(handleEvent)
onCleanup(unsub)
if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
// Start syncing workspaces, it's important to do this after
// we've started listening to events
await sdk.sync.start().catch(() => {})
}
} else {
startSSE()
}
})
onCleanup(() => {
abort.abort()
sse?.abort()
if (timer) clearTimeout(timer)
handlers.clear()
})
return {
get client() {
return sdk
},
directory: props.directory,
client: props.client,
event: emitter,
fetch: props.fetch ?? fetch,
url: props.url,
}
},
})

View file

@ -1,55 +1,33 @@
import type {
Message,
Agent,
Provider,
Session,
Part,
Config,
Todo,
Command,
PermissionRequest,
QuestionRequest,
LspStatus,
McpStatus,
McpResource,
FormatterStatus,
SessionStatus,
ProviderListResponse,
ProviderAuthMethod,
VcsInfo,
SnapshotFileDiff,
Config,
ConsoleState,
FormatterStatus,
LspStatus,
McpResource,
McpStatus,
Message,
Part,
PermissionRequest,
Provider,
ProviderAuthMethod,
ProviderListResponse,
QuestionRequest,
Session,
SnapshotFileDiff,
Todo,
VcsInfo,
} from "@opencode-ai/sdk/v2"
import { createStore, produce, reconcile } from "solid-js/store"
import { useProject } from "./project"
import { useEvent } from "./event"
import { useSDK } from "./sdk"
import { useTuiStartup } from "./runtime"
import { createStore } from "solid-js/store"
import { createSimpleContext } from "./helper"
import { useExit } from "./exit"
import { useArgs } from "./args"
import { batch, onMount } from "solid-js"
import path from "path"
import { useKV } from "./kv"
import { useProject } from "./project"
const emptyConsoleState: ConsoleState = {
consoleManagedProviders: [],
switchableOrgCount: 0,
}
function search<T>(items: T[], target: string, key: (item: T) => string) {
let left = 0
let right = items.length - 1
while (left <= right) {
const middle = Math.floor((left + right) / 2)
const value = key(items[middle])
if (value === target) return { found: true, index: middle }
if (value < target) left = middle + 1
else right = middle - 1
}
return { found: false, index: left }
}
export const {
context: SyncContext,
use: useSync,
@ -57,8 +35,7 @@ export const {
} = createSimpleContext({
name: "Sync",
init: () => {
const startup = useTuiStartup()
const kv = useKV()
const project = useProject()
const [store, setStore] = createStore<{
status: "loading" | "partial" | "complete"
provider: Provider[]
@ -71,39 +48,23 @@ export const {
provider_auth: Record<string, ProviderAuthMethod[]>
agent: Agent[]
command: Command[]
permission: {
[sessionID: string]: PermissionRequest[]
}
question: {
[sessionID: string]: QuestionRequest[]
}
permission: Record<string, PermissionRequest[]>
question: Record<string, QuestionRequest[]>
config: Config
session: Session[]
session_status: {
[sessionID: string]: SessionStatus
}
session_diff: {
[sessionID: string]: SnapshotFileDiff[]
}
todo: {
[sessionID: string]: Todo[]
}
message: {
[sessionID: string]: Message[]
}
part: {
[messageID: string]: Part[]
}
session_diff: Record<string, SnapshotFileDiff[]>
todo: Record<string, Todo[]>
message: Record<string, Message[]>
part: Record<string, Part[]>
lsp: LspStatus[]
mcp: {
[key: string]: McpStatus
}
mcp_resource: {
[key: string]: McpResource
}
mcp: Record<string, McpStatus>
mcp_resource: Record<string, McpResource>
formatter: FormatterStatus[]
vcs: VcsInfo | undefined
}>({
status: "complete",
provider: [],
provider_default: {},
provider_next: {
all: [],
default: {},
@ -114,16 +75,12 @@ export const {
experimentalBackgroundSubagents: false,
},
provider_auth: {},
config: {},
status: "loading",
agent: [],
command: [],
permission: {},
question: {},
command: [],
provider: [],
provider_default: {},
config: {},
session: [],
session_status: {},
session_diff: {},
todo: {},
message: {},
@ -135,521 +92,32 @@ export const {
vcs: undefined,
})
const event = useEvent()
const project = useProject()
const sdk = useSDK()
const fullSyncedSessions = new Set<string>()
const syncingSessions = new Map<string, Promise<void>>()
const hydratingSessions = new Map<string, { messages: Set<string>; parts: Set<string> }>()
const touchMessage = (sessionID: string, messageID: string) => {
hydratingSessions.get(sessionID)?.messages.add(messageID)
}
const touchPart = (sessionID: string, partID: string) => {
hydratingSessions.get(sessionID)?.parts.add(partID)
}
function sessionListQuery(): { scope?: "project"; path?: string } {
if (!kv.get("session_directory_filter_enabled", true)) return { scope: "project" }
if (!project.data.instance.path.worktree || !project.data.instance.path.directory) return { scope: "project" }
return {
path: path
.relative(path.resolve(project.data.instance.path.worktree), project.data.instance.path.directory)
.replaceAll("\\", "/"),
}
}
function listSessions() {
return sdk.client.session
.list({ start: Date.now() - 30 * 24 * 60 * 60 * 1000, ...sessionListQuery() })
.then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)))
}
event.subscribe((event, { workspace }) => {
switch (event.type) {
case "server.instance.disposed":
void bootstrap()
break
case "permission.replied": {
const requests = store.permission[event.properties.sessionID]
if (!requests) break
const match = search(requests, event.properties.requestID, (r) => r.id)
if (!match.found) break
setStore(
"permission",
event.properties.sessionID,
produce((draft) => {
draft.splice(match.index, 1)
}),
)
break
}
case "permission.asked": {
const request = event.properties
const requests = store.permission[request.sessionID]
if (!requests) {
setStore("permission", request.sessionID, [request])
break
}
const match = search(requests, request.id, (r) => r.id)
if (match.found) {
setStore("permission", request.sessionID, match.index, reconcile(request))
break
}
setStore(
"permission",
request.sessionID,
produce((draft) => {
draft.splice(match.index, 0, request)
}),
)
break
}
case "question.replied":
case "question.rejected": {
const requests = store.question[event.properties.sessionID]
if (!requests) break
const match = search(requests, event.properties.requestID, (r) => r.id)
if (!match.found) break
setStore(
"question",
event.properties.sessionID,
produce((draft) => {
draft.splice(match.index, 1)
}),
)
break
}
case "question.asked": {
const request = event.properties
const requests = store.question[request.sessionID]
if (!requests) {
setStore("question", request.sessionID, [request])
break
}
const match = search(requests, request.id, (r) => r.id)
if (match.found) {
setStore("question", request.sessionID, match.index, reconcile(request))
break
}
setStore(
"question",
request.sessionID,
produce((draft) => {
draft.splice(match.index, 0, request)
}),
)
break
}
case "todo.updated":
setStore("todo", event.properties.sessionID, event.properties.todos)
break
case "session.diff":
setStore("session_diff", event.properties.sessionID, event.properties.diff)
break
case "session.deleted": {
const result = search(store.session, event.properties.info.id, (s) => s.id)
if (result.found) {
setStore(
"session",
produce((draft) => {
draft.splice(result.index, 1)
}),
)
}
break
}
case "session.updated": {
const result = search(store.session, event.properties.info.id, (s) => s.id)
if (result.found) {
setStore("session", result.index, reconcile(event.properties.info))
break
}
setStore(
"session",
produce((draft) => {
draft.splice(result.index, 0, event.properties.info)
}),
)
break
}
case "session.next.moved": {
const result = search(store.session, event.properties.sessionID, (s) => s.id)
if (!result.found) break
setStore(
"session",
result.index,
produce((session) => {
session.directory = event.properties.location.directory
session.path = event.properties.subdirectory
session.workspaceID = event.properties.location.workspaceID
session.time.updated = event.properties.timestamp
}),
)
break
}
case "session.status": {
setStore("session_status", event.properties.sessionID, event.properties.status)
break
}
case "message.updated": {
touchMessage(event.properties.info.sessionID, event.properties.info.id)
const messages = store.message[event.properties.info.sessionID]
if (!messages) {
setStore("message", event.properties.info.sessionID, [event.properties.info])
break
}
const result = search(messages, event.properties.info.id, (m) => m.id)
if (result.found) {
setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info))
break
}
setStore(
"message",
event.properties.info.sessionID,
produce((draft) => {
draft.splice(result.index, 0, event.properties.info)
}),
)
const updated = store.message[event.properties.info.sessionID]
if (updated.length > 100) {
const oldest = updated[0]
batch(() => {
setStore(
"message",
event.properties.info.sessionID,
produce((draft) => {
draft.shift()
}),
)
setStore(
"part",
produce((draft) => {
delete draft[oldest.id]
}),
)
})
}
break
}
case "message.removed": {
touchMessage(event.properties.sessionID, event.properties.messageID)
const messages = store.message[event.properties.sessionID]
const result = search(messages, event.properties.messageID, (m) => m.id)
if (result.found) {
setStore(
"message",
event.properties.sessionID,
produce((draft) => {
draft.splice(result.index, 1)
}),
)
}
break
}
case "message.part.updated": {
touchPart(event.properties.part.sessionID, event.properties.part.id)
const parts = store.part[event.properties.part.messageID]
if (!parts) {
setStore("part", event.properties.part.messageID, [event.properties.part])
break
}
const result = search(parts, event.properties.part.id, (p) => p.id)
if (result.found) {
setStore("part", event.properties.part.messageID, result.index, reconcile(event.properties.part))
break
}
setStore(
"part",
event.properties.part.messageID,
produce((draft) => {
draft.splice(result.index, 0, event.properties.part)
}),
)
break
}
case "message.part.delta": {
const parts = store.part[event.properties.messageID]
if (!parts) break
const result = search(parts, event.properties.partID, (p) => p.id)
if (!result.found) break
touchPart(event.properties.sessionID, event.properties.partID)
setStore(
"part",
event.properties.messageID,
produce((draft) => {
const part = draft[result.index]
const field = event.properties.field as keyof typeof part
const existing = part[field] as string | undefined
;(part[field] as string) = (existing ?? "") + event.properties.delta
}),
)
break
}
case "message.part.removed": {
touchPart(event.properties.sessionID, event.properties.partID)
const parts = store.part[event.properties.messageID]
const result = search(parts, event.properties.partID, (p) => p.id)
if (result.found) {
setStore(
"part",
event.properties.messageID,
produce((draft) => {
draft.splice(result.index, 1)
}),
)
}
break
}
case "lsp.updated": {
const workspace = project.workspace.current()
void sdk.client.lsp.status({ workspace }).then((x) => setStore("lsp", x.data ?? []))
break
}
case "vcs.branch.updated": {
if (workspace === project.workspace.current()) {
setStore("vcs", { branch: event.properties.branch })
}
break
}
}
})
const exit = useExit()
const args = useArgs()
async function bootstrap(input: { fatal?: boolean } = {}) {
const fatal = input.fatal ?? true
const workspace = project.workspace.current()
const projectPromise = project.sync()
const sessionListPromise = projectPromise.then(() => listSessions())
// blocking - include session.list when continuing a session
const providersPromise = sdk.client.config.providers({ workspace }, { throwOnError: true })
const providerListPromise = sdk.client.provider.list({ workspace }, { throwOnError: true })
const capabilitiesPromise = sdk.client.experimental.capabilities
.get({ workspace }, { throwOnError: true })
.then((x) => x.data)
.catch(() => undefined)
const consoleStatePromise = sdk.client.experimental.console
.get({ workspace }, { throwOnError: true })
.then((x) => x.data)
.catch(() => emptyConsoleState)
const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true })
const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true })
await Promise.all([
providersPromise,
providerListPromise,
capabilitiesPromise,
agentsPromise,
configPromise,
projectPromise,
...(args.continue ? [sessionListPromise] : []),
])
.then(async () => {
const providersResponse = providersPromise.then((x) => x.data!)
const providerListResponse = providerListPromise.then((x) => x.data!)
const capabilitiesResponse = capabilitiesPromise
const consoleStateResponse = consoleStatePromise
const agentsResponse = agentsPromise.then((x) => x.data ?? [])
const configResponse = configPromise.then((x) => x.data!)
const sessionListResponse = args.continue ? sessionListPromise : undefined
return Promise.all([
providersResponse,
providerListResponse,
capabilitiesResponse,
consoleStateResponse,
agentsResponse,
configResponse,
...(sessionListResponse ? [sessionListResponse] : []),
]).then((responses) => {
const providers = responses[0]
const providerList = responses[1]
const capabilities = responses[2]
const consoleState = responses[3]
const agents = responses[4]
const config = responses[5]
const sessions = responses[6]
batch(() => {
setStore("provider", reconcile(providers.providers))
setStore("provider_default", reconcile(providers.default))
setStore("provider_next", reconcile(providerList))
setStore("capabilities", "experimentalBackgroundSubagents", capabilities?.backgroundSubagents === true)
setStore("console_state", reconcile(consoleState))
setStore("agent", reconcile(agents))
setStore("config", reconcile(config))
if (sessions !== undefined) setStore("session", reconcile(sessions))
})
})
})
.then(() => {
if (store.status !== "complete") setStore("status", "partial")
// non-blocking
void Promise.all([
...(args.continue ? [] : [sessionListPromise.then((sessions) => setStore("session", reconcile(sessions)))]),
consoleStatePromise.then((consoleState) => setStore("console_state", reconcile(consoleState))),
sdk.client.command.list({ workspace }).then((x) => setStore("command", reconcile(x.data ?? []))),
sdk.client.lsp.status({ workspace }).then((x) => setStore("lsp", reconcile(x.data ?? []))),
sdk.client.mcp.status({ workspace }).then((x) => setStore("mcp", reconcile(x.data ?? {}))),
sdk.client.experimental.resource
.list({ workspace })
.then((x) => setStore("mcp_resource", reconcile(x.data ?? {}))),
sdk.client.formatter.status({ workspace }).then((x) => setStore("formatter", reconcile(x.data ?? []))),
sdk.client.session.status({ workspace }).then((x) => {
setStore("session_status", reconcile(x.data ?? {}))
}),
sdk.client.provider.auth({ workspace }).then((x) => setStore("provider_auth", reconcile(x.data ?? {}))),
sdk.client.vcs.get({ workspace }).then((x) => setStore("vcs", reconcile(x.data))),
project.workspace.sync(),
]).then(() => {
setStore("status", "complete")
})
})
.catch(async (e) => {
console.error("tui bootstrap failed", {
error: e instanceof Error ? e.message : String(e),
name: e instanceof Error ? e.name : undefined,
stack: e instanceof Error ? e.stack : undefined,
})
if (fatal) {
exit(e)
} else {
throw e
}
})
}
onMount(() => {
void bootstrap()
})
const result = {
return {
data: store,
set: setStore,
get status() {
return store.status
},
get ready() {
if (startup.skipInitialLoading) return true
return store.status !== "loading"
return true
},
get path() {
return project.instance.path()
},
session: {
get(sessionID: string) {
const match = search(store.session, sessionID, (s) => s.id)
if (match.found) return store.session[match.index]
return undefined
get(_sessionID: string) {
return undefined as Session | undefined
},
query() {
return sessionListQuery()
return {} as { scope?: "project"; path?: string }
},
async refresh() {
const list = await listSessions()
setStore("session", reconcile(list))
},
status(sessionID: string) {
const session = result.session.get(sessionID)
if (!session) return "idle"
if (session.time.compacting) return "compacting"
const messages = store.message[sessionID] ?? []
const last = messages.at(-1)
if (!last) return "idle"
if (last.role === "user") return "working"
return last.time.completed ? "idle" : "working"
},
async sync(sessionID: string) {
if (fullSyncedSessions.has(sessionID)) return
const syncing = syncingSessions.get(sessionID)
if (syncing) return syncing
const tracker = { messages: new Set<string>(), parts: new Set<string>() }
hydratingSessions.set(sessionID, tracker)
const task = (async () => {
const [session, messages, todo, diff] = await Promise.all([
sdk.client.session.get({ sessionID }, { throwOnError: true }),
sdk.client.session.messages({ sessionID, limit: 100 }),
sdk.client.session.todo({ sessionID }),
sdk.client.session.diff({ sessionID }),
])
setStore(
produce((draft) => {
const match = search(draft.session, sessionID, (s) => s.id)
if (match.found) draft.session[match.index] = session.data!
if (!match.found) draft.session.splice(match.index, 0, session.data!)
draft.todo[sessionID] = todo.data ?? []
const currentMessages = draft.message[sessionID] ?? []
const infos = (messages.data ?? []).flatMap((message) => {
if (!tracker.messages.has(message.info.id)) return [message.info]
const current = currentMessages.find((item) => item.id === message.info.id)
return current ? [current] : []
})
infos.push(
...currentMessages.filter(
(message) => tracker.messages.has(message.id) && !infos.some((item) => item.id === message.id),
),
)
const removed = infos.slice(0, -100)
const visible = infos.slice(-100)
const visibleIDs = new Set(visible.map((message) => message.id))
for (const message of messages.data ?? []) {
if (!visibleIDs.has(message.info.id)) {
delete draft.part[message.info.id]
continue
}
const currentParts = draft.part[message.info.id] ?? []
const parts = message.parts.flatMap((part) => {
const current = currentParts.find((item) => item.id === part.id)
if (tracker.parts.has(part.id)) return current ? [current] : []
if (
current &&
(part.type === "text" || part.type === "reasoning") &&
(current.type === "text" || current.type === "reasoning") &&
part.text.length === 0 &&
current.text.length > 0
) {
return [current]
}
return [part]
})
parts.push(
...currentParts.filter(
(part) => tracker.parts.has(part.id) && !parts.some((item) => item.id === part.id),
),
)
draft.part[message.info.id] = parts
}
for (const message of removed) delete draft.part[message.id]
draft.message[sessionID] = visible
draft.session_diff[sessionID] = diff.data ?? []
}),
)
fullSyncedSessions.add(sessionID)
})().finally(() => {
syncingSessions.delete(sessionID)
hydratingSessions.delete(sessionID)
})
syncingSessions.set(sessionID, task)
return task
async refresh() {},
status(_sessionID: string) {
return "idle" as const
},
async sync(_sessionID: string) {},
},
bootstrap,
async bootstrap(_input: { fatal?: boolean } = {}) {},
}
return result
},
})

View file

@ -56,15 +56,12 @@ const tui: TuiPlugin = async (api) => {
permissions.delete(event.properties.requestID)
})
api.event.on("session.status", (event) => {
const sessionID = event.properties.sessionID
if (event.properties.status.type === "busy" || event.properties.status.type === "retry") {
active.add(sessionID)
errored.delete(sessionID)
return
}
const started = (sessionID: string) => {
active.add(sessionID)
errored.delete(sessionID)
}
if (event.properties.status.type !== "idle") return
const ended = (sessionID: string) => {
if (!active.has(sessionID)) return
active.delete(sessionID)
@ -75,6 +72,24 @@ const tui: TuiPlugin = async (api) => {
const session = api.state.session.get(sessionID)
notify(api, sessionID, "Session done", session?.parentID ? "subagent_done" : "done")
}
api.event.on("session.next.prompted", (event) => started(event.properties.sessionID))
api.event.on("session.next.shell.started", (event) => started(event.properties.sessionID))
api.event.on("session.next.step.started", (event) => started(event.properties.sessionID))
api.event.on("session.next.retried", (event) => started(event.properties.sessionID))
api.event.on("session.next.compaction.started", (event) => started(event.properties.sessionID))
api.event.on("session.next.shell.ended", (event) => ended(event.properties.sessionID))
api.event.on("session.next.step.ended", (event) => {
if (event.properties.finish === "tool-calls") return
ended(event.properties.sessionID)
})
api.event.on("session.next.step.failed", (event) => {
const sessionID = event.properties.sessionID
if (!active.has(sessionID)) return
errored.add(sessionID)
notify(api, sessionID, "Session error", "error")
ended(sessionID)
})
api.event.on("session.error", (event) => {

View file

@ -4,6 +4,7 @@ import type { useEvent } from "../context/event"
import type { useRoute } from "../context/route"
import type { useSDK } from "../context/sdk"
import type { useSync } from "../context/sync"
import type { useData } from "../context/data"
import type { useTheme } from "../context/theme"
import { Dialog as DialogUI, type useDialog } from "../ui/dialog"
import type { useOpencodeKeymap } from "../keymap"
@ -31,6 +32,7 @@ type Input = {
event: ReturnType<typeof useEvent>
sdk: ReturnType<typeof useSDK>
sync: ReturnType<typeof useSync>
data: ReturnType<typeof useData>
theme: ReturnType<typeof useTheme>
toast: ReturnType<typeof useToast>
renderer: TuiPluginApi["renderer"]
@ -95,7 +97,7 @@ function mapOptionCb<Value>(cb?: (item: TuiDialogSelectOption<Value>) => void) {
return (item: SelectOption<Value>) => cb(pickOption(item))
}
function stateApi(sync: ReturnType<typeof useSync>): TuiPluginApi["state"] {
function stateApi(sync: ReturnType<typeof useSync>, data: ReturnType<typeof useData>): TuiPluginApi["state"] {
return {
get ready() {
return sync.ready
@ -135,7 +137,7 @@ function stateApi(sync: ReturnType<typeof useSync>): TuiPluginApi["state"] {
return sync.data.message[sessionID] ?? []
},
status(sessionID) {
return sync.data.session_status[sessionID]
return data.session.status(sessionID) === "running" ? { type: "busy" } : { type: "idle" }
},
permission(sessionID) {
return sync.data.permission[sessionID] ?? []
@ -297,7 +299,7 @@ export function createTuiApiAdapters(input: Input): Omit<TuiPluginApi, "lifecycl
return input.kv.ready
},
},
state: stateApi(input.sync),
state: stateApi(input.sync, input.data),
get client() {
return input.sdk.client
},