import React from "react"
import { Box, Text } from "ink"
import TextInput from "ink-text-input"
import { execSync, spawn } from "child_process"
import { existsSync } from "fs"
import { createOpencodeClient } from "@opencode-ai/sdk"
import { useStore } from "../store.js"
import { useSpawnKeys } from "../hooks/use-keybindings.js"
import { refreshNow } from "../poller.js"
import { getSessionById } from "../db/reader.js"
import {
findNextPort,
waitForServer,
loadSpawnedInstances,
saveSpawnedInstances,
} 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(" ")
const cmd = `find ${searchPaths} -maxdepth 3 -type d 2>/dev/null | fzf --prompt="Select project folder: " --height=40%`
const result = execSync(cmd, {
stdio: ["inherit", "pipe", "inherit"],
encoding: "utf-8",
})
return result.trim() || undefined
} catch {
return undefined
}
}
export function Spawn() {
const navigate = useStore((s) => s.navigate)
const [manualPath, setManualPath] = React.useState("")
const [status, setStatus] = React.useState<"idle" | "spawning" | "error">("idle")
const [errorMsg, setErrorMsg] = React.useState("")
const [hasFzf] = React.useState(() => isFzfAvailable())
useSpawnKeys({
onCancel: () => navigate("dashboard"),
})
const doSpawn = React.useCallback(
async (cwd: string) => {
const expanded = expandHome(cwd.trim())
if (!expanded) return
if (!existsSync(expanded)) {
setErrorMsg(`Path does not exist: ${expanded}`)
setStatus("error")
return
}
setStatus("spawning")
setErrorMsg("")
try {
// 1. Find next available port
const port = await findNextPort()
// 2. Spawn opencode serve --port {port} as a detached background process
const proc = spawn("opencode", ["serve", "--port", String(port)], {
cwd: expanded,
detached: true,
stdio: "ignore",
})
proc.unref() // don't block OCMux's event loop on this child
// 3. Wait for the server to be ready
await waitForServer(port)
// 4. Create an initial session via SDK
const client = createOpencodeClient({ baseUrl: `http://localhost:${port}` })
const sessionResult = await client.session.create()
const sessionId = (sessionResult as any).data?.id ?? null
// 5. Persist the spawned instance info
const instances = loadSpawnedInstances()
instances.push({
port,
pid: proc.pid!,
cwd: expanded,
sessionId,
})
saveSpawnedInstances(instances)
// 6. Refresh so the dashboard knows about the new instance
refreshNow()
// 7. Navigate to conversation view for the new session.
// Use the DB directly instead of the store — refreshNow() is async
// and the store may not be populated yet.
if (sessionId) {
const session = getSessionById(sessionId)
if (session) {
navigate("conversation", session.projectId, sessionId)
} else {
navigate("dashboard")
}
} else {
navigate("dashboard")
}
} catch (e) {
setErrorMsg(String(e))
setStatus("error")
}
},
[navigate],
)
const openFzf = React.useCallback(() => {
const selected = runFzf()
if (selected) {
void doSpawn(selected)
}
}, [doSpawn])
React.useEffect(() => {
if (hasFzf) {
setTimeout(() => openFzf(), 50)
}
}, []) // eslint-disable-line react-hooks/exhaustive-deps
if (status === "spawning") {
return (
ocmux — spawning opencode server...
Starting background server, please wait...
)
}
return (
ocmux — open in opencode
{"─".repeat(60)}
{hasFzf ? (
Press Enter to open folder picker (fzf)
or type a path manually below:
) : (
fzf not found. Enter path manually:
)}
{">"}
{
if (manualPath.trim()) {
void doSpawn(manualPath)
} else if (hasFzf) {
openFzf()
}
}}
placeholder={
hasFzf
? "Enter to open picker, or type path..."
: "Enter path to project..."
}
/>
{status === "error" && (
{errorMsg}
(press Esc to go back)
)}
Esc: back to dashboard
)
}