From 897d189725816e15fb616a8cb3c02488cb89fa1e Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Thu, 4 Dec 2025 06:48:25 -0500 Subject: [PATCH 01/13] perf: open the panel without waiting on session --- lua/opencode/api.lua | 198 ++++++++++++++++--------------------- lua/opencode/core.lua | 78 ++++++++------- lua/opencode/state.lua | 2 + lua/opencode/ui/topbar.lua | 5 + 4 files changed, 137 insertions(+), 146 deletions(-) diff --git a/lua/opencode/api.lua b/lua/opencode/api.lua index 7b7f652b..06776cad 100644 --- a/lua/opencode/api.lua +++ b/lua/opencode/api.lua @@ -22,15 +22,15 @@ function M.toggle_zoom() end function M.open_input() - core.open({ new_session = false, focus = 'input', start_insert = true }) + return core.open({ new_session = false, focus = 'input', start_insert = true }) end function M.open_input_new_session() - core.open({ new_session = true, focus = 'input', start_insert = true }) + return core.open({ new_session = true, focus = 'input', start_insert = true }) end function M.open_output() - core.open({ new_session = false, focus = 'output' }) + return core.open({ new_session = false, focus = 'output' }) end function M.close() @@ -78,15 +78,19 @@ end ---@param prompt string ---@param opts? SendMessageOpts function M.run(prompt, opts) - opts = vim.tbl_deep_extend('force', { new_session = false, focus = 'output' }, opts or {}) - core.send_message(prompt, opts) + opts = vim.tbl_deep_extend('force', { new_session = false, focus = 'input' }, opts or {}) + return core.open(opts):and_then(function() + return core.send_message(prompt, opts) + end) end ---@param prompt string ---@param opts? SendMessageOpts function M.run_new_session(prompt, opts) - opts = vim.tbl_deep_extend('force', { new_session = true, focus = 'output' }, opts or {}) - core.send_message(prompt, opts) + opts = vim.tbl_deep_extend('force', { new_session = true, focus = 'input' }, opts or {}) + return core.open(opts):and_then(function() + return core.send_message(prompt, opts) + end) end ---@param parent_id? string @@ -114,112 +118,92 @@ end ---@param from_snapshot_id? string ---@param to_snapshot_id? string|number function M.diff_open(from_snapshot_id, to_snapshot_id) - if not state.messages or not state.active_session then - core.open({ new_session = false, focus = 'output' }) - end - - git_review.review(from_snapshot_id) + core.open({ new_session = false, focus = 'output' }):and_then(function() + git_review.review(from_snapshot_id) + end) end function M.diff_next() - if not state.windows then - core.open({ new_session = false, focus = 'output' }) - end - - git_review.next_diff() + core.open({ new_session = false, focus = 'output' }):and_then(function() + git_review.next_diff() + end) end function M.diff_prev() - if not state.windows then - core.open({ new_session = false, focus = 'output' }) - end - - git_review.prev_diff() + core.open({ new_session = false, focus = 'output' }):and_then(function() + git_review.prev_diff() + end) end function M.diff_close() - if not state.windows then - core.open({ new_session = false, focus = 'output' }) - end - - git_review.close_diff() + core.open({ new_session = false, focus = 'output' }):and_then(function() + git_review.close_diff() + end) end ---@param from_snapshot_id? string function M.diff_revert_all(from_snapshot_id) - if not state.windows then - core.open({ new_session = false, focus = 'output' }) - end - - git_review.revert_all(from_snapshot_id) + core.open({ new_session = false, focus = 'output' }):and_then(function() + git_review.revert_all(from_snapshot_id) + end) end ---@param from_snapshot_id? string ---@param to_snapshot_id? string function M.diff_revert_selected_file(from_snapshot_id, to_snapshot_id) - if not state.windows then - core.open({ new_session = false, focus = 'output' }) - end - - git_review.revert_selected_file(from_snapshot_id) + core.open({ new_session = false, focus = 'output' }):and_then(function() + git_review.revert_selected_file(from_snapshot_id) + end) end ---@param restore_point_id? string function M.diff_restore_snapshot_file(restore_point_id) - if not state.windows then - core.open({ new_session = false, focus = 'output' }) - end - - git_review.restore_snapshot_file(restore_point_id) + core.open({ new_session = false, focus = 'output' }):and_then(function() + git_review.restore_snapshot_file(restore_point_id) + end) end ---@param restore_point_id? string function M.diff_restore_snapshot_all(restore_point_id) - if not state.windows then - core.open({ new_session = false, focus = 'output' }) - end - - git_review.restore_snapshot_all(restore_point_id) + core.open({ new_session = false, focus = 'output' }):and_then(function() + git_review.restore_snapshot_all(restore_point_id) + end) end function M.diff_revert_all_last_prompt() - if not state.windows then - core.open({ new_session = false, focus = 'output' }) - end - - local snapshots = session.get_message_snapshot_ids(state.current_message) - local snapshot_id = snapshots and snapshots[1] - if not snapshot_id then - vim.notify('No snapshots found for the current message', vim.log.levels.WARN) - return - end - git_review.revert_all(snapshot_id) + core.open({ new_session = false, focus = 'output' }):and_then(function() + local snapshots = session.get_message_snapshot_ids(state.current_message) + local snapshot_id = snapshots and snapshots[1] + if not snapshot_id then + vim.notify('No snapshots found for the current message', vim.log.levels.WARN) + return + end + git_review.revert_all(snapshot_id) + end) end function M.diff_revert_this(snapshot_id) - if not state.windows then - core.open({ new_session = false, focus = 'output' }) - end - - git_review.revert_current(snapshot_id) + core.open({ new_session = false, focus = 'output' }):and_then(function() + git_review.revert_current(snapshot_id) + end) end function M.diff_revert_this_last_prompt() - if not state.windows then - core.open({ new_session = false, focus = 'output' }) - end - - local snapshots = session.get_message_snapshot_ids(state.current_message) - local snapshot_id = snapshots and snapshots[1] - if not snapshot_id then - vim.notify('No snapshots found for the current message', vim.log.levels.WARN) - return - end + core.open({ new_session = false, focus = 'output' }):and_then(function() + local snapshots = session.get_message_snapshot_ids(state.current_message) + local snapshot_id = snapshots and snapshots[1] + if not snapshot_id then + vim.notify('No snapshots found for the current message', vim.log.levels.WARN) + return + end + git_review.revert_current(snapshot_id) + end) end function M.set_review_breakpoint() - vim.notify('Setting review breakpoint is not implemented yet', vim.log.levels.WARN) - git_review.create_snapshot() + core.open({ new_session = false, focus = 'output' }):and_then(function() + git_review.create_snapshot() + end) end function M.prev_history() @@ -245,7 +229,6 @@ function M.next_history() end function M.prev_prompt_history() - local config = require('opencode.config') local key = config.get_key_for_function('input_window', 'prev_prompt_history') if key ~= '' then return M.prev_history() @@ -260,7 +243,6 @@ function M.prev_prompt_history() end function M.next_prompt_history() - local config = require('opencode.config') local key = config.get_key_for_function('input_window', 'next_prompt_history') if key ~= '' then return M.next_history() @@ -305,7 +287,6 @@ function M.mention_file() end function M.mention() - local config = require('opencode.config') local char = config.get_key_for_function('input_window', 'mention') ui.focus_input({ restore_position = false, start_insert = true }) @@ -313,14 +294,12 @@ function M.mention() end function M.context_items() - local config = require('opencode.config') local char = config.get_key_for_function('input_window', 'context_items') ui.focus_input({ restore_position = true, start_insert = true }) require('opencode.ui.completion').trigger_completion(char)() end function M.slash_commands() - local config = require('opencode.config') local char = config.get_key_for_function('input_window', 'slash_commands') ui.focus_input({ restore_position = false, start_insert = true }) require('opencode.ui.completion').trigger_completion(char)() @@ -331,7 +310,6 @@ function M.focus_input() end function M.debug_output() - local config = require('opencode.config') if not config.debug.enabled then vim.notify('Debugging is not enabled in the config', vim.log.levels.WARN) return @@ -341,7 +319,6 @@ function M.debug_output() end function M.debug_message() - local config = require('opencode.config') if not config.debug.enabled then vim.notify('Debugging is not enabled in the config', vim.log.levels.WARN) return @@ -351,7 +328,6 @@ function M.debug_message() end function M.debug_session() - local config = require('opencode.config') if not config.debug.enabled then vim.notify('Debugging is not enabled in the config', vim.log.levels.WARN) return @@ -565,33 +541,34 @@ end --- @param name string The name of the user command to run. --- @param args? string[] Additional arguments to pass to the command. function M.run_user_command(name, args) - M.open_input() - local user_commands = config_file.get_user_commands() - local command_cfg = user_commands and user_commands[name] - if not command_cfg then - vim.notify('Unknown user command: ' .. name, vim.log.levels.WARN) - return - end + return M.open_input():and_then(function() + local user_commands = config_file.get_user_commands() + local command_cfg = user_commands and user_commands[name] + if not command_cfg then + vim.notify('Unknown user command: ' .. name, vim.log.levels.WARN) + return + end - local model = command_cfg.model or state.current_model - local agent = command_cfg.agent or state.current_mode + local model = command_cfg.model or state.current_model + local agent = command_cfg.agent or state.current_mode - if not state.active_session then - vim.notify('No active session', vim.log.levels.WARN) - return - end - state.api_client - :send_command(state.active_session.id, { - command = name, - arguments = table.concat(args or {}, ' '), - model = model, - agent = agent, - }) - :and_then(function() - vim.schedule(function() - require('opencode.history').write('/' .. name .. ' ' .. table.concat(args or {}, ' ')) + if not state.active_session then + vim.notify('No active session', vim.log.levels.WARN) + return + end + state.api_client + :send_command(state.active_session.id, { + command = name, + arguments = table.concat(args or {}, ' '), + model = model, + agent = agent, + }) + :and_then(function() + vim.schedule(function() + require('opencode.history').write('/' .. name .. ' ' .. table.concat(args or {}, ' ')) + end) end) - end) + end) end --- Compacts the current session by removing unnecessary data. @@ -1147,7 +1124,7 @@ M.commands = { vim.notify('Prompt required', vim.log.levels.ERROR) return end - M.run(prompt, opts) + return M.run(prompt, opts) end, }, @@ -1159,7 +1136,7 @@ M.commands = { vim.notify('Prompt required', vim.log.levels.ERROR) return end - M.run_new_session(prompt, opts) + return M.run_new_session(prompt, opts) end, }, @@ -1359,7 +1336,6 @@ function M.complete_command(arg_lead, cmd_line, cursor_pos) end function M.setup_legacy_commands() - local config = require('opencode.config') if not config.legacy_commands then return end @@ -1395,7 +1371,7 @@ function M.get_slash_commands() slash_cmd = '/' .. name, desc = def.description or 'User command', fn = function(...) - M.run_user_command(name, ...) + return M.run_user_command(name, ...) end, args = config_file.command_takes_arguments(def), }) diff --git a/lua/opencode/core.lua b/lua/opencode/core.lua index a38905b0..0dd8d722 100644 --- a/lua/opencode/core.lua +++ b/lua/opencode/core.lua @@ -48,20 +48,12 @@ end ---@param opts? OpenOpts function M.open(opts) + local promise = require('opencode.promise').new() opts = opts or { focus = 'input', new_session = false } - if not state.opencode_server or not state.opencode_server:is_running() then - state.opencode_server = server_job.ensure_server() --[[@as OpencodeServer]] - end - - M.ensure_current_mode() + state.is_opening = true local are_windows_closed = state.windows == nil - - if not require('opencode.ui.ui').is_opencode_focused() then - require('opencode.context').load() - end - if are_windows_closed then -- Check if whether prompting will be allowed local mentioned_files = context.context.mentioned_files or {} @@ -73,38 +65,54 @@ function M.open(opts) state.windows = ui.create_windows() end - if opts.new_session then - state.active_session = nil - state.last_sent_context = nil + vim.schedule(function() + if not state.opencode_server or not state.opencode_server:is_running() then + state.opencode_server = server_job.ensure_server() --[[@as OpencodeServer]] + end - state.current_model = nil - state.current_mode = nil M.ensure_current_mode() - state.active_session = M.create_new_session() - else - if not state.active_session then - state.active_session = session.get_last_workspace_session() - if not state.active_session then - state.active_session = M.create_new_session() - end + if not require('opencode.ui.ui').is_opencode_focused() then + require('opencode.context').load() + end + + if opts.new_session then + state.active_session = nil + state.last_sent_context = nil + + state.current_model = nil + state.current_mode = nil + M.ensure_current_mode() + + state.active_session = M.create_new_session() else - if not state.display_route and are_windows_closed then - -- We're not displaying /help or something like that but we have an active session - -- and the windows were closed so we need to do a full refresh. This mostly happens - -- when opening the window after having closed it since we're not currently clearing - -- the session on api.close() - ui.render_output() + if not state.active_session then + state.active_session = session.get_last_workspace_session() + if not state.active_session then + state.active_session = M.create_new_session() + end + else + if not state.display_route and are_windows_closed then + -- We're not displaying /help or something like that but we have an active session + -- and the windows were closed so we need to do a full refresh. This mostly happens + -- when opening the window after having closed it since we're not currently clearing + -- the session on api.close() + ui.render_output() + end end end - end + promise:resolve() + state.is_opening = false - if opts.focus == 'input' then - ui.focus_input({ restore_position = are_windows_closed, start_insert = opts.start_insert == true }) - elseif opts.focus == 'output' then - ui.focus_output({ restore_position = are_windows_closed }) - end - state.is_opencode_focused = true + if opts.focus == 'input' then + ui.focus_input({ restore_position = are_windows_closed, start_insert = opts.start_insert == true }) + elseif opts.focus == 'output' then + ui.focus_output({ restore_position = are_windows_closed }) + end + + state.is_opencode_focused = true + end) + return promise end --- Sends a message to the active session, creating one if necessary. diff --git a/lua/opencode/state.lua b/lua/opencode/state.lua index 8d571cb8..2aaed930 100644 --- a/lua/opencode/state.lua +++ b/lua/opencode/state.lua @@ -8,6 +8,7 @@ ---@class OpencodeState ---@field windows OpencodeWindowState|nil +---@field is_opening boolean ---@field input_content table ---@field is_opencode_focused boolean ---@field last_focused_opencode_window string|nil @@ -52,6 +53,7 @@ local M = {} local _state = { -- ui windows = nil, ---@type OpencodeWindowState|nil + is_opening = false, input_content = {}, is_opencode_focused = false, last_focused_opencode_window = nil, diff --git a/lua/opencode/ui/topbar.lua b/lua/opencode/ui/topbar.lua index 6f17e2d9..29954caf 100644 --- a/lua/opencode/ui/topbar.lua +++ b/lua/opencode/ui/topbar.lua @@ -70,6 +70,10 @@ local function update_winbar_highlights(win_id) end local function get_session_desc() + if state.is_opening then + return 'Loading...' + end + local session_title = LABELS.NEW_SESSION_TITLE if state.active_session then @@ -117,6 +121,7 @@ function M.setup() state.subscribe('is_opencode_focused', on_change) state.subscribe('tokens_count', on_change) state.subscribe('cost', on_change) + state.subscribe('is_opening', on_change) M.render() end From 47356e10e4b907430252357f09d06276ca20a434 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Fri, 5 Dec 2025 07:57:41 -0500 Subject: [PATCH 02/13] fix: wrong focused window --- lua/opencode/api.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lua/opencode/api.lua b/lua/opencode/api.lua index 06776cad..45bc0197 100644 --- a/lua/opencode/api.lua +++ b/lua/opencode/api.lua @@ -78,7 +78,7 @@ end ---@param prompt string ---@param opts? SendMessageOpts function M.run(prompt, opts) - opts = vim.tbl_deep_extend('force', { new_session = false, focus = 'input' }, opts or {}) + opts = vim.tbl_deep_extend('force', { new_session = false, focus = 'output' }, opts or {}) return core.open(opts):and_then(function() return core.send_message(prompt, opts) end) @@ -87,7 +87,7 @@ end ---@param prompt string ---@param opts? SendMessageOpts function M.run_new_session(prompt, opts) - opts = vim.tbl_deep_extend('force', { new_session = true, focus = 'input' }, opts or {}) + opts = vim.tbl_deep_extend('force', { new_session = true, focus = 'output' }, opts or {}) return core.open(opts):and_then(function() return core.send_message(prompt, opts) end) From 2d71c2085b9bce42cf4529858f5b0b0f066e2eda Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Fri, 5 Dec 2025 09:01:09 -0500 Subject: [PATCH 03/13] perf: use promise based waiting for the ensure_server --- lua/opencode/api.lua | 9 ++---- lua/opencode/api_client.lua | 3 +- lua/opencode/core.lua | 6 ++-- lua/opencode/server_job.lua | 6 ++-- lua/opencode/ui/footer.lua | 14 ++++------ tests/unit/api_spec.lua | 50 ++++++++++++++++++++-------------- tests/unit/server_job_spec.lua | 4 +-- 7 files changed, 48 insertions(+), 44 deletions(-) diff --git a/lua/opencode/api.lua b/lua/opencode/api.lua index 45bc0197..6e5b361b 100644 --- a/lua/opencode/api.lua +++ b/lua/opencode/api.lua @@ -107,12 +107,9 @@ function M.select_history() end function M.toggle_pane() - if not state.windows then - core.open({ new_session = false, focus = 'output' }) - return - end - - ui.toggle_pane() + return core.open({ new_session = false, focus = 'output' }):and_then(function() + ui.toggle_pane() + end) end ---@param from_snapshot_id? string diff --git a/lua/opencode/api_client.lua b/lua/opencode/api_client.lua index e5466886..4bc05083 100644 --- a/lua/opencode/api_client.lua +++ b/lua/opencode/api_client.lua @@ -30,7 +30,8 @@ function OpencodeApiClient:_ensure_base_url() local state = require('opencode.state') if not state.opencode_server then - state.opencode_server = server_job.ensure_server() --[[@as OpencodeServer]] + -- this is last resort - try to start the server and could be blocking + state.opencode_server = server_job.ensure_server():wait() --[[@as OpencodeServer]] -- shouldn't normally happen but prevents error in replay tester if not state.opencode_server then return false diff --git a/lua/opencode/core.lua b/lua/opencode/core.lua index 0dd8d722..b88a1ac5 100644 --- a/lua/opencode/core.lua +++ b/lua/opencode/core.lua @@ -65,10 +65,8 @@ function M.open(opts) state.windows = ui.create_windows() end - vim.schedule(function() - if not state.opencode_server or not state.opencode_server:is_running() then - state.opencode_server = server_job.ensure_server() --[[@as OpencodeServer]] - end + server_job.ensure_server():and_then(function(server) + state.opencode_server = server M.ensure_current_mode() diff --git a/lua/opencode/server_job.lua b/lua/opencode/server_job.lua index fa922c22..fa77f687 100644 --- a/lua/opencode/server_job.lua +++ b/lua/opencode/server_job.lua @@ -132,11 +132,11 @@ function M.stream_api(url, method, body, on_chunk) end function M.ensure_server() + local promise = Promise.new() if state.opencode_server and state.opencode_server:is_running() then - return state.opencode_server + return promise:resolve(state.opencode_server) end - local promise = Promise.new() state.opencode_server = opencode_server.new() state.opencode_server:spawn({ @@ -151,7 +151,7 @@ function M.ensure_server() end, }) - return promise:wait() + return promise end return M diff --git a/lua/opencode/ui/footer.lua b/lua/opencode/ui/footer.lua index a55d7858..520d65b5 100644 --- a/lua/opencode/ui/footer.lua +++ b/lua/opencode/ui/footer.lua @@ -12,7 +12,7 @@ local function utf8_len(str) end local function get_mode_highlight() - local mode = (state.current_mode or ''):lower() + local mode = (state.current_mode or config.default_mode):lower() local highlights = { build = 'OpencodeAgentBuild', plan = 'OpencodeAgentPlan', @@ -42,17 +42,15 @@ local function build_right_segments() table.insert(segments, { ' ' }) end - if not state.is_running() and state.current_model then + if not state.is_running() and state.current_model and config.ui.display_model then table.insert(segments, { state.current_model, 'OpencodeHint' }) table.insert(segments, { ' ' }) end - if state.current_mode then - table.insert(segments, { - string.format(' %s ', state.current_mode:upper()), - get_mode_highlight(), - }) - end + table.insert(segments, { + string.format(' %s ', (state.current_mode or config.default_mode):upper()), + get_mode_highlight(), + }) return segments end diff --git a/tests/unit/api_spec.lua b/tests/unit/api_spec.lua index f6f13c97..86c1aae3 100644 --- a/tests/unit/api_spec.lua +++ b/tests/unit/api_spec.lua @@ -4,6 +4,7 @@ local ui = require('opencode.ui.ui') local state = require('opencode.state') local stub = require('luassert.stub') local assert = require('luassert') +local Promise = require('opencode.promise') describe('opencode.api', function() local created_commands = {} @@ -17,7 +18,10 @@ describe('opencode.api', function() opts = opts, }) end) - stub(core, 'open') + stub(core, 'open').invokes(function() + return Promise.new():resolve('done') + end) + stub(core, 'run') stub(core, 'cancel') stub(core, 'send_message') @@ -118,13 +122,13 @@ describe('opencode.api', function() -- Test the exported functions assert.is_function(api.open_input, 'Should export open_input') - api.open_input() + api.open_input():wait() assert.stub(core.open).was_called() assert.stub(core.open).was_called_with({ new_session = false, focus = 'input', start_insert = true }) -- Test run function assert.is_function(api.run, 'Should export run') - api.run('test prompt') + api.run('test prompt'):wait() assert.stub(core.send_message).was_called() assert.stub(core.send_message).was_called_with('test prompt', { new_session = false, @@ -133,7 +137,7 @@ describe('opencode.api', function() -- Test run_new_session function assert.is_function(api.run_new_session, 'Should export run_new_session') - api.run_new_session('test prompt new') + api.run_new_session('test prompt new'):wait() assert.stub(core.send_message).was_called() assert.stub(core.send_message).was_called_with('test prompt new', { new_session = true, @@ -144,7 +148,7 @@ describe('opencode.api', function() describe('run command argument parsing', function() it('parses agent prefix and passes to send_message', function() - api.commands.run.fn({ 'agent=plan', 'analyze', 'this', 'code' }) + api.commands.run.fn({ 'agent=plan', 'analyze', 'this', 'code' }):wait() assert.stub(core.send_message).was_called() assert.stub(core.send_message).was_called_with('analyze this code', { new_session = false, @@ -154,7 +158,7 @@ describe('opencode.api', function() end) it('parses model prefix and passes to send_message', function() - api.commands.run.fn({ 'model=openai/gpt-4', 'test', 'prompt' }) + api.commands.run.fn({ 'model=openai/gpt-4', 'test', 'prompt' }):wait() assert.stub(core.send_message).was_called() assert.stub(core.send_message).was_called_with('test prompt', { new_session = false, @@ -164,7 +168,7 @@ describe('opencode.api', function() end) it('parses context prefix and passes to send_message', function() - api.commands.run.fn({ 'context=current_file.enabled=false', 'test' }) + api.commands.run.fn({ 'context=current_file.enabled=false', 'test' }):wait() assert.stub(core.send_message).was_called() assert.stub(core.send_message).was_called_with('test', { new_session = false, @@ -174,13 +178,15 @@ describe('opencode.api', function() end) it('parses multiple prefixes and passes all to send_message', function() - api.commands.run.fn({ - 'agent=plan', - 'model=openai/gpt-4', - 'context=current_file.enabled=false', - 'analyze', - 'code', - }) + api.commands.run + .fn({ + 'agent=plan', + 'model=openai/gpt-4', + 'context=current_file.enabled=false', + 'analyze', + 'code', + }) + :wait() assert.stub(core.send_message).was_called() assert.stub(core.send_message).was_called_with('analyze code', { new_session = false, @@ -192,7 +198,7 @@ describe('opencode.api', function() end) it('works with run_new command', function() - api.commands.run_new.fn({ 'agent=plan', 'model=openai/gpt-4', 'new', 'session', 'prompt' }) + api.commands.run_new.fn({ 'agent=plan', 'model=openai/gpt-4', 'new', 'session', 'prompt' }):wait() assert.stub(core.send_message).was_called() assert.stub(core.send_message).was_called_with('new session prompt', { new_session = true, @@ -210,7 +216,7 @@ describe('opencode.api', function() end) it('Lua API accepts opts directly without parsing', function() - api.run('test prompt', { agent = 'plan', model = 'openai/gpt-4' }) + api.run('test prompt', { agent = 'plan', model = 'openai/gpt-4' }):wait() assert.stub(core.send_message).was_called() assert.stub(core.send_message).was_called_with('test prompt', { new_session = false, @@ -434,6 +440,12 @@ describe('opencode.api', function() end) describe('user command model/agent selection', function() + before_each(function() + stub(api, 'open_input').invokes(function() + return Promise.new():resolve('done') + end) + end) + it('invokes run with correct model and agent', function() local config_file = require('opencode.config_file') local original_get_user_commands = config_file.get_user_commands @@ -466,8 +478,6 @@ describe('opencode.api', function() end, } - stub(api, 'open_input') - local slash_commands = api.get_slash_commands() local test_no_model_cmd = nil @@ -484,7 +494,7 @@ describe('opencode.api', function() assert.truthy(test_no_model_cmd, 'Should find /test-no-model command') assert.truthy(test_with_model_cmd, 'Should find /test-with-model command') - test_no_model_cmd.fn() + test_no_model_cmd.fn():wait() assert.equal(1, #send_command_calls) assert.equal('test-session', send_command_calls[1].session_id) assert.equal('test-no-model', send_command_calls[1].command_data.command) @@ -494,7 +504,7 @@ describe('opencode.api', function() send_command_calls = {} - test_with_model_cmd.fn() + test_with_model_cmd.fn():wait() assert.equal(1, #send_command_calls) assert.equal('test-session', send_command_calls[1].session_id) assert.equal('test-with-model', send_command_calls[1].command_data.command) diff --git a/tests/unit/server_job_spec.lua b/tests/unit/server_job_spec.lua index 8c26964a..bf7b82c5 100644 --- a/tests/unit/server_job_spec.lua +++ b/tests/unit/server_job_spec.lua @@ -98,9 +98,9 @@ describe('server_job', function() return fake end - local first = server_job.ensure_server() + local first = server_job.ensure_server():wait() assert.same(fake, first._value or first) -- ensure_server returns resolved promise value - local second = server_job.ensure_server() + local second = server_job.ensure_server():wait() assert.same(fake, second._value or second) assert.equal(1, spawn_count) end) From 2bfcb8f4d4b70da29733cb3c6bec9eabb32b43e0 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Fri, 5 Dec 2025 09:04:15 -0500 Subject: [PATCH 04/13] fix: selection context not loading before opening the panel --- lua/opencode/core.lua | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lua/opencode/core.lua b/lua/opencode/core.lua index b88a1ac5..a6e80340 100644 --- a/lua/opencode/core.lua +++ b/lua/opencode/core.lua @@ -53,6 +53,10 @@ function M.open(opts) state.is_opening = true + if not require('opencode.ui.ui').is_opencode_focused() then + require('opencode.context').load() + end + local are_windows_closed = state.windows == nil if are_windows_closed then -- Check if whether prompting will be allowed @@ -70,10 +74,6 @@ function M.open(opts) M.ensure_current_mode() - if not require('opencode.ui.ui').is_opencode_focused() then - require('opencode.context').load() - end - if opts.new_session then state.active_session = nil state.last_sent_context = nil From bc855050a41bf859fb2d4d44c77f281f0248741e Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Fri, 5 Dec 2025 09:22:30 -0500 Subject: [PATCH 05/13] feat: ensure we can't stay in a loading state forever --- lua/opencode/core.lua | 83 ++++++++++++++++++++++++---------------- tests/unit/core_spec.lua | 33 ++++++++++++++++ 2 files changed, 83 insertions(+), 33 deletions(-) diff --git a/lua/opencode/core.lua b/lua/opencode/core.lua index a6e80340..a74aea9a 100644 --- a/lua/opencode/core.lua +++ b/lua/opencode/core.lua @@ -69,47 +69,64 @@ function M.open(opts) state.windows = ui.create_windows() end - server_job.ensure_server():and_then(function(server) - state.opencode_server = server + if opts.focus == 'input' then + ui.focus_input({ restore_position = are_windows_closed, start_insert = opts.start_insert == true }) + elseif opts.focus == 'output' then + ui.focus_output({ restore_position = are_windows_closed }) + end - M.ensure_current_mode() + server_job + .ensure_server() + :and_then(function(server) + local ok, err = pcall(function() + state.opencode_server = server - if opts.new_session then - state.active_session = nil - state.last_sent_context = nil + M.ensure_current_mode() - state.current_model = nil - state.current_mode = nil - M.ensure_current_mode() + if opts.new_session then + state.active_session = nil + state.last_sent_context = nil + + state.current_model = nil + state.current_mode = nil + M.ensure_current_mode() - state.active_session = M.create_new_session() - else - if not state.active_session then - state.active_session = session.get_last_workspace_session() - if not state.active_session then state.active_session = M.create_new_session() + else + if not state.active_session then + state.active_session = session.get_last_workspace_session() + if not state.active_session then + state.active_session = M.create_new_session() + end + else + if not state.display_route and are_windows_closed then + -- We're not displaying /help or something like that but we have an active session + -- and the windows were closed so we need to do a full refresh. This mostly happens + -- when opening the window after having closed it since we're not currently clearing + -- the session on api.close() + ui.render_output() + end + end end - else - if not state.display_route and are_windows_closed then - -- We're not displaying /help or something like that but we have an active session - -- and the windows were closed so we need to do a full refresh. This mostly happens - -- when opening the window after having closed it since we're not currently clearing - -- the session on api.close() - ui.render_output() - end - end - end - promise:resolve() - state.is_opening = false - if opts.focus == 'input' then - ui.focus_input({ restore_position = are_windows_closed, start_insert = opts.start_insert == true }) - elseif opts.focus == 'output' then - ui.focus_output({ restore_position = are_windows_closed }) - end + state.is_opencode_focused = true + end) - state.is_opencode_focused = true - end) + state.is_opening = false + + if not ok then + promise:reject(err) + vim.notify('Error opening panel: ' .. tostring(err), vim.log.levels.ERROR) + return + end + + promise:resolve() + end) + :catch(function(err) + state.is_opening = false + promise:reject(err) + vim.notify('Error ensuring server: ' .. tostring(err), vim.log.levels.ERROR) + end) return promise end diff --git a/tests/unit/core_spec.lua b/tests/unit/core_spec.lua index 3267f6a5..24ba615b 100644 --- a/tests/unit/core_spec.lua +++ b/tests/unit/core_spec.lua @@ -182,6 +182,39 @@ describe('opencode.core', function() assert.truthy(state.active_session) assert.truthy(state.active_session.id) end) + + it('resets is_opening flag when error occurs', function() + state.windows = nil + state.is_opening = false + + -- Simply cause an error by stubbing a function that will be called + local original_create_new_session = core.create_new_session + core.create_new_session = function() + error('Test error in create_new_session') + end + + local notify_stub = stub(vim, 'notify') + local result_promise = core.open({ new_session = true, focus = 'input' }) + + -- Wait for async operations to complete + local ok, err = pcall(function() + result_promise:wait() + end) + + -- Should fail due to the error + assert.is_false(ok) + assert.truthy(err) + + -- is_opening should be reset to false even when error occurs + assert.is_false(state.is_opening) + + -- Should have notified about the error + assert.stub(notify_stub).was_called() + + -- Restore original function + core.create_new_session = original_create_new_session + notify_stub:revert() + end) end) describe('select_session', function() From 96e9f2d27850fc93951b7b84a1f2efa1f83d4765 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Tue, 9 Dec 2025 05:02:29 -0500 Subject: [PATCH 06/13] refactor(promise): replace critical path with Promise.async This uses coroutines for managing promises. The sync :wait method is still used in some not critical parts where it's not obvious to change to the await method --- lua/opencode/api.lua | 56 +++--- lua/opencode/config_file.lua | 77 ++++---- lua/opencode/core.lua | 176 +++++++++--------- lua/opencode/git_review.lua | 15 +- lua/opencode/promise.lua | 148 +++++++++++++-- lua/opencode/provider.lua | 2 +- lua/opencode/server_job.lua | 2 + lua/opencode/session.lua | 109 +++-------- lua/opencode/snapshot.lua | 16 +- lua/opencode/types.lua | 15 +- lua/opencode/ui/completion/commands.lua | 17 +- lua/opencode/ui/completion/context.lua | 5 +- lua/opencode/ui/completion/engines/base.lua | 116 ++++++++++++ .../ui/completion/engines/blink_cmp.lua | 7 +- .../ui/completion/engines/nvim_cmp.lua | 7 +- lua/opencode/ui/completion/files.lua | 14 +- lua/opencode/ui/completion/subagents.lua | 11 +- lua/opencode/ui/debug_helper.lua | 1 + lua/opencode/ui/footer.lua | 2 +- lua/opencode/ui/session_picker.lua | 17 +- lua/opencode/ui/topbar.lua | 7 +- tests/unit/api_spec.lua | 82 +++++--- tests/unit/config_file_spec.lua | 122 ++++++------ tests/unit/context_completion_spec.lua | 42 +++-- tests/unit/core_spec.lua | 87 ++++++--- tests/unit/hooks_spec.lua | 16 +- tests/unit/session_spec.lua | 117 +++++++++--- tests/unit/snapshot_spec.lua | 17 ++ 28 files changed, 840 insertions(+), 463 deletions(-) create mode 100644 lua/opencode/ui/completion/engines/base.lua diff --git a/lua/opencode/api.lua b/lua/opencode/api.lua index 6e5b361b..729e7ce5 100644 --- a/lua/opencode/api.lua +++ b/lua/opencode/api.lua @@ -10,6 +10,7 @@ local icons = require('opencode.ui.icons') local git_review = require('opencode.git_review') local history = require('opencode.history') local config = require('opencode.config') +local Promise = require('opencode.promise') local M = {} @@ -49,14 +50,14 @@ function M.paste_image() core.paste_image_from_clipboard() end -function M.toggle(new_session) +M.toggle = Promise.async(function(new_session) + local focus = state.last_focused_opencode_window or 'input' ---@cast focus 'input' | 'output' if state.windows == nil then - local focus = state.last_focused_opencode_window or 'input' ---@cast focus 'input' | 'output' - core.open({ new_session = new_session == true, focus = focus, start_insert = false }) + core.open({ new_session = new_session == true, focus = focus, start_insert = false }):await() else M.close() end -end +end) function M.toggle_focus(new_session) if not ui.is_opencode_focused() then @@ -115,7 +116,7 @@ end ---@param from_snapshot_id? string ---@param to_snapshot_id? string|number function M.diff_open(from_snapshot_id, to_snapshot_id) - core.open({ new_session = false, focus = 'output' }):and_then(function() + core.open_if_closed({ new_session = false, focus = 'output' }):and_then(function() git_review.review(from_snapshot_id) end) end @@ -333,15 +334,15 @@ function M.debug_session() debug_helper.debug_session() end -function M.initialize() +M.initialize = Promise.async(function() local id = require('opencode.id') - local new_session = core.create_new_session('AGENTS.md Initialization') + local new_session = core.create_new_session('AGENTS.md Initialization'):await() if not new_session then vim.notify('Failed to create new session', vim.log.levels.ERROR) return end - if not core.initialize_current_model() or not state.current_model then + if not core.initialize_current_model():await() or not state.current_model then vim.notify('No model selected', vim.log.levels.ERROR) return end @@ -357,7 +358,7 @@ function M.initialize() modelID = modelId, messageID = id.ascending('message'), }) -end +end) function M.agent_plan() require('opencode.core').switch_to_mode('plan') @@ -463,8 +464,8 @@ function M.help() ui.render_lines(msg) end -function M.mcp() - local mcp = config_file.get_mcp_servers() +M.mcp = Promise.async(function() + local mcp = config_file.get_mcp_servers():await() if not mcp then vim.notify('No MCP configuration found. Please check your opencode config file.', vim.log.levels.WARN) return @@ -502,10 +503,10 @@ function M.mcp() table.insert(msg, '') ui.render_lines(msg) -end +end) function M.commands_list() - local commands = config_file.get_user_commands() + local commands = config_file.get_user_commands():await() if not commands then vim.notify('No user commands found. Please check your opencode config file.', vim.log.levels.WARN) return @@ -530,16 +531,16 @@ function M.commands_list() ui.render_lines(msg) end -function M.current_model() +M.current_model = Promise.async(function() return core.initialize_current_model() -end +end) --- Runs a user-defined command by name. --- @param name string The name of the user command to run. --- @param args? string[] Additional arguments to pass to the command. -function M.run_user_command(name, args) +M.run_user_command = Promise.async(function(name, args) return M.open_input():and_then(function() - local user_commands = config_file.get_user_commands() + local user_commands = config_file.get_user_commands():await() local command_cfg = user_commands and user_commands[name] if not command_cfg then vim.notify('Unknown user command: ' .. name, vim.log.levels.WARN) @@ -566,7 +567,7 @@ function M.run_user_command(name, args) end) end) end) -end +end) --- Compacts the current session by removing unnecessary data. --- @param current_session? Session The session to compact. Defaults to the active session. @@ -737,9 +738,9 @@ end ---@param current_session? Session --- @param new_title? string -function M.rename_session(current_session, new_title) +M.rename_session = Promise.async(function(current_session, new_title) local promise = require('opencode.promise').new() - current_session = current_session or vim.deepcopy(state.active_session) --[[@as Session]] + current_session = current_session or (state.active_session and vim.deepcopy(state.active_session) or nil) --[[@as Session]] if not current_session then vim.notify('No active session to rename', vim.log.levels.WARN) promise:resolve(nil) @@ -756,7 +757,7 @@ function M.rename_session(current_session, new_title) :and_then(function() current_session.title = title if state.active_session and state.active_session.id == current_session.id then - local session_obj = session.get_by_id(current_session.id) + local session_obj = session.get_by_id(current_session.id):await() if session_obj then session_obj.title = title state.active_session = vim.deepcopy(session_obj) @@ -781,7 +782,7 @@ function M.rename_session(current_session, new_title) end) end) return promise -end +end) -- Returns the ID of the next user message after the current undo point -- This is a port of the opencode tui logic @@ -906,6 +907,7 @@ function M.toggle_tool_output() ui.render_output() end +---@type table M.commands = { open = { desc = 'Open opencode window (input/output)', @@ -972,7 +974,7 @@ M.commands = { vim.notify('Failed to create new session', vim.log.levels.ERROR) return end - state.active_session = new_session + state.active_session = new_session:await() M.open_input() else M.open_input_new_session() @@ -1140,7 +1142,7 @@ M.commands = { command = { desc = 'Run user-defined command', completions = function() - local user_commands = config_file.get_user_commands() + local user_commands = config_file.get_user_commands():wait() if not user_commands then return {} end @@ -1351,7 +1353,7 @@ function M.setup_legacy_commands() end end -function M.get_slash_commands() +M.get_slash_commands = Promise.async(function() local result = {} for slash_cmd, def in pairs(M.slash_commands_map) do table.insert(result, { @@ -1361,7 +1363,7 @@ function M.get_slash_commands() }) end - local user_commands = config_file.get_user_commands() + local user_commands = config_file.get_user_commands():await() if user_commands then for name, def in pairs(user_commands) do table.insert(result, { @@ -1376,7 +1378,7 @@ function M.get_slash_commands() end return result -end +end) function M.setup() vim.api.nvim_create_user_command('Opencode', M.route_command, { diff --git a/lua/opencode/config_file.lua b/lua/opencode/config_file.lua index 8f04af1b..bcfc0521 100644 --- a/lua/opencode/config_file.lua +++ b/lua/opencode/config_file.lua @@ -1,17 +1,18 @@ +local Promise = require('opencode.promise') local M = { config_promise = nil, project_promise = nil, providers_promise = nil, } ----@return OpencodeConfigFile|nil -function M.get_opencode_config() +---@type fun(): Promise +M.get_opencode_config = Promise.async(function() if not M.config_promise then local state = require('opencode.state') M.config_promise = state.api_client:get_config() end local ok, result = pcall(function() - return M.config_promise:wait() + return M.config_promise:await() end) if not ok then @@ -19,17 +20,17 @@ function M.get_opencode_config() return nil end - return result --[[@as OpencodeConfigFile|nil]] -end + return result +end) ----@return OpencodeProject|nil -function M.get_opencode_project() +---@type fun(): Promise +M.get_opencode_project = Promise.async(function() if not M.project_promise then local state = require('opencode.state') M.project_promise = state.api_client:get_current_project() end local ok, result = pcall(function() - return M.project_promise:wait() + return M.project_promise:await() end) if not ok then vim.notify('Error fetching Opencode project: ' .. vim.inspect(result), vim.log.levels.ERROR) @@ -37,28 +38,34 @@ function M.get_opencode_project() end return result --[[@as OpencodeProject|nil]] -end +end) + +---Get the snapshot storage path for the current workspace +---@type fun(): Promise +M.get_workspace_snapshot_path = Promise.async(function() + local project = M.get_opencode_project():await() --[[@as OpencodeProject|nil]] + if not project then + return '' + end + local home = vim.uv.os_homedir() + return home .. '/.local/share/opencode/snapshot/' .. project.id +end) ----@return OpencodeProvidersResponse|nil +---@return Promise function M.get_opencode_providers() if not M.providers_promise then local state = require('opencode.state') M.providers_promise = state.api_client:list_providers() end - local ok, result = pcall(function() - return M.providers_promise:wait() - end) - if not ok then - vim.notify('Error fetching Opencode providers: ' .. vim.inspect(result), vim.log.levels.ERROR) + return M.providers_promise:catch(function(err) + vim.notify('Error fetching Opencode providers: ' .. vim.inspect(err), vim.log.levels.ERROR) return nil - end - - return result --[[@as OpencodeProvidersResponse|nil]] + end) end -function M.get_model_info(provider, model) - local config_file = require('opencode.config_file') - local providers_response = config_file.get_opencode_providers() +M.get_model_info = function(provider, model) + local providers_response = M.get_opencode_providers():peek() + local providers = providers_response and providers_response.providers or {} local filtered_providers = vim.tbl_filter(function(p) @@ -72,8 +79,9 @@ function M.get_model_info(provider, model) return filtered_providers[1] and filtered_providers[1].models and filtered_providers[1].models[model] or nil end -function M.get_opencode_agents() - local cfg = M.get_opencode_config() --[[@as OpencodeConfigFile]] +---@type fun(): Promise +M.get_opencode_agents = Promise.async(function() + local cfg = M.get_opencode_config():await() if not cfg then return {} end @@ -96,10 +104,11 @@ function M.get_opencode_agents() end end return agents -end +end) -function M.get_subagents() - local cfg = M.get_opencode_config() +---@type fun(): Promise +M.get_subagents = Promise.async(function() + local cfg = M.get_opencode_config():await() if not cfg then return {} end @@ -113,17 +122,19 @@ function M.get_subagents() table.insert(subagents, 1, 'general') return subagents -end +end) -function M.get_user_commands() - local cfg = M.get_opencode_config() --[[@as OpencodeConfigFile]] +---@type fun(): Promise|nil> +M.get_user_commands = Promise.async(function() + local cfg = M.get_opencode_config():await() return cfg and cfg.command or nil -end +end) -function M.get_mcp_servers() - local cfg = M.get_opencode_config() --[[@as OpencodeConfigFile]] +---@type fun(): Promise|nil> +M.get_mcp_servers = Promise.async(function() + local cfg = M.get_opencode_config():await() return cfg and cfg.mcp or nil -end +end) ---Does this opencode user command take arguments? ---@param command OpencodeCommand diff --git a/lua/opencode/core.lua b/lua/opencode/core.lua index a74aea9a..d241317f 100644 --- a/lua/opencode/core.lua +++ b/lua/opencode/core.lua @@ -8,13 +8,16 @@ local input_window = require('opencode.ui.input_window') local util = require('opencode.util') local config = require('opencode.config') local image_handler = require('opencode.image_handler') +local Promise = require('opencode.promise') local M = {} M._abort_count = 0 ---@param parent_id string? -function M.select_session(parent_id) - local all_sessions = session.get_all_workspace_sessions() or {} +M.select_session = Promise.async(function(parent_id) + local all_sessions = session.get_all_workspace_sessions():await() or {} + ---@cast all_sessions Session[] + local filtered_sessions = vim.tbl_filter(function(s) return s.title ~= '' and s ~= nil and s.parentID == parent_id end, all_sessions) @@ -28,14 +31,14 @@ function M.select_session(parent_id) end M.switch_session(selected_session.id) end) -end +end) -function M.switch_session(session_id) - local selected_session = session.get_by_id(session_id) +M.switch_session = Promise.async(function(session_id) + local selected_session = session.get_by_id(session_id):await() state.current_model = nil state.current_mode = nil - M.ensure_current_mode() + M.ensure_current_mode():await() state.active_session = selected_session if state.windows then @@ -44,11 +47,17 @@ function M.switch_session(session_id) else M.open() end -end +end) + +---@param opts? OpenOpts +M.open_if_closed = Promise.async(function(opts) + if not state.windows then + M.open(opts):await() + end +end) ---@param opts? OpenOpts -function M.open(opts) - local promise = require('opencode.promise').new() +M.open = Promise.async(function(opts) opts = opts or { focus = 'input', new_session = false } state.is_opening = true @@ -75,65 +84,55 @@ function M.open(opts) ui.focus_output({ restore_position = are_windows_closed }) end - server_job - .ensure_server() - :and_then(function(server) - local ok, err = pcall(function() - state.opencode_server = server - - M.ensure_current_mode() - - if opts.new_session then - state.active_session = nil - state.last_sent_context = nil - - state.current_model = nil - state.current_mode = nil - M.ensure_current_mode() - - state.active_session = M.create_new_session() - else - if not state.active_session then - state.active_session = session.get_last_workspace_session() - if not state.active_session then - state.active_session = M.create_new_session() - end - else - if not state.display_route and are_windows_closed then - -- We're not displaying /help or something like that but we have an active session - -- and the windows were closed so we need to do a full refresh. This mostly happens - -- when opening the window after having closed it since we're not currently clearing - -- the session on api.close() - ui.render_output() - end - end - end + local server = server_job.ensure_server():await() + state.opencode_server = server - state.is_opencode_focused = true - end) + local ok, err = pcall(function() + state.opencode_server = server - state.is_opening = false + if opts.new_session then + state.active_session = nil + state.last_sent_context = nil - if not ok then - promise:reject(err) - vim.notify('Error opening panel: ' .. tostring(err), vim.log.levels.ERROR) - return + state.current_model = nil + state.current_mode = nil + M.ensure_current_mode():await() + + state.active_session = M.create_new_session():await() + else + M.ensure_current_mode():await() + if not state.active_session then + state.active_session = session.get_last_workspace_session():await() + if not state.active_session then + state.active_session = M.create_new_session():await() + end + else + if not state.display_route and are_windows_closed then + -- We're not displaying /help or something like that but we have an active session + -- and the windows were closed so we need to do a full refresh. This mostly happens + -- when opening the window after having closed it since we're not currently clearing + -- the session on api.close() + ui.render_output() + end end + end - promise:resolve() - end) - :catch(function(err) - state.is_opening = false - promise:reject(err) - vim.notify('Error ensuring server: ' .. tostring(err), vim.log.levels.ERROR) - end) - return promise -end + state.is_opencode_focused = true + end) + + state.is_opening = false + + if not ok then + vim.notify('Error opening panel: ' .. tostring(err), vim.log.levels.ERROR) + return Promise:new():reject(err) + end + return Promise:new():resolve('ok') +end) --- Sends a message to the active session, creating one if necessary. --- @param prompt string The message prompt to send. --- @param opts? SendMessageOpts -function M.send_message(prompt, opts) +M.send_message = Promise.async(function(prompt, opts) if not state.active_session or not state.active_session.id then return false end @@ -151,7 +150,7 @@ function M.send_message(prompt, opts) opts.context = vim.tbl_deep_extend('force', state.current_context_config or {}, opts.context or {}) state.current_context_config = opts.context context.load() - opts.model = opts.model or M.initialize_current_model() + opts.model = opts.model or M.initialize_current_model():await() opts.agent = opts.agent or state.current_mode or config.default_mode local params = {} @@ -201,23 +200,23 @@ function M.send_message(prompt, opts) update_sent_message_count(-1) M.cancel() end) -end +end) ---@param title? string ---@return Session? -function M.create_new_session(title) +M.create_new_session = Promise.async(function(title) local session_response = state.api_client :create_session(title and { title = title } or false) :catch(function(err) vim.notify('Error creating new session: ' .. vim.inspect(err), vim.log.levels.ERROR) end) - :wait() + :await() if session_response and session_response.id then - local new_session = session.get_by_id(session_response.id) + local new_session = session.get_by_id(session_response.id):await() return new_session end -end +end) ---@param prompt string function M.after_run(prompt) @@ -256,7 +255,7 @@ function M.configure_provider() end) end -function M.cancel() +M.cancel = Promise.async(function() if state.windows and state.active_session then if state.is_running() then M._abort_count = M._abort_count + 1 @@ -279,7 +278,7 @@ function M.cancel() M._abort_count = 0 -- close existing server if state.opencode_server then - state.opencode_server:shutdown():wait() + state.opencode_server:shutdown():await() end -- start a new one @@ -287,7 +286,7 @@ function M.cancel() -- NOTE: start a new server here to make sure we're subscribed -- to server events before a user sends a message - state.opencode_server = server_job.ensure_server() --[[@as OpencodeServer]] + state.opencode_server = server_job.ensure_server():await() --[[@as OpencodeServer]] end end require('opencode.ui.footer').clear() @@ -295,7 +294,7 @@ function M.cancel() require('opencode.history').index = nil ui.focus_input() end -end +end) function M.opencode_ok() if vim.fn.executable('opencode') == 0 then @@ -338,14 +337,14 @@ end --- Switches the current mode to the specified agent. --- @param mode string|nil The agent/mode to switch to --- @return boolean success Returns true if the mode was switched successfully, false otherwise -function M.switch_to_mode(mode) +M.switch_to_mode = Promise.async(function(mode) if not mode or mode == '' then vim.notify('Mode cannot be empty', vim.log.levels.ERROR) return false end local config_file = require('opencode.config_file') - local available_agents = config_file.get_opencode_agents() + local available_agents = config_file.get_opencode_agents():await() if not vim.tbl_contains(available_agents, mode) then vim.notify( @@ -356,21 +355,22 @@ function M.switch_to_mode(mode) end state.current_mode = mode - local opencode_config = config_file.get_opencode_config() + local opencode_config = config_file.get_opencode_config():await() --[[@as OpencodeConfigFile]] + local agent_config = opencode_config and opencode_config.agent or {} local mode_config = agent_config[mode] or {} if mode_config.model and mode_config.model ~= '' then state.current_model = mode_config.model end return true -end +end) --- Ensure the current_mode is set using the config.default_mode or falling back to the first available agent. --- @return boolean success Returns true if current_mode is set -function M.ensure_current_mode() +M.ensure_current_mode = Promise.async(function() if state.current_mode == nil then local config_file = require('opencode.config_file') - local available_agents = config_file.get_opencode_agents() + local available_agents = config_file.get_opencode_agents():await() if not available_agents or #available_agents == 0 then vim.notify('No available agents found', vim.log.levels.ERROR) @@ -388,48 +388,48 @@ function M.ensure_current_mode() end end return true -end +end) ---Initialize current model if it's not already set. ---@return string|nil The current model (or the default model, if configured) -function M.initialize_current_model() +M.initialize_current_model = Promise.async(function() if state.current_model then return state.current_model end - local config_file = require('opencode.config_file').get_opencode_config() + local cfg = require('opencode.config_file').get_opencode_config():await() - if config_file and config_file.model and config_file.model ~= '' then - state.current_model = config_file.model + if cfg and cfg.model and cfg.model ~= '' then + state.current_model = cfg.model end return state.current_model -end +end) -function M._on_user_message_count_change(_, new, old) +M._on_user_message_count_change = Promise.async(function(_, new, old) if config.hooks and config.hooks.on_done_thinking then - local all_sessions = session.get_all_workspace_sessions() or {} + local all_sessions = session.get_all_workspace_sessions():await() local done_sessions = vim.tbl_filter(function(s) local msg_count = new[s.id] or 0 local old_msg_count = (old and old[s.id]) or 0 return msg_count == 0 and old_msg_count > 0 - end, all_sessions) + end, all_sessions or {}) for _, done_session in ipairs(done_sessions) do pcall(config.hooks.on_done_thinking, done_session) end end -end +end) -function M._on_current_permission_change(_, new, old) +M._on_current_permission_change = Promise.async(function(_, new, old) local permission_requested = old == nil and new ~= nil if config.hooks and config.hooks.on_permission_requested and permission_requested then local local_session = (state.active_session and state.active_session.id) - and session.get_by_id(state.active_session.id) + and session.get_by_id(state.active_session.id):await() or {} pcall(config.hooks.on_permission_requested, local_session) end -end +end) --- Handle clipboard image data by saving it to a file and adding it to context --- @return boolean success True if image was successfully handled diff --git a/lua/opencode/git_review.lua b/lua/opencode/git_review.lua index 4886f197..d15bbf61 100644 --- a/lua/opencode/git_review.lua +++ b/lua/opencode/git_review.lua @@ -4,6 +4,7 @@ local snapshot = require('opencode.snapshot') local diff_tab = require('opencode.ui.diff_tab') local utils = require('opencode.util') local session = require('opencode.session') +local config_file = require('opencode.config_file') local M = {} @@ -11,12 +12,11 @@ local M = {} ---@param opts? vim.SystemOpts ---@return string|nil, string|nil local function snapshot_git(cmd_args, opts) - local snapshot_dir = state.active_session and state.active_session.snapshot_path - if not snapshot_dir then + if not M.__snapshot_path then vim.notify('No snapshot path for the active session.') return nil, nil end - local args = { 'git', '-C', snapshot_dir } + local args = { 'git', '-C', M.__snapshot_path } vim.list_extend(args, cmd_args) local result = vim.system(args, opts or {}):wait() if result and result.code == 0 then @@ -26,6 +26,7 @@ local function snapshot_git(cmd_args, opts) end end +M.__snapshot_path = nil M.__changed_files = nil M.__current_file_index = nil M.__diff_tab = nil @@ -62,7 +63,7 @@ local git = { ---@param fn T ---@param silent any ---@return T -local function require_git_project(fn, silent) +local require_git_project = function(fn, silent) return function(...) if not git.is_project() then if not silent then @@ -76,7 +77,11 @@ local function require_git_project(fn, silent) end return end - if not state.active_session.snapshot_path or vim.fn.isdirectory(state.active_session.snapshot_path) == 0 then + if not M.__snapshot_path then + M.__snapshot_path = config_file.get_workspace_snapshot_path():wait() + end + + if not M.__snapshot_path or vim.fn.isdirectory(M.__snapshot_path) == 0 then if not silent then vim.notify('Error: No snapshot path for the active session.') end diff --git a/lua/opencode/promise.lua b/lua/opencode/promise.lua index 28faecad..40630b54 100644 --- a/lua/opencode/promise.lua +++ b/lua/opencode/promise.lua @@ -1,22 +1,29 @@ ---@generic T ---@class Promise ----@field and_then fun(self: Promise, callback: fun(value: T): any): Promise ----@field resolve fun(self: self, value: any): self ----@field reject fun(self: self, err: any): self ----@field catch fun(self: Promise, callback: fun(err: any): any): Promise ----@field wait fun(self: self, timeout?: integer, interval?: integer): any ----@field is_resolved fun(self: self): boolean ----@field is_rejected fun(self: self): boolean ---@field _resolved boolean ----@field _value any +---@field _value T ---@field _error any ----@field _then_callbacks fun(value: any)[] +---@field _then_callbacks fun(value: T)[] ---@field _catch_callbacks fun(err: any)[] +---@field _coroutines thread[] local Promise = {} Promise.__index = Promise +---Resume waiting coroutines with result +---@param coroutines thread[] +---@param value T +---@param err any +local function resume_coroutines(coroutines, value, err) + for _, co in ipairs(coroutines) do + vim.schedule(function() + if coroutine.status(co) == 'suspended' then + coroutine.resume(co, value, err) + end + end) + end +end + ---Create a waitable promise that can be resolved or rejected later ----@generic T ---@return Promise function Promise.new() local self = setmetatable({ @@ -25,11 +32,12 @@ function Promise.new() _error = nil, _then_callbacks = {}, _catch_callbacks = {}, + _coroutines = {}, }, Promise) return self end ----@generic T +---@param self Promise ---@param value T ---@return Promise function Promise:resolve(value) @@ -45,12 +53,15 @@ function Promise:resolve(value) for _, callback in ipairs(self._then_callbacks) do schedule_then(callback, value) end + + resume_coroutines(self._coroutines, value, nil) + return self end ----@generic T +---@param self Promise ---@param err any ----@return self +---@return Promise function Promise:reject(err) if self._resolved then return self @@ -64,10 +75,14 @@ function Promise:reject(err) for _, callback in ipairs(self._catch_callbacks) do schedule_catch(callback, err) end + + resume_coroutines(self._coroutines, nil, err) + return self end ----@generic T, U +---@generic U +---@param self Promise ---@param callback fun(value: T): U | Promise ---@return Promise function Promise:and_then(callback) @@ -112,7 +127,7 @@ function Promise:and_then(callback) return new_promise end ----@generic T +---@param self Promise ---@param error_callback fun(err: any): any | Promise ---@return Promise function Promise:catch(error_callback) @@ -155,7 +170,11 @@ function Promise:catch(error_callback) return new_promise end +--- Synchronously wait for the promise to resolve or reject +--- This will block the main thread, so use with caution +--- But is useful for synchronous code paths that need the result ---@generic T +---@param self Promise ---@param timeout integer|nil Timeout in milliseconds (default: 5000) ---@param interval integer|nil Interval in milliseconds to check (default: 20) ---@return T @@ -185,6 +204,15 @@ function Promise:wait(timeout, interval) return self._value end +-- Tries to get the value without waiting +-- Useful for status checks where you don't want to block +---@generic T +---@param self Promise +---@return T +function Promise:peek() + return self._value +end + function Promise:is_resolved() return self._resolved end @@ -193,14 +221,50 @@ function Promise:is_rejected() return self._resolved and self._error ~= nil end +---Await the promise from within a coroutine +---This function can only be called from within `coroutine.create` or `Promise.spawn` or `Promise.async` +---This will yield the coroutine until the promise resolves or rejects ---@generic T ----@param obj T +---@param self Promise +---@return T +function Promise:await() + -- If already resolved, return immediately + local value + if self._resolved then + if self._error then + error(self._error) + end + value = self._value + ---@cast value T + return value + end + + -- Get the current coroutine + local co = coroutine.running() + if not co then + error('await() can only be called from within a coroutine') + end + + table.insert(self._coroutines, co) + + -- Yield and wait for resume + ---@diagnostic disable-next-line: await-in-sync + local value, err = coroutine.yield() + + if err then + error(err) + end + + ---@cast value T + return value +end + +---@param obj any ---@return_cast obj Promise function Promise.is_promise(obj) return type(obj) == 'table' and type(obj.and_then) == 'function' and type(obj.catch) == 'function' end ----@generic T ---@param obj T | Promise ---@return Promise function Promise.wrap(obj) @@ -211,4 +275,54 @@ function Promise.wrap(obj) end end +---Run an async function in a coroutine +---The function can use promise:await() to wait for promises +---@generic T +---@param fn fun(): T +---@return Promise +---@return_cast T Promise +function Promise.spawn(fn) + local promise = Promise.new() + + local co = coroutine.create(function() + local ok, result = pcall(fn) + if not ok then + promise:reject(result) + else + if Promise.is_promise(result) then + result + :and_then(function(val) + promise:resolve(val) + end) + :catch(function(err) + promise:reject(err) + end) + else + promise:resolve(result) + end + end + end) + + local ok, err = coroutine.resume(co) + if not ok then + promise:reject(err) + end + + return promise +end + +---Wrap a function to run asynchronously +---Takes a function and returns a wrapped version that returns a Promise +---@generic T +---@param fn fun(...): T +---@return fun(...): Promise +function Promise.async(fn) + return function(...) + local args = { ... } + return Promise.spawn(function() + return fn(unpack(args)) + end) + end +end + return Promise diff --git a/lua/opencode/provider.lua b/lua/opencode/provider.lua index 75e63e88..ec0aa4fd 100644 --- a/lua/opencode/provider.lua +++ b/lua/opencode/provider.lua @@ -2,7 +2,7 @@ local M = {} function M._get_models() local config_file = require('opencode.config_file') - local response = config_file.get_opencode_providers() + local response = config_file.get_opencode_providers():wait() if not response then return {} diff --git a/lua/opencode/server_job.lua b/lua/opencode/server_job.lua index fa77f687..789ab19b 100644 --- a/lua/opencode/server_job.lua +++ b/lua/opencode/server_job.lua @@ -131,6 +131,8 @@ function M.stream_api(url, method, body, on_chunk) return curl.request(opts) --[[@as table]] end +--- Ensure the opencode server is running, starting it if necessary. +--- @return Promise promise A promise that resolves with the server instance function M.ensure_server() local promise = Promise.new() if state.opencode_server and state.opencode_server:is_running() then diff --git a/lua/opencode/session.lua b/lua/opencode/session.lua index 8edfa037..5ac7e98a 100644 --- a/lua/opencode/session.lua +++ b/lua/opencode/session.lua @@ -1,18 +1,19 @@ local util = require('opencode.util') +local state = require('opencode.state') local config_file = require('opencode.config_file') local Promise = require('opencode.promise') local M = {} ---Get the current OpenCode project ID ---@return string|nil -function M.project_id() - local project = config_file.get_opencode_project() +M.project_id = Promise.async(function() + local project = config_file.get_opencode_project():await() if not project then vim.notify('No OpenCode project found in the current directory', vim.log.levels.ERROR) return nil end return project.id -end +end) ---Get the base storage path for OpenCode ---@return string @@ -23,70 +24,27 @@ end ---Get the session storage path for the current workspace ---@return string -function M.get_workspace_session_path(project_id) +M.get_workspace_session_path = Promise.async(function(project_id) project_id = project_id or M.project_id() or '' local home = vim.uv.os_homedir() return home .. '/.local/share/opencode/storage/session/' .. project_id -end - ----Get the snapshot storage path for the current workspace ----@return string -function M.get_workspace_snapshot_path() - local project_id = M.project_id() - local home = vim.uv.os_homedir() - return home .. '/.local/share/opencode/snapshot/' .. project_id -end - ----@return Session[]|nil ----Get all sessions for the current workspace ----@return Session[]|nil -function M.get_all_sessions() - local state = require('opencode.state') - local ok, result = pcall(function() - return state.api_client:list_sessions():wait() - end) - - if not ok then - vim.notify('Failed to fetch session list: ' .. vim.inspect(result), vim.log.levels.ERROR) - return nil - end - - return vim.tbl_map(M.create_session_object, result --[[@as Session[] ]]) -end +end) ----Create a Session object from JSON ----@param session_json table ----@return Session -function M.create_session_object(session_json) - local sessions_dir = M.get_workspace_session_path() - local storage_path = M.get_storage_path() - return { - workspace = session_json.directory, - title = session_json.title or '', - modified = session_json.time and session_json.time.updated or os.time(), - id = session_json.id, - parentID = session_json.parentID, - path = sessions_dir .. '/' .. session_json.id .. '.json', - messages_path = storage_path .. '/message/' .. session_json.id, - parts_path = storage_path .. '/part', - cache_path = vim.fn.stdpath('cache') .. '/opencode/session/' .. session_json.id, - snapshot_path = M.get_workspace_snapshot_path(), - project_id = M.project_id(), - revert = session_json.revert or nil, - } +function M.get_cache_path(session_id) + local cache_base = vim.fn.stdpath('cache') .. '/opencode/session/' + return cache_base .. session_id end ----@return Session[]|nil ---Get all workspace sessions, sorted and filtered ---@return Session[]|nil -function M.get_all_workspace_sessions() - local sessions = M.get_all_sessions() +M.get_all_workspace_sessions = Promise.async(function() + local sessions = state.api_client:list_sessions():await() if not sessions then return nil end table.sort(sessions, function(a, b) - return a.modified > b.modified + return a.time.updated > b.time.updated end) if not util.is_git_project() then @@ -100,13 +58,13 @@ function M.get_all_workspace_sessions() end return sessions -end +end) ----@return Session|nil ---Get the most recent main workspace session ---@return Session|nil -function M.get_last_workspace_session() - local sessions = M.get_all_workspace_sessions() +M.get_last_workspace_session = Promise.async(function() + local sessions = M.get_all_workspace_sessions():await() + ---@cast sessions Session[]|nil if not sessions then return nil end @@ -116,47 +74,22 @@ function M.get_last_workspace_session() end, sessions) return main_sessions[1] -end - -local _session_by_id = {} -local _session_last_modified = {} +end) ---Get a session by its id ---@param id string ----@return Session|nil -function M.get_by_id(id) +---@return Promise +M.get_by_id = Promise.async(function(id) if not id or id == '' then return nil end - local sessions_dir = M.get_workspace_session_path() - local file = sessions_dir .. '/' .. id .. '.json' - local _, stat = pcall(vim.uv.fs_stat, file) - if not stat then - return nil - end - - if _session_by_id[id] and _session_last_modified[id] == stat.mtime.sec then - return _session_by_id[id] - end - - local content = table.concat(vim.fn.readfile(file), '\n') - local ok, session_json = pcall(vim.json.decode, content) - if not ok or not session_json then - return nil - end - - local session = M.create_session_object(session_json) - _session_by_id[id] = session - _session_last_modified[id] = stat.mtime.sec - - return session -end + return state.api_client:get_session(id):await() +end) ---Get messages for a session ---@param session Session ---@return Promise function M.get_messages(session) - local state = require('opencode.state') if not session then return Promise.new():resolve(nil) end diff --git a/lua/opencode/snapshot.lua b/lua/opencode/snapshot.lua index 83e32d8c..1bf3795f 100644 --- a/lua/opencode/snapshot.lua +++ b/lua/opencode/snapshot.lua @@ -1,15 +1,17 @@ -- This file is a port of the snapshot management logic from the original OpenCode ---- @see https://github.com/sst/opencode/blob/dev/packages/opencode/src/snapshot/index.ts +---@see https://github.com/sst/opencode/blob/dev/packages/opencode/src/snapshot/index.ts local M = {} local state = require('opencode.state') local util = require('opencode.util') +local config_file = require('opencode.config_file') +local session = require('opencode.session') ---@param cmd_args string[] ---@param opts? vim.SystemOpts ---@return string|nil, string|nil local function snapshot_git(cmd_args, opts) - local snapshot_dir = state.active_session and state.active_session.snapshot_path + local snapshot_dir = config_file.get_workspace_snapshot_path():wait() if not snapshot_dir then vim.notify('No snapshot path for the active session.') return nil, nil @@ -42,7 +44,7 @@ function M.track() return nil end - local snapshot_dir = state.active_session.snapshot_path + local snapshot_dir = config_file.get_workspace_snapshot_path():wait() if not snapshot_dir then vim.notify('No snapshot path for the active session.') return nil @@ -72,6 +74,7 @@ function M.save_restore_point(snapshot_id, from_snapshot_id, deleted_files) return nil end + local cache_path = session.get_cache_path(state.active_session.id) local patch_result = M.patch(snapshot_id) local snapshot = { id = snapshot_id, @@ -81,7 +84,7 @@ function M.save_restore_point(snapshot_id, from_snapshot_id, deleted_files) created_at = os.time(), } - local path = state.active_session.cache_path .. 'snapshots/' + local path = cache_path .. 'snapshots/' if vim.fn.isdirectory(path) == 0 then vim.fn.mkdir(path, 'p') end @@ -103,13 +106,14 @@ function M.get_restore_points() state.restore_points = {} return {} end - if not state.active_session.cache_path then + local cache_path = session.get_cache_path(state.active_session.id) + if not cache_path then return {} end if state.restore_points and #state.restore_points > 0 then return state.restore_points end - local restore_points = util.read_json_dir(state.active_session.cache_path .. 'snapshots/') or {} + local restore_points = util.read_json_dir(cache_path .. 'snapshots/') or {} table.sort(restore_points, function(a, b) return a.created_at > b.created_at end) diff --git a/lua/opencode/types.lua b/lua/opencode/types.lua index de314ed8..3e9c50e2 100644 --- a/lua/opencode/types.lua +++ b/lua/opencode/types.lua @@ -40,6 +40,12 @@ ---@field model string ---@field template string +---@class OpencodeUICommand +---@field desc string +---@field completions? string[]|function +---@field sub_completions? string[] +---@field fn function + ---@class SessionRevertInfo ---@field messageID string ---@field partID? string @@ -52,14 +58,9 @@ ---@class Session ---@field workspace string ---@field title string ----@field modified number +---@field time { created: number, updated: number } ---@field id string ---@field parentID string|nil ----@field path string ----@field messages_path string ----@field parts_path string ----@field snapshot_path string ----@field cache_path string ---@field revert? SessionRevertInfo ---@field share? SessionShareInfo @@ -343,7 +344,7 @@ ---@class CompletionSource ---@field name string Name of the completion source ---@field priority number Priority for ordering sources ----@field complete fun(context: CompletionContext): CompletionItem[] Function to generate completion items +---@field complete fun(context: CompletionContext): Promise Function to generate completion items ---@field on_complete fun(item: CompletionItem): nil Optional callback when item is selected ---@class OpencodeContext diff --git a/lua/opencode/ui/completion/commands.lua b/lua/opencode/ui/completion/commands.lua index bf951b72..016717a5 100644 --- a/lua/opencode/ui/completion/commands.lua +++ b/lua/opencode/ui/completion/commands.lua @@ -1,8 +1,9 @@ +local Promise = require('opencode.promise') local M = {} -local function get_available_commands() +get_available_commands = Promise.async(function() local api = require('opencode.api') - local commands = api.get_slash_commands() + local commands = api.get_slash_commands():await() local results = {} for key, cmd_info in ipairs(commands) do @@ -17,27 +18,27 @@ local function get_available_commands() end return results -end +end) ---@type CompletionSource local command_source = { name = 'commands', priority = 1, - complete = function(context) + complete = Promise.async(function(context) local icons = require('opencode.ui.icons') if not context.line:match('^' .. vim.pesc(context.trigger_char) .. '[^%s/]*$') then return {} end - local config_mod = require('opencode.config') - local expected_trigger = config_mod.get_key_for_function('input_window', 'slash_commands') + local config = require('opencode.config') + local expected_trigger = config.get_key_for_function('input_window', 'slash_commands') if context.trigger_char ~= expected_trigger then return {} end local items = {} local input_lower = context.input:lower() - local commands = get_available_commands() + local commands = get_available_commands():await() for _, command in ipairs(commands) do local name_lower = command.name:lower() @@ -67,7 +68,7 @@ local command_source = { sort_util.sort_by_relevance(items, context.input) return items - end, + end), on_complete = function(item) if item.kind == 'command' then if item.data.fn then diff --git a/lua/opencode/ui/completion/context.lua b/lua/opencode/ui/completion/context.lua index a2305ef9..6d9a9c07 100644 --- a/lua/opencode/ui/completion/context.lua +++ b/lua/opencode/ui/completion/context.lua @@ -2,6 +2,7 @@ local config = require('opencode.config') local context = require('opencode.context') local state = require('opencode.state') local icons = require('opencode.ui.icons') +local Promise = require('opencode.promise') local M = {} local kind_priority = { @@ -229,7 +230,7 @@ end local context_source = { name = 'context', priority = 1, - complete = function(completion_context) + complete = Promise.async(function(completion_context) local input = completion_context.input or '' local expected_trigger = config.get_key_for_function('input_window', 'context_items') @@ -255,7 +256,7 @@ local context_source = { end return items - end, + end), on_complete = function(item) local input_win = require('opencode.ui.input_window') if not item or not item.data then diff --git a/lua/opencode/ui/completion/engines/base.lua b/lua/opencode/ui/completion/engines/base.lua new file mode 100644 index 00000000..9b735d45 --- /dev/null +++ b/lua/opencode/ui/completion/engines/base.lua @@ -0,0 +1,116 @@ +---@class CompletionEngine +---@field name string The name identifier of the completion engine +---@field _completion_sources table[]|nil Internal array of registered completion sources +--- +--- Base class for all completion engines in opencode.nvim +--- Provides a common interface and shared functionality for different completion systems +--- like nvim-cmp, blink.cmp, and vim's built-in completion. +--- +--- Child classes should override: +--- - setup(completion_sources): Initialize the engine with sources +--- - trigger(trigger_char): Handle manual completion triggering +--- - is_available(): Check if the engine can be used in current context +--- +--- Common methods provided: +--- - get_trigger_characters(): Returns configured trigger characters +--- - parse_trigger(text): Parses trigger characters from text +--- - get_completion_items(context): Gets formatted completion items +--- - on_complete(item): Handles completion selection +local CompletionEngine = {} +CompletionEngine.__index = CompletionEngine + +---Create a new completion engine instance +---@param name string The identifier name for this engine (e.g., 'nvim_cmp', 'blink_cmp') +---@return CompletionEngine The new engine instance +function CompletionEngine.new(name) + local self = setmetatable({}, CompletionEngine) + self.name = name + self._completion_sources = nil + return self +end + +---Get trigger characters from config +---@return string[] +function CompletionEngine:get_trigger_characters() + local config = require('opencode.config') + local mention_key = config.get_key_for_function('input_window', 'mention') + local slash_key = config.get_key_for_function('input_window', 'slash_commands') + local context_key = config.get_key_for_function('input_window', 'context_items') + return { + slash_key or '', + mention_key or '', + context_key or '', + } +end + +---Check if the completion engine is available for use +---Default implementation checks if current buffer filetype is 'opencode' +---Child classes can override this to add engine-specific availability checks +---@return boolean true if the engine can be used in the current context +function CompletionEngine:is_available() + return vim.bo.filetype == 'opencode' +end + +---Parse trigger characters from text before cursor +---Identifies trigger characters and extracts the completion query text +---@param before_cursor string Text from line start to cursor position +---@return string|nil trigger_char The trigger character found (e.g., '@', '/') +---@return string|nil trigger_match The text after the trigger character +function CompletionEngine:parse_trigger(before_cursor) + local triggers = self:get_trigger_characters() + local trigger_chars = table.concat(vim.tbl_map(vim.pesc, triggers), '') + local trigger_char, trigger_match = before_cursor:match('.*([' .. trigger_chars .. '])([%w_%-%.]*)') + return trigger_char, trigger_match +end + +---Get completion items from all registered sources +---Queries all completion sources and formats their responses into a unified structure +---@param context table Completion context containing input, cursor_pos, line, trigger_char +---@return table[] Array of wrapped completion items with metadata +function CompletionEngine:get_completion_items(context) + local items = {} + for _, source in ipairs(self._completion_sources or {}) do + local source_items = source.complete(context) + for i, item in ipairs(source_items) do + local source_priority = source.priority or 999 + local item_priority = item.priority or 999 + table.insert(items, { + original_item = item, + source_priority = source_priority, + item_priority = item_priority, + index = i, + source_name = source.name, + }) + end + end + return items +end + +---Setup the completion engine with completion sources +---Base implementation stores sources. Child classes should call this via super +---and then perform engine-specific initialization +---@param completion_sources table[] Array of completion source objects +---@return boolean success true if setup was successful +function CompletionEngine:setup(completion_sources) + self._completion_sources = completion_sources + return true +end + +---Trigger completion manually for a specific character +---Child classes should override this to implement engine-specific triggering +---Default implementation does nothing +---@param trigger_char string The character that triggered completion +function CompletionEngine:trigger(trigger_char) + -- Default implementation does nothing +end + +---Handle completion item selection +---Called when a completion item is selected by the user +---Delegates to the completion module's on_complete handler +---@param original_item table The original completion item that was selected +function CompletionEngine:on_complete(original_item) + local completion = require('opencode.ui.completion') + completion.on_complete(original_item) +end + +return CompletionEngine diff --git a/lua/opencode/ui/completion/engines/blink_cmp.lua b/lua/opencode/ui/completion/engines/blink_cmp.lua index 44fdf746..55e05a57 100644 --- a/lua/opencode/ui/completion/engines/blink_cmp.lua +++ b/lua/opencode/ui/completion/engines/blink_cmp.lua @@ -1,3 +1,4 @@ +local Promise = require('opencode.promise') local M = {} local Source = {} @@ -25,7 +26,7 @@ function Source:is_available() return vim.bo.filetype == 'opencode' end -function Source:get_completions(ctx, callback) +Source.get_completions = Promise.async(function(self, ctx, callback) local completion = require('opencode.ui.completion') local completion_sources = completion.get_sources() @@ -50,7 +51,7 @@ function Source:get_completions(ctx, callback) local items = {} for _, completion_source in ipairs(completion_sources) do - local source_items = completion_source.complete(context) + local source_items = completion_source.complete(context):await() for i, item in ipairs(source_items) do table.insert(items, { label = item.label, @@ -77,7 +78,7 @@ function Source:get_completions(ctx, callback) end callback({ is_incomplete_forward = true, is_incomplete_backward = true, items = items }) -end +end) function Source:execute(ctx, item, callback, default_implementation) default_implementation() diff --git a/lua/opencode/ui/completion/engines/nvim_cmp.lua b/lua/opencode/ui/completion/engines/nvim_cmp.lua index 0b05cca4..45a6f6a9 100644 --- a/lua/opencode/ui/completion/engines/nvim_cmp.lua +++ b/lua/opencode/ui/completion/engines/nvim_cmp.lua @@ -27,8 +27,9 @@ function M.setup(completion_sources) return vim.bo.filetype == 'opencode' end - function source:complete(params, callback) + source.complete = Promise.async(function(self, params, callback) local line = params.context.cursor_line + local col = params.context.cursor.col local before_cursor = line:sub(1, col - 1) @@ -49,7 +50,7 @@ function M.setup(completion_sources) local items = {} for _, completion_source in ipairs(completion_sources) do - local source_items = completion_source.complete(context) + local source_items = completion_source.complete(context):await() for j, item in ipairs(source_items) do table.insert(items, { label = item.label, @@ -76,7 +77,7 @@ function M.setup(completion_sources) end callback({ items = items, isIncomplete = true }) - end + end) cmp.register_source('opencode_mentions', source.new()) diff --git a/lua/opencode/ui/completion/files.lua b/lua/opencode/ui/completion/files.lua index b5a974e5..04264328 100644 --- a/lua/opencode/ui/completion/files.lua +++ b/lua/opencode/ui/completion/files.lua @@ -1,5 +1,6 @@ local config = require('opencode.config') local icons = require('opencode.ui.icons') +local Promise = require('opencode.promise') local M = {} local last_successful_tool = nil @@ -110,13 +111,12 @@ end local file_source = { name = 'files', priority = 5, - complete = function(context) + complete = Promise.async(function(context) local sort_util = require('opencode.ui.completion.sort') local file_config = config.ui.completion.file_sources local input = context.input or '' - local config_mod = require('opencode.config') - local expected_trigger = config_mod.get_key_for_function('input_window', 'mention') + local expected_trigger = config.get_key_for_function('input_window', 'mention') if not file_config.enabled or context.trigger_char ~= expected_trigger then return {} end @@ -137,7 +137,7 @@ local file_source = { end) return vim.list_extend(recent_files, items) - end, + end), on_complete = function(item) local state = require('opencode.state') local context = require('opencode.context') @@ -150,10 +150,10 @@ local file_source = { ---Get the list of recent files ---@return CompletionItem[] -function M.get_recent_files() +M.get_recent_files = Promise.async(function() local api_client = require('opencode.state').api_client - local result = api_client:get_file_status():wait() + local result = api_client:get_file_status():await() local recent_files = {} if result then for _, file in ipairs(result) do @@ -162,7 +162,7 @@ function M.get_recent_files() end end return recent_files -end +end) ---Get the file completion source ---@return CompletionSource diff --git a/lua/opencode/ui/completion/subagents.lua b/lua/opencode/ui/completion/subagents.lua index c6d37ef3..b8a59b8a 100644 --- a/lua/opencode/ui/completion/subagents.lua +++ b/lua/opencode/ui/completion/subagents.lua @@ -1,15 +1,16 @@ local icons = require('opencode.ui.icons') +local Promise = require('opencode.promise') + local M = {} ---@type CompletionSource local subagent_source = { name = 'subagents', priority = 1, - complete = function(context) - local subagents = require('opencode.config_file').get_subagents() + complete = Promise.async(function(context) + local subagents = require('opencode.config_file').get_subagents():await() local config = require('opencode.config') - local config_mod = require('opencode.config') - local expected_trigger = config_mod.get_key_for_function('input_window', 'mention') + local expected_trigger = config.get_key_for_function('input_window', 'mention') if context.trigger_char ~= expected_trigger then return {} end @@ -42,7 +43,7 @@ local subagent_source = { sort_util.sort_by_relevance(items, context.input) return items - end, + end), on_complete = function(item) local state = require('opencode.state') local context = require('opencode.context') diff --git a/lua/opencode/ui/debug_helper.lua b/lua/opencode/ui/debug_helper.lua index fea4c0e3..4dfce79b 100644 --- a/lua/opencode/ui/debug_helper.lua +++ b/lua/opencode/ui/debug_helper.lua @@ -49,6 +49,7 @@ end function M.debug_session() local session = require('opencode.session') + local session_path = session.get_workspace_session_path() if not state.active_session then print('No active session') diff --git a/lua/opencode/ui/footer.lua b/lua/opencode/ui/footer.lua index 520d65b5..083d0468 100644 --- a/lua/opencode/ui/footer.lua +++ b/lua/opencode/ui/footer.lua @@ -35,7 +35,7 @@ end local function build_right_segments() local segments = {} - if state.is_running() then + if state.is_running() and not state.is_opening then local cancel_keymap = config.get_key_for_function('input_window', 'stop') or '' table.insert(segments, { string.format('%s ', cancel_keymap), 'OpencodeInputLegend' }) table.insert(segments, { 'to cancel', 'OpencodeHint' }) diff --git a/lua/opencode/ui/session_picker.lua b/lua/opencode/ui/session_picker.lua index c60c0040..6362bf9b 100644 --- a/lua/opencode/ui/session_picker.lua +++ b/lua/opencode/ui/session_picker.lua @@ -3,13 +3,14 @@ local config = require('opencode.config') local base_picker = require('opencode.ui.base_picker') local util = require('opencode.util') local api = require('opencode.api') +local Promise = require('opencode.promise') ---Format session parts for session picker ---@param session Session object ---@return PickerItem function format_session_item(session, width) local debug_text = 'ID: ' .. (session.id or 'N/A') - return base_picker.create_picker_item(session.title, session.modified, debug_text, width) + return base_picker.create_picker_item(session.title, session.time.updated, debug_text, width) end function M.pick(sessions, callback) @@ -45,7 +46,7 @@ function M.pick(sessions, callback) key = config.keymap.session_picker.delete_session, label = 'delete', multi_selection = true, - fn = function(selected, opts) + fn = Promise.async(function(selected, opts) local state = require('opencode.state') local sessions_to_delete = type(selected) == 'table' and selected.id == nil and selected or { selected } @@ -53,7 +54,7 @@ function M.pick(sessions, callback) for _, session in ipairs(sessions_to_delete) do if state.active_session and state.active_session.id == session.id then vim.notify('deleting current session, creating new session') - state.active_session = require('opencode.core').create_new_session() + state.active_session = require('opencode.core').create_new_session():await() end state.api_client:delete_session(session.id):catch(function(err) @@ -72,13 +73,13 @@ function M.pick(sessions, callback) vim.notify('Deleted ' .. #sessions_to_delete .. ' session(s)', vim.log.levels.INFO) return opts.items - end, + end), reload = true, }, new = { key = config.keymap.session_picker.new_session, label = 'new', - fn = function(selected, opts) + fn = Promise.async(function(selected, opts) local parent_id for _, s in ipairs(opts.items or {}) do if s.parentID ~= nil then @@ -87,14 +88,14 @@ function M.pick(sessions, callback) end end local state = require('opencode.state') - local created = state.api_client:create_session(parent_id and { parentID = parent_id } or false):wait() + local created = state.api_client:create_session(parent_id and { parentID = parent_id } or false):await() if created and created.id then - local new_session = require('opencode.session').get_by_id(created.id) + local new_session = require('opencode.session').get_by_id(created.id):await() table.insert(opts.items, 1, new_session) return opts.items end return nil - end, + end), reload = true, }, } diff --git a/lua/opencode/ui/topbar.lua b/lua/opencode/ui/topbar.lua index 29954caf..adf983a2 100644 --- a/lua/opencode/ui/topbar.lua +++ b/lua/opencode/ui/topbar.lua @@ -63,7 +63,7 @@ local function update_winbar_highlights(win_id) table.insert(parts, 'Normal:OpencodeNormal') end - table.insert(parts, 'WinBar:OpencodeSessionDescription') + table.insert(parts, 'WinBar:OpencodeSessionDecription') table.insert(parts, 'WinBarNC:OpencodeSessionDescription') vim.api.nvim_set_option_value('winhighlight', table.concat(parts, ','), { win = win_id }) @@ -77,9 +77,8 @@ local function get_session_desc() local session_title = LABELS.NEW_SESSION_TITLE if state.active_session then - local session = require('opencode.session').get_by_id(state.active_session.id) - if session and session.title ~= '' then - session_title = session.title + if state.active_session and state.active_session ~= '' then + session_title = state.active_session.title end end diff --git a/tests/unit/api_spec.lua b/tests/unit/api_spec.lua index 86c1aae3..5115d2ee 100644 --- a/tests/unit/api_spec.lua +++ b/tests/unit/api_spec.lua @@ -230,10 +230,12 @@ describe('opencode.api', function() describe('/mcp command', function() it('displays MCP server configuration when available', function() local config_file = require('opencode.config_file') + local Promise = require('opencode.promise') local original_get_mcp_servers = config_file.get_mcp_servers config_file.get_mcp_servers = function() - return { + local p = Promise.new() + p:resolve({ filesystem = { type = 'local', enabled = true, @@ -244,13 +246,14 @@ describe('opencode.api', function() enabled = false, url = 'https://example.com/mcp', }, - } + }) + return p end stub(ui, 'render_lines') stub(api, 'open_input') - api.mcp() + api.mcp():wait() assert.stub(api.open_input).was_called() assert.stub(ui.render_lines).was_called() @@ -269,15 +272,18 @@ describe('opencode.api', function() it('shows warning when no MCP configuration exists', function() local config_file = require('opencode.config_file') + local Promise = require('opencode.promise') local original_get_mcp_servers = config_file.get_mcp_servers config_file.get_mcp_servers = function() - return nil + local p = Promise.new() + p:resolve(nil) + return p end local notify_stub = stub(vim, 'notify') - api.mcp() + api.mcp():wait() assert .stub(notify_stub) @@ -294,11 +300,13 @@ describe('opencode.api', function() local original_get_user_commands = config_file.get_user_commands config_file.get_user_commands = function() - return { + local p = Promise.new() + p:resolve({ ['build'] = { description = 'Build the project' }, ['test'] = { description = 'Run tests' }, ['deploy'] = { description = 'Deploy to production' }, - } + }) + return p end stub(ui, 'render_lines') @@ -329,7 +337,9 @@ describe('opencode.api', function() local original_get_user_commands = config_file.get_user_commands config_file.get_user_commands = function() - return nil + local p = Promise.new() + p:resolve(nil) + return p end local notify_stub = stub(vim, 'notify') @@ -351,11 +361,13 @@ describe('opencode.api', function() local original_get_user_commands = config_file.get_user_commands config_file.get_user_commands = function() - return { + local p = Promise.new() + p:resolve({ ['build'] = { description = 'Build the project' }, ['test'] = { description = 'Run tests' }, ['deploy'] = { description = 'Deploy to production' }, - } + }) + return p end local completions = api.commands.command.completions() @@ -372,7 +384,9 @@ describe('opencode.api', function() local original_get_user_commands = config_file.get_user_commands config_file.get_user_commands = function() - return nil + local p = Promise.new() + p:resolve(nil) + return p end local completions = api.commands.command.completions() @@ -387,10 +401,12 @@ describe('opencode.api', function() local original_get_user_commands = config_file.get_user_commands config_file.get_user_commands = function() - return { + local p = Promise.new() + p:resolve({ ['build'] = { description = 'Build the project' }, ['test'] = { description = 'Run tests' }, - } + }) + return p end local results = api.complete_command('b', 'Opencode command b', 18) @@ -408,13 +424,15 @@ describe('opencode.api', function() local original_get_user_commands = config_file.get_user_commands config_file.get_user_commands = function() - return { + local p = Promise.new() + p:resolve({ ['build'] = { description = 'Build the project' }, ['test'] = { description = 'Run tests', template = 'Run tests with $ARGUMENTS' }, - } + }) + return p end - local slash_commands = api.get_slash_commands() + local slash_commands = api.get_slash_commands():wait() local build_found = false local test_found = false @@ -451,7 +469,8 @@ describe('opencode.api', function() local original_get_user_commands = config_file.get_user_commands config_file.get_user_commands = function() - return { + local p = Promise.new() + p:resolve({ ['test-no-model'] = { description = 'Run tests', template = 'Run tests with $ARGUMENTS' }, ['test-with-model'] = { description = 'Run tests', @@ -459,7 +478,8 @@ describe('opencode.api', function() model = 'openai/gpt-4', agent = 'tester', }, - } + }) + return p end local original_active_session = state.active_session @@ -478,7 +498,7 @@ describe('opencode.api', function() end, } - local slash_commands = api.get_slash_commands() + local slash_commands = api.get_slash_commands():wait() local test_no_model_cmd = nil local test_with_model_cmd = nil @@ -523,12 +543,14 @@ describe('opencode.api', function() local original_get_user_commands = config_file.get_user_commands config_file.get_user_commands = function() - return { + local p = Promise.new() + p:resolve({ ['custom'] = {}, - } + }) + return p end - local slash_commands = api.get_slash_commands() + local slash_commands = api.get_slash_commands():wait() local custom_found = false for _, cmd in ipairs(slash_commands) do @@ -548,12 +570,14 @@ describe('opencode.api', function() local original_get_user_commands = config_file.get_user_commands config_file.get_user_commands = function() - return { + local p = Promise.new() + p:resolve({ ['build'] = { description = 'Build the project' }, - } + }) + return p end - local slash_commands = api.get_slash_commands() + local slash_commands = api.get_slash_commands():wait() local help_found = false local build_found = false @@ -578,7 +602,7 @@ describe('opencode.api', function() local original_model = state.current_model state.current_model = 'testmodel' - local model = api.current_model() + local model = api.current_model():wait() assert.equal('testmodel', model) state.current_model = original_model @@ -592,10 +616,12 @@ describe('opencode.api', function() local original_get_opencode_config = config_file.get_opencode_config config_file.get_opencode_config = function() - return { model = 'testmodel' } + local p = Promise.new() + p:resolve({ model = 'testmodel' }) + return p end - local model = api.current_model() + local model = api.current_model():wait() assert.equal('testmodel', model) diff --git a/tests/unit/config_file_spec.lua b/tests/unit/config_file_spec.lua index 19eb831c..a7c7acb8 100644 --- a/tests/unit/config_file_spec.lua +++ b/tests/unit/config_file_spec.lua @@ -22,74 +22,82 @@ describe('config_file.setup', function() end) it('lazily loads config when accessed', function() - local get_config_called, get_project_called = false, false - local cfg = { agent = { ['a1'] = { mode = 'primary' } } } - state.api_client = { - get_config = function() - get_config_called = true - return Promise.new():resolve(cfg) - end, - get_current_project = function() - get_project_called = true - return Promise.new():resolve({ id = 'p1', name = 'P', path = '/tmp' }) - end, - } + Promise.spawn(function() + local get_config_called, get_project_called = false, false + local cfg = { agent = { ['a1'] = { mode = 'primary' } } } + state.api_client = { + get_config = function() + get_config_called = true + return Promise.new():resolve(cfg) + end, + get_current_project = function() + get_project_called = true + return Promise.new():resolve({ id = 'p1', name = 'P', path = '/tmp' }) + end, + } - -- Promises should not be set up during setup (lazy loading) - assert.falsy(config_file.config_promise) - assert.falsy(config_file.project_promise) + -- Promises should not be set up during setup (lazy loading) + assert.falsy(config_file.config_promise) + assert.falsy(config_file.project_promise) - -- Accessing config should trigger lazy loading - local resolved_cfg = config_file.get_opencode_config() - assert.same(cfg, resolved_cfg) - assert.True(get_config_called) + -- Accessing config should trigger lazy loading + local resolved_cfg = config_file.get_opencode_config():await() + assert.same(cfg, resolved_cfg) + assert.True(get_config_called) - -- Project should be loaded when accessed - local project = config_file.get_opencode_project() - assert.True(get_project_called) + -- Project should be loaded when accessed + local project = config_file.get_opencode_project():await() + assert.True(get_project_called) + end):wait() end) it('get_opencode_agents returns primary + defaults', function() - state.api_client = { - get_config = function() - return Promise.new():resolve({ agent = { ['custom'] = { mode = 'primary' } } }) - end, - get_current_project = function() - return Promise.new():resolve({ id = 'p1' }) - end, - } - local agents = config_file.get_opencode_agents() - assert.True(vim.tbl_contains(agents, 'custom')) - assert.True(vim.tbl_contains(agents, 'build')) - assert.True(vim.tbl_contains(agents, 'plan')) + Promise.spawn(function() + state.api_client = { + get_config = function() + return Promise.new():resolve({ agent = { ['custom'] = { mode = 'primary' } } }) + end, + get_current_project = function() + return Promise.new():resolve({ id = 'p1' }) + end, + } + local agents = config_file.get_opencode_agents():await() + assert.True(vim.tbl_contains(agents, 'custom')) + assert.True(vim.tbl_contains(agents, 'build')) + assert.True(vim.tbl_contains(agents, 'plan')) + end):wait() end) it('get_opencode_agents respects disabled defaults', function() - state.api_client = { - get_config = function() - return Promise.new():resolve({ agent = { ['custom'] = { mode = 'primary' }, ['build'] = { disable = true }, ['plan'] = { disable = false } } }) - end, - get_current_project = function() - return Promise.new():resolve({ id = 'p1' }) - end, - } - local agents = config_file.get_opencode_agents() - assert.True(vim.tbl_contains(agents, 'custom')) - assert.False(vim.tbl_contains(agents, 'build')) - assert.True(vim.tbl_contains(agents, 'plan')) + Promise.spawn(function() + state.api_client = { + get_config = function() + return Promise.new():resolve({ agent = { ['custom'] = { mode = 'primary' }, ['build'] = { disable = true }, ['plan'] = { disable = false } } }) + end, + get_current_project = function() + return Promise.new():resolve({ id = 'p1' }) + end, + } + local agents = config_file.get_opencode_agents():await() + assert.True(vim.tbl_contains(agents, 'custom')) + assert.False(vim.tbl_contains(agents, 'build')) + assert.True(vim.tbl_contains(agents, 'plan')) + end):wait() end) it('get_opencode_project returns project', function() - local project = { id = 'p1', name = 'X' } - state.api_client = { - get_config = function() - return Promise.new():resolve({ agent = {} }) - end, - get_current_project = function() - return Promise.new():resolve(project) - end, - } - local proj = config_file.get_opencode_project() - assert.same(project, proj) + Promise.spawn(function() + local project = { id = 'p1', name = 'X' } + state.api_client = { + get_config = function() + return Promise.new():resolve({ agent = {} }) + end, + get_current_project = function() + return Promise.new():resolve(project) + end, + } + local proj = config_file.get_opencode_project():await() + assert.same(project, proj) + end):wait() end) end) diff --git a/tests/unit/context_completion_spec.lua b/tests/unit/context_completion_spec.lua index 0a4c5b4c..acc85d9b 100644 --- a/tests/unit/context_completion_spec.lua +++ b/tests/unit/context_completion_spec.lua @@ -123,7 +123,8 @@ describe('context completion', function() input = '', } - local items = source.complete(completion_context) + local promise = source.complete(completion_context) + local items = promise:wait() assert.are.same({}, items) end) @@ -133,7 +134,8 @@ describe('context completion', function() input = '', } - local items = source.complete(completion_context) + local promise = source.complete(completion_context) + local items = promise:wait() assert.is_true(#items >= 3) -- current_file, diagnostics, cursor_data @@ -149,7 +151,8 @@ describe('context completion', function() input = '', } - local items = source.complete(completion_context) + local promise = source.complete(completion_context) + local items = promise:wait() local selection_item = find_item_by_label(items, 'Selection (1)') assert.is_not_nil(selection_item) @@ -166,13 +169,15 @@ describe('context completion', function() input = '', } - local items = source.complete(completion_context) + local promise = source.complete(completion_context) + local items = promise:wait() mock_config.context.files = { enabled = true } - local items_with_files = source.complete(completion_context) + local promise2 = source.complete(completion_context) + local items_with_files = promise2:wait() local mentioned_files = vim.tbl_filter(function(item) - return item.data.type == 'mentioned_file' + return item.data and item.data.type == 'mentioned_file' end, items_with_files) assert.are.equal(2, #mentioned_files) @@ -184,10 +189,11 @@ describe('context completion', function() input = '', } - local items = source.complete(completion_context) + local promise = source.complete(completion_context) + local items = promise:wait() local subagent_items = vim.tbl_filter(function(item) - return item.data.type == 'subagent' + return item.data and item.data.type == 'subagent' end, items) assert.are.equal(2, #subagent_items) @@ -203,7 +209,8 @@ describe('context completion', function() input = 'file', } - local items = source.complete(completion_context) + local promise = source.complete(completion_context) + local items = promise:wait() for _, item in ipairs(items) do assert.is_true(item.label:lower():find('file', 1, true) ~= nil) @@ -220,9 +227,10 @@ describe('context completion', function() input = '', } - local items = source.complete(completion_context) + local promise = source.complete(completion_context) + local items = promise:wait() - assert.is_true(items[1].data.available) + assert.is_true(items and #items > 0 and items[1].data.available) local first_unavailable_idx = nil for i, item in ipairs(items) do @@ -435,7 +443,8 @@ describe('context completion', function() input = '', } - local items = source.complete(completion_context) + local promise = source.complete(completion_context) + local items = promise:wait() local diagnostics_item = find_item_by_label(items, 'Diagnostics') assert.is_not_nil(diagnostics_item) @@ -459,7 +468,8 @@ describe('context completion', function() input = '', } - local items = source.complete(completion_context) + local promise = source.complete(completion_context) + local items = promise:wait() local cursor_item = find_item_by_label(items, 'Cursor Data') assert.is_not_nil(cursor_item) @@ -474,7 +484,8 @@ describe('context completion', function() input = '', } - local items = context_completion.get_source().complete(completion_context) + local promise = context_completion.get_source().complete(completion_context) + local items = promise:wait() local selection_detail = find_item_by_pattern(items, 'Selection 1') assert.is_not_nil(selection_detail) @@ -502,7 +513,8 @@ describe('context completion', function() input = '', } - local items = context_completion.get_source().complete(completion_context) + local promise = context_completion.get_source().complete(completion_context) + local items = promise:wait() assert.is_true(#items >= 3) end) diff --git a/tests/unit/core_spec.lua b/tests/unit/core_spec.lua index 24ba615b..21908918 100644 --- a/tests/unit/core_spec.lua +++ b/tests/unit/core_spec.lua @@ -66,21 +66,31 @@ describe('opencode.core', function() stub(ui, 'focus_input') stub(ui, 'focus_output') stub(ui, 'is_output_empty').returns(true) - stub(session, 'get_last_workspace_session').returns({ id = 'test-session' }) + stub(session, 'get_last_workspace_session').invokes(function() + local p = Promise.new() + p:resolve({ id = 'test-session' }) + return p + end) if session.get_by_id and type(session.get_by_id) == 'function' then -- stub get_by_id to return a simple session object without filesystem access stub(session, 'get_by_id').invokes(function(id) + local p = Promise.new() if not id then - return nil + p:resolve(nil) + else + p:resolve({ id = id, title = id, modified = os.time(), parentID = nil }) end - return { id = id, title = id, modified = os.time(), parentID = nil } + return p end) -- stub get_by_name to return a simple session object without filesystem access stub(session, 'get_by_name').invokes(function(name) + local p = Promise.new() if not name then - return nil + p:resolve(nil) + else + p:resolve({ id = name, title = name, modified = os.time(), parentID = nil }) end - return { id = name, title = name, modified = os.time(), parentID = nil } + return p end) end mock_api_client() @@ -131,7 +141,7 @@ describe('opencode.core', function() describe('open', function() it("creates windows if they don't exist", function() state.windows = nil - core.open({ new_session = false, focus = 'input' }) + core.open({ new_session = false, focus = 'input' }):wait() assert.truthy(state.windows) assert.same({ mock = 'windows', @@ -145,7 +155,7 @@ describe('opencode.core', function() it('handles new session properly', function() state.windows = nil state.active_session = { id = 'old-session' } - core.open({ new_session = true, focus = 'input' }) + core.open({ new_session = true, focus = 'input' }):wait() assert.truthy(state.active_session) end) @@ -161,12 +171,12 @@ describe('opencode.core', function() output_focused = true end) - core.open({ new_session = false, focus = 'input' }) + core.open({ new_session = false, focus = 'input' }):wait() assert.is_true(input_focused) assert.is_false(output_focused) input_focused, output_focused = false, false - core.open({ new_session = false, focus = 'output' }) + core.open({ new_session = false, focus = 'output' }):wait() assert.is_false(input_focused) assert.is_true(output_focused) end) @@ -175,9 +185,13 @@ describe('opencode.core', function() state.windows = nil state.active_session = nil session.get_last_workspace_session:revert() - stub(session, 'get_last_workspace_session').returns(nil) + stub(session, 'get_last_workspace_session').invokes(function() + local p = Promise.new() + p:resolve(nil) + return p + end) - core.open({ new_session = false, focus = 'input' }) + core.open({ new_session = false, focus = 'input' }):wait() assert.truthy(state.active_session) assert.truthy(state.active_session.id) @@ -224,7 +238,11 @@ describe('opencode.core', function() { id = 'session2', title = '', modified = 2, parentID = nil }, { id = 'session3', title = 'Third session', modified = 3, parentID = nil }, } - stub(session, 'get_all_workspace_sessions').returns(mock_sessions) + stub(session, 'get_all_workspace_sessions').invokes(function() + local p = Promise.new() + p:resolve(mock_sessions) + return p + end) local passed stub(ui, 'select_session').invokes(function(sessions, cb) passed = sessions @@ -234,7 +252,7 @@ describe('opencode.core', function() stub(ui, 'render_output') state.windows = { input_buf = 1, output_buf = 2 } - core.select_session(nil) + core.select_session(nil):wait() assert.equal(2, #passed) assert.equal('session3', passed[2].id) assert.truthy(state.active_session) @@ -276,7 +294,7 @@ describe('opencode.core', function() end -- override create_new_session to use api_client path synchronously - local new = core.create_new_session('title') + local new = core.create_new_session('title'):wait() assert.True(created_session) assert.truthy(new) assert.equal('sess-new', new.id) @@ -448,19 +466,26 @@ describe('opencode.core', function() describe('switch_to_mode', function() it('sets current model from config file when mode has a model configured', function() - stub(config_file, 'get_opencode_agents').returns({ 'plan', 'build', 'custom' }) - stub(config_file, 'get_opencode_config').returns({ + local Promise = require('opencode.promise') + local agents_promise = Promise.new() + agents_promise:resolve({ 'plan', 'build', 'custom' }) + local config_promise = Promise.new() + config_promise:resolve({ agent = { custom = { model = 'anthropic/claude-3-opus', }, }, }) + + stub(config_file, 'get_opencode_agents').returns(agents_promise) + stub(config_file, 'get_opencode_config').returns(config_promise) state.current_mode = nil state.current_model = nil - local success = core.switch_to_mode('custom') + local promise = core.switch_to_mode('custom') + local success = promise:wait() assert.is_true(success) assert.equal('custom', state.current_mode) @@ -471,17 +496,24 @@ describe('opencode.core', function() end) it('does not change current model when mode has no model configured', function() - stub(config_file, 'get_opencode_agents').returns({ 'plan', 'build' }) - stub(config_file, 'get_opencode_config').returns({ + local Promise = require('opencode.promise') + local agents_promise = Promise.new() + agents_promise:resolve({ 'plan', 'build' }) + local config_promise = Promise.new() + config_promise:resolve({ agent = { plan = {}, }, }) + + stub(config_file, 'get_opencode_agents').returns(agents_promise) + stub(config_file, 'get_opencode_config').returns(config_promise) state.current_mode = nil state.current_model = 'existing/model' - local success = core.switch_to_mode('plan') + local promise = core.switch_to_mode('plan') + local success = promise:wait() assert.is_true(success) assert.equal('plan', state.current_mode) @@ -492,9 +524,14 @@ describe('opencode.core', function() end) it('returns false when mode is invalid', function() - stub(config_file, 'get_opencode_agents').returns({ 'plan', 'build' }) + local Promise = require('opencode.promise') + local agents_promise = Promise.new() + agents_promise:resolve({ 'plan', 'build' }) + + stub(config_file, 'get_opencode_agents').returns(agents_promise) - local success = core.switch_to_mode('nonexistent') + local promise = core.switch_to_mode('nonexistent') + local success = promise:wait() assert.is_false(success) @@ -502,10 +539,12 @@ describe('opencode.core', function() end) it('returns false when mode is empty', function() - local success = core.switch_to_mode('') + local promise = core.switch_to_mode('') + local success = promise:wait() assert.is_false(success) - success = core.switch_to_mode(nil) + promise = core.switch_to_mode(nil) + success = promise:wait() assert.is_false(success) end) end) diff --git a/tests/unit/hooks_spec.lua b/tests/unit/hooks_spec.lua index 4c8b1eb6..7d65ebb5 100644 --- a/tests/unit/hooks_spec.lua +++ b/tests/unit/hooks_spec.lua @@ -127,7 +127,9 @@ describe('hooks', function() local session_module = require('opencode.session') local original_get_all = session_module.get_all_workspace_sessions session_module.get_all_workspace_sessions = function() - return { { id = 'test-session', title = 'Test' } } + local promise = require('opencode.promise').new() + promise:resolve({ { id = 'test-session', title = 'Test' } }) + return promise end state.subscribe('user_message_count', core._on_user_message_count_change) @@ -138,7 +140,9 @@ describe('hooks', function() state.user_message_count = { ['test-session'] = 0 } -- Wait for async notification - vim.wait(100, function() return called end) + vim.wait(100, function() + return called + end) -- Restore original function session_module.get_all_workspace_sessions = original_get_all @@ -184,7 +188,9 @@ describe('hooks', function() local session_module = require('opencode.session') local original_get_by_id = session_module.get_by_id session_module.get_by_id = function(id) - return { id = id, title = 'Test' } + local promise = require('opencode.promise').new() + promise:resolve({ id = id, title = 'Test' }) + return promise end -- Set up the subscription manually @@ -196,7 +202,9 @@ describe('hooks', function() state.current_permission = { tool = 'test_tool', action = 'read' } -- Wait for async notification - vim.wait(100, function() return called end) + vim.wait(100, function() + return called + end) -- Restore original function session_module.get_by_id = original_get_by_id diff --git a/tests/unit/session_spec.lua b/tests/unit/session_spec.lua index 1a6fcce8..46d76cbb 100644 --- a/tests/unit/session_spec.lua +++ b/tests/unit/session_spec.lua @@ -45,20 +45,20 @@ describe('opencode.session', function() original_api_client = state.api_client -- mock vim.fs and isdirectory config_file.get_opencode_project = function() - return { id = DEFAULT_WORKSPACE_ID } + local p = Promise.new() + p:resolve({ id = DEFAULT_WORKSPACE_ID }) + return p end vim.fs.dir = function(path) -- Return a mock directory listing - local session_dir = session.get_workspace_session_path() - if path:find(DEFAULT_WORKSPACE_ID, 1, true) then - if path == session_dir then - return coroutine.wrap(function() - for _, file in ipairs(session_files) do - coroutine.yield(file, 'file') - end - end) - end + -- Check if this is the session directory + if path:find(DEFAULT_WORKSPACE_ID, 1, true) and path:match('/session/') then + return coroutine.wrap(function() + for _, file in ipairs(session_files) do + coroutine.yield(file, 'file') + end + end) end if mock_data.message_files and path:match('/message/new%-8$') then return coroutine.wrap(function() @@ -85,12 +85,11 @@ describe('opencode.session', function() -- Mock the readfile function vim.fn.readfile = function(file) - local session_dir = session.get_workspace_session_path() local storage_path = session.get_storage_path() - -- Handle session info files - if vim.startswith(file, session_dir) then - local filename = file:sub(#session_dir + 2) + -- Handle session info files - check if file matches session directory pattern + if file:match('/session/') and file:match('%.json$') then + local filename = file:match('([^/]+)$') local session_name = filename:sub(1, -6) -- Remove '.json' extension if vim.tbl_contains(session_files, filename) then local data @@ -104,7 +103,7 @@ describe('opencode.session', function() end -- Handle message files - if mock_data.messages and vim.startswith(file, storage_path .. '/message/new-8/') then + if mock_data.messages and file:match('/message/new%-8/') then local msg_name = vim.fn.fnamemodify(file, ':t:r') if mock_data.messages[msg_name] then return vim.split(mock_data.messages[msg_name], '\n') @@ -215,7 +214,8 @@ describe('opencode.session', function() -- Using the default mock session list and workspace -- Call the function - local result = session.get_last_workspace_session() + local promise = session.get_last_workspace_session() + local result = promise:wait() -- Verify the result - should return "new-8" as it's the most recent assert.is_not_nil(result) @@ -229,7 +229,9 @@ describe('opencode.session', function() mock_data.workspace = NON_EXISTENT_WORKSPACE config_file.get_opencode_project = function() - return { id = NON_EXISTENT_WORKSPACE } + local p = Promise.new() + p:resolve({ id = NON_EXISTENT_WORKSPACE }) + return p end -- For this test, make it not a git project so filtering happens @@ -238,7 +240,8 @@ describe('opencode.session', function() end -- Call the function - local result = session.get_last_workspace_session() + local promise = session.get_last_workspace_session() + local result = promise:wait() -- Should be nil since no sessions match assert.is_nil(result) @@ -258,7 +261,8 @@ describe('opencode.session', function() -- Call the function inside pcall to catch the error local success, result = pcall(function() - return session.get_last_workspace_session() + local promise = session.get_last_workspace_session() + return promise:wait() end) -- Restore original function @@ -279,7 +283,8 @@ describe('opencode.session', function() mock_data.session_list = {} -- Call the function - local result = session.get_last_workspace_session() + local promise = session.get_last_workspace_session() + local result = promise:wait() -- Should be nil with empty list assert.is_nil(result) @@ -288,19 +293,47 @@ describe('opencode.session', function() describe('get_by_name', function() it('returns the session with matching ID', function() + -- Mock the get_session method + local original_get_session = state.api_client.get_session + state.api_client.get_session = function(self, id) + local p = Promise.new() + if id == 'new-8' then + local session_data = vim.trim(session_list_mock['new-8']) + local decoded = vim.json.decode(session_data) + p:resolve(decoded) + else + p:resolve(nil) + end + return p + end + -- Call the function with an ID from the mock data - local result = session.get_by_id('new-8') + local promise = session.get_by_id('new-8') + local result = promise:wait() -- Verify the result assert.is_not_nil(result) if result then assert.equal('new-8', result.id) end + + -- Restore + if original_get_session then + state.api_client.get_session = original_get_session + end end) it('returns nil when no session matches the ID', function() + -- Mock the get_session method + state.api_client.get_session = function(self, id) + local p = Promise.new() + p:resolve(nil) + return p + end + -- Call the function with non-existent ID - local result = session.get_by_id('nonexistent') + local promise = session.get_by_id('nonexistent') + local result = promise:wait() -- Should be nil since no sessions match assert.is_nil(result) @@ -328,6 +361,26 @@ describe('opencode.session', function() msg1 = '{"id": "msg1", "content": "test message"}', } + -- Update vim.fn.isdirectory to recognize this directory + vim.fn.isdirectory = function(path) + if path == dir or (mock_data.valid_dirs and vim.tbl_contains(mock_data.valid_dirs, path)) then + return 1 + end + return 0 + end + + -- Update vim.fs.dir to return the mock data for this specific path + vim.fs.dir = function(path) + if path == dir then + return coroutine.wrap(function() + for _, file in ipairs(mock_data.message_files) do + coroutine.yield(file, 'file') + end + end) + end + return original_fs_dir(path) + end + local result = util.read_json_dir(dir) assert.is_not_nil(result) if result then @@ -346,6 +399,26 @@ describe('opencode.session', function() invalid = 'not json', } + -- Update vim.fn.isdirectory to recognize this directory + vim.fn.isdirectory = function(path) + if path == dir or (mock_data.valid_dirs and vim.tbl_contains(mock_data.valid_dirs, path)) then + return 1 + end + return 0 + end + + -- Update vim.fs.dir to return the mock data for this specific path + vim.fs.dir = function(path) + if path == dir then + return coroutine.wrap(function() + for _, file in ipairs(mock_data.message_files) do + coroutine.yield(file, 'file') + end + end) + end + return original_fs_dir(path) + end + local result = util.read_json_dir(dir) assert.is_not_nil(result) if result then diff --git a/tests/unit/snapshot_spec.lua b/tests/unit/snapshot_spec.lua index 1cf7ada8..8390ea00 100644 --- a/tests/unit/snapshot_spec.lua +++ b/tests/unit/snapshot_spec.lua @@ -1,10 +1,13 @@ local snapshot = require('opencode.snapshot') local state = require('opencode.state') +local Promise = require('opencode.promise') +local config_file = require('opencode.config_file') -- Save originals to restore after tests local orig_notify = vim.notify local orig_system = vim.system local orig_getcwd = vim.fn.getcwd +local orig_get_workspace_snapshot_path = config_file.get_workspace_snapshot_path describe('snapshot.restore', function() local system_calls = {} @@ -33,6 +36,13 @@ describe('snapshot.restore', function() return '/mock/project/root' end + -- Mock config_file.get_workspace_snapshot_path to return a resolved promise + config_file.get_workspace_snapshot_path = function() + local p = Promise.new() + p:resolve('/mock/gitdir') + return p + end + state.active_session = { snapshot_path = '/mock/gitdir' } vim.g._last_notify = nil vim.g._last_system = nil @@ -42,6 +52,7 @@ describe('snapshot.restore', function() vim.notify = orig_notify vim.system = orig_system vim.fn.getcwd = orig_getcwd + config_file.get_workspace_snapshot_path = orig_get_workspace_snapshot_path state.active_session = nil vim.g._last_notify = nil vim.g._last_system = nil @@ -67,6 +78,12 @@ describe('snapshot.restore', function() it('notifies error if no active session', function() state.active_session = nil + -- When there's no active session, the promise still resolves but snapshot_git will fail + config_file.get_workspace_snapshot_path = function() + local p = Promise.new() + p:resolve(nil) + return p + end snapshot.restore('abc123') assert.is_truthy(vim.g._last_notify) -- Should match either "No snapshot path" or "Failed to read-tree" depending on implementation From bfb1d76ad5d7e9155c21bff34c70d32e7ab74d9c Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Tue, 9 Dec 2025 08:33:12 -0500 Subject: [PATCH 07/13] fix: completion async --- .../ui/completion/engines/blink_cmp.lua | 96 ++++++++++--------- .../ui/completion/engines/nvim_cmp.lua | 93 +++++++++--------- .../ui/completion/engines/vim_complete.lua | 93 +++++++++--------- lua/opencode/ui/completion/files.lua | 2 +- 4 files changed, 146 insertions(+), 138 deletions(-) diff --git a/lua/opencode/ui/completion/engines/blink_cmp.lua b/lua/opencode/ui/completion/engines/blink_cmp.lua index 55e05a57..a13765b8 100644 --- a/lua/opencode/ui/completion/engines/blink_cmp.lua +++ b/lua/opencode/ui/completion/engines/blink_cmp.lua @@ -26,59 +26,61 @@ function Source:is_available() return vim.bo.filetype == 'opencode' end -Source.get_completions = Promise.async(function(self, ctx, callback) - local completion = require('opencode.ui.completion') - local completion_sources = completion.get_sources() - - local line = ctx.line - local col = ctx.cursor[2] + 1 - local before_cursor = line:sub(1, col - 1) +function Source:get_completions(ctx, callback) + Promise.spawn(function() + local completion = require('opencode.ui.completion') + local completion_sources = completion.get_sources() - local trigger_chars = table.concat(vim.tbl_map(vim.pesc, self:get_trigger_characters()), '') - local trigger_char, trigger_match = before_cursor:match('([' .. trigger_chars .. '])([%w_/%-%.]*)$') + local line = ctx.line + local col = ctx.cursor[2] + 1 + local before_cursor = line:sub(1, col - 1) - if not trigger_match then - callback({ is_incomplete_forward = false, items = {} }) - return - end + local trigger_chars = table.concat(vim.tbl_map(vim.pesc, self:get_trigger_characters()), '') + local trigger_char, trigger_match = before_cursor:match('([' .. trigger_chars .. '])([%w_/%-%.]*)$') - local context = { - input = trigger_match, - cursor_pos = col, - line = line, - trigger_char = trigger_char, - } + if not trigger_match then + callback({ is_incomplete_forward = false, items = {} }) + return + end - local items = {} - for _, completion_source in ipairs(completion_sources) do - local source_items = completion_source.complete(context):await() - for i, item in ipairs(source_items) do - table.insert(items, { - label = item.label, - kind = item.kind, - kind_icon = item.kind_icon, - kind_hl = item.kind_hl, - detail = item.detail, - documentation = item.documentation, - insertText = item.insert_text or item.label, - sortText = string.format( - '%02d_%02d_%02d_%s', - completion_source.priority or 999, - item.priority or 999, - i, - item.label - ), - score_offset = -(completion_source.priority or 999) * 1000 + (item.priority or 999), - - data = { - original_item = item, - }, - }) + local context = { + input = trigger_match, + cursor_pos = col, + line = line, + trigger_char = trigger_char, + } + + local items = {} + for _, completion_source in ipairs(completion_sources) do + local source_items = completion_source.complete(context):await() + for i, item in ipairs(source_items) do + table.insert(items, { + label = item.label, + kind = item.kind, + kind_icon = item.kind_icon, + kind_hl = item.kind_hl, + detail = item.detail, + documentation = item.documentation, + insertText = item.insert_text or item.label, + sortText = string.format( + '%02d_%02d_%02d_%s', + completion_source.priority or 999, + item.priority or 999, + i, + item.label + ), + score_offset = -(completion_source.priority or 999) * 1000 + (item.priority or 999), + + data = { + original_item = item, + }, + }) + end end - end - callback({ is_incomplete_forward = true, is_incomplete_backward = true, items = items }) -end) + callback({ is_incomplete_forward = true, is_incomplete_backward = true, items = items }) + end) +end function Source:execute(ctx, item, callback, default_implementation) default_implementation() diff --git a/lua/opencode/ui/completion/engines/nvim_cmp.lua b/lua/opencode/ui/completion/engines/nvim_cmp.lua index 45a6f6a9..8d6b6480 100644 --- a/lua/opencode/ui/completion/engines/nvim_cmp.lua +++ b/lua/opencode/ui/completion/engines/nvim_cmp.lua @@ -1,3 +1,4 @@ +local Promise = require('opencode.promise') local M = {} function M.setup(completion_sources) @@ -27,57 +28,59 @@ function M.setup(completion_sources) return vim.bo.filetype == 'opencode' end - source.complete = Promise.async(function(self, params, callback) - local line = params.context.cursor_line + function source:complete(params, callback) + Promise.spawn(function() + local line = params.context.cursor_line - local col = params.context.cursor.col - local before_cursor = line:sub(1, col - 1) + local col = params.context.cursor.col + local before_cursor = line:sub(1, col - 1) - local trigger_chars = table.concat(vim.tbl_map(vim.pesc, self:get_trigger_characters()), '') - local trigger_char, trigger_match = before_cursor:match('.*([' .. trigger_chars .. '])([%w_%-%.]*)') + local trigger_chars = table.concat(vim.tbl_map(vim.pesc, self:get_trigger_characters()), '') + local trigger_char, trigger_match = before_cursor:match('.*([' .. trigger_chars .. '])([%w_%-%.]*)') - if not trigger_match then - callback({ items = {}, isIncomplete = false }) - return - end - - local context = { - input = trigger_match, - cursor_pos = col, - line = line, - trigger_char = trigger_char, - } + if not trigger_match then + callback({ items = {}, isIncomplete = false }) + return + end - local items = {} - for _, completion_source in ipairs(completion_sources) do - local source_items = completion_source.complete(context):await() - for j, item in ipairs(source_items) do - table.insert(items, { - label = item.label, - kind = 1, - cmp = { - kind_text = item.kind_icon, - }, - kind_hl_group = item.kind_hl, - detail = item.detail, - documentation = item.documentation, - insertText = item.insert_text or '', - sortText = string.format( - '%02d_%02d_%02d_%s', - completion_source.priority or 999, - item.priority or 999, - j, - item.label - ), - data = { - original_item = item, - }, - }) + local context = { + input = trigger_match, + cursor_pos = col, + line = line, + trigger_char = trigger_char, + } + + local items = {} + for _, completion_source in ipairs(completion_sources) do + local source_items = completion_source.complete(context):await() + for j, item in ipairs(source_items) do + table.insert(items, { + label = item.label, + kind = 1, + cmp = { + kind_text = item.kind_icon, + }, + kind_hl_group = item.kind_hl, + detail = item.detail, + documentation = item.documentation, + insertText = item.insert_text or '', + sortText = string.format( + '%02d_%02d_%02d_%s', + completion_source.priority or 999, + item.priority or 999, + j, + item.label + ), + data = { + original_item = item, + }, + }) + end end - end - callback({ items = items, isIncomplete = true }) - end) + callback({ items = items, isIncomplete = true }) + end) + end cmp.register_source('opencode_mentions', source.new()) diff --git a/lua/opencode/ui/completion/engines/vim_complete.lua b/lua/opencode/ui/completion/engines/vim_complete.lua index 4bdcbcde..b41d40b4 100644 --- a/lua/opencode/ui/completion/engines/vim_complete.lua +++ b/lua/opencode/ui/completion/engines/vim_complete.lua @@ -1,3 +1,4 @@ +local Promise = require('lua.opencode.promise') local M = {} local completion_active = false @@ -58,59 +59,61 @@ function M.trigger(trigger_char) end function M._update() - if not completion_active then - return - end - - local line = vim.api.nvim_get_current_line() - local col = vim.api.nvim_win_get_cursor(0)[2] - local before_cursor = line:sub(1, col) - local trigger_char, trigger_match = M._get_trigger(before_cursor) + Promise.spawn(function() + if not completion_active then + return + end - if not trigger_char then - completion_active = false - return - end + local line = vim.api.nvim_get_current_line() + local col = vim.api.nvim_win_get_cursor(0)[2] + local before_cursor = line:sub(1, col) + local trigger_char, trigger_match = M._get_trigger(before_cursor) - local context = { - input = trigger_match, - cursor_pos = col + 1, - line = line, - trigger_char = trigger_char, - } + if not trigger_char then + completion_active = false + return + end - local items = {} - for _, source in ipairs(M._completion_sources or {}) do - local source_items = source.complete(context) - for i, item in ipairs(source_items) do - if vim.startswith(item.insert_text or '', trigger_char) then - item.insert_text = item.insert_text:sub(2) + local context = { + input = trigger_match, + cursor_pos = col + 1, + line = line, + trigger_char = trigger_char, + } + + local items = {} + for _, source in ipairs(M._completion_sources or {}) do + local source_items = source.complete(context):await() + for i, item in ipairs(source_items) do + if vim.startswith(item.insert_text or '', trigger_char) then + item.insert_text = item.insert_text:sub(2) + end + local source_priority = source.priority or 999 + local item_priority = item.priority or 999 + table.insert(items, { + word = #item.insert_text > 0 and item.insert_text or item.label, + abbr = (item.kind_icon or '') .. item.label, + menu = source.name, + kind = item.kind:sub(1, 1):upper(), + user_data = item, + _sort_text = string.format('%02d_%02d_%02d_%s', source_priority, item_priority, i, item.label), + }) end - local source_priority = source.priority or 999 - local item_priority = item.priority or 999 - table.insert(items, { - word = #item.insert_text > 0 and item.insert_text or item.label, - abbr = (item.kind_icon or '') .. item.label, - menu = source.name, - kind = item.kind:sub(1, 1):upper(), - user_data = item, - _sort_text = string.format('%02d_%02d_%02d_%s', source_priority, item_priority, i, item.label), - }) end - end - table.sort(items, function(a, b) - return a._sort_text < b._sort_text - end) + table.sort(items, function(a, b) + return a._sort_text < b._sort_text + end) - if #items > 0 then - local start_col = before_cursor:find(vim.pesc(trigger_char) .. '[%w_%-%.]*$') - if start_col then - vim.fn.complete(start_col + 1, items) + if #items > 0 then + local start_col = before_cursor:find(vim.pesc(trigger_char) .. '[%w_%-%.]*$') + if start_col then + vim.fn.complete(start_col + 1, items) + end + else + completion_active = false end - else - completion_active = false - end + end) end M.on_complete = function() diff --git a/lua/opencode/ui/completion/files.lua b/lua/opencode/ui/completion/files.lua index 04264328..f37b3cf9 100644 --- a/lua/opencode/ui/completion/files.lua +++ b/lua/opencode/ui/completion/files.lua @@ -121,7 +121,7 @@ local file_source = { return {} end - local recent_files = #input < 1 and M.get_recent_files() or {} + local recent_files = #input < 1 and M.get_recent_files():await() or {} if #recent_files >= 5 then return recent_files end From e092046850e0a08e4819aaae5e03cc6ee10fea0a Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Tue, 9 Dec 2025 09:11:18 -0500 Subject: [PATCH 08/13] fix: core review comments --- lua/opencode/api.lua | 46 ++++++++++--------- lua/opencode/core.lua | 4 +- lua/opencode/promise.lua | 33 ++++++++----- lua/opencode/session.lua | 2 +- lua/opencode/ui/completion/commands.lua | 2 +- lua/opencode/ui/completion/engines/base.lua | 12 +++-- .../ui/completion/engines/vim_complete.lua | 2 +- lua/opencode/ui/debug_helper.lua | 7 +-- lua/opencode/ui/session_picker.lua | 3 +- lua/opencode/ui/topbar.lua | 8 ++-- 10 files changed, 68 insertions(+), 51 deletions(-) diff --git a/lua/opencode/api.lua b/lua/opencode/api.lua index 729e7ce5..600558cd 100644 --- a/lua/opencode/api.lua +++ b/lua/opencode/api.lua @@ -334,6 +334,7 @@ function M.debug_session() debug_helper.debug_session() end +---@type fun(): Promise M.initialize = Promise.async(function() local id = require('opencode.id') @@ -368,8 +369,8 @@ function M.agent_build() require('opencode.core').switch_to_mode('build') end -function M.select_agent() - local modes = config_file.get_opencode_agents() +M.select_agent = Promise.async(function() + local modes = config_file.get_opencode_agents():await() vim.ui.select(modes, { prompt = 'Select mode:', }, function(selection) @@ -379,10 +380,10 @@ function M.select_agent() require('opencode.core').switch_to_mode(selection) end) -end +end) -function M.switch_mode() - local modes = config_file.get_opencode_agents() --[[@as string[] ]] +M.switch_mode = Promise.async(function() + local modes = config_file.get_opencode_agents():await() --[[@as string[] ]] local current_index = util.index_of(modes, state.current_mode) @@ -394,7 +395,7 @@ function M.switch_mode() local next_index = (current_index % #modes) + 1 require('opencode.core').switch_to_mode(modes[next_index]) -end +end) function M.with_header(lines, show_welcome) show_welcome = show_welcome or show_welcome @@ -506,7 +507,7 @@ M.mcp = Promise.async(function() end) function M.commands_list() - local commands = config_file.get_user_commands():await() + local commands = config_file.get_user_commands():wait() if not commands then vim.notify('No user commands found. Please check your opencode config file.', vim.log.levels.WARN) return @@ -566,7 +567,7 @@ M.run_user_command = Promise.async(function(name, args) require('opencode.history').write('/' .. name .. ' ' .. table.concat(args or {}, ' ')) end) end) - end) + end) --[[@as Promise ]] end) --- Compacts the current session by removing unnecessary data. @@ -754,7 +755,7 @@ M.rename_session = Promise.async(function(current_session, new_title) vim.notify('Failed to rename session: ' .. vim.inspect(err), vim.log.levels.ERROR) end) end) - :and_then(function() + :and_then(Promise.async(function() current_session.title = title if state.active_session and state.active_session.id == current_session.id then local session_obj = session.get_by_id(current_session.id):await() @@ -764,7 +765,7 @@ M.rename_session = Promise.async(function(current_session, new_title) end end promise:resolve(current_session) - end) + end)) end if new_title and new_title ~= '' then @@ -967,18 +968,20 @@ M.commands = { fn = function(args) local subcmd = args[1] if subcmd == 'new' then - local title = table.concat(vim.list_slice(args, 2), ' ') - if title and title ~= '' then - local new_session = core.create_new_session(title) - if not new_session then - vim.notify('Failed to create new session', vim.log.levels.ERROR) - return + Promise.spawn(function() + local title = table.concat(vim.list_slice(args, 2), ' ') + if title and title ~= '' then + local new_session = core.create_new_session(title):await() + if not new_session then + vim.notify('Failed to create new session', vim.log.levels.ERROR) + return + end + state.active_session = new_session + M.open_input() + else + M.open_input_new_session() end - state.active_session = new_session:await() - M.open_input() - else - M.open_input_new_session() - end + end) elseif subcmd == 'select' then M.select_session() elseif subcmd == 'child' then @@ -1230,6 +1233,7 @@ M.slash_commands_map = { ['/timeline'] = { fn = M.timeline, desc = 'Open timeline picker' }, ['/undo'] = { fn = M.undo, desc = 'Undo last action' }, ['/unshare'] = { fn = M.unshare, desc = 'Unshare current session' }, + ['/rename'] = { fn = M.rename_session, desc = 'Rename current session' }, } M.legacy_command_map = { diff --git a/lua/opencode/core.lua b/lua/opencode/core.lua index d241317f..8d9ad5c5 100644 --- a/lua/opencode/core.lua +++ b/lua/opencode/core.lua @@ -124,9 +124,9 @@ M.open = Promise.async(function(opts) if not ok then vim.notify('Error opening panel: ' .. tostring(err), vim.log.levels.ERROR) - return Promise:new():reject(err) + return Promise.new():reject(err) end - return Promise:new():resolve('ok') + return Promise.new():resolve('ok') end) --- Sends a message to the active session, creating one if necessary. diff --git a/lua/opencode/promise.lua b/lua/opencode/promise.lua index 40630b54..4dd959e5 100644 --- a/lua/opencode/promise.lua +++ b/lua/opencode/promise.lua @@ -1,15 +1,32 @@ ---@generic T +---@generic U ---@class Promise +---@field __index Promise ---@field _resolved boolean ---@field _value T ---@field _error any ---@field _then_callbacks fun(value: T)[] ---@field _catch_callbacks fun(err: any)[] ---@field _coroutines thread[] +---@field new fun(): Promise +---@field resolve fun(self: Promise, value: T): Promise +---@field reject fun(self: Promise, err: any): Promise +---@field and_then fun(self: Promise, callback: fun(value: T): U | Promise | nil): Promise +---@field catch fun(self: Promise, error_callback: fun(err: any): any | Promise | nil): Promise +---@field wait fun(self: Promise, timeout?: integer, interval?: integer): T +---@field peek fun(self: Promise): T +---@field is_resolved fun(self: Promise): boolean +---@field is_rejected fun(self: Promise): boolean +---@field await fun(self: Promise): T +---@field is_promise fun(obj: any): boolean +---@field wrap fun(obj: T | Promise): Promise +---@field spawn fun(fn: fun(): T|nil): Promise +---@field async fun(fn: fun(...): T?): fun(...): Promise local Promise = {} Promise.__index = Promise ---Resume waiting coroutines with result +---@generic T ---@param coroutines thread[] ---@param value T ---@param err any @@ -37,7 +54,6 @@ function Promise.new() return self end ----@param self Promise ---@param value T ---@return Promise function Promise:resolve(value) @@ -59,7 +75,6 @@ function Promise:resolve(value) return self end ----@param self Promise ---@param err any ---@return Promise function Promise:reject(err) @@ -82,9 +97,8 @@ function Promise:reject(err) end ---@generic U ----@param self Promise ----@param callback fun(value: T): U | Promise ----@return Promise +---@param callback fun(value: T): U | Promise | nil +---@return Promise? function Promise:and_then(callback) if not callback then error('callback is required') @@ -127,8 +141,7 @@ function Promise:and_then(callback) return new_promise end ----@param self Promise ----@param error_callback fun(err: any): any | Promise +---@param error_callback fun(err: any): any | Promise | nil ---@return Promise function Promise:catch(error_callback) local new_promise = Promise.new() @@ -174,7 +187,6 @@ end --- This will block the main thread, so use with caution --- But is useful for synchronous code paths that need the result ---@generic T ----@param self Promise ---@param timeout integer|nil Timeout in milliseconds (default: 5000) ---@param interval integer|nil Interval in milliseconds to check (default: 20) ---@return T @@ -207,7 +219,6 @@ end -- Tries to get the value without waiting -- Useful for status checks where you don't want to block ---@generic T ----@param self Promise ---@return T function Promise:peek() return self._value @@ -225,7 +236,6 @@ end ---This function can only be called from within `coroutine.create` or `Promise.spawn` or `Promise.async` ---This will yield the coroutine until the promise resolves or rejects ---@generic T ----@param self Promise ---@return T function Promise:await() -- If already resolved, return immediately @@ -267,9 +277,10 @@ end ---@param obj T | Promise ---@return Promise +---@return_cast T Promise function Promise.wrap(obj) if Promise.is_promise(obj) then - return obj + return obj --[[@as Promise]] else return Promise.new():resolve(obj) end diff --git a/lua/opencode/session.lua b/lua/opencode/session.lua index 5ac7e98a..e7ff227e 100644 --- a/lua/opencode/session.lua +++ b/lua/opencode/session.lua @@ -25,7 +25,7 @@ end ---Get the session storage path for the current workspace ---@return string M.get_workspace_session_path = Promise.async(function(project_id) - project_id = project_id or M.project_id() or '' + project_id = project_id or M.project_id():await() or '' local home = vim.uv.os_homedir() return home .. '/.local/share/opencode/storage/session/' .. project_id end) diff --git a/lua/opencode/ui/completion/commands.lua b/lua/opencode/ui/completion/commands.lua index 016717a5..fb524564 100644 --- a/lua/opencode/ui/completion/commands.lua +++ b/lua/opencode/ui/completion/commands.lua @@ -1,7 +1,7 @@ local Promise = require('opencode.promise') local M = {} -get_available_commands = Promise.async(function() +local get_available_commands = Promise.async(function() local api = require('opencode.api') local commands = api.get_slash_commands():await() diff --git a/lua/opencode/ui/completion/engines/base.lua b/lua/opencode/ui/completion/engines/base.lua index 9b735d45..36bc8810 100644 --- a/lua/opencode/ui/completion/engines/base.lua +++ b/lua/opencode/ui/completion/engines/base.lua @@ -1,3 +1,5 @@ +local Promise = require('opencode.promise') + ---@class CompletionEngine ---@field name string The name identifier of the completion engine ---@field _completion_sources table[]|nil Internal array of registered completion sources @@ -53,7 +55,7 @@ end ---Parse trigger characters from text before cursor ---Identifies trigger characters and extracts the completion query text ----@param before_cursor string Text from line start to cursor position +---@param before_cursor string Text from line start to cursor position ---@return string|nil trigger_char The trigger character found (e.g., '@', '/') ---@return string|nil trigger_match The text after the trigger character function CompletionEngine:parse_trigger(before_cursor) @@ -67,10 +69,10 @@ end ---Queries all completion sources and formats their responses into a unified structure ---@param context table Completion context containing input, cursor_pos, line, trigger_char ---@return table[] Array of wrapped completion items with metadata -function CompletionEngine:get_completion_items(context) +CompletionEngine.get_completion_items = Promsise.async(function(context) local items = {} for _, source in ipairs(self._completion_sources or {}) do - local source_items = source.complete(context) + local source_items = source.complete(context):await() for i, item in ipairs(source_items) do local source_priority = source.priority or 999 local item_priority = item.priority or 999 @@ -84,7 +86,7 @@ function CompletionEngine:get_completion_items(context) end end return items -end +end) ---Setup the completion engine with completion sources ---Base implementation stores sources. Child classes should call this via super @@ -106,7 +108,7 @@ end ---Handle completion item selection ---Called when a completion item is selected by the user ----Delegates to the completion module's on_complete handler +---Delegates to the completion module's on_complete handler ---@param original_item table The original completion item that was selected function CompletionEngine:on_complete(original_item) local completion = require('opencode.ui.completion') diff --git a/lua/opencode/ui/completion/engines/vim_complete.lua b/lua/opencode/ui/completion/engines/vim_complete.lua index b41d40b4..891b0353 100644 --- a/lua/opencode/ui/completion/engines/vim_complete.lua +++ b/lua/opencode/ui/completion/engines/vim_complete.lua @@ -1,4 +1,4 @@ -local Promise = require('lua.opencode.promise') +local Promise = require('opencode.promise') local M = {} local completion_active = false diff --git a/lua/opencode/ui/debug_helper.lua b/lua/opencode/ui/debug_helper.lua index 4dfce79b..f1c2b301 100644 --- a/lua/opencode/ui/debug_helper.lua +++ b/lua/opencode/ui/debug_helper.lua @@ -7,6 +7,7 @@ local M = {} local state = require('opencode.state') +local Promise = require('opencode.promise') function M.open_json_file(data) local tmpfile = vim.fn.tempname() .. '.json' @@ -47,10 +48,10 @@ function M.debug_message() vim.notify('No message found in previous lines', vim.log.levels.WARN) end -function M.debug_session() +M.debug_session = Promise.async(function() local session = require('opencode.session') - local session_path = session.get_workspace_session_path() + local session_path = session.get_workspace_session_path():await() if not state.active_session then print('No active session') return @@ -59,7 +60,7 @@ function M.debug_session() vim.api.nvim_set_current_win(state.last_code_win_before_opencode --[[@as integer]]) end vim.cmd('e ' .. session_path .. '/' .. state.active_session.id .. '.json') -end +end) function M.save_captured_events(filename) if not state.event_manager then diff --git a/lua/opencode/ui/session_picker.lua b/lua/opencode/ui/session_picker.lua index 6362bf9b..795b963b 100644 --- a/lua/opencode/ui/session_picker.lua +++ b/lua/opencode/ui/session_picker.lua @@ -10,7 +10,8 @@ local Promise = require('opencode.promise') ---@return PickerItem function format_session_item(session, width) local debug_text = 'ID: ' .. (session.id or 'N/A') - return base_picker.create_picker_item(session.title, session.time.updated, debug_text, width) + local updated_time = (session.time and session.time.updated) or 'N/A' + return base_picker.create_picker_item(session.title, updated_time, debug_text, width) end function M.pick(sessions, callback) diff --git a/lua/opencode/ui/topbar.lua b/lua/opencode/ui/topbar.lua index adf983a2..695f836f 100644 --- a/lua/opencode/ui/topbar.lua +++ b/lua/opencode/ui/topbar.lua @@ -63,7 +63,7 @@ local function update_winbar_highlights(win_id) table.insert(parts, 'Normal:OpencodeNormal') end - table.insert(parts, 'WinBar:OpencodeSessionDecription') + table.insert(parts, 'WinBar:OpencodeSessionDescription') table.insert(parts, 'WinBarNC:OpencodeSessionDescription') vim.api.nvim_set_option_value('winhighlight', table.concat(parts, ','), { win = win_id }) @@ -76,10 +76,8 @@ local function get_session_desc() local session_title = LABELS.NEW_SESSION_TITLE - if state.active_session then - if state.active_session and state.active_session ~= '' then - session_title = state.active_session.title - end + if state.active_session and state.active_session.title ~= '' then + session_title = state.active_session.title end if not session_title or type(session_title) ~= 'string' then From f07011d3234ff6b250cee84db5e384c2962b73ed Mon Sep 17 00:00:00 2001 From: Cameron Ring Date: Tue, 9 Dec 2025 17:19:05 -0800 Subject: [PATCH 09/13] fix(core): small delay on open Caused by vim.system():wait() delay when checking opencode version. Fixed by adding Promise.system() which wraps vim.system in a promise (using vim.system's callback mechanism) --- lua/opencode/core.lua | 6 +++--- lua/opencode/promise.lua | 20 ++++++++++++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/lua/opencode/core.lua b/lua/opencode/core.lua index 8d9ad5c5..f68af3e8 100644 --- a/lua/opencode/core.lua +++ b/lua/opencode/core.lua @@ -296,7 +296,7 @@ M.cancel = Promise.async(function() end end) -function M.opencode_ok() +M.opencode_ok = Promise.async(function() if vim.fn.executable('opencode') == 0 then vim.notify( 'opencode command not found - please install and configure opencode before using this plugin', @@ -306,7 +306,7 @@ function M.opencode_ok() end if not state.opencode_cli_version or state.opencode_cli_version == '' then - local result = vim.system({ 'opencode', '--version' }):wait() + local result = Promise.system({ 'opencode', '--version' }):await() local out = (result and result.stdout or ''):gsub('%s+$', '') state.opencode_cli_version = out:match('(%d+%%.%d+%%.%d+)') or out end @@ -328,7 +328,7 @@ function M.opencode_ok() end return true -end +end) local function on_opencode_server() state.current_permission = nil diff --git a/lua/opencode/promise.lua b/lua/opencode/promise.lua index 4dd959e5..ea5d04c9 100644 --- a/lua/opencode/promise.lua +++ b/lua/opencode/promise.lua @@ -22,6 +22,7 @@ ---@field wrap fun(obj: T | Promise): Promise ---@field spawn fun(fn: fun(): T|nil): Promise ---@field async fun(fn: fun(...): T?): fun(...): Promise +---@field system fun() local Promise = {} Promise.__index = Promise @@ -336,4 +337,23 @@ function Promise.async(fn) end end +---Wrap vim.system in a promise +---@generic T +---@param cmd table vim.system cmd options +---@param opts table|nil vim.system opts +---@return Promise +function Promise.system(cmd, opts) + local p = Promise.new() + + vim.system(cmd, opts or {}, function(result) + if result.code == 0 then + p:resolve(result) + else + p:reject(result) + end + end) + + return p +end + return Promise From c9412054bc216ac9293064996dc7f65b468f6223 Mon Sep 17 00:00:00 2001 From: Cameron Ring Date: Tue, 9 Dec 2025 17:32:48 -0800 Subject: [PATCH 10/13] test(core): fix opencode_ok tests Also fix type in Promise --- lua/opencode/promise.lua | 2 +- tests/unit/core_spec.lua | 31 ++++++++++++++++++------------- 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/lua/opencode/promise.lua b/lua/opencode/promise.lua index ea5d04c9..869b7ca6 100644 --- a/lua/opencode/promise.lua +++ b/lua/opencode/promise.lua @@ -22,7 +22,7 @@ ---@field wrap fun(obj: T | Promise): Promise ---@field spawn fun(fn: fun(): T|nil): Promise ---@field async fun(fn: fun(...): T?): fun(...): Promise ----@field system fun() +---@field system fun(table, table): Promise local Promise = {} Promise.__index = Promise diff --git a/tests/unit/core_spec.lua b/tests/unit/core_spec.lua index 21908918..98819463 100644 --- a/tests/unit/core_spec.lua +++ b/tests/unit/core_spec.lua @@ -200,7 +200,7 @@ describe('opencode.core', function() it('resets is_opening flag when error occurs', function() state.windows = nil state.is_opening = false - + -- Simply cause an error by stubbing a function that will be called local original_create_new_session = core.create_new_session core.create_new_session = function() @@ -209,7 +209,7 @@ describe('opencode.core', function() local notify_stub = stub(vim, 'notify') local result_promise = core.open({ new_session = true, focus = 'input' }) - + -- Wait for async operations to complete local ok, err = pcall(function() result_promise:wait() @@ -218,13 +218,13 @@ describe('opencode.core', function() -- Should fail due to the error assert.is_false(ok) assert.truthy(err) - + -- is_opening should be reset to false even when error occurs assert.is_false(state.is_opening) - + -- Should have notified about the error assert.stub(notify_stub).was_called() - + -- Restore original function core.create_new_session = original_create_new_session notify_stub:revert() @@ -405,7 +405,12 @@ describe('opencode.core', function() local saved_cli local function mock_vim_system(result) - return function(_cmd, _opts) + return function(_cmd, _opts, on_exit) + if on_exit then + result.code = 0 + on_exit(result) + end + return { wait = function() return result @@ -430,7 +435,7 @@ describe('opencode.core', function() vim.fn.executable = function(_) return 0 end - assert.is_false(core.opencode_ok()) + assert.is_false(core.opencode_ok():await()) end) it('returns false when version is below required', function() @@ -440,7 +445,7 @@ describe('opencode.core', function() vim.system = mock_vim_system({ stdout = 'opencode 0.4.1' }) state.opencode_cli_version = nil state.required_version = '0.4.2' - assert.is_false(core.opencode_ok()) + assert.is_false(core.opencode_ok():await()) end) it('returns true when version equals required', function() @@ -450,7 +455,7 @@ describe('opencode.core', function() vim.system = mock_vim_system({ stdout = 'opencode 0.4.2' }) state.opencode_cli_version = nil state.required_version = '0.4.2' - assert.is_true(core.opencode_ok()) + assert.is_true(core.opencode_ok():await()) end) it('returns true when version is above required', function() @@ -460,7 +465,7 @@ describe('opencode.core', function() vim.system = mock_vim_system({ stdout = 'opencode 0.5.0' }) state.opencode_cli_version = nil state.required_version = '0.4.2' - assert.is_true(core.opencode_ok()) + assert.is_true(core.opencode_ok():await()) end) end) @@ -477,7 +482,7 @@ describe('opencode.core', function() }, }, }) - + stub(config_file, 'get_opencode_agents').returns(agents_promise) stub(config_file, 'get_opencode_config').returns(config_promise) @@ -505,7 +510,7 @@ describe('opencode.core', function() plan = {}, }, }) - + stub(config_file, 'get_opencode_agents').returns(agents_promise) stub(config_file, 'get_opencode_config').returns(config_promise) @@ -527,7 +532,7 @@ describe('opencode.core', function() local Promise = require('opencode.promise') local agents_promise = Promise.new() agents_promise:resolve({ 'plan', 'build' }) - + stub(config_file, 'get_opencode_agents').returns(agents_promise) local promise = core.switch_to_mode('nonexistent') From 40dd8e97f6c654fb1c294fe88cc93af1f5a0844e Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Wed, 10 Dec 2025 06:52:19 -0500 Subject: [PATCH 11/13] fix: context bar updates after message --- lua/opencode/ui/context_bar.lua | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/lua/opencode/ui/context_bar.lua b/lua/opencode/ui/context_bar.lua index 22c11f30..9b6bcd2d 100644 --- a/lua/opencode/ui/context_bar.lua +++ b/lua/opencode/ui/context_bar.lua @@ -164,9 +164,12 @@ local function update_winbar_highlights(win_id) end function M.setup() - state.subscribe({ 'current_context_config', 'current_code_buf', 'opencode_focused', 'context_updated_at' }, function() - M.render() - end) + state.subscribe( + { 'current_context_config', 'current_code_buf', 'opencode_focused', 'context_updated_at', 'user_message_count' }, + function() + M.render() + end + ) end function M.render(windows) From c019af68adb0d1c70b200a2b1204330115466385 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Wed, 10 Dec 2025 07:06:41 -0500 Subject: [PATCH 12/13] fix: base completion, self referencing --- lua/opencode/ui/completion/engines/base.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/opencode/ui/completion/engines/base.lua b/lua/opencode/ui/completion/engines/base.lua index 36bc8810..7f7ff261 100644 --- a/lua/opencode/ui/completion/engines/base.lua +++ b/lua/opencode/ui/completion/engines/base.lua @@ -69,7 +69,7 @@ end ---Queries all completion sources and formats their responses into a unified structure ---@param context table Completion context containing input, cursor_pos, line, trigger_char ---@return table[] Array of wrapped completion items with metadata -CompletionEngine.get_completion_items = Promsise.async(function(context) +CompletionEngine.get_completion_items = Promise.async(function(self, context) local items = {} for _, source in ipairs(self._completion_sources or {}) do local source_items = source.complete(context):await() From d0f55d619f3167ae8d933645266ec625856524d2 Mon Sep 17 00:00:00 2001 From: Cameron Ring Date: Wed, 10 Dec 2025 13:17:47 -0800 Subject: [PATCH 13/13] fix(input_window): user slash commands were broken Also, make commands_list async --- lua/opencode/api.lua | 10 +++++----- lua/opencode/ui/input_window.lua | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/lua/opencode/api.lua b/lua/opencode/api.lua index 600558cd..4e03ea09 100644 --- a/lua/opencode/api.lua +++ b/lua/opencode/api.lua @@ -262,7 +262,7 @@ function M.prev_message() require('opencode.ui.navigation').goto_prev_message() end -function M.submit_input_prompt() +M.submit_input_prompt = Promise.async(function() if state.display_route then -- we're displaying /help or something similar, need to clear that and refresh -- the session data before sending the command @@ -271,7 +271,7 @@ function M.submit_input_prompt() end input_window.handle_submit() -end +end) function M.mention_file() local picker = require('opencode.ui.file_picker') @@ -506,8 +506,8 @@ M.mcp = Promise.async(function() ui.render_lines(msg) end) -function M.commands_list() - local commands = config_file.get_user_commands():wait() +M.commands_list = Promise.async(function() + local commands = config_file.get_user_commands():await() if not commands then vim.notify('No user commands found. Please check your opencode config file.', vim.log.levels.WARN) return @@ -530,7 +530,7 @@ function M.commands_list() table.insert(msg, '') ui.render_lines(msg) -end +end) M.current_model = Promise.async(function() return core.initialize_current_model() diff --git a/lua/opencode/ui/input_window.lua b/lua/opencode/ui/input_window.lua index f66532ef..59f5e5bc 100644 --- a/lua/opencode/ui/input_window.lua +++ b/lua/opencode/ui/input_window.lua @@ -76,7 +76,7 @@ function M.handle_submit() end M._execute_slash_command = function(command) - local slash_commands = require('opencode.api').get_slash_commands() + local slash_commands = require('opencode.api').get_slash_commands():await() local key = config.get_key_for_function('input_window', 'slash_commands') or '/' local cmd = command:sub(2):match('^%s*(.-)%s*$')