From 0f490501bf84864d5181f0a8cec021ca6ade7445 Mon Sep 17 00:00:00 2001 From: disrupted Date: Mon, 9 Mar 2026 22:31:32 +0100 Subject: [PATCH 1/5] fix(formatter): right-align diff line numbers --- lua/opencode/ui/formatter/utils.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/opencode/ui/formatter/utils.lua b/lua/opencode/ui/formatter/utils.lua index 740c9602..1ad8582d 100644 --- a/lua/opencode/ui/formatter/utils.lua +++ b/lua/opencode/ui/formatter/utils.lua @@ -86,7 +86,7 @@ end local function build_diff_gutter(line_numbers, width) local line_number = line_numbers.new or line_numbers.old - return string.format('%-' .. width .. 's', line_number and tostring(line_number) or '') + return string.format('%' .. width .. 's', line_number and tostring(line_number) or '') end local function add_diff_line(output, line, line_numbers, width) From b1438b00165f4e73590b0ad007a08c0862bf5d30 Mon Sep 17 00:00:00 2001 From: disrupted Date: Mon, 9 Mar 2026 18:53:31 +0100 Subject: [PATCH 2/5] feat: render `session.status` message in loading footer --- lua/opencode/event_manager.lua | 15 ++++ lua/opencode/ui/loading_animation.lua | 112 +++++++++++++++++++++++++- 2 files changed, 126 insertions(+), 1 deletion(-) diff --git a/lua/opencode/event_manager.lua b/lua/opencode/event_manager.lua index 723973ff..c06fa224 100644 --- a/lua/opencode/event_manager.lua +++ b/lua/opencode/event_manager.lua @@ -58,6 +58,18 @@ local log = require('opencode.log') --- @field type "session.error" --- @field properties {sessionID: string, error: table} +--- @class EventSessionStatus +--- @field type "session.status" +--- @field properties { +--- sessionID: string, +--- status: { +--- type: string, +--- message?: string, +--- attempt?: number, +--- next?: number +--- } +--- } + --- @class OpencodePermission --- @field id string --- @field type string @@ -146,6 +158,7 @@ local log = require('opencode.log') --- | "session.updated" --- | "session.deleted" --- | "session.error" +--- | "session.status" --- | "permission.updated" --- | "permission.asked" --- | "permission.replied" @@ -208,6 +221,7 @@ end --- @overload fun(self: EventManager, event_name: "session.updated", callback: fun(data: EventSessionUpdated['properties']): nil) --- @overload fun(self: EventManager, event_name: "session.deleted", callback: fun(data: EventSessionDeleted['properties']): nil) --- @overload fun(self: EventManager, event_name: "session.error", callback: fun(data: EventSessionError['properties']): nil) +--- @overload fun(self: EventManager, event_name: "session.status", callback: fun(data: EventSessionStatus['properties']): nil) --- @overload fun(self: EventManager, event_name: "permission.updated", callback: fun(data: EventPermissionUpdated['properties']): nil) --- @overload fun(self: EventManager, event_name: "permission.replied", callback: fun(data: EventPermissionReplied['properties']): nil) --- @overload fun(self: EventManager, event_name: "file.edited", callback: fun(data: EventFileEdited['properties']): nil) @@ -249,6 +263,7 @@ end --- @overload fun(self: EventManager, event_name: "session.updated", callback: fun(data: EventSessionUpdated['properties']): nil) --- @overload fun(self: EventManager, event_name: "session.deleted", callback: fun(data: EventSessionDeleted['properties']): nil) --- @overload fun(self: EventManager, event_name: "session.error", callback: fun(data: EventSessionError['properties']): nil) +--- @overload fun(self: EventManager, event_name: "session.status", callback: fun(data: EventSessionStatus['properties']): nil) --- @overload fun(self: EventManager, event_name: "permission.updated", callback: fun(data: EventPermissionUpdated['properties']): nil) --- @overload fun(self: EventManager, event_name: "permission.replied", callback: fun(data: EventPermissionReplied['properties']): nil) --- @overload fun(self: EventManager, event_name: "file.edited", callback: fun(data: EventFileEdited['properties']): nil) diff --git a/lua/opencode/ui/loading_animation.lua b/lua/opencode/ui/loading_animation.lua index 78f7bf71..873c5e75 100644 --- a/lua/opencode/ui/loading_animation.lua +++ b/lua/opencode/ui/loading_animation.lua @@ -7,13 +7,115 @@ local M = {} M._animation = { frames = nil, text = 'Thinking... ', + status_data = nil, current_frame = 1, timer = nil, fps = 10, extmark_id = nil, ns_id = vim.api.nvim_create_namespace('opencode_loading_animation'), + status_event_manager = nil, } +---@param status table|nil +---@return string|nil +function M._format_status_text(status) + if type(status) ~= 'table' then + return nil + end + + local status_type = status.type + + if status_type == 'busy' then + return M._animation.text + end + + if status_type == 'idle' then + return nil + end + + if status_type == 'retry' then + local message = status.message or 'Retrying request' + local details = {} + + if type(status.attempt) == 'number' then + table.insert(details, 'retry ' .. status.attempt) + end + + if type(status.next) == 'number' then + local now_ms = os.time() * 1000 + local seconds = math.max(0, math.ceil((status.next - now_ms) / 1000)) + table.insert(details, 'in ' .. seconds .. 's') + end + + if #details > 0 then + return string.format('%s (%s)... ', message, table.concat(details, ', ')) + end + + return message .. '... ' + end + + if type(status.message) == 'string' and status.message ~= '' then + return status.message .. '... ' + end + + return M._animation.text +end + +local function unsubscribe_session_status_event(manager) + if manager and M._animation.status_event_manager == manager then + manager:unsubscribe('session.status', M.on_session_status) + M._animation.status_event_manager = nil + end +end + +local function subscribe_session_status_event(manager) + if not manager then + return + end + + if M._animation.status_event_manager and M._animation.status_event_manager ~= manager then + unsubscribe_session_status_event(M._animation.status_event_manager) + end + + if M._animation.status_event_manager == manager then + return + end + + manager:subscribe('session.status', M.on_session_status) + M._animation.status_event_manager = manager +end + +function M.on_session_status(properties) + if not properties or type(properties) ~= 'table' then + return + end + + local active_session = state.active_session + if active_session and active_session.id and properties.sessionID ~= active_session.id then + return + end + + M._animation.status_data = properties.status + M.render(state.windows) +end + +local function on_active_session_change(_, new_session, old_session) + local new_id = new_session and new_session.id + local old_id = old_session and old_session.id + if new_id ~= old_id then + M._animation.status_data = nil + end +end + +local function on_event_manager_change(_, new_manager, old_manager) + unsubscribe_session_status_event(old_manager) + subscribe_session_status_event(new_manager) +end + +function M._get_display_text() + return M._format_status_text(M._animation.status_data) or M._animation.text +end + function M._get_frames() if M._animation.frames then return M._animation.frames @@ -41,7 +143,7 @@ M.render = vim.schedule_wrap(function(windows) return false end - local loading_text = M._animation.text .. M._get_frames()[M._animation.current_frame] + local loading_text = M._get_display_text() .. M._get_frames()[M._animation.current_frame] M._animation.extmark_id = vim.api.nvim_buf_set_extmark(windows.footer_buf, M._animation.ns_id, 0, 0, { id = M._animation.extmark_id or nil, @@ -97,6 +199,7 @@ end function M.stop() M._clear_animation_timer() M._animation.current_frame = 1 + M._animation.status_data = nil if state.windows and state.windows.footer_buf and vim.api.nvim_buf_is_valid(state.windows.footer_buf) then pcall(vim.api.nvim_buf_clear_namespace, state.windows.footer_buf, M._animation.ns_id, 0, -1) end @@ -120,10 +223,17 @@ end function M.setup() state.subscribe('job_count', on_running_change) + state.subscribe('active_session', on_active_session_change) + state.subscribe('event_manager', on_event_manager_change) + subscribe_session_status_event(state.event_manager) end function M.teardown() state.unsubscribe('job_count', on_running_change) + state.unsubscribe('active_session', on_active_session_change) + state.unsubscribe('event_manager', on_event_manager_change) + unsubscribe_session_status_event(M._animation.status_event_manager) + M._animation.status_data = nil end return M From 4c2454cb62380c3212108f75ba1dcf04b660ee17 Mon Sep 17 00:00:00 2001 From: disrupted Date: Mon, 9 Mar 2026 22:23:52 +0100 Subject: [PATCH 3/5] test: create unit and synthetic replay test --- .../provider-overloaded-status.expected.json | 168 ++++++++++++++++++ tests/data/provider-overloaded-status.json | 156 ++++++++++++++++ tests/unit/loading_animation_spec.lua | 57 ++++++ 3 files changed, 381 insertions(+) create mode 100644 tests/data/provider-overloaded-status.expected.json create mode 100644 tests/data/provider-overloaded-status.json create mode 100644 tests/unit/loading_animation_spec.lua diff --git a/tests/data/provider-overloaded-status.expected.json b/tests/data/provider-overloaded-status.expected.json new file mode 100644 index 00000000..6d5076d2 --- /dev/null +++ b/tests/data/provider-overloaded-status.expected.json @@ -0,0 +1,168 @@ +{ + "actions": [], + "extmarks": [ + [ + 1, + 1, + 0, + { + "ns_id": 3, + "priority": 10, + "right_gravity": true, + "virt_text": [ + [ + "▌󰭻 ", + "OpencodeMessageRoleUser" + ], + [ + " " + ], + [ + "USER", + "OpencodeMessageRoleUser" + ], + [ + "", + "OpencodeHint" + ], + [ + " [msg_0000000000001]", + "OpencodeHint" + ] + ], + "virt_text_hide": false, + "virt_text_pos": "win_col", + "virt_text_repeat_linebreak": false, + "virt_text_win_col": -3 + } + ], + [ + 2, + 1, + 0, + { + "ns_id": 3, + "priority": 9, + "right_gravity": true, + "virt_text": [ + [ + " 2023-11-14 22:13:20", + "OpencodeHint" + ] + ], + "virt_text_hide": false, + "virt_text_pos": "right_align", + "virt_text_repeat_linebreak": false + } + ], + [ + 3, + 2, + 0, + { + "ns_id": 3, + "priority": 4096, + "right_gravity": true, + "virt_text": [ + [ + "▌", + "OpencodeMessageRoleUser" + ] + ], + "virt_text_hide": false, + "virt_text_pos": "win_col", + "virt_text_repeat_linebreak": true, + "virt_text_win_col": -3 + } + ], + [ + 4, + 3, + 0, + { + "ns_id": 3, + "priority": 4096, + "right_gravity": true, + "virt_text": [ + [ + "▌", + "OpencodeMessageRoleUser" + ] + ], + "virt_text_hide": false, + "virt_text_pos": "win_col", + "virt_text_repeat_linebreak": true, + "virt_text_win_col": -3 + } + ], + [ + 5, + 6, + 0, + { + "ns_id": 3, + "priority": 10, + "right_gravity": true, + "virt_text": [ + [ + " ", + "OpencodeMessageRoleAssistant" + ], + [ + " " + ], + [ + "BUILD", + "OpencodeMessageRoleAssistant" + ], + [ + " claude-sonnet-4-5", + "OpencodeHint" + ], + [ + " [msg_0000000000002]", + "OpencodeHint" + ] + ], + "virt_text_hide": false, + "virt_text_pos": "win_col", + "virt_text_repeat_linebreak": false, + "virt_text_win_col": -3 + } + ], + [ + 6, + 6, + 0, + { + "ns_id": 3, + "priority": 9, + "right_gravity": true, + "virt_text": [ + [ + " 2023-11-14 22:13:21", + "OpencodeHint" + ] + ], + "virt_text_hide": false, + "virt_text_pos": "right_align", + "virt_text_repeat_linebreak": false + } + ] + ], + "lines": [ + "----", + "", + "", + "Can you help me fix this bug?", + "", + "----", + "", + "", + "Sure, I can help with that.", + "", + "" + ], + "timestamp": 1773091221 +} + diff --git a/tests/data/provider-overloaded-status.json b/tests/data/provider-overloaded-status.json new file mode 100644 index 00000000..bb7485b6 --- /dev/null +++ b/tests/data/provider-overloaded-status.json @@ -0,0 +1,156 @@ +[ + { + "type": "message.updated", + "properties": { + "info": { + "id": "msg_0000000000001", + "sessionID": "ses_0000000000001", + "role": "user", + "agent": "build", + "time": { "created": 1700000000000 }, + "model": { "providerID": "anthropic", "modelID": "claude-sonnet-4-5" } + } + } + }, + { + "type": "message.part.updated", + "properties": { + "part": { + "id": "prt_0000000000001", + "messageID": "msg_0000000000001", + "sessionID": "ses_0000000000001", + "type": "text", + "text": "Can you help me fix this bug?" + } + } + }, + { + "type": "session.updated", + "properties": { + "info": { + "id": "ses_0000000000001", + "title": "Bug fix session", + "time": { "created": 1700000000000, "updated": 1700000001000 } + } + } + }, + { + "type": "session.status", + "properties": { + "sessionID": "ses_0000000000001", + "status": { "type": "busy" } + } + }, + { + "type": "message.updated", + "properties": { + "info": { + "id": "msg_0000000000002", + "sessionID": "ses_0000000000001", + "role": "assistant", + "agent": "build", + "mode": "build", + "providerID": "anthropic", + "modelID": "claude-sonnet-4-5", + "parentID": "msg_0000000000001", + "time": { "created": 1700000001000 }, + "cost": 0, + "tokens": { "input": 0, "output": 0, "reasoning": 0, "cache": { "read": 0, "write": 0 } } + } + } + }, + { + "type": "session.status", + "properties": { + "sessionID": "ses_0000000000001", + "status": { + "type": "retry", + "message": "Provider is overloaded", + "attempt": 1, + "next": 1700000011000 + } + } + }, + { + "type": "session.status", + "properties": { + "sessionID": "ses_0000000000001", + "status": { "type": "busy" } + } + }, + { + "type": "session.status", + "properties": { + "sessionID": "ses_0000000000001", + "status": { + "type": "retry", + "message": "Provider is overloaded", + "attempt": 2, + "next": 1700000021000 + } + } + }, + { + "type": "session.status", + "properties": { + "sessionID": "ses_0000000000001", + "status": { "type": "busy" } + } + }, + { + "type": "session.status", + "properties": { + "sessionID": "ses_0000000000001", + "status": { + "type": "retry", + "message": "Provider is overloaded", + "attempt": 3, + "next": 1700000041000 + } + } + }, + { + "type": "session.status", + "properties": { + "sessionID": "ses_0000000000001", + "status": { "type": "busy" } + } + }, + { + "type": "message.part.updated", + "properties": { + "part": { + "id": "prt_0000000000002", + "messageID": "msg_0000000000002", + "sessionID": "ses_0000000000001", + "type": "text", + "text": "Sure, I can help with that." + } + } + }, + { + "type": "message.updated", + "properties": { + "info": { + "id": "msg_0000000000002", + "sessionID": "ses_0000000000001", + "role": "assistant", + "agent": "build", + "mode": "build", + "providerID": "anthropic", + "modelID": "claude-sonnet-4-5", + "parentID": "msg_0000000000001", + "time": { "created": 1700000001000, "completed": 1700000050000 }, + "cost": 0, + "tokens": { "input": 100, "output": 20, "reasoning": 0, "cache": { "read": 0, "write": 0 } } + } + } + }, + { + "type": "session.status", + "properties": { + "sessionID": "ses_0000000000001", + "status": { "type": "idle" } + } + } +] diff --git a/tests/unit/loading_animation_spec.lua b/tests/unit/loading_animation_spec.lua new file mode 100644 index 00000000..22d5db71 --- /dev/null +++ b/tests/unit/loading_animation_spec.lua @@ -0,0 +1,57 @@ +local state = require('opencode.state') +local loading_animation = require('opencode.ui.loading_animation') + +describe('loading_animation status text', function() + local original_time + + before_each(function() + original_time = os.time + loading_animation._animation.status_data = nil + state.active_session = nil + end) + + after_each(function() + os.time = original_time + loading_animation._animation.status_data = nil + state.active_session = nil + end) + + it('renders busy as thinking text', function() + local text = loading_animation._format_status_text({ type = 'busy' }) + assert.are.equal('Thinking... ', text) + end) + + it('counts down retry seconds dynamically', function() + loading_animation._animation.status_data = { + type = 'retry', + message = 'Provider is overloaded', + attempt = 2, + next = 1018000, + } + + os.time = function() + return 1000 + end + local first = loading_animation._get_display_text() + + os.time = function() + return 1005 + end + local second = loading_animation._get_display_text() + + assert.is_truthy(first:find('in 18s', 1, true)) + assert.is_truthy(second:find('in 13s', 1, true)) + end) + + it('ignores status updates for non-active sessions', function() + state.active_session = { id = 'ses_active' } + loading_animation._animation.status_data = nil + + loading_animation.on_session_status({ + sessionID = 'ses_other', + status = { type = 'retry', message = 'Provider is overloaded' }, + }) + + assert.is_nil(loading_animation._animation.status_data) + end) +end) From 2fcacc6f8cbe3d20ddbb95fd361cb116a320a27b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Mar 2026 12:04:01 +0000 Subject: [PATCH 4/5] Initial plan From 72fdcf11d4a99fa213028ec483eef6a7ccce5b46 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Mar 2026 12:07:40 +0000 Subject: [PATCH 5/5] feat: add parameterized toggle_context API keymap support Co-authored-by: disrupted <4771462+disrupted@users.noreply.github.com> --- README.md | 6 ++++++ lua/opencode/api.lua | 6 ++++++ lua/opencode/keymap.lua | 12 +++++++++--- lua/opencode/types.lua | 2 +- tests/unit/api_spec.lua | 7 +++++++ tests/unit/keymap_spec.lua | 23 +++++++++++++++++++++++ 6 files changed, 52 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index d58a8851..9d7a9537 100644 --- a/README.md +++ b/README.md @@ -655,6 +655,12 @@ Example keymap for silent add: ['oY'] = { 'add_visual_selection', { open_input = false }, mode = {'v'} } ``` +Example keymap for toggling a specific context using a parameterized API action: + +```lua +['ocf'] = { 'toggle_context#current_file', desc = 'Toggle current file context' } +``` + ### Run opts You can pass additional options when running a prompt via command or API: diff --git a/lua/opencode/api.lua b/lua/opencode/api.lua index 41235179..405665e3 100644 --- a/lua/opencode/api.lua +++ b/lua/opencode/api.lua @@ -236,6 +236,12 @@ function M.quick_chat(message, range) quick_chat.quick_chat(prompt, { context_config = ctx }, range) end +---@param context_key OpencodeToggleableContextKey +---@return boolean|nil enabled +function M.toggle_context(context_key) + return require('opencode.context').toggle_context(context_key) +end + function M.toggle_pane() ui.toggle_pane() end diff --git a/lua/opencode/keymap.lua b/lua/opencode/keymap.lua index fa1f7fa4..40b665d2 100644 --- a/lua/opencode/keymap.lua +++ b/lua/opencode/keymap.lua @@ -26,8 +26,14 @@ local function process_keymap_entry(keymap_config, default_modes, base_opts, def -- Skip keymap if explicitly set to false (disabled) elseif config_entry then local func_name = config_entry[1] - local func_args = config_entry[2] - local raw_callback = type(func_name) == 'function' and func_name or api[func_name] + local resolved_func_name = func_name + local inline_arg = nil + if type(func_name) == 'string' then + resolved_func_name, inline_arg = func_name:match('^([^#]+)#(.+)$') + resolved_func_name = resolved_func_name or func_name + end + local func_args = config_entry[2] or inline_arg + local raw_callback = type(func_name) == 'function' and func_name or api[resolved_func_name] local callback = raw_callback if raw_callback and func_args then @@ -38,7 +44,7 @@ local function process_keymap_entry(keymap_config, default_modes, base_opts, def local modes = config_entry.mode or default_modes local opts = vim.tbl_deep_extend('force', {}, base_opts) - opts.desc = config_entry.desc or cmds[func_name] and cmds[func_name].desc + opts.desc = config_entry.desc or cmds[resolved_func_name] and cmds[resolved_func_name].desc if callback then if defer_to_completion then diff --git a/lua/opencode/types.lua b/lua/opencode/types.lua index 6ad6aabf..a3f1fa56 100644 --- a/lua/opencode/types.lua +++ b/lua/opencode/types.lua @@ -65,7 +65,7 @@ ---@field share? SessionShareInfo ---@class OpencodeKeymapEntry ----@field [1] string # Function name +---@field [1] string # Function name, optionally suffixed with # to pass one string arg ---@field mode? string|string[] # Mode(s) for the keymap ---@field desc? string # Keymap description diff --git a/tests/unit/api_spec.lua b/tests/unit/api_spec.lua index d77babe9..85cea520 100644 --- a/tests/unit/api_spec.lua +++ b/tests/unit/api_spec.lua @@ -1,6 +1,7 @@ local api = require('opencode.api') local core = require('opencode.core') local ui = require('opencode.ui.ui') +local context = require('opencode.context') local state = require('opencode.state') local stub = require('luassert.stub') local assert = require('luassert') @@ -104,6 +105,12 @@ describe('opencode.api', function() new_session = true, focus = 'output', }) + + assert.is_function(api.toggle_context, 'Should export toggle_context') + stub(context, 'toggle_context').returns(true) + local enabled = api.toggle_context('current_file') + assert.is_true(enabled) + assert.stub(context.toggle_context).was_called_with('current_file') end) end) diff --git a/tests/unit/keymap_spec.lua b/tests/unit/keymap_spec.lua index 9acedda2..5f60cfd6 100644 --- a/tests/unit/keymap_spec.lua +++ b/tests/unit/keymap_spec.lua @@ -39,6 +39,7 @@ describe('opencode.keymap', function() mock_api = { open_input = function() end, toggle = function() end, + toggle_context = function() end, submit_input_prompt = function() end, permission_accept = function() end, permission_accept_all = function() end, @@ -46,6 +47,7 @@ describe('opencode.keymap', function() commands = { open_input = { desc = 'Open input window' }, toggle = { desc = 'Toggle opencode windows' }, + toggle_context = { desc = 'Toggle prompt context' }, submit_input_prompt = { desc = 'Submit input prompt' }, }, } @@ -159,6 +161,27 @@ describe('opencode.keymap', function() assert.is_not_nil(keymap_entry.opts.desc, 'Should have a description from API fallback') assert.equal('Toggle opencode windows', keymap_entry.opts.desc) end) + + it('supports parameterized function names with #suffix', function() + local called_with = nil + mock_api.toggle_context = function(context_key) + called_with = context_key + end + + local test_keymap = { + editor = { + ['tcf'] = { 'toggle_context#current_file' }, + }, + } + + keymap.setup(test_keymap) + + assert.equal(1, #set_keymaps, 'Should set up 1 keymap') + assert.equal('Toggle prompt context', set_keymaps[1].opts.desc) + + set_keymaps[1].callback() + assert.equal('current_file', called_with) + end) end) describe('setup_window_keymaps', function()