Skip to content

Commit d1751b1

Browse files
committed
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
1 parent f5a6f4d commit d1751b1

File tree

5 files changed

+391
-13
lines changed

5 files changed

+391
-13
lines changed

lua/opencode/core.lua

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,6 @@ M.open = Promise.async(function(opts)
9393
if opts.new_session then
9494
state.active_session = nil
9595
state.last_sent_context = nil
96-
context.unload_attachments()
9796

9897
state.current_model = nil
9998
state.current_mode = nil

lua/opencode/ui/completion/context.lua

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ local function add_mentioned_files_items(ctx)
114114
true,
115115
'Select to remove file ' .. filename,
116116
icons.get('file'),
117-
nil,
117+
{ file_path = file },
118118
kind_priority.mentioned_file
119119
)
120120
)
@@ -277,8 +277,8 @@ local context_source = {
277277
state.current_context_config = context_cfg
278278

279279
if type == 'mentioned_file' then
280-
context.remove_file(item.data.name)
281-
input_win.remove_mention(item.data.name)
280+
local file_path = item.data.additional_data and item.data.additional_data.file_path or item.data.name
281+
context.remove_file(file_path)
282282
elseif type == 'subagent' then
283283
local subagent_name = item.data.name:gsub(' %(agent%)$', '')
284284
context.remove_subagent(subagent_name)

lua/opencode/ui/input_window.lua

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,11 @@ function M.handle_submit()
6666
return
6767
end
6868

69+
if input_content:match('^!') then
70+
M._execute_shell_command(input_content:sub(2))
71+
return
72+
end
73+
6974
local key = config.get_key_for_function('input_window', 'slash_commands') or '/'
7075
if input_content:match('^' .. key) then
7176
M._execute_slash_command(input_content)
@@ -75,6 +80,76 @@ function M.handle_submit()
7580
require('opencode.core').send_message(input_content)
7681
end
7782

83+
M._execute_shell_command = function(command)
84+
local cmd = command:match('^%s*(.-)%s*$')
85+
if cmd == '' then
86+
return
87+
end
88+
89+
local shell = vim.o.shell
90+
local shell_cmd = { shell, '-c', cmd }
91+
92+
vim.system(shell_cmd, { text = true }, function(result)
93+
vim.schedule(function()
94+
if result.code ~= 0 then
95+
vim.notify('Command failed with exit code ' .. result.code, vim.log.levels.ERROR)
96+
end
97+
98+
local output = result.stdout or ''
99+
if result.stderr and result.stderr ~= '' then
100+
output = output .. '\n' .. result.stderr
101+
end
102+
103+
M._prompt_add_to_context(cmd, output, result.code)
104+
end)
105+
end)
106+
end
107+
108+
M._prompt_add_to_context = function(cmd, output, exit_code)
109+
local output_window = require('opencode.ui.output_window')
110+
if not output_window.mounted() then
111+
return
112+
end
113+
114+
local formatted_output = string.format('$ %s\n%s', cmd, output)
115+
local lines = vim.split(formatted_output, '\n')
116+
117+
output_window.set_lines(lines)
118+
119+
vim.ui.select({ 'Yes', 'No' }, {
120+
prompt = 'Add command + output to context?',
121+
}, function(choice)
122+
if choice == 'Yes' then
123+
local message = string.format('Command: `%s`\nExit code: %d\nOutput:\n```\n%s```', cmd, exit_code, output)
124+
M._append_to_input(message)
125+
output_window.clear()
126+
end
127+
require('opencode.ui.input_window').focus_input()
128+
end)
129+
end
130+
131+
M._append_to_input = function(text)
132+
if not M.mounted() then
133+
return
134+
end
135+
136+
local current_lines = vim.api.nvim_buf_get_lines(state.windows.input_buf, 0, -1, false)
137+
local new_lines = vim.split(text, '\n')
138+
139+
if #current_lines == 1 and current_lines[1] == '' then
140+
vim.api.nvim_buf_set_lines(state.windows.input_buf, 0, -1, false, new_lines)
141+
else
142+
vim.api.nvim_buf_set_lines(state.windows.input_buf, -1, -1, false, { '', '---', '' })
143+
vim.api.nvim_buf_set_lines(state.windows.input_buf, -1, -1, false, new_lines)
144+
end
145+
146+
M.refresh_placeholder(state.windows)
147+
require('opencode.ui.mention').highlight_all_mentions(state.windows.input_buf)
148+
149+
local line_count = vim.api.nvim_buf_line_count(state.windows.input_buf)
150+
vim.api.nvim_win_set_cursor(state.windows.input_win, { line_count, 0 })
151+
end
152+
78153
M._execute_slash_command = function(command)
79154
local slash_commands = require('opencode.api').get_slash_commands():await()
80155
local key = config.get_key_for_function('input_window', 'slash_commands') or '/'
@@ -160,6 +235,8 @@ function M.refresh_placeholder(windows, input_lines)
160235
vim.api.nvim_buf_set_extmark(windows.input_buf, ns_id, 0, 0, {
161236
virt_text = {
162237
{ 'Type your prompt here... ', 'OpencodeHint' },
238+
{ '!', 'OpencodeInputLegend' },
239+
{ ' shell ', 'OpencodeHint' },
163240
{ slash_key or '/', 'OpencodeInputLegend' },
164241
{ ' commands ', 'OpencodeHint' },
165242
{ mention_key or '@', 'OpencodeInputLegend' },

tests/unit/context_completion_spec.lua

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -314,19 +314,12 @@ describe('context completion', function()
314314

315315
it('should remove mentioned file when selected', function()
316316
local remove_file_called = false
317-
local remove_mention_called = false
318317

319318
local context_module = require('opencode.context')
320-
local input_win_module = require('opencode.ui.input_window')
321319

322320
context_module.remove_file = function(name)
323321
remove_file_called = true
324-
assert.are.equal('test.lua', name)
325-
end
326-
327-
input_win_module.remove_mention = function(name)
328-
remove_mention_called = true
329-
assert.are.equal('test.lua', name)
322+
assert.are.equal('/test/file.lua', name)
330323
end
331324

332325
local item = {
@@ -335,13 +328,15 @@ describe('context completion', function()
335328
type = 'mentioned_file',
336329
name = 'test.lua',
337330
available = true,
331+
additional_data = {
332+
file_path = '/test/file.lua',
333+
},
338334
},
339335
}
340336

341337
source.on_complete(item)
342338

343339
assert.is_true(remove_file_called)
344-
assert.is_true(remove_mention_called)
345340
end)
346341

347342
it('should remove subagent when selected', function()

0 commit comments

Comments
 (0)