diff --git a/README.md b/README.md index d58a8851..4bc8d086 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ Refer to the [Quick Chat](#-quick-chat) section for more details. - [Server-Sent Events (SSE) autocmds](#-server-sent-events-sse-autocmds) - [Quick Chat](#quick-chat) - [Setting up Opencode](#-setting-up-opencode) +- [Recipes](./docs/recipes) ## ⚠️Caution diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..a500c632 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,21 @@ +# Opencode.nvim Documentation + +## Recipes + +Community-contributed configurations and workflows for opencode.nvim. + +### Available Recipes + +- [Bidirectional TUI/nvim Sync](./recipes/bidirectional-sync/README.md) - Share sessions between TUI and nvim plugin seamlessly +- [Three-State Layout Toggle](./recipes/three-state-layout/README.md) - Instantly switch between code/split/dialog viewing modes + +### Contributing a Recipe + +Want to share your setup? Use the [Recipe Template](./recipes/TEMPLATE.md) to ensure consistency: + +- Start with the problem you're solving +- Include a GIF demonstration +- Provide step-by-step setup instructions +- Cross-reference related recipes + +Recipes should be self-contained and solve a specific workflow need. diff --git a/docs/recipes/TEMPLATE.md b/docs/recipes/TEMPLATE.md new file mode 100644 index 00000000..eff989ad --- /dev/null +++ b/docs/recipes/TEMPLATE.md @@ -0,0 +1,43 @@ +# Recipe Template + +One-line description of what this recipe enables. + +![Demo animation](./demo.gif) + +## Problem + +What workflow pain does this solve? 2-3 specific scenarios. + +## Solution + +Brief description of the approach. + +## Quick Start + +### Prerequisites + +- Required tools/versions + +### Setup + +```bash +# Installation steps +``` + +### Usage + +```lua +-- Configuration snippet +``` + +## How It Works + +Key technical details. Link to relevant APIs if needed. + +## Integration + +- Combine with [other-recipe](../other-recipe/README.md) for more capabilities + +--- + +Contributed by @[username](https://github.com/username) diff --git a/docs/recipes/bidirectional-sync/README.md b/docs/recipes/bidirectional-sync/README.md new file mode 100644 index 00000000..1b907d33 --- /dev/null +++ b/docs/recipes/bidirectional-sync/README.md @@ -0,0 +1,121 @@ +# Bidirectional TUI/nvim Sync + +Switch seamlessly between opencode TUI and nvim plugin without losing context. + +![Bidirectional sync demo](./bidirectional-sync.gif) + +## Problem + +Switching between opencode TUI and nvim plugin feels like using two separate tools: + +1. **Session isolation** - Start a conversation in TUI, switch to nvim, your context is lost +2. **Double initialization** - Each interface spawns its own server, wasting 15-20s on MCP loading every time +3. **Mental overhead** - You have to remember which interface you were using for what task + +You want TUI for complex workflows and nvim for quick code edits, seamlessly. + +## Solution + +Use a single shared HTTP server that both TUI and nvim connect to: + +- Start server once, use from any interface +- Session state persists across TUI/nvim switches +- Zero context loss when changing tools + +## State Flow + +```mermaid +flowchart LR + A[Terminal: oc-sync.sh] -->|starts| B[Shared Server :4096] + C[nvim] -->|connects| B + D[TUI] -->|connects| B + B -->|shares session| C + B -->|shares session| D +``` + +## Quick Start + +### 1. Install Wrapper + +```bash +chmod +x oc-sync.sh +cp oc-sync.sh ~/.local/bin/ +``` + +### 2. Configure Nvim + +Add to your opencode.nvim setup: + +```lua +server = { + url = "localhost", + port = 4096, + timeout = 30, -- First boot can be slow (MCP initialization) + auto_kill = false, -- Keep server alive when TUI is active + spawn_command = function(port, url) + local script = vim.fn.expand("~/.local/bin/oc-sync.sh") + vim.fn.system(script .. " --sync-ensure") + return nil -- Server lifecycle managed externally + end, +} +``` + +### 3. Use It + +Terminal 1 - Start TUI: +```bash +oc-sync.sh /path/to/project +``` + +Terminal 2 - Open nvim in same directory: +```bash +cd /path/to/project && nvim +``` + +Both will share the same session state. + +## Implementation Notes + +- `oc-sync.sh --sync-ensure` starts shared HTTP server (port 4096) +- TUI runs `opencode attach ` to connect +- Nvim plugin connects to same endpoint +- Server stays alive until manually killed + +## Customization + +Environment variables: + +| Variable | Default | Description | +|----------|---------|-------------| +| `OPENCODE_SYNC_PORT` | 4096 | HTTP server port | +| `OPENCODE_SYNC_HOST` | 127.0.0.1 | Server bind address | +| `OPENCODE_SYNC_WAIT_TIMEOUT_SEC` | 20 | Startup timeout | + +## Troubleshooting + +**Port already in use?** +```bash +# Check what's using it +lsof -i :4096 + +# Kill the process +kill $(lsof -t -i :4096) +``` + +**MCP plugins taking too long?** +```bash +# Increase timeout +export OPENCODE_SYNC_WAIT_TIMEOUT_SEC=60 +``` + +**Server not responding?** +```bash +# Check health +curl http://localhost:4096/global/health +``` + +## Integration Ideas + +- Combine with [three-state-layout](../three-state-layout/README.md) to also control how you view opencode within nvim +- Use terminal multiplexers (tmux/zellij) to manage both TUI and nvim in one window +- Add shell aliases for common project paths diff --git a/docs/recipes/bidirectional-sync/bidirectional-sync.gif b/docs/recipes/bidirectional-sync/bidirectional-sync.gif new file mode 100644 index 00000000..fb95375e Binary files /dev/null and b/docs/recipes/bidirectional-sync/bidirectional-sync.gif differ diff --git a/docs/recipes/bidirectional-sync/oc-sync.sh b/docs/recipes/bidirectional-sync/oc-sync.sh new file mode 100755 index 00000000..1294a3aa --- /dev/null +++ b/docs/recipes/bidirectional-sync/oc-sync.sh @@ -0,0 +1,175 @@ +#!/bin/bash +# oc-sync.sh: low-complexity opencode sync wrapper +# - default/path argument: ensure shared server, then attach +# - other commands: pass through to opencode found in PATH +# - fail fast when no executable opencode can be resolved + +set -euo pipefail + +DEFAULT_PORT="${OPENCODE_SYNC_PORT:-4096}" +DEFAULT_HOST="${OPENCODE_SYNC_HOST:-127.0.0.1}" +SERVER_READY_TIMEOUT_SEC="${OPENCODE_SYNC_WAIT_TIMEOUT_SEC:-20}" + +log_info() { echo "[oc-sync] $*" >&2; } +log_error() { echo "[oc-sync] ERROR: $*" >&2; } + +build_endpoint() { echo "http://${1}:${2}"; } + +check_health() { + curl -sf "${1}/global/health" >/dev/null 2>&1 +} + +port_in_use() { + lsof -i ":${1}" -sTCP:LISTEN >/dev/null 2>&1 +} + +port_owner_pid() { + lsof -i ":${1}" -sTCP:LISTEN -t 2>/dev/null | head -1 +} + +_norm_path() { + local p="$1" + local d + d="$(cd "$(dirname "$p")" 2>/dev/null && pwd -P)" || return 1 + printf "%s/%s" "$d" "$(basename "$p")" +} + +get_opencode_bin() { + local script_path + local candidate + local norm_script + local norm_candidate + + script_path="$(_norm_path "${BASH_SOURCE[0]}" 2>/dev/null || printf "%s" "${BASH_SOURCE[0]}")" + norm_script="${script_path}" + + candidate="$(command -v opencode 2>/dev/null || true)" + if [ -z "${candidate}" ] || [ ! -x "${candidate}" ]; then + log_error "opencode not found in PATH" + return 1 + fi + + norm_candidate="$(_norm_path "${candidate}" 2>/dev/null || printf "%s" "${candidate}")" + if [ "${norm_candidate}" = "${norm_script}" ]; then + log_error "resolved opencode points to wrapper itself: ${candidate}" + log_error "fix PATH to point to the real opencode binary" + return 1 + fi + + echo "${candidate}" +} + +wait_for_server() { + local endpoint="$1" + local start_ts + local now_ts + start_ts="$(date +%s)" + while true; do + if check_health "${endpoint}"; then + return 0 + fi + now_ts="$(date +%s)" + if [ $((now_ts - start_ts)) -ge "${SERVER_READY_TIMEOUT_SEC}" ]; then + return 1 + fi + sleep 0.5 + done +} + +start_server() { + local host="$1" + local port="$2" + local endpoint + local opencode_bin + endpoint="$(build_endpoint "${host}" "${port}")" + + if port_in_use "${port}"; then + local pid + pid="$(port_owner_pid "${port}")" + log_error "Port ${port} is in use (PID: ${pid:-unknown})" + return 1 + fi + + opencode_bin="$(get_opencode_bin)" || return 1 + + log_info "Starting server on ${host}:${port}..." + nohup "${opencode_bin}" serve --port "${port}" --hostname "${host}" \ + /dev/null 2>&1 & + + if wait_for_server "${endpoint}"; then + log_info "Server started" + return 0 + fi + + log_error "Server failed to start within timeout" + return 1 +} + +# Ensure the server is running and print endpoint to stdout. +ensure_server() { + local port="${1:-$DEFAULT_PORT}" + local host="${2:-$DEFAULT_HOST}" + local endpoint + endpoint="$(build_endpoint "${host}" "${port}")" + + if check_health "${endpoint}"; then + echo "${endpoint}" + return 0 + fi + + if port_in_use "${port}"; then + local pid + pid="$(port_owner_pid "${port}")" + log_error "Port ${port} occupied by PID ${pid:-unknown} but not healthy" + return 1 + fi + + start_server "${host}" "${port}" || return 1 + echo "${endpoint}" +} + +handler_passthrough() { + local opencode_bin + opencode_bin="$(get_opencode_bin)" || exit 1 + exec "${opencode_bin}" "$@" +} + +handler_wrap_tui() { + local endpoint + local opencode_bin + local work_dir + endpoint="$(ensure_server)" || { + log_error "Failed to ensure shared server" + exit 1 + } + opencode_bin="$(get_opencode_bin)" || exit 1 + work_dir="${PWD}" + if [ "$#" -gt 0 ] && [ -d "$1" ]; then + work_dir="$1" + shift + fi + exec "${opencode_bin}" attach "${endpoint}" --dir "${work_dir}" "$@" +} + +route_command() { + local cmd="${1:-}" + + if [ "${cmd}" = "--sync-ensure" ]; then + shift + ensure_server "$@" + return + fi + + if [ -z "${cmd}" ] || [ -d "${cmd}" ]; then + handler_wrap_tui "$@" + return + fi + + handler_passthrough "$@" +} + +main() { + route_command "$@" +} + +main "$@" diff --git a/docs/recipes/three-state-layout/README.md b/docs/recipes/three-state-layout/README.md new file mode 100644 index 00000000..9c991d69 --- /dev/null +++ b/docs/recipes/three-state-layout/README.md @@ -0,0 +1,58 @@ +# Three-State Layout Toggle + +Instantly switch how you view opencode. + +## Problem + +Real workflows need three distinct ways to interact with AI: + +1. **Focused coding** - opencode hidden, full attention on your code +2. **Side-by-side** - opencode visible alongside code, quick reference and iteration +3. **Deep conversation** - opencode fullscreen for complex AI interactions requiring full context + +The default `toggle()` only provides binary visible/hidden control. Switching between these three modes requires multiple keypresses and interrupts flow. + +## State Transitions + +```mermaid +stateDiagram-v2 + [*] --> focused : start + + focused --> side_by_side : zl + side_by_side --> focused : zl + side_by_side --> deep : zL + deep --> side_by_side : zl + deep --> focused : zL +``` + +## Demo + +Run the standalone demo: + +```vim +:luafile docs/recipes/three-state-layout/demo.lua +``` + +Then press `zl` or `zL` to toggle between modes. + +![Three-state layout toggle demo](./three-state-toggle.gif) + +## Keybindings + +| Key | Focused coding | Side-by-side | Deep conversation | +|-----|----------------|--------------|-------------------| +| `zl` | Enter side-by-side | Return to focused | Exit to side-by-side | +| `zL` | Enter deep conversation | Switch to deep | Return to focused | + +## Implementation Notes + +The demo uses `config.ui.position` to switch between window layouts: +- `position = 'right'` for side-by-side mode +- `position = 'current'` for deep conversation mode + +## Integration Ideas + +- Combine with [bidirectional-sync](../bidirectional-sync/README.md) to also share sessions between TUI and nvim +- Add autocmds to automatically enter deep conversation mode on long AI responses +- Map to leader keys for easier access +- Use with tmux/zellij for managing multiple opencode instances diff --git a/docs/recipes/three-state-layout/demo.lua b/docs/recipes/three-state-layout/demo.lua new file mode 100644 index 00000000..28f3e7a4 --- /dev/null +++ b/docs/recipes/three-state-layout/demo.lua @@ -0,0 +1,118 @@ +-- Three-State Layout Toggle Demo +-- Instantly switch between focused coding, side-by-side, and deep conversation modes +-- Usage: :luafile docs/recipes/three-state-layout/demo.lua +-- Requires: opencode.nvim plugin installed + +-- Three interaction modes: +-- focused: opencode hidden, full attention on your code +-- side_by_side: opencode visible alongside code for quick reference +-- deep: opencode fullscreen for complex AI interactions + +local MODE = { + focused = 'focused', + side_by_side = 'side_by_side', + deep = 'deep', +} + +local get_opencode_config + +-- Actions to transition between modes +local ACTIONS = { + to_focused = function(api) + api.toggle(false) + end, + to_side_by_side = function(api) + local config = get_opencode_config() + if not config then + return + end + api.toggle(false) + config.ui.position = 'right' + api.toggle(false) + end, + to_deep = function(api) + local config = get_opencode_config() + if not config then + return + end + api.toggle(false) + config.ui.position = 'current' + api.toggle(false) + end, + open_side_by_side = function(api) + local config = get_opencode_config() + if config then + config.ui.position = 'right' + end + api.toggle(false) + end, + open_deep = function(api) + local config = get_opencode_config() + if config then + config.ui.position = 'current' + end + api.toggle(false) + end, +} + +-- Transition table: which action to take from each mode +local TRANSITIONS = { + zl = { + [MODE.focused] = 'open_side_by_side', + [MODE.side_by_side] = 'to_focused', + [MODE.deep] = 'to_side_by_side', + }, + zL = { + [MODE.focused] = 'open_deep', + [MODE.side_by_side] = 'to_deep', + [MODE.deep] = 'to_focused', + }, +} + +-- Get opencode config (helper for position switching) +get_opencode_config = function() + local ok, config = pcall(require, 'opencode.config') + return ok and config or nil +end + +-- Detect current mode from opencode state +local function get_current_mode(api) + local ok_window_state, window_state = pcall(api.get_window_state) + if not ok_window_state or not window_state or window_state.status ~= 'visible' then + return MODE.focused + end + + local config = get_opencode_config() + if config and config.ui.position == 'current' then + return MODE.deep + end + + return MODE.side_by_side +end + +-- Execute transition based on trigger key +local function run_transition(trigger) + local ok_api, api = pcall(require, 'opencode.api') + if not ok_api then + return + end + + local current = get_current_mode(api) + local action_name = TRANSITIONS[trigger] and TRANSITIONS[trigger][current] + local action = action_name and ACTIONS[action_name] + if not action then + return + end + action(api) +end + +-- Set up keymaps +vim.keymap.set('n', 'zl', function() + run_transition('zl') +end, { desc = 'Toggle opencode side-by-side/focused', noremap = true, silent = true }) + +vim.keymap.set('n', 'zL', function() + run_transition('zL') +end, { desc = 'Toggle opencode deep/focused', noremap = true, silent = true }) + +vim.notify('Three-state layout loaded. zl: side-by-side, zL: deep conversation', vim.log.levels.INFO) diff --git a/docs/recipes/three-state-layout/three-state-toggle.gif b/docs/recipes/three-state-layout/three-state-toggle.gif new file mode 100644 index 00000000..03744b62 Binary files /dev/null and b/docs/recipes/three-state-layout/three-state-toggle.gif differ