From f5a6f4da7092191efdd305b65865e89636efd514 Mon Sep 17 00:00:00 2001 From: Aaron Weisberg Date: Fri, 19 Dec 2025 14:10:33 -0800 Subject: [PATCH 1/4] fix: improve context clearing in new sessions and file mentions - Clear attachments when starting a new session with /new command - Toggle file removal when selecting already-mentioned files with @ trigger - Fixes issue where context persisted across new sessions - Fixes issue where @ file selection only added files, never removed them --- lua/opencode/core.lua | 1 + 1 file changed, 1 insertion(+) diff --git a/lua/opencode/core.lua b/lua/opencode/core.lua index f68af3e8..76783859 100644 --- a/lua/opencode/core.lua +++ b/lua/opencode/core.lua @@ -93,6 +93,7 @@ M.open = Promise.async(function(opts) if opts.new_session then state.active_session = nil state.last_sent_context = nil + context.unload_attachments() state.current_model = nil state.current_mode = nil From d1751b10cdb0190903f5a8fc2f73e146e353f795 Mon Sep 17 00:00:00 2001 From: Aaron Weisberg Date: Fri, 19 Dec 2025 15:57:58 -0800 Subject: [PATCH 2/4] feat(input_window): add shell command execution support - enable executing shell commands prefixed with '!' in input window - display command output in a dedicated output window - prompt user to append command and output to input context - handle command execution errors and notify user on failure - touches(ui): update input window interactions and context management --- lua/opencode/core.lua | 1 - lua/opencode/ui/completion/context.lua | 6 +- lua/opencode/ui/input_window.lua | 77 +++++++ tests/unit/context_completion_spec.lua | 13 +- tests/unit/input_window_spec.lua | 307 +++++++++++++++++++++++++ 5 files changed, 391 insertions(+), 13 deletions(-) create mode 100644 tests/unit/input_window_spec.lua diff --git a/lua/opencode/core.lua b/lua/opencode/core.lua index 76783859..f68af3e8 100644 --- a/lua/opencode/core.lua +++ b/lua/opencode/core.lua @@ -93,7 +93,6 @@ M.open = Promise.async(function(opts) if opts.new_session then state.active_session = nil state.last_sent_context = nil - context.unload_attachments() state.current_model = nil state.current_mode = nil diff --git a/lua/opencode/ui/completion/context.lua b/lua/opencode/ui/completion/context.lua index 6d9a9c07..e8ec2c9a 100644 --- a/lua/opencode/ui/completion/context.lua +++ b/lua/opencode/ui/completion/context.lua @@ -114,7 +114,7 @@ local function add_mentioned_files_items(ctx) true, 'Select to remove file ' .. filename, icons.get('file'), - nil, + { file_path = file }, kind_priority.mentioned_file ) ) @@ -277,8 +277,8 @@ local context_source = { state.current_context_config = context_cfg if type == 'mentioned_file' then - context.remove_file(item.data.name) - input_win.remove_mention(item.data.name) + local file_path = item.data.additional_data and item.data.additional_data.file_path or item.data.name + context.remove_file(file_path) elseif type == 'subagent' then local subagent_name = item.data.name:gsub(' %(agent%)$', '') context.remove_subagent(subagent_name) diff --git a/lua/opencode/ui/input_window.lua b/lua/opencode/ui/input_window.lua index 2c72189e..3e7d4113 100644 --- a/lua/opencode/ui/input_window.lua +++ b/lua/opencode/ui/input_window.lua @@ -66,6 +66,11 @@ function M.handle_submit() return end + if input_content:match('^!') then + M._execute_shell_command(input_content:sub(2)) + return + end + local key = config.get_key_for_function('input_window', 'slash_commands') or '/' if input_content:match('^' .. key) then M._execute_slash_command(input_content) @@ -75,6 +80,76 @@ function M.handle_submit() require('opencode.core').send_message(input_content) end +M._execute_shell_command = function(command) + local cmd = command:match('^%s*(.-)%s*$') + if cmd == '' then + return + end + + local shell = vim.o.shell + local shell_cmd = { shell, '-c', cmd } + + vim.system(shell_cmd, { text = true }, function(result) + vim.schedule(function() + if result.code ~= 0 then + vim.notify('Command failed with exit code ' .. result.code, vim.log.levels.ERROR) + end + + local output = result.stdout or '' + if result.stderr and result.stderr ~= '' then + output = output .. '\n' .. result.stderr + end + + M._prompt_add_to_context(cmd, output, result.code) + end) + end) +end + +M._prompt_add_to_context = function(cmd, output, exit_code) + local output_window = require('opencode.ui.output_window') + if not output_window.mounted() then + return + end + + local formatted_output = string.format('$ %s\n%s', cmd, output) + local lines = vim.split(formatted_output, '\n') + + output_window.set_lines(lines) + + vim.ui.select({ 'Yes', 'No' }, { + prompt = 'Add command + output to context?', + }, function(choice) + if choice == 'Yes' then + local message = string.format('Command: `%s`\nExit code: %d\nOutput:\n```\n%s```', cmd, exit_code, output) + M._append_to_input(message) + output_window.clear() + end + require('opencode.ui.input_window').focus_input() + end) +end + +M._append_to_input = function(text) + if not M.mounted() then + return + end + + local current_lines = vim.api.nvim_buf_get_lines(state.windows.input_buf, 0, -1, false) + local new_lines = vim.split(text, '\n') + + if #current_lines == 1 and current_lines[1] == '' then + vim.api.nvim_buf_set_lines(state.windows.input_buf, 0, -1, false, new_lines) + else + vim.api.nvim_buf_set_lines(state.windows.input_buf, -1, -1, false, { '', '---', '' }) + vim.api.nvim_buf_set_lines(state.windows.input_buf, -1, -1, false, new_lines) + end + + M.refresh_placeholder(state.windows) + require('opencode.ui.mention').highlight_all_mentions(state.windows.input_buf) + + local line_count = vim.api.nvim_buf_line_count(state.windows.input_buf) + vim.api.nvim_win_set_cursor(state.windows.input_win, { line_count, 0 }) +end + M._execute_slash_command = function(command) local slash_commands = require('opencode.api').get_slash_commands():await() local key = config.get_key_for_function('input_window', 'slash_commands') or '/' @@ -160,6 +235,8 @@ function M.refresh_placeholder(windows, input_lines) vim.api.nvim_buf_set_extmark(windows.input_buf, ns_id, 0, 0, { virt_text = { { 'Type your prompt here... ', 'OpencodeHint' }, + { '!', 'OpencodeInputLegend' }, + { ' shell ', 'OpencodeHint' }, { slash_key or '/', 'OpencodeInputLegend' }, { ' commands ', 'OpencodeHint' }, { mention_key or '@', 'OpencodeInputLegend' }, diff --git a/tests/unit/context_completion_spec.lua b/tests/unit/context_completion_spec.lua index acc85d9b..a95de397 100644 --- a/tests/unit/context_completion_spec.lua +++ b/tests/unit/context_completion_spec.lua @@ -314,19 +314,12 @@ describe('context completion', function() it('should remove mentioned file when selected', function() local remove_file_called = false - local remove_mention_called = false local context_module = require('opencode.context') - local input_win_module = require('opencode.ui.input_window') context_module.remove_file = function(name) remove_file_called = true - assert.are.equal('test.lua', name) - end - - input_win_module.remove_mention = function(name) - remove_mention_called = true - assert.are.equal('test.lua', name) + assert.are.equal('/test/file.lua', name) end local item = { @@ -335,13 +328,15 @@ describe('context completion', function() type = 'mentioned_file', name = 'test.lua', available = true, + additional_data = { + file_path = '/test/file.lua', + }, }, } source.on_complete(item) assert.is_true(remove_file_called) - assert.is_true(remove_mention_called) end) it('should remove subagent when selected', function() diff --git a/tests/unit/input_window_spec.lua b/tests/unit/input_window_spec.lua new file mode 100644 index 00000000..e09de288 --- /dev/null +++ b/tests/unit/input_window_spec.lua @@ -0,0 +1,307 @@ +local input_window = require('opencode.ui.input_window') +local state = require('opencode.state') + +describe('input_window', function() + describe('shell command execution', function() + local original_system + local original_schedule + local original_ui_select + + before_each(function() + original_system = vim.system + original_schedule = vim.schedule + original_ui_select = vim.ui.select + + vim.schedule = function(fn) + fn() + end + end) + + after_each(function() + vim.system = original_system + vim.schedule = original_schedule + vim.ui.select = original_ui_select + end) + + it('should detect shell commands starting with !', function() + local executed = false + vim.system = function(cmd, opts, callback) + executed = true + assert.are.same({ vim.o.shell, '-c', 'echo test' }, cmd) + vim.schedule(function() + callback({ code = 0, stdout = 'test\n', stderr = '' }) + end) + end + + vim.ui.select = function(choices, opts, callback) + callback('No') + end + + local input_buf = vim.api.nvim_create_buf(false, true) + local output_buf = vim.api.nvim_create_buf(false, true) + local input_win = vim.api.nvim_open_win(input_buf, true, { + relative = 'editor', + width = 80, + height = 10, + row = 0, + col = 0, + }) + local output_win = vim.api.nvim_open_win(output_buf, false, { + relative = 'editor', + width = 80, + height = 10, + row = 11, + col = 0, + }) + + state.windows = { + input_buf = input_buf, + input_win = input_win, + output_buf = output_buf, + output_win = output_win, + } + + vim.api.nvim_buf_set_lines(state.windows.input_buf, 0, -1, false, { '!echo test' }) + + input_window.handle_submit() + + assert.is_true(executed) + + vim.api.nvim_win_close(input_win, true) + vim.api.nvim_win_close(output_win, true) + vim.api.nvim_buf_delete(input_buf, { force = true }) + vim.api.nvim_buf_delete(output_buf, { force = true }) + state.windows = nil + end) + + it('should display command output in output window', function() + local output_lines = nil + local output_window = require('opencode.ui.output_window') + local original_set_lines = output_window.set_lines + + output_window.set_lines = function(lines) + output_lines = lines + end + + vim.system = function(cmd, opts, callback) + vim.schedule(function() + callback({ code = 0, stdout = 'hello world\n', stderr = '' }) + end) + end + + vim.ui.select = function(choices, opts, callback) + callback('No') + end + + local input_buf = vim.api.nvim_create_buf(false, true) + local output_buf = vim.api.nvim_create_buf(false, true) + local input_win = vim.api.nvim_open_win(input_buf, true, { + relative = 'editor', + width = 80, + height = 10, + row = 0, + col = 0, + }) + local output_win = vim.api.nvim_open_win(output_buf, false, { + relative = 'editor', + width = 80, + height = 10, + row = 11, + col = 0, + }) + + state.windows = { + input_buf = input_buf, + input_win = input_win, + output_buf = output_buf, + output_win = output_win, + } + + vim.api.nvim_buf_set_lines(state.windows.input_buf, 0, -1, false, { '!echo "hello world"' }) + + input_window.handle_submit() + + assert.is_not_nil(output_lines) + assert.are.same('$ echo "hello world"', output_lines[1]) + assert.are.same('hello world', output_lines[2]) + + output_window.set_lines = original_set_lines + vim.api.nvim_win_close(input_win, true) + vim.api.nvim_win_close(output_win, true) + vim.api.nvim_buf_delete(input_buf, { force = true }) + vim.api.nvim_buf_delete(output_buf, { force = true }) + state.windows = nil + end) + + it('should prompt user to add output to input', function() + local prompt_shown = false + local prompt_text = nil + + vim.system = function(cmd, opts, callback) + vim.schedule(function() + callback({ code = 0, stdout = 'output\n', stderr = '' }) + end) + end + + vim.ui.select = function(choices, opts, callback) + prompt_shown = true + prompt_text = opts.prompt + assert.are.same({ 'Yes', 'No' }, choices) + callback('No') + end + + local input_buf = vim.api.nvim_create_buf(false, true) + local output_buf = vim.api.nvim_create_buf(false, true) + local input_win = vim.api.nvim_open_win(input_buf, true, { + relative = 'editor', + width = 80, + height = 10, + row = 0, + col = 0, + }) + local output_win = vim.api.nvim_open_win(output_buf, false, { + relative = 'editor', + width = 80, + height = 10, + row = 11, + col = 0, + }) + + state.windows = { + input_buf = input_buf, + input_win = input_win, + output_buf = output_buf, + output_win = output_win, + } + + vim.api.nvim_buf_set_lines(state.windows.input_buf, 0, -1, false, { '!ls' }) + + input_window.handle_submit() + + assert.is_true(prompt_shown) + assert.are.equal('Add command + output to context?', prompt_text) + + vim.api.nvim_win_close(input_win, true) + vim.api.nvim_win_close(output_win, true) + vim.api.nvim_buf_delete(input_buf, { force = true }) + vim.api.nvim_buf_delete(output_buf, { force = true }) + state.windows = nil + end) + + it('should append formatted output to input when user selects Yes', function() + vim.system = function(cmd, opts, callback) + vim.schedule(function() + callback({ code = 0, stdout = 'test output\n', stderr = '' }) + end) + end + + vim.ui.select = function(choices, opts, callback) + callback('Yes') + end + + local input_buf = vim.api.nvim_create_buf(false, true) + local output_buf = vim.api.nvim_create_buf(false, true) + local input_win = vim.api.nvim_open_win(input_buf, true, { + relative = 'editor', + width = 80, + height = 10, + row = 0, + col = 0, + }) + local output_win = vim.api.nvim_open_win(output_buf, false, { + relative = 'editor', + width = 80, + height = 10, + row = 11, + col = 0, + }) + + state.windows = { + input_buf = input_buf, + input_win = input_win, + output_buf = output_buf, + output_win = output_win, + } + + vim.api.nvim_buf_set_lines(state.windows.input_buf, 0, -1, false, { '!echo test' }) + + input_window.handle_submit() + + local input_lines = vim.api.nvim_buf_get_lines(input_buf, 0, -1, false) + local input_text = table.concat(input_lines, '\n') + + assert.is_true(input_text:find('Command: `echo test`', 1, true) ~= nil) + assert.is_true(input_text:find('Exit code: 0', 1, true) ~= nil) + assert.is_true(input_text:find('Output:', 1, true) ~= nil) + assert.is_true(input_text:find('```', 1, true) ~= nil) + assert.is_true(input_text:find('test output', 1, true) ~= nil) + + local output_lines = vim.api.nvim_buf_get_lines(output_buf, 0, -1, false) + assert.are.same({ '' }, output_lines) + + vim.api.nvim_win_close(input_win, true) + vim.api.nvim_win_close(output_win, true) + vim.api.nvim_buf_delete(input_buf, { force = true }) + vim.api.nvim_buf_delete(output_buf, { force = true }) + state.windows = nil + end) + + it('should handle command errors', function() + local error_notified = false + + vim.system = function(cmd, opts, callback) + vim.schedule(function() + callback({ code = 1, stdout = '', stderr = 'command not found\n' }) + end) + end + + vim.ui.select = function(choices, opts, callback) + callback('No') + end + + local original_notify = vim.notify + vim.notify = function(msg, level) + if msg:match('failed with exit code') then + error_notified = true + end + end + + local input_buf = vim.api.nvim_create_buf(false, true) + local output_buf = vim.api.nvim_create_buf(false, true) + local input_win = vim.api.nvim_open_win(input_buf, true, { + relative = 'editor', + width = 80, + height = 10, + row = 0, + col = 0, + }) + local output_win = vim.api.nvim_open_win(output_buf, false, { + relative = 'editor', + width = 80, + height = 10, + row = 11, + col = 0, + }) + + state.windows = { + input_buf = input_buf, + input_win = input_win, + output_buf = output_buf, + output_win = output_win, + } + + vim.api.nvim_buf_set_lines(state.windows.input_buf, 0, -1, false, { '!invalid_command' }) + + input_window.handle_submit() + + assert.is_true(error_notified) + + vim.notify = original_notify + vim.api.nvim_win_close(input_win, true) + vim.api.nvim_win_close(output_win, true) + vim.api.nvim_buf_delete(input_buf, { force = true }) + vim.api.nvim_buf_delete(output_buf, { force = true }) + state.windows = nil + end) + end) +end) From b8db78a9ce1dd48e4b185dd3f1bdf69fccf7a0fb Mon Sep 17 00:00:00 2001 From: Aaron Weisberg Date: Fri, 19 Dec 2025 16:09:38 -0800 Subject: [PATCH 3/4] feat(ui): clear output window when user selects No to prompt - adjust confirmation dialog to clear the output window based on choice - update unit tests to ensure desired behavior on user selection - ensures consistent UI experience in input output handling --- lua/opencode/ui/input_window.lua | 2 +- tests/unit/input_window_spec.lua | 54 ++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/lua/opencode/ui/input_window.lua b/lua/opencode/ui/input_window.lua index 3e7d4113..550a8217 100644 --- a/lua/opencode/ui/input_window.lua +++ b/lua/opencode/ui/input_window.lua @@ -122,8 +122,8 @@ M._prompt_add_to_context = function(cmd, output, exit_code) if choice == 'Yes' then local message = string.format('Command: `%s`\nExit code: %d\nOutput:\n```\n%s```', cmd, exit_code, output) M._append_to_input(message) - output_window.clear() end + output_window.clear() require('opencode.ui.input_window').focus_input() end) end diff --git a/tests/unit/input_window_spec.lua b/tests/unit/input_window_spec.lua index e09de288..17409689 100644 --- a/tests/unit/input_window_spec.lua +++ b/tests/unit/input_window_spec.lua @@ -78,11 +78,15 @@ describe('input_window', function() local output_lines = nil local output_window = require('opencode.ui.output_window') local original_set_lines = output_window.set_lines + local original_clear = output_window.clear output_window.set_lines = function(lines) output_lines = lines end + output_window.clear = function() + end + vim.system = function(cmd, opts, callback) vim.schedule(function() callback({ code = 0, stdout = 'hello world\n', stderr = '' }) @@ -126,6 +130,7 @@ describe('input_window', function() assert.are.same('hello world', output_lines[2]) output_window.set_lines = original_set_lines + output_window.clear = original_clear vim.api.nvim_win_close(input_win, true) vim.api.nvim_win_close(output_win, true) vim.api.nvim_buf_delete(input_buf, { force = true }) @@ -246,6 +251,55 @@ describe('input_window', function() state.windows = nil end) + it('should clear output window when user selects No', function() + vim.system = function(cmd, opts, callback) + vim.schedule(function() + callback({ code = 0, stdout = 'test output\n', stderr = '' }) + end) + end + + vim.ui.select = function(choices, opts, callback) + callback('No') + end + + local input_buf = vim.api.nvim_create_buf(false, true) + local output_buf = vim.api.nvim_create_buf(false, true) + local input_win = vim.api.nvim_open_win(input_buf, true, { + relative = 'editor', + width = 80, + height = 10, + row = 0, + col = 0, + }) + local output_win = vim.api.nvim_open_win(output_buf, false, { + relative = 'editor', + width = 80, + height = 10, + row = 11, + col = 0, + }) + + state.windows = { + input_buf = input_buf, + input_win = input_win, + output_buf = output_buf, + output_win = output_win, + } + + vim.api.nvim_buf_set_lines(state.windows.input_buf, 0, -1, false, { '!echo test' }) + + input_window.handle_submit() + + local output_lines = vim.api.nvim_buf_get_lines(output_buf, 0, -1, false) + assert.are.same({ '' }, output_lines) + + vim.api.nvim_win_close(input_win, true) + vim.api.nvim_win_close(output_win, true) + vim.api.nvim_buf_delete(input_buf, { force = true }) + vim.api.nvim_buf_delete(output_buf, { force = true }) + state.windows = nil + end) + it('should handle command errors', function() local error_notified = false From 3f11b5aaeda9f3e44d84a9fe850d568f0e03bc6b Mon Sep 17 00:00:00 2001 From: Aaron Weisberg Date: Fri, 19 Dec 2025 16:10:07 -0800 Subject: [PATCH 4/4] update --- lua/opencode/core.lua | 1 + 1 file changed, 1 insertion(+) diff --git a/lua/opencode/core.lua b/lua/opencode/core.lua index f68af3e8..76783859 100644 --- a/lua/opencode/core.lua +++ b/lua/opencode/core.lua @@ -93,6 +93,7 @@ M.open = Promise.async(function(opts) if opts.new_session then state.active_session = nil state.last_sent_context = nil + context.unload_attachments() state.current_model = nil state.current_mode = nil