From b77d43911ea415d31f91363e393de8e319c335f9 Mon Sep 17 00:00:00 2001 From: Anton Brekhov Date: Thu, 12 Feb 2026 17:25:54 +0000 Subject: [PATCH 1/2] feat(tui): add worktree support for session creation --- packages/opencode/src/cli/cmd/tui/app.tsx | 26 +++++++++ .../cmd/tui/component/dialog-session-list.tsx | 5 ++ .../cli/cmd/tui/component/dialog-worktree.tsx | 53 +++++++++++++++++++ .../cli/cmd/tui/component/prompt/index.tsx | 27 ++++++++-- .../opencode/src/cli/cmd/tui/context/sdk.tsx | 14 ++++- .../opencode/src/cli/cmd/tui/context/sync.tsx | 8 +++ .../opencode/src/cli/cmd/tui/routes/home.tsx | 30 ++++++++++- 7 files changed, 157 insertions(+), 6 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/component/dialog-worktree.tsx diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index dbad3f699fc6..332fbc0da79d 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -18,6 +18,7 @@ import { DialogThemeList } from "@tui/component/dialog-theme-list" import { DialogHelp } from "./ui/dialog-help" import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command" import { DialogAgent } from "@tui/component/dialog-agent" +import { DialogWorktree } from "@tui/component/dialog-worktree" import { DialogSessionList } from "@tui/component/dialog-session-list" import { KeybindProvider } from "@tui/context/keybind" import { ThemeProvider, useTheme } from "@tui/context/theme" @@ -463,6 +464,31 @@ function App() { local.agent.move(-1) }, }, + ...(sync.data.vcs + ? [ + { + title: "Select workspace", + value: "workspace.select", + keybind: undefined as any, + suggested: route.data.type === "home", + slash: { + name: "workspace", + aliases: ["worktree", "sandbox"], + }, + onSelect: () => { + dialog.replace(() => ( + { + kv.set("worktree_selection", value) + }} + /> + )) + }, + category: "Session", + }, + ] + : []), { title: "Connect provider", value: "provider.connect", diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx index 775969bfcb38..c443510c37bf 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx @@ -1,3 +1,4 @@ +import path from "path" import { useDialog } from "@tui/ui/dialog" import { DialogSelect } from "@tui/ui/dialog-select" import { useRoute } from "@tui/context/route" @@ -34,6 +35,8 @@ export function DialogSessionList() { const sessions = createMemo(() => searchResults() ?? sync.data.session) + const primary = createMemo(() => sync.data.path.worktree) + const options = createMemo(() => { const today = new Date().toDateString() return sessions() @@ -48,8 +51,10 @@ export function DialogSessionList() { const isDeleting = toDelete() === x.id const status = sync.data.session_status?.[x.id] const isWorking = status?.type === "busy" + const worktree = primary() && x.directory !== primary() ? path.basename(x.directory) : undefined return { title: isDeleting ? `Press ${keybind.print("session_delete")} again to confirm` : x.title, + description: worktree ? `[${worktree}]` : undefined, bg: isDeleting ? theme.error : undefined, value: x.id, category, diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-worktree.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-worktree.tsx new file mode 100644 index 000000000000..445ebde88ad9 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-worktree.tsx @@ -0,0 +1,53 @@ +import path from "path" +import { createMemo } from "solid-js" +import { useSync } from "@tui/context/sync" +import { DialogSelect } from "@tui/ui/dialog-select" +import { useDialog } from "@tui/ui/dialog" + +const MAIN = "main" +const CREATE = "create" + +export function DialogWorktree(props: { + current: string + onSelect: (value: string) => void +}) { + const sync = useSync() + const dialog = useDialog() + + const branch = createMemo(() => sync.data.vcs?.branch) + + const options = createMemo(() => { + const items = [ + { + value: MAIN, + title: "Main workspace", + description: branch() ? `branch: ${branch()}` : undefined, + }, + ] + for (const sandbox of sync.data.sandboxes) { + items.push({ + value: sandbox, + title: path.basename(sandbox), + description: sandbox, + }) + } + items.push({ + value: CREATE, + title: "Create new workspace", + description: "Create a new git worktree", + }) + return items + }) + + return ( + { + props.onSelect(option.value) + dialog.clear() + }} + /> + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index cefef208de4a..82d72527d3cd 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -41,6 +41,8 @@ export type PromptProps = { ref?: (ref: PromptRef) => void hint?: JSX.Element showPlaceholder?: boolean + worktree?: string + onWorktreeChange?: (value: string) => void } export type PromptRef = { @@ -537,10 +539,27 @@ export function Prompt(props: PromptProps) { promptModelWarning() return } + const worktreeSelection = !props.sessionID ? (props.worktree ?? "main") : "main" + let client = sdk.client + if (!props.sessionID && worktreeSelection !== "main") { + if (worktreeSelection === "create") { + const created = await sdk.client.worktree.create({}).then((x) => x.data).catch(() => undefined) + if (!created?.directory) { + toast.show({ variant: "error", message: "Failed to create workspace" }) + return + } + client = sdk.createClient(created.directory) + toast.show({ variant: "info", message: `Creating workspace ${created.name}...`, duration: 3000 }) + } else { + client = sdk.createClient(worktreeSelection) + } + props.onWorktreeChange?.("main") + } + const sessionID = props.sessionID ? props.sessionID : await (async () => { - const sessionID = await sdk.client.session.create({}).then((x) => x.data!.id) + const sessionID = await client.session.create({}).then((x) => x.data!.id) return sessionID })() const messageID = Identifier.ascending("message") @@ -570,7 +589,7 @@ export function Prompt(props: PromptProps) { const variant = local.model.variant.current() if (store.mode === "shell") { - sdk.client.session.shell({ + client.session.shell({ sessionID, agent: local.agent.current().name, model: { @@ -595,7 +614,7 @@ export function Prompt(props: PromptProps) { const restOfInput = firstLineEnd === -1 ? "" : inputText.slice(firstLineEnd + 1) const args = firstLineArgs.join(" ") + (restOfInput ? "\n" + restOfInput : "") - sdk.client.session.command({ + client.session.command({ sessionID, command: command.slice(1), arguments: args, @@ -611,7 +630,7 @@ export function Prompt(props: PromptProps) { })), }) } else { - sdk.client.session + client.session .prompt({ sessionID, ...selectedModel, diff --git a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx index 7fa7e05c3d25..07ce5fa51402 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx @@ -96,6 +96,18 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ if (timer) clearTimeout(timer) }) - return { client: sdk, event: emitter, url: props.url } + return { + client: sdk, + event: emitter, + url: props.url, + createClient(directory: string) { + return createOpencodeClient({ + baseUrl: props.url, + fetch: props.fetch, + headers: props.headers, + directory, + }) + }, + } }, }) diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index eb8ed2d9bbad..a4d7c60c6332 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -71,6 +71,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ [key: string]: McpResource } formatter: FormatterStatus[] + sandboxes: string[] vcs: VcsInfo | undefined path: Path }>({ @@ -98,6 +99,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ mcp: {}, mcp_resource: {}, formatter: [], + sandboxes: [], vcs: undefined, path: { state: "", config: "", worktree: "", directory: "" }, }) @@ -318,6 +320,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ break } + case "project.updated": { + sdk.client.worktree.list().then((x) => setStore("sandboxes", x.data ?? [])) + break + } + case "vcs.branch.updated": { setStore("vcs", { branch: event.properties.branch }) break @@ -395,6 +402,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ sdk.client.provider.auth().then((x) => setStore("provider_auth", reconcile(x.data ?? {}))), sdk.client.vcs.get().then((x) => setStore("vcs", reconcile(x.data))), sdk.client.path.get().then((x) => setStore("path", reconcile(x.data!))), + sdk.client.worktree.list().then((x) => setStore("sandboxes", x.data ?? [])), ]).then(() => { setStore("status", "complete") }) diff --git a/packages/opencode/src/cli/cmd/tui/routes/home.tsx b/packages/opencode/src/cli/cmd/tui/routes/home.tsx index c011f6c62468..982c39469792 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/home.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/home.tsx @@ -1,5 +1,6 @@ +import path from "path" import { Prompt, type PromptRef } from "@tui/component/prompt" -import { createMemo, Match, onMount, Show, Switch } from "solid-js" +import { createMemo, createSignal, Match, onMount, Show, Switch } from "solid-js" import { useTheme } from "@tui/context/theme" import { useKeybind } from "@tui/context/keybind" import { Logo } from "../component/logo" @@ -34,6 +35,22 @@ export function Home() { return Object.values(sync.data.mcp).filter((x) => x.status === "connected").length }) + const [worktree, setWorktreeRaw] = createSignal(kv.get("worktree_selection", "main") as string) + const setWorktree = (value: string) => { + setWorktreeRaw(value) + kv.set("worktree_selection", value) + } + const isGit = createMemo(() => !!sync.data.vcs) + const worktreeLabel = createMemo(() => { + const value = worktree() + if (value === "main") { + const branch = sync.data.vcs?.branch + return branch ? `main (${branch})` : "main" + } + if (value === "create") return "new workspace" + return path.basename(value) + }) + const isFirstTimeUser = createMemo(() => sync.data.session.length === 0) const tipsHidden = createMemo(() => kv.get("tips_hidden", false)) const showTips = createMemo(() => { @@ -107,6 +124,8 @@ export function Home() { promptRef.set(r) }} hint={Hint} + worktree={worktree()} + onWorktreeChange={setWorktree} /> @@ -119,6 +138,15 @@ export function Home() { {directory()} + + + + + {worktreeLabel()} + + /workspace + + From adaa6a7ac918d76b127d270a70c8bebb184f3b86 Mon Sep 17 00:00:00 2001 From: Anton Brekhov Date: Fri, 13 Feb 2026 23:13:46 +0000 Subject: [PATCH 2/2] fix(tui): fix worktree reactivity, global event forwarding, and session sync - Replace standalone signal with KV-derived accessor for worktree selection - Forward global.event RPC channel in createEventSource for cross-worktree events - Add directory field to SessionRoute for worktree-scoped navigation - Use worktree-scoped client in session.sync when directory is provided - Track resolved directory through submit flow for correct routing --- .../src/cli/cmd/tui/component/prompt/index.tsx | 8 ++++++-- packages/opencode/src/cli/cmd/tui/context/route.tsx | 1 + packages/opencode/src/cli/cmd/tui/context/sync.tsx | 11 ++++++----- packages/opencode/src/cli/cmd/tui/routes/home.tsx | 9 +++------ .../opencode/src/cli/cmd/tui/routes/session/index.tsx | 2 +- packages/opencode/src/cli/cmd/tui/thread.ts | 11 ++++++++++- 6 files changed, 27 insertions(+), 15 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 82d72527d3cd..fe09466ec549 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -541,6 +541,7 @@ export function Prompt(props: PromptProps) { } const worktreeSelection = !props.sessionID ? (props.worktree ?? "main") : "main" let client = sdk.client + let directory: string | undefined if (!props.sessionID && worktreeSelection !== "main") { if (worktreeSelection === "create") { const created = await sdk.client.worktree.create({}).then((x) => x.data).catch(() => undefined) @@ -548,10 +549,12 @@ export function Prompt(props: PromptProps) { toast.show({ variant: "error", message: "Failed to create workspace" }) return } - client = sdk.createClient(created.directory) + directory = created.directory + client = sdk.createClient(directory) toast.show({ variant: "info", message: `Creating workspace ${created.name}...`, duration: 3000 }) } else { - client = sdk.createClient(worktreeSelection) + directory = worktreeSelection + client = sdk.createClient(directory) } props.onWorktreeChange?.("main") } @@ -670,6 +673,7 @@ export function Prompt(props: PromptProps) { route.navigate({ type: "session", sessionID, + directory, }) }, 50) input.clear() diff --git a/packages/opencode/src/cli/cmd/tui/context/route.tsx b/packages/opencode/src/cli/cmd/tui/context/route.tsx index 358461921b20..7005a2e2d1e1 100644 --- a/packages/opencode/src/cli/cmd/tui/context/route.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/route.tsx @@ -10,6 +10,7 @@ export type HomeRoute = { export type SessionRoute = { type: "session" sessionID: string + directory?: string initialPrompt?: PromptInfo } diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index a4d7c60c6332..62c6354a4fdf 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -447,13 +447,14 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ if (last.role === "user") return "working" return last.time.completed ? "idle" : "working" }, - async sync(sessionID: string) { + async sync(sessionID: string, directory?: string) { if (fullSyncedSessions.has(sessionID)) return + const client = directory ? sdk.createClient(directory) : sdk.client const [session, messages, todo, diff] = await Promise.all([ - sdk.client.session.get({ sessionID }, { throwOnError: true }), - sdk.client.session.messages({ sessionID, limit: 100 }), - sdk.client.session.todo({ sessionID }), - sdk.client.session.diff({ sessionID }), + client.session.get({ sessionID }, { throwOnError: true }), + client.session.messages({ sessionID, limit: 100 }), + client.session.todo({ sessionID }), + client.session.diff({ sessionID }), ]) setStore( produce((draft) => { diff --git a/packages/opencode/src/cli/cmd/tui/routes/home.tsx b/packages/opencode/src/cli/cmd/tui/routes/home.tsx index 982c39469792..3f9f4ff89e4d 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/home.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/home.tsx @@ -1,6 +1,6 @@ import path from "path" import { Prompt, type PromptRef } from "@tui/component/prompt" -import { createMemo, createSignal, Match, onMount, Show, Switch } from "solid-js" +import { createMemo, Match, onMount, Show, Switch } from "solid-js" import { useTheme } from "@tui/context/theme" import { useKeybind } from "@tui/context/keybind" import { Logo } from "../component/logo" @@ -35,11 +35,8 @@ export function Home() { return Object.values(sync.data.mcp).filter((x) => x.status === "connected").length }) - const [worktree, setWorktreeRaw] = createSignal(kv.get("worktree_selection", "main") as string) - const setWorktree = (value: string) => { - setWorktreeRaw(value) - kv.set("worktree_selection", value) - } + const worktree = () => kv.get("worktree_selection", "main") as string + const setWorktree = (value: string) => kv.set("worktree_selection", value) const isGit = createMemo(() => !!sync.data.vcs) const worktreeLabel = createMemo(() => { const value = worktree() diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index b843bda1c9db..e657c7aa6634 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -177,7 +177,7 @@ export function Session() { createEffect(async () => { await sync.session - .sync(route.sessionID) + .sync(route.sessionID, route.directory) .then(() => { if (scroll) scroll.scrollBy(100_000) }) diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index 6d41fe857a61..03986e683f16 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -37,7 +37,16 @@ function createWorkerFetch(client: RpcClient): typeof fetch { function createEventSource(client: RpcClient): EventSource { return { - on: (handler) => client.on("event", handler), + on: (handler) => { + const unsub1 = client.on("event", handler) + const unsub2 = client.on<{ directory?: string; payload: Event }>("global.event", (event) => { + handler(event.payload) + }) + return () => { + unsub1() + unsub2() + } + }, } }