diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx
index c08fc99b6e3..5ecd130eaed 100644
--- a/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx
@@ -1,6 +1,7 @@
import { TextAttributes } from "@opentui/core"
import { useTheme } from "../context/theme"
import { useSync } from "@tui/context/sync"
+import { useSDK } from "@tui/context/sdk"
import { For, Match, Switch, Show, createMemo } from "solid-js"
import { Installation } from "@/installation"
@@ -8,7 +9,9 @@ export type DialogStatusProps = {}
export function DialogStatus() {
const sync = useSync()
+ const sdk = useSDK()
const { theme } = useTheme()
+ const isAttached = createMemo(() => !sdk.url.includes(".internal"))
const enabledFormatters = createMemo(() => sync.data.formatter.filter((f) => f.enabled))
@@ -46,6 +49,11 @@ export function DialogStatus() {
esc
OpenCode v{Installation.VERSION}
+
+
+ Attached to {sdk.url}
+
+
0} fallback={No MCP Servers}>
{Object.keys(sync.data.mcp).length} MCP Servers
diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts
index 05714268545..c02712df20d 100644
--- a/packages/opencode/src/cli/cmd/tui/thread.ts
+++ b/packages/opencode/src/cli/cmd/tui/thread.ts
@@ -9,6 +9,8 @@ import { Log } from "@/util/log"
import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
import type { Event } from "@opencode-ai/sdk/v2"
import type { EventSource } from "./context/sdk"
+import { Global } from "@/global"
+import { parse as parseJsonc } from "jsonc-parser"
declare global {
const OPENCODE_WORKER_PATH: string
@@ -40,6 +42,38 @@ function createEventSource(client: RpcClient): EventSource {
}
}
+async function tryAttachToServer(url: string): Promise {
+ try {
+ const response = await fetch(`${url}/health`, {
+ method: "GET",
+ signal: AbortSignal.timeout(2000),
+ })
+ return response.ok
+ } catch {
+ return false
+ }
+}
+
+async function getAttachUrl(cwd: string): Promise {
+ const candidates = [
+ path.join(cwd, "opencode.jsonc"),
+ path.join(cwd, "opencode.json"),
+ path.join(Global.Path.config, "opencode.jsonc"),
+ path.join(Global.Path.config, "opencode.json"),
+ ]
+
+ for (const filepath of candidates) {
+ try {
+ const text = await Bun.file(filepath).text()
+ const config = parseJsonc(text) as { tui?: { attach?: string } } | undefined
+ if (config?.tui?.attach) return config.tui.attach
+ } catch {
+ // File doesn't exist or invalid, try next
+ }
+ }
+ return undefined
+}
+
export const TuiThreadCommand = cmd({
command: "$0 [project]",
describe: "start opencode tui",
@@ -71,11 +105,49 @@ export const TuiThreadCommand = cmd({
.option("agent", {
type: "string",
describe: "agent to use",
+ })
+ .option("attach", {
+ type: "string",
+ describe: "attach to a running opencode server (e.g., http://localhost:4096)",
}),
handler: async (args) => {
// Resolve relative paths against PWD to preserve behavior when using --cwd flag
const baseCwd = process.env.PWD ?? process.cwd()
const cwd = args.project ? path.resolve(baseCwd, args.project) : process.cwd()
+
+ try {
+ process.chdir(cwd)
+ } catch (e) {
+ UI.error("Failed to change directory to " + cwd)
+ return
+ }
+
+ // Check if --attach flag or tui.attach config is set - try to connect to existing server first
+ const attachUrl = args.attach ?? (await getAttachUrl(cwd))
+ if (attachUrl) {
+ const serverAvailable = await tryAttachToServer(attachUrl)
+ if (serverAvailable) {
+ // Server is available, attach to it directly (no worker spawn)
+ const prompt = await iife(async () => {
+ const piped = !process.stdin.isTTY ? await Bun.stdin.text() : undefined
+ if (!args.prompt) return piped
+ return piped ? piped + "\n" + args.prompt : args.prompt
+ })
+ await tui({
+ url: attachUrl,
+ args: {
+ continue: args.continue,
+ sessionID: args.session,
+ agent: args.agent,
+ model: args.model,
+ prompt,
+ },
+ })
+ return
+ }
+ // Server not available, fall through to spawn a new worker
+ }
+
const localWorker = new URL("./worker.ts", import.meta.url)
const distWorker = new URL("./cli/cmd/tui/worker.js", import.meta.url)
const workerPath = await iife(async () => {
@@ -83,12 +155,6 @@ export const TuiThreadCommand = cmd({
if (await Bun.file(distWorker).exists()) return distWorker
return localWorker
})
- try {
- process.chdir(cwd)
- } catch (e) {
- UI.error("Failed to change directory to " + cwd)
- return
- }
const worker = new Worker(workerPath, {
env: Object.fromEntries(
diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts
index b2142e29b94..6c59cb57653 100644
--- a/packages/opencode/src/config/config.ts
+++ b/packages/opencode/src/config/config.ts
@@ -804,6 +804,11 @@ export namespace Config {
.enum(["auto", "stacked"])
.optional()
.describe("Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column"),
+ attach: z
+ .string()
+ .url()
+ .optional()
+ .describe("URL to attach to existing server. Falls back to spawning new server if unavailable."),
})
export const Server = z
diff --git a/packages/web/src/content/docs/config.mdx b/packages/web/src/content/docs/config.mdx
index 1474cb91558..9a2da035820 100644
--- a/packages/web/src/content/docs/config.mdx
+++ b/packages/web/src/content/docs/config.mdx
@@ -174,6 +174,7 @@ Available options:
- `scroll_acceleration.enabled` - Enable macOS-style scroll acceleration. **Takes precedence over `scroll_speed`.**
- `scroll_speed` - Custom scroll speed multiplier (default: `3`, minimum: `1`). Ignored if `scroll_acceleration.enabled` is `true`.
- `diff_style` - Control diff rendering. `"auto"` adapts to terminal width, `"stacked"` always shows single column.
+- `attach` - URL of an existing OpenCode server to connect to. Falls back to spawning a new server if unavailable.
[Learn more about using the TUI here](/docs/tui).
diff --git a/packages/web/src/content/docs/server.mdx b/packages/web/src/content/docs/server.mdx
index 7229e09b22f..f81c5141cd8 100644
--- a/packages/web/src/content/docs/server.mdx
+++ b/packages/web/src/content/docs/server.mdx
@@ -68,6 +68,32 @@ The [`/tui`](#tui) endpoint can be used to drive the TUI through the server. For
---
+#### Attach TUI to a running server
+
+You can attach a new TUI instance to an existing server using the `--attach` flag or `tui.attach` config:
+
+```bash
+# Start the server
+opencode serve --port 4096
+
+# In another terminal, attach the TUI
+opencode --attach http://localhost:4096
+```
+
+Or configure it in your config file to always attach:
+
+```json title="opencode.json"
+{
+ "tui": {
+ "attach": "http://localhost:4096"
+ }
+}
+```
+
+[Learn more about attaching to servers](/docs/tui#attach-to-server).
+
+---
+
## Spec
The server publishes an OpenAPI 3.1 spec that can be viewed at:
diff --git a/packages/web/src/content/docs/tui.mdx b/packages/web/src/content/docs/tui.mdx
index 085eb6169f8..2250e8de6ff 100644
--- a/packages/web/src/content/docs/tui.mdx
+++ b/packages/web/src/content/docs/tui.mdx
@@ -373,6 +373,38 @@ You can customize TUI behavior through your OpenCode config file.
- `scroll_acceleration` - Enable macOS-style scroll acceleration for smooth, natural scrolling. When enabled, scroll speed increases with rapid scrolling gestures and stays precise for slower movements. **This setting takes precedence over `scroll_speed` and overrides it when enabled.**
- `scroll_speed` - Controls how fast the TUI scrolls when using scroll commands (minimum: `1`). Defaults to `3`. **Note: This is ignored if `scroll_acceleration.enabled` is set to `true`.**
+- `attach` - URL of an existing OpenCode server to connect to instead of spawning a new one. Falls back to spawning a new server if unavailable. [Learn more](#attach-to-server).
+
+---
+
+## Attach to server
+
+You can attach the TUI to an existing OpenCode server instead of spawning a new one. This is useful when running OpenCode as a persistent background service.
+
+### Via CLI flag
+
+```bash
+opencode --attach http://localhost:4096
+```
+
+### Via config
+
+```json title="opencode.json"
+{
+ "$schema": "https://opencode.ai/config.json",
+ "tui": {
+ "attach": "http://opencode.local"
+ }
+}
+```
+
+When `attach` is configured, the TUI will:
+
+1. Check if the server is available via a health check
+2. If available, connect directly without spawning a new worker
+3. If unavailable, fall back to spawning a new server
+
+The CLI flag `--attach` takes precedence over the config file setting.
---