Skip to content

Commit 86f6888

Browse files
authored
Merge pull request #439 from jensenojs/feat/message-actions-dialog
[feat] : Add inline message actions
2 parents dc729d2 + b18eb94 commit 86f6888

21 files changed

Lines changed: 1808 additions & 18 deletions

‎lua/opencode/commands/handlers/session.lua‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -522,6 +522,15 @@ function M.actions.timeline()
522522
end)
523523
end
524524

525+
---@param source? string
526+
function M.actions.message_actions(source)
527+
local message_actions = require('opencode.ui.message_actions')
528+
if source == 'mouse' then
529+
return message_actions.open_from_mouse()
530+
end
531+
return message_actions.open_at_cursor()
532+
end
533+
525534
---@param message_id? string
526535
function M.actions.fork_session(message_id)
527536
return with_active_session('No active session to fork', function(state_obj)
@@ -668,6 +677,12 @@ M.command_defs = {
668677
desc = 'Open timeline picker to navigate/undo/redo/fork to message',
669678
execute = M.actions.timeline,
670679
},
680+
message_actions = {
681+
desc = 'Open message actions',
682+
execute = function(args)
683+
return M.actions.message_actions(args[1])
684+
end,
685+
},
671686
}
672687

673688
return M

‎lua/opencode/config.lua‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ M.defaults = {
7676
['gr'] = { 'references', desc = 'Browse code references' },
7777
['gf'] = { 'jump_to_file', desc = 'Jump to file at cursor' },
7878
['<CR>'] = { 'jump_to_target_at_cursor', desc = 'Jump to target at cursor' },
79+
['<LeftMouse>'] = { 'message_actions', { 'mouse' }, desc = 'Open message actions' },
80+
['<leader>o<CR>'] = { 'message_actions', desc = 'Open message actions' },
7981
['<M-i>'] = { 'toggle_input', mode = { 'n' }, desc = 'Toggle input window' },
8082
['<M-r>'] = { 'cycle_variant', mode = { 'n' }, desc = 'Cycle model variants' },
8183
['<leader>oS'] = { 'navigate_session_tree', { 'child', 'picker' }, desc = 'Select child session' },

‎lua/opencode/types.lua‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@
148148

149149
---@class OpencodeKeymapEntry
150150
---@field [1] string # Function name
151+
---@field [2]? string|string[] # Preset command arguments
151152
---@field mode? string|string[] # Mode(s) for the keymap
152153
---@field desc? string # Keymap description
153154
---@field defer_to_completion? boolean # Whether to defer the keymap when completion menu is open

‎lua/opencode/ui/dialog.lua‎

Lines changed: 122 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,16 @@
1010
---@field keymaps? DialogKeymaps Custom keymap configuration
1111
---@field namespace_prefix? string Prefix for vim.on_key namespace (default: 'opencode_dialog')
1212
---@field hide_input? boolean Whether to hide the input window when dialog is active (default: true)
13+
---@field render_part_id? string Rendered part ID used to resolve mouse clicks against output lines
14+
---@field mouse_select? boolean Whether Dialog should register <LeftMouse> for option selection (default: false)
1315

1416
---@class DialogKeymaps
1517
---@field up? string[] Keys for navigating up (default: {'k', '<Up>'})
1618
---@field down? string[] Keys for navigating down (default: {'j', '<Down>'})
1719
---@field left? string[] Keys for navigating left between groups
1820
---@field right? string[] Keys for navigating right between groups
1921
---@field select? string Key for selecting current option (default: '<CR>')
20-
---@field dismiss? string Key for dismissing dialog (default: '<Esc>')
22+
---@field dismiss? string|string[] Keys for dismissing dialog (default: {'<Esc>', '<C-c>'})
2123
---@field number_shortcuts? boolean Enable 1-9 number shortcuts (default: true)
2224

2325
---@class Dialog
@@ -27,9 +29,38 @@
2729
---@field private _selected_index integer Currently selected option index
2830
---@field private _active boolean Whether dialog is currently active
2931
---@field private _group_index integer Currently selected group index
32+
---@field private _option_local_lines integer[] 0-based output-local lines for each option
3033
local Dialog = {}
3134
Dialog.__index = Dialog
3235

36+
---@param keymap string|string[]|nil
37+
---@return string[]
38+
local function keymap_list(keymap)
39+
local keymaps = {}
40+
if type(keymap) == 'string' then
41+
if keymap ~= '' then
42+
table.insert(keymaps, keymap)
43+
end
44+
elseif type(keymap) == 'table' then
45+
for _, key in ipairs(keymap) do
46+
if key and key ~= '' then
47+
table.insert(keymaps, key)
48+
end
49+
end
50+
end
51+
return keymaps
52+
end
53+
54+
---@param keymaps string[]
55+
---@return string
56+
local function keymap_legend(keymaps)
57+
local labels = {}
58+
for _, key in ipairs(keymaps) do
59+
table.insert(labels, '`' .. key .. '`')
60+
end
61+
return table.concat(labels, ' or ')
62+
end
63+
3364
---Create a new dialog instance
3465
---@param config DialogConfig Dialog configuration
3566
---@return Dialog
@@ -43,7 +74,7 @@ function Dialog.new(config)
4374
left = {},
4475
right = {},
4576
select = '<CR>',
46-
dismiss = '<Esc>',
77+
dismiss = { '<Esc>', '<C-c>' },
4778
number_shortcuts = true,
4879
}
4980

