Skip to content
7 changes: 4 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ A Neovim Lua plugin that bridges Neovim and the `opencode` CLI (external binary)
## Dependencies

- **Required**: `opencode` CLI, `curl`
- **Auto-discovery**: `pgrep` + `lsof` (Unix, unless `server.url` is set)
- **Auto-discovery**: reads OpenCode's background service registration (`service.json`) from its state directory (unless `server.url` is set)
- **Optional**: `snacks.nvim` (enhances `ask()` with `snacks.input`, `select()` with `snacks.picker`), `blink.cmp` (completion plugin with LSP source)
- No hard Lua dependencies beyond Neovim itself

Expand Down Expand Up @@ -62,10 +62,11 @@ stylua .
## Architecture notes

- **Async**: custom Promise implementation in `lua/opencode/promise/init.lua` (fork of `promise.nvim`)
- **Server discovery flow** (`lua/opencode/server/discovery/init.lua`): connected server → configured URL → local process scan (filtered by CWD overlap) → auto-start + poll (5s timeout)
- **Server discovery flow** (`lua/opencode/server/discovery/init.lua`): connected server → configured URL → OpenCode background service registration (`service.json`, URL + password) → auto-start + poll (5s timeout)
- **OpenCode v2 API** (`lua/opencode/server/init.lua`): the HTTP API lives under `/api/*` and always requires HTTP basic auth. The password comes from the service registration or `opts.server.password`. TUI-driving commands (scroll, navigation, prompt box) require the legacy `/tui/*` endpoints, detected at connect time into `server.tui` and absent in OpenCode v2.0.x.
- **Discovery vs connection**: server.connect (default true) controls whether auto-discovered servers are automatically subscribed to via SSE. When false, the server is found but not connected — use the select menu's "Connect to a server" / "Disconnect from connected server" items to manage connections manually.
- **Context system** (`lua/opencode/context/init.lua`): captures buffer/win/cursor/selection before UI opens, renders placeholders (`@this`, `@buffer`, etc.) in prompts
- **Events**: SSE subscribed on `connect()`, dispatched as `OpencodeEvent:<type>` User autocmds
- **Events**: SSE subscribed on `connect()` (`/api/event`), dispatched as `OpencodeEvent:<type>` User autocmds. OpenCode v2 events are shaped `{ id, type, data }`.
- **Edit review**: opens diff in new tab via `:diffpatch`, keymaps `da`/`dr` to accept/reject, `dp`/`do` for per-hunk
- **Ask completion**: in-process LSP server (`lua/opencode/ui/ask/cmp.lua`) providing context placeholder + agent completions
- **Integration policy**: code that bridges another tool _to_ opencode.nvim (e.g. picker send, terminal toggle) belongs in README examples. Code that enhances opencode.nvim's own UI (ask/select with snacks input/picker) stays in the plugin.
Expand Down
74 changes: 39 additions & 35 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ require("snacks").setup({
<summary><a href="https://github.com/folke/snacks.nvim/blob/main/docs/terminal.md">snacks.terminal</a> (Server)</summary>

```lua
local opencode_cmd = 'opencode --port'
local opencode_cmd = 'opencode'
---@type snacks.terminal.Opts
local snacks_terminal_opts = {
win = {
Expand All @@ -164,17 +164,13 @@ vim.keymap.set({ 'n', 't' }, '<C-.>', function()
require('snacks.terminal').toggle(opencode_cmd, snacks_terminal_opts)
end, { desc = 'Toggle OpenCode' })

-- Optionally show upon submitting prompt
-- Optionally show the terminal when a prompt is admitted to the session
vim.api.nvim_create_autocmd('User', {
pattern = { 'OpencodeEvent:tui.command.execute' },
callback = function(args)
---@type opencode.server.Event
local event = args.data.event
if event.properties.command == 'prompt.submit' then
local win = require('snacks.terminal').get(opencode_cmd, { create = false })
if win then
win:show()
end
pattern = { 'OpencodeEvent:session.inbox.delivered' },
callback = function()
local win = require('snacks.terminal').get(opencode_cmd, { create = false })
if win then
win:show()
end
end,
})
Expand Down Expand Up @@ -263,12 +259,14 @@ Select prompts to review, explain, and improve your code:

### Server

Run `opencode` locally however you like and opencode.nvim will find them! Or point `vim.g.opencode_opts.server.url` to a specific server, including remotes.
OpenCode v2 runs a **background service** that the TUI and other clients attach to. opencode.nvim discovers it automatically by reading OpenCode's registration file — its URL and generated password — from OpenCode's state directory (`$XDG_STATE_HOME/opencode/service.json`, or `~/.local/state/opencode/service.json`).

Run `opencode service start` yourself, or point `vim.g.opencode_opts.server.url` to a specific server, including remotes.

> [!IMPORTANT]
> You _must_ run `opencode` with the `--port` flag to expose its server.
> OpenCode v2 always secures its server with HTTP basic auth. opencode.nvim reads the generated password from the registration file, or falls back to `vim.g.opencode_opts.server.password` (and `username`, defaulting to the same `$OPENCODE_SERVER_PASSWORD` / `$OPENCODE_SERVER_USERNAME` environment variables as OpenCode).

If opencode.nvim can't find a running `opencode`, it starts one via `vim.g.opencode_opts.server.start`, defaulting to `term://opencode --port`. See [Integrations > snacks.terminal (Server)](#integrations) for a custom start example.
If opencode.nvim can't find a running service, it starts one via `vim.g.opencode_opts.server.start`, which defaults to running `opencode service start` and opening a TUI connected to it. See [Integrations > snacks.terminal (Server)](#integrations) for a custom start example.

opencode.nvim prioritizes focused pairing with a single OpenCode instance. As such, it connects to an OpenCode server before interacting with it, listening for events and targeting it for future interactions. Consider disabling `vim.g.opencode_opts.server.connect` if you frequently jump between servers or don't care for disruptive synchronous events like permission requests.

Expand Down Expand Up @@ -299,9 +297,12 @@ Highlights and previews items when using [snacks.picker](https://github.com/folk
Prompt OpenCode.

- Injects configured contexts.
- Trailing space appends; trailing "..." opens in Ask.
- Trailing "..." opens in Ask.
- OpenCode will interpret references to files or subagents.

> [!NOTE]
> On servers that expose the legacy `/tui/*` endpoints, a trailing space appends to the TUI prompt without submitting. Without them (OpenCode v2.0.x), the prompt is admitted straight to the active session.

### Operator — `require("opencode").operator()`

Wraps Prompt as an operator, supporting ranges and dot-repeat.
Expand All @@ -310,24 +311,27 @@ Wraps Prompt as an operator, supporting ranges and dot-repeat.

Command OpenCode:

| Command | Description |
| ------------------------ | ------------------------------------------ |
| `agent.cycle` | Cycle selected agent |
| `prompt.clear` | Clear current prompt |
| `prompt.submit` | Submit current prompt |
| `session.compact` | Compact current session |
| `session.first` | Jump to first message in session |
| `session.half.page.up` | Scroll messages up half a page |
| `session.half.page.down` | Scroll messages down half a page |
| `session.interrupt` | Interrupt current session |
| `session.last` | Jump to last message in current session |
| `session.new` | Start new session |
| `session.page.up` | Scroll messages up one page |
| `session.page.down` | Scroll messages down one page |
| `session.select` | Select session |
| `session.share` | Share current session |
| `session.redo` | Redo last undone action in current session |
| `session.undo` | Undo last action in current session |
| Command | Description |
| ------------------------- | ------------------------------------------ |
| `agent.cycle` | Cycle selected agent |
| `prompt.clear` † | Clear current prompt |
| `prompt.submit` † | Submit current prompt |
| `session.compact` | Compact current session |
| `session.first` † | Jump to first message in session |
| `session.half.page.up` † | Scroll messages up half a page |
| `session.half.page.down` †| Scroll messages down half a page |
| `session.interrupt` | Interrupt current session |
| `session.last` † | Jump to last message in current session |
| `session.new` | Start new session |
| `session.page.up` † | Scroll messages up one page |
| `session.page.down` † | Scroll messages down one page |
| `session.select` † | Select session |
| `session.share` † | Share current session |
| `session.redo` † | Redo last undone action in current session |
| `session.undo` † | Undo last action in current session |

> [!NOTE]
> Commands marked † rely on OpenCode's legacy TUI control endpoints, which were removed in OpenCode v2.0.x and re-added in newer builds. They work when the connected server exposes `/tui/*`, and are no-ops otherwise. The unmarked commands map directly to OpenCode v2's HTTP API.

## 👀 Events

Expand All @@ -343,11 +347,11 @@ vim.api.nvim_create_autocmd("User", {
---@type string
local url = args.data.url

-- See the available event types and their properties
-- See the available event types and their data
vim.notify(vim.inspect(event))
-- Do something useful
if event.type == "session.status" then
vim.notify("OpenCode status updated: " .. event.properties.status.type)
vim.notify("OpenCode status updated: " .. event.data.status.type)
end
end,
})
Expand Down
73 changes: 73 additions & 0 deletions lua/opencode.lua
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ end
---
---@param default? string Text to pre-fill the input with.
function M.ask(default)
M.open()
Comment thread
Copilot marked this conversation as resolved.
require("opencode.server.discovery")
.get()
:next(function(server)
Expand All @@ -39,6 +40,7 @@ end
---
---@param opts? opencode.select.Opts Override configured options for this call.
function M.select(opts)
M.open()
require("opencode.server.discovery")
.get()
:next(function(server)
Expand All @@ -58,6 +60,7 @@ M.statusline = require("opencode.events.status").statusline
---
---@param prompt string
function M.prompt(prompt)
M.open()
require("opencode.server.discovery")
.get()
:next(function(server)
Expand All @@ -71,6 +74,7 @@ end
---
---@param command opencode.server.Command | string
function M.command(command)
M.open()
require("opencode.server.discovery")
.get()
:next(function(server)
Expand Down Expand Up @@ -108,6 +112,75 @@ function M.operator(prompt)
return "g@"
end

---The buffer backing an OpenCode TUI opened by `toggle()`, if any.
---@type integer?
local tui_buf = nil
---The window currently showing that TUI, if visible.
---@type integer?
local tui_win = nil

---Whether the tracked TUI terminal still has a live job.
---
---@return boolean
local function tui_alive()
return tui_buf ~= nil and vim.api.nvim_buf_is_valid(tui_buf) and (vim.bo[tui_buf].channel or 0) > 0
end

---Show the OpenCode TUI panel on the right.
---
---Opens `opencode` in a right-hand split, reusing the existing terminal when one
---is already running. The TUI runs in the Neovim working directory so opencode
---creates and manages sessions for THIS project — the server's `/api/session`
---cannot place a new session in a target directory, so prompts target the
---project's existing sessions instead.
function M.open()
if tui_win and vim.api.nvim_win_is_valid(tui_win) then
return
end

-- The panel is a local TUI attached to the local background service. When a
-- URL is configured (e.g. a remote server), leave it alone (PR #330 review).
if require("opencode.config").opts.server.url ~= nil then
return
end

local origin = vim.api.nvim_get_current_win()
vim.cmd("botright vsplit")
if tui_alive() and tui_buf then
vim.api.nvim_win_set_buf(0, tui_buf)
else
-- Run the TUI in the Neovim working directory. The terminal inherits nvim's
-- cwd, so opencode builds sessions for this project; prompts then target
-- those project sessions (see :resolve_session_id). When the TUI already has
-- open tabs here, resume the last one instead of opening a fresh session.
local session_id = require("opencode.util.state").last_tab_session(vim.fn.getcwd())
if session_id and session_id ~= "" then
-- TODO: a tab recorded before the project-directory fix could point at a
-- ~ session, and a stale/deleted tab id makes `--session` fail. Consider
-- validating that the session still exists and lives in the cwd here.
vim.cmd("terminal opencode --session " .. session_id)
else
vim.cmd("terminal opencode")
end
tui_buf = vim.api.nvim_get_current_buf()
end
tui_win = vim.api.nvim_get_current_win()
vim.api.nvim_set_current_win(origin)
end

---Toggle the OpenCode TUI panel on the right.
---
---Opens `opencode` in a right-hand split if hidden, or hides it if visible.
---The terminal keeps running across toggles.
function M.toggle()
if tui_win and vim.api.nvim_win_is_valid(tui_win) then
vim.api.nvim_win_hide(tui_win)
tui_win = nil
return
end
M.open()
end

M.format = require("opencode.context").format

return M
112 changes: 106 additions & 6 deletions lua/opencode/api/command.lua
Original file line number Diff line number Diff line change
@@ -1,16 +1,116 @@
local M = {}

local Promise = require("opencode.promise")

---Commands that only the legacy `/tui/*` control endpoints can perform.
---These have no OpenCode v2 HTTP API equivalent (scroll, navigation, prompt box).
---@type table<string, boolean>
local TUI_ONLY = {
["prompt.clear"] = true,
["prompt.submit"] = true,
["session.first"] = true,
["session.last"] = true,
["session.half.page.up"] = true,
["session.half.page.down"] = true,
["session.page.up"] = true,
["session.page.down"] = true,
["session.select"] = true,
["session.share"] = true,
["session.redo"] = true,
["session.undo"] = true,
}

---Cycle the active session's agent through the visible primary agents.
---
---@param server opencode.server.Server
---@return Promise<any>
local function cycle_agent(server)
return server:resolve_session_id():next(function(session_id)
return Promise.all({
server:get_sessions(),
server:get_agents(),
}):next(function(results)
local sessions, agents = results[1], results[2]

-- The session's current agent is only exposed on list items.
local current
for _, session in ipairs(sessions) do
if session.id == session_id then
current = session.agent
break
end
end

local primary = vim.tbl_filter(function(agent) ---@param agent { id: string, mode: string, hidden?: boolean }
return agent.mode == "primary" and not agent.hidden
end, agents or {})

if #primary == 0 then
return Promise.resolve(nil)
end

local next_agent = primary[1]
for index, agent in ipairs(primary) do
if agent.id == current then
next_agent = primary[index % #primary + 1]
break
end
end

return server:switch_agent(session_id, next_agent.id)
end)
end)
end

---Execute a built-in OpenCode command.
---
---Commands map onto v2 HTTP API calls where possible; TUI-only commands
---(scroll, navigation, prompt box) require the legacy `/tui/*` endpoints and
---degrade to a silent no-op when the connected server does not expose them.
---
---@param command opencode.server.Command | string
---@param server opencode.server.Server
---@return Promise<any>
function M.command(command, server)
return server:tui_execute_command(command):next(function()
if command == "session.interrupt" then
-- Evidently OpenCode only uses this command for their "double-tap Esc to interrupt" user keybind.
-- So we have to double-send it to actually interrupt.
return server:tui_execute_command(command)
if command == "session.new" then
-- TODO: `create_session()` POSTs /api/session, which on the shared service
-- always creates the session in the service's ambient cwd (often ~) — the
-- same limitation as the retired panel session. Revisit once opencode lets
-- the API place a session in a target directory.
return server:create_session():next(function(created)
return Promise.resolve(created)
end)
elseif command == "session.interrupt" then
return server:resolve_session_id():next(function(session_id)
return server:interrupt(session_id)
end)
elseif command == "session.compact" then
return server:resolve_session_id():next(function(session_id)
return server:compact(session_id)
end)
elseif command == "agent.cycle" then
return cycle_agent(server)
end

if TUI_ONLY[command] then
if not server.tui then
-- No `/tui/*` on OpenCode v2.0.x: these have no API equivalent.
return Promise.resolve(nil)
end
end)
return server:tui_execute_command(command):next(function()
if command == "session.interrupt" then
-- Evidently OpenCode only uses this command for their "double-tap Esc to interrupt" user keybind.
-- So we have to double-send it to actually interrupt.
return server:tui_execute_command(command)
end
end)
end

-- Unknown command: forward to the TUI when available, otherwise surface it.
if server.tui then
return server:tui_execute_command(command)
end
return Promise.reject("Unknown OpenCode command: `" .. command .. "`")
end

return M
Loading
Loading