Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions docs/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,121 @@ The function returns `true` when a source was removed and `false` otherwise.
For a practical selection callback, see the [GitHub issue references recipe](recipes/completion-source.md),
which expands the selected issue into the visible draft.

## Tool formatters

Recipes can render tools added by an OpenCode server plugin. Registration uses
the exact tool name reported in `part.name`. This one renders `pty_read` from
[opencode-pty](https://github.com/shekohex/opencode-pty), keeping the terminal
buffer and dropping the `<pty_output>` wrapper and model-facing reminders:

```lua
local function result_text(part)
local text = {}
for _, item in ipairs(part.result or {}) do
if item.kind == 'text' then
text[#text + 1] = item.text
end
end
return table.concat(text, '\n')
end

require('opencode').register_tool_formatter('pty_read', {
format = function(output, part)
local input = part.input or {}
output:add_line('**pty read** `' .. (input.id or 'pending') .. '`')

local body = result_text(part):match('<pty_output[^>]*>\n(.-)\n</pty_output>')
if not body then
return
end

output:add_empty_line()
local content_start = output:get_line_count() + 1
output:add_lines(vim.split(body, '\n'))
local tools = require('opencode.config').values.ui.output.tools
output:add_fold_with_threshold(content_start, tools.show_output, tools.use_folds)
end,
summary = function(part)
return '', 'pty read', (part.input and part.input.id) or ''
end,
})

require('opencode').setup({})
```

Use the exact name the server reports; a plugin can add several tools, so
register each one separately. `format(output, part, context)` runs on every
render, including while the tool streams. Do not assume the final input or
result is present:

- `pending` and `streaming`: `part.input` may be incomplete or absent. V2
exposes the raw streamed fragment as `part.input_text`; V1 may expose a
partial `part.input`.
- `running`: input is usually available, but `part.result` may not be.
- `completed` and `error`: `part.result` and `part.error` are available when
the server supplies them.

Guard optional fields and return without content when nothing useful is
available yet. Core re-renders the part as input and results arrive, so the
formatter stays in sync without callbacks or timers.

Add detail lines under a fold to let the streaming preview work. While a tool
or reasoning part streams, core keeps the last
`ui.output.tools.streaming_preview_lines` content lines of the final fold
visible. Fold filtering, including `fold_exclude`, still applies after the
formatter returns.

Use the output methods available to built-in formatters: `add_line`,
`add_lines`, `add_empty_line`, `add_extmark`, `add_action`, `add_target`, and
`add_fold_with_threshold`. `part` is the normalized tool part, so the same
fields (`name`, `state`, `input`, `input_text`, `result`, `time`, `error`) are
available for V1 and V2 sessions. Do not create windows or buffers, change the
renderer, or keep references to the output after `format` returns; the renderer
owns that lifecycle.

`summary(part)` is optional and returns the icon, label, and value shown when
the tool appears as a row inside a task or subagent. An override without
`summary` keeps the matching built-in summary when one exists, then falls back
to the generic tool summary.

Core rendering still owns:

- the tool border;
- fold filtering, including `fold_exclude`;
- streaming previews;
- error callouts;
- renderer scheduling and lifecycle.

Registration can happen before or after `setup()` and applies to the next
render of a matching tool part. An unregistered plugin tool name uses the MCP
formatter when it contains an underscore, otherwise the generic tool formatter.

Built-in names such as `bash`, `shell`, `read`, `edit`, `write`, `grep`,
`webfetch`, and `task` are reserved. Replacing one requires an explicit opt-in:

```lua
require('opencode').register_tool_formatter('bash', {
format = function(output, part)
output:add_line('Bash: ' .. (part.input.command or ''))
end,
}, { replace = true })
```

Duplicate custom registrations are rejected, including a second attempt with
`replace = true`; only one active override exists per tool. If a custom
formatter throws, the error is logged, its partial output is discarded, and the
matching built-in formatter renders the part instead. Remove a registration
with:

```lua
require('opencode').unregister_tool_formatter('pty_read')
```

The function returns `true` when a custom formatter or built-in override was
removed and `false` otherwise. Removing an override restores the built-in
formatter. For complete formatters covering `pty_spawn` and `pty_read`, see the
[opencode-pty recipe](recipes/tool-formatter.md).

## Prompt guard

`prompt_guard(mentioned_files)` must return a boolean. It receives a list of
Expand Down
1 change: 1 addition & 0 deletions docs/recipes/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ plugin; read [Usage](../usage.md) first if you are new.
| [Review a session diff](review-session-diff.md) | You want to comment on V2 changes and send the comments with your next prompt |
| [Quiet Blink completion](quiet-blink.md) | The completion menu pops up while you type a prompt |
| [GitHub issue references](completion-source.md) | You want issue references available from a custom completion source |
| [opencode-pty tools](tool-formatter.md) | You want readable output for tools added by a server plugin |

## Share a recipe

Expand Down
142 changes: 142 additions & 0 deletions docs/recipes/tool-formatter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
# Render opencode-pty tools

[Recipes](README.md) / Render opencode-pty tools

Give the tools from [opencode-pty](https://github.com/shekohex/opencode-pty)
a compact presentation: the spawned command as a header, and the terminal
buffer without the plugin's XML wrapper and model-facing reminders.

## When to use it

opencode-pty lets the agent run dev servers, watchers, and REPLs in background
terminals. Its tool names contain an underscore, so without a formatter they
render through the MCP fallback: `pty_read` shows up as server `pty`, tool
`read`, followed by the raw input JSON and the full `<pty_output>` block. Use
this recipe as a starting point for any server plugin that adds its own tools.

## Prerequisites

- OpenCode V1 or V2 with opencode-pty installed (`"plugin": ["opencode-pty"]`
for V1, `"opencode-pty/v2"` for V2). Both normalize tool parts to the same
`input`, `result`, `state`, `error`, and `time` fields before formatting.
- No additional Neovim plugins needed.

## Setup

Register before the first PTY tool part renders. `pty_spawn` and `pty_read`
are not built-in names, so no `replace` opt-in is needed.

```lua
local opencode = require('opencode')
local config = require('opencode.config')

local function result_text(part)
local text = {}
for _, item in ipairs(part.result or {}) do
if item.kind == 'text' then
text[#text + 1] = item.text
end
end
return table.concat(text, '\n')
end

local function command_line(input)
return vim.trim((input.command or '') .. ' ' .. table.concat(input.args or {}, ' '))
end

-- pty_read wraps the buffer in <pty_output> and may append <system_reminder>
-- blocks meant for the model; keep only the buffer.
local function buffer_lines(text)
local body = text:match('<pty_output[^>]*>\n(.-)\n</pty_output>')
if not body then
return {}
end
local lines = {}
for _, line in ipairs(vim.split(body, '\n')) do
lines[#lines + 1] = (line:gsub('^%d+| ', ''))
end
return lines
end

local function add_folded(output, lines)
local tools = config.values.ui.output.tools
if #lines == 0 or not (tools.show_output or tools.use_folds) then
return
end
output:add_empty_line()
local content_start = output:get_line_count() + 1
output:add_lines(lines)
output:add_fold_with_threshold(content_start, tools.show_output, tools.use_folds)
end

opencode.register_tool_formatter('pty_spawn', {
format = function(output, part)
local input = part.input or {}
local command = command_line(input)
if command == '' then
command = 'starting'
end
local id = result_text(part):match('ID: (pty_%w+)')
output:add_line('**pty** `' .. command .. '`' .. (id and (' · ' .. id) or ''))
if input.title or input.description then
output:add_line(input.title or input.description)
end
end,
summary = function(part)
return '', 'pty', command_line(part.input or {})
end,
})

opencode.register_tool_formatter('pty_read', {
format = function(output, part)
local input = part.input or {}
local header = '**pty read** `' .. (input.id or 'pending') .. '`'
if input.pattern then
header = header .. ' /' .. input.pattern .. '/'
end
output:add_line(header)
add_folded(output, buffer_lines(result_text(part)))
end,
summary = function(part)
return '', 'pty read', (part.input and part.input.id) or ''
end,
})

opencode.setup({})
```

`pty_write`, `pty_list`, and `pty_kill` keep the MCP fallback. Register them
the same way if you want them to match.

## Try it

1. Install opencode-pty and restart the OpenCode server.
2. Ask `Start the dev server in a background PTY session, then show me any
errors in its output`.
3. The `pty_spawn` part shows the command and session ID. The `pty_read` part
shows the filter pattern and the matching buffer lines under a fold, with
no XML tags, line-number prefixes, or system reminders. Folding still
follows `ui.output.tools.show_output`, `use_folds`, and `folding_threshold`.

## Caveats and undo

The formatter parses opencode-pty's text output (`ID: pty_…` and the
`<pty_output>` wrapper). If a plugin release changes that text, the header
loses the ID and the buffer renders empty; the tool border, error callouts,
and streaming preview still come from core rendering. If the formatter throws,
the error is logged and the MCP fallback renders that part instead.

Remove the formatters to restore the fallback for future renders:

```lua
require('opencode').unregister_tool_formatter('pty_spawn')
require('opencode').unregister_tool_formatter('pty_read')
```

See [Tool formatters](../extensions.md#tool-formatters) for the stable contract,
including how to override a built-in formatter such as `bash`.

## Related

- [GitHub issue references](completion-source.md) uses the same recipe-owned
registration pattern for completion.
17 changes: 17 additions & 0 deletions lua/opencode/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,23 @@ function M.unregister_completion_source(name)
return require('opencode.ui.completion').unregister_source(name)
end

---Register a recipe-owned tool formatter. Replacing a built-in formatter
---requires `opts.replace = true`.
---@param name string
---@param formatter ToolFormatter
---@param opts? { replace?: boolean }
function M.register_tool_formatter(name, formatter, opts)
return require('opencode.ui.formatter.tool_registry').register(name, formatter, opts)
end

---Unregister a recipe-owned tool formatter. An active built-in override is
---removed and the built-in formatter is restored.
---@param name string
---@return boolean
function M.unregister_tool_formatter(name)
return require('opencode.ui.formatter.tool_registry').unregister(name)
end

local function on_opencode_server()
require('opencode.ui.permission_window').clear_all()
end
Expand Down
4 changes: 4 additions & 0 deletions lua/opencode/types.lua
Original file line number Diff line number Diff line change
Expand Up @@ -523,6 +523,10 @@
---@field current_files? string[]
---@field symbol_cycle? SymbolSnapshotCycle

---@class ToolFormatter
---@field format fun(output: Output, part: table, context?: FormatterContext, ...: any) Write this tool part's content to the output
---@field summary? fun(part: table): string, string, string Icon, label, and value for nested tool rows

---@class OutputTargetRange
---@field line integer Output-local line, 1-based
---@field start_col integer Output-local column, 0-based inclusive
Expand Down
38 changes: 35 additions & 3 deletions lua/opencode/ui/formatter.lua
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ local snapshot = require('opencode.snapshot')
local mention = require('opencode.ui.mention')
local system_formatters = require('opencode.ui.formatter.system')
local symbol_tokens = require('opencode.ui.symbol_tokens')
local tool_formatters = require('opencode.ui.formatter.tools')
local tool_formatters = require('opencode.ui.formatter.tool_registry')
local format_utils = require('opencode.ui.formatter.utils')

local M = {}
Expand Down Expand Up @@ -883,6 +883,28 @@ function M._format_assistant_message(output, text, part, message, context)
add_symbol_reference_highlights(output, rendered, rendered_mention_ranges, targeted_tokens, first_line_idx)
end

---@param output Output
---@return table
local function snapshot_output(output)
return {
lines = vim.deepcopy(output.lines),
extmarks = vim.deepcopy(output.extmarks),
actions = vim.deepcopy(output.actions),
targets = vim.deepcopy(output.targets),
fold_ranges = vim.deepcopy(output.fold_ranges),
}
end

---@param output Output
---@param saved table
local function restore_output(output, saved)
output.lines = saved.lines
output.extmarks = saved.extmarks
output.actions = saved.actions
output.targets = saved.targets
output.fold_ranges = saved.fold_ranges
end

---@param output Output Output object to write to
---@param part table
---@param context? FormatterContext
Expand All @@ -894,9 +916,19 @@ function M.format_tool(output, part, context, message)

local start_line = output:get_line_count() + 1

local formatter = tool_formatters[tool] or (tool:match('_') and tool_formatters.mcp) or tool_formatters.tool
local formatter = tool_formatters.resolve(tool)
local fold_count = #output.fold_ranges
formatter.format(output, part, context, tool_formatters, message)
if tool_formatters.is_override(tool) then
local before = snapshot_output(output)
local ok, err = pcall(formatter.format, output, part, context, tool_formatters, message)
if not ok then
require('opencode.log').error('Tool formatter "%s" failed: %s', tool, tostring(err))
restore_output(output, before)
tool_formatters.fallback(tool).format(output, part, context, tool_formatters, message)
end
else
formatter.format(output, part, context, tool_formatters, message)
end

if not format_utils.should_fold_tool(tool) then
for idx = #output.fold_ranges, fold_count + 1, -1 do
Expand Down
Loading
Loading