refactor(tui): migrate shared data to v2 API

This commit is contained in:
Dax Raad
2026-06-10 19:16:24 -04:00
parent 7de746fade
commit efe6ded6b1
32 changed files with 921 additions and 1974 deletions
+10 -1
View File
@@ -5,6 +5,8 @@ import * as Effect from "effect/Effect"
import { HttpRouter, HttpServer } from "effect/unstable/http"
import { createServer } from "node:http"
import { createRoutes } from "@opencode-ai/server/routes"
import { ServerAuth } from "@opencode-ai/server/auth"
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { Daemon } from "../../services/daemon"
@@ -15,7 +17,14 @@ export default Runtime.handler(
return yield* Effect.scoped(
Effect.gen(function* () {
const daemon = yield* Daemon.Service
const address = yield* listen(input.hostname, input.port, yield* daemon.password())
const password = yield* daemon.password()
const address = yield* listen(input.hostname, input.port, password)
yield* Effect.tryPromise(() =>
createOpencodeClient({
baseUrl: HttpServer.formatAddress(address),
headers: ServerAuth.headers({ password }),
}).v2.location.get(undefined, { throwOnError: true }),
)
if (input.register) yield* daemon.register(address)
console.log(`server listening on ${HttpServer.formatAddress(address)}`)
return yield* Effect.never
@@ -8,6 +8,6 @@ export default Runtime.handler(
Commands.commands.service.commands.status,
Effect.fn("cli.service.status")(function* () {
const url = yield* (yield* Daemon.Service).status()
process.stdout.write((url ? `running ${url}` : "stopped") + EOL)
process.stdout.write((url ? url : "stopped") + EOL)
}),
)
+8 -2
View File
@@ -2,17 +2,23 @@ import { run } from "@opencode-ai/tui"
import { TuiConfig } from "@opencode-ai/tui/config"
import { Effect } from "effect"
import { Global } from "@opencode-ai/core/global"
import { loadBuiltinPlugins } from "@opencode-ai/tui/builtins"
export function runTui(transport: { url: string; headers: RequestInit["headers"] }) {
const config = TuiConfig.resolve({}, { terminalSuspend: false })
let disposeSlots: (() => void) | undefined
return run({
...transport,
args: {},
config,
fetch: gracefulFetch,
pluginHost: {
async start() {},
async dispose() {},
async start(input) {
disposeSlots = await loadBuiltinPlugins(input.api, input.runtime)
},
async dispose() {
disposeSlots?.()
},
},
}).pipe(Effect.provide(Global.defaultLayer))
}
+2 -5
View File
@@ -1,10 +1,7 @@
import { createBuiltinPlugins, type BuiltinTuiPlugin } from "@opencode-ai/tui/builtins"
import type { RuntimeFlags } from "@/effect/runtime-flags"
export type InternalTuiPlugin = BuiltinTuiPlugin
export function internalTuiPlugins(flags: Pick<RuntimeFlags.Info, "experimentalEventSystem">): InternalTuiPlugin[] {
return createBuiltinPlugins({
experimentalEventSystem: flags.experimentalEventSystem,
})
export function internalTuiPlugins(): InternalTuiPlugin[] {
return createBuiltinPlugins()
}
+1 -1
View File
@@ -1089,7 +1089,7 @@ async function load(input: {
if (Flag.OPENCODE_PURE && pluginOrigins.length) {
}
for (const item of internalTuiPlugins(flags)) {
for (const item of internalTuiPlugins()) {
const entry = loadInternalPlugin(item)
const meta = createMeta(entry.source, entry.spec, entry.target, undefined, entry.id)
addPluginEntry(next, {
@@ -646,6 +646,7 @@ const scenarios: Scenario[] = [
object(body)
check(body.healthy === true, "v2 server should report healthy")
}),
http.protected.get("/api/location", "v2.location.get").json(200, object),
http.protected.get("/api/agent", "v2.agent.list").json(200, locationData(array)),
http.protected.get("/api/model", "v2.model.list").json(200, locationData(array)),
http.protected.get("/api/provider", "v2.provider.list").json(200, locationData(array)),
@@ -698,6 +699,14 @@ const scenarios: Scenario[] = [
headers: ctx.headers(),
}))
.json(200, data(array)),
http.protected
.get("/api/session/{sessionID}/question", "v2.session.question.list")
.seeded((ctx) => ctx.session({ title: "Question list owner" }))
.at((ctx) => ({
path: route("/api/session/{sessionID}/question", { sessionID: ctx.state.id }),
headers: ctx.headers(),
}))
.json(200, data(array)),
http.protected
.post("/api/session/{sessionID}/permission/{requestID}/reply", "v2.session.permission.reply")
.seeded((ctx) => ctx.session({ title: "Permission owner" }))
@@ -807,6 +816,14 @@ const scenarios: Scenario[] = [
headers: ctx.headers(),
}))
.status(400, undefined, "none"),
http.protected
.get("/api/session/{sessionID}", "v2.session.get")
.seeded((ctx) => ctx.session({ title: "Session get" }))
.at((ctx) => ({
path: route("/api/session/{sessionID}", { sessionID: ctx.state.id }),
headers: ctx.headers(),
}))
.json(200, data(object)),
http.protected
.get("/api/session/{sessionID}/context", "v2.session.context")
.at((ctx) => ({
+77
View File
@@ -274,6 +274,8 @@ import type {
V2FsReadResponses,
V2HealthGetErrors,
V2HealthGetResponses,
V2LocationGetErrors,
V2LocationGetResponses,
V2ModelListErrors,
V2ModelListResponses,
V2PermissionRequestListErrors,
@@ -294,6 +296,8 @@ import type {
V2SessionCompactResponses,
V2SessionContextErrors,
V2SessionContextResponses,
V2SessionGetErrors,
V2SessionGetResponses,
V2SessionListErrors,
V2SessionListResponses,
V2SessionMessagesErrors,
@@ -304,6 +308,8 @@ import type {
V2SessionPermissionReplyResponses,
V2SessionPromptErrors,
V2SessionPromptResponses,
V2SessionQuestionListErrors,
V2SessionQuestionListResponses,
V2SessionQuestionRejectErrors,
V2SessionQuestionRejectResponses,
V2SessionQuestionReplyErrors,
@@ -5013,6 +5019,30 @@ export class Health extends HeyApiClient {
}
}
export class Location extends HeyApiClient {
/**
* Get location
*
* Resolve the requested location or the server default location.
*/
public get<ThrowOnError extends boolean = false>(
parameters?: {
location?: {
directory?: string
workspace?: string
}
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }])
return (options?.client ?? this.client).get<V2LocationGetResponses, V2LocationGetErrors, ThrowOnError>({
url: "/api/location",
...options,
...params,
})
}
}
export class Agent extends HeyApiClient {
/**
* List agents
@@ -5106,6 +5136,29 @@ export class Permission2 extends HeyApiClient {
}
export class Question2 extends HeyApiClient {
/**
* List session question requests
*
* Retrieve pending question requests owned by a session.
*/
public list<ThrowOnError extends boolean = false>(
parameters: {
sessionID: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "sessionID" }] }])
return (options?.client ?? this.client).get<
V2SessionQuestionListResponses,
V2SessionQuestionListErrors,
ThrowOnError
>({
url: "/api/session/{sessionID}/question",
...options,
...params,
})
}
/**
* Reply to pending question request
*
@@ -5225,6 +5278,25 @@ export class Session3 extends HeyApiClient {
})
}
/**
* Get session
*
* Retrieve a session by ID.
*/
public get<ThrowOnError extends boolean = false>(
parameters: {
sessionID: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "sessionID" }] }])
return (options?.client ?? this.client).get<V2SessionGetResponses, V2SessionGetErrors, ThrowOnError>({
url: "/api/session/{sessionID}",
...options,
...params,
})
}
/**
* Send message
*
@@ -5779,6 +5851,11 @@ export class V2 extends HeyApiClient {
return (this._health ??= new Health({ client: this.client }))
}
private _location?: Location
get location(): Location {
return (this._location ??= new Location({ client: this.client }))
}
private _agent?: Agent
get agent(): Agent {
return (this._agent ??= new Agent({ client: this.client }))
+114 -6
View File
@@ -2737,18 +2737,18 @@ export type InvalidCursorError = {
message: string
}
export type ConflictError = {
_tag: "ConflictError"
message: string
resource?: string
}
export type SessionNotFoundError = {
_tag: "SessionNotFoundError"
sessionID: string
message: string
}
export type ConflictError = {
_tag: "ConflictError"
message: string
resource?: string
}
export type ServiceUnavailableError = {
_tag: "ServiceUnavailableError"
message: string
@@ -9453,6 +9453,40 @@ export type V2HealthGetResponses = {
export type V2HealthGetResponse = V2HealthGetResponses[keyof V2HealthGetResponses]
export type V2LocationGetData = {
body?: never
path?: never
query?: {
location?: {
directory?: string
workspace?: string
}
}
url: "/api/location"
}
export type V2LocationGetErrors = {
/**
* InvalidRequestError
*/
400: InvalidRequestError
/**
* UnauthorizedError
*/
401: UnauthorizedError
}
export type V2LocationGetError = V2LocationGetErrors[keyof V2LocationGetErrors]
export type V2LocationGetResponses = {
/**
* Location.Info
*/
200: LocationInfo
}
export type V2LocationGetResponse = V2LocationGetResponses[keyof V2LocationGetResponses]
export type V2AgentListData = {
body?: never
path?: never
@@ -9531,6 +9565,43 @@ export type V2SessionListResponses = {
export type V2SessionListResponse = V2SessionListResponses[keyof V2SessionListResponses]
export type V2SessionGetData = {
body?: never
path: {
sessionID: string
}
query?: never
url: "/api/session/{sessionID}"
}
export type V2SessionGetErrors = {
/**
* InvalidRequestError
*/
400: InvalidRequestError
/**
* UnauthorizedError
*/
401: UnauthorizedError
/**
* SessionNotFoundError
*/
404: SessionNotFoundError
}
export type V2SessionGetError = V2SessionGetErrors[keyof V2SessionGetErrors]
export type V2SessionGetResponses = {
/**
* Success
*/
200: {
data: SessionV2Info
}
}
export type V2SessionGetResponse = V2SessionGetResponses[keyof V2SessionGetResponses]
export type V2SessionPromptData = {
body: {
id?: string
@@ -10310,6 +10381,43 @@ export type V2QuestionRequestListResponses = {
export type V2QuestionRequestListResponse = V2QuestionRequestListResponses[keyof V2QuestionRequestListResponses]
export type V2SessionQuestionListData = {
body?: never
path: {
sessionID: string
}
query?: never
url: "/api/session/{sessionID}/question"
}
export type V2SessionQuestionListErrors = {
/**
* InvalidRequestError
*/
400: InvalidRequestError
/**
* UnauthorizedError
*/
401: UnauthorizedError
/**
* SessionNotFoundError
*/
404: SessionNotFoundError
}
export type V2SessionQuestionListError = V2SessionQuestionListErrors[keyof V2SessionQuestionListErrors]
export type V2SessionQuestionListResponses = {
/**
* Success
*/
200: {
data: Array<QuestionV2Request>
}
}
export type V2SessionQuestionListResponse = V2SessionQuestionListResponses[keyof V2SessionQuestionListResponses]
export type V2SessionQuestionReplyData = {
body: QuestionV2Reply
path: {
+2
View File
@@ -14,9 +14,11 @@ import { HealthGroup } from "./groups/health"
import { QuestionGroup } from "./groups/question"
import { ReferenceGroup } from "./groups/reference"
import { Authorization } from "./middleware/authorization"
import { LocationGroup } from "./groups/location"
export const Api = HttpApi.make("server")
.add(HealthGroup)
.add(LocationGroup)
.add(AgentGroup)
.add(SessionGroup)
.add(MessageGroup)
+18 -1
View File
@@ -5,7 +5,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import { Effect, Layer, Schema } from "effect"
import { HttpServerRequest } from "effect/unstable/http"
import { HttpApiMiddleware, OpenApi } from "effect/unstable/httpapi"
import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, OpenApi } from "effect/unstable/httpapi"
export const LocationQuery = Schema.Struct({
location: Schema.optional(
@@ -54,6 +54,23 @@ export class LocationMiddleware extends HttpApiMiddleware.Service<
}
>()("@opencode/HttpApiLocation") {}
export const LocationGroup = HttpApiGroup.make("server.location")
.add(
HttpApiEndpoint.get("location.get", "/api/location", {
query: LocationQuery,
success: Location.Info,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.location.get",
summary: "Get location",
description: "Resolve the requested location or the server default location.",
}),
),
)
.middleware(LocationMiddleware)
function ref(request: HttpServerRequest.HttpServerRequest): Location.Ref {
const query = new URL(request.url, "http://localhost").searchParams
const workspaceID = query.get("location[workspace]") || request.headers["x-opencode-workspace"]
+15
View File
@@ -24,6 +24,21 @@ export const QuestionGroup = HttpApiGroup.make("server.question")
)
.annotateMerge(OpenApi.annotations({ title: "questions", description: "Experimental question routes." }))
.middleware(LocationMiddleware)
.add(
HttpApiEndpoint.get("session.question.list", "/api/session/:sessionID/question", {
params: { sessionID: SessionV2.ID },
success: Schema.Struct({ data: Schema.Array(QuestionV2.Request) }),
error: SessionNotFoundError,
})
.middleware(SessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.question.list",
summary: "List session question requests",
description: "Retrieve pending question requests owned by a session.",
}),
),
)
.add(
HttpApiEndpoint.post("session.question.reply", "/api/session/:sessionID/question/:requestID/reply", {
params: { sessionID: SessionV2.ID, requestID: QuestionV2.ID },
+15
View File
@@ -105,6 +105,21 @@ export const SessionGroup = HttpApiGroup.make("server.session")
}),
),
)
.add(
HttpApiEndpoint.get("session.get", "/api/session/:sessionID", {
params: { sessionID: SessionV2.ID },
success: Schema.Struct({ data: SessionV2.Info }),
error: SessionNotFoundError,
})
.middleware(SessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.get",
summary: "Get session",
description: "Retrieve a session by ID.",
}),
),
)
.add(
HttpApiEndpoint.post("session.prompt", "/api/session/:sessionID/prompt", {
params: { sessionID: SessionV2.ID },
+2
View File
@@ -18,9 +18,11 @@ import { HealthHandler } from "./handlers/health"
import { QuestionHandler } from "./handlers/question"
import { ReferenceHandler } from "./handlers/reference"
import * as SessionExecutionLocal from "@opencode-ai/core/session/execution/local"
import { LocationHandler } from "./handlers/location"
export const handlers = Layer.mergeAll(
HealthHandler,
LocationHandler,
AgentHandler,
SessionHandler,
MessageHandler,
+18
View File
@@ -0,0 +1,18 @@
import { Location } from "@opencode-ai/core/location"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api"
export const LocationHandler = HttpApiBuilder.group(Api, "server.location", (handlers) =>
handlers.handle(
"location.get",
Effect.fn(function* () {
const location = yield* Location.Service
return new Location.Info({
directory: location.directory,
workspaceID: location.workspaceID,
project: location.project,
})
}),
),
)
+7
View File
@@ -29,6 +29,13 @@ export const QuestionHandler = HttpApiBuilder.group(Api, "server.question", (han
return yield* response((yield* QuestionV2.Service).list())
}),
)
.handle(
"session.question.list",
Effect.fn(function* (ctx) {
const requests = yield* (yield* QuestionV2.Service).list()
return { data: requests.filter((request) => request.sessionID === ctx.params.sessionID) }
}),
)
.handle(
"session.question.reply",
Effect.fn(function* (ctx) {
+17
View File
@@ -61,6 +61,23 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
}
}),
)
.handle(
"session.get",
Effect.fn(function* (ctx) {
return {
data: yield* session.get(ctx.params.sessionID).pipe(
Effect.catchTag(
"Session.NotFoundError",
(error) =>
new SessionNotFoundError({
sessionID: error.sessionID,
message: `Session not found: ${error.sessionID}`,
}),
),
),
}
}),
)
.handle(
"session.prompt",
Effect.fn(function* (ctx) {
+92 -123
View File
@@ -5,7 +5,6 @@ import { Global } from "@opencode-ai/core/global"
import { Flag } from "@opencode-ai/core/flag/flag"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { ClipboardProvider, useClipboard } from "./context/clipboard"
import { ExitProvider, useExit } from "./context/exit"
import { EpilogueProvider } from "./context/epilogue"
import * as Selection from "./util/selection"
import { createCliRenderer, MouseButton, type CliRenderer } from "@opentui/core"
@@ -21,7 +20,6 @@ import {
onCleanup,
batch,
Show,
on,
} from "solid-js"
import { TuiPathsProvider, TuiStartupProvider, TuiTerminalEnvironmentProvider, useTuiStartup } from "./context/runtime"
import { DialogProvider, useDialog } from "./ui/dialog"
@@ -81,7 +79,6 @@ import { createTuiAttention } from "./attention"
import * as TuiAudio from "./audio"
import { win32DisableProcessedInput, win32FlushInputBuffer } from "./terminal-win32"
import { destroyRenderer } from "./util/renderer"
import { cliErrorMessage, errorFormat } from "./util/error"
const appGlobalBindingCommands = [
"session.list",
@@ -177,8 +174,8 @@ function isVersionGreater(left: string, right: string) {
export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
const global = yield* Global.Service
const exit = { epilogue: undefined as string | undefined, reason: undefined as unknown }
yield* Effect.scoped(
const epilogue = { value: undefined as string | undefined }
const output = yield* Effect.scoped(
Effect.gen(function* () {
const renderer = yield* Effect.acquireRelease(
Effect.tryPromise(() =>
@@ -196,10 +193,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
},
}),
),
(renderer) =>
Effect.sync(() => {
destroyRenderer(renderer)
}),
(renderer) => Effect.sync(() => destroyRenderer(renderer)),
)
win32DisableProcessedInput()
const keymap = createDefaultOpenTuiKeymap(renderer)
@@ -217,7 +211,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
}),
)
yield* Effect.addFinalizer(() => Effect.sync(TuiAudio.dispose))
const shutdown = yield* Deferred.make<unknown>()
const shutdown = yield* Deferred.make<void>()
const onSighup = () => destroyRenderer(renderer)
yield* Effect.acquireRelease(
Effect.sync(() => process.on("SIGHUP", onSighup)),
@@ -234,116 +228,103 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
await render(() => {
return (
<ExitProvider
exit={(reason) => {
if (renderer.isDestroyed) return
exit.reason = reason
destroyRenderer(renderer)
}}
>
<EpilogueProvider set={(value) => (exit.epilogue = value)}>
<ErrorBoundary fallback={(error, reset) => <ErrorComponent error={error} reset={reset} mode={mode} />}>
<TuiPathsProvider
<ErrorBoundary fallback={(error, reset) => <ErrorComponent error={error} reset={reset} mode={mode} />}>
<TuiPathsProvider
value={{
cwd: process.cwd(),
home: global.home,
state: global.state,
worktree: global.data + "/worktree",
}}
>
<TuiTerminalEnvironmentProvider
value={{
platform: process.platform,
multiplexer: process.env.TMUX ? "tmux" : process.env.STY ? "screen" : undefined,
displayServer: process.env.WAYLAND_DISPLAY ? "wayland" : process.env.DISPLAY ? "x11" : undefined,
}}
>
<TuiStartupProvider
value={{
cwd: process.cwd(),
home: global.home,
state: global.state,
worktree: global.data + "/worktree",
initialRoute: process.env.OPENCODE_ROUTE ? JSON.parse(process.env.OPENCODE_ROUTE) : undefined,
skipInitialLoading: Boolean(process.env.OPENCODE_FAST_BOOT),
}}
>
<TuiTerminalEnvironmentProvider
value={{
platform: process.platform,
multiplexer: process.env.TMUX ? "tmux" : process.env.STY ? "screen" : undefined,
displayServer: process.env.WAYLAND_DISPLAY
? "wayland"
: process.env.DISPLAY
? "x11"
: undefined,
}}
>
<TuiStartupProvider
value={{
initialRoute: process.env.OPENCODE_ROUTE ? JSON.parse(process.env.OPENCODE_ROUTE) : undefined,
skipInitialLoading: Boolean(process.env.OPENCODE_FAST_BOOT),
}}
>
<ClipboardProvider>
<OpencodeKeymapProvider keymap={keymap}>
<ArgsProvider {...input.args}>
<KVProvider>
<ToastProvider>
<RouteProvider
initialRoute={
input.args.continue
? {
type: "session",
sessionID: "dummy",
}
: undefined
}
>
<TuiConfigProvider config={input.config}>
<PluginRuntimeProvider value={pluginRuntime}>
<SDKProvider
url={input.url}
directory={input.directory}
fetch={input.fetch}
headers={input.headers}
events={input.events}
>
<ProjectProvider>
<SyncProvider>
<DataProvider>
<ThemeProvider mode={mode}>
<LocalProvider>
<PromptStashProvider>
<DialogProvider>
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<EditorContextProvider>
<App
onSnapshot={input.onSnapshot}
pluginHost={input.pluginHost}
/>
</EditorContextProvider>
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
</DialogProvider>
</PromptStashProvider>
</LocalProvider>
</ThemeProvider>
</DataProvider>
</SyncProvider>
</ProjectProvider>
</SDKProvider>
</PluginRuntimeProvider>
</TuiConfigProvider>
</RouteProvider>
</ToastProvider>
</KVProvider>
</ArgsProvider>
</OpencodeKeymapProvider>
</ClipboardProvider>
</TuiStartupProvider>
</TuiTerminalEnvironmentProvider>
</TuiPathsProvider>
</ErrorBoundary>
</EpilogueProvider>
</ExitProvider>
<ClipboardProvider>
<EpilogueProvider set={(value) => (epilogue.value = value)}>
<OpencodeKeymapProvider keymap={keymap}>
<ArgsProvider {...input.args}>
<KVProvider>
<ToastProvider>
<RouteProvider
initialRoute={
input.args.continue
? {
type: "session",
sessionID: "dummy",
}
: undefined
}
>
<TuiConfigProvider config={input.config}>
<PluginRuntimeProvider value={pluginRuntime}>
<SDKProvider
url={input.url}
directory={input.directory}
fetch={input.fetch}
headers={input.headers}
events={input.events}
>
<ProjectProvider>
<SyncProvider>
<DataProvider>
<ThemeProvider mode={mode}>
<LocalProvider>
<PromptStashProvider>
<DialogProvider>
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<EditorContextProvider>
<App
onSnapshot={input.onSnapshot}
pluginHost={input.pluginHost}
/>
</EditorContextProvider>
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
</DialogProvider>
</PromptStashProvider>
</LocalProvider>
</ThemeProvider>
</DataProvider>
</SyncProvider>
</ProjectProvider>
</SDKProvider>
</PluginRuntimeProvider>
</TuiConfigProvider>
</RouteProvider>
</ToastProvider>
</KVProvider>
</ArgsProvider>
</OpencodeKeymapProvider>
</EpilogueProvider>
</ClipboardProvider>
</TuiStartupProvider>
</TuiTerminalEnvironmentProvider>
</TuiPathsProvider>
</ErrorBoundary>
)
}, renderer)
})
yield* Deferred.await(shutdown)
return epilogue.value
}),
)
yield* Effect.sync(() => {
win32FlushInputBuffer()
if (exit.reason !== undefined)
process.stderr.write((cliErrorMessage(exit.reason) ?? errorFormat(exit.reason)) + "\n")
if (exit.epilogue) process.stdout.write(exit.epilogue + "\n")
if (output) process.stdout.write(output + "\n")
})
})
@@ -364,7 +345,6 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
const { theme, mode, setMode, locked, lock, unlock } = themeState
const sync = useSync()
const project = useProject()
const exit = useExit()
const promptRef = usePromptRef()
const pluginRuntime = usePluginRuntime()
const attention = createTuiAttention({ renderer, config: tuiConfig, kv })
@@ -522,17 +502,6 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
})
})
createEffect(
on(
() => sync.status === "complete" && sync.data.provider.length === 0,
(isEmpty, wasEmpty) => {
// only trigger when we transition into an empty-provider state
if (!isEmpty || wasEmpty) return
dialog.replace(() => <DialogProviderList />)
},
),
)
const connected = useConnected()
const currentWorktreeWorkspace = createMemo(() => {
const workspaceID = project.workspace.current()
@@ -805,7 +774,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
title: "Exit the app",
slashName: "exit",
slashAliases: ["quit", "q"],
run: () => exit(),
run: () => destroyRenderer(renderer),
category: "System",
},
{
@@ -1039,7 +1008,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
`Successfully updated to OpenCode v${result.data.version}. Please restart the application.`,
)
void exit()
destroyRenderer(renderer)
})
const plugin = createMemo(() => {
+48 -43
View File
@@ -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<T extends { footer?: string; releaseDate: string; title: string }>(
export function sortModelOptions<T extends { footer?: string; releaseDate: string | number; title: string }>(
options: T[],
newestFirst: boolean,
) {
@@ -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) {
@@ -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)
}
@@ -85,7 +85,7 @@ export function Autocomplete(props: {
const editor = useEditorContext()
const sdk = useSDK()
const sync = useSync()
const syncV2 = useData()
const data = useData()
const project = useProject()
const slashes = useCommandSlashes()
const modeStack = useOpencodeModeStack()
@@ -273,14 +273,6 @@ export function Autocomplete(props: {
}
}
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)
})
function normalizeMentionPath(filePath: string) {
const baseDir = sync.path.directory || paths.cwd
const absolute = path.resolve(filePath)
@@ -311,15 +303,12 @@ export function Autocomplete(props: {
() => search(),
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 +318,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 +379,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,
@@ -410,30 +399,6 @@ export function Autocomplete(props: {
)
})
const referenceAliases = createMemo(() =>
syncV2.data.reference
.filter((reference) => !reference.hidden)
.map(
(reference): AutocompleteOption => ({
display: "@" + reference.name,
description: ` ${reference.source.type === "git" ? reference.source.repository : reference.source.path}`,
onSelect: () => {
insertPart(reference.name, {
type: "file",
mime: "application/x-directory",
filename: reference.name,
url: pathToFileURL(reference.path).href,
source: {
type: "file",
text: { start: 0, end: 0, value: "" },
path: reference.name,
},
})
},
}),
),
)
const commands = createMemo((): AutocompleteOption[] => {
const results: AutocompleteOption[] = [...slashes()]
@@ -465,23 +430,15 @@ export function Autocomplete(props: {
const options = createMemo((prev: AutocompleteOption[] | undefined) => {
const filesValue = files()
const referenceMatchValue = referenceMatch()
const agentsValue = agents()
const referenceAliasesValue = referenceAliases()
const commandsValue = commands()
const searchValue = search()
// @<alias>/... — 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}`)
}
// Files come from fff already fuzzy ranked and filtered
// it shouldn't be additionally sorted by fuzzysort as it will loose the results
const fileOptions: AutocompleteOption[] = store.visible === "@" ? filesValue || [] : []
const nonFileOptions: AutocompleteOption[] =
store.visible === "@" ? [...referenceAliasesValue, ...agentsValue, ...mcpResources()] : [...commandsValue]
store.visible === "@" ? [...agentsValue, ...mcpResources()] : [...commandsValue]
if (!searchValue) {
return [...nonFileOptions, ...fileOptions]
@@ -563,7 +520,7 @@ export function Autocomplete(props: {
const endCursor = input.logicalCursor
input.deleteRange(startCursor.row, startCursor.col, endCursor.row, endCursor.col)
input.insertText("@" + path + "/")
input.insertText("@" + path)
setStore("selected", 0)
}
+3 -5
View File
@@ -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))
}
+271 -185
View File
@@ -1,144 +1,127 @@
import { useEvent } from "./event"
import type {
AgentV2Info,
CommandV2Info,
Event,
ReferenceInfo,
LocationRef,
ModelV2Info,
PermissionSavedInfo,
PermissionV2Request,
ProviderV2Info,
QuestionV2Request,
SessionMessage,
SessionMessageAssistant,
SessionMessageAssistantReasoning,
SessionMessageAssistantText,
SessionMessageAssistantTool,
SessionV2Info,
SkillV2Info,
} from "@opencode-ai/sdk/v2"
import { createStore, produce, reconcile } from "solid-js/store"
import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "./helper"
import { useSDK } from "./sdk"
import { useProject } from "./project"
import { createEffect } from "solid-js"
import { createSignal, onMount } 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
type LocationData = {
agent?: AgentV2Info[]
command?: CommandV2Info[]
model?: ModelV2Info[]
provider?: ProviderV2Info[]
skill?: SkillV2Info[]
}
function ownedAssistant(messages: SessionMessage[], messageID: string) {
const message = messages.find((message) => message.type === "assistant" && message.id === messageID)
return message?.type === "assistant" ? message : undefined
type Data = {
session: {
info: Record<string, SessionV2Info>
message: Record<string, SessionMessage[]>
permission: Record<string, PermissionV2Request[]>
question: Record<string, QuestionV2Request[]>
}
project: {
permission: Record<string, PermissionSavedInfo[]>
}
location: Record<string, LocationData>
}
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 locationKey(location: LocationRef) {
return JSON.stringify([location.directory, location.workspaceID])
}
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)
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<{
messages: {
[sessionID: string]: SessionMessage[]
}
reference: ReferenceInfo[]
}>({
messages: {},
reference: [],
const [store, setStore] = createStore<Data>({
session: {
info: {},
message: {},
permission: {},
question: {},
},
project: {
permission: {},
},
location: {},
})
const event = useEvent()
const sdk = useSDK()
const project = useProject()
const applied = new Set<string>()
const buffering = new Map<string, Event[]>()
const syncing = new Map<string, Promise<void>>()
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()
const [defaultLocation, setDefaultLocation] = createSignal<LocationRef>({
directory: sdk.directory ?? process.cwd(),
})
function apply(event: Event) {
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":
update(event.properties.sessionID, (draft) => {
prepend(draft, {
message.update(event.properties.sessionID, (draft) => {
message.prepend(draft, {
id: event.properties.messageID,
type: "agent-switched",
agent: event.properties.agent,
@@ -147,8 +130,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
})
break
case "session.next.model.switched":
update(event.properties.sessionID, (draft) => {
prepend(draft, {
message.update(event.properties.sessionID, (draft) => {
message.prepend(draft, {
id: event.properties.messageID,
type: "model-switched",
model: event.properties.model,
@@ -157,8 +140,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
})
break
case "session.next.prompted": {
update(event.properties.sessionID, (draft) => {
prepend(draft, {
message.update(event.properties.sessionID, (draft) => {
message.prepend(draft, {
id: event.properties.messageID,
type: "user",
text: event.properties.prompt.text,
@@ -172,8 +155,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
case "session.next.prompt.admitted":
break
case "session.next.prompt.promoted":
update(event.properties.sessionID, (draft) => {
prepend(draft, {
message.update(event.properties.sessionID, (draft) => {
message.prepend(draft, {
id: event.properties.messageID,
type: "user",
text: event.properties.prompt.text,
@@ -184,8 +167,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
})
break
case "session.next.context.updated":
update(event.properties.sessionID, (draft) => {
prepend(draft, {
message.update(event.properties.sessionID, (draft) => {
message.prepend(draft, {
id: event.properties.messageID,
type: "system",
text: event.properties.text,
@@ -194,8 +177,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
})
break
case "session.next.synthetic":
update(event.properties.sessionID, (draft) => {
prepend(draft, {
message.update(event.properties.sessionID, (draft) => {
message.prepend(draft, {
id: event.properties.messageID,
type: "synthetic",
sessionID: event.properties.sessionID,
@@ -205,8 +188,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
})
break
case "session.next.shell.started":
update(event.properties.sessionID, (draft) => {
prepend(draft, {
message.update(event.properties.sessionID, (draft) => {
message.prepend(draft, {
id: event.properties.messageID,
type: "shell",
callID: event.properties.callID,
@@ -217,19 +200,19 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
})
break
case "session.next.shell.ended":
update(event.properties.sessionID, (draft) => {
const match = activeShell(draft, event.properties.callID)
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":
update(event.properties.sessionID, (draft) => {
message.update(event.properties.sessionID, (draft) => {
if (draft.some((message) => message.id === event.properties.assistantMessageID)) return
const currentAssistant = activeAssistant(draft)
const currentAssistant = message.activeAssistant(draft)
if (currentAssistant) currentAssistant.time.completed = event.properties.timestamp
prepend(draft, {
message.prepend(draft, {
id: event.properties.assistantMessageID,
type: "assistant",
agent: event.properties.agent,
@@ -241,8 +224,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
})
break
case "session.next.step.ended":
update(event.properties.sessionID, (draft) => {
const currentAssistant = ownedAssistant(draft, event.properties.assistantMessageID)
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
@@ -253,8 +236,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
})
break
case "session.next.step.failed":
update(event.properties.sessionID, (draft) => {
const currentAssistant = ownedAssistant(draft, event.properties.assistantMessageID)
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"
@@ -262,8 +245,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
})
break
case "session.next.text.started":
update(event.properties.sessionID, (draft) => {
ownedAssistant(draft, event.properties.assistantMessageID)?.content.push({
message.update(event.properties.sessionID, (draft) => {
message.assistant(draft, event.properties.assistantMessageID)?.content.push({
type: "text",
id: event.properties.textID,
text: "",
@@ -271,26 +254,26 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
})
break
case "session.next.text.delta":
update(event.properties.sessionID, (draft) => {
const match = latestText(
ownedAssistant(draft, event.properties.assistantMessageID),
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":
update(event.properties.sessionID, (draft) => {
const match = latestText(
ownedAssistant(draft, event.properties.assistantMessageID),
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":
update(event.properties.sessionID, (draft) => {
ownedAssistant(draft, event.properties.assistantMessageID)?.content.push({
message.update(event.properties.sessionID, (draft) => {
message.assistant(draft, event.properties.assistantMessageID)?.content.push({
type: "tool",
id: event.properties.callID,
name: event.properties.name,
@@ -300,27 +283,27 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
})
break
case "session.next.tool.input.delta":
update(event.properties.sessionID, (draft) => {
const match = latestTool(
ownedAssistant(draft, event.properties.assistantMessageID),
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":
update(event.properties.sessionID, (draft) => {
const match = latestTool(
ownedAssistant(draft, event.properties.assistantMessageID),
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":
update(event.properties.sessionID, (draft) => {
const match = latestTool(
ownedAssistant(draft, event.properties.assistantMessageID),
message.update(event.properties.sessionID, (draft) => {
const match = message.latestTool(
message.assistant(draft, event.properties.assistantMessageID),
event.properties.callID,
)
if (!match) return
@@ -330,9 +313,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
})
break
case "session.next.tool.progress":
update(event.properties.sessionID, (draft) => {
const match = latestTool(
ownedAssistant(draft, event.properties.assistantMessageID),
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
@@ -341,9 +324,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
})
break
case "session.next.tool.success":
update(event.properties.sessionID, (draft) => {
const match = latestTool(
ownedAssistant(draft, event.properties.assistantMessageID),
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
@@ -363,9 +346,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
})
break
case "session.next.tool.failed":
update(event.properties.sessionID, (draft) => {
const match = latestTool(
ownedAssistant(draft, event.properties.assistantMessageID),
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
@@ -386,8 +369,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
})
break
case "session.next.reasoning.started":
update(event.properties.sessionID, (draft) => {
ownedAssistant(draft, event.properties.assistantMessageID)?.content.push({
message.update(event.properties.sessionID, (draft) => {
message.assistant(draft, event.properties.assistantMessageID)?.content.push({
type: "reasoning",
id: event.properties.reasoningID,
text: "",
@@ -396,18 +379,18 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
})
break
case "session.next.reasoning.delta":
update(event.properties.sessionID, (draft) => {
const match = latestReasoning(
ownedAssistant(draft, event.properties.assistantMessageID),
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":
update(event.properties.sessionID, (draft) => {
const match = latestReasoning(
ownedAssistant(draft, event.properties.assistantMessageID),
message.update(event.properties.sessionID, (draft) => {
const match = message.latestReasoning(
message.assistant(draft, event.properties.assistantMessageID),
event.properties.reasoningID,
)
if (match) {
@@ -422,8 +405,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
case "session.next.compaction.delta":
break
case "session.next.compaction.ended":
update(event.properties.sessionID, (draft) => {
prepend(draft, {
message.update(event.properties.sessionID, (draft) => {
message.prepend(draft, {
id: event.properties.messageID,
type: "compaction",
reason: event.properties.reason,
@@ -433,33 +416,136 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
})
})
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: {
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: {
sync,
fromSession(sessionID: string) {
const messages = store.messages[sessionID]
if (!messages) return []
return messages
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)
},
},
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.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
},
})
+25 -18
View File
@@ -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()
+23 -21
View File
@@ -24,9 +24,7 @@ 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 { createSimpleContext } from "./helper"
import { useExit } from "./exit"
import { useArgs } from "./args"
import { batch, onMount } from "solid-js"
import path from "path"
@@ -57,7 +55,6 @@ export const {
} = createSimpleContext({
name: "Sync",
init: () => {
const startup = useTuiStartup()
const kv = useKV()
const [store, setStore] = createStore<{
status: "loading" | "partial" | "complete"
@@ -422,11 +419,9 @@ export const {
}
})
const exit = useExit()
const args = useArgs()
async function bootstrap(input: { fatal?: boolean } = {}) {
const fatal = input.fatal ?? true
async function bootstrap() {
const workspace = project.workspace.current()
const projectPromise = project.sync()
const sessionListPromise = projectPromise.then(() => listSessions())
@@ -440,14 +435,26 @@ export const {
.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,
agentsPromise,
configPromise,
projectPromise,
...(args.continue ? [sessionListPromise] : []),
])
const blockingRequests: { name: string; promise: Promise<unknown> }[] = [
{ name: "config.providers", promise: providersPromise },
{ name: "provider.list", promise: providerListPromise },
{ name: "app.agents", promise: agentsPromise },
{ name: "config.get", promise: configPromise },
{ name: "project.sync", promise: projectPromise },
...(args.continue ? [{ name: "session.list", promise: sessionListPromise }] : []),
]
await Promise.allSettled(blockingRequests.map((r) => r.promise))
.then((settled) => {
// Surface every failed endpoint in one labeled message instead of
// letting the first rejection drown its siblings as unhandled
// rejections.
const failures = blockingRequests.flatMap((request, index) => {
const result = settled[index]
return result?.status === "rejected" ? [`${request.name}: ${String(result.reason)}`] : []
})
if (failures.length) throw new Error(failures.join("\n"))
})
.then(async () => {
const providersResponse = providersPromise.then((x) => x.data!)
const providerListResponse = providerListPromise.then((x) => x.data!)
@@ -511,11 +518,7 @@ export const {
name: e instanceof Error ? e.name : undefined,
stack: e instanceof Error ? e.stack : undefined,
})
if (fatal) {
exit(e)
} else {
throw e
}
setStore("status", "partial")
})
}
@@ -530,8 +533,7 @@ export const {
return store.status
},
get ready() {
if (startup.skipInitialLoading) return true
return store.status !== "loading"
return true
},
get path() {
return project.instance.path()
+43 -4
View File
@@ -1,4 +1,5 @@
import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
import type { PluginRuntime } from "../plugin/runtime"
import HomeFooter from "./home/footer"
import HomeTips from "./home/tips"
import SidebarContext from "./sidebar/context"
@@ -10,8 +11,8 @@ 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"
import Scrap from "./system/scrap"
export type BuiltinTuiPlugin = Omit<TuiPluginModule, "id"> & {
id: string
@@ -19,7 +20,7 @@ export type BuiltinTuiPlugin = Omit<TuiPluginModule, "id"> & {
enabled?: boolean
}
export function createBuiltinPlugins(options: { experimentalEventSystem: boolean }): BuiltinTuiPlugin[] {
export function createBuiltinPlugins(): BuiltinTuiPlugin[] {
return [
HomeFooter,
HomeTips,
@@ -32,7 +33,45 @@ export function createBuiltinPlugins(options: { experimentalEventSystem: boolean
Notifications,
PluginManager,
WhichKey,
Scrap,
DiffViewer,
...(options.experimentalEventSystem ? [SessionV2Debug] : []),
]
}
export async function loadBuiltinPlugins(
api: TuiPluginApi,
runtime: PluginRuntime,
) {
const slots = runtime.setupSlots(api)
const dispose: Array<() => void> = []
for (const plugin of createBuiltinPlugins()) {
if (plugin.enabled === false) continue
const scoped = Object.assign(Object.create(api), {
slots: {
register(input: Parameters<typeof slots.register>[0]) {
dispose.push(slots.register({ ...input, id: plugin.id }))
return plugin.id
},
},
}) as TuiPluginApi
const now = Date.now()
await plugin.tui(scoped, undefined, {
id: plugin.id,
source: "internal",
spec: plugin.id,
target: plugin.id,
first_time: now,
last_time: now,
time_changed: now,
load_count: 1,
fingerprint: plugin.id,
state: "first",
})
}
return () => {
for (const fn of dispose.reverse()) fn()
slots.dispose()
}
}
@@ -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
})
@@ -1,1196 +0,0 @@
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import type { BuiltinTuiPlugin } from "../builtins"
import { useData } from "../../context/data"
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 = useData()
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 (
<box width={dimensions().width} height={dimensions().height} backgroundColor={theme.background}>
<box flexDirection="row">
<box flexGrow={1} paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1}>
<scrollbox
viewportOptions={{ paddingRight: 0 }}
verticalScrollbarOptions={{ visible: false }}
stickyScroll={true}
stickyStart="bottom"
flexGrow={1}
>
<box height={1} />
<Show when={messages().length === 0}>
<MissingData label="Messages" detail="No v2 messages loaded from useData yet." />
</Show>
<For each={renderedMessages()}>
{(message, index) => (
<Switch>
<Match when={message.type === "user"}>
<UserMessage message={message as SessionMessageUser} index={index()} />
</Match>
<Match when={message.type === "assistant"}>
<AssistantMessage
message={message as SessionMessageAssistant}
sessionID={props.sessionID}
last={lastAssistant()?.id === message.id}
syntax={syntax()}
subtleSyntax={subtleSyntax()}
start={lastUserCreated(index())}
/>
</Match>
<Match when={message.type === "synthetic"}>
<></>
</Match>
<Match when={message.type === "system"}>
<></>
</Match>
<Match when={message.type === "shell"}>
<ShellMessage message={message as SessionMessageShell} />
</Match>
<Match when={message.type === "compaction"}>
<CompactionMessage message={message as SessionMessageCompaction} />
</Match>
<Match when={message.type === "agent-switched"}>
<AgentSwitchedMessage message={message as SessionMessageAgentSwitched} />
</Match>
<Match when={message.type === "model-switched"}>
<ModelSwitchedMessage message={message as SessionMessageModelSwitched} />
</Match>
<Match when={true}>
<UnknownMessage message={message} />
</Match>
</Switch>
)}
</For>
</scrollbox>
<MissingData
label="Session prompt, permission prompt, question prompt, sidebar"
detail="The v2 message endpoint only exposes messages, so these session UI regions cannot be rendered here. Press Esc to return to the live session."
/>
</box>
</box>
</box>
)
}
function MissingData(props: { label: string; detail: string }) {
const { theme } = useTheme()
return (
<box
border={["left"]}
customBorderChars={SplitBorder.customBorderChars}
borderColor={theme.warning}
backgroundColor={theme.backgroundPanel}
paddingLeft={2}
paddingTop={1}
paddingBottom={1}
marginTop={1}
flexShrink={0}
>
<text fg={theme.text}>
<span style={{ bg: theme.warning, fg: theme.background, bold: true }}> MISSING DATA </span> {props.label}
</text>
<text fg={theme.textMuted}>{props.detail}</text>
</box>
)
}
function UserMessage(props: { message: SessionMessageUser; index: number }) {
const { theme } = useTheme()
const attachments = createMemo(() => [...(props.message.files ?? []), ...(props.message.agents ?? [])])
return (
<box
id={props.message.id}
border={["left"]}
borderColor={theme.secondary}
customBorderChars={SplitBorder.customBorderChars}
marginTop={props.index === 0 ? 0 : 1}
flexShrink={0}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
backgroundColor={theme.backgroundPanel}
>
<text fg={theme.text}>{props.message.text}</text>
<Show when={attachments().length}>
<box flexDirection="row" paddingTop={1} gap={1} flexWrap="wrap">
<For each={props.message.files ?? []}>
{(file) => (
<text fg={theme.text}>
<span style={{ bg: theme.secondary, fg: theme.background }}> {file.mime} </span>
<span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> {file.name ?? file.uri} </span>
</text>
)}
</For>
<For each={props.message.agents ?? []}>
{(agent) => (
<text fg={theme.text}>
<span style={{ bg: theme.accent, fg: theme.background }}> agent </span>
<span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> {agent.name} </span>
</text>
)}
</For>
</box>
</Show>
</box>
)
}
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 (
<BlockTool
title="# Shell"
spinner={!props.message.time.completed}
onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}
>
<box gap={1}>
<text fg={theme.text}>$ {props.message.command}</text>
<Show when={output()}>
<text fg={theme.text}>{limited()}</text>
</Show>
<Show when={collapsed().overflow}>
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
</Show>
</box>
</BlockTool>
)
}
function CompactionMessage(props: { message: SessionMessageCompaction }) {
const { theme } = useTheme()
return (
<box
marginTop={1}
border={["top"]}
title={props.message.reason === "auto" ? " Auto Compaction " : " Compaction "}
titleAlignment="center"
borderColor={theme.borderActive}
flexShrink={0}
/>
)
}
function AgentSwitchedMessage(props: { message: SessionMessageAgentSwitched }) {
const { theme } = useTheme()
const local = useLocal()
return (
<box paddingLeft={3} marginTop={1} flexShrink={0}>
<text>
<span style={{ fg: local.agent.color(props.message.agent) }}> </span>
<span style={{ fg: theme.textMuted }}>Switched agent to </span>
<span style={{ fg: theme.text }}>{Locale.titlecase(props.message.agent)}</span>
</text>
</box>
)
}
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 (
<box paddingLeft={3} marginTop={1} flexShrink={0}>
<text>
<span style={{ fg: theme.secondary }}> </span>
<span style={{ fg: theme.textMuted }}>Switched model to </span>
<span style={{ fg: theme.text }}>{model()}</span>
</text>
</box>
)
}
function UnknownMessage(props: { message: SessionMessage }) {
return <MissingData label="Unknown message type" detail={JSON.stringify(props.message)} />
}
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 (
<>
<For each={props.message.content}>
{(part) => (
<Switch>
<Match when={part.type === "text"}>
<AssistantText part={part as SessionMessageAssistantText} syntax={props.syntax} />
</Match>
<Match when={part.type === "reasoning"}>
<AssistantReasoning
part={part as SessionMessageAssistantReasoning}
subtleSyntax={props.subtleSyntax}
completedAt={() => props.message.time.completed}
/>
</Match>
<Match when={part.type === "tool"}>
<AssistantTool part={part as SessionMessageAssistantTool} sessionID={props.sessionID} />
</Match>
</Switch>
)}
</For>
<Show when={props.message.content.length === 0}>
<MissingData label="Assistant content" detail={`Assistant message ${props.message.id} has no content items.`} />
</Show>
<Show when={props.message.error}>
<box
border={["left"]}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
marginTop={1}
backgroundColor={theme.backgroundPanel}
customBorderChars={SplitBorder.customBorderChars}
borderColor={theme.error}
flexShrink={0}
>
<text fg={theme.textMuted}>{props.message.error}</text>
</box>
</Show>
<Show when={props.last || final() || props.message.error}>
<box paddingLeft={3} flexShrink={0}>
<text marginTop={1}>
<span style={{ fg: local.agent.color(props.message.agent) }}> </span>
<span style={{ fg: theme.text }}>{Locale.titlecase(props.message.agent)}</span>
<span style={{ fg: theme.textMuted }}> · {model()}</span>
<Show when={duration()}>
<span style={{ fg: theme.textMuted }}> · {Locale.duration(duration())}</span>
</Show>
</text>
</box>
</Show>
</>
)
}
function AssistantText(props: { part: SessionMessageAssistantText; syntax: SyntaxStyle }) {
const { theme } = useTheme()
return (
<Show when={props.part.text.trim()}>
<box paddingLeft={3} marginTop={1} flexShrink={0} id={`text-${props.part.id}`}>
<code
filetype="markdown"
drawUnstyledText={false}
streaming={true}
syntaxStyle={props.syntax}
content={props.part.text.trim()}
conceal={true}
fg={theme.text}
/>
</box>
</Show>
)
}
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 (
<Show when={content()}>
<box paddingLeft={3} marginTop={1} flexDirection="column" flexShrink={0}>
<box onMouseUp={toggle}>
<ReasoningHeader
toggleable={inMinimal()}
open={!inMinimal() || expanded()}
done={isDone()}
title={summary().title}
/>
</box>
<Show when={(!inMinimal() || expanded()) && summary().body}>
<box paddingLeft={inMinimal() ? 2 : 0} marginTop={1}>
<code
filetype="markdown"
drawUnstyledText={false}
streaming={true}
syntaxStyle={props.subtleSyntax}
content={summary().body}
conceal={true}
fg={theme.textMuted}
/>
</box>
</Show>
</box>
</Show>
)
}
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 (
<Switch>
<Match when={!props.done}>
<box flexDirection="row">
<Spinner color={fg()}>{props.title ? "Thinking: " + props.title : "Thinking"}</Spinner>
</box>
</Match>
<Match when={true}>
<text fg={fg()} wrapMode="none">
<Show when={props.toggleable}>
<span>{props.open ? "- " : "+ "}</span>
</Show>
<span>Thought</span>
<Show when={props.title}>
<span>: </span>
<span>{props.title}</span>
</Show>
</text>
</Match>
</Switch>
)
}
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 (
<Switch>
<Match when={props.part.name === "bash"}>
<Bash {...toolprops} />
</Match>
<Match when={props.part.name === "glob"}>
<Glob {...toolprops} />
</Match>
<Match when={props.part.name === "read"}>
<Read {...toolprops} />
</Match>
<Match when={props.part.name === "grep"}>
<Grep {...toolprops} />
</Match>
<Match when={props.part.name === "webfetch"}>
<WebFetch {...toolprops} />
</Match>
<Match when={props.part.name === "websearch"}>
<WebSearch {...toolprops} />
</Match>
<Match when={props.part.name === "write"}>
<Write {...toolprops} />
</Match>
<Match when={props.part.name === "edit"}>
<Edit {...toolprops} />
</Match>
<Match when={props.part.name === "apply_patch"}>
<ApplyPatch {...toolprops} />
</Match>
<Match when={props.part.name === "todowrite"}>
<TodoWrite {...toolprops} />
</Match>
<Match when={props.part.name === "question"}>
<Question {...toolprops} />
</Match>
<Match when={props.part.name === "skill"}>
<Skill {...toolprops} />
</Match>
<Match when={props.part.name === "task"}>
<Task {...toolprops} />
</Match>
<Match when={true}>
<GenericTool {...toolprops} />
</Match>
</Switch>
)
}
type ToolProps = {
input: Record<string, unknown>
metadata: Record<string, unknown>
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 (
<Show
when={output()}
fallback={
<InlineTool icon="⚙" pending="Writing command..." complete={toolComplete(props.part)} part={props.part}>
{props.part.name} {input(props.input)}
</InlineTool>
}
>
<BlockTool
title={`# ${props.part.name} ${input(props.input)}`}
part={props.part}
onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}
>
<box gap={1}>
<text fg={theme.text}>{limited()}</text>
<Show when={collapsed().overflow}>
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
</Show>
</box>
</BlockTool>
</Show>
)
}
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 (
<box
paddingLeft={3}
flexShrink={0}
flexDirection="row"
gap={1}
backgroundColor={hover() && error() ? theme.backgroundMenu : undefined}
onMouseOver={() => 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))
}}
>
<box flexShrink={0}>
<Switch>
<Match when={props.spinner}>
<Spinner color={theme.text} />
</Match>
<Match when={complete()}>
<text fg={fg()} attributes={attributes()}>
{props.icon}
</text>
</Match>
<Match when={true}>
<text fg={fg()} attributes={attributes()}>
~
</text>
</Match>
</Switch>
</box>
<box flexGrow={1}>
<box>
<Switch>
<Match when={complete()}>
<text fg={fg()} attributes={attributes()}>
{props.children}
</text>
</Match>
<Match when={true}>
<text fg={fg()} attributes={attributes()}>
{props.pending}
</text>
</Match>
</Switch>
</box>
<Show when={showError() && error()}>
<box>
<text fg={theme.error}>{error()}</text>
</box>
</Show>
</box>
</box>
)
}
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 (
<box
border={["left"]}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
marginTop={1}
gap={1}
backgroundColor={hover() ? theme.backgroundMenu : theme.backgroundPanel}
customBorderChars={SplitBorder.customBorderChars}
borderColor={theme.background}
onMouseOver={() => props.onClick && setHover(true)}
onMouseOut={() => setHover(false)}
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
props.onClick?.()
}}
flexShrink={0}
>
<Show
when={props.spinner}
fallback={
<text paddingLeft={3} fg={theme.textMuted}>
{props.title}
</text>
}
>
<Spinner color={theme.textMuted}>{props.title.replace(/^# /, "")}</Spinner>
</Show>
{props.children}
<Show when={error()}>
<text fg={theme.error}>{error()}</text>
</Show>
</box>
)
}
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 (
<Switch>
<Match when={output()}>
<BlockTool
title={title()}
part={props.part}
spinner={props.part.state.status === "running"}
onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}
>
<box gap={1}>
<text fg={theme.text}>$ {command()}</text>
<text fg={theme.text}>{limited()}</text>
<Show when={collapsed().overflow}>
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
</Show>
</box>
</BlockTool>
</Match>
<Match when={true}>
<InlineTool icon="$" pending="Writing command..." complete={command()} part={props.part}>
{command()}
</InlineTool>
</Match>
</Switch>
)
}
function Glob(props: ToolProps) {
const normalizePath = usePathNormalizer()
return (
<InlineTool icon="✱" pending="Finding files..." complete={toolComplete(props.part)} part={props.part}>
Glob "{stringValue(props.input.pattern) ?? pendingInput(props.part)}"{" "}
<Show when={stringValue(props.input.path)}>in {normalizePath(stringValue(props.input.path))} </Show>
<Show when={numberValue(props.metadata.count)}>
{(count) => (
<>
({count()} {count() === 1 ? "match" : "matches"})
</>
)}
</Show>
</InlineTool>
)
}
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 (
<>
<InlineTool
icon="→"
pending="Reading file..."
complete={stringValue(props.input.filePath) ?? pendingInput(props.part)}
spinner={props.part.state.status === "running"}
part={props.part}
>
Read {normalizePath(stringValue(props.input.filePath) ?? pendingInput(props.part))}{" "}
{input(props.input, ["filePath"])}
</InlineTool>
<For each={loaded()}>
{(filepath) => (
<box paddingLeft={3} flexShrink={0}>
<text paddingLeft={3} fg={theme.textMuted}>
Loaded {normalizePath(filepath)}
</text>
</box>
)}
</For>
</>
)
}
function Grep(props: ToolProps) {
const normalizePath = usePathNormalizer()
return (
<InlineTool icon="✱" pending="Searching content..." complete={toolComplete(props.part)} part={props.part}>
Grep "{stringValue(props.input.pattern) ?? pendingInput(props.part)}"{" "}
<Show when={stringValue(props.input.path)}>in {normalizePath(stringValue(props.input.path))} </Show>
<Show when={numberValue(props.metadata.matches)}>
{(matches) => (
<>
({matches()} {matches() === 1 ? "match" : "matches"})
</>
)}
</Show>
</InlineTool>
)
}
function WebFetch(props: ToolProps) {
return (
<InlineTool icon="%" pending="Fetching from the web..." complete={toolComplete(props.part)} part={props.part}>
WebFetch {stringValue(props.input.url) ?? pendingInput(props.part)}
</InlineTool>
)
}
function WebSearch(props: ToolProps) {
const label = createMemo(() => webSearchProviderLabel(props.metadata.provider))
return (
<InlineTool icon="◈" pending="Searching web..." complete={toolComplete(props.part)} part={props.part}>
{label()} "{stringValue(props.input.query) ?? pendingInput(props.part)}"{" "}
<Show when={numberValue(props.metadata.numResults)}>{(results) => <>({results()} results)</>}</Show>
</InlineTool>
)
}
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 (
<Switch>
<Match when={content() && props.part.state.status === "completed"}>
<BlockTool title={"# Wrote " + normalizePath(filePath())} part={props.part}>
<line_number fg={theme.textMuted} minWidth={3} paddingRight={1}>
<code
conceal={false}
fg={theme.text}
filetype={filetype(filePath())}
syntaxStyle={syntax()}
content={content()}
/>
</line_number>
<Diagnostics diagnostics={props.metadata.diagnostics} filePath={filePath()} />
</BlockTool>
</Match>
<Match when={true}>
<InlineTool icon="←" pending="Preparing write..." complete={filePath()} part={props.part}>
Write {normalizePath(filePath())}
</InlineTool>
</Match>
</Switch>
)
}
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 (
<Switch>
<Match when={diff()}>
{(diff) => (
<BlockTool title={"← Edit " + normalizePath(filePath())} part={props.part}>
<box paddingLeft={1}>
<diff
diff={diff()}
view={dimensions().width > 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}
/>
</box>
<Diagnostics diagnostics={props.metadata.diagnostics} filePath={filePath()} />
</BlockTool>
)}
</Match>
<Match when={true}>
<InlineTool icon="←" pending="Preparing edit..." complete={filePath()} part={props.part}>
Edit {normalizePath(filePath())} {input({ replaceAll: props.input.replaceAll })}
</InlineTool>
</Match>
</Switch>
)
}
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<string, unknown>) => {
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 (
<Switch>
<Match when={files().length > 0}>
<For each={files()}>
{(file) => (
<BlockTool title={fileTitle(file)} part={props.part}>
<Show
when={stringValue(file.patch)}
fallback={
<text fg={theme.diffRemoved}>
-{numberValue(file.deletions) ?? 0} line{numberValue(file.deletions) === 1 ? "" : "s"}
</text>
}
>
{(patch) => (
<box paddingLeft={1}>
<diff
diff={patch()}
view={dimensions().width > 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}
/>
</box>
)}
</Show>
</BlockTool>
)}
</For>
</Match>
<Match when={true}>
<InlineTool icon="%" pending="Preparing patch..." complete={false} part={props.part}>
Patch
</InlineTool>
</Match>
</Switch>
)
}
function TodoWrite(props: ToolProps) {
const { theme } = useTheme()
const todos = createMemo(() => arrayValue(props.input.todos).flatMap((item) => (isRecord(item) ? [item] : [])))
return (
<Switch>
<Match when={todos().length > 0 && props.part.state.status === "completed"}>
<BlockTool title="# Todos" part={props.part}>
<box>
<For each={todos()}>
{(todo) => (
<text fg={theme.text}>
{todoIcon(stringValue(todo.status))} {stringValue(todo.content)}
</text>
)}
</For>
</box>
</BlockTool>
</Match>
<Match when={true}>
<InlineTool icon="⚙" pending="Updating todos..." complete={false} part={props.part}>
Updating todos...
</InlineTool>
</Match>
</Switch>
)
}
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 (
<Switch>
<Match when={answers().length > 0}>
<BlockTool title="# Questions" part={props.part}>
<box gap={1}>
<For each={questions()}>
{(question, index) => (
<box>
<text fg={theme.textMuted}>{stringValue(question.question)}</text>
<text fg={theme.text}>{formatAnswer(answers()[index()])}</text>
</box>
)}
</For>
</box>
</BlockTool>
</Match>
<Match when={true}>
<InlineTool icon="→" pending="Asking questions..." complete={questions().length} part={props.part}>
Asked {questions().length} question{questions().length === 1 ? "" : "s"}
</InlineTool>
</Match>
</Switch>
)
}
function Skill(props: ToolProps) {
return (
<InlineTool icon="→" pending="Loading skill..." complete={toolComplete(props.part)} part={props.part}>
Skill "{stringValue(props.input.name) ?? pendingInput(props.part)}"
</InlineTool>
)
}
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 (
<InlineTool
icon="│"
spinner={props.part.state.status === "running"}
complete={toolComplete(props.part)}
pending="Delegating..."
part={props.part}
>
{content()}
</InlineTool>
)
}
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 (
<Show when={errors().length}>
<box>
<For each={errors()}>
{(diagnostic) => <text fg={theme.error}>Error {stringValue(diagnostic.message)}</text>}
</For>
</box>
</Show>
)
}
function toolOutput(content?: Array<ToolTextContent | ToolFileContent>) {
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<string, unknown>) {
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<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value)
}
function input(input: Record<string, unknown>, 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 <text fg={api.theme.current.error}>Missing sessionID</text>
}
return <View api={api} sessionID={sessionID} />
},
},
])
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
@@ -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<Context>()
export function HomeSessionDestinationProvider(props: ParentProps) {
const sync = useSync()
const paths = useTuiPaths()
const data = useData()
const [selected, setDestination] = createSignal<HomeSessionDestination>()
const destination = createMemo<HomeSessionDestination>(
() => selected() ?? { type: "directory", directory: sync.path.directory || paths.cwd, subdirectory: false },
() => selected() ?? { type: "directory", directory: data.location.default().directory, subdirectory: false },
)
return (
<HomeSessionDestinationContext.Provider
+1 -3
View File
@@ -298,9 +298,7 @@ export function Session() {
// workspace may not exist anymore which is why this is not
// fatal. If it doesn't we still want to show the session
// (which will be non-interactive)
try {
await sync.bootstrap({ fatal: false })
} catch {}
await sync.bootstrap()
}
editor.reconnect(result.data.directory)
await sync.session.sync(sessionID)
+60 -292
View File
@@ -21,31 +21,46 @@ function global(payload: Event): GlobalEvent {
return { directory, project: "proj_test", payload }
}
function emitTwice(events: ReturnType<typeof createEventSource>, payload: Event) {
const event = global(payload)
events.emit(event)
events.emit(event)
function emitEvent(events: ReturnType<typeof createEventSource>, payload: Event) {
events.emit(global(payload))
}
test("sync v2 refreshes references after updates", async () => {
const events = createEventSource()
let requests = 0
test("refreshes resources into reactive getters", async () => {
const location = {
directory,
project: { id: "proj_test", directory },
}
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" } }],
})
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
})
let sync!: ReturnType<typeof useData>
const events = createEventSource()
let data!: ReturnType<typeof useData>
let ready!: () => void
const mounted = new Promise<void>((resolve) => {
ready = resolve
})
function Probe() {
sync = useData()
data = useData()
onMount(ready)
return <box />
}
@@ -64,10 +79,16 @@ test("sync v2 refreshes references after updates", async () => {
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")
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()
}
@@ -102,12 +123,12 @@ test("sync v2 settles pending tools when a live failure arrives", async () => {
try {
await mounted
emitTwice(events, {
emitEvent(events, {
id: "evt_agent_1",
type: "session.next.agent.switched",
properties: { sessionID: "session-1", messageID: "msg_agent_1", timestamp: 0, agent: "build" },
})
emitTwice(events, {
emitEvent(events, {
id: "evt_model_1",
type: "session.next.model.switched",
properties: {
@@ -117,7 +138,7 @@ test("sync v2 settles pending tools when a live failure arrives", async () => {
model: { id: "model-1", providerID: "provider-1" },
},
})
emitTwice(events, {
emitEvent(events, {
id: "evt_step_started_1",
type: "session.next.step.started",
properties: {
@@ -128,7 +149,7 @@ test("sync v2 settles pending tools when a live failure arrives", async () => {
model: { id: "model-1", providerID: "provider-1" },
},
})
emitTwice(events, {
emitEvent(events, {
id: "evt_input_1",
type: "session.next.tool.input.started",
properties: {
@@ -139,7 +160,7 @@ test("sync v2 settles pending tools when a live failure arrives", async () => {
name: "bash",
},
})
emitTwice(events, {
emitEvent(events, {
id: "evt_called_1",
type: "session.next.tool.called",
properties: {
@@ -152,7 +173,7 @@ test("sync v2 settles pending tools when a live failure arrives", async () => {
provider: { executed: false, metadata: { fake: { call: true } } },
},
})
emitTwice(events, {
emitEvent(events, {
id: "evt_failed_1",
type: "session.next.tool.failed",
properties: {
@@ -166,7 +187,7 @@ test("sync v2 settles pending tools when a live failure arrives", async () => {
})
await wait(() => {
const assistant = sync.session.message.fromSession("session-1")[0]
const assistant = sync.session.message.list("session-1")?.[0]
return (
assistant?.type === "assistant" &&
assistant.content[0]?.type === "tool" &&
@@ -174,7 +195,7 @@ test("sync v2 settles pending tools when a live failure arrives", async () => {
)
})
const assistant = sync.session.message.fromSession("session-1")[0]
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")
@@ -192,7 +213,7 @@ test("sync v2 settles pending tools when a live failure arrives", async () => {
metadata: { fake: { call: true } },
resultMetadata: { fake: { result: true } },
})
expect(sync.session.message.fromSession("session-1").map((message) => message.type)).toEqual([
expect((sync.session.message.list("session-1") ?? []).map((message) => message.type)).toEqual([
"assistant",
"model-switched",
"agent-switched",
@@ -231,7 +252,7 @@ test("sync v2 renders admitted prompts only after promotion", async () => {
try {
await mounted
emitTwice(events, {
emitEvent(events, {
id: "evt_admitted_1",
type: "session.next.prompt.admitted",
properties: {
@@ -242,9 +263,9 @@ test("sync v2 renders admitted prompts only after promotion", async () => {
delivery: "steer",
},
})
expect(sync.session.message.fromSession("session-1")).toEqual([])
expect(sync.session.message.list("session-1") ?? []).toEqual([])
emitTwice(events, {
emitEvent(events, {
id: "evt_promoted_1",
type: "session.next.prompt.promoted",
properties: {
@@ -256,8 +277,8 @@ test("sync v2 renders admitted prompts only after promotion", async () => {
},
})
await wait(() => sync.session.message.fromSession("session-1").length === 1)
const message = sync.session.message.fromSession("session-1")[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" })
@@ -295,7 +316,7 @@ test("sync v2 renders a promoted prompt when admission was missed", async () =>
try {
await mounted
emitTwice(events, {
emitEvent(events, {
id: "evt_promoted_1",
type: "session.next.prompt.promoted",
properties: {
@@ -307,8 +328,8 @@ test("sync v2 renders a promoted prompt when admission was missed", async () =>
},
})
await wait(() => sync.session.message.fromSession("session-1").length === 1)
expect(sync.session.message.fromSession("session-1")[0]?.id).toBe("msg_user_1")
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()
}
@@ -343,7 +364,7 @@ test("sync v2 projects live context updates with their message ID", async () =>
try {
await mounted
emitTwice(events, {
emitEvent(events, {
id: "evt_context_1",
type: "session.next.context.updated",
properties: {
@@ -354,8 +375,8 @@ test("sync v2 projects live context updates with their message ID", async () =>
},
})
await wait(() => sync.session.message.fromSession("session-1").length === 1)
expect(sync.session.message.fromSession("session-1")[0]).toMatchObject({
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",
@@ -364,256 +385,3 @@ test("sync v2 projects live context updates with their message ID", async () =>
app.renderer.destroy()
}
})
test("sync v2 preserves live events while snapshot hydration is in flight", async () => {
const events = createEventSource()
const response = Promise.withResolvers<Response>()
const calls = createFetch((url) => {
if (url.pathname === "/api/session/session-1/message") return response.promise
return undefined
})
let sync!: ReturnType<typeof useData>
let ready!: () => void
const mounted = new Promise<void>((resolve) => {
ready = resolve
})
function Probe() {
sync = useData()
onMount(ready)
return <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</SDKProvider>
</TestTuiContexts>
))
try {
await 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<Response>()
const calls = createFetch((url) => {
if (url.pathname === "/api/session/session-1/message") return response.promise
return undefined
})
let sync!: ReturnType<typeof useData>
let ready!: () => void
const mounted = new Promise<void>((resolve) => {
ready = resolve
})
function Probe() {
sync = useData()
onMount(ready)
return <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</SDKProvider>
</TestTuiContexts>
))
try {
await 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<Response>()
const calls = createFetch((url) => {
if (url.pathname === "/api/session/session-1/message") return response.promise
return undefined
})
let sync!: ReturnType<typeof useData>
let ready!: () => void
const mounted = new Promise<void>((resolve) => {
ready = resolve
})
function Probe() {
sync = useData()
onMount(ready)
return <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</SDKProvider>
</TestTuiContexts>
))
try {
await 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()
}
})
+7
View File
@@ -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: [] })