Skip to content

Commit 97e0bec

Browse files
committed
docs: add bidirectional sync recipe
- Add recipe for sharing opencode sessions between TUI and nvim plugin.
1 parent dffa3f3 commit 97e0bec

3 files changed

Lines changed: 284 additions & 0 deletions

File tree

docs/outline.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Opencode.nvim Documentation
2+
3+
## Recipes
4+
5+
- [Bidirectional TUI/nvim Sync](./recipes/bidirectional-sync/README.md) - Switch between TUI and nvim seamlessly
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
# Bidirectional TUI/nvim Sync
2+
3+
> See [video demonstration](https://github.com/sudo-tee/opencode.nvim/issues/289#issuecomment-4030869229) for a walkthrough.
4+
5+
Enable real-time session sharing between opencode TUI and nvim plugin through a shared HTTP server.
6+
7+
## Problem
8+
9+
By default, opencode TUI and nvim plugin run isolated:
10+
- TUI starts in internal RPC mode (no TCP port)
11+
- Nvim plugin spawns its own local server
12+
- Sessions are not synchronized between the two interfaces
13+
14+
## Solution
15+
16+
Use a wrapper script (`oc-sync.sh`) to manage a shared HTTP server that both TUI and nvim connect to:
17+
- Single server instance on fixed port (4096)
18+
- Both interfaces see the same session state
19+
- Seamless switching between TUI and nvim
20+
21+
## Quick Start
22+
23+
### 1. Install Wrapper
24+
25+
```bash
26+
chmod +x oc-sync.sh
27+
cp oc-sync.sh ~/.local/bin/
28+
```
29+
30+
### 2. Configure Nvim
31+
32+
Add to your opencode.nvim setup:
33+
34+
```lua
35+
server = {
36+
url = "localhost",
37+
port = 4096,
38+
timeout = 30, -- First boot can be slow (MCP initialization)
39+
auto_kill = false, -- Keep server alive when nvim closes
40+
spawn_command = function(port, url)
41+
local script = vim.fn.expand("~/.local/bin/oc-sync.sh")
42+
vim.fn.system(script .. " --sync-ensure")
43+
return nil -- Server lifecycle managed externally
44+
end,
45+
}
46+
```
47+
48+
### 3. Use It
49+
50+
Terminal 1 - Start TUI:
51+
```bash
52+
oc-sync.sh /path/to/project
53+
```
54+
55+
Terminal 2 - Open nvim in same directory:
56+
```bash
57+
cd /path/to/project && nvim
58+
```
59+
60+
Both will share the same session state.
61+
62+
## How It Works
63+
64+
1. `oc-sync.sh --sync-ensure` starts shared HTTP server (port 4096)
65+
2. TUI runs `opencode attach <endpoint>` to connect
66+
3. Nvim plugin connects to same endpoint
67+
4. Server stays alive until manually killed
68+
69+
## Customization
70+
71+
Environment variables:
72+
73+
| Variable | Default | Description |
74+
|----------|---------|-------------|
75+
| `OPENCODE_SYNC_PORT` | 4096 | HTTP server port |
76+
| `OPENCODE_SYNC_HOST` | 127.0.0.1 | Server bind address |
77+
| `OPENCODE_SYNC_WAIT_TIMEOUT_SEC` | 20 | Startup timeout |
78+
79+
## Troubleshooting
80+
81+
**Port already in use?**
82+
```bash
83+
# Check what's using it
84+
lsof -i :4096
85+
86+
# Kill the process
87+
kill $(lsof -t -i :4096)
88+
```
89+
90+
**MCP plugins taking too long?**
91+
```bash
92+
# Increase timeout
93+
export OPENCODE_SYNC_WAIT_TIMEOUT_SEC=60
94+
```
95+
96+
**Server not responding?**
97+
```bash
98+
# Check health
99+
curl http://localhost:4096/global/health
100+
```
101+
102+
## Acknowledgements
103+
104+
Thanks to @sei-dwtompkins for implementing external server configuration support in [PR #295](https://github.com/sudo-tee/opencode.nvim/pull/295), which enables this workflow.
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
#!/bin/bash
2+
# oc-sync.sh: low-complexity opencode sync wrapper
3+
# - default/path argument: ensure shared server, then attach
4+
# - other commands: pass through to opencode found in PATH
5+
# - fail fast when no executable opencode can be resolved
6+
7+
set -euo pipefail
8+
9+
DEFAULT_PORT="${OPENCODE_SYNC_PORT:-4096}"
10+
DEFAULT_HOST="${OPENCODE_SYNC_HOST:-127.0.0.1}"
11+
SERVER_READY_TIMEOUT_SEC="${OPENCODE_SYNC_WAIT_TIMEOUT_SEC:-20}"
12+
13+
log_info() { echo "[oc-sync] $*" >&2; }
14+
log_error() { echo "[oc-sync] ERROR: $*" >&2; }
15+
16+
build_endpoint() { echo "http://${1}:${2}"; }
17+
18+
check_health() {
19+
curl -sf "${1}/global/health" >/dev/null 2>&1
20+
}
21+
22+
port_in_use() {
23+
lsof -i ":${1}" -sTCP:LISTEN >/dev/null 2>&1
24+
}
25+
26+
port_owner_pid() {
27+
lsof -i ":${1}" -sTCP:LISTEN -t 2>/dev/null | head -1
28+
}
29+
30+
_norm_path() {
31+
local p="$1"
32+
local d
33+
d="$(cd "$(dirname "$p")" 2>/dev/null && pwd -P)" || return 1
34+
printf "%s/%s" "$d" "$(basename "$p")"
35+
}
36+
37+
get_opencode_bin() {
38+
local script_path
39+
local candidate
40+
local norm_script
41+
local norm_candidate
42+
43+
script_path="$(_norm_path "${BASH_SOURCE[0]}" 2>/dev/null || printf "%s" "${BASH_SOURCE[0]}")"
44+
norm_script="${script_path}"
45+
46+
candidate="$(command -v opencode 2>/dev/null || true)"
47+
if [ -z "${candidate}" ] || [ ! -x "${candidate}" ]; then
48+
log_error "opencode not found in PATH"
49+
return 1
50+
fi
51+
52+
norm_candidate="$(_norm_path "${candidate}" 2>/dev/null || printf "%s" "${candidate}")"
53+
if [ "${norm_candidate}" = "${norm_script}" ]; then
54+
log_error "resolved opencode points to wrapper itself: ${candidate}"
55+
log_error "fix PATH to point to the real opencode binary"
56+
return 1
57+
fi
58+
59+
echo "${candidate}"
60+
}
61+
62+
wait_for_server() {
63+
local endpoint="$1"
64+
local start_ts
65+
local now_ts
66+
start_ts="$(date +%s)"
67+
while true; do
68+
if check_health "${endpoint}"; then
69+
return 0
70+
fi
71+
now_ts="$(date +%s)"
72+
if [ $((now_ts - start_ts)) -ge "${SERVER_READY_TIMEOUT_SEC}" ]; then
73+
return 1
74+
fi
75+
sleep 0.5
76+
done
77+
}
78+
79+
start_server() {
80+
local host="$1"
81+
local port="$2"
82+
local endpoint
83+
local opencode_bin
84+
endpoint="$(build_endpoint "${host}" "${port}")"
85+
86+
if port_in_use "${port}"; then
87+
local pid
88+
pid="$(port_owner_pid "${port}")"
89+
log_error "Port ${port} is in use (PID: ${pid:-unknown})"
90+
return 1
91+
fi
92+
93+
opencode_bin="$(get_opencode_bin)" || return 1
94+
95+
log_info "Starting server on ${host}:${port}..."
96+
nohup "${opencode_bin}" serve --port "${port}" --hostname "${host}" \
97+
</dev/null >/dev/null 2>&1 &
98+
99+
if wait_for_server "${endpoint}"; then
100+
log_info "Server started"
101+
return 0
102+
fi
103+
104+
log_error "Server failed to start within timeout"
105+
return 1
106+
}
107+
108+
# Ensure the server is running and print endpoint to stdout.
109+
ensure_server() {
110+
local port="${1:-$DEFAULT_PORT}"
111+
local host="${2:-$DEFAULT_HOST}"
112+
local endpoint
113+
endpoint="$(build_endpoint "${host}" "${port}")"
114+
115+
if check_health "${endpoint}"; then
116+
echo "${endpoint}"
117+
return 0
118+
fi
119+
120+
if port_in_use "${port}"; then
121+
local pid
122+
pid="$(port_owner_pid "${port}")"
123+
log_error "Port ${port} occupied by PID ${pid:-unknown} but not healthy"
124+
return 1
125+
fi
126+
127+
start_server "${host}" "${port}" || return 1
128+
echo "${endpoint}"
129+
}
130+
131+
handler_passthrough() {
132+
local opencode_bin
133+
opencode_bin="$(get_opencode_bin)" || exit 1
134+
exec "${opencode_bin}" "$@"
135+
}
136+
137+
handler_wrap_tui() {
138+
local endpoint
139+
local opencode_bin
140+
local work_dir
141+
endpoint="$(ensure_server)" || {
142+
log_error "Failed to ensure shared server"
143+
exit 1
144+
}
145+
opencode_bin="$(get_opencode_bin)" || exit 1
146+
work_dir="${PWD}"
147+
if [ "$#" -gt 0 ] && [ -d "$1" ]; then
148+
work_dir="$1"
149+
shift
150+
fi
151+
exec "${opencode_bin}" attach "${endpoint}" --dir "${work_dir}" "$@"
152+
}
153+
154+
route_command() {
155+
local cmd="${1:-}"
156+
157+
if [ "${cmd}" = "--sync-ensure" ]; then
158+
shift
159+
ensure_server "$@"
160+
return
161+
fi
162+
163+
if [ -z "${cmd}" ] || [ -d "${cmd}" ]; then
164+
handler_wrap_tui "$@"
165+
return
166+
fi
167+
168+
handler_passthrough "$@"
169+
}
170+
171+
main() {
172+
route_command "$@"
173+
}
174+
175+
main "$@"

0 commit comments

Comments
 (0)