From c726640517151d7651d326cfe807a331265ba297 Mon Sep 17 00:00:00 2001 From: Habeeb Date: Wed, 8 Jul 2026 20:47:06 +0530 Subject: [PATCH 01/37] feat(vcs): add stash and stashPop endpoints --- packages/opencode/src/project/vcs.ts | 25 +++++++++++++++++++ .../instance/httpapi/groups/instance.ts | 24 ++++++++++++++++++ .../instance/httpapi/handlers/instance.ts | 10 ++++++++ 3 files changed, 59 insertions(+) diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index eca56c0501a1..72b451e69c62 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -278,6 +278,11 @@ export class PatchApplyError extends Schema.TaggedErrorClass()( reason: Schema.Literals(["non-git", "not-clean"]), }) {} +export const StashInput = Schema.Struct({ + message: Schema.String, +}) +export type StashInput = Schema.Schema.Type + export interface Interface { readonly init: () => Effect.Effect readonly branch: () => Effect.Effect @@ -286,6 +291,8 @@ export interface Interface { readonly diff: (mode: Mode, options?: DiffOptions) => Effect.Effect readonly diffRaw: () => Effect.Effect readonly apply: (input: ApplyInput) => Effect.Effect + readonly stash: (input: StashInput) => Effect.Effect + readonly stashPop: (input: StashInput) => Effect.Effect } interface State { @@ -414,6 +421,24 @@ const layer: Layer.Layer = } return { applied: true } }), + stash: Effect.fn("Vcs.stash")(function* (input: StashInput) { + const ctx = yield* InstanceState.context + if (ctx.project.vcs !== "git") return + yield* git.run(["stash", "push", "-m", input.message], { cwd: ctx.directory }) + }), + stashPop: Effect.fn("Vcs.stashPop")(function* (input: StashInput) { + const ctx = yield* InstanceState.context + if (ctx.project.vcs !== "git") return false + const list = yield* git.run(["stash", "list"], { cwd: ctx.directory }) + const ref = list + .text() + .split(/\r?\n/) + .find((line) => line.includes(input.message)) + ?.split(":")[0] + if (!ref) return false + yield* git.run(["stash", "pop", ref], { cwd: ctx.directory }) + return true + }), }) }), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts index 6fd18c8624c7..c67317ffe09b 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts @@ -48,6 +48,8 @@ export const InstancePaths = { vcsDiff: "/vcs/diff", vcsDiffRaw: "/vcs/diff/raw", vcsApply: "/vcs/apply", + vcsStash: "/vcs/stash", + vcsStashPop: "/vcs/stash-pop", command: "/command", agent: "/agent", skill: "/skill", @@ -136,6 +138,28 @@ export const InstanceApi = HttpApi.make("instance") description: "Apply a raw patch to the current working tree.", }), ), + HttpApiEndpoint.post("vcsStash", InstancePaths.vcsStash, { + query: WorkspaceRoutingQuery, + payload: Vcs.StashInput, + success: described(HttpApiSchema.NoContent, "Changes stashed"), + }).annotateMerge( + OpenApi.annotations({ + identifier: "vcs.stash", + summary: "Stash changes", + description: "Stash uncommitted changes with a message.", + }), + ), + HttpApiEndpoint.post("vcsStashPop", InstancePaths.vcsStashPop, { + query: WorkspaceRoutingQuery, + payload: Vcs.StashInput, + success: described(Schema.Boolean, "Stash popped"), + }).annotateMerge( + OpenApi.annotations({ + identifier: "vcs.stashPop", + summary: "Pop stashed changes", + description: "Pop a stash with a matching message.", + }), + ), HttpApiEndpoint.get("command", InstancePaths.command, { query: WorkspaceRoutingQuery, success: described(Schema.Array(Command.Info), "List of commands"), diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/instance.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/instance.ts index f851b3a31005..3294255986a4 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/instance.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/instance.ts @@ -73,6 +73,14 @@ export const instanceHandlers = HttpApiBuilder.group(InstanceHttpApi, "instance" ) }) + const stashVcs = Effect.fn("InstanceHttpApi.vcsStash")(function* (ctx: { payload: Vcs.StashInput }) { + yield* vcs.stash(ctx.payload) + }) + + const stashPopVcs = Effect.fn("InstanceHttpApi.vcsStashPop")(function* (ctx: { payload: Vcs.StashInput }) { + return yield* vcs.stashPop(ctx.payload) + }) + const getCommand = Effect.fn("InstanceHttpApi.command")(function* () { return yield* command.list() }) @@ -101,6 +109,8 @@ export const instanceHandlers = HttpApiBuilder.group(InstanceHttpApi, "instance" .handle("vcsDiff", getVcsDiff) .handle("vcsDiffRaw", getVcsDiffRaw) .handle("vcsApply", applyVcs) + .handle("vcsStash", stashVcs) + .handle("vcsStashPop", stashPopVcs) .handle("command", getCommand) .handle("agent", getAgent) .handle("skill", getSkill) From fdef07639db92d373de8c5e0a0d8970a31782cdf Mon Sep 17 00:00:00 2001 From: Habeeb Date: Wed, 8 Jul 2026 20:49:24 +0530 Subject: [PATCH 02/37] feat(workspace): add name to CreateInput, directory to SessionWarpInput --- packages/opencode/src/control-plane/workspace.ts | 4 +++- .../server/routes/instance/httpapi/groups/workspace.ts | 1 + .../server/routes/instance/httpapi/handlers/workspace.ts | 1 + packages/opencode/src/session/session.ts | 9 ++++++--- 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index 8f746e2568a8..4dc423c81df5 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -64,6 +64,7 @@ export const CreateInput = Schema.Struct({ type: Info.fields.type, branch: Info.fields.branch, projectID: ProjectV2.ID, + name: Schema.optional(Info.fields.name), extra: Schema.optional(Info.fields.extra), }) export type CreateInput = Schema.Schema.Type @@ -72,6 +73,7 @@ export const SessionWarpInput = Schema.Struct({ workspaceID: Schema.NullOr(WorkspaceV2.ID), sessionID: SessionID, copyChanges: Schema.optional(Schema.Boolean), + directory: Schema.optional(Schema.String), }) export type SessionWarpInput = Schema.Schema.Type @@ -495,7 +497,7 @@ const layer = Layer.effect( const config = yield* WorkspaceAdapterRuntime.configure(adapter, { ...input, id, - name: Slug.create(), + name: input.name || Slug.create(), directory: null, extra: input.extra ?? null, }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts index 09a6e67b6e9b..6198b84896ad 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts @@ -15,6 +15,7 @@ export const WarpPayload = Schema.Struct({ id: Schema.NullOr(Workspace.Info.fields.id), sessionID: Workspace.SessionWarpInput.fields.sessionID, copyChanges: Workspace.SessionWarpInput.fields.copyChanges, + directory: Workspace.SessionWarpInput.fields.directory, }) export class ApiWorkspaceWarpError extends Schema.ErrorClass("WorkspaceWarpError")( diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts index 7f5c437d9d58..b7bd9049c4c9 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts @@ -67,6 +67,7 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac workspaceID: ctx.payload.id, sessionID: ctx.payload.sessionID, copyChanges: ctx.payload.copyChanges, + directory: ctx.payload.directory, }) .pipe( Effect.mapError((error) => { diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index de8c3dc4cbd1..8b13cf26e70e 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -816,10 +816,13 @@ const layer: Layer.Layer< const setWorkspace = Effect.fn("Session.setWorkspace")(function* (input: { sessionID: SessionID workspaceID: Info["workspaceID"] + directory?: string }) { - yield* patch(input.sessionID, { workspaceID: input.workspaceID, time: { updated: Date.now() } }).pipe( - Effect.orDie, - ) + yield* patch(input.sessionID, { + workspaceID: input.workspaceID, + ...(input.directory !== undefined ? { directory: input.directory } : {}), + time: { updated: Date.now() }, + }).pipe(Effect.orDie) }) const diff = Effect.fn("Session.diff")(function* (sessionID: SessionID) { From f9f189c8f86044f1c0907ccdb5e484e54c559c46 Mon Sep 17 00:00:00 2001 From: Habeeb Date: Wed, 8 Jul 2026 20:50:27 +0530 Subject: [PATCH 03/37] fix(worktree): return branch and set detached=false in configure --- packages/opencode/src/control-plane/adapters/worktree.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/control-plane/adapters/worktree.ts b/packages/opencode/src/control-plane/adapters/worktree.ts index 87e30e1139be..dd8d96ec0d35 100644 --- a/packages/opencode/src/control-plane/adapters/worktree.ts +++ b/packages/opencode/src/control-plane/adapters/worktree.ts @@ -32,13 +32,14 @@ export const WorktreeAdapter: WorkspaceAdapter = { const { AppRuntime, Worktree } = await loadWorktree() const next = await AppRuntime.runPromise( provideContext( - Worktree.Service.use((svc) => svc.makeWorktreeInfo({ detached: true })), + Worktree.Service.use((svc) => svc.makeWorktreeInfo({ detached: false })), context, ), ) return { ...info, name: next.name, + branch: next.branch, directory: next.directory, } }, From 369553bfa8deba7bbeda00ca188f036335a0c3d1 Mon Sep 17 00:00:00 2001 From: Habeeb Date: Wed, 8 Jul 2026 21:16:08 +0530 Subject: [PATCH 04/37] feat(workspace): reorder startSync flag, detach sessions on remove --- .../opencode/src/control-plane/workspace.ts | 21 +++++++------------ 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index 4dc423c81df5..37d934be9092 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -441,8 +441,6 @@ const layer = Layer.effect( }) const startSync = Effect.fn("Workspace.startSync")(function* (space: Info) { - if (!flags.experimentalWorkspaces) return - const target = yield* WorkspaceAdapterRuntime.target(space).pipe( Effect.catch((error) => Effect.gen(function* () { @@ -462,6 +460,8 @@ const layer = Layer.effect( return } + if (!flags.experimentalWorkspaces) return + const exists = yield* FiberMap.has(syncFibers, space.id) if (exists && connections.get(space.id)?.status !== "error") return @@ -623,7 +623,7 @@ const layer = Layer.effect( } if (input.workspaceID === null) { - yield* session.setWorkspace({ sessionID: input.sessionID, workspaceID: undefined }) + yield* session.setWorkspace({ sessionID: input.sessionID, workspaceID: undefined, directory: input.directory }) return } @@ -785,19 +785,12 @@ const layer = Layer.effect( }) const remove = Effect.fn("Workspace.remove")(function* (id: WorkspaceV2.ID) { - const sessions = yield* db - .select({ id: SessionTable.id, parentID: SessionTable.parent_id }) - .from(SessionTable) + yield* db + .update(SessionTable) + .set({ workspace_id: null, directory: "" }) .where(eq(SessionTable.workspace_id, id)) - .all() + .run() .pipe(Effect.orDie) - const sessionIDs = new Set(sessions.map((sessionInfo) => sessionInfo.id)) - yield* Effect.forEach( - sessions.filter((sessionInfo) => !sessionInfo.parentID || !sessionIDs.has(sessionInfo.parentID)), - (sessionInfo) => - session.remove(sessionInfo.id).pipe(Effect.catchIf(NotFoundError.isInstance, () => Effect.void)), - { discard: true }, - ) const row = yield* db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get().pipe(Effect.orDie) if (!row) return From 8a1421ad1d3e2136afe62bae69c3b694d7d693a5 Mon Sep 17 00:00:00 2001 From: Habeeb Date: Wed, 8 Jul 2026 21:16:16 +0530 Subject: [PATCH 05/37] fix(workspace): use instance.directory when warping to local --- .../src/server/routes/instance/httpapi/handlers/workspace.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts index b7bd9049c4c9..81226d5ace86 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts @@ -62,12 +62,13 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac }) const warp = Effect.fn("WorkspaceHttpApi.warp")(function* (ctx: { payload: typeof WarpPayload.Type }) { + const instance = yield* InstanceState.context yield* workspace .sessionWarp({ workspaceID: ctx.payload.id, sessionID: ctx.payload.sessionID, copyChanges: ctx.payload.copyChanges, - directory: ctx.payload.directory, + directory: ctx.payload.id === null ? instance.directory : undefined, }) .pipe( Effect.mapError((error) => { From b4e06d1833c6494e112e13b9bc4e82b1cc1a0af3 Mon Sep 17 00:00:00 2001 From: Habeeb Date: Wed, 8 Jul 2026 21:16:22 +0530 Subject: [PATCH 06/37] fix(worktree): rename to Linked workspace, pass name, return branch --- packages/opencode/src/control-plane/adapters/worktree.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/control-plane/adapters/worktree.ts b/packages/opencode/src/control-plane/adapters/worktree.ts index dd8d96ec0d35..62fff2370b03 100644 --- a/packages/opencode/src/control-plane/adapters/worktree.ts +++ b/packages/opencode/src/control-plane/adapters/worktree.ts @@ -26,21 +26,23 @@ const provideContext = (effect: Effect.Effect, context: Worksp ) export const WorktreeAdapter: WorkspaceAdapter = { - name: "Worktree", + name: "Linked workspace", description: "Create a git worktree", async configure(info, context) { const { AppRuntime, Worktree } = await loadWorktree() const next = await AppRuntime.runPromise( provideContext( - Worktree.Service.use((svc) => svc.makeWorktreeInfo({ detached: false })), + Worktree.Service.use((svc) => + svc.makeWorktreeInfo({ detached: false, ...(info.name ? { name: info.name } : {}) }), + ), context, ), ) return { ...info, name: next.name, - branch: next.branch, directory: next.directory, + branch: next.branch ?? null, } }, async create(info, _env, _from, context) { From b8395644849d497263bbc0f3d4fd4c85e571f04e Mon Sep 17 00:00:00 2001 From: Habeeb Date: Wed, 8 Jul 2026 21:16:33 +0530 Subject: [PATCH 07/37] fix(session): add directory to setWorkspace interface --- packages/opencode/src/session/session.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 8b13cf26e70e..9b09c24048f2 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -445,7 +445,11 @@ export interface Interface { readonly clearRevert: (sessionID: SessionID) => Effect.Effect readonly setSummary: (input: { sessionID: SessionID; summary: Info["summary"] }) => Effect.Effect readonly setShare: (input: { sessionID: SessionID; share: Info["share"] }) => Effect.Effect - readonly setWorkspace: (input: { sessionID: SessionID; workspaceID: Info["workspaceID"] }) => Effect.Effect + readonly setWorkspace: (input: { + sessionID: SessionID + workspaceID: Info["workspaceID"] + directory?: string + }) => Effect.Effect readonly diff: (sessionID: SessionID) => Effect.Effect readonly messages: (input: { sessionID: SessionID; limit?: number }) => Effect.Effect readonly children: (parentID: SessionID) => Effect.Effect From c73397578443a29b30fb3a13d0b960354ee4a751 Mon Sep 17 00:00:00 2001 From: Habeeb Date: Wed, 8 Jul 2026 21:16:41 +0530 Subject: [PATCH 08/37] fix(workspace-adapter-runtime): pass workspaceID in create context --- .../opencode/src/control-plane/workspace-adapter-runtime.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/control-plane/workspace-adapter-runtime.ts b/packages/opencode/src/control-plane/workspace-adapter-runtime.ts index 235edc9d22b2..00d3e0be9d80 100644 --- a/packages/opencode/src/control-plane/workspace-adapter-runtime.ts +++ b/packages/opencode/src/control-plane/workspace-adapter-runtime.ts @@ -32,7 +32,8 @@ export const create = ( ) => Effect.gen(function* () { const ctx = yield* context - return yield* EffectBridge.fromPromise(() => adapter.create(info, env, from, ctx)) + const workspaceID = ctx.workspaceID ?? info.id + return yield* EffectBridge.fromPromise(() => adapter.create(info, env, from, { ...ctx, workspaceID })) }) export const list = (adapter: WorkspaceAdapter) => From 4fc081ff4b791651fc7d63673b3b1d55774aff71 Mon Sep 17 00:00:00 2001 From: Habeeb Date: Wed, 8 Jul 2026 21:16:47 +0530 Subject: [PATCH 09/37] feat(cli): register WorktreeCommand --- packages/opencode/src/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 13540a73a36f..5448c1471111 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -25,6 +25,7 @@ import { EOL } from "os" import { WebCommand } from "./cli/cmd/web" import { PrCommand } from "./cli/cmd/pr" import { SessionCommand } from "./cli/cmd/session" +import { WorktreeCommand } from "./cli/cmd/worktree" import { DbCommand } from "./cli/cmd/db" import { errorMessage } from "./util/error" import { PluginCommand } from "./cli/cmd/plug" @@ -99,6 +100,7 @@ const cli = yargs(args) .command(GithubCommand) .command(PrCommand) .command(SessionCommand) + .command(WorktreeCommand) .command(PluginCommand) .command(DbCommand) .fail((msg, err) => { From 5860041008464ba2a3f3d4656f9d8e27b35a97cc Mon Sep 17 00:00:00 2001 From: Habeeb Date: Wed, 8 Jul 2026 21:16:57 +0530 Subject: [PATCH 10/37] fix(tui): add VCS to bootstrap, use syncKeepCurrent, workspace name in directory --- packages/tui/src/context/directory.ts | 15 ++++++++++++--- packages/tui/src/context/project.tsx | 5 +++-- packages/tui/src/context/sync.tsx | 11 ++++++++--- 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/packages/tui/src/context/directory.ts b/packages/tui/src/context/directory.ts index b107a40b8164..d6c216ff18d3 100644 --- a/packages/tui/src/context/directory.ts +++ b/packages/tui/src/context/directory.ts @@ -10,8 +10,17 @@ export function useDirectory() { const paths = useTuiPaths() return createMemo(() => { const directory = project.instance.path().directory || paths.cwd - const result = abbreviateHome(directory, paths.home) - if (sync.data.vcs?.branch) return result + ":" + sync.data.vcs.branch - return result + + const workspaceID = project.workspace.current() + const workspace = workspaceID + ? project.workspace.list().find((w) => w.id === workspaceID) + : undefined + + const base = workspace + ? workspace.name + : abbreviateHome(project.data.project.mainDir || directory, paths.home) + + if (sync.data.vcs?.branch) return base + ":" + sync.data.vcs.branch + return base }) } diff --git a/packages/tui/src/context/project.tsx b/packages/tui/src/context/project.tsx index d73a17e7ecda..47b7e37e7602 100644 --- a/packages/tui/src/context/project.tsx +++ b/packages/tui/src/context/project.tsx @@ -52,7 +52,7 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex }) } - async function syncWorkspace() { + async function syncWorkspace(keepCurrent?: boolean) { const listed = await sdk.client.experimental.workspace.list().catch(() => undefined) if (!listed?.data) return const status = await sdk.client.experimental.workspace.status().catch(() => undefined) @@ -61,7 +61,7 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex batch(() => { setStore("workspace", "list", reconcile(listed.data)) setStore("workspace", "status", reconcile(next)) - if (!listed.data.some((item) => item.id === store.workspace.current)) { + if (!keepCurrent && !listed.data.some((item) => item.id === store.workspace.current)) { setStore("workspace", "current", undefined) } }) @@ -108,6 +108,7 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex return store.workspace.status }, sync: syncWorkspace, + syncKeepCurrent: () => syncWorkspace(true), }, sync, } diff --git a/packages/tui/src/context/sync.tsx b/packages/tui/src/context/sync.tsx index d0511c5183e4..9c3be1e01aaa 100644 --- a/packages/tui/src/context/sync.tsx +++ b/packages/tui/src/context/sync.tsx @@ -461,12 +461,14 @@ export const { .catch(() => emptyConsoleState) const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true }) const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true }) + const vcsPromise = sdk.client.vcs.get({ workspace }).then((x) => x.data) await Promise.all([ providersPromise, providerListPromise, capabilitiesPromise, agentsPromise, configPromise, + vcsPromise, projectPromise, ...(args.continue ? [sessionListPromise] : []), ]) @@ -477,6 +479,7 @@ export const { const consoleStateResponse = consoleStatePromise const agentsResponse = agentsPromise.then((x) => x.data ?? []) const configResponse = configPromise.then((x) => x.data!) + const vcsPromiseResolved = vcsPromise const sessionListResponse = args.continue ? sessionListPromise : undefined return Promise.all([ @@ -486,6 +489,7 @@ export const { consoleStateResponse, agentsResponse, configResponse, + vcsPromiseResolved, ...(sessionListResponse ? [sessionListResponse] : []), ]).then((responses) => { const providers = responses[0] @@ -494,7 +498,8 @@ export const { const consoleState = responses[3] const agents = responses[4] const config = responses[5] - const sessions = responses[6] + const vcs = responses[6] + const sessions = responses[7] batch(() => { setStore("provider", reconcile(providers.providers)) @@ -504,6 +509,7 @@ export const { setStore("console_state", reconcile(consoleState)) setStore("agent", reconcile(agents)) setStore("config", reconcile(config)) + setStore("vcs", reconcile(vcs)) if (sessions !== undefined) setStore("session", reconcile(sessions)) }) }) @@ -525,8 +531,7 @@ export const { setStore("session_status", reconcile(x.data ?? {})) }), sdk.client.provider.auth({ workspace }).then((x) => setStore("provider_auth", reconcile(x.data ?? {}))), - sdk.client.vcs.get({ workspace }).then((x) => setStore("vcs", reconcile(x.data))), - project.workspace.sync(), + project.workspace.syncKeepCurrent(), ]).then(() => { setStore("status", "complete") }) From 9c2028a584715e330e1d5a20247a0d5eb1940b54 Mon Sep 17 00:00:00 2001 From: Habeeb Date: Wed, 8 Jul 2026 21:17:04 +0530 Subject: [PATCH 11/37] feat(tui): redesign workspace dialog with stash-based warp --- .../src/component/dialog-workspace-create.tsx | 246 ++++++++++-------- 1 file changed, 136 insertions(+), 110 deletions(-) diff --git a/packages/tui/src/component/dialog-workspace-create.tsx b/packages/tui/src/component/dialog-workspace-create.tsx index 98c71bb00023..bda812c95942 100644 --- a/packages/tui/src/component/dialog-workspace-create.tsx +++ b/packages/tui/src/component/dialog-workspace-create.tsx @@ -1,48 +1,50 @@ import type { ExperimentalWorkspaceAdapterListResponse, Workspace } from "@opencode-ai/sdk/v2" import { useDialog } from "../ui/dialog" import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select" +import { useTheme } from "../context/theme" import { useSync } from "../context/sync" import { useProject } from "../context/project" import { useRoute } from "../context/route" -import { createMemo, createSignal, onMount } from "solid-js" +import { createEffect, createMemo, createSignal, onMount } from "solid-js" import { errorMessage } from "../util/error" import { useSDK } from "../context/sdk" import { useToast } from "../ui/toast" import { DialogAlert } from "../ui/dialog-alert" +import { DialogConfirm } from "../ui/dialog-confirm" +import { DialogPrompt } from "../ui/dialog-prompt" import { DialogWorkspaceFileChanges } from "./dialog-workspace-file-changes" type Adapter = ExperimentalWorkspaceAdapterListResponse[number] export type WorkspaceSelection = - | { - type: "none" - } - | { - type: "new" - workspaceType: string - workspaceName: string - } - | { - type: "existing" - workspaceID: string - workspaceType: string - workspaceName: string - } + | { type: "none" } + | { type: "new"; workspaceType: string; workspaceName: string; name?: string } + | { type: "existing"; workspaceID: string; workspaceType: string; workspaceName: string } -type WorkspaceSelectValue = WorkspaceSelection | { type: "existing-list" } -type ExistingWorkspaceSelectValue = { workspace: Workspace } +type WorkspaceSelectValue = WorkspaceSelection -export function recentConnectedWorkspaces(input: { - workspaces: readonly WorkspaceInfo[] - status: (workspaceID: string) => string | undefined - limit?: number - omitWorkspaceID?: string -}) { - const allWorkspaces = input.workspaces.filter((workspace) => input.status(workspace.id) === "connected") - const workspaces = allWorkspaces.toSorted((a, b) => Number(b.timeUsed) - Number(a.timeUsed)) - const recent = workspaces.slice(0, input.limit ?? 3) +function relativeTime(ts: number): string { + const diff = Date.now() - ts + const secs = Math.floor(diff / 1000) + if (secs < 60) return "now" + const mins = Math.floor(secs / 60) + if (mins < 60) return `${mins}m` + const hours = Math.floor(mins / 60) + if (hours < 24) return `${hours}h` + const days = Math.floor(hours / 24) + if (days < 30) return `${days}d` + const months = Math.floor(days / 30) + return `${months}mo` +} - return { recent, hasMore: recent.length < workspaces.length } +export function buildDetails(workspace: Workspace): string[] | undefined { + const lines: string[] = [] + if (workspace.directory) lines.push(workspace.directory) + if (workspace.branch) lines.push(`branch: ${workspace.branch}`) + if (workspace.timeUsed && typeof workspace.timeUsed === "number") { + lines.push(`used ${relativeTime(workspace.timeUsed)} ago`) + } + return lines.length ? lines : undefined } export function warpReminderText(dir: string) { @@ -132,6 +134,17 @@ export async function warpWorkspaceSession(input: { input.project.workspace.set(input.workspaceID) + const targetWorkspace = input.workspaceID + ? input.project.workspace.get(input.workspaceID) + : undefined + if (targetWorkspace?.branch) { + input.sync.set("vcs", { branch: targetWorkspace.branch }) + } + + await Promise.all([ + input.project.sync().catch(() => undefined), + input.sdk.client.vcs.get({ workspace: input.workspaceID ?? undefined }).then((x) => input.sync.set("vcs", x.data)).catch(() => undefined), + ]) await input.sync.bootstrap({ fatal: false }).catch(() => undefined) const dir = input.project.instance.directory() || input.sync.path.directory @@ -152,7 +165,7 @@ export async function warpWorkspaceSession(input: { .catch(() => undefined) } - await Promise.all([input.project.workspace.sync(), input.sync.session.refresh()]) + await Promise.all([input.project.workspace.syncKeepCurrent(), input.sync.session.refresh()]) if (input.done) { input.done() @@ -179,6 +192,7 @@ export function DialogWorkspaceSelect(props: { adapters?: Adapter[] onSelect: (selection: WorkspaceSelection) => Promise | void }) { + const { theme } = useTheme() const dialog = useDialog() const project = useProject() const route = useRoute() @@ -186,10 +200,10 @@ export function DialogWorkspaceSelect(props: { const sdk = useSDK() const toast = useToast() const [adapters, setAdapters] = createSignal(props.adapters) - const omittedWorkspaceID = createMemo(() => (route.data.type === "session" ? project.workspace.current() : undefined)) + const [removing, setRemoving] = createSignal() onMount(() => { - dialog.setSize("medium") + dialog.setSize("large") void (async () => { if (adapters()) return const res = await loadWorkspaceAdapters({ sdk, sync, toast }) @@ -198,111 +212,123 @@ export function DialogWorkspaceSelect(props: { })() }) + async function remove(workspace: Workspace) { + if (removing()) return + + const confirmed = await DialogConfirm.show( + dialog, + "Delete workspace", + `Are you sure you want to delete "${workspace.name}"?`, + "delete", + ) + if (confirmed !== true) return + + setRemoving(workspace.id) + const result = await sdk.client.experimental.workspace.remove({ id: workspace.id }).catch((err) => ({ + error: err, + })) + if (result?.error) { + setRemoving(undefined) + toast.show({ + variant: "error", + title: "Failed to delete workspace", + message: errorMessage(result.error), + }) + return + } + + if (project.workspace.current() === workspace.id) { + project.workspace.set(undefined) + await project.sync().catch(() => undefined) + route.navigate({ type: "home" }) + } + await project.workspace.sync() + await project.sync().catch(() => undefined) + setRemoving(undefined) + } + const options = createMemo[]>(() => { const list = adapters() if (!list) return [] - const { recent, hasMore } = recentConnectedWorkspaces({ - workspaces: project.workspace.list(), - status: project.workspace.status, - omitWorkspaceID: omittedWorkspaceID(), - }) + + const workspaces = project + .workspace.list() + .toSorted((a, b) => Number(b.timeUsed) - Number(a.timeUsed)) + return [ - ...list.map((adapter) => ({ - title: adapter.name, - value: { type: "new" as const, workspaceType: adapter.type, workspaceName: adapter.name }, - description: adapter.description, - category: "New workspace", - })), { - title: "None", + title: "Workspace root", value: { type: "none" as const }, - description: "Use the local project", - category: "Choose workspace", + description: "Use project root directory", + category: "Switch to", }, - ...recent.map((workspace: Workspace) => ({ - title: workspace.name, - description: `(${workspace.type})`, + ...workspaces.map((workspace: Workspace) => { + const isCurrent = workspace.id === project.workspace.current() + return { + title: + removing() === workspace.id + ? "Deleting..." + : isCurrent + ? `${workspace.name} (current)` + : workspace.name, value: { type: "existing" as const, workspaceID: workspace.id, workspaceType: workspace.type, workspaceName: workspace.name, }, - category: "Choose workspace", + category: "Workspaces", + footer: workspace.type === "worktree" ? "linked" : workspace.type, + gutter: () => { + const status = project.workspace.status(workspace.id) + return ( + + ? + + ) + }, + details: buildDetails(workspace), + } + }), + ...list.map((adapter) => ({ + title: adapter.name, + value: { type: "new" as const, workspaceType: adapter.type, workspaceName: adapter.name }, + description: adapter.description, + category: "Create new", })), - ...(hasMore - ? [ - { - title: "View all workspaces", - value: { type: "existing-list" as const }, - description: "Choose from all workspaces", - category: "Choose workspace", - }, - ] - : []), ] }) if (!adapters()) return null return ( - title="Warp" - skipFilter={true} - renderFilter={false} + title="Move session to..." + renderFilter={true} options={options()} - onSelect={(option) => { + onSelect={async (option) => { if (!option.value) return - if (option.value.type === "none") { - void props.onSelect(option.value) - return - } if (option.value.type === "new") { - void props.onSelect(option.value) - return + const name = await DialogPrompt.show(dialog, "Worktree name (optional)", { + placeholder: "leave empty for random name", + }) + if (name === null) return + option.value.name = name || undefined } - if (option.value.type === "existing") { - void props.onSelect(option.value) - return - } - - dialog.replace(() => ( - - )) - }} - /> - ) -} - -function DialogExistingWorkspaceSelect(props: { - omitWorkspaceID?: string - onSelect: (selection: WorkspaceSelection) => Promise | void -}) { - const project = useProject() - - const options = createMemo[]>(() => - project.workspace - .list() - .filter((workspace) => project.workspace.status(workspace.id) === "connected") - .filter((workspace) => workspace.id !== props.omitWorkspaceID) - .map((workspace: Workspace) => ({ - title: workspace.name, - description: `(${workspace.type})`, - value: { workspace }, - })), - ) - - return ( - - title="Existing Workspace" - options={options()} - onSelect={(option) => { - void props.onSelect({ - type: "existing", - workspaceID: option.value.workspace.id, - workspaceType: option.value.workspace.type, - workspaceName: option.value.workspace.name, - }) + void props.onSelect(option.value) }} + actions={[ + { + command: "session.delete", + title: "delete", + disabled: (option) => !option?.value || option.value.type !== "existing", + onTrigger: (option) => { + const val = option.value + if (val?.type !== "existing") return + const ws = project.workspace.list().find((w) => w.id === val.workspaceID) + if (ws) void remove(ws) + }, + }, + ]} /> ) } From 374a0c81fe95ea04184e8717db2c295beb2995b2 Mon Sep 17 00:00:00 2001 From: Habeeb Date: Wed, 8 Jul 2026 21:17:16 +0530 Subject: [PATCH 12/37] feat(tui): stash-based warp with name prompt and existence check --- .../tui/src/component/prompt/workspace.tsx | 36 +++++++++++++++---- 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/packages/tui/src/component/prompt/workspace.tsx b/packages/tui/src/component/prompt/workspace.tsx index 57fad0ec3b8e..e745913c3831 100644 --- a/packages/tui/src/component/prompt/workspace.tsx +++ b/packages/tui/src/component/prompt/workspace.tsx @@ -6,7 +6,6 @@ import { useSync } from "../../context/sync" import { useToast } from "../../ui/toast" import { errorMessage } from "../../util/error" import { - confirmWorkspaceFileChanges, openWorkspaceSelect, warpWorkspaceSession, type WorkspaceSelection, @@ -28,7 +27,11 @@ export function usePromptWorkspace(sessionID?: string) { setCreating(true) let result try { - result = await sdk.client.experimental.workspace.create({ type: selection.workspaceType, branch: null }) + result = await sdk.client.experimental.workspace.create({ + type: selection.workspaceType, + branch: null, + ...(selection.name ? { name: selection.name } : {}), + }) } catch (err) { setSelection(undefined) setCreating(false) @@ -66,11 +69,18 @@ export function usePromptWorkspace(sessionID?: string) { return } const sourceWorkspaceID = project.workspace.current() - const copyChanges = await confirmWorkspaceFileChanges({ dialog, sdk, sourceWorkspaceID }) - if (copyChanges === undefined) return setSelection(selection) dialog.clear() + const stashMsg = sessionID ? `opencode:${sessionID}` : undefined + if (sourceWorkspaceID && stashMsg) { + const status = await sdk.client.vcs.status({ workspace: sourceWorkspaceID }).catch(() => undefined) + if (status?.data?.length) { + await sdk.client.vcs.stash({ message: stashMsg, workspace: sourceWorkspaceID }).catch(() => undefined) + toast.show({ title: "Warp", message: "Uncommitted changes stashed", variant: "info" }) + } + } + const workspace = selection.type === "none" ? { id: null, name: "local project" } @@ -88,9 +98,15 @@ export function usePromptWorkspace(sessionID?: string) { sourceWorkspaceID, workspaceID: workspace.id, sessionID, - copyChanges, + copyChanges: false, }) - if (warped) showNotice(workspace.name) + if (warped) { + showNotice(workspace.name) + if (stashMsg) { + const popped = await sdk.client.vcs.stashPop({ message: stashMsg, workspace: workspace.id ?? undefined }).catch(() => undefined) + if (popped?.data) toast.show({ title: "Warp", message: "Uncommitted changes restored", variant: "success" }) + } + } } function showNotice(name: string) { @@ -106,6 +122,14 @@ export function usePromptWorkspace(sessionID?: string) { void openWorkspaceSelect({ dialog, sdk, sync, project, toast, onSelect: warp }) } + createEffect(() => { + const selected = selection() + if (selected && selected.type === "existing") { + const exists = project.workspace.list().some((w) => w.id === selected.workspaceID) + if (!exists) setSelection(undefined) + } + }) + createEffect(() => { if (!creating()) { setCreatingDots(3) From 90425166e2592e463da75c87bb038752d2574010 Mon Sep 17 00:00:00 2001 From: Habeeb Date: Wed, 8 Jul 2026 21:17:22 +0530 Subject: [PATCH 13/37] fix(tui): remove Flag import and experimental workspaces check, use linked workspace --- packages/tui/src/component/prompt/index.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 00efcbed2887..738c49c877b5 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -14,7 +14,6 @@ import { registerOpencodeSpinner } from "../register-spinner" import path from "path" import { fileURLToPath } from "url" import { useLocal } from "../../context/local" -import { Flag } from "@opencode-ai/core/flag/flag" import { tint, useTheme } from "../../context/theme" import { EmptyBorder, SplitBorder } from "../../ui/border" import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime" @@ -536,7 +535,6 @@ export function Prompt(props: PromptProps) { desc: "Change the workspace for the session", name: "workspace.set", category: "Session", - enabled: Flag.OPENCODE_EXPERIMENTAL_WORKSPACES, slashName: "warp", run: () => { workspace.open() @@ -1606,11 +1604,12 @@ export function Prompt(props: PromptProps) { {(() => { const item = label() if (item.type === "new") { + const typeName = item.workspaceType === "worktree" ? "linked workspace" : item.workspaceType if (workspace.creating()) - return `Creating ${item.workspaceType}${".".repeat(workspace.creatingDots())}` + return `Creating ${typeName}${".".repeat(workspace.creatingDots())}` return ( <> - Workspace (new {item.workspaceType}) + Workspace (new {typeName}) ) } From 4a5611de02162050b156d926e85ff43a74d36922 Mon Sep 17 00:00:00 2001 From: Habeeb Date: Wed, 8 Jul 2026 21:17:34 +0530 Subject: [PATCH 14/37] feat(tui): add optimistic VCS on session navigation --- packages/tui/src/routes/session/index.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 6d77b0ea58fd..6640ea64a675 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -293,6 +293,13 @@ export function Session() { if (result.data.workspaceID !== previousWorkspace) { project.workspace.set(result.data.workspaceID) + const targetWorkspace = result.data.workspaceID + ? project.workspace.get(result.data.workspaceID) + : undefined + if (targetWorkspace?.branch) { + sync.set("vcs", { branch: targetWorkspace.branch }) + } + await sdk.client.vcs.get({ workspace: result.data.workspaceID ?? undefined }).then((x) => sync.set("vcs", x.data)).catch(() => undefined) // Sync all the data for this workspace. Note that this // workspace may not exist anymore which is why this is not From 77031e31c03b253cc3fadb951212cfe3a1a87867 Mon Sep 17 00:00:00 2001 From: Habeeb Date: Wed, 8 Jul 2026 21:17:45 +0530 Subject: [PATCH 15/37] fix(tui): use useDirectory hook in footers --- .../tui/src/feature-plugins/home/footer.tsx | 19 +++---------------- .../src/feature-plugins/sidebar/footer.tsx | 5 ++--- 2 files changed, 5 insertions(+), 19 deletions(-) diff --git a/packages/tui/src/feature-plugins/home/footer.tsx b/packages/tui/src/feature-plugins/home/footer.tsx index 41bee5da5a49..b6935cf042cf 100644 --- a/packages/tui/src/feature-plugins/home/footer.tsx +++ b/packages/tui/src/feature-plugins/home/footer.tsx @@ -1,27 +1,14 @@ import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" import type { BuiltinTuiPlugin } from "../builtins" import { createMemo, Match, Show, Switch } from "solid-js" -import { abbreviateHome } from "../../runtime" -import { useTuiPaths } from "../../context/runtime" -import { useHomeSessionDestination } from "../../routes/home/session-destination" +import { useDirectory } from "../../context/directory" const id = "internal:home-footer" function Directory(props: { api: TuiPluginApi }) { const theme = () => props.api.theme.current - const destination = useHomeSessionDestination() - const paths = useTuiPaths() - const dir = createMemo(() => { - const selected = destination?.destination() - if (!selected || selected.type === "new") return - const out = abbreviateHome(selected.directory, paths.home) - const branch = - selected.directory === (props.api.state.path.directory || paths.cwd) ? props.api.state.vcs?.branch : undefined - if (branch) return out + ":" + branch - return out - }) - - return {(value) => {value()}} + const dir = useDirectory() + return {dir()} } function Mcp(props: { api: TuiPluginApi }) { diff --git a/packages/tui/src/feature-plugins/sidebar/footer.tsx b/packages/tui/src/feature-plugins/sidebar/footer.tsx index c59046a01722..84077a6505e8 100644 --- a/packages/tui/src/feature-plugins/sidebar/footer.tsx +++ b/packages/tui/src/feature-plugins/sidebar/footer.tsx @@ -17,10 +17,9 @@ function View(props: { api: TuiPluginApi; sessionID: string }) { const done = createMemo(() => props.api.kv.get("dismissed_getting_started", false)) const show = createMemo(() => !has() && !done()) const path = createMemo(() => { - const session = props.api.state.session.get(props.sessionID) - const dir = session?.directory || props.api.state.path.directory || paths.cwd + const dir = props.api.state.path.directory || paths.cwd const out = abbreviateHome(dir, paths.home) - const branch = session?.directory === props.api.state.path.directory ? props.api.state.vcs?.branch : undefined + const branch = props.api.state.vcs?.branch const text = branch ? out + ":" + branch : out const list = text.split("/") return { From dd8a92e16e3681f4961494ae1a610d73398e507f Mon Sep 17 00:00:00 2001 From: Habeeb Date: Wed, 8 Jul 2026 21:17:52 +0530 Subject: [PATCH 16/37] fix(tui): remove workspace.list command and Flag imports --- packages/tui/src/app.tsx | 12 +----------- packages/tui/src/context/sdk.tsx | 17 ++++++----------- 2 files changed, 7 insertions(+), 22 deletions(-) diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 57f372ef709a..7323fa6ba155 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -48,7 +48,7 @@ import { DialogThemeList } from "./component/dialog-theme-list" import { DialogHelp } from "./ui/dialog-help" import { DialogAgent } from "./component/dialog-agent" import { DialogSessionList } from "./component/dialog-session-list" -import { DialogWorkspaceList } from "./component/dialog-workspace-list" + import { DialogConsoleOrg } from "./component/dialog-console-org" import { ThemeProvider, useTheme } from "./context/theme" import { Home } from "./routes/home" @@ -607,16 +607,6 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi dialog.clear() }, }, - { - name: "workspace.list", - title: "Manage workspaces", - category: "Workspace", - hidden: !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES, - slashName: "workspaces", - run: () => { - dialog.replace(() => ) - }, - }, ...Array.from({ length: 9 }, (_, i) => ({ name: `session.quick_switch.${i + 1}`, title: `Switch to session in quick slot ${i + 1}`, diff --git a/packages/tui/src/context/sdk.tsx b/packages/tui/src/context/sdk.tsx index 93180c6e21da..49fbf3ee5bfe 100644 --- a/packages/tui/src/context/sdk.tsx +++ b/packages/tui/src/context/sdk.tsx @@ -1,6 +1,5 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2" import type { GlobalEvent } from "@opencode-ai/sdk/v2" -import { Flag } from "@opencode-ai/core/flag/flag" import { createSimpleContext } from "./helper" import { batch, onCleanup, onMount } from "solid-js" @@ -93,11 +92,9 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ sseMaxRetryAttempts: 0, }) - if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) { - // Start syncing workspaces, it's important to do this after - // we've started listening to events - await sdk.sync.start().catch(() => {}) - } + // Start syncing workspaces, it's important to do this after + // we've started listening to events + await sdk.sync.start().catch(() => {}) for await (const event of events.stream) { if (ctrl.signal.aborted) break @@ -121,11 +118,9 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ const unsub = await props.events.subscribe(handleEvent) onCleanup(unsub) - if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) { - // Start syncing workspaces, it's important to do this after - // we've started listening to events - await sdk.sync.start().catch(() => {}) - } + // Start syncing workspaces, it's important to do this after + // we've started listening to events + await sdk.sync.start().catch(() => {}) } else { startSSE() } From fe74470b8353507d9ae6739af1234e5def5e5cb1 Mon Sep 17 00:00:00 2001 From: Habeeb Date: Wed, 8 Jul 2026 21:18:11 +0530 Subject: [PATCH 17/37] fix(tui): add alignItems center to dialog-select --- packages/tui/src/ui/dialog-select.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/tui/src/ui/dialog-select.tsx b/packages/tui/src/ui/dialog-select.tsx index dab9103d244c..e76819efa7cc 100644 --- a/packages/tui/src/ui/dialog-select.tsx +++ b/packages/tui/src/ui/dialog-select.tsx @@ -665,6 +665,7 @@ export function DialogSelect(props: DialogSelectProps) { > Date: Wed, 8 Jul 2026 21:18:18 +0530 Subject: [PATCH 18/37] test(tui): update dialog-workspace-create tests for buildDetails --- .../cmd/tui/dialog-workspace-create.test.ts | 33 ++++++------------- 1 file changed, 10 insertions(+), 23 deletions(-) diff --git a/packages/tui/test/cli/cmd/tui/dialog-workspace-create.test.ts b/packages/tui/test/cli/cmd/tui/dialog-workspace-create.test.ts index 17e7090cf34e..2fbe9371a7b8 100644 --- a/packages/tui/test/cli/cmd/tui/dialog-workspace-create.test.ts +++ b/packages/tui/test/cli/cmd/tui/dialog-workspace-create.test.ts @@ -1,28 +1,15 @@ import { describe, expect, test } from "bun:test" -import { recentConnectedWorkspaces } from "../../../../src/component/dialog-workspace-create" +import { buildDetails } from "../../../../src/component/dialog-workspace-create" -describe("recentConnectedWorkspaces", () => { - test("returns connected workspaces sorted by time used", () => { - const workspaces = [ - { id: "wrk_a", name: "alpha", timeUsed: 700 }, - { id: "wrk_b", name: "beta", timeUsed: 800 }, - { id: "wrk_c", name: "gamma", timeUsed: 400 }, - { id: "wrk_d", name: "delta", timeUsed: 300 }, - { id: "wrk_e", name: "epsilon", timeUsed: 200 }, - ] - const status = { - wrk_a: "connected", - wrk_b: "disconnected", - wrk_c: "error", - wrk_d: "connected", - wrk_e: "connected", - } as const - - const { recent } = recentConnectedWorkspaces({ - workspaces, - status: (workspaceID) => status[workspaceID as keyof typeof status], - }) +describe("buildDetails", () => { + test("includes directory when present", () => { + const workspace = { id: "wrk_a", name: "alpha", directory: "/home/user/proj", type: "worktree", timeUsed: 500, branch: "feat", extra: null, projectID: "proj" } + const details = buildDetails(workspace) + expect(details?.some((l) => l.includes("/home/user/proj"))).toBe(true) + }) - expect(recent.map((workspace) => workspace.id)).toEqual(["wrk_a", "wrk_d", "wrk_e"]) + test("returns undefined when no details", () => { + const workspace = { id: "wrk_a", name: "alpha", type: "local", timeUsed: 0, extra: null, projectID: "proj" } + expect(buildDetails(workspace)).toBeUndefined() }) }) From fe972f2c37dacf40cee5761b7d31f75c2a65244e Mon Sep 17 00:00:00 2001 From: Habeeb Date: Wed, 8 Jul 2026 21:19:10 +0530 Subject: [PATCH 19/37] feat(cli): add worktree CLI command --- packages/opencode/src/cli/cmd/worktree.ts | 155 ++++++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 packages/opencode/src/cli/cmd/worktree.ts diff --git a/packages/opencode/src/cli/cmd/worktree.ts b/packages/opencode/src/cli/cmd/worktree.ts new file mode 100644 index 000000000000..0dc5703b83d3 --- /dev/null +++ b/packages/opencode/src/cli/cmd/worktree.ts @@ -0,0 +1,155 @@ +import type { Argv } from "yargs" +import { EOL } from "os" +import { Effect } from "effect" +import { cmd } from "./cmd" +import { effectCmd, fail } from "../effect-cmd" +import { Worktree } from "@/worktree" +import { UI } from "../ui" +import { GlobalBus, type GlobalEvent } from "@/bus/global" + +function formatTable(entries: (Omit & { branch?: string })[]): string { + const maxName = Math.max(4, ...entries.map((e) => e.name.length)) + const maxBranch = Math.max(6, ...entries.map((e) => (e.branch ?? "-").length)) + const fmt = ` ${"Name".padEnd(maxName)} ${"Branch".padEnd(maxBranch)} Directory` + const sep = " " + "─".repeat(maxName) + " " + "─".repeat(maxBranch) + " " + "─".repeat(80) + const rows = entries.map( + (e) => ` ${e.name.padEnd(maxName)} ${(e.branch ?? "-").padEnd(maxBranch)} ${e.directory}`, + ) + return [fmt, sep, ...rows].join(EOL) +} + +export const WorktreeCommand = cmd({ + command: "worktree", + describe: "manage git worktrees", + builder: (yargs: Argv) => + yargs + .command(WorktreeCreateCommand) + .command(WorktreeListCommand) + .command(WorktreeRemoveCommand) + .command(WorktreeResetCommand) + .demandCommand(), + async handler() {}, +}) + +const waitWorktreeEvent = (directory: string) => + Effect.callback<"ready" | "failed", never>((resume) => { + const handler = (event: GlobalEvent) => { + if (event.directory !== directory) return + if (event.payload.type === Worktree.Event.Ready.type) { + GlobalBus.off("event", handler) + resume(Effect.succeed("ready" as const)) + } + if (event.payload.type === Worktree.Event.Failed.type) { + GlobalBus.off("event", handler) + resume(Effect.succeed("failed" as const)) + } + } + + GlobalBus.on("event", handler) + return Effect.sync(() => GlobalBus.off("event", handler)) + }) + +export const WorktreeCreateCommand = effectCmd({ + command: "create", + describe: "create a worktree", + builder: (yargs) => + yargs + .option("name", { + describe: "worktree name (slug; generated if omitted)", + type: "string", + }) + .option("start-command", { + describe: "additional startup script", + type: "string", + }), + handler: Effect.fn("Cli.worktree.create")(function* (args) { + const svc = yield* Worktree.Service + const info = yield* svc.create({ name: args.name, startCommand: args.startCommand }).pipe( + Effect.catch((e) => fail(e.message)), + ) + + UI.println(UI.Style.TEXT_DIM + "Worktree directory: " + UI.Style.TEXT_NORMAL + info.directory) + if (info.branch) { + UI.println(UI.Style.TEXT_DIM + "Branch: " + UI.Style.TEXT_NORMAL + info.branch) + } + UI.empty() + + const result = yield* waitWorktreeEvent(info.directory).pipe( + Effect.timeoutOrElse({ + duration: "120 seconds", + orElse: () => Effect.succeed("timeout" as const), + }), + ) + + if (result === "timeout") { + UI.println(UI.Style.TEXT_WARNING_BOLD + "Worktree created but bootstrap still running" + UI.Style.TEXT_NORMAL) + return + } + + if (result === "failed") { + return yield* fail("Worktree bootstrap failed") + } + + UI.println(UI.Style.TEXT_SUCCESS_BOLD + "Worktree ready" + UI.Style.TEXT_NORMAL) + }), +}) + +export const WorktreeListCommand = effectCmd({ + command: "list", + describe: "list worktrees", + builder: (yargs) => + yargs.option("format", { + describe: "output format", + type: "string", + choices: ["table", "json"], + default: "table", + }), + handler: Effect.fn("Cli.worktree.list")(function* (args) { + const svc = yield* Worktree.Service + const list = yield* svc.list().pipe( + Effect.catch((e) => fail(e.message)), + ) + if (list.length === 0) return + if (args.format === "json") { + console.log(JSON.stringify(list, null, 2)) + } else { + console.log(formatTable(list)) + } + }), +}) + +export const WorktreeRemoveCommand = effectCmd({ + command: "remove ", + describe: "remove a worktree", + builder: (yargs) => + yargs.positional("directory", { + describe: "worktree directory", + type: "string", + demandOption: true, + }), + handler: Effect.fn("Cli.worktree.remove")(function* (args) { + const svc = yield* Worktree.Service + yield* svc.remove({ directory: args.directory }).pipe( + Effect.catch((e) => fail(e.message)), + ) + UI.println(UI.Style.TEXT_SUCCESS_BOLD + "Worktree removed" + UI.Style.TEXT_NORMAL) + }), +}) + +export const WorktreeResetCommand = effectCmd({ + command: "reset ", + describe: "reset a worktree", + builder: (yargs) => + yargs.positional("directory", { + describe: "worktree directory", + type: "string", + demandOption: true, + }), + handler: Effect.fn("Cli.worktree.reset")(function* (args) { + const svc = yield* Worktree.Service + yield* svc.reset({ directory: args.directory }).pipe( + Effect.catch((e) => fail(e.message)), + ) + UI.println(UI.Style.TEXT_SUCCESS_BOLD + "Worktree reset" + UI.Style.TEXT_NORMAL) + }), +}) From 5c4e45978e00e6d008242ebe7a8eea2d9d0b0313 Mon Sep 17 00:00:00 2001 From: Habeeb Date: Wed, 8 Jul 2026 22:15:35 +0530 Subject: [PATCH 20/37] fix(tui): fix stash pop target and add error handling --- packages/tui/src/component/prompt/workspace.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/tui/src/component/prompt/workspace.tsx b/packages/tui/src/component/prompt/workspace.tsx index e745913c3831..7bf99946febd 100644 --- a/packages/tui/src/component/prompt/workspace.tsx +++ b/packages/tui/src/component/prompt/workspace.tsx @@ -76,7 +76,11 @@ export function usePromptWorkspace(sessionID?: string) { if (sourceWorkspaceID && stashMsg) { const status = await sdk.client.vcs.status({ workspace: sourceWorkspaceID }).catch(() => undefined) if (status?.data?.length) { - await sdk.client.vcs.stash({ message: stashMsg, workspace: sourceWorkspaceID }).catch(() => undefined) + const stashResult = await sdk.client.vcs.stash({ message: stashMsg, workspace: sourceWorkspaceID }).catch((err) => ({ error: err })) + if (stashResult && "error" in stashResult) { + toast.show({ title: "Warp", message: "Failed to stash changes", variant: "error" }) + return + } toast.show({ title: "Warp", message: "Uncommitted changes stashed", variant: "info" }) } } @@ -103,8 +107,12 @@ export function usePromptWorkspace(sessionID?: string) { if (warped) { showNotice(workspace.name) if (stashMsg) { - const popped = await sdk.client.vcs.stashPop({ message: stashMsg, workspace: workspace.id ?? undefined }).catch(() => undefined) - if (popped?.data) toast.show({ title: "Warp", message: "Uncommitted changes restored", variant: "success" }) + const popped = await sdk.client.vcs.stashPop({ message: stashMsg, workspace: sourceWorkspaceID }).catch((err) => ({ error: err })) + if (popped && "error" in popped) { + toast.show({ title: "Warp", message: "Failed to restore stashed changes", variant: "error" }) + } else if (popped?.data) { + toast.show({ title: "Warp", message: "Uncommitted changes restored", variant: "success" }) + } } } } From d0c0ef7a67e8661840d1c989ad181eb6ce8a6575 Mon Sep 17 00:00:00 2001 From: Habeeb Date: Wed, 8 Jul 2026 22:15:44 +0530 Subject: [PATCH 21/37] chore(sdk): regenerate SDK with stash/stashPop methods --- packages/sdk/js/src/v2/gen/sdk.gen.ts | 94 ++++++++++++++++++++++++- packages/sdk/js/src/v2/gen/types.gen.ts | 62 ++++++++++++++++ 2 files changed, 154 insertions(+), 2 deletions(-) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 9ed0084aac84..344e08a3a8df 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -393,6 +393,10 @@ import type { VcsDiffResponses, VcsGetErrors, VcsGetResponses, + VcsStashErrors, + VcsStashPopErrors, + VcsStashPopResponses, + VcsStashResponses, VcsStatusErrors, VcsStatusResponses, WorktreeCreateErrors, @@ -1050,6 +1054,7 @@ export class Workspace extends HeyApiClient { id?: string type?: string branch?: string | null + name?: string extra?: unknown | null }, options?: Options, @@ -1064,6 +1069,7 @@ export class Workspace extends HeyApiClient { { in: "body", key: "id" }, { in: "body", key: "type" }, { in: "body", key: "branch" }, + { in: "body", key: "name" }, { in: "body", key: "extra" }, ], }, @@ -1196,11 +1202,12 @@ export class Workspace extends HeyApiClient { */ public warp( parameters?: { - directory?: string + query_directory?: string workspace?: string id?: string | null sessionID?: string copyChanges?: boolean + body_directory?: string }, options?: Options, ) { @@ -1209,11 +1216,20 @@ export class Workspace extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, + { + in: "query", + key: "query_directory", + map: "directory", + }, { in: "query", key: "workspace" }, { in: "body", key: "id" }, { in: "body", key: "sessionID" }, { in: "body", key: "copyChanges" }, + { + in: "body", + key: "body_directory", + map: "directory", + }, ], }, ], @@ -2150,6 +2166,80 @@ export class Vcs extends HeyApiClient { }) } + /** + * Stash changes + * + * Stash uncommitted changes with a message. + */ + public stash( + parameters?: { + directory?: string + workspace?: string + message?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "message" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/vcs/stash", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Pop stashed changes + * + * Pop a stash with a matching message. + */ + public stashPop( + parameters?: { + directory?: string + workspace?: string + message?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "message" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/vcs/stash-pop", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + private _diff?: Diff get diff2(): Diff { return (this._diff ??= new Diff({ client: this.client })) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 42d224780d32..67d328483675 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -8279,6 +8279,66 @@ export type VcsApplyResponses = { export type VcsApplyResponse = VcsApplyResponses[keyof VcsApplyResponses] +export type VcsStashData = { + body?: { + message: string + } + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/vcs/stash" +} + +export type VcsStashErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type VcsStashError = VcsStashErrors[keyof VcsStashErrors] + +export type VcsStashResponses = { + /** + * Changes stashed + */ + 204: void +} + +export type VcsStashResponse = VcsStashResponses[keyof VcsStashResponses] + +export type VcsStashPopData = { + body?: { + message: string + } + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/vcs/stash-pop" +} + +export type VcsStashPopErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type VcsStashPopError = VcsStashPopErrors[keyof VcsStashPopErrors] + +export type VcsStashPopResponses = { + /** + * Stash popped + */ + 200: boolean +} + +export type VcsStashPopResponse = VcsStashPopResponses[keyof VcsStashPopResponses] + export type CommandListData = { body?: never path?: never @@ -11064,6 +11124,7 @@ export type ExperimentalWorkspaceCreateData = { id?: string type: string branch?: string | null + name?: string extra?: unknown | null } path?: never @@ -11191,6 +11252,7 @@ export type ExperimentalWorkspaceWarpData = { id: string | null sessionID: string copyChanges?: boolean + directory?: string } path?: never query?: { From 2a6db7d0e9ee7341e67e6b06575eb1b45a5f5652 Mon Sep 17 00:00:00 2001 From: Habeeb Date: Wed, 8 Jul 2026 22:37:07 +0530 Subject: [PATCH 22/37] fix(tui): add file status feedback for local transitions, fix stash pop guard --- .../tui/src/component/prompt/workspace.tsx | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/tui/src/component/prompt/workspace.tsx b/packages/tui/src/component/prompt/workspace.tsx index 7bf99946febd..56832090c7d5 100644 --- a/packages/tui/src/component/prompt/workspace.tsx +++ b/packages/tui/src/component/prompt/workspace.tsx @@ -73,15 +73,21 @@ export function usePromptWorkspace(sessionID?: string) { dialog.clear() const stashMsg = sessionID ? `opencode:${sessionID}` : undefined - if (sourceWorkspaceID && stashMsg) { + let hasStashed = false + if (stashMsg) { const status = await sdk.client.vcs.status({ workspace: sourceWorkspaceID }).catch(() => undefined) if (status?.data?.length) { - const stashResult = await sdk.client.vcs.stash({ message: stashMsg, workspace: sourceWorkspaceID }).catch((err) => ({ error: err })) - if (stashResult && "error" in stashResult) { - toast.show({ title: "Warp", message: "Failed to stash changes", variant: "error" }) - return + if (sourceWorkspaceID) { + const stashResult = await sdk.client.vcs.stash({ message: stashMsg, workspace: sourceWorkspaceID }).catch((err) => ({ error: err })) + if (stashResult && "error" in stashResult) { + toast.show({ title: "Warp", message: "Failed to stash changes", variant: "error" }) + return + } + hasStashed = true + toast.show({ title: "Warp", message: "Uncommitted changes stashed", variant: "info" }) + } else { + toast.show({ title: "Warp", message: "You have uncommitted changes in local project", variant: "info" }) } - toast.show({ title: "Warp", message: "Uncommitted changes stashed", variant: "info" }) } } @@ -106,7 +112,7 @@ export function usePromptWorkspace(sessionID?: string) { }) if (warped) { showNotice(workspace.name) - if (stashMsg) { + if (hasStashed && stashMsg) { const popped = await sdk.client.vcs.stashPop({ message: stashMsg, workspace: sourceWorkspaceID }).catch((err) => ({ error: err })) if (popped && "error" in popped) { toast.show({ title: "Warp", message: "Failed to restore stashed changes", variant: "error" }) From 890b779b79112dc2408d797fcc44eaae501dbc76 Mon Sep 17 00:00:00 2001 From: Habeeb Date: Thu, 9 Jul 2026 11:30:37 +0530 Subject: [PATCH 23/37] fix(tui): fix stashPop typecheck error --- .../plans/2026-07-07-worktree-enhancements.md | 72 +++++ .../tui/src/component/prompt/workspace.tsx | 6 +- worktree-cli-tui.md | 248 ++++++++++++++++++ 3 files changed, 323 insertions(+), 3 deletions(-) create mode 100644 docs/plans/2026-07-07-worktree-enhancements.md create mode 100644 worktree-cli-tui.md diff --git a/docs/plans/2026-07-07-worktree-enhancements.md b/docs/plans/2026-07-07-worktree-enhancements.md new file mode 100644 index 000000000000..7f25ad7b6731 --- /dev/null +++ b/docs/plans/2026-07-07-worktree-enhancements.md @@ -0,0 +1,72 @@ +# Worktree Enhancements Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Make the TUI worktree/workspace feature useful for daily development workflow. + +**Architecture:** Four independent enhancements: (1) show workspace name in agent status bar, (2) branch-per-worktree instead of detached, (3) `/switch` quick command, (4) richer workspace list dialog with branch/timeUsed. Auto-cleanup deferred — needs background scheduler and DB cleanup logic. + +**Tech Stack:** TypeScript, Effect, SolidJS (TUI), Drizzle (DB) + +--- + +### Task 1: Status Indicator — Workspace Name in Agent/Model Info Bar + +**Files:** +- Modify: `packages/tui/src/component/prompt/index.tsx:1437-1478` + +**Changes:** +Add a `Match` for `workspace.label()` in the agent info bar (the top bar showing agent name and model). This shows the workspace name always visible, not just in the status bar below. + +```tsx + + + WS + + {workspace.label()!.workspaceName} + + +``` + +--- + +### Task 2: Branch-Per-Worktree + +**Files:** +- Modify: `packages/opencode/src/control-plane/adapters/worktree.ts:35` + +**Changes:** +Change `detached: true` → `detached: false` so worktree auto-creates branch `opencode/{name}`. + +--- + +### Task 3: Quick Switch — `/switch` Command + +**Files:** +- Modify: `packages/tui/src/component/prompt/index.tsx:531-540` (add command) +- Modify: `packages/tui/src/component/prompt/workspace.tsx` (add switch function) + +**Changes:** +Add `/switch` command that opens the workspace list dialog (same as `/workspaces`). Session warp happens when user selects a workspace from the list. + +--- + +### Task 4: Visual Diff in Workspace List Dialog + +**Files:** +- Modify: `packages/tui/src/component/dialog-workspace-list.tsx` + +**Changes:** +When expanded, also show: +- Branch name (if available) +- Time used (relative, like "2h ago") + +--- + +### Task 5: Fix Workspace Create Flow + +**Files:** +- Fix already applied: workspace-adapter-runtime.ts and worktree/index.ts + +**Changes:** +Already done in prior session. Ensure workspace ID is passed to boot context and Event.Status is emitted. diff --git a/packages/tui/src/component/prompt/workspace.tsx b/packages/tui/src/component/prompt/workspace.tsx index 56832090c7d5..8054dc6f211f 100644 --- a/packages/tui/src/component/prompt/workspace.tsx +++ b/packages/tui/src/component/prompt/workspace.tsx @@ -113,10 +113,10 @@ export function usePromptWorkspace(sessionID?: string) { if (warped) { showNotice(workspace.name) if (hasStashed && stashMsg) { - const popped = await sdk.client.vcs.stashPop({ message: stashMsg, workspace: sourceWorkspaceID }).catch((err) => ({ error: err })) - if (popped && "error" in popped) { + const popped = await sdk.client.vcs.stashPop({ message: stashMsg, workspace: sourceWorkspaceID }).catch(() => undefined) + if (!popped) { toast.show({ title: "Warp", message: "Failed to restore stashed changes", variant: "error" }) - } else if (popped?.data) { + } else { toast.show({ title: "Warp", message: "Uncommitted changes restored", variant: "success" }) } } diff --git a/worktree-cli-tui.md b/worktree-cli-tui.md new file mode 100644 index 000000000000..9013aa027cb2 --- /dev/null +++ b/worktree-cli-tui.md @@ -0,0 +1,248 @@ +# Worktree CLI and TUI + +## Why it doesn't exist yet + +The backend is complete and tested. The `Worktree.Service` (create, list, remove, +reset), the HTTP API under `/experimental/worktree`, the control-plane +`WorktreeAdapter`, and the `worktree.ready` / `worktree.failed` event schema are +all production-quality and ship today. + +The user-facing layer was never built because the workspace concept was gated +behind `OPENCODE_EXPERIMENTAL_WORKSPACES` while the GUI app (`packages/app`) +was the primary harness. The TUI received only partial wiring: +- `DialogWorkspaceList` can delete existing workspaces but cannot create one. +- `workspace.list` slash command is hidden unless the flag is set. +- `/workspace.set` exists in the prompt but requires an already-created workspace. +- There is no `opencode worktree` CLI subcommand at all. + +This spec fills that gap without touching the flag, the backend, or the HTTP API. + +--- + +## Goals + +1. `opencode worktree` — a first-class CLI subcommand with `create`, `list`, + `remove`, and `reset` sub-subcommands. +2. TUI — remove the `OPENCODE_EXPERIMENTAL_WORKSPACES` gate from `workspace.list` + and add a **"New worktree"** action inside `DialogWorkspaceList` so users can + create worktrees without the GUI. + +Non-goals for this iteration: +- Auto-provisioning a worktree per session (separate design decision). +- An apply/merge-back flow (requires conflict-resolution UX). +- Changes to the HTTP API or backend service. + +--- + +## Requirements + +### R1 — CLI: `opencode worktree create` + +``` +opencode worktree create [--name ] [--start-command ] +``` + +- Calls `Worktree.Service.create({ name?, startCommand? })`. +- Waits for `worktree.ready` or `worktree.failed` via the event bus (uses the + existing `GlobalBus` listener already available in `AppRuntime`). +- On success: prints the worktree directory path and branch name (if any) to + stdout. +- On failure: prints the error message and exits non-zero. +- `--name` is optional; omitting it lets the service generate a slug. +- `--start-command` is optional; it is passed as `CreateInput.startCommand`. + +### R2 — CLI: `opencode worktree list` + +``` +opencode worktree list [--format table|json] +``` + +- Calls `Worktree.Service.list()`. +- Default format is `table`: columns `Name`, `Branch`, `Directory`. +- `--format json` prints the raw array as JSON. +- Exits 0 even when the list is empty (prints nothing in table mode, `[]` in + JSON mode). + +### R3 — CLI: `opencode worktree remove` + +``` +opencode worktree remove +``` + +- Takes a positional directory path. +- Calls `Worktree.Service.remove({ directory })`. +- Prints a confirmation message on success. +- On `NotGitError` or `RemoveFailedError`: prints the message and exits non-zero. + +### R4 — CLI: `opencode worktree reset` + +``` +opencode worktree reset +``` + +- Takes a positional directory path. +- Calls `Worktree.Service.reset({ directory })`. +- Prints a confirmation message on success. +- Errors are surfaced the same way as `remove`. + +### R5 — TUI: ungated `workspace.list` command + +- Remove `hidden: !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES` from the + `workspace.list` command in `packages/tui/src/app.tsx`. +- The command and its dialog are stable enough to ship without the flag. + +### R6 — TUI: "New worktree" action inside `DialogWorkspaceList` + +- Add a "new worktree" option at the top of `DialogWorkspaceList` (analogous to + how `DialogWorkspaceSelect` lists adapters). +- Selecting it calls `sdk.client.experimental.workspace.create({ type: + "worktree", branch: null })` (the same path the GUI app uses). +- Shows an inline "Creating…" state while the worktree boots. +- On `worktree.ready`: refreshes the workspace list; shows a toast. +- On `worktree.failed`: shows a toast with the error message. +- The option label is **"New worktree"**; description is "Create a git worktree". + +### R7 — TUI: `/workspace.set` ungated + +- Remove the `enabled: Flag.OPENCODE_EXPERIMENTAL_WORKSPACES` gate from the + `/workspace.set` slash command in `packages/tui/src/component/prompt/index.tsx` + (it is already wired to `workspace.open()` which calls `DialogWorkspaceSelect`). + +--- + +## Implementation plan + +### Step 1 — `packages/opencode/src/cli/cmd/worktree.ts` (new file) + +Create a `WorktreeCommand` that nests four `effectCmd`-based sub-subcommands +following the same pattern as `SessionCommand` in `session.ts`. + +Each subcommand: +- Uses `instance: true` (default) so `InstanceRef` is provided. +- Yields `Worktree.Service` directly. +- Uses `UI.println` / `UI.error` for output. + +**`create` subcommand** waits for the async boot event. The service forks +`boot()` into the instance scope and emits `worktree.ready` or `worktree.failed` +via `GlobalBus`. The CLI implementation: + +1. Call `Worktree.Service.create()` which returns `Info` immediately (after git + worktree setup but before async bootstrap). +2. Use the existing `waitEvent()` utility from `@/control-plane/util` which wraps + `GlobalBus.on("event", handler)` in `Effect.callback` with built-in timeout + and abort-signal support. Filter events by the returned `info.directory` and + payload type (`worktree.ready` or `worktree.failed`). +3. Set a 120-second timeout on the `waitEvent` call. +4. On success: print directory and branch. +5. On failure (`worktree.failed`): print error message and exit non-zero. +6. On timeout: print a warning that the worktree was created but bootstrap is + still running (don't fail, since git worktree setup succeeded). + +Event payload structure: +```ts +{ + directory: string, + project: string, + workspace?: string, + payload: { + type: "worktree.ready" | "worktree.failed", + properties: { name: string, branch?: string } | { message: string } + } +} +``` + +**`list` subcommand** — `Worktree.Service.list()` returns an `Effect.Effect<(...)[], Error>`. +Yield it in the handler (same pattern as `SessionListCommand` which does +`yield* Session.Service.use((svc) => svc.list(...))`). The resolved value is a +plain array. + +**`remove` / `reset` subcommands** — both return `Effect.Effect`. +Yield them in the handler. The resolved value is a plain `boolean`. + +### Step 2 — Register `WorktreeCommand` in `packages/opencode/src/index.ts` + +Add `.command(WorktreeCommand)` alongside `SessionCommand`. + +### Step 3 — `DialogWorkspaceList` — add "New worktree" action + +In `packages/tui/src/component/dialog-workspace-list.tsx`: + +- Add a `creating` signal (boolean). +- Insert a synthetic option at the top of `options()`: + ```ts + { + title: creating() ? "Creating worktree…" : "New worktree", + value: { workspace: NEW_WORKTREE_SENTINEL }, + footer: "worktree", + } + ``` + where `NEW_WORKTREE_SENTINEL` is a local constant with a sentinel `id`. +- In `onSelect`: when the sentinel is detected: + 1. Call `sdk.client.experimental.workspace.create({ type: "worktree", branch: null })` + 2. Set `creating(true)` + 3. Poll `project.workspace.sync()` every 500ms for up to 120 seconds + 4. Check if the new workspace appears in the list (compare against pre-create list) + 5. On success: show success toast and clear creating state + 6. On timeout: show timeout toast +- Disable the sentinel option (no-op select) while `creating()` is true. + +The polling approach is simpler than wiring SSE into the dialog and matches +existing TUI patterns. + +### Step 4 — Ungate `workspace.list` in `app.tsx` + +Remove `hidden: !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES` from the +`workspace.list` command entry. + +### Step 5 — Ungate `/workspace.set` in prompt `index.tsx` + +Remove `enabled: Flag.OPENCODE_EXPERIMENTAL_WORKSPACES` from the +`workspace.set` slash command entry. + +### Step 6 — Verify + +```bash +# from packages/opencode +bun typecheck + +# from packages/tui +bun typecheck + +# worktree tests +bun test test/project/worktree.test.ts +bun test test/project/worktree-remove.test.ts +``` + +--- + +## File changes summary + +| File | Change | +|------|--------| +| `packages/opencode/src/cli/cmd/worktree.ts` | **new** — CLI subcommand | +| `packages/opencode/src/index.ts` | register `WorktreeCommand` | +| `packages/tui/src/component/dialog-workspace-list.tsx` | add "New worktree" option | +| `packages/tui/src/app.tsx` | remove `OPENCODE_EXPERIMENTAL_WORKSPACES` gate from `workspace.list` | +| `packages/tui/src/component/prompt/index.tsx` | remove flag gate from `workspace.set` | + +The backend, HTTP API, schema, and tests are untouched. + +--- + +## Open questions + +1. **Startup scripts in TUI**: `CreateInput.startCommand` is supported by the + backend. For the first iteration the TUI "New worktree" action omits it + (uses whatever the project's configured start command is). A follow-up could + show a text input for an extra command. + +2. **Auto-provision per session**: Cursor creates a worktree automatically when + you start a session with the `Agents` mode. That requires a session-creation + policy change and is out of scope here. + +3. **Apply/merge-back**: No `/apply-worktree` equivalent is planned now. The + user uses git outside opencode to merge the branch. + +4. **Flag cleanup**: Once this ships and is stable, `OPENCODE_EXPERIMENTAL_WORKSPACES` + can be removed from `flag.ts` and all remaining guards cleared. That is a + separate clean-up PR. From dbcfbf6b66e7a0458d0d25f3a8ab8954a45e684c Mon Sep 17 00:00:00 2001 From: Habeeb Date: Thu, 9 Jul 2026 12:05:36 +0530 Subject: [PATCH 24/37] fix(tui): restore popped.data guard, remove scratch files --- .../plans/2026-07-07-worktree-enhancements.md | 72 ----- worktree-cli-tui.md | 248 ------------------ 2 files changed, 320 deletions(-) delete mode 100644 docs/plans/2026-07-07-worktree-enhancements.md delete mode 100644 worktree-cli-tui.md diff --git a/docs/plans/2026-07-07-worktree-enhancements.md b/docs/plans/2026-07-07-worktree-enhancements.md deleted file mode 100644 index 7f25ad7b6731..000000000000 --- a/docs/plans/2026-07-07-worktree-enhancements.md +++ /dev/null @@ -1,72 +0,0 @@ -# Worktree Enhancements Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Make the TUI worktree/workspace feature useful for daily development workflow. - -**Architecture:** Four independent enhancements: (1) show workspace name in agent status bar, (2) branch-per-worktree instead of detached, (3) `/switch` quick command, (4) richer workspace list dialog with branch/timeUsed. Auto-cleanup deferred — needs background scheduler and DB cleanup logic. - -**Tech Stack:** TypeScript, Effect, SolidJS (TUI), Drizzle (DB) - ---- - -### Task 1: Status Indicator — Workspace Name in Agent/Model Info Bar - -**Files:** -- Modify: `packages/tui/src/component/prompt/index.tsx:1437-1478` - -**Changes:** -Add a `Match` for `workspace.label()` in the agent info bar (the top bar showing agent name and model). This shows the workspace name always visible, not just in the status bar below. - -```tsx - - - WS - - {workspace.label()!.workspaceName} - - -``` - ---- - -### Task 2: Branch-Per-Worktree - -**Files:** -- Modify: `packages/opencode/src/control-plane/adapters/worktree.ts:35` - -**Changes:** -Change `detached: true` → `detached: false` so worktree auto-creates branch `opencode/{name}`. - ---- - -### Task 3: Quick Switch — `/switch` Command - -**Files:** -- Modify: `packages/tui/src/component/prompt/index.tsx:531-540` (add command) -- Modify: `packages/tui/src/component/prompt/workspace.tsx` (add switch function) - -**Changes:** -Add `/switch` command that opens the workspace list dialog (same as `/workspaces`). Session warp happens when user selects a workspace from the list. - ---- - -### Task 4: Visual Diff in Workspace List Dialog - -**Files:** -- Modify: `packages/tui/src/component/dialog-workspace-list.tsx` - -**Changes:** -When expanded, also show: -- Branch name (if available) -- Time used (relative, like "2h ago") - ---- - -### Task 5: Fix Workspace Create Flow - -**Files:** -- Fix already applied: workspace-adapter-runtime.ts and worktree/index.ts - -**Changes:** -Already done in prior session. Ensure workspace ID is passed to boot context and Event.Status is emitted. diff --git a/worktree-cli-tui.md b/worktree-cli-tui.md deleted file mode 100644 index 9013aa027cb2..000000000000 --- a/worktree-cli-tui.md +++ /dev/null @@ -1,248 +0,0 @@ -# Worktree CLI and TUI - -## Why it doesn't exist yet - -The backend is complete and tested. The `Worktree.Service` (create, list, remove, -reset), the HTTP API under `/experimental/worktree`, the control-plane -`WorktreeAdapter`, and the `worktree.ready` / `worktree.failed` event schema are -all production-quality and ship today. - -The user-facing layer was never built because the workspace concept was gated -behind `OPENCODE_EXPERIMENTAL_WORKSPACES` while the GUI app (`packages/app`) -was the primary harness. The TUI received only partial wiring: -- `DialogWorkspaceList` can delete existing workspaces but cannot create one. -- `workspace.list` slash command is hidden unless the flag is set. -- `/workspace.set` exists in the prompt but requires an already-created workspace. -- There is no `opencode worktree` CLI subcommand at all. - -This spec fills that gap without touching the flag, the backend, or the HTTP API. - ---- - -## Goals - -1. `opencode worktree` — a first-class CLI subcommand with `create`, `list`, - `remove`, and `reset` sub-subcommands. -2. TUI — remove the `OPENCODE_EXPERIMENTAL_WORKSPACES` gate from `workspace.list` - and add a **"New worktree"** action inside `DialogWorkspaceList` so users can - create worktrees without the GUI. - -Non-goals for this iteration: -- Auto-provisioning a worktree per session (separate design decision). -- An apply/merge-back flow (requires conflict-resolution UX). -- Changes to the HTTP API or backend service. - ---- - -## Requirements - -### R1 — CLI: `opencode worktree create` - -``` -opencode worktree create [--name ] [--start-command ] -``` - -- Calls `Worktree.Service.create({ name?, startCommand? })`. -- Waits for `worktree.ready` or `worktree.failed` via the event bus (uses the - existing `GlobalBus` listener already available in `AppRuntime`). -- On success: prints the worktree directory path and branch name (if any) to - stdout. -- On failure: prints the error message and exits non-zero. -- `--name` is optional; omitting it lets the service generate a slug. -- `--start-command` is optional; it is passed as `CreateInput.startCommand`. - -### R2 — CLI: `opencode worktree list` - -``` -opencode worktree list [--format table|json] -``` - -- Calls `Worktree.Service.list()`. -- Default format is `table`: columns `Name`, `Branch`, `Directory`. -- `--format json` prints the raw array as JSON. -- Exits 0 even when the list is empty (prints nothing in table mode, `[]` in - JSON mode). - -### R3 — CLI: `opencode worktree remove` - -``` -opencode worktree remove -``` - -- Takes a positional directory path. -- Calls `Worktree.Service.remove({ directory })`. -- Prints a confirmation message on success. -- On `NotGitError` or `RemoveFailedError`: prints the message and exits non-zero. - -### R4 — CLI: `opencode worktree reset` - -``` -opencode worktree reset -``` - -- Takes a positional directory path. -- Calls `Worktree.Service.reset({ directory })`. -- Prints a confirmation message on success. -- Errors are surfaced the same way as `remove`. - -### R5 — TUI: ungated `workspace.list` command - -- Remove `hidden: !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES` from the - `workspace.list` command in `packages/tui/src/app.tsx`. -- The command and its dialog are stable enough to ship without the flag. - -### R6 — TUI: "New worktree" action inside `DialogWorkspaceList` - -- Add a "new worktree" option at the top of `DialogWorkspaceList` (analogous to - how `DialogWorkspaceSelect` lists adapters). -- Selecting it calls `sdk.client.experimental.workspace.create({ type: - "worktree", branch: null })` (the same path the GUI app uses). -- Shows an inline "Creating…" state while the worktree boots. -- On `worktree.ready`: refreshes the workspace list; shows a toast. -- On `worktree.failed`: shows a toast with the error message. -- The option label is **"New worktree"**; description is "Create a git worktree". - -### R7 — TUI: `/workspace.set` ungated - -- Remove the `enabled: Flag.OPENCODE_EXPERIMENTAL_WORKSPACES` gate from the - `/workspace.set` slash command in `packages/tui/src/component/prompt/index.tsx` - (it is already wired to `workspace.open()` which calls `DialogWorkspaceSelect`). - ---- - -## Implementation plan - -### Step 1 — `packages/opencode/src/cli/cmd/worktree.ts` (new file) - -Create a `WorktreeCommand` that nests four `effectCmd`-based sub-subcommands -following the same pattern as `SessionCommand` in `session.ts`. - -Each subcommand: -- Uses `instance: true` (default) so `InstanceRef` is provided. -- Yields `Worktree.Service` directly. -- Uses `UI.println` / `UI.error` for output. - -**`create` subcommand** waits for the async boot event. The service forks -`boot()` into the instance scope and emits `worktree.ready` or `worktree.failed` -via `GlobalBus`. The CLI implementation: - -1. Call `Worktree.Service.create()` which returns `Info` immediately (after git - worktree setup but before async bootstrap). -2. Use the existing `waitEvent()` utility from `@/control-plane/util` which wraps - `GlobalBus.on("event", handler)` in `Effect.callback` with built-in timeout - and abort-signal support. Filter events by the returned `info.directory` and - payload type (`worktree.ready` or `worktree.failed`). -3. Set a 120-second timeout on the `waitEvent` call. -4. On success: print directory and branch. -5. On failure (`worktree.failed`): print error message and exit non-zero. -6. On timeout: print a warning that the worktree was created but bootstrap is - still running (don't fail, since git worktree setup succeeded). - -Event payload structure: -```ts -{ - directory: string, - project: string, - workspace?: string, - payload: { - type: "worktree.ready" | "worktree.failed", - properties: { name: string, branch?: string } | { message: string } - } -} -``` - -**`list` subcommand** — `Worktree.Service.list()` returns an `Effect.Effect<(...)[], Error>`. -Yield it in the handler (same pattern as `SessionListCommand` which does -`yield* Session.Service.use((svc) => svc.list(...))`). The resolved value is a -plain array. - -**`remove` / `reset` subcommands** — both return `Effect.Effect`. -Yield them in the handler. The resolved value is a plain `boolean`. - -### Step 2 — Register `WorktreeCommand` in `packages/opencode/src/index.ts` - -Add `.command(WorktreeCommand)` alongside `SessionCommand`. - -### Step 3 — `DialogWorkspaceList` — add "New worktree" action - -In `packages/tui/src/component/dialog-workspace-list.tsx`: - -- Add a `creating` signal (boolean). -- Insert a synthetic option at the top of `options()`: - ```ts - { - title: creating() ? "Creating worktree…" : "New worktree", - value: { workspace: NEW_WORKTREE_SENTINEL }, - footer: "worktree", - } - ``` - where `NEW_WORKTREE_SENTINEL` is a local constant with a sentinel `id`. -- In `onSelect`: when the sentinel is detected: - 1. Call `sdk.client.experimental.workspace.create({ type: "worktree", branch: null })` - 2. Set `creating(true)` - 3. Poll `project.workspace.sync()` every 500ms for up to 120 seconds - 4. Check if the new workspace appears in the list (compare against pre-create list) - 5. On success: show success toast and clear creating state - 6. On timeout: show timeout toast -- Disable the sentinel option (no-op select) while `creating()` is true. - -The polling approach is simpler than wiring SSE into the dialog and matches -existing TUI patterns. - -### Step 4 — Ungate `workspace.list` in `app.tsx` - -Remove `hidden: !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES` from the -`workspace.list` command entry. - -### Step 5 — Ungate `/workspace.set` in prompt `index.tsx` - -Remove `enabled: Flag.OPENCODE_EXPERIMENTAL_WORKSPACES` from the -`workspace.set` slash command entry. - -### Step 6 — Verify - -```bash -# from packages/opencode -bun typecheck - -# from packages/tui -bun typecheck - -# worktree tests -bun test test/project/worktree.test.ts -bun test test/project/worktree-remove.test.ts -``` - ---- - -## File changes summary - -| File | Change | -|------|--------| -| `packages/opencode/src/cli/cmd/worktree.ts` | **new** — CLI subcommand | -| `packages/opencode/src/index.ts` | register `WorktreeCommand` | -| `packages/tui/src/component/dialog-workspace-list.tsx` | add "New worktree" option | -| `packages/tui/src/app.tsx` | remove `OPENCODE_EXPERIMENTAL_WORKSPACES` gate from `workspace.list` | -| `packages/tui/src/component/prompt/index.tsx` | remove flag gate from `workspace.set` | - -The backend, HTTP API, schema, and tests are untouched. - ---- - -## Open questions - -1. **Startup scripts in TUI**: `CreateInput.startCommand` is supported by the - backend. For the first iteration the TUI "New worktree" action omits it - (uses whatever the project's configured start command is). A follow-up could - show a text input for an extra command. - -2. **Auto-provision per session**: Cursor creates a worktree automatically when - you start a session with the `Agents` mode. That requires a session-creation - policy change and is out of scope here. - -3. **Apply/merge-back**: No `/apply-worktree` equivalent is planned now. The - user uses git outside opencode to merge the branch. - -4. **Flag cleanup**: Once this ships and is stable, `OPENCODE_EXPERIMENTAL_WORKSPACES` - can be removed from `flag.ts` and all remaining guards cleared. That is a - separate clean-up PR. From d8da7ab019f4cf16b71e2ef03217df348c8e29de Mon Sep 17 00:00:00 2001 From: Habeeb Date: Thu, 9 Jul 2026 12:15:18 +0530 Subject: [PATCH 25/37] fix(cli): register worktree command before default to fix help visibility --- packages/opencode/src/index.ts | 2 +- packages/tui/src/component/prompt/workspace.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 5448c1471111..944275e591bb 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -81,6 +81,7 @@ const cli = yargs(args) .completion("completion", "generate shell completion script") .command(AcpCommand) .command(McpCommand) + .command(WorktreeCommand) .command(TuiThreadCommand) .command(AttachCommand) .command(RunCommand) @@ -100,7 +101,6 @@ const cli = yargs(args) .command(GithubCommand) .command(PrCommand) .command(SessionCommand) - .command(WorktreeCommand) .command(PluginCommand) .command(DbCommand) .fail((msg, err) => { diff --git a/packages/tui/src/component/prompt/workspace.tsx b/packages/tui/src/component/prompt/workspace.tsx index 8054dc6f211f..8425f25da6fa 100644 --- a/packages/tui/src/component/prompt/workspace.tsx +++ b/packages/tui/src/component/prompt/workspace.tsx @@ -114,7 +114,7 @@ export function usePromptWorkspace(sessionID?: string) { showNotice(workspace.name) if (hasStashed && stashMsg) { const popped = await sdk.client.vcs.stashPop({ message: stashMsg, workspace: sourceWorkspaceID }).catch(() => undefined) - if (!popped) { + if (!popped?.data) { toast.show({ title: "Warp", message: "Failed to restore stashed changes", variant: "error" }) } else { toast.show({ title: "Warp", message: "Uncommitted changes restored", variant: "success" }) From 3b9a3b3f9a46b4977a938bae6d540e356c279be9 Mon Sep 17 00:00:00 2001 From: Habeeb Date: Thu, 9 Jul 2026 12:42:18 +0530 Subject: [PATCH 26/37] fix(sync): add .catch() to vcsPromise to prevent bootstrap crash --- packages/tui/src/context/sync.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tui/src/context/sync.tsx b/packages/tui/src/context/sync.tsx index 9c3be1e01aaa..c934628b5c0c 100644 --- a/packages/tui/src/context/sync.tsx +++ b/packages/tui/src/context/sync.tsx @@ -461,7 +461,7 @@ export const { .catch(() => emptyConsoleState) const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true }) const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true }) - const vcsPromise = sdk.client.vcs.get({ workspace }).then((x) => x.data) + const vcsPromise = sdk.client.vcs.get({ workspace }).then((x) => x.data).catch(() => undefined) await Promise.all([ providersPromise, providerListPromise, From e3f3a25cc32242c385665ae3e99523b4b13d8c17 Mon Sep 17 00:00:00 2001 From: Habeeb Date: Thu, 9 Jul 2026 12:53:34 +0530 Subject: [PATCH 27/37] fix(workspace): restore original session deletion on workspace remove --- packages/opencode/src/control-plane/workspace.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index 37d934be9092..02d86ad4ee35 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -785,12 +785,19 @@ const layer = Layer.effect( }) const remove = Effect.fn("Workspace.remove")(function* (id: WorkspaceV2.ID) { - yield* db - .update(SessionTable) - .set({ workspace_id: null, directory: "" }) + const sessions = yield* db + .select({ id: SessionTable.id, parentID: SessionTable.parent_id }) + .from(SessionTable) .where(eq(SessionTable.workspace_id, id)) - .run() + .all() .pipe(Effect.orDie) + const sessionIDs = new Set(sessions.map((sessionInfo) => sessionInfo.id)) + yield* Effect.forEach( + sessions.filter((sessionInfo) => !sessionInfo.parentID || !sessionIDs.has(sessionInfo.parentID)), + (sessionInfo) => + session.remove(sessionInfo.id).pipe(Effect.catchIf(NotFoundError.isInstance, () => Effect.void)), + { discard: true }, + ) const row = yield* db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get().pipe(Effect.orDie) if (!row) return From 3fb8696ddd8d89552f987ebb71f8a2099e0e3e5d Mon Sep 17 00:00:00 2001 From: Habeeb Date: Thu, 9 Jul 2026 13:25:24 +0530 Subject: [PATCH 28/37] =?UTF-8?q?fix(tui):=20replace=20=3F=20with=20?= =?UTF-8?q?=E2=80=A2=20in=20workspace=20status=20indicator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/tui/src/component/dialog-workspace-create.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tui/src/component/dialog-workspace-create.tsx b/packages/tui/src/component/dialog-workspace-create.tsx index bda812c95942..9af63eeb22d8 100644 --- a/packages/tui/src/component/dialog-workspace-create.tsx +++ b/packages/tui/src/component/dialog-workspace-create.tsx @@ -283,7 +283,7 @@ export function DialogWorkspaceSelect(props: { const status = project.workspace.status(workspace.id) return ( - ? + ) }, From 4c28fc037439bda1eb563ec03d61c34b35c9b515 Mon Sep 17 00:00:00 2001 From: Habeeb Date: Thu, 9 Jul 2026 15:04:21 +0530 Subject: [PATCH 29/37] fix(cli): resolve worktree name to directory in remove and reset --- packages/opencode/src/cli/cmd/worktree.ts | 25 +++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/cli/cmd/worktree.ts b/packages/opencode/src/cli/cmd/worktree.ts index 0dc5703b83d3..53be6e8f9111 100644 --- a/packages/opencode/src/cli/cmd/worktree.ts +++ b/packages/opencode/src/cli/cmd/worktree.ts @@ -118,18 +118,26 @@ export const WorktreeListCommand = effectCmd({ }), }) +const resolveWorktreeName = Effect.fnUntraced(function* (svc: Worktree.Interface, name: string) { + const list = yield* svc.list() + const match = list.find((w) => w.name === name) + if (!match) return name + return match.directory +}) + export const WorktreeRemoveCommand = effectCmd({ - command: "remove ", + command: "remove ", describe: "remove a worktree", builder: (yargs) => - yargs.positional("directory", { - describe: "worktree directory", + yargs.positional("name", { + describe: "worktree name or directory", type: "string", demandOption: true, }), handler: Effect.fn("Cli.worktree.remove")(function* (args) { const svc = yield* Worktree.Service - yield* svc.remove({ directory: args.directory }).pipe( + const directory = yield* resolveWorktreeName(svc, args.name) + yield* svc.remove({ directory }).pipe( Effect.catch((e) => fail(e.message)), ) UI.println(UI.Style.TEXT_SUCCESS_BOLD + "Worktree removed" + UI.Style.TEXT_NORMAL) @@ -137,17 +145,18 @@ export const WorktreeRemoveCommand = effectCmd({ }) export const WorktreeResetCommand = effectCmd({ - command: "reset ", + command: "reset ", describe: "reset a worktree", builder: (yargs) => - yargs.positional("directory", { - describe: "worktree directory", + yargs.positional("name", { + describe: "worktree name or directory", type: "string", demandOption: true, }), handler: Effect.fn("Cli.worktree.reset")(function* (args) { const svc = yield* Worktree.Service - yield* svc.reset({ directory: args.directory }).pipe( + const directory = yield* resolveWorktreeName(svc, args.name) + yield* svc.reset({ directory }).pipe( Effect.catch((e) => fail(e.message)), ) UI.println(UI.Style.TEXT_SUCCESS_BOLD + "Worktree reset" + UI.Style.TEXT_NORMAL) From 927d7f9b5e6803c36a60f1fdd506b300d31a05d3 Mon Sep 17 00:00:00 2001 From: Habeeb Date: Wed, 15 Jul 2026 18:55:32 +0530 Subject: [PATCH 30/37] fix(vcs): exact stash message match in stashPop instead of substring --- packages/opencode/src/project/vcs.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index 72b451e69c62..7c98557128de 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -430,11 +430,15 @@ const layer: Layer.Layer = const ctx = yield* InstanceState.context if (ctx.project.vcs !== "git") return false const list = yield* git.run(["stash", "list"], { cwd: ctx.directory }) - const ref = list + const entry = list .text() .split(/\r?\n/) - .find((line) => line.includes(input.message)) - ?.split(":")[0] + .find((line) => { + const parts = line.split(": ") + const message = parts.slice(2).join(": ") + return message === input.message + }) + const ref = entry?.split(":")[0] if (!ref) return false yield* git.run(["stash", "pop", ref], { cwd: ctx.directory }) return true From 8fadf0029859ad04eda053dbd939ca20e554dbce Mon Sep 17 00:00:00 2001 From: Habeeb Date: Wed, 15 Jul 2026 19:32:19 +0530 Subject: [PATCH 31/37] fix(tui): guard session bootstrap against stale async writes on fast switch --- packages/opencode/test/project/vcs.test.ts | 71 ++++++++++++++++++++++ packages/tui/src/routes/session/index.tsx | 12 ++-- 2 files changed, 79 insertions(+), 4 deletions(-) diff --git a/packages/opencode/test/project/vcs.test.ts b/packages/opencode/test/project/vcs.test.ts index c13b108bc6a9..9de34d59d0bc 100644 --- a/packages/opencode/test/project/vcs.test.ts +++ b/packages/opencode/test/project/vcs.test.ts @@ -40,6 +40,8 @@ const write = Effect.fn("VcsTest.write")(function* (file: string, content: strin yield* FSUtil.Service.use((fs) => fs.writeWithDirs(file, content)) }) +const readString = (file: string) => Effect.promise(() => Bun.file(file).text()) + const remove = Effect.fn("VcsTest.remove")(function* (file: string) { yield* FSUtil.Service.use((fs) => fs.remove(file)) }) @@ -210,6 +212,75 @@ describe("Vcs diff", () => { }), ) + it.instance( + "stash and stashPop round-trips correctly", + () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* write(path.join(test.directory, "file.txt"), "original\n") + yield* git(test.directory, ["add", "."]) + yield* git(test.directory, ["commit", "--no-gpg-sign", "-m", "add file"]) + yield* write(path.join(test.directory, "file.txt"), "stashed\n") + + const vcs = yield* init() + yield* vcs.stash({ message: "my-work-in-progress" }) + + const content = yield* readString(path.join(test.directory, "file.txt")) + expect(content).toBe("original\n") + + const popped = yield* vcs.stashPop({ message: "my-work-in-progress" }) + expect(popped).toBe(true) + + const restored = yield* readString(path.join(test.directory, "file.txt")) + expect(restored).toBe("stashed\n") + }), + { git: true }, + ) + + it.instance( + "stashPop uses exact message match, not substring", + () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* git(test.directory, ["branch", "-M", "main"]) + yield* write(path.join(test.directory, "file.txt"), "base\n") + yield* git(test.directory, ["add", "."]) + yield* git(test.directory, ["commit", "--no-gpg-sign", "-m", "base commit"]) + + yield* write(path.join(test.directory, "file.txt"), "fix-bug\n") + const vcs = yield* init() + yield* vcs.stash({ message: "fix-bug" }) + + yield* write(path.join(test.directory, "file.txt"), "fix\n") + yield* vcs.stash({ message: "fix" }) + + const popped = yield* vcs.stashPop({ message: "fix" }) + expect(popped).toBe(true) + + const restored = yield* readString(path.join(test.directory, "file.txt")) + expect(restored).toBe("fix\n") + }), + { git: true }, + ) + + it.instance( + "stashPop returns false when no stash matches", + () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* write(path.join(test.directory, "file.txt"), "original\n") + yield* git(test.directory, ["add", "."]) + yield* git(test.directory, ["commit", "--no-gpg-sign", "-m", "add file"]) + yield* write(path.join(test.directory, "file.txt"), "changed\n") + + const vcs = yield* init() + yield* vcs.stash({ message: "my-work" }) + const popped = yield* vcs.stashPop({ message: "nonexistent" }) + expect(popped).toBe(false) + }), + { git: true }, + ) + it.instance( "diff('git') returns uncommitted changes", () => diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 6640ea64a675..8487d8825a2a 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -278,10 +278,13 @@ export function Session() { createEffect(() => { const sessionID = route.sessionID + let cancelled = false + onCleanup(() => { cancelled = true }) void (async () => { const previousWorkspace = untrack(() => project.workspace.current()) const result = await sdk.client.session.get({ sessionID }, { throwOnError: true }) if (!result.data) { + if (cancelled) return toast.show({ message: `Session not found: ${sessionID}`, variant: "error", @@ -292,6 +295,7 @@ export function Session() { } if (result.data.workspaceID !== previousWorkspace) { + if (cancelled) return project.workspace.set(result.data.workspaceID) const targetWorkspace = result.data.workspaceID ? project.workspace.get(result.data.workspaceID) @@ -299,17 +303,17 @@ export function Session() { if (targetWorkspace?.branch) { sync.set("vcs", { branch: targetWorkspace.branch }) } + if (cancelled) return await sdk.client.vcs.get({ workspace: result.data.workspaceID ?? undefined }).then((x) => sync.set("vcs", x.data)).catch(() => undefined) - // 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) + if (cancelled) return try { await sync.bootstrap({ fatal: false }) } catch {} } + if (cancelled) return editor.reconnect(result.data.directory) + if (cancelled) return await sync.session.sync(sessionID) if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000) })().catch((error) => { From 01bf8e85270c23c438b8c7ba2cb57bf644fda900 Mon Sep 17 00:00:00 2001 From: Habeeb Date: Wed, 15 Jul 2026 19:37:42 +0530 Subject: [PATCH 32/37] fix(tui): restore stashed changes on warp failure instead of losing them --- .../tui/src/component/prompt/workspace.tsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/tui/src/component/prompt/workspace.tsx b/packages/tui/src/component/prompt/workspace.tsx index 8425f25da6fa..b63368f80b61 100644 --- a/packages/tui/src/component/prompt/workspace.tsx +++ b/packages/tui/src/component/prompt/workspace.tsx @@ -110,15 +110,15 @@ export function usePromptWorkspace(sessionID?: string) { sessionID, copyChanges: false, }) - if (warped) { - showNotice(workspace.name) - if (hasStashed && stashMsg) { - const popped = await sdk.client.vcs.stashPop({ message: stashMsg, workspace: sourceWorkspaceID }).catch(() => undefined) - if (!popped?.data) { - toast.show({ title: "Warp", message: "Failed to restore stashed changes", variant: "error" }) - } else { - toast.show({ title: "Warp", message: "Uncommitted changes restored", variant: "success" }) - } + if (warped) showNotice(workspace.name) + if (hasStashed && stashMsg) { + const popped = await sdk.client.vcs.stashPop({ message: stashMsg, workspace: sourceWorkspaceID }).catch(() => undefined) + if (!popped?.data) { + toast.show({ title: "Warp", message: "Failed to restore stashed changes", variant: "error" }) + } else if (!warped) { + toast.show({ title: "Warp", message: "Warp failed; uncommitted changes restored", variant: "info" }) + } else { + toast.show({ title: "Warp", message: "Uncommitted changes restored", variant: "success" }) } } } From 812284b036a5b9e854af0ec07ac27705baf52444 Mon Sep 17 00:00:00 2001 From: Habeeb Date: Wed, 15 Jul 2026 19:42:07 +0530 Subject: [PATCH 33/37] fix(tui): remove stale workspace.list keybinding --- packages/tui/src/app.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 7323fa6ba155..7974723491d3 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -126,7 +126,6 @@ const appBindingCommands = [ "help.show", "docs.open", "diff.open", - "workspace.list", "app.debug", "app.console", "app.heap_snapshot", From fc6dc7323e217d263610717a5453154bf472060d Mon Sep 17 00:00:00 2001 From: Habeeb Date: Wed, 15 Jul 2026 19:44:46 +0530 Subject: [PATCH 34/37] fix(tui): add timeout to sync.start() to prevent SSE stall --- packages/tui/src/context/sdk.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/tui/src/context/sdk.tsx b/packages/tui/src/context/sdk.tsx index 49fbf3ee5bfe..1842601c7403 100644 --- a/packages/tui/src/context/sdk.tsx +++ b/packages/tui/src/context/sdk.tsx @@ -94,7 +94,10 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ // Start syncing workspaces, it's important to do this after // we've started listening to events - await sdk.sync.start().catch(() => {}) + await Promise.race([ + sdk.sync.start(), + new Promise((_, reject) => setTimeout(() => reject(new Error("sync.start timeout")), 10_000)), + ]).catch(() => {}) for await (const event of events.stream) { if (ctrl.signal.aborted) break @@ -120,7 +123,10 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ // Start syncing workspaces, it's important to do this after // we've started listening to events - await sdk.sync.start().catch(() => {}) + await Promise.race([ + sdk.sync.start(), + new Promise((_, reject) => setTimeout(() => reject(new Error("sync.start timeout")), 10_000)), + ]).catch(() => {}) } else { startSSE() } From 951f6f6e9b9faa33e6af04e4b0618d1c58a2dd4b Mon Sep 17 00:00:00 2001 From: Habeeb Date: Wed, 15 Jul 2026 19:46:27 +0530 Subject: [PATCH 35/37] fix(tui): add missing error handler on workspace.sync() in delete flow --- packages/tui/src/component/dialog-workspace-create.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tui/src/component/dialog-workspace-create.tsx b/packages/tui/src/component/dialog-workspace-create.tsx index 9af63eeb22d8..c405ab0f0997 100644 --- a/packages/tui/src/component/dialog-workspace-create.tsx +++ b/packages/tui/src/component/dialog-workspace-create.tsx @@ -242,7 +242,7 @@ export function DialogWorkspaceSelect(props: { await project.sync().catch(() => undefined) route.navigate({ type: "home" }) } - await project.workspace.sync() + await project.workspace.sync().catch(() => undefined) await project.sync().catch(() => undefined) setRemoving(undefined) } From d8716e420e7db39a0b9f9fbe9cbdbcfbef55d549 Mon Sep 17 00:00:00 2001 From: Habeeb Date: Wed, 15 Jul 2026 19:52:50 +0530 Subject: [PATCH 36/37] refactor(cli): rename resolveWorktreeName to resolveWorktreeTarget, add error handling --- packages/opencode/src/cli/cmd/worktree.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/cli/cmd/worktree.ts b/packages/opencode/src/cli/cmd/worktree.ts index 53be6e8f9111..d12e5e063f83 100644 --- a/packages/opencode/src/cli/cmd/worktree.ts +++ b/packages/opencode/src/cli/cmd/worktree.ts @@ -118,7 +118,7 @@ export const WorktreeListCommand = effectCmd({ }), }) -const resolveWorktreeName = Effect.fnUntraced(function* (svc: Worktree.Interface, name: string) { +const resolveWorktreeTarget = Effect.fnUntraced(function* (svc: Worktree.Interface, name: string) { const list = yield* svc.list() const match = list.find((w) => w.name === name) if (!match) return name @@ -136,7 +136,9 @@ export const WorktreeRemoveCommand = effectCmd({ }), handler: Effect.fn("Cli.worktree.remove")(function* (args) { const svc = yield* Worktree.Service - const directory = yield* resolveWorktreeName(svc, args.name) + const directory = yield* resolveWorktreeTarget(svc, args.name).pipe( + Effect.catch((e) => fail(e.message)), + ) yield* svc.remove({ directory }).pipe( Effect.catch((e) => fail(e.message)), ) @@ -155,7 +157,9 @@ export const WorktreeResetCommand = effectCmd({ }), handler: Effect.fn("Cli.worktree.reset")(function* (args) { const svc = yield* Worktree.Service - const directory = yield* resolveWorktreeName(svc, args.name) + const directory = yield* resolveWorktreeTarget(svc, args.name).pipe( + Effect.catch((e) => fail(e.message)), + ) yield* svc.reset({ directory }).pipe( Effect.catch((e) => fail(e.message)), ) From 5921b0df124e4c79f8a24bb257c304bf8e50cdad Mon Sep 17 00:00:00 2001 From: Habeeb Date: Wed, 15 Jul 2026 19:58:03 +0530 Subject: [PATCH 37/37] fix(server): add ApiVcsStashError contract to stash endpoints --- .../routes/instance/httpapi/groups/instance.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts index c67317ffe09b..5ef262703f8c 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts @@ -40,6 +40,16 @@ export class ApiVcsApplyError extends Schema.ErrorClass("VcsAp { httpApiStatus: 400 }, ) {} +export class ApiVcsStashError extends Schema.ErrorClass("VcsStashError")( + { + name: Schema.Literal("VcsStashError"), + data: Schema.Struct({ + message: Schema.String, + }), + }, + { httpApiStatus: 400 }, +) {} + export const InstancePaths = { dispose: "/instance/dispose", path: "/path", @@ -142,6 +152,7 @@ export const InstanceApi = HttpApi.make("instance") query: WorkspaceRoutingQuery, payload: Vcs.StashInput, success: described(HttpApiSchema.NoContent, "Changes stashed"), + error: ApiVcsStashError, }).annotateMerge( OpenApi.annotations({ identifier: "vcs.stash", @@ -153,6 +164,7 @@ export const InstanceApi = HttpApi.make("instance") query: WorkspaceRoutingQuery, payload: Vcs.StashInput, success: described(Schema.Boolean, "Stash popped"), + error: ApiVcsStashError, }).annotateMerge( OpenApi.annotations({ identifier: "vcs.stashPop",