import React from "react" import { Box, Text } from "ink" import TextInput from "ink-text-input" import { execSync, spawn as spawnProcess } from "child_process" import { existsSync } from "fs" import { join } from "path" import { createOpencodeClient } from "@opencode-ai/sdk" import { useStore } from "../store.js" import { useSpawnKeys } from "../hooks/use-keybindings.js" import { refreshNow } from "../poller.js" import { findNextPort, waitForServer, loadSpawnedInstances, saveSpawnedInstances, createWorktree, } from "../registry/instances.js" function expandHome(p: string): string { if (p.startsWith("~")) { return p.replace(/^~/, process.env.HOME ?? process.env.USERPROFILE ?? "") } return p } function isFzfAvailable(): boolean { try { execSync("which fzf", { stdio: "pipe" }) return true } catch { return false } } function runFzf(): string | undefined { try { const searchPaths = [ expandHome("~/Programming"), expandHome("~/repos"), expandHome("~/projects"), expandHome("~/code"), expandHome("~"), ] .filter((p) => p && existsSync(p)) .join(" ") // Search for .git directories and strip the /.git suffix to get repo roots // Use # as sed delimiter to avoid escaping issues with / const cmd = `find ${searchPaths} -maxdepth 4 -name .git -type d 2>/dev/null | sed 's#/.git$##' | sort -u | fzf --prompt="Select repo: " --height=40%` const result = execSync(cmd, { stdio: ["inherit", "pipe", "inherit"], encoding: "utf-8", }) return result.trim() || undefined } catch { return undefined } } function isGitRepo(dir: string): boolean { try { execSync("git rev-parse --is-inside-work-tree", { cwd: dir, stdio: ["pipe", "pipe", "pipe"], }) return true } catch { return false } } export function detectProjectVenv(projectRoot: string): { root: string; binDir: string } | null { for (const dirName of [".venv", "venv"]) { const root = join(projectRoot, dirName) const binDir = join(root, "bin") if (existsSync(join(binDir, "python"))) { return { root, binDir } } } return null } export function Worktree() { const navigate = useStore((s) => s.navigate) const [step, setStep] = React.useState<"repo" | "branch" | "spawning" | "error">("repo") const [repoDir, setRepoDir] = React.useState("") const [branchName, setBranchName] = React.useState("") const [errorMsg, setErrorMsg] = React.useState("") const [manualPath, setManualPath] = React.useState("") const [hasFzf] = React.useState(() => isFzfAvailable()) useSpawnKeys({ onCancel: () => navigate("dashboard"), }) const pickRepo = React.useCallback((dir: string) => { const expanded = expandHome(dir.trim()) if (!expanded || !existsSync(expanded)) { setErrorMsg(`Path does not exist: ${expanded}`) setStep("error") return } if (!isGitRepo(expanded)) { setErrorMsg(`Not a git repository: ${expanded}`) setStep("error") return } setRepoDir(expanded) setStep("branch") }, []) const openFzf = React.useCallback(() => { const selected = runFzf() if (selected) { pickRepo(selected) } else { navigate("dashboard") } }, [pickRepo, navigate]) React.useEffect(() => { if (hasFzf && step === "repo") { setTimeout(() => openFzf(), 50) } }, []) // eslint-disable-line const doSpawn = React.useCallback(async (cwd: string, projectRoot: string) => { setStep("spawning") setErrorMsg("") try { const port = await findNextPort() const projectVenv = detectProjectVenv(projectRoot) const env = projectVenv ? { ...process.env, PATH: `${projectVenv.binDir}:${process.env.PATH ?? ""}`, VIRTUAL_ENV: projectVenv.root, } : undefined const proc = spawnProcess("opencode", ["serve", "--port", String(port)], { cwd, detached: true, stdio: "ignore", env, }) proc.unref() await waitForServer(port) const client = createOpencodeClient({ baseUrl: `http://localhost:${port}` }) const sessionResult = await client.session.create() const sessionId = (sessionResult as any).data?.id ?? null const instances = loadSpawnedInstances() instances.push({ port, pid: proc.pid!, cwd, sessionId }) saveSpawnedInstances(instances) refreshNow() if (sessionId) { const projectId = useStore.getState().instances.find( (i) => i.sessionId === sessionId )?.projectId ?? null if (projectId) { navigate("conversation", projectId, sessionId) } else { navigate("dashboard") } } else { navigate("dashboard") } } catch (e) { setErrorMsg(String(e)) setStep("error") } }, [navigate]) const handleBranchSubmit = React.useCallback((name: string) => { if (!name.trim()) { void doSpawn(repoDir, repoDir) return } try { const worktreeDir = createWorktree(repoDir, name.trim()) void doSpawn(worktreeDir, repoDir) } catch (e) { setErrorMsg(`Failed to create worktree: ${String(e)}`) setStep("error") } }, [repoDir, doSpawn]) if (step === "spawning") { return ( ocmux — creating worktree session... Starting background server, please wait... ) } if (step === "branch") { return ( ocmux — new worktree session {"─".repeat(60)} Repo: {repoDir} Branch name (empty = session in root directory): branch: Esc: back to dashboard ) } return ( ocmux — new worktree session {"─".repeat(60)} {hasFzf ? ( Press Enter to open repo picker (fzf) or type a path manually below: ) : ( fzf not found. Enter path manually: )} {">"} { if (manualPath.trim()) { pickRepo(manualPath) } else if (hasFzf) { openFzf() } }} placeholder={hasFzf ? "Enter to open picker, or type path..." : "Enter path to repo..."} /> {step === "error" && ( {errorMsg} (press Esc to go back) )} Esc: back to dashboard ) }