|
| 1 | +---@class DialogConfig |
| 2 | +---@field buffer integer Buffer ID where keymaps should be set |
| 3 | +---@field on_select function(index: integer) Called when an option is selected |
| 4 | +---@field on_dismiss? function() Called when dialog is dismissed |
| 5 | +---@field on_navigate? function() Called when selection changes |
| 6 | +---@field get_option_count function(): integer Returns the total number of options |
| 7 | +---@field check_focused? function(): boolean Returns whether dialog should be active |
| 8 | +---@field keymaps? DialogKeymaps Custom keymap configuration |
| 9 | +---@field namespace_prefix? string Prefix for vim.on_key namespace (default: 'opencode_dialog') |
| 10 | +---@field hide_input? boolean Whether to hide the input window when dialog is active (default: true) |
| 11 | + |
| 12 | +---@class DialogKeymaps |
| 13 | +---@field up? string[] Keys for navigating up (default: {'k', '<Up>'}) |
| 14 | +---@field down? string[] Keys for navigating down (default: {'j', '<Down>'}) |
| 15 | +---@field select? string Key for selecting current option (default: '<CR>') |
| 16 | +---@field dismiss? string Key for dismissing dialog (default: '<Esc>') |
| 17 | +---@field number_shortcuts? boolean Enable 1-9 number shortcuts (default: true) |
| 18 | + |
| 19 | +---@class Dialog |
| 20 | +---@field private _config DialogConfig |
| 21 | +---@field private _keymaps integer[] List of keymap IDs for cleanup |
| 22 | +---@field private _key_capture_ns integer? Namespace for vim.on_key |
| 23 | +---@field private _selected_index integer Currently selected option index |
| 24 | +---@field private _active boolean Whether dialog is currently active |
| 25 | +local Dialog = {} |
| 26 | +Dialog.__index = Dialog |
| 27 | + |
| 28 | +---Create a new dialog instance |
| 29 | +---@param config DialogConfig Dialog configuration |
| 30 | +---@return Dialog |
| 31 | +function Dialog.new(config) |
| 32 | + local self = setmetatable({}, Dialog) |
| 33 | + |
| 34 | + -- Set up default keymaps if not provided |
| 35 | + local default_keymaps = { |
| 36 | + up = { 'k', '<Up>' }, |
| 37 | + down = { 'j', '<Down>' }, |
| 38 | + select = '<CR>', |
| 39 | + dismiss = '<Esc>', |
| 40 | + number_shortcuts = true, |
| 41 | + } |
| 42 | + |
| 43 | + self._config = vim.tbl_deep_extend('force', { |
| 44 | + keymaps = default_keymaps, |
| 45 | + namespace_prefix = 'opencode_dialog', |
| 46 | + check_focused = function() |
| 47 | + return true |
| 48 | + end, |
| 49 | + hide_input = true, |
| 50 | + } --[[@as DialogConfig]], config) |
| 51 | + |
| 52 | + self._keymaps = {} |
| 53 | + self._key_capture_ns = nil |
| 54 | + self._selected_index = 1 |
| 55 | + self._active = false |
| 56 | + |
| 57 | + return self |
| 58 | +end |
| 59 | + |
| 60 | +---Get the currently selected option index |
| 61 | +---@return integer |
| 62 | +function Dialog:get_selection() |
| 63 | + return self._selected_index |
| 64 | +end |
| 65 | + |
| 66 | +---Set the selected option index |
| 67 | +---@param index integer Option index to select |
| 68 | +function Dialog:set_selection(index) |
| 69 | + local option_count = self._config.get_option_count() |
| 70 | + if index >= 1 and index <= option_count then |
| 71 | + self._selected_index = index |
| 72 | + end |
| 73 | +end |
| 74 | + |
| 75 | +---Navigate selection by delta (positive for down, negative for up) |
| 76 | +---@param delta integer Amount to move selection |
| 77 | +function Dialog:navigate(delta) |
| 78 | + if not self._active or not self._config.check_focused() then |
| 79 | + return |
| 80 | + end |
| 81 | + |
| 82 | + local option_count = self._config.get_option_count() |
| 83 | + if option_count == 0 then |
| 84 | + return |
| 85 | + end |
| 86 | + |
| 87 | + self._selected_index = self._selected_index + delta |
| 88 | + |
| 89 | + -- Wrap around selection |
| 90 | + if self._selected_index < 1 then |
| 91 | + self._selected_index = option_count |
| 92 | + elseif self._selected_index > option_count then |
| 93 | + self._selected_index = 1 |
| 94 | + end |
| 95 | + |
| 96 | + if self._config.on_navigate then |
| 97 | + self._config.on_navigate() |
| 98 | + end |
| 99 | +end |
| 100 | + |
| 101 | +---Select the current option |
| 102 | +function Dialog:select() |
| 103 | + if not self._active or not self._config.check_focused() then |
| 104 | + return |
| 105 | + end |
| 106 | + |
| 107 | + local option_count = self._config.get_option_count() |
| 108 | + if option_count == 0 then |
| 109 | + return |
| 110 | + end |
| 111 | + |
| 112 | + self._config.on_select(self._selected_index) |
| 113 | +end |
| 114 | + |
| 115 | +---Dismiss the dialog |
| 116 | +function Dialog:dismiss() |
| 117 | + if not self._active or not self._config.check_focused() then |
| 118 | + return |
| 119 | + end |
| 120 | + |
| 121 | + if self._config.on_dismiss then |
| 122 | + self._config.on_dismiss() |
| 123 | + end |
| 124 | +end |
| 125 | + |
| 126 | +---Set up keymaps and activate the dialog |
| 127 | +function Dialog:setup() |
| 128 | + if self._active then |
| 129 | + self:teardown() |
| 130 | + end |
| 131 | + |
| 132 | + self._active = true |
| 133 | + |
| 134 | + -- Hide input window if configured |
| 135 | + if self._config.hide_input then |
| 136 | + local input_window = require('opencode.ui.input_window') |
| 137 | + input_window._hide() |
| 138 | + end |
| 139 | + |
| 140 | + self:_setup_keymaps() |
| 141 | +end |
| 142 | + |
| 143 | +---Clean up keymaps and deactivate the dialog |
| 144 | +function Dialog:teardown() |
| 145 | + self._active = false |
| 146 | + self:_clear_keymaps() |
| 147 | + |
| 148 | + -- Show input window if it was hidden |
| 149 | + if self._config.hide_input then |
| 150 | + local input_window = require('opencode.ui.input_window') |
| 151 | + input_window._show() |
| 152 | + end |
| 153 | +end |
| 154 | + |
| 155 | +---Check if dialog is currently active |
| 156 | +---@return boolean |
| 157 | +function Dialog:is_active() |
| 158 | + return self._active |
| 159 | +end |
| 160 | + |
| 161 | +---Format the legend/instructions for this dialog |
| 162 | +---@param output Output Output object to write to |
| 163 | +---@param options? table Options for legend formatting |
| 164 | +function Dialog:format_legend(output, options) |
| 165 | + options = options or {} |
| 166 | + local ui = require('opencode.ui.ui') |
| 167 | + |
| 168 | + if not self._active then |
| 169 | + return |
| 170 | + end |
| 171 | + |
| 172 | + local option_count = self._config.get_option_count() |
| 173 | + if option_count == 0 then |
| 174 | + return |
| 175 | + end |
| 176 | + |
| 177 | + if ui.is_opencode_focused() then |
| 178 | + local legend_parts = {} |
| 179 | + local keymaps = self._config.keymaps |
| 180 | + if not keymaps then |
| 181 | + return |
| 182 | + end |
| 183 | + |
| 184 | + if keymaps.up and #keymaps.up > 0 and keymaps.down and #keymaps.down > 0 then |
| 185 | + table.insert(legend_parts, string.format('Navigate: `%s`/`%s` or `↑`/`↓`', keymaps.down[1], keymaps.up[1])) |
| 186 | + end |
| 187 | + |
| 188 | + if keymaps.select and keymaps.select ~= '' then |
| 189 | + local select_text = string.format('Select: `%s`', keymaps.select) |
| 190 | + if keymaps.number_shortcuts and option_count > 0 then |
| 191 | + local max_shortcut = math.min(option_count, 9) |
| 192 | + select_text = select_text .. string.format(' or `1-%d`', max_shortcut) |
| 193 | + end |
| 194 | + table.insert(legend_parts, select_text) |
| 195 | + end |
| 196 | + |
| 197 | + if keymaps.dismiss and keymaps.dismiss ~= '' then |
| 198 | + table.insert(legend_parts, string.format('Dismiss: `%s`', keymaps.dismiss)) |
| 199 | + end |
| 200 | + |
| 201 | + if #legend_parts > 0 then |
| 202 | + output:add_line(table.concat(legend_parts, ' ')) |
| 203 | + end |
| 204 | + else |
| 205 | + local message = options.unfocused_message or 'Focus Opencode window to interact' |
| 206 | + output:add_line(message) |
| 207 | + end |
| 208 | +end |
| 209 | + |
| 210 | +---Format a complete dialog with title, options, legend, and border |
| 211 | +---@param output Output Output object to write to |
| 212 | +---@param config table Configuration for dialog rendering |
| 213 | +--- - title: string - Dialog title |
| 214 | +--- - title_hl: string - Highlight group for title |
| 215 | +--- - border_hl: string - Highlight group for border |
| 216 | +--- - options: table[] - Array of option objects with {label: string, description?: string} |
| 217 | +--- - unfocused_message: string - Message to show when not focused |
| 218 | +--- - progress?: string - Progress indicator (e.g., "(1/3)") |
| 219 | +--- - content?: string[] - Array of lines to render before options |
| 220 | +--- - render_content?: function(output: Output) - Custom function to render content before options |
| 221 | +function Dialog:format_dialog(output, config) |
| 222 | + if not self._active then |
| 223 | + return |
| 224 | + end |
| 225 | + |
| 226 | + local formatter = require('opencode.ui.formatter') |
| 227 | + local icons = require('opencode.ui.icons') |
| 228 | + |
| 229 | + local start_line = output:get_line_count() |
| 230 | + |
| 231 | + local title = config.title or 'Dialog' |
| 232 | + if config.progress then |
| 233 | + title = title .. config.progress |
| 234 | + end |
| 235 | + |
| 236 | + output:add_line(title) |
| 237 | + if config.title_hl then |
| 238 | + output:add_extmark(start_line, { line_hl_group = config.title_hl } --[[@as OutputExtmark]]) |
| 239 | + end |
| 240 | + output:add_line('') |
| 241 | + |
| 242 | + if config.render_content then |
| 243 | + config.render_content(output) |
| 244 | + output:add_line('') |
| 245 | + elseif config.content then |
| 246 | + for _, line in ipairs(config.content) do |
| 247 | + output:add_line(line) |
| 248 | + end |
| 249 | + output:add_line('') |
| 250 | + end |
| 251 | + |
| 252 | + self:format_options(output, config.options or {}) |
| 253 | + |
| 254 | + output:add_line('') |
| 255 | + |
| 256 | + self:format_legend(output, { unfocused_message = config.unfocused_message }) |
| 257 | + |
| 258 | + local end_line = output:get_line_count() |
| 259 | + |
| 260 | + -- Border |
| 261 | + if config.border_hl then |
| 262 | + formatter.add_vertical_border(output, start_line + 1, end_line, config.border_hl, -2) |
| 263 | + end |
| 264 | + |
| 265 | + output:add_line('') |
| 266 | +end |
| 267 | + |
| 268 | +---Format options list with selection indicator |
| 269 | +---@param output Output Output object to write to |
| 270 | +---@param options table[] Array of option objects with {label: string, description?: string} |
| 271 | +function Dialog:format_options(output, options) |
| 272 | + for i, option in ipairs(options) do |
| 273 | + local label = option.label |
| 274 | + if option.description and option.description ~= '' then |
| 275 | + label = label .. ' - ' .. option.description |
| 276 | + end |
| 277 | + |
| 278 | + local line_idx = output:get_line_count() |
| 279 | + local is_selected = self._selected_index == i |
| 280 | + local line_text = is_selected and string.format(' %d. %s ', i, label) or string.format(' %d. %s', i, label) |
| 281 | + |
| 282 | + output:add_line(line_text) |
| 283 | + |
| 284 | + if is_selected then |
| 285 | + output:add_extmark(line_idx, { line_hl_group = 'OpencodeDialogOptionHover' } --[[@as OutputExtmark]]) |
| 286 | + output:add_extmark(line_idx, { |
| 287 | + start_col = 2, |
| 288 | + virt_text = { { '› ', 'OpencodeDialogOptionHover' } }, |
| 289 | + virt_text_pos = 'overlay', |
| 290 | + } --[[@as OutputExtmark]]) |
| 291 | + end |
| 292 | + end |
| 293 | +end |
| 294 | + |
| 295 | +---Set up buffer-scoped keymaps |
| 296 | +function Dialog:_setup_keymaps() |
| 297 | + self:_clear_keymaps() |
| 298 | + |
| 299 | + local buf = self._config.buffer |
| 300 | + if not buf or not vim.api.nvim_buf_is_valid(buf) then |
| 301 | + return |
| 302 | + end |
| 303 | + |
| 304 | + local keymaps = self._config.keymaps |
| 305 | + local keymap_opts = { buffer = buf, silent = true } |
| 306 | + |
| 307 | + if keymaps.up then |
| 308 | + for _, key in ipairs(keymaps.up) do |
| 309 | + if key and key ~= '' then |
| 310 | + local id = vim.keymap.set('n', key, function() |
| 311 | + self:navigate(-1) |
| 312 | + end, keymap_opts) |
| 313 | + table.insert(self._keymaps, id) |
| 314 | + end |
| 315 | + end |
| 316 | + end |
| 317 | + |
| 318 | + if keymaps.down then |
| 319 | + for _, key in ipairs(keymaps.down) do |
| 320 | + if key and key ~= '' then |
| 321 | + local id = vim.keymap.set('n', key, function() |
| 322 | + self:navigate(1) |
| 323 | + end, keymap_opts) |
| 324 | + table.insert(self._keymaps, id) |
| 325 | + end |
| 326 | + end |
| 327 | + end |
| 328 | + |
| 329 | + if keymaps.select and keymaps.select ~= '' then |
| 330 | + local id = vim.keymap.set('n', keymaps.select, function() |
| 331 | + self:select() |
| 332 | + end, keymap_opts) |
| 333 | + table.insert(self._keymaps, id) |
| 334 | + end |
| 335 | + |
| 336 | + if keymaps.dismiss and keymaps.dismiss ~= '' then |
| 337 | + local id = vim.keymap.set('n', keymaps.dismiss, function() |
| 338 | + self:dismiss() |
| 339 | + end, keymap_opts) |
| 340 | + table.insert(self._keymaps, id) |
| 341 | + end |
| 342 | + |
| 343 | + if keymaps.number_shortcuts then |
| 344 | + local option_count = self._config.get_option_count() |
| 345 | + local number_keymap_opts = vim.tbl_extend('force', keymap_opts, { nowait = true }) |
| 346 | + for i = 1, math.min(option_count, 9) do |
| 347 | + local id = vim.keymap.set('n', tostring(i), function() |
| 348 | + if not self._active or not self._config.check_focused() then |
| 349 | + return |
| 350 | + end |
| 351 | + self._selected_index = i |
| 352 | + self._config.on_select(i) |
| 353 | + end, number_keymap_opts) |
| 354 | + table.insert(self._keymaps, id) |
| 355 | + end |
| 356 | + end |
| 357 | +end |
| 358 | + |
| 359 | +---Clear all buffer-scoped keymaps |
| 360 | +function Dialog:_clear_keymaps() |
| 361 | + for _, keymap_id in ipairs(self._keymaps) do |
| 362 | + pcall(vim.keymap.del, 'n', keymap_id, { buffer = self._config.buffer }) |
| 363 | + end |
| 364 | + self._keymaps = {} |
| 365 | +end |
| 366 | + |
| 367 | +return Dialog |
0 commit comments