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..fe09466ec549 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,30 @@ export function Prompt(props: PromptProps) { promptModelWarning() return } + 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) + if (!created?.directory) { + toast.show({ variant: "error", message: "Failed to create workspace" }) + return + } + directory = created.directory + client = sdk.createClient(directory) + toast.show({ variant: "info", message: `Creating workspace ${created.name}...`, duration: 3000 }) + } else { + directory = worktreeSelection + client = sdk.createClient(directory) + } + 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 +592,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 +617,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 +633,7 @@ export function Prompt(props: PromptProps) { })), }) } else { - sdk.client.session + client.session .prompt({ sessionID, ...selectedModel, @@ -651,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/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..62c6354a4fdf 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") }) @@ -439,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 c011f6c62468..3f9f4ff89e4d 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/home.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/home.tsx @@ -1,3 +1,4 @@ +import path from "path" import { Prompt, type PromptRef } from "@tui/component/prompt" import { createMemo, Match, onMount, Show, Switch } from "solid-js" import { useTheme } from "@tui/context/theme" @@ -34,6 +35,19 @@ export function Home() { return Object.values(sync.data.mcp).filter((x) => x.status === "connected").length }) + 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() + 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 +121,8 @@ export function Home() { promptRef.set(r) }} hint={Hint} + worktree={worktree()} + onWorktreeChange={setWorktree} /> @@ -119,6 +135,15 @@ export function Home() { {directory()} + + + + + {worktreeLabel()} + + /workspace + + 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() + } + }, } }