@@ -54,13 +85,15 @@ function Dialog.new(config)
5485
return true
5586
end,
5687
hide_input = true,
88+
mouse_select = false,
5789
} --[[@as DialogConfig]], config)
5890

5991
self._keymaps = {}
6092
self._key_capture_ns = nil
6193
self._selected_index = 1
6294
self._group_index = 1
6395
self._active = false
96+
self._option_local_lines = {}
6497

6598
return self
6699
end
@@ -248,8 +281,9 @@ function Dialog:format_legend(output, options)
248281
local line = output:add_line(select_text)
249282
end
250283

251-
if keymaps.dismiss and keymaps.dismiss ~= '' then
252-
local line = output:add_line('Close: `<Esc>`')
284+
local dismiss_keymaps = keymap_list(keymaps.dismiss)
285+
if #dismiss_keymaps > 0 then
286+
local line = output:add_line('Close: ' .. keymap_legend(dismiss_keymaps))
253287
end
254288
else
255289
local message = options.unfocused_message or 'Focus Opencode window to interact'
@@ -268,6 +302,7 @@ end
268302
--- - progress?: string - Progress indicator (e.g., "(1/3)")
269303
--- - content?: string[] - Array of lines to render before options
270304
--- - render_content?: function(output: Output) - Custom function to render content before options
305+
--- - hide_legend?: boolean - Whether to hide movement/select/dismiss instructions
271306
function Dialog:format_dialog(output, config)
272307
if not self._active then
273308
return
@@ -301,9 +336,10 @@ function Dialog:format_dialog(output, config)
301336

302337
self:format_options(output, config.options or {})
303338

304-
output:add_line('')
305-
306-
self:format_legend(output, { unfocused_message = config.unfocused_message })
339+
if not config.hide_legend then
340+
output:add_line('')
341+
self:format_legend(output, { unfocused_message = config.unfocused_message })
342+
end
307343

308344
local end_line = output:get_line_count()
309345

@@ -322,6 +358,8 @@ end
322358
---@param output Output Output object to write to
323359
---@param options table[] Array of option objects with {label: string, description?: string}
324360
function Dialog:format_options(output, options)
361+
self._option_local_lines = {}
362+
325363
for i, option in ipairs(options) do
326364
local label = option.label
327365
if option.description and option.description ~= '' then
@@ -339,11 +377,12 @@ function Dialog:format_options(output, options)
339377
-- add_line returns a 1-based line index; Output extmarks use 0-based
340378
-- keys, so subtract 1 to get the correct extmark key.
341379
local added_idx = output:add_line(line_text)
380+
local option_local_line = added_idx - 1
381+
self._option_local_lines[i] = option_local_line
342382

