diff --git a/docs/extensions.md b/docs/extensions.md index b3d3e760..6ac2a664 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -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 `` 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(']*>\n(.-)\n') + 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 diff --git a/docs/recipes/README.md b/docs/recipes/README.md index 06a180e7..4db3e9a3 100644 --- a/docs/recipes/README.md +++ b/docs/recipes/README.md @@ -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 diff --git a/docs/recipes/tool-formatter.md b/docs/recipes/tool-formatter.md new file mode 100644 index 00000000..96c51b6f --- /dev/null +++ b/docs/recipes/tool-formatter.md @@ -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 `` 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 and may append +-- blocks meant for the model; keep only the buffer. +local function buffer_lines(text) + local body = text:match(']*>\n(.-)\n') + 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 +`` 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. diff --git a/lua/opencode/init.lua b/lua/opencode/init.lua index e140d506..f37bf1eb 100644 --- a/lua/opencode/init.lua +++ b/lua/opencode/init.lua @@ -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 diff --git a/lua/opencode/types.lua b/lua/opencode/types.lua index 0931e2b2..61b781b8 100644 --- a/lua/opencode/types.lua +++ b/lua/opencode/types.lua @@ -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 diff --git a/lua/opencode/ui/formatter.lua b/lua/opencode/ui/formatter.lua index 466e769b..4a2f643b 100644 --- a/lua/opencode/ui/formatter.lua +++ b/lua/opencode/ui/formatter.lua @@ -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 = {} @@ -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 @@ -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 diff --git a/lua/opencode/ui/formatter/tool_registry.lua b/lua/opencode/ui/formatter/tool_registry.lua new file mode 100644 index 00000000..36d79a77 --- /dev/null +++ b/lua/opencode/ui/formatter/tool_registry.lua @@ -0,0 +1,94 @@ +local builtins = require('opencode.ui.formatter.tools') + +local M = {} + +---@type table +local overrides = {} + +---@param name string +---@return ToolFormatter +local function builtin(name) + return builtins[name] or (name:match('_') and builtins.mcp) or builtins.tool +end + +---Register a tool formatter. Replacing a built-in requires `replace = true`. +---@param name string +---@param formatter ToolFormatter +---@param opts? { replace?: boolean } +function M.register(name, formatter, opts) + if name == '' then + error('Tool formatter name must be a non-empty string') + end + if overrides[name] then + error('Tool formatter already registered: ' .. name) + end + if builtins[name] and not (opts and opts.replace == true) then + error('Tool formatter already exists: ' .. name .. ' (pass { replace = true } to override)') + end + overrides[name] = formatter +end + +---Remove a registered formatter, restoring any built-in formatter. +---@param name string +---@return boolean +function M.unregister(name) + if not overrides[name] then + return false + end + overrides[name] = nil + return true +end + +---@param name string +---@return boolean +function M.is_override(name) + return overrides[name] ~= nil +end + +---Formatter used by dispatch, including the MCP and generic fallbacks. +---@param name string +---@return ToolFormatter +function M.resolve(name) + return overrides[name] or builtin(name) +end + +---Built-in formatter for a tool, ignoring any active override. +---@param name string +---@return ToolFormatter +function M.fallback(name) + return builtin(name) +end + +---@param name string +---@return fun(part: table): string, string, string +local function builtin_summary(name) + local builtin_formatter = builtins[name] + if builtin_formatter and builtin_formatter.summary then + return builtin_formatter.summary + end + return builtins.tool.summary +end + +---Summary function for nested tool rows. An override without a summary keeps +---the matching built-in summary, then falls back to the generic tool summary. +---A failing override summary is logged and replaced by the built-in summary. +---@param name string +---@return fun(part: table): string, string, string +function M.summary(name) + local override = overrides[name] + local fallback = builtin_summary(name) + if not (override and override.summary) then + return fallback + end + local custom_summary = override.summary + return function(part) + local ok, icon, label, value = pcall(custom_summary, part) + if ok then + return icon, label, value + end + require('opencode.log').error('Tool summary "%s" failed: %s', name, tostring(icon)) + return fallback(part) + end +end + +return M diff --git a/lua/opencode/ui/formatter/tools/task.lua b/lua/opencode/ui/formatter/tools/task.lua index 14af09d0..a374bca8 100644 --- a/lua/opencode/ui/formatter/tools/task.lua +++ b/lua/opencode/ui/formatter/tools/task.lua @@ -4,13 +4,12 @@ local icons = require('opencode.ui.icons') ---@param part table ---@param status string ---@param utils table ----@param tool_formatters table registry of tool formatters (passed in by the +---@param tool_formatters table tool formatter registry (passed in by the --- dispatch site; requiring the registry module here would form a cycle) ---@return string function M.tool_action_line(part, status, utils, tool_formatters) local tool = part.name - local formatter = tool_formatters[tool] or tool_formatters.tool - local summary = formatter.summary or tool_formatters.tool.summary + local summary = tool_formatters.summary(tool) local icon, tool_label, tool_value = summary(part) if status ~= 'completed' then @@ -23,7 +22,7 @@ end ---@param output Output ---@param part table ---@param context? FormatterContext ----@param tool_formatters? table registry passed in by the dispatch site +---@param tool_formatters? table tool formatter registry passed by the dispatch site function M.format(output, part, context, tool_formatters) if part.name ~= 'task' and part.name ~= 'subagent' then return diff --git a/tests/unit/tool_formatter_spec.lua b/tests/unit/tool_formatter_spec.lua new file mode 100644 index 00000000..30943d07 --- /dev/null +++ b/tests/unit/tool_formatter_spec.lua @@ -0,0 +1,245 @@ +local assert = require('luassert') +local config = require('opencode.config') +local formatter = require('opencode.ui.formatter') +local opencode = require('opencode') +local registry = require('opencode.ui.formatter.tool_registry') + +describe('custom tool formatters', function() + local registered = {} + local original_config + + local function register(name, tool_formatter, opts) + opencode.register_tool_formatter(name, tool_formatter, opts) + table.insert(registered, name) + end + + local function render(name, fields) + local part = vim.tbl_extend('force', { + id = 'prt_tool_formatter', + kind = 'tool', + name = name, + state = 'completed', + input = {}, + }, fields or {}) + return formatter.format_part(part, { + id = 'msg_tool_formatter', + session_id = 'ses_tool_formatter', + kind = 'assistant', + content = { part }, + }, true) + end + + local function text(output) + return table.concat(output.lines, '\n') + end + + before_each(function() + original_config = vim.deepcopy(config.values) + config.setup({ + ui = { + output = { + tools = { + show_output = true, + use_folds = false, + }, + }, + }, + }) + end) + + after_each(function() + for _, name in ipairs(registered) do + opencode.unregister_tool_formatter(name) + end + registered = {} + config.values = original_config + end) + + it('exposes tool formatter registration in the public API', function() + assert.is_function(opencode.register_tool_formatter) + assert.is_function(opencode.unregister_tool_formatter) + end) + + it('renders a custom tool through its registered formatter', function() + register('ci_report', { + format = function(output, part) + output:add_line('CI report for ' .. part.input.branch) + end, + }) + + local output = render('ci_report', { input = { branch = 'main' } }) + + assert.is_truthy(text(output):find('CI report for main', 1, true)) + end) + + it('rejects empty names', function() + assert.has_error(function() + opencode.register_tool_formatter('', { format = function() end }) + end) + end) + + it('rejects duplicate custom formatters', function() + register('ci_report', { format = function() end }) + + assert.has_error(function() + opencode.register_tool_formatter('ci_report', { format = function() end }) + end) + assert.has_error(function() + opencode.register_tool_formatter('ci_report', { format = function() end }, { replace = true }) + end) + end) + + it('requires an explicit opt-in to replace a built-in formatter', function() + assert.has_error(function() + opencode.register_tool_formatter('bash', { format = function() end }) + end) + end) + + it('restores the built-in formatter after unregistering an override', function() + register('bash', { + format = function(output) + output:add_line('CUSTOM BASH') + end, + }, { replace = true }) + + local overridden = render('bash', { command = 'echo hi' }) + assert.is_truthy(text(overridden):find('CUSTOM BASH', 1, true)) + + assert.is_true(opencode.unregister_tool_formatter('bash')) + assert.is_false(opencode.unregister_tool_formatter('bash')) + + local restored = render('bash', { command = 'echo hi' }) + assert.is_nil(text(restored):find('CUSTOM BASH', 1, true)) + assert.is_truthy(text(restored):find('run', 1, true)) + end) + + it('falls back to the built-in formatter when an override fails', function() + local original_log = package.loaded['opencode.log'] + local logged + package.loaded['opencode.log'] = { + error = function(message, ...) + logged = string.format(message, ...) + end, + } + register('bash', { + format = function(output) + output:add_line('PARTIAL OUTPUT') + error('formatter exploded') + end, + }, { replace = true }) + + local output = render('bash', { command = 'echo hi' }) + package.loaded['opencode.log'] = original_log + + assert.is_nil(text(output):find('PARTIAL OUTPUT', 1, true)) + assert.is_truthy(text(output):find('run', 1, true)) + assert.is_truthy(logged:find('bash', 1, true)) + end) + + it('renders a custom tool while its input is still streaming', function() + register('deploy_status', { + format = function(output, part) + local service = (part.input and part.input.service) or part.input_text or part.state + output:add_line('deploy: ' .. service) + end, + }) + + local part = { + id = 'prt_streaming_tool', + kind = 'tool', + name = 'deploy_status', + state = 'streaming', + input_text = '{"service":"api', + } + local output = formatter.format_part(part, { + id = 'msg_streaming_tool', + session_id = 'ses_tool_formatter', + kind = 'assistant', + content = { part }, + }, true) + + assert.is_truthy(text(output):find('deploy: {"service":"api', 1, true)) + end) + + it('keeps core error rendering and fold policy for custom formatters', function() + config.ui.output.tools.use_folds = true + config.ui.output.tools.fold_exclude = { 'ci_report' } + register('ci_report', { + format = function(output) + output:add_line('line one') + output:add_line('line two') + output:add_line('line three') + output:add_fold(1, 3) + end, + }) + + local output = render('ci_report', { + state = 'error', + error = { message = 'tests failed' }, + }) + + assert.same({}, output.fold_ranges) + assert.is_truthy(text(output):find('tests failed', 1, true)) + end) + + it('uses a custom summary for nested tool rows and keeps built-in summaries otherwise', function() + local custom_summary = function() + return 'icon', 'ci', 'report' + end + register('ci_report', { + format = function() end, + summary = custom_summary, + }) + assert.same({ 'icon', 'ci', 'report' }, { registry.summary('ci_report')({}) }) + + register('grep', { + format = function() end, + }, { replace = true }) + assert.are.equal(require('opencode.ui.formatter.tools.grep').summary, registry.summary('grep')) + end) + + it('falls back to the built-in summary when a custom summary fails', function() + local original_log = package.loaded['opencode.log'] + local logged + package.loaded['opencode.log'] = { + error = function(message, ...) + logged = string.format(message, ...) + end, + } + register('grep', { + format = function() end, + summary = function() + error('summary exploded') + end, + }, { replace = true }) + + local part = { name = 'grep', state = 'completed', input = { pattern = 'x' } } + local expected = { require('opencode.ui.formatter.tools.grep').summary(part) } + local actual = { registry.summary('grep')(part) } + package.loaded['opencode.log'] = original_log + + assert.same(expected, actual) + assert.is_truthy(logged:find('summary exploded', 1, true)) + end) + + it('logs formatter errors containing format specifiers', function() + local original_log = package.loaded['opencode.log'] + local logged + package.loaded['opencode.log'] = { + error = function(message, ...) + logged = string.format(message, ...) + end, + } + register('bash', { + format = function() + error('bad pattern %d %y') + end, + }, { replace = true }) + + local output = render('bash', { command = 'echo hi' }) + package.loaded['opencode.log'] = original_log + + assert.is_truthy(text(output):find('run', 1, true)) + assert.is_truthy(logged:find('bad pattern %d %y', 1, true)) + end) +end)