From b16aa4439da89c408d4050893f74edb8bdbb90bd Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 10 Jun 2026 22:40:26 -0400 Subject: [PATCH] feat(cli): add standalone v2 session flow --- packages/cli/src/commands/commands.ts | 7 + packages/cli/src/commands/handlers/default.ts | 5 +- packages/cli/src/commands/handlers/serve.ts | 8 +- packages/cli/src/framework/runtime.ts | 7 +- packages/cli/src/index.ts | 11 + packages/cli/src/services/daemon.ts | 32 ++- packages/cli/src/services/standalone.ts | 38 +++ packages/core/src/session/info.ts | 3 + packages/core/src/session/schema.ts | 3 + .../test/server/httpapi-exercise/index.ts | 8 + packages/sdk/js/src/v2/gen/sdk.gen.ts | 46 ++++ packages/sdk/js/src/v2/gen/types.gen.ts | 46 ++++ packages/server/src/groups/session.ts | 21 ++ packages/server/src/handlers/session.ts | 14 ++ packages/tui/src/app.tsx | 219 ++++++++++-------- packages/tui/src/component/prompt/index.tsx | 9 +- packages/tui/src/context/sync.tsx | 44 ++-- .../tui/src/feature-plugins/system/scrap.tsx | 66 ++++++ packages/tui/src/routes/session/index.tsx | 31 ++- 19 files changed, 469 insertions(+), 149 deletions(-) create mode 100644 packages/cli/src/services/standalone.ts create mode 100644 packages/tui/src/feature-plugins/system/scrap.tsx diff --git a/packages/cli/src/commands/commands.ts b/packages/cli/src/commands/commands.ts index 39594e9951..8164459943 100644 --- a/packages/cli/src/commands/commands.ts +++ b/packages/cli/src/commands/commands.ts @@ -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)), }, }), ], diff --git a/packages/cli/src/commands/handlers/default.ts b/packages/cli/src/commands/handlers/default.ts index d0a9968e5d..919124c02d 100644 --- a/packages/cli/src/commands/handlers/default.ts +++ b/packages/cli/src/commands/handlers/default.ts @@ -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) }), diff --git a/packages/cli/src/commands/handlers/serve.ts b/packages/cli/src/commands/handlers/serve.ts index 3b68e0bad3..1db0a6aa32 100644 --- a/packages/cli/src/commands/handlers/serve.ts +++ b/packages/cli/src/commands/handlers/serve.ts @@ -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 }), ) diff --git a/packages/cli/src/framework/runtime.ts b/packages/cli/src/framework/runtime.ts index 97247e4d6b..060b5a0d89 100644 --- a/packages/cli/src/framework/runtime.ts +++ b/packages/cli/src/framework/runtime.ts @@ -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 extends Spec.Node @@ -10,11 +11,11 @@ export type Input = ? Input : never -type RuntimeHandler = (input: unknown) => Effect.Effect +type RuntimeHandler = (input: unknown) => Effect.Effect type Loader = () => Promise<{ - default: (input: Input) => Effect.Effect + default: (input: Input) => Effect.Effect }> -type ProvidedCommand = Command.Command +type ProvidedCommand = Command.Command export type Handlers = keyof Node["commands"] extends never ? Loader diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index c15362ac4f..e377a6c5c9 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -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, ) diff --git a/packages/cli/src/services/daemon.ts b/packages/cli/src/services/daemon.ts index 2e1f5bee4e..027f1782ac 100644 --- a/packages/cli/src/services/daemon.ts +++ b/packages/cli/src/services/daemon.ts @@ -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] diff --git a/packages/cli/src/services/standalone.ts b/packages/cli/src/services/standalone.ts new file mode 100644 index 0000000000..3c87fd5f90 --- /dev/null +++ b/packages/cli/src/services/standalone.ts @@ -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" diff --git a/packages/core/src/session/info.ts b/packages/core/src/session/info.ts index 2308d06460..d629a2a06a 100644 --- a/packages/core/src/session/info.ts +++ b/packages/core/src/session/info.ts @@ -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 diff --git a/packages/core/src/session/schema.ts b/packages/core/src/session/schema.ts index 8509cabee4..e266d65d3b 100644 --- a/packages/core/src/session/schema.ts +++ b/packages/core/src/session/schema.ts @@ -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("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), }) {} diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index 4434038350..3b97c50834 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -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" })) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index b936f430a1..9e70c4b44a 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -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( + parameters?: { + id?: string + agent?: string + model?: { + id: string + providerID: string + variant?: string + } + location?: LocationRef + }, + options?: Options, + ) { + 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({ + url: "/api/session", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + /** * Get session * diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 0d4eb16ff9..3348c986a7 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -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: { diff --git a/packages/server/src/groups/session.ts b/packages/server/src/groups/session.ts index b65c1d019a..604fcf9073 100644 --- a/packages/server/src/groups/session.ts +++ b/packages/server/src/groups/session.ts @@ -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 }, diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index 6233ac5252..66383cfbff 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -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) { diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 9133c1fde2..8ab854e27e 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -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() + const shutdown = yield* Deferred.make() 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 ( - }> - - - { + if (renderer.isDestroyed) return + exit.reason = reason + destroyRenderer(renderer) + }} + > + (exit.epilogue = value)}> + }> + - - (epilogue.value = value)}> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) }, 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; 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; 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(() => ) + }, + ), + ) + const connected = useConnected() const currentWorktreeWorkspace = createMemo(() => { const workspaceID = project.workspace.current() @@ -774,7 +809,7 @@ function App(props: { onSnapshot?: () => Promise; 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; pluginHost: TuiPlugi `Successfully updated to OpenCode v${result.data.version}. Please restart the application.`, ) - destroyRenderer(renderer) + void exit() }) const plugin = createMemo(() => { diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 0b83119632..2433a45fdd 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -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( diff --git a/packages/tui/src/context/sync.tsx b/packages/tui/src/context/sync.tsx index 974646baba..4882c13920 100644 --- a/packages/tui/src/context/sync.tsx +++ b/packages/tui/src/context/sync.tsx @@ -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 }[] = [ - { 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() diff --git a/packages/tui/src/feature-plugins/system/scrap.tsx b/packages/tui/src/feature-plugins/system/scrap.tsx new file mode 100644 index 0000000000..7327ef71cd --- /dev/null +++ b/packages/tui/src/feature-plugins/system/scrap.tsx @@ -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 ( + + + + ~/code/anomalyco/opencode + + esc home + + + ) +} + +const tui: TuiPlugin = async (api) => { + api.route.register([{ name: route, render: () => }]) + 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 diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 0aff4e66de..3ee000cd34 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -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>(() => []) 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 ( - +