343383
if is_selected then
344-
local extmark_idx = added_idx - 1
345-
output:add_extmark(extmark_idx, { line_hl_group = 'OpencodeDialogOptionHover' } --[[@as OutputExtmark]])
346-
output:add_extmark(extmark_idx, {
384+
output:add_extmark(option_local_line, { line_hl_group = 'OpencodeDialogOptionHover' } --[[@as OutputExtmark]])
385+
output:add_extmark(option_local_line, {
347386
start_col = 2,
348387
virt_text = { { '› ', 'OpencodeDialogOptionHover' } },
349388
virt_text_pos = 'overlay',
@@ -352,6 +391,58 @@ function Dialog:format_options(output, options)
352391
end
353392
end
354393

394+
---@return integer|nil
395+
function Dialog:_mouse_option_index()
396+
local render_part_id = self._config.render_part_id
397+
if not render_part_id or render_part_id == '' then
398+
return nil
399+
end
400+
401+
local mouse = vim.fn.getmousepos()
402+
local winid = mouse and mouse.winid
403+
if not winid or winid == 0 or not vim.api.nvim_win_is_valid(winid) then
404+
return nil
405+
end
406+
407+
local buf = self._config.buffer
408+
if not buf or vim.api.nvim_win_get_buf(winid) ~= buf then
409+
return nil
410+
end
411+
412+
local rendered_part = require('opencode.ui.renderer.ctx').render_state:get_part(render_part_id)
413+
if not rendered_part or rendered_part.line_start == nil then
414+
return nil
415+
end
416+
417+
local mouse_line = mouse.line
418+
if not mouse_line or mouse_line <= 0 then
419+
return nil
420+
end
421+
422+
local clicked_output_line = mouse_line - 1
423+
for option_index, option_local_line in ipairs(self._option_local_lines) do
424+
if clicked_output_line == rendered_part.line_start + option_local_line then
425+
return option_index
426+
end
427+
end
428+
end
429+
430+
---@return boolean selected
431+
function Dialog:select_mouse_option()
432+
if not self._active or not self._config.check_focused() then
433+
return false
434+
end
435+
436+
local option_index = self:_mouse_option_index()
437+
if not option_index then
438+
return false
439+
end
440+
441+
self._selected_index = option_index
442+
self._config.on_select(option_index)
443+
return true
444+
end
445+
355446
---Set up buffer-scoped keymaps
356447
function Dialog:_setup_keymaps()
357448
self:_clear_keymaps()
@@ -450,18 +541,34 @@ function Dialog:_setup_keymaps()
450541
table.insert(self._keymaps, keymaps.select)
451542
end
452543

453-
if keymaps.dismiss and keymaps.dismiss ~= '' then
544+
if self._config.mouse_select ~= false and self._config.render_part_id and self._config.render_part_id ~= '' then
454545
vim.keymap.set(
455546
'n',
456-
keymaps.dismiss,
547+
'<LeftMouse>',
457548
function()
458-
self:dismiss()
549+
self:select_mouse_option()
459550
end,
460551
vim.tbl_extend('force', keymap_opts, {
461-
desc = 'Dialog: dismiss',
552+
desc = 'Dialog: select clicked option',
462553
})
463554
)
464-
table.insert(self._keymaps, keymaps.dismiss)
555+
table.insert(self._keymaps, '<LeftMouse>')
556+
end
557+
558+
for _, key in ipairs(keymap_list(keymaps.dismiss)) do
559+
if key and key ~= '' then
560+
vim.keymap.set(
561+
'n',
562+
key,
563+
function()
564+
self:dismiss()
565+
end,
566+
vim.tbl_extend('force', keymap_opts, {
567+
desc = 'Dialog: dismiss',
568+
})
569+
)
570+
table.insert(self._keymaps, key)
571+
end
465572
end
466573

467574
if keymaps.number_shortcuts then

‎lua/opencode/ui/formatter.lua‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -719,7 +719,11 @@ function M.format_part(part, message, is_last_part, get_child_parts)
719719

720720
local role = message.info.role
721721

722-
if role == 'user' then
722+
if part.type == 'message-actions-display' then
723+
local message_actions = require('opencode.ui.message_actions')
724+
message_actions.format_display(output)
725+
content_added = true
726+
elseif role == 'user' then
723727
if is_compaction_part(part) then
724728
format_compaction_divider(output)
725729
content_added = true

0 commit comments

Comments
 (0)