feat(cli): add standalone v2 session flow

This commit is contained in:
Dax Raad
2026-06-10 22:40:26 -04:00
parent efe6ded6b1
commit b16aa4439d
19 changed files with 469 additions and 149 deletions
+7
View File
@@ -5,6 +5,12 @@ declare const OPENCODE_CLI_NAME: string | undefined
export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME : "opencode", {
description: "OpenCode 2.0 preview command line interface",
params: {
standalone: Flag.boolean("standalone").pipe(
Flag.withDescription("Run with a private server instead of the background service"),
Flag.withDefault(false),
),
},
commands: [
Spec.make("debug", {
description: "Debugging and troubleshooting tools",
@@ -30,6 +36,7 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
hostname: Flag.string("hostname").pipe(Flag.withDefault("127.0.0.1")),
port: Flag.integer("port").pipe(Flag.optional),
register: Flag.boolean("register").pipe(Flag.withDefault(false)),
stdio: Flag.boolean("stdio").pipe(Flag.withDefault(false)),
},
}),
],
@@ -2,11 +2,12 @@ import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { Effect } from "effect"
import { Daemon } from "../../services/daemon"
import { Standalone } from "../../services/standalone"
export default Runtime.handler(Commands, () =>
export default Runtime.handler(Commands, (input) =>
Effect.gen(function* () {
const daemon = yield* Daemon.Service
const transport = yield* daemon.transport()
const transport = yield* (input.standalone ? Standalone.transport() : daemon.transport())
const { runTui } = yield* Effect.promise(() => import("../../tui"))
yield* runTui(transport)
}),
+6 -2
View File
@@ -17,7 +17,10 @@ export default Runtime.handler(
return yield* Effect.scoped(
Effect.gen(function* () {
const daemon = yield* Daemon.Service
const password = yield* daemon.password()
const standalonePassword = process.env.OPENCODE_SERVER_PASSWORD
if (input.stdio) delete process.env.OPENCODE_SERVER_PASSWORD
const password = input.stdio ? standalonePassword : yield* daemon.password()
if (!password) return yield* Effect.fail(new Error("Missing server password"))
const address = yield* listen(input.hostname, input.port, password)
yield* Effect.tryPromise(() =>
createOpencodeClient({
@@ -26,7 +29,8 @@ export default Runtime.handler(
}).v2.location.get(undefined, { throwOnError: true }),
)
if (input.register) yield* daemon.register(address)
console.log(`server listening on ${HttpServer.formatAddress(address)}`)
const url = HttpServer.formatAddress(address)
console.log(input.stdio ? JSON.stringify({ url }) : `server listening on ${url}`)
return yield* Effect.never
}),
)
+4 -3
View File
@@ -2,6 +2,7 @@ import * as Effect from "effect/Effect"
import * as Command from "effect/unstable/cli/Command"
import { Spec } from "./spec"
import { Daemon } from "../services/daemon"
import { Scope } from "effect"
export type Input<Value> =
Value extends Spec.Node<infer _Name, infer Command, infer _Commands>
@@ -10,11 +11,11 @@ export type Input<Value> =
? Input
: never
type RuntimeHandler = (input: unknown) => Effect.Effect<void, unknown, Daemon.Service>
type RuntimeHandler = (input: unknown) => Effect.Effect<void, unknown, Daemon.Service | Scope.Scope>
type Loader<Node extends Spec.Any> = () => Promise<{
default: (input: Input<Node>) => Effect.Effect<void, any, Daemon.Service>
default: (input: Input<Node>) => Effect.Effect<void, any, Daemon.Service | Scope.Scope>
}>
type ProvidedCommand = Command.Command<string, unknown, unknown, unknown, Daemon.Service>
type ProvidedCommand = Command.Command<string, unknown, unknown, unknown, Daemon.Service | Scope.Scope>
export type Handlers<Node extends Spec.Any> = keyof Node["commands"] extends never
? Loader<Node>
+11
View File
@@ -2,10 +2,19 @@
import * as NodeRuntime from "@effect/platform-node/NodeRuntime"
import * as NodeServices from "@effect/platform-node/NodeServices"
import { NodeFileSystem } from "@effect/platform-node"
import * as Effect from "effect/Effect"
import { Layer, Logger, References } from "effect"
import { Commands } from "./commands/commands"
import { Runtime } from "./framework/runtime"
import { Daemon } from "./services/daemon"
import { Logging } from "@opencode-ai/core/observability/logging"
const LoggingLayer = Logger.layer(Logging.loggers(), { mergeWithExisting: false }).pipe(
Layer.provide(NodeFileSystem.layer),
Layer.orDie,
Layer.merge(Layer.succeed(References.MinimumLogLevel, Logging.minimumLogLevel())),
)
const Handlers = Runtime.handlers(Commands, {
$: () => import("./commands/handlers/default"),
@@ -25,7 +34,9 @@ const Handlers = Runtime.handlers(Commands, {
Runtime.run(Commands, Handlers, { version: "local" }).pipe(
Effect.provide(Daemon.defaultLayer),
Effect.provide(LoggingLayer),
Effect.provide(NodeServices.layer),
Effect.scoped,
Effect.tap(() => Effect.sync(() => process.exit(0))),
NodeRuntime.runMain,
)
+22 -10
View File
@@ -28,6 +28,10 @@ const Registration = Schema.Struct({
})
type Registration = typeof Registration.Type
const Config = Schema.Struct({
password: Schema.optional(Schema.String),
})
function sameRegistration(left: Registration, right: Registration) {
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
}
@@ -38,21 +42,29 @@ export const layer = Layer.effect(
const fs = yield* FileSystem.FileSystem
const directory = Global.Path.state
const file = path.join(directory, "server.json")
const passwordFile = path.join(directory, "password")
const configFile = path.join(Global.Path.config, "service.json")
const legacyPasswordFile = path.join(directory, "password")
const decodeRegistration = Schema.decodeUnknownEffect(Schema.fromJsonString(Registration))
const decodeConfig = Schema.decodeUnknownEffect(Schema.fromJsonString(Config))
const password = Effect.fn("cli.daemon.password")(function* (value?: string) {
const existing = yield* fs.readFileString(passwordFile).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (value === undefined && existing) return existing
const config = yield* fs
.readFileString(configFile)
.pipe(Effect.flatMap(decodeConfig), Effect.catch(() => Effect.succeed(undefined)))
if (value === undefined && config?.password) return config.password
const legacy = yield* fs
.readFileString(legacyPasswordFile)
.pipe(Effect.catch(() => Effect.succeed(undefined)))
const next = value ?? legacy ?? randomBytes(32).toString("base64url")
// Keep one private credential across server restarts so discovered clients
// can reconnect without exposing a password flag or environment variable.
const generated = value ?? randomBytes(32).toString("base64url")
const temp = passwordFile + ".tmp"
yield* fs.makeDirectory(directory, { recursive: true })
yield* fs.writeFileString(temp, generated, { mode: 0o600 })
yield* fs.rename(temp, passwordFile)
return generated
const temp = configFile + ".tmp"
yield* fs.writeFileString(temp, JSON.stringify({ password: next }, null, 2) + "\n", { mode: 0o600 })
yield* fs.rename(temp, configFile)
if (legacy) yield* fs.remove(legacyPasswordFile).pipe(Effect.ignore)
return next
})
const registration = Effect.fnUntraced(function* () {
@@ -111,7 +123,7 @@ export const layer = Layer.effect(
const existing = yield* healthy().pipe(Effect.option)
const found = Option.getOrUndefined(existing)
const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun"
if (found?.version === InstallationVersion && compiled) return found.url
if (found?.version === InstallationVersion) return found.url
if (found) yield* stopProcess(found).pipe(Effect.ignore)
const entrypoint = compiled ? undefined : process.argv[1]
+38
View File
@@ -0,0 +1,38 @@
import { ServerAuth } from "@opencode-ai/server/auth"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Effect, Schema, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { randomBytes } from "node:crypto"
import path from "node:path"
const Ready = Schema.Struct({ url: Schema.String })
const decodeReady = Schema.decodeUnknownPromise(Schema.fromJsonString(Ready))
function command(password: string) {
const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun"
const entrypoint = compiled ? [] : process.argv[1] ? [process.argv[1]] : []
if (!compiled && entrypoint.length === 0) throw new Error("Failed to resolve CLI entrypoint")
return ChildProcess.make(process.execPath, [...entrypoint, "serve", "--stdio", "--port", "0"], {
cwd: process.cwd(),
env: { OPENCODE_SERVER_PASSWORD: password },
extendEnv: true,
stdin: "ignore",
stderr: "ignore",
killSignal: "SIGKILL",
})
}
export const transport = Effect.fn("cli.standalone.transport")(
function* () {
const password = randomBytes(32).toString("base64url")
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const proc = yield* spawner.spawn(command(password))
const output = yield* proc.stdout.pipe(Stream.decodeText(), Stream.splitLines, Stream.take(1), Stream.mkString)
if (!output) return yield* Effect.fail(new Error("Standalone server exited before reporting readiness"))
const ready = yield* Effect.tryPromise(() => decodeReady(output))
return { url: ready.url, headers: ServerAuth.headers({ password }) }
},
Effect.provide(CrossSpawnSpawner.defaultLayer),
)
export * as Standalone from "./standalone"
+3
View File
@@ -8,12 +8,15 @@ import { AbsolutePath, RelativePath } from "../schema"
import { WorkspaceV2 } from "../workspace"
import { SessionSchema } from "./schema"
import { SessionTable } from "./sql"
import { SessionMessageID } from "./message-id"
export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.Info {
return SessionSchema.Info.make({
id: SessionSchema.ID.make(row.id),
projectID: ProjectV2.ID.make(row.project_id),
title: row.title,
share: row.share_url ? { url: row.share_url } : undefined,
revert: row.revert ? { messageID: SessionMessageID.ID.make(row.revert.messageID) } : undefined,
parentID: row.parent_id ? SessionSchema.ID.make(row.parent_id) : undefined,
agent: row.agent ? AgentV2.ID.make(row.agent) : undefined,
model: row.model
+3
View File
@@ -8,6 +8,7 @@ import { externalID, type ExternalID, RelativePath, optionalOmitUndefined, withS
import { Identifier } from "../util/identifier"
import { V2Schema } from "../v2-schema"
import { AgentV2 } from "../agent"
import { SessionMessageID } from "./message-id"
export const ID = Schema.String.check(Schema.isStartsWith("ses")).pipe(
Schema.brand("SessionID"),
@@ -44,6 +45,8 @@ export class Info extends Schema.Class<Info>("SessionV2.Info")({
archived: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional),
}),
title: Schema.String,
share: Schema.Struct({ url: Schema.String }).pipe(optionalOmitUndefined),
revert: Schema.Struct({ messageID: SessionMessageID.ID }).pipe(optionalOmitUndefined),
location: Location.Ref,
subpath: RelativePath.pipe(Schema.optional),
}) {}
@@ -816,6 +816,14 @@ const scenarios: Scenario[] = [
headers: ctx.headers(),
}))
.status(400, undefined, "none"),
http.protected
.post("/api/session", "v2.session.create")
.at((ctx) => ({
path: "/api/session",
headers: { ...ctx.headers(), "content-type": "application/json" },
body: {},
}))
.json(200, data(object)),
http.protected
.get("/api/session/{sessionID}", "v2.session.get")
.seeded((ctx) => ctx.session({ title: "Session get" }))
+46
View File
@@ -92,6 +92,7 @@ import type {
GlobalUpgradeResponses,
InstanceDisposeErrors,
InstanceDisposeResponses,
LocationRef,
LspStatusErrors,
LspStatusResponses,
McpAddErrors,
@@ -296,6 +297,8 @@ import type {
V2SessionCompactResponses,
V2SessionContextErrors,
V2SessionContextResponses,
V2SessionCreateErrors,
V2SessionCreateResponses,
V2SessionGetErrors,
V2SessionGetResponses,
V2SessionListErrors,
@@ -5278,6 +5281,49 @@ export class Session3 extends HeyApiClient {
})
}
/**
* Create session
*
* Create a session at the requested location.
*/
public create<ThrowOnError extends boolean = false>(
parameters?: {
id?: string
agent?: string
model?: {
id: string
providerID: string
variant?: string
}
location?: LocationRef
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "body", key: "id" },
{ in: "body", key: "agent" },
{ in: "body", key: "model" },
{ in: "body", key: "location" },
],
},
],
)
return (options?.client ?? this.client).post<V2SessionCreateResponses, V2SessionCreateErrors, ThrowOnError>({
url: "/api/session",
...options,
...params,
headers: {
"Content-Type": "application/json",
...options?.headers,
...params.headers,
},
})
}
/**
* Get session
*
+46
View File
@@ -3807,6 +3807,12 @@ export type SessionV2Info = {
archived?: number
}
title: string
share?: {
url: string
}
revert?: {
messageID: string
}
location: LocationRef
subpath?: string
}
@@ -9565,6 +9571,46 @@ export type V2SessionListResponses = {
export type V2SessionListResponse = V2SessionListResponses[keyof V2SessionListResponses]
export type V2SessionCreateData = {
body: {
id?: string
agent?: string
model?: {
id: string
providerID: string
variant?: string
}
location?: LocationRef
}
path?: never
query?: never
url: "/api/session"
}
export type V2SessionCreateErrors = {
/**
* InvalidRequestError
*/
400: InvalidRequestError
/**
* UnauthorizedError
*/
401: UnauthorizedError
}
export type V2SessionCreateError = V2SessionCreateErrors[keyof V2SessionCreateErrors]
export type V2SessionCreateResponses = {
/**
* Success
*/
200: {
data: SessionV2Info
}
}
export type V2SessionCreateResponse = V2SessionCreateResponses[keyof V2SessionCreateResponses]
export type V2SessionGetData = {
body?: never
path: {
+21
View File
@@ -16,6 +16,9 @@ import {
UnknownError,
} from "../errors"
import { SessionLocationMiddleware } from "../middleware/session-location"
import { AgentV2 } from "@opencode-ai/core/agent"
import { ModelV2 } from "@opencode-ai/core/model"
import { Location } from "@opencode-ai/core/location"
const SessionsQueryFields = {
workspace: WorkspaceV2.ID.pipe(Schema.optional),
@@ -105,6 +108,24 @@ export const SessionGroup = HttpApiGroup.make("server.session")
}),
),
)
.add(
HttpApiEndpoint.post("session.create", "/api/session", {
payload: Schema.Struct({
id: SessionV2.ID.pipe(Schema.optional),
agent: AgentV2.ID.pipe(Schema.optional),
model: ModelV2.Ref.pipe(Schema.optional),
location: Location.Ref.pipe(Schema.optional),
}),
success: Schema.Struct({ data: SessionV2.Info }),
})
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.create",
summary: "Create session",
description: "Create a session at the requested location.",
}),
),
)
.add(
HttpApiEndpoint.get("session.get", "/api/session/:sessionID", {
params: { sessionID: SessionV2.ID },
+14
View File
@@ -10,6 +10,7 @@ import {
SessionNotFoundError,
UnknownError,
} from "../errors"
import { AbsolutePath } from "@opencode-ai/core/schema"
const DefaultSessionsLimit = 50
@@ -61,6 +62,19 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
}
}),
)
.handle(
"session.create",
Effect.fn(function* (ctx) {
return {
data: yield* session.create({
id: ctx.payload.id,
agent: ctx.payload.agent,
model: ctx.payload.model,
location: ctx.payload.location ?? { directory: AbsolutePath.make(process.cwd()) },
}),
}
}),
)
.handle(
"session.get",
Effect.fn(function* (ctx) {
+127 -92
View File
@@ -5,6 +5,7 @@ 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"
@@ -20,6 +21,7 @@ import {
onCleanup,
batch,
Show,
on,
} from "solid-js"
import { TuiPathsProvider, TuiStartupProvider, TuiTerminalEnvironmentProvider, useTuiStartup } from "./context/runtime"
import { DialogProvider, useDialog } from "./ui/dialog"
@@ -79,6 +81,7 @@ 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",
@@ -174,8 +177,8 @@ function isVersionGreater(left: string, right: string) {
export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
const global = yield* Global.Service
const epilogue = { value: undefined as string | undefined }
const output = yield* Effect.scoped(
const exit = { epilogue: undefined as string | undefined, reason: undefined as unknown }
yield* Effect.scoped(
Effect.gen(function* () {
const renderer = yield* Effect.acquireRelease(
Effect.tryPromise(() =>
@@ -193,7 +196,10 @@ 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)
@@ -211,7 +217,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
}),
)
yield* Effect.addFinalizer(() => Effect.sync(TuiAudio.dispose))
const shutdown = yield* Deferred.make<void>()
const shutdown = yield* Deferred.make<unknown>()
const onSighup = () => destroyRenderer(renderer)
yield* Effect.acquireRelease(
Effect.sync(() => process.on("SIGHUP", onSighup)),
@@ -228,103 +234,120 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
await render(() => {
return (
<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
<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
value={{
initialRoute: process.env.OPENCODE_ROUTE ? JSON.parse(process.env.OPENCODE_ROUTE) : undefined,
skipInitialLoading: Boolean(process.env.OPENCODE_FAST_BOOT),
cwd: process.cwd(),
home: global.home,
state: global.state,
worktree: global.data + "/worktree",
}}
>
<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>
<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_SCRAP
? { type: "plugin", id: "scrap" }
: 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>
)
}, renderer)
})
yield* Deferred.await(shutdown)
return epilogue.value
}),
)
yield* Effect.sync(() => {
win32FlushInputBuffer()
if (output) process.stdout.write(output + "\n")
if (exit.reason !== undefined)
process.stderr.write((cliErrorMessage(exit.reason) ?? errorFormat(exit.reason)) + "\n")
if (exit.epilogue) process.stdout.write(exit.epilogue + "\n")
})
})
@@ -345,6 +368,7 @@ 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 })
@@ -502,6 +526,17 @@ 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()
@@ -774,7 +809,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
title: "Exit the app",
slashName: "exit",
slashAliases: ["quit", "q"],
run: () => destroyRenderer(renderer),
run: () => exit(),
category: "System",
},
{
@@ -1008,7 +1043,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
`Successfully updated to OpenCode v${result.data.version}. Please restart the application.`,
)
destroyRenderer(renderer)
void exit()
})
const plugin = createMemo(() => {
+3 -6
View File
@@ -991,9 +991,8 @@ export function Prompt(props: PromptProps) {
if (move.pending() && !directory) return false
finishMoveProgress = Boolean(move.progress())
const res = await sdk.client.session.create({
directory,
workspace: workspaceID,
const res = await sdk.client.v2.session.create({
location: directory ? { directory, workspaceID } : undefined,
agent: agent.name,
model: {
providerID: selectedModel.providerID,
@@ -1004,8 +1003,6 @@ export function Prompt(props: PromptProps) {
if (res.error) {
if (finishMoveProgress) move.finishSubmit()
console.log("Creating a session failed:", res.error)
toast.show({
message: "Creating a session failed. Open console for more details.",
variant: "error",
@@ -1014,7 +1011,7 @@ export function Prompt(props: PromptProps) {
return true
}
sessionID = res.data.id
sessionID = res.data.data.id
}
const inputText = expandTrackedPastedText(
+21 -23
View File
@@ -24,7 +24,9 @@ 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"
@@ -55,6 +57,7 @@ export const {
} = createSimpleContext({
name: "Sync",
init: () => {
const startup = useTuiStartup()
const kv = useKV()
const [store, setStore] = createStore<{
status: "loading" | "partial" | "complete"
@@ -419,9 +422,11 @@ export const {
}
})
const exit = useExit()
const args = useArgs()
async function bootstrap() {
async function bootstrap(input: { fatal?: boolean } = {}) {
const fatal = input.fatal ?? true
const workspace = project.workspace.current()
const projectPromise = project.sync()
const sessionListPromise = projectPromise.then(() => listSessions())
@@ -435,26 +440,14 @@ export const {
.catch(() => emptyConsoleState)
const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true })
const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true })
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"))
})
await Promise.all([
providersPromise,
providerListPromise,
agentsPromise,
configPromise,
projectPromise,
...(args.continue ? [sessionListPromise] : []),
])
.then(async () => {
const providersResponse = providersPromise.then((x) => x.data!)
const providerListResponse = providerListPromise.then((x) => x.data!)
@@ -518,7 +511,11 @@ export const {
name: e instanceof Error ? e.name : undefined,
stack: e instanceof Error ? e.stack : undefined,
})
setStore("status", "partial")
if (fatal) {
exit(e)
} else {
throw e
}
})
}
@@ -533,7 +530,8 @@ export const {
return store.status
},
get ready() {
return true
if (startup.skipInitialLoading) return true
return store.status !== "loading"
},
get path() {
return project.instance.path()
@@ -0,0 +1,66 @@
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import { useTerminalDimensions } from "@opentui/solid"
import { useTheme } from "../../context/theme"
import { useBindings } from "../../keymap"
import type { BuiltinTuiPlugin } from "../builtins"
const id = "internal:scrap"
const route = "scrap"
function Scrap(props: { api: TuiPluginApi }) {
const dimensions = useTerminalDimensions()
const { theme } = useTheme()
useBindings(() => ({
bindings: [
{
key: "escape",
desc: "Back home",
group: "Scrap",
cmd() {
props.api.route.navigate("home")
},
},
],
}))
return (
<box width={dimensions().width} height={dimensions().height} backgroundColor={theme.background}>
<box flexGrow={1} />
<box
height={1}
flexShrink={0}
backgroundColor={theme.backgroundPanel}
paddingLeft={1}
paddingRight={1}
flexDirection="row"
>
<text fg={theme.textMuted}>~/code/anomalyco/opencode</text>
<box flexGrow={1} />
<text fg={theme.textMuted}>esc home</text>
</box>
</box>
)
}
const tui: TuiPlugin = async (api) => {
api.route.register([{ name: route, render: () => <Scrap api={api} /> }])
api.keymap.registerLayer({
commands: [
{
name: "app.scrap",
title: "Open scrap screen",
category: "Debug",
namespace: "palette",
run() {
api.route.navigate(route)
api.ui.dialog.clear()
},
},
],
})
}
const plugin: BuiltinTuiPlugin = { id, tui }
export default plugin
+20 -11
View File
@@ -20,6 +20,7 @@ import { mkdir, writeFile } from "node:fs/promises"
import { useRoute, useRouteData } from "../../context/route"
import { useProject } from "../../context/project"
import { useSync } from "../../context/sync"
import { useData } from "../../context/data"
import { useEvent } from "../../context/event"
import { SplitBorder } from "../../ui/border"
import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime"
@@ -184,6 +185,7 @@ export function Session() {
const route = useRouteData("session")
const { navigate } = useRoute()
const sync = useSync()
const data = useData()
const event = useEvent()
const project = useProject()
const paths = useTuiPaths()
@@ -191,7 +193,7 @@ export function Session() {
const kv = useKV()
const { theme } = useTheme()
const promptRef = usePromptRef()
const session = createMemo(() => sync.session.get(route.sessionID))
const session = createMemo(() => data.session.get(route.sessionID))
createEffect(() => {
const title = Locale.truncate(session()?.title ?? "", 50)
@@ -280,8 +282,14 @@ export function Session() {
const sessionID = route.sessionID
void (async () => {
const previousWorkspace = untrack(() => project.workspace.current())
const result = await sdk.client.session.get({ sessionID }, { throwOnError: true })
if (!result.data) {
await Promise.all([
data.session.refresh(sessionID),
data.session.message.refresh(sessionID),
data.session.permission.refresh(sessionID),
data.session.question.refresh(sessionID),
])
const info = data.session.get(sessionID)
if (!info) {
toast.show({
message: `Session not found: ${sessionID}`,
variant: "error",
@@ -291,17 +299,18 @@ export function Session() {
return
}
if (result.data.workspaceID !== previousWorkspace) {
project.workspace.set(result.data.workspaceID)
if (info.location.workspaceID !== previousWorkspace) {
project.workspace.set(info.location.workspaceID)
// Sync all the data for this workspace. Note that this
// workspace may not exist anymore which is why this is not
// fatal. If it doesn't we still want to show the session
// (which will be non-interactive)
await sync.bootstrap()
try {
await sync.bootstrap({ fatal: false })
} catch {}
}
editor.reconnect(result.data.directory)
await sync.session.sync(sessionID)
editor.reconnect(info.location.directory)
if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000)
})().catch((error) => {
if (route.sessionID !== sessionID) return
@@ -1117,7 +1126,7 @@ export function Session() {
const revertInfo = createMemo(() => session()?.revert)
const revertMessageID = createMemo(() => revertInfo()?.messageID)
const revertDiffFiles = createMemo(() => getRevertDiffFiles(revertInfo()?.diff ?? ""))
const revertDiffFiles = createMemo<ReturnType<typeof getRevertDiffFiles>>(() => [])
const revertRevertedMessages = createMemo(() => {
const messageID = revertMessageID()
@@ -1132,7 +1141,7 @@ export function Session() {
return {
messageID: info.messageID,
reverted: revertRevertedMessages(),
diff: info.diff,
diff: undefined,
diffFiles: revertDiffFiles(),
}
})
@@ -1141,7 +1150,7 @@ export function Session() {
createEffect(on(() => route.sessionID, toBottom))
return (
<PathFormatterProvider path={session()?.directory}>
<PathFormatterProvider path={session()?.location.directory}>
<context.Provider
value={{
get width() {