Compare commits

...

1 Commits

Author SHA1 Message Date
James Long fe8b58589e feat(core): moving sessions 2026-06-03 19:21:17 -04:00
30 changed files with 1730 additions and 427 deletions
@@ -0,0 +1,130 @@
export * as MoveSession from "./move-session"
import { Context, DateTime, Effect, Layer, Schema } from "effect"
import { EventV2 } from "../event"
import { Git } from "../git"
import { Location } from "../location"
import { ProjectV2 } from "../project"
import { SessionV2 } from "../session"
import { SessionEvent } from "../session/event"
import { SessionSchema } from "../session/schema"
import { AbsolutePath, RelativePath } from "../schema"
import path from "path"
export const Destination = Schema.Struct({
directory: AbsolutePath,
}).annotate({ identifier: "MoveSession.Destination" })
export type Destination = typeof Destination.Type
export const Input = Schema.Struct({
sessionID: SessionSchema.ID,
destination: Destination,
moveChanges: Schema.optional(Schema.Boolean),
}).annotate({ identifier: "MoveSession.Input" })
export type Input = typeof Input.Type
export class DestinationProjectMismatchError extends Schema.TaggedErrorClass<DestinationProjectMismatchError>()(
"MoveSession.DestinationProjectMismatchError",
{
expected: ProjectV2.ID,
actual: ProjectV2.ID,
},
) {}
export class ApplyChangesError extends Schema.TaggedErrorClass<ApplyChangesError>()("MoveSession.ApplyChangesError", {
message: Schema.String,
}) {}
export class CaptureChangesError extends Schema.TaggedErrorClass<CaptureChangesError>()(
"MoveSession.CaptureChangesError",
{
message: Schema.String,
},
) {}
export class ResetSourceChangesError extends Schema.TaggedErrorClass<ResetSourceChangesError>()(
"MoveSession.ResetSourceChangesError",
{
directory: AbsolutePath,
message: Schema.String,
cause: Schema.optional(Schema.Defect),
},
) {}
export type Error =
| SessionV2.NotFoundError
| DestinationProjectMismatchError
| CaptureChangesError
| ApplyChangesError
| ResetSourceChangesError
export interface Interface {
readonly moveSession: (input: Input) => Effect.Effect<void, Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ControlPlaneMoveSession") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const git = yield* Git.Service
const events = yield* EventV2.Service
const project = yield* ProjectV2.Service
const session = yield* SessionV2.Service
const moveSession = Effect.fn("MoveSession.moveSession")(function* (input: Input) {
const current = yield* session.get(input.sessionID)
const directory = AbsolutePath.make(input.destination.directory)
if (current.location.directory === directory) return
const source = yield* project.resolve(current.location.directory)
const destination = yield* project.resolve(directory)
if (current.projectID !== destination.id) {
return yield* new DestinationProjectMismatchError({ expected: current.projectID, actual: destination.id })
}
const patch =
input.moveChanges && source.directory !== destination.directory
? yield* git
.patch(current.location.directory)
.pipe(Effect.mapError((error) => new CaptureChangesError({ message: error.message })))
: ""
if (patch) {
yield* git
.applyPatch({ directory, patch })
.pipe(Effect.mapError((error) => new ApplyChangesError({ message: error.message })))
}
yield* events.publish(SessionEvent.Moved, {
sessionID: input.sessionID,
location: Location.Ref.make({ directory }),
subpath: RelativePath.make(path.relative(destination.directory, directory).replaceAll("\\", "/")),
timestamp: yield* DateTime.now,
})
if (patch) {
// AI: DO NOT ENABLE THIS
//
// yield* git.resetChanges(current.location.directory).pipe(
// Effect.mapError(
// (error) =>
// new ResetSourceChangesError({
// directory: current.location.directory,
// message: error.message,
// cause: error.cause,
// }),
// ),
// )
}
})
return Service.of({ moveSession })
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(Git.defaultLayer),
Layer.provide(EventV2.defaultLayer),
Layer.provide(ProjectV2.defaultLayer),
Layer.provide(SessionV2.defaultLayer),
)
+121 -1
View File
@@ -1,7 +1,7 @@
export * as Git from "./git"
import path from "path"
import { Context, Effect, Layer, Schema } from "effect"
import { Context, Effect, Layer, Schema, Stream } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { AbsolutePath } from "./schema"
import { FSUtil } from "./fs-util"
@@ -33,6 +33,13 @@ export class WorktreeError extends Schema.TaggedErrorClass<WorktreeError>()("Git
cause: Schema.optional(Schema.Defect),
}) {}
export class PatchError extends Schema.TaggedErrorClass<PatchError>()("Git.PatchError", {
operation: Schema.Literals(["capture", "apply", "reset"]),
directory: AbsolutePath,
message: Schema.String,
cause: Schema.optional(Schema.Defect),
}) {}
export interface Interface {
readonly find: (input: AbsolutePath) => Effect.Effect<Repo | undefined>
readonly remote: (repo: Repo, name?: string) => Effect.Effect<string | undefined>
@@ -52,6 +59,9 @@ export interface Interface {
readonly fetchBranch: (directory: string, branch: string) => Effect.Effect<Result, AppProcess.AppProcessError>
readonly checkout: (directory: string, branch: string) => Effect.Effect<Result, AppProcess.AppProcessError>
readonly reset: (directory: string, target: string) => Effect.Effect<Result, AppProcess.AppProcessError>
readonly patch: (directory: AbsolutePath) => Effect.Effect<string, PatchError>
readonly applyPatch: (input: { directory: AbsolutePath; patch: string }) => Effect.Effect<void, PatchError>
readonly resetChanges: (directory: AbsolutePath) => Effect.Effect<void, PatchError>
readonly worktreeCreate: (input: { repo: Repo; directory: AbsolutePath }) => Effect.Effect<void, WorktreeError>
readonly worktreeRemove: (input: { repo: Repo; directory: AbsolutePath }) => Effect.Effect<void, WorktreeError>
readonly worktreeList: (repo: Repo) => Effect.Effect<AbsolutePath[], WorktreeError>
@@ -159,6 +169,113 @@ export const layer = Layer.effect(
execute(directory, proc)(["reset", "--hard", target]),
)
const patch = Effect.fn("Git.patch")(function* (directory: AbsolutePath) {
const tracked = yield* execute(
directory,
proc,
)(["diff", "--binary", "HEAD", "--", "."]).pipe(
Effect.mapError((cause) => new PatchError({ operation: "capture", directory, message: cause.message, cause })),
)
if (tracked.exitCode !== 0) {
return yield* new PatchError({
operation: "capture",
directory,
message: tracked.stderr.trim() || tracked.text.trim() || "Failed to capture tracked changes",
})
}
const untracked = yield* execute(
directory,
proc,
)(["ls-files", "--others", "--exclude-standard", "-z", "--", "."]).pipe(
Effect.mapError((cause) => new PatchError({ operation: "capture", directory, message: cause.message, cause })),
)
if (untracked.exitCode !== 0) {
return yield* new PatchError({
operation: "capture",
directory,
message: untracked.stderr.trim() || untracked.text.trim() || "Failed to list untracked changes",
})
}
const created = yield* Effect.forEach(untracked.text.split("\0").filter(Boolean), (file) =>
execute(
directory,
proc,
)(["diff", "--binary", "--no-index", "--", "/dev/null", file]).pipe(
Effect.mapError(
(cause) => new PatchError({ operation: "capture", directory, message: cause.message, cause }),
),
Effect.flatMap((result) =>
// git diff --no-index returns 1 when differences were found.
result.exitCode === 0 || result.exitCode === 1
? Effect.succeed(result.text)
: Effect.fail(
new PatchError({
operation: "capture",
directory,
message:
result.stderr.trim() || result.text.trim() || `Failed to capture untracked change: ${file}`,
}),
),
),
),
)
return [tracked.text, ...created].filter(Boolean).join("\n")
})
const applyPatch = Effect.fn("Git.applyPatch")(function* (input: { directory: AbsolutePath; patch: string }) {
const result = yield* proc
.run(
ChildProcess.make("git", ["apply", "-"], {
cwd: input.directory,
extendEnv: true,
stdin: Stream.make(new TextEncoder().encode(input.patch)),
}),
)
.pipe(
Effect.mapError(
(cause) =>
new PatchError({ operation: "apply", directory: input.directory, message: cause.message, cause }),
),
)
if (result.exitCode === 0) return
return yield* new PatchError({
operation: "apply",
directory: input.directory,
message:
result.stderr.toString("utf8").trim() || result.stdout.toString("utf8").trim() || "Failed to apply changes",
})
})
const resetChanges = Effect.fn("Git.resetChanges")(function* (directory: AbsolutePath) {
const reset = yield* execute(
directory,
proc,
)(["reset", "--hard", "HEAD"]).pipe(
Effect.mapError((cause) => new PatchError({ operation: "reset", directory, message: cause.message, cause })),
)
if (reset.exitCode !== 0) {
return yield* new PatchError({
operation: "reset",
directory,
message: reset.stderr.trim() || reset.text.trim() || "Failed to reset tracked changes",
})
}
const clean = yield* execute(
directory,
proc,
)(["clean", "-fd"]).pipe(
Effect.mapError((cause) => new PatchError({ operation: "reset", directory, message: cause.message, cause })),
)
if (clean.exitCode === 0) return
return yield* new PatchError({
operation: "reset",
directory,
message: clean.stderr.trim() || clean.text.trim() || "Failed to clean untracked changes",
})
})
const worktree = Effect.fnUntraced(function* (
operation: "create" | "remove" | "list",
repo: Repo,
@@ -216,6 +333,9 @@ export const layer = Layer.effect(
fetchBranch,
checkout,
reset,
patch,
applyPatch,
resetChanges,
worktreeCreate,
worktreeRemove,
worktreeList,
+9 -3
View File
@@ -25,11 +25,17 @@ export function makeStrategies(input: {
yield* input.git.worktreeRemove({ repo: found, directory })
}),
list: Effect.fn("ProjectCopy.GitWorktree.list")(function* (directory) {
const entries = yield* input.git.worktreeList(repo(directory))
const found = yield* input.git.find(directory)
if (!found) return yield* new DirectoryUnavailableError({ directory })
const core = path.basename(found.store) === ".git" ? path.dirname(found.store) : found.store
const entries = yield* input.git.worktreeList(found)
return yield* Effect.forEach(entries, (entry) =>
entry === directory
entry === core
? Effect.succeed(undefined)
: input.canonical(entry).pipe(Effect.map((directory) => ({ directory }))),
: input.canonical(entry).pipe(
Effect.map((directory) => ({ directory })),
Effect.catchTag("ProjectCopy.DirectoryUnavailableError", () => Effect.succeed(undefined)),
),
).pipe(Effect.map((items) => items.filter((item): item is Copy => item !== undefined)))
}),
detect: Effect.fn("ProjectCopy.GitWorktree.detect")(function* (inputDirectory) {
-7
View File
@@ -65,11 +65,6 @@ type CreateInput = {
location: Location.Ref
}
type MoveInput = {
sessionID: SessionSchema.ID
location: Location.Ref
}
type CompactInput = {
sessionID: SessionSchema.ID
prompt?: Prompt
@@ -96,7 +91,6 @@ export type Error = NotFoundError | MessageDecodeError | OperationUnavailableErr
export interface Interface {
readonly list: (input?: ListInput) => Effect.Effect<SessionSchema.Info[]>
readonly create: (input?: CreateInput) => Effect.Effect<SessionSchema.Info>
readonly move: (input: MoveInput) => Effect.Effect<void, NotFoundError>
readonly get: (sessionID: SessionSchema.ID) => Effect.Effect<SessionSchema.Info, NotFoundError>
readonly messages: (input: {
sessionID: SessionSchema.ID
@@ -327,7 +321,6 @@ export const layer = Layer.effect(
return yield* new OperationUnavailableError({ operation: "wait" })
}),
resume: Effect.fn("V2Session.resume")(function* () {}),
move: Effect.fn("V2Session.move")(function* () {}),
})
return result
+14
View File
@@ -6,6 +6,8 @@ import { ToolOutput } from "../tool-output"
import { V2Schema } from "../v2-schema"
import { FileAttachment, Prompt } from "./prompt"
import { SessionSchema } from "./schema"
import { Location } from "../location"
import { RelativePath } from "../schema"
export { FileAttachment }
@@ -58,6 +60,17 @@ export const ModelSwitched = EventV2.define({
})
export type ModelSwitched = typeof ModelSwitched.Type
export const Moved = EventV2.define({
type: "session.next.moved",
...options,
schema: {
...Base,
location: Location.Ref,
subpath: RelativePath.pipe(Schema.optional),
},
})
export type Moved = typeof Moved.Type
export const Prompted = EventV2.define({
type: "session.next.prompted",
...options,
@@ -368,6 +381,7 @@ export const All = Schema.Union(
[
AgentSwitched,
ModelSwitched,
Moved,
Prompted,
Synthetic,
Shell.Started,
@@ -125,6 +125,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
}),
)
},
"session.next.moved": () => Effect.void,
"session.next.prompted": (event) => {
return adapter.appendMessage(
new SessionMessage.User({
+14
View File
@@ -9,6 +9,7 @@ import { SessionV1 } from "../v1/session"
import { WorkspaceTable } from "../control-plane/workspace.sql"
import { SessionMessage } from "./message"
import { SessionMessageUpdater } from "./message-updater"
import { WorkspaceV2 } from "../workspace"
import { MessageTable, PartTable, SessionMessageTable, SessionTable } from "./sql"
import type { DeepMutable } from "../schema"
@@ -289,6 +290,19 @@ export const layer = Layer.effectDiscard(
.run()
.pipe(Effect.orDie),
)
yield* events.project(SessionEvent.Moved, (event) =>
db
.update(SessionTable)
.set({
directory: event.data.location.directory,
path: event.data.subpath,
workspace_id: event.data.location.workspaceID ? WorkspaceV2.ID.make(event.data.location.workspaceID) : null,
time_updated: DateTime.toEpochMillis(event.data.timestamp),
})
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie),
)
yield* events.project(SessionV1.Event.Deleted, (event) =>
db.delete(SessionTable).where(eq(SessionTable.id, event.data.sessionID)).run().pipe(Effect.orDie),
)
+166
View File
@@ -0,0 +1,166 @@
import { describe, expect } from "bun:test"
import { $ } from "bun"
import fs from "fs/promises"
import path from "path"
import { eq } from "drizzle-orm"
import { Effect, Layer } from "effect"
import { MoveSession } from "@opencode-ai/core/control-plane/move-session"
import { Database } from "@opencode-ai/core/database/database"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Git } from "@opencode-ai/core/git"
import { EventV2 } from "@opencode-ai/core/event"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const database = Database.layerFromPath(":memory:")
const events = EventV2.layer.pipe(Layer.provide(database))
const projector = SessionProjector.layer.pipe(Layer.provide(database), Layer.provide(events))
const layer = MoveSession.layer.pipe(
Layer.provide(database),
Layer.provide(FSUtil.defaultLayer),
Layer.provide(Git.defaultLayer),
Layer.provide(events),
Layer.provide(
Project.layer.pipe(Layer.provide(database), Layer.provide(FSUtil.defaultLayer), Layer.provide(Git.defaultLayer)),
),
Layer.provide(SessionV2.layer.pipe(Layer.provide(database))),
)
const project = Project.layer.pipe(
Layer.provide(database),
Layer.provide(FSUtil.defaultLayer),
Layer.provide(Git.defaultLayer),
)
const it = testEffect(Layer.mergeAll(layer, database, events, project, projector))
function abs(input: string) {
return AbsolutePath.make(input)
}
async function initRepo(directory: string) {
await $`git init`.cwd(directory).quiet()
await $`git config core.fsmonitor false`.cwd(directory).quiet()
await $`git config commit.gpgsign false`.cwd(directory).quiet()
await $`git config user.email test@opencode.test`.cwd(directory).quiet()
await $`git config user.name Test`.cwd(directory).quiet()
await fs.writeFile(path.join(directory, "tracked.txt"), "initial\n")
await $`git add tracked.txt`.cwd(directory).quiet()
await $`git commit -m root`.cwd(directory).quiet()
}
describe("MoveSession", () => {
it.live("moves session changes to another project directory", () =>
Effect.gen(function* () {
const root = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
)
yield* Effect.promise(() => initRepo(root.path))
const source = abs(yield* Effect.promise(() => fs.realpath(root.path)))
const destination = abs(`${root.path}-move-destination`)
yield* Effect.addFinalizer(() =>
Effect.promise(() => fs.rm(destination, { recursive: true, force: true })).pipe(Effect.ignore),
)
yield* Effect.promise(() => $`git worktree add --detach ${destination} HEAD`.cwd(root.path).quiet())
const moved = abs(yield* Effect.promise(() => fs.realpath(destination)))
yield* Effect.promise(() => fs.writeFile(path.join(source, "tracked.txt"), "changed\n"))
yield* Effect.promise(() => fs.writeFile(path.join(source, "untracked.txt"), "new\n"))
const projectID = (yield* Project.Service.use((service) => service.resolve(source))).id
const sessionID = SessionV2.ID.make("ses_move")
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: projectID, worktree: source, sandboxes: [], time_created: 1, time_updated: 1 })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: projectID,
slug: "move",
directory: source,
title: "move",
version: "test",
time_created: 1,
time_updated: 1,
})
.run()
.pipe(Effect.orDie)
yield* MoveSession.Service.use((service) =>
service.moveSession({ sessionID, destination: { directory: moved }, moveChanges: true }),
)
expect(yield* Effect.promise(() => fs.readFile(path.join(moved, "tracked.txt"), "utf8"))).toBe("changed\n")
expect(yield* Effect.promise(() => fs.readFile(path.join(moved, "untracked.txt"), "utf8"))).toBe("new\n")
expect(yield* Effect.promise(() => fs.readFile(path.join(source, "tracked.txt"), "utf8"))).toBe("initial\n")
expect(yield* Effect.promise(() => Bun.file(path.join(source, "untracked.txt")).exists())).toBe(false)
expect(
yield* db
.select({ directory: SessionTable.directory, path: SessionTable.path })
.from(SessionTable)
.where(eq(SessionTable.id, sessionID))
.get(),
).toEqual({ directory: moved, path: "" })
}),
)
it.live("moves within a checkout without transferring existing changes", () =>
Effect.gen(function* () {
const root = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
)
yield* Effect.promise(() => initRepo(root.path))
const source = abs(yield* Effect.promise(() => fs.realpath(root.path)))
const destination = abs(path.join(source, "packages"))
yield* Effect.promise(() => fs.mkdir(destination))
yield* Effect.promise(() => fs.writeFile(path.join(source, "tracked.txt"), "changed\n"))
yield* Effect.promise(() => fs.writeFile(path.join(source, "untracked.txt"), "new\n"))
const projectID = (yield* Project.Service.use((service) => service.resolve(source))).id
const sessionID = SessionV2.ID.make("ses_move_nested")
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: projectID, worktree: source, sandboxes: [], time_created: 1, time_updated: 1 })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: projectID,
slug: "move-nested",
directory: source,
title: "move nested",
version: "test",
time_created: 1,
time_updated: 1,
})
.run()
.pipe(Effect.orDie)
yield* MoveSession.Service.use((service) =>
service.moveSession({ sessionID, destination: { directory: destination }, moveChanges: true }),
)
expect(yield* Effect.promise(() => fs.readFile(path.join(source, "tracked.txt"), "utf8"))).toBe("changed\n")
expect(yield* Effect.promise(() => fs.readFile(path.join(source, "untracked.txt"), "utf8"))).toBe("new\n")
expect(
yield* db
.select({ directory: SessionTable.directory, path: SessionTable.path })
.from(SessionTable)
.where(eq(SessionTable.id, sessionID))
.get(),
).toEqual({ directory: destination, path: "packages" })
}),
)
})
+25
View File
@@ -181,6 +181,31 @@ describe("ProjectCopy", () => {
}),
)
it.live("refresh ignores stale git worktree registrations", () =>
Effect.gen(function* () {
const input = yield* setup()
const copy = yield* ProjectCopy.Service
const stale = abs(`${input.root.path}-copy-stale`)
const target = abs(`${input.root.path}-copy-after-stale`)
yield* Effect.addFinalizer(() =>
Effect.promise(() => fs.rm(target, { recursive: true, force: true })).pipe(Effect.ignore),
)
yield* Effect.promise(() => $`git worktree add --detach ${stale} HEAD`.cwd(input.root.path).quiet())
yield* Effect.promise(() => fs.rm(stale, { recursive: true, force: true }))
yield* Effect.promise(() => $`git worktree add --detach ${target} HEAD`.cwd(input.root.path).quiet())
yield* copy.refresh({ projectID: input.projectID })
const discovered = abs(yield* Effect.promise(() => fs.realpath(target)))
expect(yield* stored(input.projectID)).toEqual(
[
{ directory: input.sourceDirectory, type: "main" as const },
{ directory: discovered, type: "git_worktree" as const },
].toSorted((a, b) => a.directory.localeCompare(b.directory)),
)
}),
)
it.live("refresh with no roots is a no-op", () =>
Effect.gen(function* () {
const copy = yield* ProjectCopy.Service
@@ -0,0 +1,129 @@
import { useTerminalDimensions } from "@opentui/solid"
import { createMemo, createResource, createSignal, onMount, Show } from "solid-js"
import path from "path"
import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
import { useDialog } from "@tui/ui/dialog"
import { useSDK } from "@tui/context/sdk"
import { useTheme } from "@tui/context/theme"
import { useKV } from "@tui/context/kv"
import { useSync } from "@tui/context/sync"
import { Global } from "@opencode-ai/core/global"
import { Locale } from "@/util/locale"
import "opentui-spinner/solid"
const REFRESH_FRAMES = ["■", "⬝"]
export type MoveSessionSelection = { type: "directory"; directory: string } | { type: "new" }
export function DialogMoveSession(props: { projectID: string; onSelect: (selection: MoveSessionSelection) => void }) {
const dialog = useDialog()
const sdk = useSDK()
const dimensions = useTerminalDimensions()
const { theme } = useTheme()
const kv = useKV()
const sync = useSync()
const [refreshing, setRefreshing] = createSignal(false)
const [directories] = createResource(
() => props.projectID,
async (projectID) => {
setRefreshing(true)
const [, project] = await Promise.all([
sdk.client.experimental.projectCopy
.refresh({ projectID }, { throwOnError: true })
.finally(() => setRefreshing(false)),
sdk.client.project.current({}, { throwOnError: true }),
])
const directories = await sdk.client.project.directories({ projectID }, { throwOnError: true })
return {
directories: directories.data ?? [],
main: project.data?.id === projectID ? project.data.worktree : undefined,
}
},
)
const options = createMemo<DialogSelectOption<string | undefined>[]>(() => {
if (directories.loading) return [{ title: "Loading project directories...", value: undefined }]
if (directories.error) return [{ title: "Failed to load project directories", value: undefined }]
const data = directories()
const roots = data ? [...new Set(data.main ? [data.main, ...data.directories] : data.directories)] : []
if (roots.length === 0) return [{ title: "No project directories found", value: undefined }]
const subdirectories = sync.data.session
.filter((session) => session.projectID === props.projectID && session.path && ![".", "/"].includes(session.path))
.map((session) => session.directory)
.filter((directory) => !roots.includes(directory))
.filter((directory, index, directories) => directories.indexOf(directory) === index)
.map((location) => ({
location,
root: roots
.filter((root) => {
const relative = path.relative(root, location)
return relative && relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative)
})
.toSorted((a, b) => b.length - a.length)[0],
}))
.filter((item): item is { location: string; root: string } => item.root !== undefined)
const list = [...roots.map((location) => ({ location, root: location })), ...subdirectories].toSorted((a, b) => {
const root = roots.indexOf(a.root) - roots.indexOf(b.root)
if (root !== 0) return root
if (a.location === a.root) return -1
if (b.location === b.root) return 1
return a.location.localeCompare(b.location)
})
const titleWidth = Math.max(1, Math.min(116, dimensions().width - 2) - 12)
return list.map((item) => {
const title =
Global.Path.home &&
(item.location === Global.Path.home || item.location.startsWith(Global.Path.home + path.sep))
? item.location.replace(Global.Path.home, "~")
: item.location
const suffix = item.location === item.root ? undefined : path.sep + path.relative(item.root, item.location)
const visible = Locale.truncateLeft(title, titleWidth)
const split = suffix ? Math.max(0, visible.length - suffix.length) : visible.length
return {
title,
titleView: suffix ? (
<>
{visible.slice(0, split)}
<span style={{ fg: theme.textMuted }}>{visible.slice(split)}</span>
</>
) : undefined,
value: item.location,
category: item.root === data?.main ? "Project" : "Working copies",
titleWidth,
truncateTitle: "left" as const,
}
})
})
onMount(() => dialog.setSize("xlarge"))
return (
<box minHeight={Math.max(8, Math.min(16, dimensions().height - Math.floor(dimensions().height / 4) - 2))}>
<DialogSelect
title="Move session"
options={options()}
onSelect={(option) => {
if (option.value) props.onSelect({ type: "directory", directory: option.value })
}}
actions={[
{
command: "dialog.move_session.new",
title: "new",
onTrigger: () => props.onSelect({ type: "new" }),
},
]}
footer={
<Show when={refreshing()}>
<box flexDirection="row" gap={1}>
<Show when={kv.get("animations_enabled", true)} fallback={<text fg={theme.textMuted}></text>}>
<spinner color={theme.textMuted} frames={REFRESH_FRAMES} interval={160} />
</Show>
<text fg={theme.textMuted}>refreshing</text>
</box>
</Show>
}
/>
</box>
)
}
@@ -103,7 +103,7 @@ export function DialogWorkspaceFileChanges(props: {
</scrollbox>
<box paddingLeft={2} paddingRight={2}>
<text fg={theme.textMuted} wrapMode="word">
Do you want to apply these changes after warping?
Do you want to move these changes with the session?
</text>
</box>
<box flexDirection="row" justifyContent="flex-end" paddingLeft={2} paddingRight={2} paddingBottom={1}>
@@ -51,18 +51,13 @@ import { useToast } from "../../ui/toast"
import { useKV } from "../../context/kv"
import { createFadeIn } from "../../util/signal"
import { DialogSkill } from "../dialog-skill"
import {
confirmWorkspaceFileChanges,
openWorkspaceSelect,
warpWorkspaceSession,
type WorkspaceSelection,
} from "../dialog-workspace-create"
import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable"
import { useArgs } from "@tui/context/args"
import { Flag } from "@opencode-ai/core/flag/flag"
import { type WorkspaceStatus } from "../workspace-label"
import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut, useLeaderActive, useOpencodeKeymap } from "../../keymap"
import { useTuiConfig } from "../../context/tui-config"
import { usePromptWorkspace } from "./workspace"
import { usePromptMove } from "./move"
export type PromptProps = {
sessionID?: string
@@ -195,109 +190,12 @@ export function Prompt(props: PromptProps) {
})
const editorContextLabelState = createMemo(() => editor.labelState())
const [auto, setAuto] = createSignal<AutocompleteRef>()
const [workspaceSelection, setWorkspaceSelection] = createSignal<WorkspaceSelection>()
const [workspaceCreating, setWorkspaceCreating] = createSignal(false)
const [workspaceCreatingDots, setWorkspaceCreatingDots] = createSignal(3)
const [warpNotice, setWarpNotice] = createSignal<string>()
const workspace = usePromptWorkspace(props.sessionID)
const move = usePromptMove({ projectID: project.project, sessionID: () => props.sessionID })
const [cursorVersion, setCursorVersion] = createSignal(0)
const currentProviderLabel = createMemo(() => local.model.parsed().provider)
const hasRightContent = createMemo(() => Boolean(props.right))
function selectWorkspace(selection: WorkspaceSelection | undefined) {
setWorkspaceSelection(selection)
}
function setCreatingWorkspace(creating: boolean) {
setWorkspaceCreating(creating)
}
function showWarpNotice(name: string) {
setWarpNotice(`Warped to ${name}`)
setTimeout(() => setWarpNotice(undefined), 4000)
}
async function createWorkspace(selection: Extract<WorkspaceSelection, { type: "new" }>) {
setCreatingWorkspace(true)
let result
try {
result = await sdk.client.experimental.workspace.create({ type: selection.workspaceType, branch: null })
} catch (err) {
selectWorkspace(undefined)
setCreatingWorkspace(false)
toast.show({
title: "Creating workspace failed",
message: errorMessage(err),
variant: "error",
})
return
}
if (result.error || !result.data) {
selectWorkspace(undefined)
setCreatingWorkspace(false)
toast.show({
title: "Creating workspace failed",
message: errorMessage(result.error ?? "no response"),
variant: "error",
})
return
}
await project.workspace.sync()
const workspace = result.data
selectWorkspace({
type: "existing",
workspaceID: workspace.id,
workspaceType: workspace.type,
workspaceName: workspace.name,
})
setCreatingWorkspace(false)
return workspace
}
async function warpSession(selection: WorkspaceSelection) {
if (!props.sessionID) {
selectWorkspace(selection)
dialog.clear()
if (selection.type === "new") void createWorkspace(selection)
return
}
const sourceWorkspaceID = project.workspace.current()
const copyChanges = await confirmWorkspaceFileChanges({ dialog, sdk, sourceWorkspaceID })
if (copyChanges === undefined) return
selectWorkspace(selection)
dialog.clear()
const workspace =
selection.type === "none"
? { id: null, name: "local project" }
: selection.type === "existing"
? { id: selection.workspaceID, name: selection.workspaceName }
: await createWorkspace(selection)
if (!workspace) return
const warped = await warpWorkspaceSession({
dialog,
sdk,
sync,
project,
toast,
sourceWorkspaceID,
workspaceID: workspace.id,
sessionID: props.sessionID,
copyChanges,
})
if (warped) showWarpNotice(workspace.name)
}
createEffect(() => {
if (!workspaceCreating()) {
setWorkspaceCreatingDots(3)
return
}
const timer = setInterval(() => setWorkspaceCreatingDots((dots) => (dots % 3) + 1), 1000)
onCleanup(() => clearInterval(timer))
})
function promptModelWarning() {
toast.show({
variant: "warning",
@@ -623,16 +521,17 @@ export function Prompt(props: PromptProps) {
enabled: Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
slashName: "warp",
run: () => {
void openWorkspaceSelect({
dialog,
sdk,
sync,
project,
toast,
onSelect: (selection) => {
void warpSession(selection)
},
})
workspace.open()
},
},
{
title: "Move session",
desc: "Move the session to another project directory",
name: "session.move",
category: "Session",
slashName: "move",
run: () => {
move.open()
},
},
].map((entry) => ({
@@ -656,6 +555,7 @@ export function Prompt(props: PromptProps) {
"prompt.stash.list",
"session.interrupt",
"workspace.set",
"session.move",
]),
}))
@@ -1025,7 +925,7 @@ export function Prompt(props: PromptProps) {
}
async function submitInner() {
setWarpNotice(undefined)
workspace.clearNotice()
// IME: double-defer may fire before onContentChange flushes the last
// composed character (e.g. Korean hangul) to the store, so read
@@ -1035,7 +935,7 @@ export function Prompt(props: PromptProps) {
syncExtmarksWithPromptParts()
}
if (props.disabled) return false
if (workspaceCreating()) return false
if (workspace.creating() || move.creating()) return false
if (auto()?.visible) return false
if (!store.prompt.input) return false
const agent = local.agent.current()
@@ -1058,16 +958,7 @@ export function Prompt(props: PromptProps) {
dialog.replace(() => (
<DialogWorkspaceUnavailable
onRestore={() => {
void openWorkspaceSelect({
dialog,
sdk,
sync,
project,
toast,
onSelect: (selection) => {
void warpSession(selection)
},
})
workspace.open()
return false
}}
/>
@@ -1077,16 +968,22 @@ export function Prompt(props: PromptProps) {
const variant = local.model.variant.current()
let sessionID = props.sessionID
let finishMoveProgress = false
if (sessionID == null) {
const workspace = workspaceSelection()
const selectedWorkspace = workspace.selection()
const workspaceID = iife(() => {
if (!workspace) return undefined
if (workspace.type === "none") return undefined
if (workspace.type === "existing") return workspace.workspaceID
if (!selectedWorkspace) return undefined
if (selectedWorkspace.type === "none") return undefined
if (selectedWorkspace.type === "existing") return selectedWorkspace.workspaceID
return undefined
})
const directory = await move.directoryForSubmit()
if (move.pending() && !directory) return false
finishMoveProgress = Boolean(move.progress())
const res = await sdk.client.session.create({
directory,
workspace: workspaceID,
agent: agent.name,
model: {
@@ -1097,6 +994,7 @@ export function Prompt(props: PromptProps) {
})
if (res.error) {
if (finishMoveProgress) move.finishSubmit()
console.log("Creating a session failed:", res.error)
toast.show({
@@ -1146,6 +1044,7 @@ export function Prompt(props: PromptProps) {
: []
if (store.mode === "shell") {
move.startSubmit()
void sdk.client.session.shell({
sessionID,
agent: agent.name,
@@ -1164,6 +1063,7 @@ export function Prompt(props: PromptProps) {
return sync.data.command.some((x) => x.name === command)
})
) {
move.startSubmit()
// Parse command from first line, preserve multi-line content in arguments
const firstLineEnd = inputText.indexOf("\n")
const firstLine = firstLineEnd === -1 ? inputText : inputText.slice(0, firstLineEnd)
@@ -1187,6 +1087,7 @@ export function Prompt(props: PromptProps) {
})),
})
} else {
move.startSubmit()
sdk.client.session
.prompt({
sessionID,
@@ -1231,6 +1132,7 @@ export function Prompt(props: PromptProps) {
}, 50)
}
input.clear()
if (finishMoveProgress) move.finishSubmit()
return true
}
const exit = useExit()
@@ -1427,29 +1329,6 @@ export function Prompt(props: PromptProps) {
return `Ask anything... "${list()[store.placeholder % list().length]}"`
})
const workspaceLabel = createMemo<
| { type: "new"; workspaceType: string }
| { type: "existing"; workspaceType: string; workspaceName: string; status?: WorkspaceStatus }
| undefined
>(() => {
const selected = workspaceSelection()
if (!selected) return
if (selected.type === "none") return
if (props.sessionID && !workspaceCreating()) return
if (selected.type === "new") {
return {
type: "new",
workspaceType: selected.workspaceType,
}
}
return {
type: "existing",
workspaceType: selected.workspaceType,
workspaceName: selected.workspaceName,
status: selected.type === "existing" ? "connected" : undefined,
}
})
const spinnerDef = createMemo(() => {
const agent =
status().type !== "idle"
@@ -1474,6 +1353,7 @@ export function Prompt(props: PromptProps) {
}
})
const maxHeight = createMemo(() => tuiConfig.prompt?.max_height ?? Math.max(6, Math.floor(dimensions().height / 3)))
const moveLabelWidth = createMemo(() => Math.max(12, Math.min(44, dimensions().width - 48)))
return (
<>
@@ -1717,25 +1597,25 @@ export function Prompt(props: PromptProps) {
</text>
</box>
</Match>
<Match when={warpNotice()}>
<Match when={workspace.notice()}>
{(notice) => (
<box paddingLeft={3}>
<text fg={theme.accent}>{notice()}</text>
</box>
)}
</Match>
<Match when={workspaceLabel()}>
{(workspace) => (
<Match when={workspace.label()}>
{(label) => (
<box paddingLeft={3} flexDirection="row" gap={1}>
<Show when={workspaceCreating()}>
<Show when={workspace.creating()}>
<Spinner color={theme.accent} />
</Show>
<text fg={workspaceCreating() ? theme.accent : theme.text}>
<text fg={workspace.creating() ? theme.accent : theme.text}>
{(() => {
const item = workspace()
const item = label()
if (item.type === "new") {
if (workspaceCreating())
return `Creating ${item.workspaceType}${".".repeat(workspaceCreatingDots())}`
if (workspace.creating())
return `Creating ${item.workspaceType}${".".repeat(workspace.creatingDots())}`
return (
<>
Workspace <span style={{ fg: theme.textMuted }}>(new {item.workspaceType})</span>
@@ -1752,6 +1632,17 @@ export function Prompt(props: PromptProps) {
</box>
)}
</Match>
<Match when={move.progress()}>
{(progress) => (
<box paddingLeft={3} flexDirection="row" gap={1}>
<Spinner color={theme.accent} />
<text fg={theme.accent} width={moveLabelWidth()} wrapMode="none">
{progress()}
<span style={{ fg: theme.textMuted }}>{".".repeat(move.creatingDots())}</span>
</text>
</box>
)}
</Match>
<Match when={true}>{props.hint ?? <text />}</Match>
</Switch>
<Show when={status().type !== "retry"}>
@@ -0,0 +1,125 @@
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import { errorMessage } from "@/util/error"
import { useDialog } from "@tui/ui/dialog"
import { useSDK } from "@tui/context/sdk"
import { useSync } from "@tui/context/sync"
import { useToast } from "@tui/ui/toast"
import { DialogMoveSession, type MoveSessionSelection } from "../dialog-move-session"
import { DialogWorkspaceFileChanges } from "../dialog-workspace-file-changes"
import { useHomeSessionDestination } from "../../routes/home/session-destination"
export function usePromptMove(input: { projectID: () => string | undefined; sessionID: () => string | undefined }) {
const dialog = useDialog()
const sdk = useSDK()
const sync = useSync()
const toast = useToast()
const homeDestination = useHomeSessionDestination()
const [creating, setCreating] = createSignal(false)
const [creatingDots, setCreatingDots] = createSignal(3)
const [progress, setProgress] = createSignal<string>()
async function create() {
setCreating(true)
setProgress("Creating copy")
try {
const result = await sdk.client.worktree.create({}, { throwOnError: true })
const directory = result.data?.directory
if (!directory) throw new Error("No worktree directory returned")
setProgress("Creating session")
return directory
} catch (err) {
homeDestination?.clear()
setProgress(undefined)
setCreating(false)
toast.show({ title: "Creating workspace failed", message: errorMessage(err), variant: "error" })
return
}
}
function open() {
const projectID = input.projectID()
if (!projectID) return
dialog.replace(() => (
<DialogMoveSession
projectID={projectID}
onSelect={(selection) => {
const sessionID = input.sessionID()
if (!sessionID) {
homeDestination?.setDestination(selection)
dialog.clear()
return
}
void moveExistingSession(sessionID, selection)
}}
/>
))
}
async function moveExistingSession(sessionID: string, selection: MoveSessionSelection) {
const session = sync.session.get(sessionID)
const status = await sdk.client.vcs.status({ directory: session?.directory }).catch(() => undefined)
const choice = status?.data?.length ? await DialogWorkspaceFileChanges.show(dialog, status.data) : "no"
if (!choice) return
const directory = selection.type === "new" ? await create() : selection.directory
if (!directory) {
setProgress(undefined)
dialog.clear()
return
}
setProgress("Moving session")
await sdk.client.experimental.controlPlane
.moveSession(
{
sessionID,
destination: { directory },
moveChanges: choice === "yes",
},
{ throwOnError: true },
)
.then(() => dialog.clear())
.catch((error) => {
toast.error(error)
dialog.clear()
})
.finally(() => {
setProgress(undefined)
setCreating(false)
})
}
const pending = createMemo(() => Boolean(homeDestination?.destination()))
const directory = createMemo(() => {
const value = homeDestination?.destination()
return value?.type === "directory" ? value.directory : undefined
})
async function directoryForSubmit() {
const value = homeDestination?.destination()
if (!value) return
if (value.type === "directory") {
return value.directory
}
return await create()
}
function startSubmit() {
if (progress()) setProgress("Submitting prompt")
}
function finishSubmit() {
setProgress(undefined)
setCreating(false)
}
createEffect(() => {
if (!creating()) {
setCreatingDots(3)
return
}
const timer = setInterval(() => setCreatingDots((dots) => (dots % 3) + 1), 1000)
onCleanup(() => clearInterval(timer))
})
return { creating, creatingDots, directory, directoryForSubmit, finishSubmit, open, pending, progress, startSubmit }
}
@@ -0,0 +1,137 @@
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import { useDialog } from "@tui/ui/dialog"
import { useSDK } from "@tui/context/sdk"
import { useProject } from "@tui/context/project"
import { useSync } from "@tui/context/sync"
import { useToast } from "@tui/ui/toast"
import { errorMessage } from "@/util/error"
import {
confirmWorkspaceFileChanges,
openWorkspaceSelect,
warpWorkspaceSession,
type WorkspaceSelection,
} from "../dialog-workspace-create"
import type { WorkspaceStatus } from "../workspace-label"
export function usePromptWorkspace(sessionID?: string) {
const dialog = useDialog()
const sdk = useSDK()
const project = useProject()
const sync = useSync()
const toast = useToast()
const [selection, setSelection] = createSignal<WorkspaceSelection>()
const [creating, setCreating] = createSignal(false)
const [creatingDots, setCreatingDots] = createSignal(3)
const [notice, setNotice] = createSignal<string>()
async function create(selection: Extract<WorkspaceSelection, { type: "new" }>) {
setCreating(true)
let result
try {
result = await sdk.client.experimental.workspace.create({ type: selection.workspaceType, branch: null })
} catch (err) {
setSelection(undefined)
setCreating(false)
toast.show({ title: "Creating workspace failed", message: errorMessage(err), variant: "error" })
return
}
if (result.error || !result.data) {
setSelection(undefined)
setCreating(false)
toast.show({
title: "Creating workspace failed",
message: errorMessage(result.error ?? "no response"),
variant: "error",
})
return
}
await project.workspace.sync()
const workspace = result.data
setSelection({
type: "existing",
workspaceID: workspace.id,
workspaceType: workspace.type,
workspaceName: workspace.name,
})
setCreating(false)
return workspace
}
async function warp(selection: WorkspaceSelection) {
if (!sessionID) {
setSelection(selection)
dialog.clear()
if (selection.type === "new") void create(selection)
return
}
const sourceWorkspaceID = project.workspace.current()
const copyChanges = await confirmWorkspaceFileChanges({ dialog, sdk, sourceWorkspaceID })
if (copyChanges === undefined) return
setSelection(selection)
dialog.clear()
const workspace =
selection.type === "none"
? { id: null, name: "local project" }
: selection.type === "existing"
? { id: selection.workspaceID, name: selection.workspaceName }
: await create(selection)
if (!workspace) return
const warped = await warpWorkspaceSession({
dialog,
sdk,
sync,
project,
toast,
sourceWorkspaceID,
workspaceID: workspace.id,
sessionID,
copyChanges,
})
if (warped) showNotice(workspace.name)
}
function showNotice(name: string) {
setNotice(`Warped to ${name}`)
setTimeout(() => setNotice(undefined), 4000)
}
function clearNotice() {
setNotice(undefined)
}
function open() {
void openWorkspaceSelect({ dialog, sdk, sync, project, toast, onSelect: warp })
}
createEffect(() => {
if (!creating()) {
setCreatingDots(3)
return
}
const timer = setInterval(() => setCreatingDots((dots) => (dots % 3) + 1), 1000)
onCleanup(() => clearInterval(timer))
})
const label = createMemo<
| { type: "new"; workspaceType: string }
| { type: "existing"; workspaceType: string; workspaceName: string; status?: WorkspaceStatus }
| undefined
>(() => {
const selected = selection()
if (!selected) return
if (selected.type === "none") return
if (sessionID && !creating()) return
if (selected.type === "new") return { type: "new", workspaceType: selected.workspaceType }
return {
type: "existing",
workspaceType: selected.workspaceType,
workspaceName: selected.workspaceName,
status: selected.type === "existing" ? "connected" : undefined,
}
})
return { selection, creating, creatingDots, notice, label, open, warp, clearNotice }
}
@@ -204,6 +204,7 @@ export const Definitions = {
"dialog.select.submit": keybind("return", "Submit selected dialog item"),
"dialog.prompt.submit": keybind("return", "Submit dialog prompt"),
"dialog.mcp.toggle": keybind("space", "Toggle MCP in MCP dialog"),
"dialog.move_session.new": keybind("ctrl+w", "New project copy"),
"prompt.autocomplete.prev": keybind("up,ctrl+p", "Move to previous autocomplete item"),
"prompt.autocomplete.next": keybind("down,ctrl+n", "Move to next autocomplete item"),
"prompt.autocomplete.hide": keybind("escape", "Hide autocomplete"),
@@ -253,6 +253,22 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
break
}
case "session.next.moved": {
const result = Binary.search(store.session, event.properties.sessionID, (s) => s.id)
if (!result.found) break
setStore(
"session",
result.index,
produce((session) => {
session.directory = event.properties.location.directory
session.path = event.properties.subpath
session.workspaceID = event.properties.location.workspaceID
session.time.updated = event.properties.timestamp
}),
)
break
}
case "session.status": {
setStore("session_status", event.properties.sessionID, event.properties.status)
break
@@ -2,12 +2,17 @@ import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import type { InternalTuiPlugin } from "../../plugin/internal"
import { createMemo, Match, Show, Switch } from "solid-js"
import { Global } from "@opencode-ai/core/global"
import { useHomeSessionDestination } from "../../routes/home/session-destination"
const id = "internal:home-footer"
function Directory(props: { api: TuiPluginApi }) {
const theme = () => props.api.theme.current
const destination = useHomeSessionDestination()
const dir = createMemo(() => {
const selected = destination?.destination()
if (selected?.type === "new") return "(new working copy)"
if (selected?.type === "directory") return selected.directory.replace(Global.Path.home, "~")
const dir = props.api.state.path.directory || process.cwd()
const out = dir.replace(Global.Path.home, "~")
const branch = props.api.state.vcs?.branch
@@ -5,7 +5,7 @@ import { Global } from "@opencode-ai/core/global"
const id = "internal:sidebar-footer"
function View(props: { api: TuiPluginApi }) {
function View(props: { api: TuiPluginApi; sessionID: string }) {
const theme = () => props.api.theme.current
const has = createMemo(() =>
props.api.state.provider.some(
@@ -15,9 +15,11 @@ function View(props: { api: TuiPluginApi }) {
const done = createMemo(() => props.api.kv.get("dismissed_getting_started", false))
const show = createMemo(() => !has() && !done())
const path = createMemo(() => {
const dir = props.api.state.path.directory || process.cwd()
const session = props.api.state.session.get(props.sessionID)
const dir = session?.directory || props.api.state.path.directory || process.cwd()
const out = dir.replace(Global.Path.home, "~")
const text = props.api.state.vcs?.branch ? out + ":" + props.api.state.vcs.branch : out
const branch = session?.directory === props.api.state.path.directory ? props.api.state.vcs?.branch : undefined
const text = branch ? out + ":" + branch : out
const list = text.split("/")
return {
parent: list.slice(0, -1).join("/"),
@@ -79,8 +81,8 @@ const tui: TuiPlugin = async (api) => {
api.slots.register({
order: 100,
slots: {
sidebar_footer() {
return <View api={api} />
sidebar_footer(_ctx, props) {
return <View api={api} sessionID={props.session_id} />
},
},
})
@@ -11,6 +11,7 @@ import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
import { useEditorContext } from "@tui/context/editor"
import { useTerminalDimensions } from "@opentui/solid"
import { useTuiConfig } from "../context/tui-config"
import { HomeSessionDestinationProvider } from "./home/session-destination"
let once = false
const placeholder = {
@@ -66,7 +67,7 @@ export function Home() {
})
return (
<>
<HomeSessionDestinationProvider>
<box flexGrow={1} alignItems="center" paddingLeft={2} paddingRight={2}>
<box flexGrow={1} minHeight={0} />
<box height={4} minHeight={0} flexShrink={1} />
@@ -88,6 +89,6 @@ export function Home() {
<box width="100%" flexShrink={0}>
<TuiPluginRuntime.Slot name="home_footer" mode="single_winner" />
</box>
</>
</HomeSessionDestinationProvider>
)
}
@@ -0,0 +1,26 @@
import { createContext, createSignal, useContext, type Accessor, type ParentProps, type Setter } from "solid-js"
export type HomeSessionDestination = { type: "directory"; directory: string } | { type: "new" }
type Context = {
destination: Accessor<HomeSessionDestination | undefined>
setDestination: Setter<HomeSessionDestination | undefined>
clear: () => void
}
const HomeSessionDestinationContext = createContext<Context>()
export function HomeSessionDestinationProvider(props: ParentProps) {
const [destination, setDestination] = createSignal<HomeSessionDestination>()
return (
<HomeSessionDestinationContext.Provider
value={{ destination, setDestination, clear: () => setDestination(undefined) }}
>
{props.children}
</HomeSessionDestinationContext.Provider>
)
}
export function useHomeSessionDestination() {
return useContext(HomeSessionDestinationContext)
}
@@ -23,6 +23,7 @@ import { formatKeyBindings, useBindings, useKeymapSelector } from "../keymap"
export interface DialogSelectProps<T> {
title: string
placeholder?: string
footer?: JSX.Element
options: DialogSelectOption<T>[]
flat?: boolean
ref?: (ref: DialogSelectRef<T>) => void
@@ -49,10 +50,13 @@ export interface DialogSelectProps<T> {
export interface DialogSelectOption<T = any> {
title: string
titleView?: JSX.Element
value: T
description?: string
details?: string[]
footer?: JSX.Element | string
titleWidth?: number
truncateTitle?: boolean | "left"
category?: string
categoryView?: JSX.Element
disabled?: boolean
@@ -467,7 +471,10 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
</Show>
<Option
title={option.title}
titleView={option.titleView}
footer={flatten() ? (option.category ?? option.footer) : option.footer}
titleWidth={option.titleWidth}
truncateTitle={option.truncateTitle}
description={option.description !== category ? option.description : undefined}
active={active()}
current={current()}
@@ -493,7 +500,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
</scrollbox>
</Show>
</box>
<Show when={visibleActions().length} fallback={<box flexShrink={0} />}>
<Show when={props.footer || visibleActions().length} fallback={<box flexShrink={0} />}>
<box
paddingRight={2}
paddingLeft={4}
@@ -503,6 +510,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
paddingTop={1}
>
<box flexDirection="row" gap={2}>
{props.footer}
<For each={left()}>
{(item) => (
<text>
@@ -534,10 +542,13 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
function Option(props: {
title: string
titleView?: JSX.Element
description?: string
active?: boolean
current?: boolean
footer?: JSX.Element | string
titleWidth?: number
truncateTitle?: boolean | "left"
gutter?: () => JSX.Element
onMouseOver?: () => void
}) {
@@ -564,7 +575,12 @@ function Option(props: {
wrapMode="none"
paddingLeft={3}
>
{Locale.truncate(props.title, 61)}
{props.titleView ??
(props.truncateTitle === false
? props.title
: props.truncateTitle === "left"
? Locale.truncateLeft(props.title, props.titleWidth ?? 61)
: Locale.truncate(props.title, props.titleWidth ?? 61))}
<Show when={props.description}>
<span style={{ fg: props.active ? fg : theme.textMuted }}> {props.description}</span>
</Show>
@@ -5,6 +5,7 @@ import { InstanceDisposed } from "@/server/event"
import { Question } from "@/question"
import { ConfigApi } from "./groups/config"
import { ControlApi } from "./groups/control"
import { ControlPlaneApi } from "./groups/control-plane"
import { EventApi } from "./groups/event"
import { ExperimentalApi } from "./groups/experimental"
import { FileApi } from "./groups/file"
@@ -42,6 +43,7 @@ const EventSchema = Schema.Union([
export const RootHttpApi = HttpApi.make("opencode-root")
.addHttpApi(ControlApi)
.addHttpApi(ControlPlaneApi)
.addHttpApi(GlobalApi)
.middleware(SchemaErrorMiddleware)
.middleware(Authorization)
@@ -0,0 +1,35 @@
import { MoveSession } from "@opencode-ai/core/control-plane/move-session"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { described } from "./metadata"
const root = "/experimental/control-plane"
export const MoveSessionPayload = Schema.Struct({ ...MoveSession.Input.fields })
export class ApiMoveSessionError extends Schema.ErrorClass<ApiMoveSessionError>("MoveSessionError")(
{
name: Schema.Literal("MoveSessionError"),
data: Schema.Struct({
message: Schema.String,
}),
},
{ httpApiStatus: 400 },
) {}
export const ControlPlaneApi = HttpApi.make("controlPlane").add(
HttpApiGroup.make("controlPlane")
.add(
HttpApiEndpoint.post("moveSession", `${root}/move-session`, {
payload: MoveSessionPayload,
success: described(HttpApiSchema.NoContent, "Session moved"),
error: ApiMoveSessionError,
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.controlPlane.moveSession",
summary: "Move session",
description: "Move a session to another project directory, optionally transferring local changes.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "controlPlane", description: "Control-plane orchestration routes." })),
)
@@ -0,0 +1,35 @@
import { MoveSession } from "@opencode-ai/core/control-plane/move-session"
import { SessionV2 } from "@opencode-ai/core/session"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { RootHttpApi } from "../api"
import { ApiMoveSessionError, MoveSessionPayload } from "../groups/control-plane"
export const controlPlaneHandlers = HttpApiBuilder.group(RootHttpApi, "controlPlane", (handlers) =>
Effect.gen(function* () {
const service = yield* MoveSession.Service
const moveSession = Effect.fn("ControlPlaneHttpApi.moveSession")(function* (ctx: {
payload: typeof MoveSessionPayload.Type
}) {
yield* service.moveSession(ctx.payload).pipe(
Effect.mapError(
(error) =>
new ApiMoveSessionError({
name: "MoveSessionError",
data: { message: message(error) },
}),
),
)
})
return handlers.handle("moveSession", moveSession)
}),
)
function message(error: MoveSession.Error) {
if (error instanceof SessionV2.NotFoundError) return `Session not found: ${error.sessionID}`
if (error instanceof MoveSession.DestinationProjectMismatchError)
return "Destination directory belongs to another project"
return error.message
}
@@ -28,6 +28,7 @@ import { Plugin } from "@/plugin"
import { Project } from "@/project/project"
import { ProjectV2 } from "@opencode-ai/core/project"
import { ProjectCopy } from "@opencode-ai/core/project/copy"
import { MoveSession } from "@opencode-ai/core/control-plane/move-session"
import { ProviderAuth } from "@/provider/auth"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { Provider } from "@/provider/provider"
@@ -68,6 +69,7 @@ import { PtyConnectApi } from "./groups/pty"
import { eventHandlers } from "./handlers/event"
import { configHandlers } from "./handlers/config"
import { controlHandlers } from "./handlers/control"
import { controlPlaneHandlers } from "./handlers/control-plane"
import { experimentalHandlers } from "./handlers/experimental"
import { fileHandlers } from "./handlers/file"
import { globalHandlers } from "./handlers/global"
@@ -117,7 +119,7 @@ const ptyConnectHttpApiAuthLayer = ptyConnectAuthorizationLayer.pipe(Layer.provi
const v2HttpApiAuthLayer = v2AuthorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))
const workspaceRoutingLive = workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal))
const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe(
Layer.provide([controlHandlers, globalHandlers]),
Layer.provide([controlHandlers, controlPlaneHandlers, globalHandlers]),
Layer.provide(schemaErrorLayer),
Layer.provide(httpApiAuthLayer),
)
@@ -209,6 +211,7 @@ export function createRoutes(
Project.defaultLayer,
ProjectV2.defaultLayer,
ProjectCopy.defaultLayer,
MoveSession.defaultLayer,
ProviderAuth.defaultLayer,
Provider.defaultLayer,
PtyTicket.defaultLayer,
@@ -0,0 +1,63 @@
import { NodeHttpServer } from "@effect/platform-node"
import { describe, expect } from "bun:test"
import { Context, Effect, Layer, Option, Ref } from "effect"
import { HttpBody, HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { MoveSession } from "@opencode-ai/core/control-plane/move-session"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { Auth } from "../../src/auth"
import { Config } from "../../src/config/config"
import { Installation } from "../../src/installation"
import { ServerAuth } from "../../src/server/auth"
import { RootHttpApi } from "../../src/server/routes/instance/httpapi/api"
import { controlHandlers } from "../../src/server/routes/instance/httpapi/handlers/control"
import { controlPlaneHandlers } from "../../src/server/routes/instance/httpapi/handlers/control-plane"
import { globalHandlers } from "../../src/server/routes/instance/httpapi/handlers/global"
import { authorizationLayer } from "../../src/server/routes/instance/httpapi/middleware/authorization"
import { schemaErrorLayer } from "../../src/server/routes/instance/httpapi/middleware/schema-error"
import { testEffect } from "../lib/effect"
const input = MoveSession.Input.make({
sessionID: SessionV2.ID.make("ses_move"),
destination: { directory: AbsolutePath.make("/destination") },
moveChanges: true,
})
const called = Ref.makeUnsafe<MoveSession.Input | undefined>(undefined)
const apiLayer = HttpRouter.serve(
HttpApiBuilder.layer(RootHttpApi).pipe(
Layer.provide([controlHandlers, controlPlaneHandlers, globalHandlers]),
Layer.provide([authorizationLayer, schemaErrorLayer]),
// Raw HttpApi routes expose an opaque handler context at the request boundary.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
HttpRouter.provideRequest(Layer.succeedContext(Context.empty() as Context.Context<unknown>)),
),
{ disableListenLog: true, disableLogger: true },
).pipe(
Layer.provideMerge(NodeHttpServer.layerTest),
Layer.provide(Layer.mock(Auth.Service)({})),
Layer.provide(Layer.mock(Config.Service)({})),
Layer.provide(Layer.mock(Installation.Service)({})),
Layer.provide(
Layer.mock(MoveSession.Service)({
moveSession: (value) => Ref.set(called, value),
}),
),
Layer.provide(ServerAuth.Config.layer({ password: Option.none(), username: "opencode" })),
)
const it = testEffect(apiLayer)
describe("control-plane HttpApi", () => {
it.live("moves a session through the root control-plane route", () =>
Effect.gen(function* () {
const response = yield* HttpClientRequest.post("/experimental/control-plane/move-session").pipe(
HttpClientRequest.setBody(HttpBody.jsonUnsafe(input)),
HttpClient.execute,
)
expect(response.status).toBe(204)
expect(yield* Ref.get(called)).toEqual(input)
}),
)
})
@@ -6,10 +6,12 @@ import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Auth } from "../../src/auth"
import { Config } from "../../src/config/config"
import { Installation } from "../../src/installation"
import { MoveSession } from "@opencode-ai/core/control-plane/move-session"
import { ServerAuth } from "../../src/server/auth"
import { RootHttpApi } from "../../src/server/routes/instance/httpapi/api"
import { GlobalPaths } from "../../src/server/routes/instance/httpapi/groups/global"
import { controlHandlers } from "../../src/server/routes/instance/httpapi/handlers/control"
import { controlPlaneHandlers } from "../../src/server/routes/instance/httpapi/handlers/control-plane"
import { globalHandlers } from "../../src/server/routes/instance/httpapi/handlers/global"
import { authorizationLayer } from "../../src/server/routes/instance/httpapi/middleware/authorization"
import { schemaErrorLayer } from "../../src/server/routes/instance/httpapi/middleware/schema-error"
@@ -17,7 +19,7 @@ import { testEffect } from "../lib/effect"
const apiLayer = HttpRouter.serve(
HttpApiBuilder.layer(RootHttpApi).pipe(
Layer.provide([controlHandlers, globalHandlers]),
Layer.provide([controlHandlers, controlPlaneHandlers, globalHandlers]),
Layer.provide([authorizationLayer, schemaErrorLayer]),
// Raw HttpApi routes expose an opaque handler context at the request boundary.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
@@ -28,6 +30,7 @@ const apiLayer = HttpRouter.serve(
Layer.provideMerge(NodeHttpServer.layerTest),
Layer.provide(Layer.mock(Auth.Service)({})),
Layer.provide(Layer.mock(Config.Service)({})),
Layer.provide(Layer.mock(MoveSession.Service)({})),
Layer.provide(
Layer.mock(Installation.Service)({
method: () => Effect.succeed("npm"),
+274 -223
View File
@@ -34,6 +34,8 @@ import type {
ExperimentalConsoleListOrgsErrors,
ExperimentalConsoleListOrgsResponses,
ExperimentalConsoleSwitchOrgResponses,
ExperimentalControlPlaneMoveSessionErrors,
ExperimentalControlPlaneMoveSessionResponses,
ExperimentalProjectCopyCreateErrors,
ExperimentalProjectCopyCreateResponses,
ExperimentalProjectCopyRefreshErrors,
@@ -108,6 +110,7 @@ import type {
McpRemoteConfig,
McpStatusErrors,
McpStatusResponses,
MoveSessionDestination,
OutputFormat,
Part as Part2,
PartDeleteErrors,
@@ -516,33 +519,38 @@ export class App extends HeyApiClient {
}
}
export class Config extends HeyApiClient {
export class ControlPlane extends HeyApiClient {
/**
* Get global configuration
* Move session
*
* Retrieve the current global OpenCode configuration settings and preferences.
* Move a session to another project directory, optionally transferring local changes.
*/
public get<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) {
return (options?.client ?? this.client).get<GlobalConfigGetResponses, GlobalConfigGetErrors, ThrowOnError>({
url: "/global/config",
...options,
})
}
/**
* Update global configuration
*
* Update global OpenCode configuration settings and preferences.
*/
public update<ThrowOnError extends boolean = false>(
public moveSession<ThrowOnError extends boolean = false>(
parameters?: {
config?: Config3
sessionID?: string
destination?: MoveSessionDestination
moveChanges?: boolean
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams([parameters], [{ args: [{ key: "config", map: "body" }] }])
return (options?.client ?? this.client).patch<GlobalConfigUpdateResponses, GlobalConfigUpdateErrors, ThrowOnError>({
url: "/global/config",
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "body", key: "sessionID" },
{ in: "body", key: "destination" },
{ in: "body", key: "moveChanges" },
],
},
],
)
return (options?.client ?? this.client).post<
ExperimentalControlPlaneMoveSessionResponses,
ExperimentalControlPlaneMoveSessionErrors,
ThrowOnError
>({
url: "/experimental/control-plane/move-session",
...options,
...params,
headers: {
@@ -554,204 +562,6 @@ export class Config extends HeyApiClient {
}
}
export class Global extends HeyApiClient {
/**
* Get health
*
* Get health information about the OpenCode server.
*/
public health<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) {
return (options?.client ?? this.client).get<GlobalHealthResponses, GlobalHealthErrors, ThrowOnError>({
url: "/global/health",
...options,
})
}
/**
* Get global events
*
* Subscribe to global events from the OpenCode system using server-sent events.
*/
public event<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) {
return (options?.client ?? this.client).sse.get<GlobalEventResponses, GlobalEventErrors, ThrowOnError>({
url: "/global/event",
...options,
})
}
/**
* Dispose instance
*
* Clean up and dispose all OpenCode instances, releasing all resources.
*/
public dispose<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) {
return (options?.client ?? this.client).post<GlobalDisposeResponses, GlobalDisposeErrors, ThrowOnError>({
url: "/global/dispose",
...options,
})
}
/**
* Upgrade opencode
*
* Upgrade opencode to the specified version or latest if not specified.
*/
public upgrade<ThrowOnError extends boolean = false>(
parameters?: {
target?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams([parameters], [{ args: [{ in: "body", key: "target" }] }])
return (options?.client ?? this.client).post<GlobalUpgradeResponses, GlobalUpgradeErrors, ThrowOnError>({
url: "/global/upgrade",
...options,
...params,
headers: {
"Content-Type": "application/json",
...options?.headers,
...params.headers,
},
})
}
private _config?: Config
get config(): Config {
return (this._config ??= new Config({ client: this.client }))
}
}
export class Event extends HeyApiClient {
/**
* Subscribe to events
*
* Get events
*/
public subscribe<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
workspace?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
],
},
],
)
return (options?.client ?? this.client).sse.get<EventSubscribeResponses, unknown, ThrowOnError>({
url: "/event",
...options,
...params,
})
}
}
export class Config2 extends HeyApiClient {
/**
* Get configuration
*
* Retrieve the current OpenCode configuration settings and preferences.
*/
public get<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
workspace?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
],
},
],
)
return (options?.client ?? this.client).get<ConfigGetResponses, ConfigGetErrors, ThrowOnError>({
url: "/config",
...options,
...params,
})
}
/**
* Update configuration
*
* Update OpenCode configuration settings and preferences.
*/
public update<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
workspace?: string
config?: Config3
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
{ key: "config", map: "body" },
],
},
],
)
return (options?.client ?? this.client).patch<ConfigUpdateResponses, ConfigUpdateErrors, ThrowOnError>({
url: "/config",
...options,
...params,
headers: {
"Content-Type": "application/json",
...options?.headers,
...params.headers,
},
})
}
/**
* List config providers
*
* Get a list of all configured AI providers and their default models.
*/
public providers<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
workspace?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
],
},
],
)
return (options?.client ?? this.client).get<ConfigProvidersResponses, ConfigProvidersErrors, ThrowOnError>({
url: "/config/providers",
...options,
...params,
})
}
}
export class Console extends HeyApiClient {
/**
* Get active Console provider metadata
@@ -1361,6 +1171,11 @@ export class Workspace extends HeyApiClient {
}
export class Experimental extends HeyApiClient {
private _controlPlane?: ControlPlane
get controlPlane(): ControlPlane {
return (this._controlPlane ??= new ControlPlane({ client: this.client }))
}
private _console?: Console
get console(): Console {
return (this._console ??= new Console({ client: this.client }))
@@ -1387,6 +1202,242 @@ export class Experimental extends HeyApiClient {
}
}
export class Config extends HeyApiClient {
/**
* Get global configuration
*
* Retrieve the current global OpenCode configuration settings and preferences.
*/
public get<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) {
return (options?.client ?? this.client).get<GlobalConfigGetResponses, GlobalConfigGetErrors, ThrowOnError>({
url: "/global/config",
...options,
})
}
/**
* Update global configuration
*
* Update global OpenCode configuration settings and preferences.
*/
public update<ThrowOnError extends boolean = false>(
parameters?: {
config?: Config3
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams([parameters], [{ args: [{ key: "config", map: "body" }] }])
return (options?.client ?? this.client).patch<GlobalConfigUpdateResponses, GlobalConfigUpdateErrors, ThrowOnError>({
url: "/global/config",
...options,
...params,
headers: {
"Content-Type": "application/json",
...options?.headers,
...params.headers,
},
})
}
}
export class Global extends HeyApiClient {
/**
* Get health
*
* Get health information about the OpenCode server.
*/
public health<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) {
return (options?.client ?? this.client).get<GlobalHealthResponses, GlobalHealthErrors, ThrowOnError>({
url: "/global/health",
...options,
})
}
/**
* Get global events
*
* Subscribe to global events from the OpenCode system using server-sent events.
*/
public event<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) {
return (options?.client ?? this.client).sse.get<GlobalEventResponses, GlobalEventErrors, ThrowOnError>({
url: "/global/event",
...options,
})
}
/**
* Dispose instance
*
* Clean up and dispose all OpenCode instances, releasing all resources.
*/
public dispose<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) {
return (options?.client ?? this.client).post<GlobalDisposeResponses, GlobalDisposeErrors, ThrowOnError>({
url: "/global/dispose",
...options,
})
}
/**
* Upgrade opencode
*
* Upgrade opencode to the specified version or latest if not specified.
*/
public upgrade<ThrowOnError extends boolean = false>(
parameters?: {
target?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams([parameters], [{ args: [{ in: "body", key: "target" }] }])
return (options?.client ?? this.client).post<GlobalUpgradeResponses, GlobalUpgradeErrors, ThrowOnError>({
url: "/global/upgrade",
...options,
...params,
headers: {
"Content-Type": "application/json",
...options?.headers,
...params.headers,
},
})
}
private _config?: Config
get config(): Config {
return (this._config ??= new Config({ client: this.client }))
}
}
export class Event extends HeyApiClient {
/**
* Subscribe to events
*
* Get events
*/
public subscribe<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
workspace?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
],
},
],
)
return (options?.client ?? this.client).sse.get<EventSubscribeResponses, unknown, ThrowOnError>({
url: "/event",
...options,
...params,
})
}
}
export class Config2 extends HeyApiClient {
/**
* Get configuration
*
* Retrieve the current OpenCode configuration settings and preferences.
*/
public get<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
workspace?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
],
},
],
)
return (options?.client ?? this.client).get<ConfigGetResponses, ConfigGetErrors, ThrowOnError>({
url: "/config",
...options,
...params,
})
}
/**
* Update configuration
*
* Update OpenCode configuration settings and preferences.
*/
public update<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
workspace?: string
config?: Config3
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
{ key: "config", map: "body" },
],
},
],
)
return (options?.client ?? this.client).patch<ConfigUpdateResponses, ConfigUpdateErrors, ThrowOnError>({
url: "/config",
...options,
...params,
headers: {
"Content-Type": "application/json",
...options?.headers,
...params.headers,
},
})
}
/**
* List config providers
*
* Get a list of all configured AI providers and their default models.
*/
public providers<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
workspace?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
],
},
],
)
return (options?.client ?? this.client).get<ConfigProvidersResponses, ConfigProvidersErrors, ThrowOnError>({
url: "/config/providers",
...options,
...params,
})
}
}
export class Tool extends HeyApiClient {
/**
* List tools
@@ -5482,6 +5533,11 @@ export class OpencodeClient extends HeyApiClient {
return (this._app ??= new App({ client: this.client }))
}
private _experimental?: Experimental
get experimental(): Experimental {
return (this._experimental ??= new Experimental({ client: this.client }))
}
private _global?: Global
get global(): Global {
return (this._global ??= new Global({ client: this.client }))
@@ -5497,11 +5553,6 @@ export class OpencodeClient extends HeyApiClient {
return (this._config ??= new Config2({ client: this.client }))
}
private _experimental?: Experimental
get experimental(): Experimental {
return (this._experimental ??= new Experimental({ client: this.client }))
}
private _tool?: Tool
get tool(): Tool {
return (this._tool ??= new Tool({ client: this.client }))
+84 -5
View File
@@ -17,6 +17,7 @@ export type Event =
| EventMessagePartRemoved
| EventSessionNextAgentSwitched
| EventSessionNextModelSwitched
| EventSessionNextMoved
| EventSessionNextPrompted
| EventSessionNextSynthetic
| EventSessionNextShellStarted
@@ -133,6 +134,13 @@ export type InvalidRequestError = {
field?: string
}
export type MoveSessionError = {
name: "MoveSessionError"
data: {
message: string
}
}
export type SnapshotFileDiff = {
file?: string
patch?: string
@@ -809,6 +817,16 @@ export type GlobalEvent = {
}
}
}
| {
id: string
type: "session.next.moved"
properties: {
timestamp: number
sessionID: string
location: LocationRef
subpath?: string
}
}
| {
id: string
type: "session.next.prompted"
@@ -1492,6 +1510,7 @@ export type GlobalEvent = {
| SyncEventMessagePartRemoved
| SyncEventSessionNextAgentSwitched
| SyncEventSessionNextModelSwitched
| SyncEventSessionNextMoved
| SyncEventSessionNextPrompted
| SyncEventSessionNextSynthetic
| SyncEventSessionNextShellStarted
@@ -2683,6 +2702,10 @@ export type EventTuiSessionSelect2 = {
}
}
export type MoveSessionDestination = {
directory: string
}
export type ModelV2Info = {
id: string
apiID: string
@@ -2781,6 +2804,11 @@ export type ModelV2Info = {
}
}
export type LocationRef = {
directory: string
workspaceID?: string
}
export type PromptSource = {
start: number
end: number
@@ -2998,6 +3026,20 @@ export type SyncEventSessionNextModelSwitched = {
}
}
export type SyncEventSessionNextMoved = {
type: "sync"
name: "session.next.moved.1"
id: string
seq: number
aggregateID: "sessionID"
data: {
timestamp: number
sessionID: string
location: LocationRef
subpath?: string
}
}
export type SyncEventSessionNextPrompted = {
type: "sync"
name: "session.next.prompted.1"
@@ -3380,11 +3422,6 @@ export type ProjectCopyCopy = {
directory: string
}
export type LocationRef = {
directory: string
workspaceID?: string
}
export type SessionV2Info = {
id: string
parentID?: string
@@ -3937,6 +3974,17 @@ export type EventSessionNextModelSwitched = {
}
}
export type EventSessionNextMoved = {
id: string
type: "session.next.moved"
properties: {
timestamp: number
sessionID: string
location: LocationRef
subpath?: string
}
}
export type EventSessionNextPrompted = {
id: string
type: "session.next.prompted"
@@ -4728,6 +4776,37 @@ export type AppLogResponses = {
export type AppLogResponse = AppLogResponses[keyof AppLogResponses]
export type ExperimentalControlPlaneMoveSessionData = {
body?: {
sessionID: string
destination: MoveSessionDestination
moveChanges?: boolean
}
path?: never
query?: never
url: "/experimental/control-plane/move-session"
}
export type ExperimentalControlPlaneMoveSessionErrors = {
/**
* MoveSessionError | InvalidRequestError
*/
400: MoveSessionError | InvalidRequestError
}
export type ExperimentalControlPlaneMoveSessionError =
ExperimentalControlPlaneMoveSessionErrors[keyof ExperimentalControlPlaneMoveSessionErrors]
export type ExperimentalControlPlaneMoveSessionResponses = {
/**
* Session moved
*/
204: void
}
export type ExperimentalControlPlaneMoveSessionResponse =
ExperimentalControlPlaneMoveSessionResponses[keyof ExperimentalControlPlaneMoveSessionResponses]
export type GlobalHealthData = {
body?: never
path?: never
+227 -13
View File
@@ -212,6 +212,66 @@
]
}
},
"/experimental/control-plane/move-session": {
"post": {
"tags": ["controlPlane"],
"operationId": "experimental.controlPlane.moveSession",
"parameters": [],
"responses": {
"204": {
"description": "Session moved"
},
"400": {
"description": "MoveSessionError | InvalidRequestError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/MoveSessionError"
},
{
"$ref": "#/components/schemas/InvalidRequestError"
}
]
}
}
}
}
},
"description": "Move a session to another project directory, optionally transferring local changes.",
"summary": "Move session",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"sessionID": {
"type": "string",
"pattern": "^ses"
},
"destination": {
"$ref": "#/components/schemas/MoveSessionDestination"
},
"moveChanges": {
"type": "boolean"
}
},
"required": ["sessionID", "destination"],
"additionalProperties": false
}
}
}
},
"x-codeSamples": [
{
"lang": "js",
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.controlPlane.moveSession({\n ...\n})"
}
]
}
},
"/global/health": {
"get": {
"tags": ["global"],
@@ -11324,6 +11384,9 @@
{
"$ref": "#/components/schemas/EventSessionNextModelSwitched"
},
{
"$ref": "#/components/schemas/EventSessionNextMoved"
},
{
"$ref": "#/components/schemas/EventSessionNextPrompted"
},
@@ -11674,6 +11737,27 @@
"required": ["_tag", "message"],
"additionalProperties": false
},
"MoveSessionError": {
"type": "object",
"properties": {
"name": {
"type": "string",
"enum": ["MoveSessionError"]
},
"data": {
"type": "object",
"properties": {
"message": {
"type": "string"
}
},
"required": ["message"],
"additionalProperties": false
}
},
"required": ["name", "data"],
"additionalProperties": false
},
"SnapshotFileDiff": {
"type": "object",
"properties": {
@@ -13718,6 +13802,40 @@
"required": ["id", "type", "properties"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string",
"enum": ["session.next.moved"]
},
"properties": {
"type": "object",
"properties": {
"timestamp": {
"type": "number"
},
"sessionID": {
"type": "string",
"pattern": "^ses"
},
"location": {
"$ref": "#/components/schemas/LocationRef"
},
"subpath": {
"type": "string"
}
},
"required": ["timestamp", "sessionID", "location"],
"additionalProperties": false
}
},
"required": ["id", "type", "properties"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
@@ -15970,6 +16088,9 @@
{
"$ref": "#/components/schemas/SyncEventSessionNextModelSwitched"
},
{
"$ref": "#/components/schemas/SyncEventSessionNextMoved"
},
{
"$ref": "#/components/schemas/SyncEventSessionNextPrompted"
},
@@ -19294,6 +19415,16 @@
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"MoveSessionDestination": {
"type": "object",
"properties": {
"directory": {
"type": "string"
}
},
"required": ["directory"],
"additionalProperties": false
},
"ModelV2Info": {
"type": "object",
"properties": {
@@ -19618,6 +19749,19 @@
],
"additionalProperties": false
},
"LocationRef": {
"type": "object",
"properties": {
"directory": {
"type": "string"
},
"workspaceID": {
"type": "string"
}
},
"required": ["directory"],
"additionalProperties": false
},
"PromptSource": {
"type": "object",
"properties": {
@@ -20277,6 +20421,51 @@
"required": ["type", "name", "id", "seq", "aggregateID", "data"],
"additionalProperties": false
},
"SyncEventSessionNextMoved": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["sync"]
},
"name": {
"type": "string",
"enum": ["session.next.moved.1"]
},
"id": {
"type": "string"
},
"seq": {
"type": "number"
},
"aggregateID": {
"type": "string",
"enum": ["sessionID"]
},
"data": {
"type": "object",
"properties": {
"timestamp": {
"type": "number"
},
"sessionID": {
"type": "string",
"pattern": "^ses"
},
"location": {
"$ref": "#/components/schemas/LocationRef"
},
"subpath": {
"type": "string"
}
},
"required": ["timestamp", "sessionID", "location"],
"additionalProperties": false
}
},
"required": ["type", "name", "id", "seq", "aggregateID", "data"],
"additionalProperties": false
},
"SyncEventSessionNextPrompted": {
"type": "object",
"properties": {
@@ -21481,19 +21670,6 @@
"required": ["directory"],
"additionalProperties": false
},
"LocationRef": {
"type": "object",
"properties": {
"directory": {
"type": "string"
},
"workspaceID": {
"type": "string"
}
},
"required": ["directory"],
"additionalProperties": false
},
"SessionV2Info": {
"type": "object",
"properties": {
@@ -23152,6 +23328,40 @@
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"EventSessionNextMoved": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string",
"enum": ["session.next.moved"]
},
"properties": {
"type": "object",
"properties": {
"timestamp": {
"type": "number"
},
"sessionID": {
"type": "string",
"pattern": "^ses"
},
"location": {
"$ref": "#/components/schemas/LocationRef"
},
"subpath": {
"type": "string"
}
},
"required": ["timestamp", "sessionID", "location"],
"additionalProperties": false
}
},
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"EventSessionNextPrompted": {
"type": "object",
"properties": {
@@ -25271,6 +25481,10 @@
"name": "control",
"description": "Control plane routes."
},
{
"name": "controlPlane",
"description": "Control-plane orchestration routes."
},
{
"name": "global",
"description": "Global server routes."