diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index 295b20de..b0d226fa 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -142,6 +142,11 @@ M.defaults = { rendering = { markdown_debounce_ms = 250, on_data_rendered = nil, + markdown_on_idle = false, + -- If set to a number, markdown rendering will be deferred while + -- `state.user_message_count[session_id]` is greater than this value. + -- If `nil`, the existing behavior is used (defer while > 0). + markdown_on_idle_threshold = nil, event_throttle_ms = 40, event_collapsing = true, }, @@ -255,6 +260,8 @@ M.defaults = { enabled = false, capture_streamed_events = false, show_ids = true, + highlight_changed_lines = false, + highlight_changed_lines_timeout_ms = 120, quick_chat = { keep_session = false, set_active_session = false, diff --git a/lua/opencode/core.lua b/lua/opencode/core.lua index dd01d8ec..567952c6 100644 --- a/lua/opencode/core.lua +++ b/lua/opencode/core.lua @@ -545,6 +545,8 @@ M.initialize_current_model = Promise.async(function() end) M._on_user_message_count_change = Promise.async(function(_, new, old) + require('opencode.ui.renderer.flush').flush_pending_on_data_rendered() + if config.hooks and config.hooks.on_done_thinking then local all_sessions = session.get_all_workspace_sessions():await() local done_sessions = vim.tbl_filter(function(s) diff --git a/lua/opencode/id.lua b/lua/opencode/id.lua index 106089eb..01c0ebae 100644 --- a/lua/opencode/id.lua +++ b/lua/opencode/id.lua @@ -142,4 +142,3 @@ function M.get_prefixes() end return M - diff --git a/lua/opencode/throttling_emitter.lua b/lua/opencode/throttling_emitter.lua index 482932b8..fb6eabdc 100644 --- a/lua/opencode/throttling_emitter.lua +++ b/lua/opencode/throttling_emitter.lua @@ -46,7 +46,9 @@ function ThrottlingEmitter:_drain() local items_to_process = self.queue self.queue = {} - self.process_fn(items_to_process) + if #items_to_process > 0 then + self.process_fn(items_to_process) + end -- double check that items weren't added while processing if #self.queue > 0 and not self.drain_scheduled then diff --git a/lua/opencode/types.lua b/lua/opencode/types.lua index 3129acaf..a2b6d545 100644 --- a/lua/opencode/types.lua +++ b/lua/opencode/types.lua @@ -172,6 +172,7 @@ ---@class OpencodeUIOutputRenderingConfig ---@field markdown_debounce_ms number ---@field on_data_rendered (fun(buf: integer, win: integer)|boolean)|nil +---@field markdown_on_idle boolean ---@field event_throttle_ms number ---@field event_collapsing boolean @@ -207,6 +208,8 @@ ---@field enabled boolean ---@field capture_streamed_events boolean ---@field show_ids boolean +---@field highlight_changed_lines boolean +---@field highlight_changed_lines_timeout_ms number ---@field quick_chat {keep_session: boolean, set_active_session: boolean} ---@class OpencodeHooks diff --git a/lua/opencode/ui/base_picker.lua b/lua/opencode/ui/base_picker.lua index 9a289898..b924022f 100644 --- a/lua/opencode/ui/base_picker.lua +++ b/lua/opencode/ui/base_picker.lua @@ -85,6 +85,8 @@ local function telescope_ui(opts) local entry_display = require('telescope.pickers.entry_display') -- Create displayer dynamically based on number of parts + ---@param picker_item PickerItem + ---@return table local function create_displayer(picker_item) local items = {} for _ in ipairs(picker_item.parts) do @@ -129,6 +131,7 @@ local function telescope_ui(opts) return entry end + ---@return unknown local function refresh_picker() return current_picker and current_picker:refresh( @@ -232,6 +235,7 @@ end local function fzf_ui(opts) local fzf_lua = require('fzf-lua') + ---@return table local function create_fzf_config() local has_multi_action = util.some(opts.actions, function(action) return action.multi_selection @@ -261,6 +265,7 @@ local function fzf_ui(opts) } end + ---@return fun(fzf_cb: fun(line?: string)) local function create_finder() return function(fzf_cb) for idx, item in ipairs(opts.items) do @@ -299,6 +304,7 @@ local function fzf_ui(opts) end end + ---Reopen fzf-lua to reflect updated picker items. local function refresh_fzf() vim.schedule(function() fzf_ui(opts) @@ -644,6 +650,7 @@ function M.create_picker_item(parts) parts = parts, } + ---@return string function item:to_string() local texts = {} for _, part in ipairs(self.parts) do @@ -652,6 +659,7 @@ function M.create_picker_item(parts) return table.concat(texts, ' ') end + ---@return table function item:to_formatted_text() local formatted = {} for _, part in ipairs(self.parts) do diff --git a/lua/opencode/ui/dialog.lua b/lua/opencode/ui/dialog.lua index 2a0893db..6c3e56a5 100644 --- a/lua/opencode/ui/dialog.lua +++ b/lua/opencode/ui/dialog.lua @@ -261,7 +261,11 @@ function Dialog:format_dialog(output, config) local end_line = output:get_line_count() if config.border_hl then - formatter.add_vertical_border(output, start_line + 1, end_line, config.border_hl, -2) + local border_end = end_line + if config.extend_border_to_trailing_blank then + border_end = border_end + 1 + end + formatter.add_vertical_border(output, start_line + 1, border_end, config.border_hl, -2) end output:add_line('') @@ -277,15 +281,22 @@ function Dialog:format_options(output, options) label = label .. ' - ' .. option.description end - local line_idx = output:get_line_count() local is_selected = self._selected_index == i local line_text = is_selected and string.format(' %d. %s ', i, label) or string.format(' %d. %s', i, label) - output:add_line(line_text) + -- Output uses 0-based indexing for extmarks. The correct target for + -- extmarks is the previous line count (0-based) because add_line will + -- append a new line and increase the 1-based line count. Capture the + -- current count first and then add the line so we can use that 0-based + -- index for extmarks. + -- add_line returns a 1-based line index; Output extmarks use 0-based + -- keys, so subtract 1 to get the correct extmark key. + local added_idx = output:add_line(line_text) if is_selected then - output:add_extmark(line_idx, { line_hl_group = 'OpencodeDialogOptionHover' } --[[@as OutputExtmark]]) - output:add_extmark(line_idx, { + local extmark_idx = added_idx - 1 + output:add_extmark(extmark_idx, { line_hl_group = 'OpencodeDialogOptionHover' } --[[@as OutputExtmark]]) + output:add_extmark(extmark_idx, { start_col = 2, virt_text = { { '› ', 'OpencodeDialogOptionHover' } }, virt_text_pos = 'overlay', @@ -309,11 +320,16 @@ function Dialog:_setup_keymaps() if keymaps.up then for _, key in ipairs(keymaps.up) do if key and key ~= '' then - vim.keymap.set('n', key, function() - self:navigate(-1) - end, vim.tbl_extend('force', keymap_opts, { - desc = 'Dialog: navigate up', - })) + vim.keymap.set( + 'n', + key, + function() + self:navigate(-1) + end, + vim.tbl_extend('force', keymap_opts, { + desc = 'Dialog: navigate up', + }) + ) table.insert(self._keymaps, key) end end @@ -322,31 +338,46 @@ function Dialog:_setup_keymaps() if keymaps.down then for _, key in ipairs(keymaps.down) do if key and key ~= '' then - vim.keymap.set('n', key, function() - self:navigate(1) - end, vim.tbl_extend('force', keymap_opts, { - desc = 'Dialog: navigate down', - })) + vim.keymap.set( + 'n', + key, + function() + self:navigate(1) + end, + vim.tbl_extend('force', keymap_opts, { + desc = 'Dialog: navigate down', + }) + ) table.insert(self._keymaps, key) end end end if keymaps.select and keymaps.select ~= '' then - vim.keymap.set('n', keymaps.select, function() - self:select() - end, vim.tbl_extend('force', keymap_opts, { - desc = 'Dialog: select option', - })) + vim.keymap.set( + 'n', + keymaps.select, + function() + self:select() + end, + vim.tbl_extend('force', keymap_opts, { + desc = 'Dialog: select option', + }) + ) table.insert(self._keymaps, keymaps.select) end if keymaps.dismiss and keymaps.dismiss ~= '' then - vim.keymap.set('n', keymaps.dismiss, function() - self:dismiss() - end, vim.tbl_extend('force', keymap_opts, { - desc = 'Dialog: dismiss', - })) + vim.keymap.set( + 'n', + keymaps.dismiss, + function() + self:dismiss() + end, + vim.tbl_extend('force', keymap_opts, { + desc = 'Dialog: dismiss', + }) + ) table.insert(self._keymaps, keymaps.dismiss) end @@ -355,15 +386,20 @@ function Dialog:_setup_keymaps() local number_keymap_opts = vim.tbl_extend('force', keymap_opts, { nowait = true }) for i = 1, math.min(option_count, 9) do local key = tostring(i) - vim.keymap.set('n', key, function() - if not self._active or not self._config.check_focused() then - return - end - self._selected_index = i - self._config.on_select(i) - end, vim.tbl_extend('force', number_keymap_opts, { - desc = 'Dialog: select option ' .. key, - })) + vim.keymap.set( + 'n', + key, + function() + if not self._active or not self._config.check_focused() then + return + end + self._selected_index = i + self._config.on_select(i) + end, + vim.tbl_extend('force', number_keymap_opts, { + desc = 'Dialog: select option ' .. key, + }) + ) table.insert(self._keymaps, key) end end diff --git a/lua/opencode/ui/formatter.lua b/lua/opencode/ui/formatter.lua index dc025960..835331ab 100644 --- a/lua/opencode/ui/formatter.lua +++ b/lua/opencode/ui/formatter.lua @@ -61,7 +61,6 @@ function M._format_revert_message(session_data, start_idx) local message_text = stats.messages == 1 and 'message' or 'messages' local tool_text = stats.tool_calls == 1 and 'tool call' or 'tool calls' - output:add_lines(M.separator) output:add_line( string.format('> %d %s reverted, %d %s reverted', stats.messages, message_text, stats.tool_calls, tool_text) ) @@ -95,9 +94,17 @@ function M._format_revert_message(session_data, start_idx) end end end + + output:add_empty_line() return output end +---@param output Output +---@param text string +---@param action_type string +---@param args any[] +---@param key? string +---@param line? integer local function add_action(output, text, action_type, args, key, line) -- actions use api-indexing (e.g. 0 indexed) line = (line or output:get_line_count()) - 1 @@ -154,6 +161,11 @@ end function M.format_message_header(message) local output = Output.new() + if message.info and message.info.id == '__opencode_revert_message__' then + output:add_lines(M.separator) + return output + end + output:add_lines(M.separator) local role = message.info.role or 'unknown' local icon = message.info.role == 'user' and icons.get('header_user') or icons.get('header_assistant') @@ -169,15 +181,10 @@ function M.format_message_header(message) local mode = message.info.mode if mode and mode ~= '' then display_name = mode:upper() + elseif state.current_mode and state.current_mode ~= '' then + display_name = state.current_mode:upper() else - -- For the most recent assistant message, show current_mode if mode is missing - -- This handles new messages that haven't been stamped yet - local is_last_message = #state.messages == 0 or message.info.id == state.messages[#state.messages].info.id - if is_last_message and state.current_mode and state.current_mode ~= '' then - display_name = state.current_mode:upper() - else - display_name = 'ASSISTANT' - end + display_name = 'ASSISTANT' end else display_name = role:upper() @@ -291,11 +298,35 @@ end ---@param output Output Output object to write to ---@param part OpencodeMessagePart function M._format_selection_context(output, part) + local part_message = part._message_context local json = context_module.decode_json_context(part.text or '', 'selection') if not json then return end - local start_line = output:get_line_count() + local start_line = output:get_line_count() + 1 + + if part_message and part_message.parts then + for i, message_part in ipairs(part_message.parts) do + if message_part.id == part.id then + local previous_part = part_message.parts[i - 1] + if previous_part and previous_part.type == 'text' and previous_part.synthetic then + local has_selection = context_module.decode_json_context(previous_part.text or '', 'selection') ~= nil + local has_cursor = context_module.decode_json_context(previous_part.text or '', 'cursor-data') ~= nil + local diagnostics = context_module.decode_json_context(previous_part.text or '', 'diagnostics') + local has_diagnostics = diagnostics + and diagnostics.content + and type(diagnostics.content) == 'table' + and #diagnostics.content > 0 + + if has_selection or has_cursor or has_diagnostics then + start_line = output:get_line_count() + end + end + break + end + end + end + output:add_lines(vim.split(json.content or '', '\n')) output:add_empty_line() @@ -359,6 +390,81 @@ function M._format_diagnostics_context(output, part) M.add_vertical_border(output, start_line, end_line, 'OpencodeMessageRoleUser', -3) end +---@param part OpencodeMessagePart|nil +---@return string|nil +local function get_visible_user_part_kind(part) + if not part then + return nil + end + + if part.type == 'file' and part.filename and part.filename ~= '' then + return 'file' + end + + if part.type ~= 'text' or not part.text or part.text == '' then + return nil + end + + if not part.synthetic then + return 'text' + end + + if context_module.decode_json_context(part.text, 'selection') then + return 'selection' + end + + if context_module.decode_json_context(part.text, 'cursor-data') then + return 'cursor-data' + end + + local diagnostics = context_module.decode_json_context(part.text, 'diagnostics') + if diagnostics and diagnostics.content and type(diagnostics.content) == 'table' and #diagnostics.content > 0 then + return 'diagnostics' + end + + return nil +end + +---@param message OpencodeMessage|nil +---@param part OpencodeMessagePart|nil +---@return string|nil previous_kind +---@return string|nil next_kind +local function get_user_part_neighbors(message, part) + if not message or not message.parts or not part or not part.id then + return nil, nil + end + + local current_index = nil + for i, message_part in ipairs(message.parts) do + if message_part.id == part.id then + current_index = i + break + end + end + + if not current_index then + return nil, nil + end + + local previous_kind = nil + for i = current_index - 1, 1, -1 do + previous_kind = get_visible_user_part_kind(message.parts[i]) + if previous_kind then + break + end + end + + local next_kind = nil + for i = current_index + 1, #message.parts do + next_kind = get_visible_user_part_kind(message.parts[i]) + if next_kind then + break + end + end + + return previous_kind, next_kind +end + ---Format and display the file path in the context ---@param output Output Output object to write to ---@param path string|nil File path @@ -450,19 +556,15 @@ end ---@param win_col number ---@param text_hl_group? string Optional highlight group for the background/foreground of text lines function M.add_vertical_border(output, start_line, end_line, hl_group, win_col, text_hl_group) - for line = start_line, end_line do - local extmark_opts = { - virt_text = { { require('opencode.ui.icons').get('border'), hl_group } }, - virt_text_pos = 'overlay', - virt_text_win_col = win_col, - virt_text_repeat_linebreak = true, - } - - -- Add line highlight if text_hl_group is provided - if text_hl_group then - extmark_opts.line_hl_group = text_hl_group - end + local extmark_opts = { + virt_text = { { require('opencode.ui.icons').get('border'), hl_group } }, + virt_text_pos = 'overlay', + virt_text_win_col = win_col, + virt_text_repeat_linebreak = true, + line_hl_group = text_hl_group or nil, + } + for line = start_line, end_line do output:add_extmark(line - 1, extmark_opts --[[@as OutputExtmark]]) end end @@ -486,9 +588,11 @@ function M.format_part(part, message, is_last_part, get_child_parts) if role == 'user' then if part.type == 'text' and part.text then if part.synthetic == true then + part._message_context = message M._format_selection_context(output, part) M._format_cursor_data_context(output, part) M._format_diagnostics_context(output, part) + part._message_context = nil else M._format_user_prompt(output, vim.trim(part.text), message) content_added = true @@ -496,7 +600,18 @@ function M.format_part(part, message, is_last_part, get_child_parts) elseif part.type == 'file' then local file_line = M._format_context_file(output, part.filename) if file_line then - M.add_vertical_border(output, file_line - 1, file_line, 'OpencodeMessageRoleUser', -3) + local previous_kind, next_kind = get_user_part_neighbors(message, part) + local previous_is_context = previous_kind == 'selection' + or previous_kind == 'cursor-data' + or previous_kind == 'diagnostics' + + if next_kind == 'text' or (previous_is_context and not next_kind) then + M.add_vertical_border(output, file_line - 1, file_line, 'OpencodeMessageRoleUser', -3) + elseif next_kind == 'file' then + M.add_vertical_border(output, file_line, file_line + 1, 'OpencodeMessageRoleUser', -3) + else + M.add_vertical_border(output, file_line, file_line, 'OpencodeMessageRoleUser', -3) + end content_added = true end end @@ -522,6 +637,12 @@ function M.format_part(part, message, is_last_part, get_child_parts) local question_window = require('opencode.ui.question_window') question_window.format_display(output) content_added = true + elseif part.type == 'revert-display' then + local revert_index = part.state and part.state.revert_index + if revert_index then + output = M._format_revert_message(state.messages or {}, revert_index) + content_added = output:get_line_count() > 0 + end end end diff --git a/lua/opencode/ui/formatter/tools/grep.lua b/lua/opencode/ui/formatter/tools/grep.lua index 2dcc4338..131c9d3c 100644 --- a/lua/opencode/ui/formatter/tools/grep.lua +++ b/lua/opencode/ui/formatter/tools/grep.lua @@ -1,14 +1,35 @@ local icons = require('opencode.ui.icons') local M = {} +---@param value any +---@return string +local function normalize_part(value) + if value == nil or value == vim.NIL then + return '' + end + + local value_type = type(value) + if value_type == 'string' then + return value + end + if value_type == 'number' or value_type == 'boolean' then + return tostring(value) + end + + return '' +end + ---@param input GrepToolInput|nil ---@return string local function resolve_grep_string(input) if not input then return '' end - local path_part = input.path or input.include or '' - local pattern_part = input.pattern or '' + local path_part = normalize_part(input.path) + if path_part == '' then + path_part = normalize_part(input.include) + end + local pattern_part = normalize_part(input.pattern) return table.concat( vim.tbl_filter(function(p) return p ~= nil and p ~= '' diff --git a/lua/opencode/ui/formatter/tools/task.lua b/lua/opencode/ui/formatter/tools/task.lua index 808f74a0..3c9b00cf 100644 --- a/lua/opencode/ui/formatter/tools/task.lua +++ b/lua/opencode/ui/formatter/tools/task.lua @@ -79,8 +79,8 @@ function M.format(output, part, get_child_parts) type = 'select_child_session', args = {}, key = 'S', - display_line = start_line - 1, - range = { from = start_line, to = end_line }, + display_line = start_line, + range = { from = start_line + 1, to = end_line + 1 }, }) end diff --git a/lua/opencode/ui/highlight.lua b/lua/opencode/ui/highlight.lua index 84e26c9a..1e5bb4c2 100644 --- a/lua/opencode/ui/highlight.lua +++ b/lua/opencode/ui/highlight.lua @@ -1,5 +1,6 @@ local M = {} +---Define the plugin highlight groups for the current background. function M.setup() local is_light = vim.o.background == 'light' @@ -47,6 +48,7 @@ function M.setup() vim.api.nvim_set_hl(0, 'OpencodeQuestionOption', { link = 'Normal', default = true }) vim.api.nvim_set_hl(0, 'OpencodeQuestionBorder', { fg = '#E3F2FD', default = true }) vim.api.nvim_set_hl(0, 'OpencodeQuestionTitle', { link = '@label', bold = true, default = true }) + vim.api.nvim_set_hl(0, 'OpencodeChangedLines', { bg = '#FFF3BF', default = true }) else vim.api.nvim_set_hl(0, 'OpencodeBorder', { fg = '#616161', default = true }) vim.api.nvim_set_hl(0, 'OpencodeBackground', { link = 'Normal', default = true }) @@ -90,6 +92,7 @@ function M.setup() vim.api.nvim_set_hl(0, 'OpencodeQuestionOption', { link = 'Normal', default = true }) vim.api.nvim_set_hl(0, 'OpencodeQuestionBorder', { fg = '#2B3A5A', default = true }) vim.api.nvim_set_hl(0, 'OpencodeQuestionTitle', { link = '@label', bold = true, default = true }) + vim.api.nvim_set_hl(0, 'OpencodeChangedLines', { bg = '#3D3520', default = true }) end end diff --git a/lua/opencode/ui/output_window.lua b/lua/opencode/ui/output_window.lua index b703c748..8857cf4d 100644 --- a/lua/opencode/ui/output_window.lua +++ b/lua/opencode/ui/output_window.lua @@ -4,6 +4,10 @@ local window_options = require('opencode.ui.window_options') local M = {} M.namespace = vim.api.nvim_create_namespace('opencode_output') +M.debug_namespace = vim.api.nvim_create_namespace('opencode_output_debug') +M.markdown_namespace = vim.api.nvim_create_namespace('opencode_output_markdown') +M._last_visible_bottom_by_win = {} +M._was_at_bottom_by_win = {} local _update_depth = 0 local _update_buf = nil @@ -36,6 +40,7 @@ function M.end_update() end end +---@return integer function M.create_buf() local output_buf = vim.api.nvim_create_buf(false, true) local filetype = config.ui.output.filetype or 'opencode_output' @@ -49,6 +54,7 @@ function M.create_buf() return output_buf end +---@return vim.api.keyset.win_config function M._build_output_win_config() return { relative = 'editor', @@ -75,9 +81,21 @@ function M.buffer_valid(windows) return windows and windows.output_buf and vim.api.nvim_buf_is_valid(windows.output_buf) end ----Check if the cursor in output window is at the bottom +---Check if the output window viewport is scrolled to the bottom of the buffer. +---Returns true if the output window should continue auto-scrolling to follow +---new content. Uses the viewport position (visible bottom line) rather than +---the cursor, so that mouse-wheel scrolling—which moves the viewport but not +---the cursor—correctly stops the tail-follow behavior. +--- +---The `_was_at_bottom_by_win` flag is the persistent signal: it is set to +---`true` by `scroll_win_to_bottom` and cleared to `false` by +---`sync_cursor_with_viewport` whenever the viewport is scrolled away from the +---buffer's last line. Reading a sticky flag (rather than the live viewport +---position) lets callers like `renderer.scroll_to_bottom()` that run *after* +---a buffer write still return the correct answer even though the viewport has +---not yet caught up to the newly appended lines. ---@param win? integer Window ID, defaults to state.windows.output_win ----@return boolean true if cursor at bottom, false otherwise +---@return boolean function M.is_at_bottom(win) if config.ui.output.always_scroll_to_bottom then return true @@ -98,18 +116,83 @@ function M.is_at_bottom(win) return true end - local ok2, cursor = pcall(vim.api.nvim_win_get_cursor, win) - if not ok2 then + -- Prefer the sticky flag when it has been set by scroll/WinScrolled events. + -- Fall back to a live viewport check on the very first call (flag is nil). + if M._was_at_bottom_by_win[win] ~= nil then + return M._was_at_bottom_by_win[win] == true + end + + local visible_bottom = M.get_visible_bottom_line(win) + if not visible_bottom then return true end - return cursor[1] >= line_count + return visible_bottom >= line_count +end + +---@param win? integer +---@return integer|nil +function M.get_visible_bottom_line(win) + win = win or (state.windows and state.windows.output_win) + if not win or not vim.api.nvim_win_is_valid(win) then + return nil + end + local ok, line = pcall(vim.fn.line, 'w$', win) + return (ok and line and line > 0) and line or nil +end + +---@param win? integer +function M.reset_scroll_tracking(win) + if win then + M._last_visible_bottom_by_win[win] = nil + M._was_at_bottom_by_win[win] = nil + return + end + + M._last_visible_bottom_by_win = {} + M._was_at_bottom_by_win = {} +end + +---@param win? integer +function M.sync_cursor_with_viewport(win) + win = win or (state.windows and state.windows.output_win) + if not win or not vim.api.nvim_win_is_valid(win) then + return + end + + local windows = state.windows + local buf = windows and windows.output_buf + if not buf or not vim.api.nvim_buf_is_valid(buf) or vim.api.nvim_win_get_buf(win) ~= buf then + M.reset_scroll_tracking(win) + return + end + + local ok, line_count = pcall(vim.api.nvim_buf_line_count, buf) + local visible_bottom = M.get_visible_bottom_line(win) + if not ok or not line_count or line_count == 0 or not visible_bottom then + return + end + + M._last_visible_bottom_by_win[win] = visible_bottom + + -- Update the sticky at-bottom flag based on whether the viewport now shows + -- the last line. This is the key mechanism: when the user scrolls up (mouse + -- or keyboard), WinScrolled fires here and clears the flag so that the next + -- `is_at_bottom()` call returns false and streaming stops following the tail. + M._was_at_bottom_by_win[win] = visible_bottom >= line_count end +---@param windows OpencodeWindowState function M.setup(windows) - window_options.set_window_option('winhighlight', config.ui.window_highlight, windows.output_win, { save_original = true }) + window_options.set_window_option( + 'winhighlight', + config.ui.window_highlight, + windows.output_win, + { save_original = true } + ) window_options.set_window_option('wrap', true, windows.output_win, { save_original = true }) window_options.set_window_option('linebreak', true, windows.output_win, { save_original = true }) + window_options.set_window_option('cursorline', false, windows.output_win, { save_original = true }) window_options.set_window_option('number', false, windows.output_win, { save_original = true }) window_options.set_window_option('relativenumber', false, windows.output_win, { save_original = true }) window_options.set_buffer_option('modifiable', false, windows.output_buf) @@ -117,6 +200,8 @@ function M.setup(windows) window_options.set_buffer_option('bufhidden', 'hide', windows.output_buf) window_options.set_buffer_option('buflisted', false, windows.output_buf) window_options.set_buffer_option('swapfile', false, windows.output_buf) + window_options.set_buffer_option('undofile', false, windows.output_buf) + window_options.set_buffer_option('undolevels', -1, windows.output_buf) if config.ui.position ~= 'current' then window_options.set_window_option('winfixbuf', true, windows.output_win, { save_original = true }) @@ -128,9 +213,12 @@ function M.setup(windows) window_options.set_window_option('statuscolumn', '', windows.output_win, { save_original = true }) M.update_dimensions(windows) + M.reset_scroll_tracking(windows.output_win) + M._last_visible_bottom_by_win[windows.output_win] = M.get_visible_bottom_line(windows.output_win) M.setup_keymaps(windows) end +---@param windows OpencodeWindowState? function M.update_dimensions(windows) if config.ui.position == 'current' then return @@ -166,6 +254,7 @@ function M.update_dimensions(windows) pcall(vim.api.nvim_win_set_config, windows.output_win, { width = width }) end +---@return integer function M.get_buf_line_count() local windows = state.windows if not windows or not windows.output_buf or not vim.api.nvim_buf_is_valid(windows.output_buf) then @@ -188,6 +277,26 @@ function M.set_lines(lines, start_line, end_line) start_line = start_line or 0 end_line = end_line or -1 + -- Skip identical content outside of batch mode to avoid unnecessary writes + -- that cause flicker (e.g. when a markdown plugin re-renders an unchanged part). + -- Inside begin_update/end_update the caller controls exactly what is written, + -- so the check would be redundant and expensive. + if _update_depth == 0 then + local ok, existing = pcall(vim.api.nvim_buf_get_lines, buf, start_line, end_line, false) + if ok and existing and #existing == #lines then + local same = true + for i = 1, #lines do + if existing[i] ~= lines[i] then + same = false + break + end + end + if same then + return + end + end + end + if _update_depth == 0 then vim.api.nvim_set_option_value('modifiable', true, { buf = buf }) vim.api.nvim_buf_set_lines(buf, start_line, end_line, false, lines) @@ -229,23 +338,73 @@ function M.set_extmarks(extmarks, line_offset) local output_buf = windows.output_buf - for line_idx, marks in pairs(extmarks) do + local line_indices = vim.tbl_keys(extmarks) + table.sort(line_indices) + + for _, line_idx in ipairs(line_indices) do + local marks = extmarks[line_idx] + table.sort(marks, function(a, b) + local ma = type(a) == 'function' and a() or a + local mb = type(b) == 'function' and b() or b + return (ma.priority or 0) > (mb.priority or 0) + end) + for _, mark in ipairs(marks) do - local actual_mark = type(mark) == 'function' and mark() or mark + local m = type(mark) == 'function' and mark() or mark local target_line = line_offset + line_idx --[[@as integer]] - if actual_mark.end_row then - actual_mark.end_row = actual_mark.end_row + line_offset - end - local start_col = actual_mark.start_col - if actual_mark.start_col then - actual_mark.start_col = nil + local start_col = m.start_col + -- Only deepcopy when we need to mutate: start_col must be removed from the + -- opts table, and end_row must be offset when line_offset is non-zero. + -- The vast majority of extmarks (border virt_text) have neither field, so + -- we avoid 100k+ deepcopy calls during a full session render. + if start_col ~= nil or (m.end_row ~= nil and line_offset ~= 0) then + m = vim.deepcopy(m) + m.start_col = nil + if m.end_row then + m.end_row = m.end_row + line_offset + end end - ---@cast actual_mark vim.api.keyset.set_extmark - pcall(vim.api.nvim_buf_set_extmark, output_buf, M.namespace, target_line, start_col or 0, actual_mark) + ---@cast m vim.api.keyset.set_extmark + pcall(vim.api.nvim_buf_set_extmark, output_buf, M.namespace, target_line, start_col or 0, m) end end end +---@param start_line integer +---@param end_line integer +function M.highlight_changed_lines(start_line, end_line) + local windows = state.windows + if not windows or not windows.output_buf or not vim.api.nvim_buf_is_valid(windows.output_buf) then + return + end + if not config.debug.highlight_changed_lines then + return + end + + local buf = windows.output_buf + local first = math.max(0, start_line) + if end_line < start_line then + return + end + local last = math.max(first, end_line) + + vim.api.nvim_buf_clear_namespace(buf, M.debug_namespace, first, last + 1) + for line = first, last do + vim.api.nvim_buf_set_extmark(buf, M.debug_namespace, line, 0, { + line_hl_group = 'OpencodeChangedLines', + hl_eol = true, + priority = 250, + }) + end + + vim.defer_fn(function() + if vim.api.nvim_buf_is_valid(buf) then + vim.api.nvim_buf_clear_namespace(buf, M.debug_namespace, first, last + 1) + end + end, config.debug.highlight_changed_lines_timeout_ms or 120) +end + +---@param should_stop_insert? boolean function M.focus_output(should_stop_insert) if not M.mounted() then return @@ -258,21 +417,26 @@ function M.focus_output(should_stop_insert) vim.api.nvim_set_current_win(state.windows.output_win) end +---Close and delete the output window and buffer. function M.close() if not M.mounted() then return end ---@cast state.windows { output_win: integer, output_buf: integer } + M.reset_scroll_tracking(state.windows.output_win) pcall(vim.api.nvim_win_close, state.windows.output_win, true) pcall(vim.api.nvim_buf_delete, state.windows.output_buf, { force = true }) end +---@param windows OpencodeWindowState function M.setup_keymaps(windows) local keymap = require('opencode.keymap') keymap.setup_window_keymaps(config.keymap.output_window, windows.output_buf) end +---@param windows OpencodeWindowState +---@param group integer function M.setup_autocmds(windows, group) vim.api.nvim_create_autocmd('WinEnter', { group = group, @@ -313,40 +477,12 @@ function M.setup_autocmds(windows, group) group = group, buffer = windows.output_buf, callback = function() - if not windows.output_win or not vim.api.nvim_win_is_valid(windows.output_win) then - return - end - - local ok, cursor = pcall(vim.api.nvim_win_get_cursor, windows.output_win) - if not ok then - return - end - - local ok2, line_count = pcall(vim.api.nvim_buf_line_count, windows.output_buf) - if not ok2 or line_count == 0 then - return - end - - if cursor[1] >= line_count then - local ok3, view = pcall(vim.api.nvim_win_call, windows.output_win, vim.fn.winsaveview) - if ok3 and type(view) == 'table' then - local topline = view.topline or 1 - local win_height = vim.api.nvim_win_get_height(windows.output_win) - local visible_bottom = math.min(topline + win_height - 1, line_count) - - if visible_bottom < line_count then - pcall(vim.api.nvim_win_set_cursor, windows.output_win, { visible_bottom, 0 }) - local pos = state.ui.get_window_cursor(windows.output_win) - if pos then - state.ui.set_cursor_position('output', pos) - end - end - end - end + M.sync_cursor_with_viewport(windows.output_win) end, }) end +---Clear the output buffer and all namespaces. function M.clear() M.set_lines({}) -- clear extmarks in all namespaces as I've seen RenderMarkdown leave some diff --git a/lua/opencode/ui/question_window.lua b/lua/opencode/ui/question_window.lua index 11505e66..2a701e03 100644 --- a/lua/opencode/ui/question_window.lua +++ b/lua/opencode/ui/question_window.lua @@ -12,10 +12,51 @@ M._collected_answers = {} M._answering = false M._dialog = nil +---@param question_request OpencodeQuestionRequest|nil +---@return boolean +function M.matches_active_question(question_request) + return question_request ~= nil + and M._current_question ~= nil + and question_request.id ~= nil + and M._current_question.id == question_request.id +end + +---@param question_request OpencodeQuestionRequest|nil +---@return boolean +function M.belongs_to_active_session(question_request) + if not question_request then + return false + end + + local active_session = state.active_session + if active_session and active_session.id and question_request.sessionID == active_session.id then + return true + end + + local tool = question_request.tool + local tool_message_id = tool and tool.messageID + if tool_message_id and state.messages then + for _, message in ipairs(state.messages) do + if message.info and message.info.id == tool_message_id then + return true + end + end + end + + if question_request.sessionID and question_request.sessionID ~= '' then + local render_state = require('opencode.ui.renderer.ctx').render_state + return render_state:get_task_part_by_child_session(question_request.sessionID) ~= nil + end + + return false +end + +---Request the renderer to show the current question display. local function render_question() require('opencode.ui.renderer.events').render_question_display() end +---Request the renderer to remove the current question display. local function clear_question() require('opencode.ui.renderer.events').clear_question_display() end @@ -38,6 +79,41 @@ function M.show_question(question_request) end end +---@param session_id string|nil +function M.restore_pending_question(session_id) + if not state.api_client or not session_id or session_id == '' then + return + end + + if M.has_question() and M.belongs_to_active_session(M._current_question) then + return + end + + state.api_client:list_questions() + :and_then(function(requests) + if not requests or type(requests) ~= 'table' then + return + end + + for _, request in ipairs(requests) do + if request and request.questions and #request.questions > 0 and M.belongs_to_active_session(request) then + if M.matches_active_question(request) then + return + end + + M.show_question(request) + return + end + end + end) + :catch(function(err) + vim.schedule(function() + vim.notify('Failed to restore pending question: ' .. vim.inspect(err), vim.log.levels.WARN) + end) + end) +end + +---Reset the current question state and remove any dialog UI. function M.clear_question() M._clear_dialog() M._current_question = nil @@ -88,6 +164,8 @@ local function answer_current_question(answer_value) end) end +---@param options OpencodeQuestionOption[] +---@return integer|nil local function find_other_option(options) for i, opt in ipairs(options) do if vim.startswith(opt.label:lower(), 'other') then @@ -97,6 +175,8 @@ local function find_other_option(options) return nil end +---@param question_info OpencodeQuestionInfo +---@return integer local function get_total_options(question_info) local has_other = find_other_option(question_info.options) ~= nil return has_other and #question_info.options or (#question_info.options + 1) @@ -125,6 +205,7 @@ function M._answer_with_option(option_index) answer_current_question(question_info.options[option_index].label) end +---Prompt for a free-form answer to the active question. function M._answer_with_custom() vim.ui.input({ prompt = 'Enter your response: ' }, function(input) if input and input ~= '' then @@ -137,6 +218,8 @@ function M._answer_with_custom() end) end +---@param options OpencodeQuestionOption[] +---@return OpencodeQuestionOption[] local function add_other_if_missing(options) if find_other_option(options) ~= nil then return options @@ -185,6 +268,7 @@ function M.format_display(output) }) end +---Create the in-buffer dialog used to answer the active question. function M._setup_dialog() if not M.has_question() then return @@ -199,11 +283,13 @@ function M._setup_dialog() local buf = state.windows.output_buf + ---@return boolean local function check_focused() local ui = require('opencode.ui.ui') return ui.is_opencode_focused() and M.has_question() end + ---@param index integer local function on_select(index) if not check_focused() then return @@ -216,6 +302,7 @@ function M._setup_dialog() end, 100) end + ---Reject the current question if the dialog is dismissed. local function on_dismiss() if not check_focused() then return @@ -225,10 +312,12 @@ function M._setup_dialog() render_question() end + ---Refresh the rendered question state after navigation changes. local function on_navigate() render_question() end + ---@return integer local function get_option_count() local question_info = M.get_current_question_info() return question_info and get_total_options(question_info) or 0 @@ -247,6 +336,7 @@ function M._setup_dialog() M._dialog:setup() end +---Tear down the active question dialog, if any. function M._clear_dialog() if M._dialog then M._dialog:teardown() diff --git a/lua/opencode/ui/renderer.lua b/lua/opencode/ui/renderer.lua index fd3fd77d..98e25c38 100644 --- a/lua/opencode/ui/renderer.lua +++ b/lua/opencode/ui/renderer.lua @@ -1,12 +1,12 @@ local state = require('opencode.state') local config = require('opencode.config') -local formatter = require('opencode.ui.formatter') local output_window = require('opencode.ui.output_window') local permission_window = require('opencode.ui.permission_window') local Promise = require('opencode.promise') local ctx = require('opencode.ui.renderer.ctx') -local buf = require('opencode.ui.renderer.buffer') local events = require('opencode.ui.renderer.events') +local flush = require('opencode.ui.renderer.flush') +local scroll = require('opencode.ui.renderer.scroll') local M = {} @@ -14,23 +14,6 @@ local M = {} -- can be stubbed cleanly (e.g. stub(renderer, '_render_full_session_data')) M.on_session_updated = events.on_session_updated -local trigger_on_data_rendered = require('opencode.util').debounce(function() - local cb_type = type(config.ui.output.rendering.on_data_rendered) - if cb_type == 'boolean' then - return - end - if not state.windows or not state.windows.output_buf or not state.windows.output_win then - return - end - if cb_type == 'function' then - pcall(config.ui.output.rendering.on_data_rendered, state.windows.output_buf, state.windows.output_win) - elseif vim.fn.exists(':RenderMarkdown') > 0 then - vim.cmd(':RenderMarkdown') - elseif vim.fn.exists(':Markview') > 0 then - vim.cmd(':Markview render ' .. state.windows.output_buf) - end -end, config.ui.output.rendering.markdown_debounce_ms or 250) - ---Reset all renderer state and clear the output buffer function M.reset() ctx:reset() @@ -45,7 +28,7 @@ function M.reset() permission_window.clear_all() state.renderer.reset() - trigger_on_data_rendered() + flush.trigger_on_data_rendered() end ---Unsubscribe from all events and reset @@ -141,6 +124,8 @@ function M._render_full_session_data(session_data) local revert_index = nil local set_mode_from_messages = not state.current_model + flush.begin_bulk_mode() + for i, msg in ipairs(session_data) do if state.active_session.revert and state.active_session.revert.messageID == msg.info.id then revert_index = i @@ -152,9 +137,32 @@ function M._render_full_session_data(session_data) end if revert_index then - buf.write_formatted_data(formatter._format_revert_message(state.messages, revert_index)) + local revert_message = { + info = { + id = '__opencode_revert_message__', + sessionID = state.active_session.id, + role = 'system', + }, + parts = { + { + id = '__opencode_revert_part__', + messageID = '__opencode_revert_message__', + sessionID = state.active_session.id, + type = 'revert-display', + state = { + revert_index = revert_index, + }, + }, + }, + } + + events.on_message_updated(revert_message) + events.on_part_updated({ part = revert_message.parts[1] }) end + flush.flush() + flush.end_bulk_mode() + if set_mode_from_messages then set_model_and_mode_from_messages() end @@ -172,7 +180,14 @@ function M.render_full_session() if not output_window.mounted() or not state.api_client then return Promise.new():resolve(nil) end - return fetch_session():and_then(M._render_full_session_data) + return fetch_session():and_then(function(session_data) + M._render_full_session_data(session_data) + local active_session = state.active_session + if active_session and active_session.id then + require('opencode.ui.question_window').restore_pending_question(active_session.id) + end + return session_data + end) end ---Replace the entire output buffer with the given lines @@ -192,6 +207,7 @@ function M.render_output(output_data) output_window.set_lines(output_data.lines or {}) output_window.clear_extmarks() output_window.set_extmarks(output_data.extmarks) + flush.trigger_on_data_rendered() M.scroll_to_bottom() end @@ -210,30 +226,8 @@ function M.scroll_to_bottom(force) return end - local ok, line_count = pcall(vim.api.nvim_buf_line_count, output_buf) - if not ok or line_count == 0 then - return - end - - local prev_line_count = ctx.prev_line_count - ctx.prev_line_count = line_count - - trigger_on_data_rendered() - - local should_scroll = force - or prev_line_count == 0 - or config.ui.output.always_scroll_to_bottom - or (function() - local ok_cursor, cursor = pcall(vim.api.nvim_win_get_cursor, output_win) - return ok_cursor and cursor and (cursor[1] >= prev_line_count or cursor[1] >= line_count) - end)() - - if should_scroll then - local last_line = vim.api.nvim_buf_get_lines(output_buf, line_count - 1, line_count, false)[1] or '' - vim.api.nvim_win_set_cursor(output_win, { line_count, #last_line }) - vim.api.nvim_win_call(output_win, function() - vim.cmd('normal! zb') - end) + if force or config.ui.output.always_scroll_to_bottom or output_window.is_at_bottom(output_win) then + scroll.scroll_win_to_bottom(output_win, output_buf) end end @@ -242,8 +236,8 @@ function M.on_focus_changed() if not permission_window.get_all_permissions()[1] then return end - buf.rerender_part('permission-display-part') - trigger_on_data_rendered() + flush.mark_part_dirty('permission-display-part', 'permission-display-message') + flush.flush() end ---Re-render when the active session changes diff --git a/lua/opencode/ui/renderer/append.lua b/lua/opencode/ui/renderer/append.lua new file mode 100644 index 00000000..52fbd045 --- /dev/null +++ b/lua/opencode/ui/renderer/append.lua @@ -0,0 +1,43 @@ +local M = {} + +---@param old_lines string[] +---@param new_lines string[] +---@return boolean +function M.is_append_only(old_lines, new_lines) + local old_count = #old_lines + if #new_lines <= old_count then + return false + end + + for i = old_count, 1, -1 do + if old_lines[i] ~= new_lines[i] then + return false + end + end + + return true +end + +---@param old_lines string[] +---@param new_lines string[] +---@return string[] +function M.tail_lines(old_lines, new_lines) + return vim.list_slice(new_lines, #old_lines + 1, #new_lines) +end + +---@param row_offset integer +---@param extmarks table|nil +---@return table +function M.tail_extmarks(row_offset, extmarks) + local tail = {} + + for line_idx, marks in pairs(extmarks or {}) do + if line_idx >= row_offset then + tail[line_idx - row_offset] = vim.deepcopy(marks) + end + end + + return tail +end + +return M diff --git a/lua/opencode/ui/renderer/buffer.lua b/lua/opencode/ui/renderer/buffer.lua index 6d9b6e7d..3287dda1 100644 --- a/lua/opencode/ui/renderer/buffer.lua +++ b/lua/opencode/ui/renderer/buffer.lua @@ -1,107 +1,337 @@ local ctx = require('opencode.ui.renderer.ctx') local state = require('opencode.state') -local formatter = require('opencode.ui.formatter') local output_window = require('opencode.ui.output_window') local M = {} +local pinned_bottom_message_ids = { + ['permission-display-message'] = true, + ['question-display-message'] = true, +} + +---@param message_id string|nil +---@return boolean +local function is_pinned_bottom_message(message_id) + return message_id ~= nil and pinned_bottom_message_ids[message_id] == true +end + +---@param extmarks table[]|table|nil +---@return boolean local function has_extmarks(extmarks) return type(extmarks) == 'table' and next(extmarks) ~= nil end +---@param extmarks table +---@param line_start integer +local function accumulate_bulk_extmarks(extmarks, line_start) + for line_idx, marks in pairs(extmarks) do + local actual_line = line_start + line_idx + local bucket = ctx.bulk_extmarks_by_line[actual_line] + if not bucket then + bucket = {} + ctx.bulk_extmarks_by_line[actual_line] = bucket + end + for _, mark in ipairs(marks) do + local copy = vim.deepcopy(mark) + if copy.end_row then + copy.end_row = line_start + copy.end_row + end + bucket[#bucket + 1] = copy + end + end +end + +---@param actions OutputAction[]|nil +---@return boolean local function has_actions(actions) return type(actions) == 'table' and #actions > 0 end ----@param old_lines string[] ----@param new_lines string[] ----@return integer, integer -local function get_shared_prefix_suffix(old_lines, new_lines) - local old_count = #old_lines - local new_count = #new_lines - local prefix = 0 - - while prefix < old_count and prefix < new_count do - if old_lines[prefix + 1] ~= new_lines[prefix + 1] then +---@param previous_formatted Output|nil +---@param formatted_data Output +---@return integer +local function unchanged_prefix_len(previous_formatted, formatted_data) + local previous_lines = previous_formatted and previous_formatted.lines or {} + local next_lines = formatted_data and formatted_data.lines or {} + local prefix_len = 0 + + for i = 1, math.min(#previous_lines, #next_lines) do + if previous_lines[i] ~= next_lines[i] then break end - prefix = prefix + 1 + prefix_len = i end - local suffix = 0 - while suffix < (old_count - prefix) and suffix < (new_count - prefix) do - if old_lines[old_count - suffix] ~= new_lines[new_count - suffix] then - break + return prefix_len +end + +---@param lines string[]|nil +---@param start_idx integer +---@return string[] +local function slice_lines(lines, start_idx) + local slice = {} + for i = start_idx, #(lines or {}) do + slice[#slice + 1] = lines[i] + end + return slice +end + +---@param extmarks table|nil +---@param start_line integer +---@return table +local function slice_extmarks(extmarks, start_line) + local slice = {} + for line_idx, marks in pairs(extmarks or {}) do + if line_idx < 0 then + slice[line_idx] = vim.deepcopy(marks) + elseif line_idx >= start_line then + slice[line_idx - start_line] = vim.deepcopy(marks) end - suffix = suffix + 1 end + return slice +end - return prefix, suffix +---@param mark OutputExtmark|fun(): OutputExtmark +---@return OutputExtmark +local function resolve_mark(mark) + return type(mark) == 'function' and mark() or mark end ----Find the last renderable part ID in a message (skips step-start/finish) ----@param message OpencodeMessage ----@return string? -function M.get_last_part_for_message(message) - if not message or not message.parts or #message.parts == 0 then - return nil +---@param a (OutputExtmark|fun(): OutputExtmark)[]|nil +---@param b (OutputExtmark|fun(): OutputExtmark)[]|nil +---@return boolean +local function marks_equal(a, b) + a = a or {} + b = b or {} + + if #a ~= #b then + return false end - for i = #message.parts, 1, -1 do - local part = message.parts[i] - if part.type ~= 'step-start' and part.type ~= 'step-finish' and part.id then - return part.id + + for i = 1, #a do + if not vim.deep_equal(resolve_mark(a[i]), resolve_mark(b[i])) then + return false end end - return nil + + return true end ----Find the first non-synthetic text part ID in a message ----@param message OpencodeMessage ----@return string? -function M.find_text_part_for_message(message) - if not message or not message.parts then - return nil +---@param previous_formatted Output|nil +---@param formatted_data Output +---@return integer +local function unchanged_extmark_prefix_len(previous_formatted, formatted_data) + local previous_extmarks = previous_formatted and previous_formatted.extmarks or {} + local next_extmarks = formatted_data and formatted_data.extmarks or {} + + for line_idx, _ in pairs(previous_extmarks) do + if line_idx < 0 and not marks_equal(previous_extmarks[line_idx], next_extmarks[line_idx]) then + return 0 + end end - for _, part in ipairs(message.parts) do - if part.type == 'text' and not part.synthetic then - return part.id + + for line_idx, _ in pairs(next_extmarks) do + if line_idx < 0 and not marks_equal(previous_extmarks[line_idx], next_extmarks[line_idx]) then + return 0 end end - return nil + + local previous_lines = previous_formatted and previous_formatted.lines or {} + local next_lines = formatted_data and formatted_data.lines or {} + local max_lines = math.max(#previous_lines, #next_lines) + local prefix_len = 0 + + for line_idx = 0, math.max(max_lines - 1, 0) do + local previous_marks = previous_formatted and previous_formatted.extmarks and previous_formatted.extmarks[line_idx] + or nil + local next_marks = formatted_data and formatted_data.extmarks and formatted_data.extmarks[line_idx] or nil + + if not marks_equal(previous_marks, next_marks) then + break + end + + prefix_len = line_idx + 1 + end + + return prefix_len +end + +---@param start_line integer +---@param lines string[] +local function highlight_written_lines(start_line, lines) + if #lines == 0 then + return + end + output_window.highlight_changed_lines(start_line, start_line + #lines - 1) +end + +---@param previous_formatted Output|nil +---@param formatted_data Output +---@param line_start integer +---@param old_line_end integer +---@param new_line_end integer +---@return integer clear_start +---@return integer clear_end +local function extmark_clear_range(previous_formatted, formatted_data, line_start, old_line_end, new_line_end) + local prefix_len = math.min( + unchanged_prefix_len(previous_formatted, formatted_data), + unchanged_extmark_prefix_len(previous_formatted, formatted_data) + ) + + ---@param formatted Output|nil + ---@return integer|nil + local function min_extmark_line(formatted) + local min_line = nil + for line_idx in pairs(formatted and formatted.extmarks or {}) do + if min_line == nil or line_idx < min_line then + min_line = line_idx + end + end + return min_line + end + + ---@param formatted Output|nil + ---@param fallback integer + ---@return integer + local function max_extmark_line(formatted, fallback) + local max_line = fallback + for line_idx in pairs(formatted and formatted.extmarks or {}) do + max_line = math.max(max_line, line_start + line_idx) + end + return max_line + end + + local clear_start = line_start + prefix_len + local previous_min_extmark = min_extmark_line(previous_formatted) + local next_min_extmark = min_extmark_line(formatted_data) + if previous_min_extmark ~= nil then + clear_start = math.min(clear_start, line_start + previous_min_extmark) + end + if next_min_extmark ~= nil then + clear_start = math.min(clear_start, line_start + next_min_extmark) + end + + clear_start = math.max(0, clear_start) + local clear_end = math.max( + max_extmark_line(previous_formatted, old_line_end), + max_extmark_line(formatted_data, new_line_end) + ) + 1 + + return clear_start, clear_end +end + +---@param previous_formatted Output|nil +---@param formatted_data Output +---@param line_start integer +---@param old_line_end integer +---@param new_line_end integer +---@param skip_clear? boolean +local function apply_extmarks(previous_formatted, formatted_data, line_start, old_line_end, new_line_end, skip_clear) + local clear_start, clear_end = extmark_clear_range(previous_formatted, formatted_data, line_start, old_line_end, new_line_end) + if not skip_clear then + output_window.clear_extmarks(clear_start, clear_end) + end + + local extmark_start_line = math.max(0, clear_start - line_start) + local extmarks = slice_extmarks(formatted_data.extmarks, extmark_start_line) + if has_extmarks(extmarks) then + output_window.set_extmarks(extmarks, clear_start) + end end ----Find part ID by call ID and message ID ----@param call_id string ---@param message_id string ----@return string? -function M.find_part_by_call_id(call_id, message_id) - return ctx.render_state:get_part_by_call_id(call_id, message_id) +---@return integer +local function get_message_insert_line(message_id) + local rendered_message = ctx.render_state:get_message(message_id) + if rendered_message and rendered_message.line_start then + return rendered_message.line_start + end + + local line_count = output_window.get_buf_line_count() + local append_at = math.max(line_count - 1, 0) + if line_count == 1 then + local windows = state.windows + local output_buf = windows and windows.output_buf + if output_buf and vim.api.nvim_buf_is_valid(output_buf) then + local lines = vim.api.nvim_buf_get_lines(output_buf, 0, 1, false) + if lines[1] == '' then + return 0 + end + end + end + + local messages = state.messages or {} + local message_index = nil + for i, message in ipairs(messages) do + if message.info and message.info.id == message_id then + message_index = i + break + end + end + + if not message_index then + if is_pinned_bottom_message(message_id) then + return append_at + end + + for _, pinned_message_id in ipairs({ 'permission-display-message', 'question-display-message' }) do + local pinned_rendered = ctx.render_state:get_message(pinned_message_id) + if pinned_rendered and pinned_rendered.line_start then + return pinned_rendered.line_start + end + end + + return append_at + end + + if is_pinned_bottom_message(message_id) then + return append_at + end + + for i = message_index + 1, #messages do + local next_message = messages[i] + if next_message and next_message.info and next_message.info.id then + if is_pinned_bottom_message(next_message.info.id) then + local next_rendered = ctx.render_state:get_message(next_message.info.id) + if next_rendered and next_rendered.line_start then + return next_rendered.line_start + end + end + + local next_rendered = ctx.render_state:get_message(next_message.info.id) + if next_rendered and next_rendered.line_start then + return next_rendered.line_start + end + end + end + + for _, pinned_message_id in ipairs({ 'permission-display-message', 'question-display-message' }) do + local pinned_rendered = ctx.render_state:get_message(pinned_message_id) + if pinned_rendered and pinned_rendered.line_start then + return pinned_rendered.line_start + end + end + + return append_at end ----Determine where to insert an out-of-order part (after the last rendered ----sibling, or right after the message header if no siblings are rendered yet) ---@param part_id string ---@param message_id string ----@return integer? -local function get_insertion_point_for_part(part_id, message_id) +---@return integer|nil +local function get_part_insertion_line(part_id, message_id) local rendered_message = ctx.render_state:get_message(message_id) - if not rendered_message or not rendered_message.message then + if not rendered_message or not rendered_message.message or not rendered_message.line_end then return nil end local message = rendered_message.message - local insertion_line = rendered_message.line_end and (rendered_message.line_end + 1) - if not insertion_line then - return nil - end - + local insertion_line = rendered_message.line_end + 1 local current_part_index = nil - if message.parts then - for i, part in ipairs(message.parts) do - if part.id == part_id then - current_part_index = i - break - end + + for i, part in ipairs(message.parts or {}) do + if part.id == part_id then + current_part_index = i + break end end @@ -109,13 +339,12 @@ local function get_insertion_point_for_part(part_id, message_id) return insertion_line end - -- Walk backwards through earlier siblings to find the last rendered one for i = current_part_index - 1, 1, -1 do - local prev_part = message.parts[i] - if prev_part and prev_part.id then - local prev_rendered = ctx.render_state:get_part(prev_part.id) - if prev_rendered and prev_rendered.line_end then - return prev_rendered.line_end + 1 + local previous = message.parts[i] + if previous and previous.id then + local previous_rendered = ctx.render_state:get_part(previous.id) + if previous_rendered and previous_rendered.line_end then + return previous_rendered.line_end + 1 end end end @@ -123,279 +352,295 @@ local function get_insertion_point_for_part(part_id, message_id) return insertion_line end ----Append formatted data to the end of the buffer, or insert at start_line. ----Returns the range of lines written, or nil if nothing was written. +---@param lines string[] +---@param start_line integer +---@param end_line integer +---@return { line_start: integer, line_end: integer } +local function write_at(lines, start_line, end_line) + output_window.set_lines(lines, start_line, end_line) + highlight_written_lines(start_line, lines) + return { + line_start = start_line, + line_end = start_line + #lines - 1, + } +end + +---@param part_id string ---@param formatted_data Output ----@param part_id? string When provided, actions are registered for this part ----@param start_line? integer When provided, content is inserted here (shifts down) ----@return {line_start: integer, line_end: integer}? -function M.write_formatted_data(formatted_data, part_id, start_line) - if not state.windows or not state.windows.output_buf then - return nil +---@param line_start integer +local function apply_part_actions(part_id, formatted_data, line_start) + if has_actions(formatted_data.actions) then + ctx.render_state:clear_actions(part_id) + ctx.render_state:add_actions(part_id, vim.deepcopy(formatted_data.actions), line_start) + else + ctx.render_state:clear_actions(part_id) end - local new_lines = formatted_data.lines - if #new_lines == 0 then - return nil + local part_data = ctx.render_state:get_part(part_id) + if part_data then + part_data.has_extmarks = has_extmarks(formatted_data.extmarks) end +end - local is_insertion = start_line ~= nil - local target_line = start_line or output_window.get_buf_line_count() - - if is_insertion then - output_window.set_lines(new_lines, target_line, target_line) - else - -- Append: overlap the last buffer line with our lines - target_line = target_line - 1 - local append_lines = table.move(new_lines, 1, #new_lines, 1, {}) - append_lines[#append_lines + 1] = '' - output_window.set_lines(append_lines, target_line) +---@param part_id string +---@param formatted_data Output +local function set_part_extmark_state(part_id, formatted_data) + local part_data = ctx.render_state:get_part(part_id) + if part_data then + part_data.has_extmarks = has_extmarks(formatted_data.extmarks) end +end - if part_id and formatted_data.actions then - ctx.render_state:add_actions(part_id, formatted_data.actions, target_line) +---@param message OpencodeMessage|nil +---@return string|nil +function M.get_last_part_for_message(message) + if not message or not message.parts or #message.parts == 0 then + return nil + end + for i = #message.parts, 1, -1 do + local part = message.parts[i] + if part.type ~= 'step-start' and part.type ~= 'step-finish' and part.id then + return part.id + end end + return nil +end - if has_extmarks(formatted_data.extmarks) then - output_window.set_extmarks(formatted_data.extmarks, target_line) - local part_data = ctx.render_state:get_part(part_id) - if part_data then - part_data.has_extmarks = true +---@param message OpencodeMessage|nil +---@return string|nil +function M.find_text_part_for_message(message) + if not message or not message.parts then + return nil + end + for _, part in ipairs(message.parts) do + if part.type == 'text' and not part.synthetic then + return part.id end end + return nil +end - return { line_start = target_line, line_end = target_line + #new_lines - 1 } +---@param call_id string +---@param message_id string +---@return string|nil +function M.find_part_by_call_id(call_id, message_id) + return ctx.render_state:get_part_by_call_id(call_id, message_id) end ----Insert a new part into the buffer. ----Appends if the part belongs to the current message; inserts in-order otherwise. ----@param part_id string +---@param message_id string ---@param formatted_data Output +---@param previous_formatted Output|nil ---@return boolean -function M.insert_part(part_id, formatted_data) - local cached = ctx.render_state:get_part(part_id) - if not cached then - return false - end +function M.upsert_message_now(message_id, formatted_data, previous_formatted) + if ctx.bulk_mode then + local line_start = #ctx.bulk_buffer_lines + local line_end = line_start + #formatted_data.lines - 1 + + for _, line in ipairs(formatted_data.lines) do + ctx.bulk_buffer_lines[#ctx.bulk_buffer_lines + 1] = line + end + if has_extmarks(formatted_data.extmarks) then + accumulate_bulk_extmarks(formatted_data.extmarks, line_start) + end + + local message_data = ctx.render_state:get_message(message_id) + if message_data then + ctx.render_state:set_message(message_data.message, line_start, line_end) + end - if #formatted_data.lines == 0 then return true end - local is_current_message = state.current_message - and state.current_message.info - and state.current_message.info.id == cached.message_id - - if is_current_message then - local range = M.write_formatted_data(formatted_data, part_id) - if not range then - return false + local cached = ctx.render_state:get_message(message_id) + if cached and cached.line_start and cached.line_end then + local old_line_end = cached.line_end + local prefix_len = unchanged_prefix_len(previous_formatted, formatted_data) + local write_start = cached.line_start + prefix_len + local lines_to_write = slice_lines(formatted_data.lines, prefix_len + 1) + local clear_start, clear_end = extmark_clear_range( + previous_formatted, + formatted_data, + cached.line_start, + old_line_end, + cached.line_start + #formatted_data.lines - 1 + ) + + output_window.clear_extmarks(clear_start, clear_end) + output_window.set_lines(lines_to_write, write_start, cached.line_end + 1) + highlight_written_lines(write_start, lines_to_write) + + local new_line_end = cached.line_start + #formatted_data.lines - 1 + apply_extmarks(previous_formatted, formatted_data, cached.line_start, old_line_end, new_line_end, true) + ctx.render_state:set_message(cached.message, cached.line_start, new_line_end) + + local delta = new_line_end - old_line_end + if delta ~= 0 then + ctx.render_state:shift_all(old_line_end + 1, delta) end - ctx.render_state:set_part(cached.part, range.line_start, range.line_end) - ctx.last_part_formatted = { part_id = part_id, formatted_data = formatted_data } return true end - -- Out-of-order part: find the correct insertion point - local insertion_line = get_insertion_point_for_part(part_id, cached.message_id) - if not insertion_line then - return false - end + local insert_at = get_message_insert_line(message_id) + local message_data = ctx.render_state:get_message(message_id) + if message_data and message_data.message then + local range = write_at(formatted_data.lines, insert_at, insert_at) + if has_extmarks(formatted_data.extmarks) then + output_window.set_extmarks(formatted_data.extmarks, insert_at) + end - local range = M.write_formatted_data(formatted_data, part_id, insertion_line) - if not range then - return false + ctx.render_state:shift_all(insert_at, #formatted_data.lines) + ctx.render_state:set_message(message_data.message, range.line_start, range.line_end) + return true end - ctx.render_state:shift_all(insertion_line, #formatted_data.lines) - ctx.render_state:set_part(cached.part, range.line_start, range.line_end) - return true + return false end ----Replace an existing part in the buffer. ----Only writes lines that differ from the previous render (diff optimisation). ---@param part_id string +---@param message_id string ---@param formatted_data Output +---@param previous_formatted Output|nil ---@return boolean -function M.replace_part(part_id, formatted_data) - local cached = ctx.render_state:get_part(part_id) - if not cached or not cached.line_start or not cached.line_end then - return false - end +function M.upsert_part_now(part_id, message_id, formatted_data, previous_formatted) + if ctx.bulk_mode then + local line_start = #ctx.bulk_buffer_lines + local line_end = line_start + #formatted_data.lines - 1 - local new_lines = formatted_data.lines - local new_line_count = #new_lines - local next_has_extmarks = has_extmarks(formatted_data.extmarks) - local had_extmarks = cached.has_extmarks == true - local next_has_actions = has_actions(formatted_data.actions) - local had_actions = cached.actions and #cached.actions > 0 - local old_buf_line_count = output_window.get_buf_line_count() - local was_tail_part = cached.line_end == old_buf_line_count - 1 - - -- Diff optimisation: skip lines that haven't changed since the last render - local old = ctx.last_part_formatted - local lines_to_write = new_lines - local write_start = cached.line_start - local write_end = cached.line_end + 1 - local prefix = 0 - local suffix = 0 - - if old and old.part_id == part_id and old.formatted_data and old.formatted_data.lines then - local old_lines = old.formatted_data.lines - prefix, suffix = get_shared_prefix_suffix(old_lines, new_lines) - - if prefix == #old_lines and prefix == new_line_count then - if not had_extmarks and not next_has_extmarks and not had_actions and not next_has_actions then - ctx.last_part_formatted = { part_id = part_id, formatted_data = formatted_data } - return true - end + for _, line in ipairs(formatted_data.lines) do + ctx.bulk_buffer_lines[#ctx.bulk_buffer_lines + 1] = line + end + if has_extmarks(formatted_data.extmarks) then + accumulate_bulk_extmarks(formatted_data.extmarks, line_start) end - local replace_from = prefix + 1 - local replace_to = new_line_count - suffix - lines_to_write = replace_from <= replace_to and vim.list_slice(new_lines, replace_from, replace_to) or {} - write_start = cached.line_start + prefix - write_end = cached.line_end + 1 - suffix - end - - if had_actions or next_has_actions then - ctx.render_state:clear_actions(part_id) - end + local part_data = ctx.render_state:get_part(part_id) + if part_data then + ctx.render_state:set_part(part_data.part, line_start, line_end) + apply_part_actions(part_id, formatted_data, line_start) + end - output_window.begin_update() - if had_extmarks or next_has_extmarks then - output_window.clear_extmarks(cached.line_start - 1, cached.line_end + 1) + return true end - output_window.set_lines(lines_to_write, write_start, write_end) - local new_line_end = cached.line_start + new_line_count - 1 - if next_has_extmarks then - output_window.set_extmarks(formatted_data.extmarks, cached.line_start) + local cached = ctx.render_state:get_part(part_id) + if cached and cached.line_start and cached.line_end then + local old_line_end = cached.line_end + local prefix_len = unchanged_prefix_len(previous_formatted, formatted_data) + local write_start = cached.line_start + prefix_len + local lines_to_write = slice_lines(formatted_data.lines, prefix_len + 1) + local clear_start, clear_end = extmark_clear_range( + previous_formatted, + formatted_data, + cached.line_start, + old_line_end, + cached.line_start + #formatted_data.lines - 1 + ) + + output_window.clear_extmarks(clear_start, clear_end) + output_window.set_lines(lines_to_write, write_start, cached.line_end + 1) + highlight_written_lines(write_start, lines_to_write) + + local new_line_end = cached.line_start + #formatted_data.lines - 1 + apply_part_actions(part_id, formatted_data, cached.line_start) + + if new_line_end ~= cached.line_end then + ctx.render_state:update_part_lines(part_id, cached.line_start, new_line_end) + end + apply_extmarks(previous_formatted, formatted_data, cached.line_start, old_line_end, new_line_end, true) + set_part_extmark_state(part_id, formatted_data) + return true end - output_window.end_update() - cached.has_extmarks = next_has_extmarks - if next_has_actions then - ctx.render_state:add_actions(part_id, formatted_data.actions, cached.line_start + 1) + local insert_at = get_part_insertion_line(part_id, message_id) + if not insert_at then + return false end - if new_line_end ~= cached.line_end then - if was_tail_part then - ctx.render_state:set_part(cached.part, cached.line_start, new_line_end) - else - ctx.render_state:update_part_lines(part_id, cached.line_start, new_line_end) + local part_data = ctx.render_state:get_part(part_id) + if part_data and part_data.part then + local range = write_at(formatted_data.lines, insert_at, insert_at) + ctx.render_state:shift_all(insert_at, #formatted_data.lines) + ctx.render_state:set_part(part_data.part, range.line_start, range.line_end) + apply_part_actions(part_id, formatted_data, range.line_start) + if has_extmarks(formatted_data.extmarks) then + output_window.set_extmarks(formatted_data.extmarks, range.line_start) end + set_part_extmark_state(part_id, formatted_data) + return true end - ctx.last_part_formatted = { part_id = part_id, formatted_data = formatted_data } - return true + return false end ----Remove a part and its extmarks from the buffer ---@param part_id string -function M.remove_part(part_id) - local cached = ctx.render_state:get_part(part_id) - if not cached or not cached.line_start or not cached.line_end then - return - end - output_window.begin_update() - output_window.clear_extmarks(cached.line_start - 1, cached.line_end + 1) - output_window.set_lines({}, cached.line_start, cached.line_end + 1) - output_window.end_update() - ctx.render_state:remove_part(part_id) -end - ----Write a message header into the buffer ----@param message OpencodeMessage -function M.add_message(message) - local header_data = formatter.format_message_header(message) - local range = M.write_formatted_data(header_data) - if range then - ctx.render_state:set_message(message, range.line_start, range.line_end) - end -end - ----Replace an existing message header in the buffer ----@param message_id string ----@param formatted_data Output +---@param extra_lines string[] +---@param extra_extmarks table|nil +---@param previous_formatted Output|nil ---@return boolean -function M.replace_message(message_id, formatted_data) - local cached = ctx.render_state:get_message(message_id) - if not cached or not cached.line_start or not cached.line_end then +function M.append_part_now(part_id, extra_lines, extra_extmarks, previous_formatted) + local cached = ctx.render_state:get_part(part_id) + if not cached or not cached.line_start or not cached.line_end or #extra_lines == 0 then return false end - local new_lines = formatted_data.lines - local new_line_count = #new_lines - - output_window.begin_update() - output_window.clear_extmarks(cached.line_start, cached.line_end + 1) - output_window.set_lines(new_lines, cached.line_start, cached.line_end + 1) - output_window.set_extmarks(formatted_data.extmarks, cached.line_start) - output_window.end_update() - + local insert_at = cached.line_end + 1 local old_line_end = cached.line_end - local new_line_end = cached.line_start + new_line_count - 1 + output_window.set_lines(extra_lines, insert_at, insert_at) + highlight_written_lines(insert_at, extra_lines) - ctx.render_state:set_message(cached.message, cached.line_start, new_line_end) + local new_line_end = cached.line_end + #extra_lines + ctx.render_state:update_part_lines(part_id, cached.line_start, new_line_end) - local delta = new_line_end - old_line_end - if delta ~= 0 then - ctx.render_state:shift_all(old_line_end + 1, delta) + local formatted_data = ctx.formatted_parts[part_id] + if formatted_data then + apply_part_actions(part_id, formatted_data, cached.line_start) + apply_extmarks(previous_formatted, formatted_data, cached.line_start, old_line_end, new_line_end) + set_part_extmark_state(part_id, formatted_data) + elseif has_extmarks(extra_extmarks) then + output_window.set_extmarks(extra_extmarks, insert_at) end return true end ----Remove a message header and its extmarks from the buffer ----@param message_id string -function M.remove_message(message_id) - local cached = ctx.render_state:get_message(message_id) - if not cached or not cached.line_start or not cached.line_end then - return - end - if not state.windows or not state.windows.output_buf then +---@param part_id string +function M.remove_part_now(part_id) + if ctx.bulk_mode then + -- In bulk mode, we don't actually remove from buffer since we're building fresh + -- Just track that this part should be excluded + ctx.render_state:remove_part(part_id) return end - if cached.line_start == 0 and cached.line_end == 0 then + + local cached = ctx.render_state:get_part(part_id) + if not cached or not cached.line_start or not cached.line_end then + ctx.render_state:remove_part(part_id) return end - output_window.begin_update() + output_window.clear_extmarks(cached.line_start - 1, cached.line_end + 1) output_window.set_lines({}, cached.line_start, cached.line_end + 1) - output_window.end_update() - ctx.render_state:remove_message(message_id) + ctx.render_state:remove_part(part_id) end ----Re-render an existing part using its current data from render_state ----@param part_id string -function M.rerender_part(part_id) - local cached = ctx.render_state:get_part(part_id) - if not cached or not cached.part then +---@param message_id string +function M.remove_message_now(message_id) + if ctx.bulk_mode then + -- In bulk mode, we don't actually remove from buffer since we're building fresh + -- Just track that this message should be excluded + ctx.render_state:remove_message(message_id) return end - local rendered_message = ctx.render_state:get_message(cached.message_id) - if not rendered_message or not rendered_message.message then + local cached = ctx.render_state:get_message(message_id) + if not cached or not cached.line_start or not cached.line_end then + ctx.render_state:remove_message(message_id) return end - local message = rendered_message.message - local is_last_part = (M.get_last_part_for_message(message) == part_id) - local formatted = formatter.format_part(cached.part, message, is_last_part, function(session_id) - return ctx.render_state:get_child_session_parts(session_id) - end) - - M.replace_part(part_id, formatted) -end - ----Re-render the task-tool part that owns the given child session ----@param child_session_id string -function M.rerender_task_tool_for_child_session(child_session_id) - local part_id = ctx.render_state:get_task_part_by_child_session(child_session_id) - if part_id then - M.rerender_part(part_id) - end + output_window.clear_extmarks(cached.line_start, cached.line_end + 1) + output_window.set_lines({}, cached.line_start, cached.line_end + 1) + ctx.render_state:remove_message(message_id) end return M diff --git a/lua/opencode/ui/renderer/ctx.lua b/lua/opencode/ui/renderer/ctx.lua index 7cbeddee..41983eb0 100644 --- a/lua/opencode/ui/renderer/ctx.lua +++ b/lua/opencode/ui/renderer/ctx.lua @@ -6,16 +6,57 @@ local RenderState = require('opencode.ui.render_state') local ctx = { ---@type RenderState render_state = RenderState.new(), - ---@type integer - prev_line_count = 0, ---@type { part_id: string|nil, formatted_data: Output|nil } last_part_formatted = { part_id = nil, formatted_data = nil }, + ---@type table + formatted_parts = {}, + ---@type table + formatted_messages = {}, + pending = { + dirty_message_order = {}, + dirty_messages = {}, + dirty_part_by_message = {}, + dirty_part_order = {}, + dirty_parts = {}, + removed_part_order = {}, + removed_parts = {}, + removed_message_order = {}, + removed_messages = {}, + }, + flush_scheduled = false, + markdown_render_scheduled = false, + bulk_mode = false, + bulk_buffer_lines = {}, + bulk_extmarks_by_line = {}, } +---Reset all renderer caches and pending state. function ctx:reset() self.render_state:reset() - self.prev_line_count = 0 self.last_part_formatted = { part_id = nil, formatted_data = nil } + self.formatted_parts = {} + self.formatted_messages = {} + self.pending = { + dirty_message_order = {}, + dirty_messages = {}, + dirty_part_by_message = {}, + dirty_part_order = {}, + dirty_parts = {}, + removed_part_order = {}, + removed_parts = {}, + removed_message_order = {}, + removed_messages = {}, + } + self.flush_scheduled = false + self.markdown_render_scheduled = false + self:bulk_reset() +end + +---Reset the temporary bulk-render accumulators. +function ctx:bulk_reset() + self.bulk_mode = false + self.bulk_buffer_lines = {} + self.bulk_extmarks_by_line = {} end return ctx diff --git a/lua/opencode/ui/renderer/events.lua b/lua/opencode/ui/renderer/events.lua index e46231b1..13d48d23 100644 --- a/lua/opencode/ui/renderer/events.lua +++ b/lua/opencode/ui/renderer/events.lua @@ -1,11 +1,40 @@ local state = require('opencode.state') local config = require('opencode.config') -local formatter = require('opencode.ui.formatter') local ctx = require('opencode.ui.renderer.ctx') -local buf = require('opencode.ui.renderer.buffer') local permission_window = require('opencode.ui.permission_window') +local flush = require('opencode.ui.renderer.flush') + +---@param message OpencodeMessage|nil +---@return string|nil +local function get_last_part_for_message(message) + if not message or not message.parts or #message.parts == 0 then + return nil + end + for i = #message.parts, 1, -1 do + local part = message.parts[i] + if part.type ~= 'step-start' and part.type ~= 'step-finish' and part.id then + return part.id + end + end + return nil +end + +---@param message OpencodeMessage|nil +---@return string|nil +local function find_text_part_for_message(message) + if not message or not message.parts then + return nil + end + for _, part in ipairs(message.parts) do + if part.type == 'text' and not part.synthetic then + return part.id + end + end + return nil +end -- Lazy require to avoid circular dependency: renderer.lua <-> events.lua +---@param force? boolean local function scroll(force) require('opencode.ui.renderer').scroll_to_bottom(force) end @@ -33,8 +62,8 @@ end function M.render_permissions_display() local permissions = permission_window.get_all_permissions() if not permissions or #permissions == 0 then - buf.remove_part('permission-display-part') - buf.remove_message('permission-display-message') + flush.queue_part_removal('permission-display-part') + flush.queue_message_removal('permission-display-message') return end @@ -55,7 +84,6 @@ function M.render_permissions_display() type = 'permissions-display', } M.on_part_updated({ part = fake_part }) - scroll(true) end ---Render the current question as a synthetic part at the end of the buffer @@ -69,8 +97,8 @@ function M.render_question_display() local current_question = question_window._current_question if not question_window.has_question() or not current_question or not current_question.id then - buf.remove_part('question-display-part') - buf.remove_message('question-display-message') + flush.queue_part_removal('question-display-part') + flush.queue_message_removal('question-display-message') return end @@ -101,8 +129,8 @@ function M.clear_question_display() question_window.clear_question() if not use_vim_ui then - buf.remove_part('question-display-part') - buf.remove_message('question-display-message') + flush.queue_part_removal('question-display-part') + flush.queue_message_removal('question-display-message') end end @@ -142,17 +170,17 @@ function M.on_message_updated(message, revert_index) -- Re-render the last part (or the header if there are no parts) so the -- error appears in the right place. if error_changed then - local last_part_id = buf.get_last_part_for_message(found_msg) + local last_part_id = get_last_part_for_message(found_msg) if last_part_id then - buf.rerender_part(last_part_id) + flush.mark_part_dirty(last_part_id, msg.info.id) else - local header_data = formatter.format_message_header(found_msg) - buf.replace_message(msg.info.id, header_data) + flush.mark_message_dirty(msg.info.id) end end else table.insert(state.messages, msg) - buf.add_message(msg) + ctx.render_state:set_message(msg) + flush.mark_message_dirty(msg.info.id) state.renderer.set_current_message(msg) if message.info.role == 'user' then state.renderer.set_last_user_message(msg) @@ -182,11 +210,11 @@ function M.on_message_removed(properties) for _, part in ipairs(rendered_message.message.parts or {}) do if part.id then - buf.remove_part(part.id) + flush.queue_part_removal(part.id) end end - buf.remove_message(message_id) + flush.queue_message_removal(message_id) for i, msg in ipairs(state.messages or {}) do if msg.info.id == message_id then @@ -213,7 +241,10 @@ function M.on_part_updated(properties, revert_index) if state.active_session.id ~= part.sessionID then if part.tool or part.type == 'tool' then ctx.render_state:upsert_child_session_part(part.sessionID, part) - buf.rerender_task_tool_for_child_session(part.sessionID) + local task_part_id = ctx.render_state:get_task_part_by_child_session(part.sessionID) + if task_part_id then + flush.mark_part_dirty(task_part_id) + end end return end @@ -230,8 +261,7 @@ function M.on_part_updated(properties, revert_index) local part_data = ctx.render_state:get_part(part.id) local is_new_part = not part_data - local prev_last_part_id = buf.get_last_part_for_message(message) - local is_last_part = is_new_part or (prev_last_part_id == part.id) + local prev_last_part_id = get_last_part_for_message(message) -- Update the part reference in the message if is_new_part then @@ -277,32 +307,27 @@ function M.on_part_updated(properties, revert_index) return end - local formatted = formatter.format_part(part, message, is_last_part, function(session_id) - return ctx.render_state:get_child_session_parts(session_id) - end) - if is_new_part then - buf.insert_part(part.id, formatted) + flush.mark_part_dirty(part.id, part.messageID) -- If there's already an error on this message, adjust adjacent parts so -- the error only appears after the last part. if message.info.error then if not prev_last_part_id then - local header_data = formatter.format_message_header(message) - buf.replace_message(part.messageID, header_data) + flush.mark_message_dirty(part.messageID) elseif prev_last_part_id ~= part.id then - buf.rerender_part(prev_last_part_id) + flush.mark_part_dirty(prev_last_part_id, part.messageID) end end else - buf.replace_part(part.id, formatted) + flush.mark_part_dirty(part.id, part.messageID) end -- File / agent mentions: re-render the text part to highlight them if (part.type == 'file' or part.type == 'agent') and part.source then - local text_part_id = buf.find_text_part_for_message(message) + local text_part_id = find_text_part_for_message(message) if text_part_id then - buf.rerender_part(text_part_id) + flush.mark_part_dirty(text_part_id, part.messageID) end end end @@ -321,8 +346,9 @@ function M.on_part_removed(properties) -- Remove the part from the in-memory message too local cached = ctx.render_state:get_part(part_id) - if cached and cached.message_id then - local rendered_message = ctx.render_state:get_message(cached.message_id) + local message_id = cached and cached.message_id + if message_id then + local rendered_message = ctx.render_state:get_message(message_id) if rendered_message and rendered_message.message and rendered_message.message.parts then for i, part in ipairs(rendered_message.message.parts) do if part.id == part_id then @@ -333,7 +359,12 @@ function M.on_part_removed(properties) end end - buf.remove_part(part_id) + flush.queue_part_removal(part_id) + + -- Mark message dirty so header (timestamp, etc.) gets re-rendered + if message_id then + flush.mark_message_dirty(message_id) + end end ---Handle session.updated — re-render the full session if the revert state changed @@ -357,7 +388,10 @@ function M.on_session_updated(properties) end if revert_changed then - require('opencode.ui.renderer')._render_full_session_data(state.messages) + local real_messages = vim.tbl_filter(function(msg) + return not (msg.info and msg.info.id and msg.info.id:match('^__opencode_')) + end, state.messages or {}) + require('opencode.ui.renderer')._render_full_session_data(real_messages) end end @@ -380,14 +414,14 @@ end ---Handle permission.updated / permission.asked ---@param permission OpencodePermission function M.on_permission_updated(permission) + if not permission or not permission.id then + return + end + local tool = permission.tool local callID = tool and tool.callID or permission.callID local messageID = tool and tool.messageID or permission.messageID - if not permission or not messageID or not callID then - return - end - if not state.pending_permissions then state.renderer.set_pending_permissions({}) end @@ -429,8 +463,8 @@ function M.on_permission_replied(properties) state.renderer.set_pending_permissions(vim.deepcopy(permission_window.get_all_permissions())) if #state.pending_permissions == 0 then - buf.remove_part('permission-display-part') - buf.remove_message('permission-display-message') + flush.queue_part_removal('permission-display-part') + flush.queue_message_removal('permission-display-message') else M.render_permissions_display() end diff --git a/lua/opencode/ui/renderer/flush.lua b/lua/opencode/ui/renderer/flush.lua new file mode 100644 index 00000000..40de9d4d --- /dev/null +++ b/lua/opencode/ui/renderer/flush.lua @@ -0,0 +1,513 @@ +local state = require('opencode.state') +local config = require('opencode.config') +local formatter = require('opencode.ui.formatter') +local output_window = require('opencode.ui.output_window') +local ctx = require('opencode.ui.renderer.ctx') +local scroll = require('opencode.ui.renderer.scroll') +local buffer = require('opencode.ui.renderer.buffer') +local append = require('opencode.ui.renderer.append') + +local M = {} + +---@generic T +---@param fn fun(): T +---@return T +local function with_suppressed_output_autocmds(fn) + local output_win = state.windows and state.windows.output_win + local has_output_win = output_win and vim.api.nvim_win_is_valid(output_win) + -- 'eventignorewin' is not available in all Neovim versions. Use pcall to + -- detect support and avoid throwing an error on older versions used in CI. + local supports_eventignorewin = false + local saved_eventignorewin = nil + if has_output_win then + local ok, val = pcall(vim.api.nvim_get_option_value, 'eventignorewin', { win = output_win }) + if ok then + supports_eventignorewin = true + saved_eventignorewin = val + pcall(vim.api.nvim_set_option_value, 'eventignorewin', 'all', { win = output_win, scope = 'local' }) + end + end + + local begin_ok, began_update = xpcall(output_window.begin_update, debug.traceback) + if not begin_ok then + if has_output_win and supports_eventignorewin then + pcall(vim.api.nvim_set_option_value, 'eventignorewin', saved_eventignorewin, { win = output_win, scope = 'local' }) + end + error(began_update) + end + + local ok, result = xpcall(fn, debug.traceback) + local end_ok, end_err = true, nil + + if began_update then + end_ok, end_err = xpcall(output_window.end_update, debug.traceback) + end + if has_output_win and supports_eventignorewin then + pcall(vim.api.nvim_set_option_value, 'eventignorewin', saved_eventignorewin, { win = output_win, scope = 'local' }) + end + + if not ok then + error(result) + end + if not end_ok then + error(end_err) + end + + return result +end + +---@param a string[]|nil +---@param b string[]|nil +---@return boolean +local function lines_equal(a, b) + a = a or {} + b = b or {} + if #a ~= #b then + return false + end + for i = 1, #a do + if a[i] ~= b[i] then + return false + end + end + return true +end + +---@param m OutputExtmark|fun(): OutputExtmark +---@return OutputExtmark +local function resolve_mark(m) + return type(m) == 'function' and m() or m +end + +---@param a table|nil +---@param b table|nil +---@return boolean +local function extmarks_equal(a, b) + a = a or {} + b = b or {} + for k, va in pairs(a) do + local vb = b[k] + if not vb or #va ~= #vb then + return false + end + for i = 1, #va do + if not vim.deep_equal(resolve_mark(va[i]), resolve_mark(vb[i])) then + return false + end + end + end + for k in pairs(b) do + if not a[k] then + return false + end + end + return true +end + +---@return boolean +local function is_markdown_render_deferred() + if not config.ui.output.rendering.markdown_on_idle then + return false + end + + local active_session = state.active_session + local session_id = active_session and active_session.id + if not session_id then + return false + end + + local pending = state.user_message_count or {} + local threshold = config.ui.output.rendering.markdown_on_idle_threshold + if type(threshold) == 'number' then + return (pending[session_id] or 0) > threshold + end + return (pending[session_id] or 0) > 0 +end + +---@param order string[] +---@param lookup table +---@param id string +local function enqueue_once(order, lookup, id) + if lookup[id] then + return + end + order[#order + 1] = id +end + +---@param message_id string|nil +---@param part_id string|nil +local function track_message_for_part(message_id, part_id) + if not message_id or not part_id then + return + end + + local part_ids = ctx.pending.dirty_part_by_message[message_id] + if not part_ids then + part_ids = {} + ctx.pending.dirty_part_by_message[message_id] = part_ids + end + part_ids[part_id] = true +end + +---@param message_id string|nil +---@param part_id string +local function untrack_message_for_part(message_id, part_id) + local part_ids = message_id and ctx.pending.dirty_part_by_message[message_id] + if not part_ids then + return + end + part_ids[part_id] = nil + if next(part_ids) == nil then + ctx.pending.dirty_part_by_message[message_id] = nil + end +end + +---@param message_id string|nil +function M.mark_message_dirty(message_id) + if not message_id then + return + end + ctx.pending.removed_messages[message_id] = nil + enqueue_once(ctx.pending.dirty_message_order, ctx.pending.dirty_messages, message_id) + ctx.pending.dirty_messages[message_id] = true + -- Clear cached formatted data so the message gets fully re-rendered + ctx.formatted_messages[message_id] = nil + M.schedule() +end + +---@param part_id string|nil +---@param message_id? string +function M.mark_part_dirty(part_id, message_id) + if not part_id then + return + end + + local rendered_part = ctx.render_state:get_part(part_id) + message_id = message_id or (rendered_part and rendered_part.message_id) + if not message_id then + return + end + + ctx.pending.removed_parts[part_id] = nil + enqueue_once(ctx.pending.dirty_part_order, ctx.pending.dirty_parts, part_id) + ctx.pending.dirty_parts[part_id] = message_id + track_message_for_part(message_id, part_id) + M.schedule() +end + +---@param part_id string|nil +function M.queue_part_removal(part_id) + if not part_id then + return + end + + local rendered_part = ctx.render_state:get_part(part_id) + if rendered_part and rendered_part.message_id then + untrack_message_for_part(rendered_part.message_id, part_id) + end + + ctx.pending.dirty_parts[part_id] = nil + enqueue_once(ctx.pending.removed_part_order, ctx.pending.removed_parts, part_id) + ctx.pending.removed_parts[part_id] = true + ctx.formatted_parts[part_id] = nil + M.schedule() +end + +---@param message_id string|nil +function M.queue_message_removal(message_id) + if not message_id then + return + end + + ctx.pending.dirty_messages[message_id] = nil + ctx.pending.dirty_part_by_message[message_id] = nil + enqueue_once(ctx.pending.removed_message_order, ctx.pending.removed_messages, message_id) + ctx.pending.removed_messages[message_id] = true + ctx.formatted_messages[message_id] = nil + M.schedule() +end + +---Schedule a renderer flush on the next event loop tick. +function M.schedule() + if ctx.flush_scheduled then + return + end + + ctx.flush_scheduled = true + vim.schedule(function() + ctx.flush_scheduled = false + M.flush() + end) +end + +---@return RendererCtx['pending'] +local function snapshot_pending() + local pending = ctx.pending + ctx.pending = { + dirty_message_order = {}, + dirty_messages = {}, + dirty_part_by_message = {}, + dirty_part_order = {}, + dirty_parts = {}, + removed_part_order = {}, + removed_parts = {}, + removed_message_order = {}, + removed_messages = {}, + } + return pending +end + +---@param message_id string +---@return Output|nil +local function format_message(message_id) + local rendered_message = ctx.render_state:get_message(message_id) + local message = rendered_message and rendered_message.message + if not message then + return nil + end + + local prev = ctx.formatted_messages[message_id] + local formatted = formatter.format_message_header(message) + + if prev and lines_equal(prev.lines, formatted.lines) and extmarks_equal(prev.extmarks, formatted.extmarks) then + -- no visible change + return nil + end + + ctx.formatted_messages[message_id] = formatted + return formatted +end + +---@param part_id string +---@return Output|nil formatted +---@return string|nil message_id +local function format_part(part_id) + local rendered_part = ctx.render_state:get_part(part_id) + if not rendered_part or not rendered_part.part then + return nil + end + + local rendered_message = ctx.render_state:get_message(rendered_part.message_id) + local message = rendered_message and rendered_message.message + if not message then + return nil + end + + local is_last_part = (buffer.get_last_part_for_message(message) == part_id) + local formatted = formatter.format_part(rendered_part.part, message, is_last_part, function(session_id) + return ctx.render_state:get_child_session_parts(session_id) + end) + + return formatted, rendered_part.message_id +end + +---@param message_id string +local function apply_message(message_id) + local previous = ctx.formatted_messages[message_id] + local formatted = format_message(message_id) + if not formatted then + return + end + buffer.upsert_message_now(message_id, formatted, previous) +end + +---@param part_id string +---@param message_id string|nil +local function apply_part(part_id, message_id) + local previous = ctx.formatted_parts[part_id] + local formatted = nil + formatted, message_id = format_part(part_id) + if not formatted or not message_id then + return + end + + local cached = ctx.render_state:get_part(part_id) + local can_append = previous + and cached + and cached.line_start + and cached.line_end + and append.is_append_only(previous.lines or {}, formatted.lines or {}) + + ctx.formatted_parts[part_id] = formatted + ctx.last_part_formatted = { part_id = part_id, formatted_data = formatted } + + if can_append then + buffer.append_part_now( + part_id, + append.tail_lines(previous.lines or {}, formatted.lines or {}), + append.tail_extmarks(#(previous.lines or {}), formatted.extmarks), + previous + ) + return + end + + buffer.upsert_part_now(part_id, message_id, formatted, previous) +end + +---@param pending RendererCtx['pending'] +---@return boolean +local function apply_pending(pending) + local buf = state.windows and state.windows.output_buf + if not buf or not vim.api.nvim_buf_is_valid(buf) then + return false + end + + local has_updates = #pending.removed_part_order > 0 + or #pending.removed_message_order > 0 + or #pending.dirty_message_order > 0 + or #pending.dirty_part_order > 0 + + if not has_updates then + return false + end + + local scroll_snapshot = scroll.pre_flush(buf) + with_suppressed_output_autocmds(function() + for _, part_id in ipairs(pending.removed_part_order) do + if pending.removed_parts[part_id] then + buffer.remove_part_now(part_id) + end + end + + for _, message_id in ipairs(pending.removed_message_order) do + if pending.removed_messages[message_id] then + buffer.remove_message_now(message_id) + end + end + + for _, message_id in ipairs(pending.dirty_message_order) do + if pending.dirty_messages[message_id] then + apply_message(message_id) + end + + local dirty_parts = pending.dirty_part_by_message[message_id] + if dirty_parts then + local message = ctx.render_state:get_message(message_id) + local parts = message and message.message and message.message.parts or {} + for _, part in ipairs(parts or {}) do + if part.id and dirty_parts[part.id] then + apply_part(part.id, message_id) + dirty_parts[part.id] = nil + pending.dirty_parts[part.id] = nil + end + end + end + end + + for _, part_id in ipairs(pending.dirty_part_order) do + local message_id = pending.dirty_parts[part_id] + if message_id then + apply_part(part_id, message_id) + end + end + end) + + scroll.post_flush(scroll_snapshot, buf) + return true +end + +---Trigger post-render markdown callbacks or commands. +local function do_trigger_on_data_rendered() + local cb_type = type(config.ui.output.rendering.on_data_rendered) + if cb_type == 'boolean' then + return + end + if not state.windows or not state.windows.output_buf or not state.windows.output_win then + return + end + vim.b[state.windows.output_buf].opencode_markdown_namespace = output_window.markdown_namespace + if cb_type == 'function' then + pcall(config.ui.output.rendering.on_data_rendered, state.windows.output_buf, state.windows.output_win) + elseif vim.fn.exists(':RenderMarkdown') > 0 then + vim.cmd(':RenderMarkdown') + elseif vim.fn.exists(':Markview') > 0 then + vim.cmd(':Markview render ' .. state.windows.output_buf) + end +end + +M.trigger_on_data_rendered = + require('opencode.util').debounce(do_trigger_on_data_rendered, config.ui.output.rendering.markdown_debounce_ms or 250) + +---@param force? boolean +function M.request_on_data_rendered(force) + if force or not is_markdown_render_deferred() then + ctx.markdown_render_scheduled = false + M.trigger_on_data_rendered() + return + end + + ctx.markdown_render_scheduled = true +end + +---Run deferred markdown rendering once idle conditions are met. +function M.flush_pending_on_data_rendered() + if not ctx.markdown_render_scheduled or is_markdown_render_deferred() then + return + end + + ctx.markdown_render_scheduled = false + M.trigger_on_data_rendered() +end + +---Start collecting renderer writes into a single bulk update. +function M.begin_bulk_mode() + ctx:bulk_reset() + ctx.bulk_mode = true +end + +---Apply the buffered bulk render output to the output window. +function M.end_bulk_mode() + if not ctx.bulk_mode then + return + end + ctx.bulk_mode = false + local lines = ctx.bulk_buffer_lines + if #lines == 0 then + ctx:bulk_reset() + return + end + + -- Add trailing empty line to match non-bulk behavior + table.insert(lines, '') + + local buf = state.windows and state.windows.output_buf + if not buf or not vim.api.nvim_buf_is_valid(buf) then + ctx:bulk_reset() + return + end + + -- Write all lines at once. Suppress autocmds so render-markdown and similar + -- plugins don't fire mid-write; restore state even if the write fails. + local ok, err = xpcall(function() + with_suppressed_output_autocmds(function() + output_window.set_lines(lines, 0, -1) + end) + + output_window.clear_extmarks() + + if next(ctx.bulk_extmarks_by_line) then + output_window.set_extmarks(ctx.bulk_extmarks_by_line, 0) + end + end, debug.traceback) + + ctx:bulk_reset() + + if not ok then + error(err) + end + + vim.schedule(function() + M.request_on_data_rendered(true) + end) +end + +---Flush all pending renderer changes to the output buffer. +function M.flush() + local pending = snapshot_pending() + local applied = apply_pending(pending) + if applied and not ctx.bulk_mode then + M.request_on_data_rendered() + end +end + +return M diff --git a/lua/opencode/ui/renderer/scroll.lua b/lua/opencode/ui/renderer/scroll.lua new file mode 100644 index 00000000..80f2346b --- /dev/null +++ b/lua/opencode/ui/renderer/scroll.lua @@ -0,0 +1,65 @@ +local config = require('opencode.config') +local state = require('opencode.state') +local output_window = require('opencode.ui.output_window') + +local M = {} + +---@return integer|nil +function M.get_output_win() + local windows = state.windows + local win = windows and windows.output_win + if not win or not vim.api.nvim_win_is_valid(win) then + return nil + end + return win +end + +---Move the cursor in `win` to the last line of `buf` and scroll so it's visible. +---Also marks the window as "at bottom" so that the next is_at_bottom() call +---returns true even when the buffer grew past the current viewport. +---@param win integer +---@param buf integer +function M.scroll_win_to_bottom(win, buf) + local line_count = vim.api.nvim_buf_line_count(buf) + if line_count == 0 then + return + end + local last_line = vim.api.nvim_buf_get_lines(buf, line_count - 1, line_count, false)[1] or '' + vim.api.nvim_win_set_cursor(win, { line_count, #last_line }) + vim.api.nvim_win_call(win, function() + vim.cmd('normal! zb') + end) + output_window._was_at_bottom_by_win[win] = true +end + +---@param buf integer|nil +---@return { win: integer, follow: boolean }|nil +function M.pre_flush(buf) + if not buf or not vim.api.nvim_buf_is_valid(buf) then + return nil + end + + local win = M.get_output_win() + if not win or vim.api.nvim_win_get_buf(win) ~= buf then + return nil + end + + return { + win = win, + follow = output_window.is_at_bottom(win), + } +end + +---@param snapshot { win: integer, follow: boolean }|nil +---@param buf integer|nil +function M.post_flush(snapshot, buf) + if not snapshot or not snapshot.follow or not buf or not vim.api.nvim_buf_is_valid(buf) then + return + end + if not vim.api.nvim_win_is_valid(snapshot.win) or vim.api.nvim_win_get_buf(snapshot.win) ~= buf then + return + end + M.scroll_win_to_bottom(snapshot.win, buf) +end + +return M diff --git a/lua/opencode/ui/ui.lua b/lua/opencode/ui/ui.lua index b99d41e6..fe1684bc 100644 --- a/lua/opencode/ui/ui.lua +++ b/lua/opencode/ui/ui.lua @@ -90,6 +90,7 @@ function M.close_windows(windows, persist) return M.teardown_visible_windows(windows) end +---Clear Opencode-specific autocmds and shared UI state before closing windows. local function prepare_window_close() if M.is_opencode_focused() then M.return_to_last_code_win() @@ -105,6 +106,7 @@ local function prepare_window_close() topbar.close() end +---@param windows OpencodeWindowState local function close_or_restore_output_window(windows) if config.ui.position == 'current' then if windows.output_win and vim.api.nvim_win_is_valid(windows.output_win) then @@ -125,6 +127,7 @@ local function close_or_restore_output_window(windows) pcall(vim.api.nvim_win_close, windows.output_win, true) end +---@param windows OpencodeWindowState? function M.hide_visible_windows(windows) if not windows then return @@ -166,6 +169,7 @@ function M.hide_visible_windows(windows) end end +---@param windows OpencodeWindowState? function M.teardown_visible_windows(windows) if not windows then return @@ -186,6 +190,7 @@ function M.teardown_visible_windows(windows) state.ui.clear_hidden_window_state() end +---Drop preserved hidden buffers and clear hidden window state. function M.drop_hidden_snapshot() renderer.teardown() @@ -282,6 +287,7 @@ function M.has_hidden_buffers() return state.ui.has_hidden_buffers() end +---Return focus to the window that was active before opening Opencode. function M.return_to_last_code_win() local last_win = state.last_code_win_before_opencode if last_win and vim.api.nvim_win_is_valid(last_win) then @@ -289,6 +295,7 @@ function M.return_to_last_code_win() end end +---@return { input_buf: integer, output_buf: integer, footer_buf: integer } function M.setup_buffers() local input_buf = input_window.create_buf() local output_buf = output_window.create_buf() @@ -307,6 +314,9 @@ local function open_split(direction, type) return vim.api.nvim_get_current_win() end +---@param input_buf integer +---@param output_buf integer +---@return { input_win: integer, output_win: integer } function M.create_split_windows(input_buf, output_buf) if input_window.mounted() or output_window.mounted() then M.close_windows(state.windows, false) @@ -333,10 +343,17 @@ function M.create_split_windows(input_buf, output_buf) return { input_win = input_win, output_win = output_win } end +---@return OpencodeWindowState function M.create_windows() if config.ui.enable_treesitter_markdown then - vim.treesitter.language.register('markdown', 'opencode_output') - vim.treesitter.language.register('markdown', 'opencode') + local ok, treesitter = pcall(function() + return vim.treesitter + end) + + if ok and treesitter and treesitter.language and treesitter.language.register then + treesitter.language.register('markdown', 'opencode_output') + treesitter.language.register('markdown', 'opencode') + end end local autocmds = require('opencode.ui.autocmds') @@ -368,6 +385,7 @@ function M.create_windows() return windows end +---@param opts? { restore_position?: boolean, start_insert?: boolean } function M.focus_input(opts) opts = opts or {} local windows = state.windows @@ -398,6 +416,7 @@ function M.focus_input(opts) end end +---@param opts? { restore_position?: boolean } function M.focus_output(opts) opts = opts or {} local windows = state.windows @@ -412,6 +431,7 @@ function M.focus_output(opts) end end +---@return boolean function M.is_opencode_focused() if not state.windows then return false @@ -420,6 +440,8 @@ function M.is_opencode_focused() return M.is_opencode_window(current_win) end +---@param win integer +---@return boolean function M.is_opencode_window(win) local windows = state.windows if not windows then @@ -428,6 +450,7 @@ function M.is_opencode_window(win) return win == windows.input_win or win == windows.output_win end +---@return boolean function M.is_output_empty() local windows = state.windows if not windows or not windows.output_buf then @@ -437,6 +460,7 @@ function M.is_output_empty() return #lines == 0 or (#lines == 1 and lines[1] == '') end +---Reset renderer state and clear all visible output UI. function M.clear_output() renderer.reset() output_window.clear() @@ -457,11 +481,14 @@ function M.render_output(synchronous, opts) end end +---@param lines string[] function M.render_lines(lines) M.clear_output() renderer.render_lines(lines) end +---@param sessions Session[] +---@param cb fun(session: Session|nil) function M.select_session(sessions, cb) local session_picker = require('opencode.ui.session_picker') local util = require('opencode.util') @@ -493,6 +520,7 @@ function M.select_session(sessions, cb) end end +---Switch focus between the input and output panes. function M.toggle_pane() local current_win = vim.api.nvim_get_current_win() if state.windows and current_win == state.windows.input_win then @@ -502,6 +530,7 @@ function M.toggle_pane() end end +---Swap the split position and reopen the UI. function M.swap_position() local ui_conf = config.ui local new_pos = (ui_conf.position == 'left') and 'right' or 'left' @@ -515,6 +544,7 @@ function M.swap_position() end) end +---Toggle the current Opencode window width between normal and zoomed. function M.toggle_zoom() local windows = state.windows if not windows or not (windows.output_win or windows.input_win) then @@ -531,6 +561,7 @@ function M.toggle_zoom() width = math.floor(config.ui.zoom_width * vim.o.columns) end + ---@param win integer|nil local function resize_window(win) if not win or not vim.api.nvim_win_is_valid(win) then return diff --git a/lua/opencode/util.lua b/lua/opencode/util.lua index cde1b4ee..1f51a76f 100644 --- a/lua/opencode/util.lua +++ b/lua/opencode/util.lua @@ -499,6 +499,17 @@ function M.check_prompt_allowed(guard_callback, mentioned_files) return result, nil end +local _filetype_overrides = { + javascriptreact = 'jsx', + typescriptreact = 'tsx', + typescript = 'ts', + javascipt = 'js', + sh = 'bash', + yaml = 'yml', + text = 'txt', -- nvim 0.12-nightly returns text as the type which breaks our unit tests +} +local _filetype_cache = {} + --- Get the markdown type to use based on the filename. First gets the neovim type --- for the file. Then apply any specific overrides. Falls back to using the file --- extension if nothing else matches @@ -509,27 +520,16 @@ function M.get_markdown_filetype(filename) return '' end - local file_type_overrides = { - javascriptreact = 'jsx', - typescriptreact = 'tsx', - typescript = 'ts', - javascipt = 'js', - sh = 'bash', - yaml = 'yml', - text = 'txt', -- nvim 0.12-nightly returns text as the type which breaks our unit tests - } - - local file_type = vim.filetype.match({ filename = filename }) or '' - - if file_type_overrides[file_type] then - return file_type_overrides[file_type] + local cached = _filetype_cache[filename] + if cached ~= nil then + return cached end - if file_type and file_type ~= '' then - return file_type - end + local file_type = vim.filetype.match({ filename = filename }) or '' + local result = _filetype_overrides[file_type] or (file_type ~= '' and file_type) or vim.fn.fnamemodify(filename, ':e') - return vim.fn.fnamemodify(filename, ':e') + _filetype_cache[filename] = result + return result end function M.strdisplaywidth(str) diff --git a/tests/data/message-removal.expected.json b/tests/data/message-removal.expected.json index eecce14e..6fe1d937 100644 --- a/tests/data/message-removal.expected.json +++ b/tests/data/message-removal.expected.json @@ -1,4 +1,5 @@ { + "timestamp": 1774895738, "lines": [ "----", "", @@ -36,33 +37,19 @@ 0, { "virt_text_repeat_linebreak": false, - "virt_text": [ - [ - "▌󰭻 ", - "OpencodeMessageRoleUser" - ], - [ - " " - ], - [ - "USER", - "OpencodeMessageRoleUser" - ], - [ - "", - "OpencodeHint" - ], - [ - " [msg_001]", - "OpencodeHint" - ] - ], + "virt_text_pos": "win_col", "right_gravity": true, + "virt_text_hide": false, "virt_text_win_col": -3, - "priority": 10, - "virt_text_pos": "win_col", + "virt_text": [ + ["▌󰭻 ", "OpencodeMessageRoleUser"], + [" "], + ["USER", "OpencodeMessageRoleUser"], + ["", "OpencodeHint"], + [" [msg_001]", "OpencodeHint"] + ], "ns_id": 3, - "virt_text_hide": false + "priority": 10 } ], [ @@ -71,17 +58,12 @@ 0, { "virt_text_repeat_linebreak": false, - "virt_text": [ - [ - " 2025-10-09 08:53:21", - "OpencodeHint" - ] - ], "right_gravity": true, - "priority": 9, "virt_text_pos": "right_align", + "virt_text_hide": false, + "virt_text": [[" 2025-10-09 08:53:21", "OpencodeHint"]], "ns_id": 3, - "virt_text_hide": false + "priority": 9 } ], [ @@ -90,18 +72,13 @@ 0, { "virt_text_repeat_linebreak": true, - "virt_text": [ - [ - "▌", - "OpencodeMessageRoleUser" - ] - ], + "virt_text_pos": "win_col", "right_gravity": true, + "virt_text_hide": false, "virt_text_win_col": -3, - "priority": 4096, - "virt_text_pos": "win_col", + "virt_text": [["▌", "OpencodeMessageRoleUser"]], "ns_id": 3, - "virt_text_hide": false + "priority": 4096 } ], [ @@ -110,18 +87,13 @@ 0, { "virt_text_repeat_linebreak": true, - "virt_text": [ - [ - "▌", - "OpencodeMessageRoleUser" - ] - ], + "virt_text_pos": "win_col", "right_gravity": true, + "virt_text_hide": false, "virt_text_win_col": -3, - "priority": 4096, - "virt_text_pos": "win_col", + "virt_text": [["▌", "OpencodeMessageRoleUser"]], "ns_id": 3, - "virt_text_hide": false + "priority": 4096 } ], [ @@ -130,18 +102,13 @@ 0, { "virt_text_repeat_linebreak": true, - "virt_text": [ - [ - "▌", - "OpencodeMessageRoleUser" - ] - ], + "virt_text_pos": "win_col", "right_gravity": true, + "virt_text_hide": false, "virt_text_win_col": -3, - "priority": 4096, - "virt_text_pos": "win_col", + "virt_text": [["▌", "OpencodeMessageRoleUser"]], "ns_id": 3, - "virt_text_hide": false + "priority": 4096 } ], [ @@ -150,113 +117,73 @@ 0, { "virt_text_repeat_linebreak": true, - "virt_text": [ - [ - "▌", - "OpencodeMessageRoleUser" - ] - ], + "virt_text_pos": "win_col", "right_gravity": true, + "virt_text_hide": false, "virt_text_win_col": -3, - "priority": 4096, - "virt_text_pos": "win_col", + "virt_text": [["▌", "OpencodeMessageRoleUser"]], "ns_id": 3, - "virt_text_hide": false + "priority": 4096 } ], [ 7, - 7, + 6, 0, { "virt_text_repeat_linebreak": true, - "virt_text": [ - [ - "▌", - "OpencodeMessageRoleUser" - ] - ], + "virt_text_pos": "win_col", "right_gravity": true, + "virt_text_hide": false, "virt_text_win_col": -3, - "priority": 4096, - "virt_text_pos": "win_col", + "virt_text": [["▌", "OpencodeMessageRoleUser"]], "ns_id": 3, - "virt_text_hide": false + "priority": 4096 } ], [ 8, - 8, + 7, 0, { "virt_text_repeat_linebreak": true, - "virt_text": [ - [ - "▌", - "OpencodeMessageRoleUser" - ] - ], + "virt_text_pos": "win_col", "right_gravity": true, + "virt_text_hide": false, "virt_text_win_col": -3, - "priority": 4096, - "virt_text_pos": "win_col", + "virt_text": [["▌", "OpencodeMessageRoleUser"]], "ns_id": 3, - "virt_text_hide": false + "priority": 4096 } ], [ 9, - 9, + 8, 0, { "virt_text_repeat_linebreak": true, - "virt_text": [ - [ - "▌", - "OpencodeMessageRoleUser" - ] - ], + "virt_text_pos": "win_col", "right_gravity": true, + "virt_text_hide": false, "virt_text_win_col": -3, - "priority": 4096, - "virt_text_pos": "win_col", + "virt_text": [["▌", "OpencodeMessageRoleUser"]], "ns_id": 3, - "virt_text_hide": false + "priority": 4096 } ], [ 10, - 12, + 9, 0, { - "virt_text_repeat_linebreak": false, - "virt_text": [ - [ - " ", - "OpencodeMessageRoleAssistant" - ], - [ - " " - ], - [ - "BUILD", - "OpencodeMessageRoleAssistant" - ], - [ - "", - "OpencodeHint" - ], - [ - " [msg_002]", - "OpencodeHint" - ] - ], + "virt_text_repeat_linebreak": true, + "virt_text_pos": "win_col", "right_gravity": true, + "virt_text_hide": false, "virt_text_win_col": -3, - "priority": 10, - "virt_text_pos": "win_col", + "virt_text": [["▌", "OpencodeMessageRoleUser"]], "ns_id": 3, - "virt_text_hide": false + "priority": 4096 } ], [ @@ -265,52 +192,33 @@ 0, { "virt_text_repeat_linebreak": false, + "virt_text_pos": "win_col", + "right_gravity": true, + "virt_text_hide": false, + "virt_text_win_col": -3, "virt_text": [ - [ - " 2025-10-09 08:53:22", - "OpencodeHint" - ] + [" ", "OpencodeMessageRoleAssistant"], + [" "], + ["BUILD", "OpencodeMessageRoleAssistant"], + ["", "OpencodeHint"], + [" [msg_002]", "OpencodeHint"] ], - "right_gravity": true, - "priority": 9, - "virt_text_pos": "right_align", "ns_id": 3, - "virt_text_hide": false + "priority": 10 } ], [ 12, - 21, + 12, 0, { "virt_text_repeat_linebreak": false, - "virt_text": [ - [ - " ", - "OpencodeMessageRoleAssistant" - ], - [ - " " - ], - [ - "BUILD", - "OpencodeMessageRoleAssistant" - ], - [ - "", - "OpencodeHint" - ], - [ - " [msg_004]", - "OpencodeHint" - ] - ], "right_gravity": true, - "virt_text_win_col": -3, - "priority": 10, - "virt_text_pos": "win_col", + "virt_text_pos": "right_align", + "virt_text_hide": false, + "virt_text": [[" 2025-10-09 08:53:22", "OpencodeHint"]], "ns_id": 3, - "virt_text_hide": false + "priority": 9 } ], [ @@ -319,20 +227,35 @@ 0, { "virt_text_repeat_linebreak": false, + "virt_text_pos": "win_col", + "right_gravity": true, + "virt_text_hide": false, + "virt_text_win_col": -3, "virt_text": [ - [ - " 2025-10-09 08:53:24", - "OpencodeHint" - ] + [" ", "OpencodeMessageRoleAssistant"], + [" "], + ["BUILD", "OpencodeMessageRoleAssistant"], + ["", "OpencodeHint"], + [" [msg_004]", "OpencodeHint"] ], + "ns_id": 3, + "priority": 10 + } + ], + [ + 14, + 21, + 0, + { + "virt_text_repeat_linebreak": false, "right_gravity": true, - "priority": 9, "virt_text_pos": "right_align", + "virt_text_hide": false, + "virt_text": [[" 2025-10-09 08:53:24", "OpencodeHint"]], "ns_id": 3, - "virt_text_hide": false + "priority": 9 } ] ], - "actions": [], - "timestamp": 1770935236 + "actions": [] } diff --git a/tests/data/multiple-question-ask.expected.json b/tests/data/multiple-question-ask.expected.json index 5b528cf1..2b816f34 100644 --- a/tests/data/multiple-question-ask.expected.json +++ b/tests/data/multiple-question-ask.expected.json @@ -1,39 +1,53 @@ { - "actions": [], + "lines": [ + "----", + "", + "", + "[`tests/replay/renderer_spec.lua`](tests/replay/renderer_spec.lua)", + "", + "can you use the question tool and ask me a couple of questions", + "", + "----", + "", + "", + "** question** ", + "", + "----", + "", + "", + " Question (1/2)", + "", + "Which Lua testing framework do you use or prefer for your Neovim Lua plugins?", + "", + " 1. busted - Busted is the most common Lua testing framework. ", + " 2. plenary.nvim - Plenary.nvim provides unit test utilities for Neovim plugins.", + " 3. other - Other or custom test framework", + "", + "Navigate: `j`/`k` or `↑`/`↓` Select: `` or `1-3` Dismiss: ``", + "", + "" + ], + "timestamp": 1774895736, "extmarks": [ [ 1, 1, 0, { + "virt_text_hide": false, + "right_gravity": true, + "virt_text_repeat_linebreak": false, + "virt_text_win_col": -3, "ns_id": 3, "priority": 10, - "right_gravity": true, - "virt_text": [ - [ - "▌󰭻 ", - "OpencodeMessageRoleUser" - ], - [ - " " - ], - [ - "USER", - "OpencodeMessageRoleUser" - ], - [ - "", - "OpencodeHint" - ], - [ - " [msg_bfab6dbf3001ZOVHTFKR1CMUE5]", - "OpencodeHint" - ] - ], - "virt_text_hide": false, "virt_text_pos": "win_col", - "virt_text_repeat_linebreak": false, - "virt_text_win_col": -3 + "virt_text": [ + ["▌󰭻 ", "OpencodeMessageRoleUser"], + [" "], + ["USER", "OpencodeMessageRoleUser"], + ["", "OpencodeHint"], + [" [msg_bfab6dbf3001ZOVHTFKR1CMUE5]", "OpencodeHint"] + ] } ], [ @@ -41,18 +55,13 @@ 1, 0, { - "ns_id": 3, - "priority": 9, "right_gravity": true, - "virt_text": [ - [ - " 2026-01-26 14:30:46", - "OpencodeHint" - ] - ], + "virt_text_repeat_linebreak": false, "virt_text_hide": false, + "ns_id": 3, + "priority": 9, "virt_text_pos": "right_align", - "virt_text_repeat_linebreak": false + "virt_text": [[" 2026-01-26 14:30:46", "OpencodeHint"]] } ], [ @@ -60,19 +69,14 @@ 2, 0, { + "virt_text_hide": false, + "right_gravity": true, + "virt_text_repeat_linebreak": true, + "virt_text_win_col": -3, "ns_id": 3, "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeMessageRoleUser" - ] - ], - "virt_text_hide": false, "virt_text_pos": "win_col", - "virt_text_repeat_linebreak": true, - "virt_text_win_col": -3 + "virt_text": [["▌", "OpencodeMessageRoleUser"]] } ], [ @@ -80,19 +84,14 @@ 3, 0, { + "virt_text_hide": false, + "right_gravity": true, + "virt_text_repeat_linebreak": true, + "virt_text_win_col": -3, "ns_id": 3, "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeMessageRoleUser" - ] - ], - "virt_text_hide": false, "virt_text_pos": "win_col", - "virt_text_repeat_linebreak": true, - "virt_text_win_col": -3 + "virt_text": [["▌", "OpencodeMessageRoleUser"]] } ], [ @@ -100,19 +99,14 @@ 4, 0, { + "virt_text_hide": false, + "right_gravity": true, + "virt_text_repeat_linebreak": true, + "virt_text_win_col": -3, "ns_id": 3, "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeMessageRoleUser" - ] - ], - "virt_text_hide": false, "virt_text_pos": "win_col", - "virt_text_repeat_linebreak": true, - "virt_text_win_col": -3 + "virt_text": [["▌", "OpencodeMessageRoleUser"]] } ], [ @@ -120,19 +114,14 @@ 5, 0, { + "virt_text_hide": false, + "right_gravity": true, + "virt_text_repeat_linebreak": true, + "virt_text_win_col": -3, "ns_id": 3, "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeMessageRoleUser" - ] - ], - "virt_text_hide": false, "virt_text_pos": "win_col", - "virt_text_repeat_linebreak": true, - "virt_text_win_col": -3 + "virt_text": [["▌", "OpencodeMessageRoleUser"]] } ], [ @@ -140,19 +129,14 @@ 6, 0, { + "virt_text_hide": false, + "right_gravity": true, + "virt_text_repeat_linebreak": true, + "virt_text_win_col": -3, "ns_id": 3, "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeMessageRoleUser" - ] - ], - "virt_text_hide": false, "virt_text_pos": "win_col", - "virt_text_repeat_linebreak": true, - "virt_text_win_col": -3 + "virt_text": [["▌", "OpencodeMessageRoleUser"]] } ], [ @@ -160,34 +144,20 @@ 8, 0, { + "virt_text_hide": false, + "right_gravity": true, + "virt_text_repeat_linebreak": false, + "virt_text_win_col": -3, "ns_id": 3, "priority": 10, - "right_gravity": true, - "virt_text": [ - [ - " ", - "OpencodeMessageRoleAssistant" - ], - [ - " " - ], - [ - "BUILD", - "OpencodeMessageRoleAssistant" - ], - [ - " gpt-4.1", - "OpencodeHint" - ], - [ - " [msg_bfab6dc80001FueCN7E2691J2R]", - "OpencodeHint" - ] - ], - "virt_text_hide": false, "virt_text_pos": "win_col", - "virt_text_repeat_linebreak": false, - "virt_text_win_col": -3 + "virt_text": [ + [" ", "OpencodeMessageRoleAssistant"], + [" "], + ["BUILD", "OpencodeMessageRoleAssistant"], + [" gpt-4.1", "OpencodeHint"], + [" [msg_bfab6dc80001FueCN7E2691J2R]", "OpencodeHint"] + ] } ], [ @@ -195,18 +165,13 @@ 8, 0, { - "ns_id": 3, - "priority": 9, "right_gravity": true, - "virt_text": [ - [ - " 2026-01-26 14:30:46", - "OpencodeHint" - ] - ], + "virt_text_repeat_linebreak": false, "virt_text_hide": false, + "ns_id": 3, + "priority": 9, "virt_text_pos": "right_align", - "virt_text_repeat_linebreak": false + "virt_text": [[" 2026-01-26 14:30:46", "OpencodeHint"]] } ], [ @@ -214,34 +179,20 @@ 13, 0, { + "virt_text_hide": false, + "right_gravity": true, + "virt_text_repeat_linebreak": false, + "virt_text_win_col": -3, "ns_id": 3, "priority": 10, - "right_gravity": true, - "virt_text": [ - [ - " ", - "OpencodeMessageRoleSystem" - ], - [ - " " - ], - [ - "SYSTEM", - "OpencodeMessageRoleSystem" - ], - [ - "", - "OpencodeHint" - ], - [ - " [question-display-message]", - "OpencodeHint" - ] - ], - "virt_text_hide": false, "virt_text_pos": "win_col", - "virt_text_repeat_linebreak": false, - "virt_text_win_col": -3 + "virt_text": [ + [" ", "OpencodeMessageRoleSystem"], + [" "], + ["SYSTEM", "OpencodeMessageRoleSystem"], + ["", "OpencodeHint"], + [" [question-display-message]", "OpencodeHint"] + ] } ], [ @@ -249,10 +200,10 @@ 15, 0, { - "line_hl_group": "OpencodeQuestionTitle", "ns_id": 3, - "priority": 4096, - "right_gravity": true + "right_gravity": true, + "line_hl_group": "OpencodeQuestionTitle", + "priority": 4096 } ], [ @@ -260,19 +211,14 @@ 15, 0, { + "virt_text_hide": false, + "right_gravity": true, + "virt_text_repeat_linebreak": true, + "virt_text_win_col": -2, "ns_id": 3, "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeQuestionBorder" - ] - ], - "virt_text_hide": false, "virt_text_pos": "win_col", - "virt_text_repeat_linebreak": true, - "virt_text_win_col": -2 + "virt_text": [["▌", "OpencodeQuestionBorder"]] } ], [ @@ -280,19 +226,14 @@ 16, 0, { + "virt_text_hide": false, + "right_gravity": true, + "virt_text_repeat_linebreak": true, + "virt_text_win_col": -2, "ns_id": 3, "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeQuestionBorder" - ] - ], - "virt_text_hide": false, "virt_text_pos": "win_col", - "virt_text_repeat_linebreak": true, - "virt_text_win_col": -2 + "virt_text": [["▌", "OpencodeQuestionBorder"]] } ], [ @@ -300,19 +241,14 @@ 17, 0, { + "virt_text_hide": false, + "right_gravity": true, + "virt_text_repeat_linebreak": true, + "virt_text_win_col": -2, "ns_id": 3, "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeQuestionBorder" - ] - ], - "virt_text_hide": false, "virt_text_pos": "win_col", - "virt_text_repeat_linebreak": true, - "virt_text_win_col": -2 + "virt_text": [["▌", "OpencodeQuestionBorder"]] } ], [ @@ -320,19 +256,14 @@ 18, 0, { + "virt_text_hide": false, + "right_gravity": true, + "virt_text_repeat_linebreak": true, + "virt_text_win_col": -2, "ns_id": 3, "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeQuestionBorder" - ] - ], - "virt_text_hide": false, "virt_text_pos": "win_col", - "virt_text_repeat_linebreak": true, - "virt_text_win_col": -2 + "virt_text": [["▌", "OpencodeQuestionBorder"]] } ], [ @@ -340,10 +271,10 @@ 19, 0, { - "line_hl_group": "OpencodeDialogOptionHover", "ns_id": 3, - "priority": 4096, - "right_gravity": true + "right_gravity": true, + "line_hl_group": "OpencodeDialogOptionHover", + "priority": 4096 } ], [ @@ -351,19 +282,14 @@ 19, 0, { + "virt_text_hide": false, + "right_gravity": true, + "virt_text_repeat_linebreak": true, + "virt_text_win_col": -2, "ns_id": 3, "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeQuestionBorder" - ] - ], - "virt_text_hide": false, "virt_text_pos": "win_col", - "virt_text_repeat_linebreak": true, - "virt_text_win_col": -2 + "virt_text": [["▌", "OpencodeQuestionBorder"]] } ], [ @@ -371,18 +297,13 @@ 19, 2, { - "ns_id": 3, - "priority": 4096, "right_gravity": true, - "virt_text": [ - [ - "› ", - "OpencodeDialogOptionHover" - ] - ], + "virt_text_repeat_linebreak": false, "virt_text_hide": false, + "ns_id": 3, + "priority": 4096, "virt_text_pos": "overlay", - "virt_text_repeat_linebreak": false + "virt_text": [["› ", "OpencodeDialogOptionHover"]] } ], [ @@ -390,19 +311,14 @@ 20, 0, { + "virt_text_hide": false, + "right_gravity": true, + "virt_text_repeat_linebreak": true, + "virt_text_win_col": -2, "ns_id": 3, "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeQuestionBorder" - ] - ], - "virt_text_hide": false, "virt_text_pos": "win_col", - "virt_text_repeat_linebreak": true, - "virt_text_win_col": -2 + "virt_text": [["▌", "OpencodeQuestionBorder"]] } ], [ @@ -410,19 +326,14 @@ 21, 0, { + "virt_text_hide": false, + "right_gravity": true, + "virt_text_repeat_linebreak": true, + "virt_text_win_col": -2, "ns_id": 3, "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeQuestionBorder" - ] - ], - "virt_text_hide": false, "virt_text_pos": "win_col", - "virt_text_repeat_linebreak": true, - "virt_text_win_col": -2 + "virt_text": [["▌", "OpencodeQuestionBorder"]] } ], [ @@ -430,19 +341,14 @@ 22, 0, { + "virt_text_hide": false, + "right_gravity": true, + "virt_text_repeat_linebreak": true, + "virt_text_win_col": -2, "ns_id": 3, "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeQuestionBorder" - ] - ], - "virt_text_hide": false, "virt_text_pos": "win_col", - "virt_text_repeat_linebreak": true, - "virt_text_win_col": -2 + "virt_text": [["▌", "OpencodeQuestionBorder"]] } ], [ @@ -450,49 +356,16 @@ 23, 0, { + "virt_text_hide": false, + "right_gravity": true, + "virt_text_repeat_linebreak": true, + "virt_text_win_col": -2, "ns_id": 3, "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeQuestionBorder" - ] - ], - "virt_text_hide": false, "virt_text_pos": "win_col", - "virt_text_repeat_linebreak": true, - "virt_text_win_col": -2 + "virt_text": [["▌", "OpencodeQuestionBorder"]] } ] ], - "lines": [ - "----", - "", - "", - "[`tests/replay/renderer_spec.lua`](tests/replay/renderer_spec.lua)", - "", - "can you use the question tool and ask me a couple of questions", - "", - "----", - "", - "", - "** question** ", - "", - "----", - "", - "", - " Question (1/2)", - "", - "Which Lua testing framework do you use or prefer for your Neovim Lua plugins?", - "", - " 1. busted - Busted is the most common Lua testing framework. ", - " 2. plenary.nvim - Plenary.nvim provides unit test utilities for Neovim plugins.", - " 3. other - Other or custom test framework", - "", - "Navigate: `j`/`k` or `↑`/`↓` Select: `` or `1-3` Dismiss: ``", - "", - "" - ], - "timestamp": 1770935237 -} \ No newline at end of file + "actions": [] +} diff --git a/tests/data/permission-ask-new.expected.json b/tests/data/permission-ask-new.expected.json index ad54b7ff..ca4f1322 100644 --- a/tests/data/permission-ask-new.expected.json +++ b/tests/data/permission-ask-new.expected.json @@ -1,5 +1,4 @@ { - "actions": [], "extmarks": [ [ 1, @@ -7,33 +6,19 @@ 0, { "ns_id": 3, + "virt_text_hide": false, + "virt_text_repeat_linebreak": false, + "virt_text_win_col": -3, "priority": 10, - "right_gravity": true, "virt_text": [ - [ - "▌󰭻 ", - "OpencodeMessageRoleUser" - ], - [ - " " - ], - [ - "USER", - "OpencodeMessageRoleUser" - ], - [ - "", - "OpencodeHint" - ], - [ - " [msg_b8e7c60a2001Kisjwk2mVB4dye]", - "OpencodeHint" - ] + ["▌󰭻 ", "OpencodeMessageRoleUser"], + [" "], + ["USER", "OpencodeMessageRoleUser"], + ["", "OpencodeHint"], + [" [msg_b8e7c60a2001Kisjwk2mVB4dye]", "OpencodeHint"] ], - "virt_text_hide": false, - "virt_text_pos": "win_col", - "virt_text_repeat_linebreak": false, - "virt_text_win_col": -3 + "right_gravity": true, + "virt_text_pos": "win_col" } ], [ @@ -42,17 +27,12 @@ 0, { "ns_id": 3, + "virt_text_hide": false, + "virt_text_repeat_linebreak": false, "priority": 9, + "virt_text": [[" 2026-01-05 14:07:54", "OpencodeHint"]], "right_gravity": true, - "virt_text": [ - [ - " 2026-01-05 14:07:54", - "OpencodeHint" - ] - ], - "virt_text_hide": false, - "virt_text_pos": "right_align", - "virt_text_repeat_linebreak": false + "virt_text_pos": "right_align" } ], [ @@ -61,18 +41,13 @@ 0, { "ns_id": 3, - "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeMessageRoleUser" - ] - ], "virt_text_hide": false, - "virt_text_pos": "win_col", "virt_text_repeat_linebreak": true, - "virt_text_win_col": -3 + "virt_text_win_col": -3, + "priority": 4096, + "virt_text": [["▌", "OpencodeMessageRoleUser"]], + "right_gravity": true, + "virt_text_pos": "win_col" } ], [ @@ -81,18 +56,13 @@ 0, { "ns_id": 3, - "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeMessageRoleUser" - ] - ], "virt_text_hide": false, - "virt_text_pos": "win_col", "virt_text_repeat_linebreak": true, - "virt_text_win_col": -3 + "virt_text_win_col": -3, + "priority": 4096, + "virt_text": [["▌", "OpencodeMessageRoleUser"]], + "right_gravity": true, + "virt_text_pos": "win_col" } ], [ @@ -101,18 +71,13 @@ 0, { "ns_id": 3, - "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeMessageRoleUser" - ] - ], "virt_text_hide": false, - "virt_text_pos": "win_col", "virt_text_repeat_linebreak": true, - "virt_text_win_col": -3 + "virt_text_win_col": -3, + "priority": 4096, + "virt_text": [["▌", "OpencodeMessageRoleUser"]], + "right_gravity": true, + "virt_text_pos": "win_col" } ], [ @@ -121,18 +86,13 @@ 0, { "ns_id": 3, - "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeMessageRoleUser" - ] - ], "virt_text_hide": false, - "virt_text_pos": "win_col", "virt_text_repeat_linebreak": true, - "virt_text_win_col": -3 + "virt_text_win_col": -3, + "priority": 4096, + "virt_text": [["▌", "OpencodeMessageRoleUser"]], + "right_gravity": true, + "virt_text_pos": "win_col" } ], [ @@ -141,18 +101,13 @@ 0, { "ns_id": 3, - "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeMessageRoleUser" - ] - ], "virt_text_hide": false, - "virt_text_pos": "win_col", "virt_text_repeat_linebreak": true, - "virt_text_win_col": -3 + "virt_text_win_col": -3, + "priority": 4096, + "virt_text": [["▌", "OpencodeMessageRoleUser"]], + "right_gravity": true, + "virt_text_pos": "win_col" } ], [ @@ -161,18 +116,13 @@ 0, { "ns_id": 3, - "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeMessageRoleUser" - ] - ], "virt_text_hide": false, - "virt_text_pos": "win_col", "virt_text_repeat_linebreak": true, - "virt_text_win_col": -3 + "virt_text_win_col": -3, + "priority": 4096, + "virt_text": [["▌", "OpencodeMessageRoleUser"]], + "right_gravity": true, + "virt_text_pos": "win_col" } ], [ @@ -181,18 +131,13 @@ 0, { "ns_id": 3, - "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeMessageRoleUser" - ] - ], "virt_text_hide": false, - "virt_text_pos": "win_col", "virt_text_repeat_linebreak": true, - "virt_text_win_col": -3 + "virt_text_win_col": -3, + "priority": 4096, + "virt_text": [["▌", "OpencodeMessageRoleUser"]], + "right_gravity": true, + "virt_text_pos": "win_col" } ], [ @@ -201,33 +146,19 @@ 0, { "ns_id": 3, + "virt_text_hide": false, + "virt_text_repeat_linebreak": false, + "virt_text_win_col": -3, "priority": 10, - "right_gravity": true, "virt_text": [ - [ - " ", - "OpencodeMessageRoleAssistant" - ], - [ - " " - ], - [ - "BUILD", - "OpencodeMessageRoleAssistant" - ], - [ - " gpt-4.1", - "OpencodeHint" - ], - [ - " [msg_b8e7c60f1001aEWYlAaDRXQ4aJ]", - "OpencodeHint" - ] + [" ", "OpencodeMessageRoleAssistant"], + [" "], + ["BUILD", "OpencodeMessageRoleAssistant"], + [" gpt-4.1", "OpencodeHint"], + [" [msg_b8e7c60f1001aEWYlAaDRXQ4aJ]", "OpencodeHint"] ], - "virt_text_hide": false, - "virt_text_pos": "win_col", - "virt_text_repeat_linebreak": false, - "virt_text_win_col": -3 + "right_gravity": true, + "virt_text_pos": "win_col" } ], [ @@ -236,17 +167,12 @@ 0, { "ns_id": 3, + "virt_text_hide": false, + "virt_text_repeat_linebreak": false, "priority": 9, + "virt_text": [[" 2026-01-05 14:07:54", "OpencodeHint"]], "right_gravity": true, - "virt_text": [ - [ - " 2026-01-05 14:07:54", - "OpencodeHint" - ] - ], - "virt_text_hide": false, - "virt_text_pos": "right_align", - "virt_text_repeat_linebreak": false + "virt_text_pos": "right_align" } ], [ @@ -255,18 +181,13 @@ 0, { "ns_id": 3, - "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeToolBorder" - ] - ], "virt_text_hide": false, - "virt_text_pos": "win_col", "virt_text_repeat_linebreak": true, - "virt_text_win_col": -1 + "virt_text_win_col": -1, + "priority": 4096, + "virt_text": [["▌", "OpencodeToolBorder"]], + "right_gravity": true, + "virt_text_pos": "win_col" } ], [ @@ -275,18 +196,13 @@ 0, { "ns_id": 3, - "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeToolBorder" - ] - ], "virt_text_hide": false, - "virt_text_pos": "win_col", "virt_text_repeat_linebreak": true, - "virt_text_win_col": -1 + "virt_text_win_col": -1, + "priority": 4096, + "virt_text": [["▌", "OpencodeToolBorder"]], + "right_gravity": true, + "virt_text_pos": "win_col" } ], [ @@ -295,18 +211,13 @@ 0, { "ns_id": 3, - "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeToolBorder" - ] - ], "virt_text_hide": false, - "virt_text_pos": "win_col", "virt_text_repeat_linebreak": true, - "virt_text_win_col": -1 + "virt_text_win_col": -1, + "priority": 4096, + "virt_text": [["▌", "OpencodeToolBorder"]], + "right_gravity": true, + "virt_text_pos": "win_col" } ], [ @@ -315,18 +226,13 @@ 0, { "ns_id": 3, - "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeToolBorder" - ] - ], "virt_text_hide": false, - "virt_text_pos": "win_col", "virt_text_repeat_linebreak": true, - "virt_text_win_col": -1 + "virt_text_win_col": -1, + "priority": 4096, + "virt_text": [["▌", "OpencodeToolBorder"]], + "right_gravity": true, + "virt_text_pos": "win_col" } ], [ @@ -335,18 +241,13 @@ 0, { "ns_id": 3, - "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeToolBorder" - ] - ], "virt_text_hide": false, - "virt_text_pos": "win_col", "virt_text_repeat_linebreak": true, - "virt_text_win_col": -1 + "virt_text_win_col": -1, + "priority": 4096, + "virt_text": [["▌", "OpencodeToolBorder"]], + "right_gravity": true, + "virt_text_pos": "win_col" } ], [ @@ -355,18 +256,13 @@ 0, { "ns_id": 3, - "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeToolBorder" - ] - ], "virt_text_hide": false, - "virt_text_pos": "win_col", "virt_text_repeat_linebreak": true, - "virt_text_win_col": -1 + "virt_text_win_col": -1, + "priority": 4096, + "virt_text": [["▌", "OpencodeToolBorder"]], + "right_gravity": true, + "virt_text_pos": "win_col" } ], [ @@ -375,33 +271,19 @@ 0, { "ns_id": 3, + "virt_text_hide": false, + "virt_text_repeat_linebreak": false, + "virt_text_win_col": -3, "priority": 10, - "right_gravity": true, "virt_text": [ - [ - " ", - "OpencodeMessageRoleSystem" - ], - [ - " " - ], - [ - "SYSTEM", - "OpencodeMessageRoleSystem" - ], - [ - "", - "OpencodeHint" - ], - [ - " [permission-display-message]", - "OpencodeHint" - ] + [" ", "OpencodeMessageRoleSystem"], + [" "], + ["SYSTEM", "OpencodeMessageRoleSystem"], + ["", "OpencodeHint"], + [" [permission-display-message]", "OpencodeHint"] ], - "virt_text_hide": false, - "virt_text_pos": "win_col", - "virt_text_repeat_linebreak": false, - "virt_text_win_col": -3 + "right_gravity": true, + "virt_text_pos": "win_col" } ], [ @@ -409,10 +291,10 @@ 22, 0, { - "line_hl_group": "OpencodePermissionTitle", "ns_id": 3, - "priority": 4096, - "right_gravity": true + "line_hl_group": "OpencodePermissionTitle", + "right_gravity": true, + "priority": 4096 } ], [ @@ -421,18 +303,13 @@ 0, { "ns_id": 3, - "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodePermissionBorder" - ] - ], "virt_text_hide": false, - "virt_text_pos": "win_col", "virt_text_repeat_linebreak": true, - "virt_text_win_col": -2 + "virt_text_win_col": -2, + "priority": 4096, + "virt_text": [["▌", "OpencodePermissionBorder"]], + "right_gravity": true, + "virt_text_pos": "win_col" } ], [ @@ -441,18 +318,13 @@ 0, { "ns_id": 3, - "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodePermissionBorder" - ] - ], "virt_text_hide": false, - "virt_text_pos": "win_col", "virt_text_repeat_linebreak": true, - "virt_text_win_col": -2 + "virt_text_win_col": -2, + "priority": 4096, + "virt_text": [["▌", "OpencodePermissionBorder"]], + "right_gravity": true, + "virt_text_pos": "win_col" } ], [ @@ -461,18 +333,13 @@ 0, { "ns_id": 3, - "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodePermissionBorder" - ] - ], "virt_text_hide": false, - "virt_text_pos": "win_col", "virt_text_repeat_linebreak": true, - "virt_text_win_col": -2 + "virt_text_win_col": -2, + "priority": 4096, + "virt_text": [["▌", "OpencodePermissionBorder"]], + "right_gravity": true, + "virt_text_pos": "win_col" } ], [ @@ -481,18 +348,13 @@ 0, { "ns_id": 3, - "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodePermissionBorder" - ] - ], "virt_text_hide": false, - "virt_text_pos": "win_col", "virt_text_repeat_linebreak": true, - "virt_text_win_col": -2 + "virt_text_win_col": -2, + "priority": 4096, + "virt_text": [["▌", "OpencodePermissionBorder"]], + "right_gravity": true, + "virt_text_pos": "win_col" } ], [ @@ -501,18 +363,13 @@ 0, { "ns_id": 3, - "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodePermissionBorder" - ] - ], "virt_text_hide": false, - "virt_text_pos": "win_col", "virt_text_repeat_linebreak": true, - "virt_text_win_col": -2 + "virt_text_win_col": -2, + "priority": 4096, + "virt_text": [["▌", "OpencodePermissionBorder"]], + "right_gravity": true, + "virt_text_pos": "win_col" } ], [ @@ -521,18 +378,13 @@ 0, { "ns_id": 3, - "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodePermissionBorder" - ] - ], "virt_text_hide": false, - "virt_text_pos": "win_col", "virt_text_repeat_linebreak": true, - "virt_text_win_col": -2 + "virt_text_win_col": -2, + "priority": 4096, + "virt_text": [["▌", "OpencodePermissionBorder"]], + "right_gravity": true, + "virt_text_pos": "win_col" } ], [ @@ -541,18 +393,13 @@ 0, { "ns_id": 3, - "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodePermissionBorder" - ] - ], "virt_text_hide": false, - "virt_text_pos": "win_col", "virt_text_repeat_linebreak": true, - "virt_text_win_col": -2 + "virt_text_win_col": -2, + "priority": 4096, + "virt_text": [["▌", "OpencodePermissionBorder"]], + "right_gravity": true, + "virt_text_pos": "win_col" } ], [ @@ -561,18 +408,13 @@ 0, { "ns_id": 3, - "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodePermissionBorder" - ] - ], "virt_text_hide": false, - "virt_text_pos": "win_col", "virt_text_repeat_linebreak": true, - "virt_text_win_col": -2 + "virt_text_win_col": -2, + "priority": 4096, + "virt_text": [["▌", "OpencodePermissionBorder"]], + "right_gravity": true, + "virt_text_pos": "win_col" } ], [ @@ -580,10 +422,10 @@ 30, 0, { - "line_hl_group": "OpencodeDialogOptionHover", "ns_id": 3, - "priority": 4096, - "right_gravity": true + "line_hl_group": "OpencodeDialogOptionHover", + "right_gravity": true, + "priority": 4096 } ], [ @@ -592,18 +434,13 @@ 0, { "ns_id": 3, - "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodePermissionBorder" - ] - ], "virt_text_hide": false, - "virt_text_pos": "win_col", "virt_text_repeat_linebreak": true, - "virt_text_win_col": -2 + "virt_text_win_col": -2, + "priority": 4096, + "virt_text": [["▌", "OpencodePermissionBorder"]], + "right_gravity": true, + "virt_text_pos": "win_col" } ], [ @@ -612,17 +449,12 @@ 2, { "ns_id": 3, + "virt_text_hide": false, + "virt_text_repeat_linebreak": false, "priority": 4096, + "virt_text": [["› ", "OpencodeDialogOptionHover"]], "right_gravity": true, - "virt_text": [ - [ - "› ", - "OpencodeDialogOptionHover" - ] - ], - "virt_text_hide": false, - "virt_text_pos": "overlay", - "virt_text_repeat_linebreak": false + "virt_text_pos": "overlay" } ], [ @@ -631,18 +463,13 @@ 0, { "ns_id": 3, - "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodePermissionBorder" - ] - ], "virt_text_hide": false, - "virt_text_pos": "win_col", "virt_text_repeat_linebreak": true, - "virt_text_win_col": -2 + "virt_text_win_col": -2, + "priority": 4096, + "virt_text": [["▌", "OpencodePermissionBorder"]], + "right_gravity": true, + "virt_text_pos": "win_col" } ], [ @@ -651,18 +478,13 @@ 0, { "ns_id": 3, - "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodePermissionBorder" - ] - ], "virt_text_hide": false, - "virt_text_pos": "win_col", "virt_text_repeat_linebreak": true, - "virt_text_win_col": -2 + "virt_text_win_col": -2, + "priority": 4096, + "virt_text": [["▌", "OpencodePermissionBorder"]], + "right_gravity": true, + "virt_text_pos": "win_col" } ], [ @@ -671,18 +493,13 @@ 0, { "ns_id": 3, - "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodePermissionBorder" - ] - ], "virt_text_hide": false, - "virt_text_pos": "win_col", "virt_text_repeat_linebreak": true, - "virt_text_win_col": -2 + "virt_text_win_col": -2, + "priority": 4096, + "virt_text": [["▌", "OpencodePermissionBorder"]], + "right_gravity": true, + "virt_text_pos": "win_col" } ], [ @@ -691,18 +508,13 @@ 0, { "ns_id": 3, - "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodePermissionBorder" - ] - ], "virt_text_hide": false, - "virt_text_pos": "win_col", "virt_text_repeat_linebreak": true, - "virt_text_win_col": -2 + "virt_text_win_col": -2, + "priority": 4096, + "virt_text": [["▌", "OpencodePermissionBorder"]], + "right_gravity": true, + "virt_text_pos": "win_col" } ] ], @@ -745,5 +557,6 @@ "", "" ], - "timestamp": 1770935239 -} \ No newline at end of file + "timestamp": 1774895766, + "actions": [] +} diff --git a/tests/data/permission-prompt.expected.json b/tests/data/permission-prompt.expected.json index 537b1a4d..a2f7df03 100644 --- a/tests/data/permission-prompt.expected.json +++ b/tests/data/permission-prompt.expected.json @@ -1,5 +1,4 @@ { - "actions": [], "extmarks": [ [ 1, @@ -9,31 +8,17 @@ "ns_id": 3, "priority": 10, "right_gravity": true, - "virt_text": [ - [ - " ", - "OpencodeMessageRoleAssistant" - ], - [ - " " - ], - [ - "PLAN", - "OpencodeMessageRoleAssistant" - ], - [ - " claude-sonnet-4.5", - "OpencodeHint" - ], - [ - " [msg_9eb45fbe60020xE560OGH3Vdoo]", - "OpencodeHint" - ] - ], - "virt_text_hide": false, "virt_text_pos": "win_col", + "virt_text_win_col": -3, "virt_text_repeat_linebreak": false, - "virt_text_win_col": -3 + "virt_text": [ + [" ", "OpencodeMessageRoleAssistant"], + [" "], + ["PLAN", "OpencodeMessageRoleAssistant"], + [" claude-sonnet-4.5", "OpencodeHint"], + [" [msg_9eb45fbe60020xE560OGH3Vdoo]", "OpencodeHint"] + ], + "virt_text_hide": false } ], [ @@ -43,16 +28,11 @@ { "ns_id": 3, "priority": 9, - "right_gravity": true, - "virt_text": [ - [ - " 2025-10-16 04:27:36", - "OpencodeHint" - ] - ], - "virt_text_hide": false, "virt_text_pos": "right_align", - "virt_text_repeat_linebreak": false + "right_gravity": true, + "virt_text_repeat_linebreak": false, + "virt_text": [[" 2025-10-16 04:27:36", "OpencodeHint"]], + "virt_text_hide": false } ], [ @@ -63,16 +43,11 @@ "ns_id": 3, "priority": 4096, "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeToolBorder" - ] - ], - "virt_text_hide": false, "virt_text_pos": "win_col", + "virt_text_win_col": -1, "virt_text_repeat_linebreak": true, - "virt_text_win_col": -1 + "virt_text": [["▌", "OpencodeToolBorder"]], + "virt_text_hide": false } ], [ @@ -83,16 +58,11 @@ "ns_id": 3, "priority": 4096, "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeToolBorder" - ] - ], - "virt_text_hide": false, "virt_text_pos": "win_col", + "virt_text_win_col": -1, "virt_text_repeat_linebreak": true, - "virt_text_win_col": -1 + "virt_text": [["▌", "OpencodeToolBorder"]], + "virt_text_hide": false } ], [ @@ -103,16 +73,11 @@ "ns_id": 3, "priority": 4096, "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeToolBorder" - ] - ], - "virt_text_hide": false, "virt_text_pos": "win_col", + "virt_text_win_col": -1, "virt_text_repeat_linebreak": true, - "virt_text_win_col": -1 + "virt_text": [["▌", "OpencodeToolBorder"]], + "virt_text_hide": false } ], [ @@ -123,16 +88,11 @@ "ns_id": 3, "priority": 4096, "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeToolBorder" - ] - ], - "virt_text_hide": false, "virt_text_pos": "win_col", + "virt_text_win_col": -1, "virt_text_repeat_linebreak": true, - "virt_text_win_col": -1 + "virt_text": [["▌", "OpencodeToolBorder"]], + "virt_text_hide": false } ], [ @@ -143,16 +103,11 @@ "ns_id": 3, "priority": 4096, "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeToolBorder" - ] - ], - "virt_text_hide": false, "virt_text_pos": "win_col", + "virt_text_win_col": -1, "virt_text_repeat_linebreak": true, - "virt_text_win_col": -1 + "virt_text": [["▌", "OpencodeToolBorder"]], + "virt_text_hide": false } ], [ @@ -163,16 +118,11 @@ "ns_id": 3, "priority": 4096, "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeToolBorder" - ] - ], - "virt_text_hide": false, "virt_text_pos": "win_col", + "virt_text_win_col": -1, "virt_text_repeat_linebreak": true, - "virt_text_win_col": -1 + "virt_text": [["▌", "OpencodeToolBorder"]], + "virt_text_hide": false } ], [ @@ -183,31 +133,17 @@ "ns_id": 3, "priority": 10, "right_gravity": true, - "virt_text": [ - [ - " ", - "OpencodeMessageRoleSystem" - ], - [ - " " - ], - [ - "SYSTEM", - "OpencodeMessageRoleSystem" - ], - [ - "", - "OpencodeHint" - ], - [ - " [permission-display-message]", - "OpencodeHint" - ] - ], - "virt_text_hide": false, "virt_text_pos": "win_col", + "virt_text_win_col": -3, "virt_text_repeat_linebreak": false, - "virt_text_win_col": -3 + "virt_text": [ + [" ", "OpencodeMessageRoleSystem"], + [" "], + ["SYSTEM", "OpencodeMessageRoleSystem"], + ["", "OpencodeHint"], + [" [permission-display-message]", "OpencodeHint"] + ], + "virt_text_hide": false } ], [ @@ -215,10 +151,10 @@ 15, 0, { - "line_hl_group": "OpencodePermissionTitle", + "right_gravity": true, "ns_id": 3, "priority": 4096, - "right_gravity": true + "line_hl_group": "OpencodePermissionTitle" } ], [ @@ -229,16 +165,11 @@ "ns_id": 3, "priority": 4096, "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodePermissionBorder" - ] - ], - "virt_text_hide": false, "virt_text_pos": "win_col", + "virt_text_win_col": -2, "virt_text_repeat_linebreak": true, - "virt_text_win_col": -2 + "virt_text": [["▌", "OpencodePermissionBorder"]], + "virt_text_hide": false } ], [ @@ -249,16 +180,11 @@ "ns_id": 3, "priority": 4096, "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodePermissionBorder" - ] - ], - "virt_text_hide": false, "virt_text_pos": "win_col", + "virt_text_win_col": -2, "virt_text_repeat_linebreak": true, - "virt_text_win_col": -2 + "virt_text": [["▌", "OpencodePermissionBorder"]], + "virt_text_hide": false } ], [ @@ -269,16 +195,11 @@ "ns_id": 3, "priority": 4096, "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodePermissionBorder" - ] - ], - "virt_text_hide": false, "virt_text_pos": "win_col", + "virt_text_win_col": -2, "virt_text_repeat_linebreak": true, - "virt_text_win_col": -2 + "virt_text": [["▌", "OpencodePermissionBorder"]], + "virt_text_hide": false } ], [ @@ -289,16 +210,11 @@ "ns_id": 3, "priority": 4096, "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodePermissionBorder" - ] - ], - "virt_text_hide": false, "virt_text_pos": "win_col", + "virt_text_win_col": -2, "virt_text_repeat_linebreak": true, - "virt_text_win_col": -2 + "virt_text": [["▌", "OpencodePermissionBorder"]], + "virt_text_hide": false } ], [ @@ -309,16 +225,11 @@ "ns_id": 3, "priority": 4096, "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodePermissionBorder" - ] - ], - "virt_text_hide": false, "virt_text_pos": "win_col", + "virt_text_win_col": -2, "virt_text_repeat_linebreak": true, - "virt_text_win_col": -2 + "virt_text": [["▌", "OpencodePermissionBorder"]], + "virt_text_hide": false } ], [ @@ -329,16 +240,11 @@ "ns_id": 3, "priority": 4096, "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodePermissionBorder" - ] - ], - "virt_text_hide": false, "virt_text_pos": "win_col", + "virt_text_win_col": -2, "virt_text_repeat_linebreak": true, - "virt_text_win_col": -2 + "virt_text": [["▌", "OpencodePermissionBorder"]], + "virt_text_hide": false } ], [ @@ -349,16 +255,11 @@ "ns_id": 3, "priority": 4096, "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodePermissionBorder" - ] - ], - "virt_text_hide": false, "virt_text_pos": "win_col", + "virt_text_win_col": -2, "virt_text_repeat_linebreak": true, - "virt_text_win_col": -2 + "virt_text": [["▌", "OpencodePermissionBorder"]], + "virt_text_hide": false } ], [ @@ -369,16 +270,11 @@ "ns_id": 3, "priority": 4096, "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodePermissionBorder" - ] - ], - "virt_text_hide": false, "virt_text_pos": "win_col", + "virt_text_win_col": -2, "virt_text_repeat_linebreak": true, - "virt_text_win_col": -2 + "virt_text": [["▌", "OpencodePermissionBorder"]], + "virt_text_hide": false } ], [ @@ -386,10 +282,10 @@ 23, 0, { - "line_hl_group": "OpencodeDialogOptionHover", + "right_gravity": true, "ns_id": 3, "priority": 4096, - "right_gravity": true + "line_hl_group": "OpencodeDialogOptionHover" } ], [ @@ -400,16 +296,11 @@ "ns_id": 3, "priority": 4096, "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodePermissionBorder" - ] - ], - "virt_text_hide": false, "virt_text_pos": "win_col", + "virt_text_win_col": -2, "virt_text_repeat_linebreak": true, - "virt_text_win_col": -2 + "virt_text": [["▌", "OpencodePermissionBorder"]], + "virt_text_hide": false } ], [ @@ -419,16 +310,11 @@ { "ns_id": 3, "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "› ", - "OpencodeDialogOptionHover" - ] - ], - "virt_text_hide": false, "virt_text_pos": "overlay", - "virt_text_repeat_linebreak": false + "right_gravity": true, + "virt_text_repeat_linebreak": false, + "virt_text": [["› ", "OpencodeDialogOptionHover"]], + "virt_text_hide": false } ], [ @@ -439,16 +325,11 @@ "ns_id": 3, "priority": 4096, "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodePermissionBorder" - ] - ], - "virt_text_hide": false, "virt_text_pos": "win_col", + "virt_text_win_col": -2, "virt_text_repeat_linebreak": true, - "virt_text_win_col": -2 + "virt_text": [["▌", "OpencodePermissionBorder"]], + "virt_text_hide": false } ], [ @@ -459,16 +340,11 @@ "ns_id": 3, "priority": 4096, "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodePermissionBorder" - ] - ], - "virt_text_hide": false, "virt_text_pos": "win_col", + "virt_text_win_col": -2, "virt_text_repeat_linebreak": true, - "virt_text_win_col": -2 + "virt_text": [["▌", "OpencodePermissionBorder"]], + "virt_text_hide": false } ], [ @@ -479,16 +355,11 @@ "ns_id": 3, "priority": 4096, "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodePermissionBorder" - ] - ], - "virt_text_hide": false, "virt_text_pos": "win_col", + "virt_text_win_col": -2, "virt_text_repeat_linebreak": true, - "virt_text_win_col": -2 + "virt_text": [["▌", "OpencodePermissionBorder"]], + "virt_text_hide": false } ], [ @@ -499,19 +370,15 @@ "ns_id": 3, "priority": 4096, "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodePermissionBorder" - ] - ], - "virt_text_hide": false, "virt_text_pos": "win_col", + "virt_text_win_col": -2, "virt_text_repeat_linebreak": true, - "virt_text_win_col": -2 + "virt_text": [["▌", "OpencodePermissionBorder"]], + "virt_text_hide": false } ] ], + "actions": [], "lines": [ "----", "", @@ -544,5 +411,5 @@ "", "" ], - "timestamp": 1770935239 -} \ No newline at end of file + "timestamp": 1774895766 +} diff --git a/tests/data/question-ask-other.expected.json b/tests/data/question-ask-other.expected.json index 11c73e98..475e1c70 100644 --- a/tests/data/question-ask-other.expected.json +++ b/tests/data/question-ask-other.expected.json @@ -1,4 +1,35 @@ { + "lines": [ + "----", + "", + "", + "[`docker-compose.yaml`](docker-compose.yaml)", + "", + "starting today, minIO is no longer maintained. can we migrate to something else, e.g. rustfs?", + "", + "----", + "", + "", + "I need to clarify a couple of things before we proceed with the migration:", + "", + "** question** ", + "", + "----", + "", + "", + " Question", + "", + "Did you mean RustyFS or perhaps a different storage solution? RustyFS appears to be a FUSE filesystem implementation. For S3-compatible object storage (MinIO replacement), common alternatives are SeaweedFS, LocalStack S3, or simply using local filesystem storage. Which would you prefer?", + "", + " 1. Local filesystem storage - Store files directly on disk, simpler setup, no S3 protocol needed ", + " 2. SeaweedFS - S3-compatible distributed storage, good MinIO alternative", + " 3. LocalStack S3 - AWS S3 emulator for local development", + " 4. Other S3-compatible solution - Different S3-compatible storage backend", + "", + "Navigate: `j`/`k` or `↑`/`↓` Select: `` or `1-4` Dismiss: ``", + "", + "" + ], "actions": [], "extmarks": [ [ @@ -6,34 +37,20 @@ 1, 0, { - "ns_id": 3, "priority": 10, - "right_gravity": true, + "virt_text_repeat_linebreak": false, + "ns_id": 3, "virt_text": [ - [ - "▌󰭻 ", - "OpencodeMessageRoleUser" - ], - [ - " " - ], - [ - "USER", - "OpencodeMessageRoleUser" - ], - [ - "", - "OpencodeHint" - ], - [ - " [msg_c595d93bb001YP4s8b1oxCvGev]", - "OpencodeHint" - ] + ["▌󰭻 ", "OpencodeMessageRoleUser"], + [" "], + ["USER", "OpencodeMessageRoleUser"], + ["", "OpencodeHint"], + [" [msg_c595d93bb001YP4s8b1oxCvGev]", "OpencodeHint"] ], - "virt_text_hide": false, "virt_text_pos": "win_col", - "virt_text_repeat_linebreak": false, - "virt_text_win_col": -3 + "virt_text_hide": false, + "virt_text_win_col": -3, + "right_gravity": true } ], [ @@ -41,18 +58,13 @@ 1, 0, { - "ns_id": 3, - "priority": 9, + "virt_text_repeat_linebreak": false, "right_gravity": true, - "virt_text": [ - [ - " 2026-02-13 23:37:10", - "OpencodeHint" - ] - ], + "priority": 9, + "virt_text": [[" 2026-02-13 23:37:10", "OpencodeHint"]], "virt_text_hide": false, "virt_text_pos": "right_align", - "virt_text_repeat_linebreak": false + "ns_id": 3 } ], [ @@ -60,19 +72,14 @@ 2, 0, { - "ns_id": 3, "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeMessageRoleUser" - ] - ], - "virt_text_hide": false, - "virt_text_pos": "win_col", "virt_text_repeat_linebreak": true, - "virt_text_win_col": -3 + "ns_id": 3, + "virt_text": [["▌", "OpencodeMessageRoleUser"]], + "virt_text_pos": "win_col", + "virt_text_hide": false, + "virt_text_win_col": -3, + "right_gravity": true } ], [ @@ -80,19 +87,14 @@ 3, 0, { - "ns_id": 3, "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeMessageRoleUser" - ] - ], - "virt_text_hide": false, - "virt_text_pos": "win_col", "virt_text_repeat_linebreak": true, - "virt_text_win_col": -3 + "ns_id": 3, + "virt_text": [["▌", "OpencodeMessageRoleUser"]], + "virt_text_pos": "win_col", + "virt_text_hide": false, + "virt_text_win_col": -3, + "right_gravity": true } ], [ @@ -100,19 +102,14 @@ 4, 0, { - "ns_id": 3, "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeMessageRoleUser" - ] - ], - "virt_text_hide": false, - "virt_text_pos": "win_col", "virt_text_repeat_linebreak": true, - "virt_text_win_col": -3 + "ns_id": 3, + "virt_text": [["▌", "OpencodeMessageRoleUser"]], + "virt_text_pos": "win_col", + "virt_text_hide": false, + "virt_text_win_col": -3, + "right_gravity": true } ], [ @@ -120,19 +117,14 @@ 5, 0, { - "ns_id": 3, "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeMessageRoleUser" - ] - ], - "virt_text_hide": false, - "virt_text_pos": "win_col", "virt_text_repeat_linebreak": true, - "virt_text_win_col": -3 + "ns_id": 3, + "virt_text": [["▌", "OpencodeMessageRoleUser"]], + "virt_text_pos": "win_col", + "virt_text_hide": false, + "virt_text_win_col": -3, + "right_gravity": true } ], [ @@ -140,19 +132,14 @@ 6, 0, { - "ns_id": 3, "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeMessageRoleUser" - ] - ], - "virt_text_hide": false, - "virt_text_pos": "win_col", "virt_text_repeat_linebreak": true, - "virt_text_win_col": -3 + "ns_id": 3, + "virt_text": [["▌", "OpencodeMessageRoleUser"]], + "virt_text_pos": "win_col", + "virt_text_hide": false, + "virt_text_win_col": -3, + "right_gravity": true } ], [ @@ -160,34 +147,20 @@ 8, 0, { - "ns_id": 3, "priority": 10, - "right_gravity": true, + "virt_text_repeat_linebreak": false, + "ns_id": 3, "virt_text": [ - [ - " ", - "OpencodeMessageRoleAssistant" - ], - [ - " " - ], - [ - "BUILD", - "OpencodeMessageRoleAssistant" - ], - [ - " claude-sonnet-4-5", - "OpencodeHint" - ], - [ - " [msg_c595d93ce001BDc0RIyyN71asg]", - "OpencodeHint" - ] + [" ", "OpencodeMessageRoleAssistant"], + [" "], + ["BUILD", "OpencodeMessageRoleAssistant"], + [" claude-sonnet-4-5", "OpencodeHint"], + [" [msg_c595d93ce001BDc0RIyyN71asg]", "OpencodeHint"] ], - "virt_text_hide": false, "virt_text_pos": "win_col", - "virt_text_repeat_linebreak": false, - "virt_text_win_col": -3 + "virt_text_hide": false, + "virt_text_win_col": -3, + "right_gravity": true } ], [ @@ -195,18 +168,13 @@ 8, 0, { - "ns_id": 3, - "priority": 9, + "virt_text_repeat_linebreak": false, "right_gravity": true, - "virt_text": [ - [ - " 2026-02-13 23:37:10", - "OpencodeHint" - ] - ], + "priority": 9, + "virt_text": [[" 2026-02-13 23:37:10", "OpencodeHint"]], "virt_text_hide": false, "virt_text_pos": "right_align", - "virt_text_repeat_linebreak": false + "ns_id": 3 } ], [ @@ -214,34 +182,20 @@ 15, 0, { - "ns_id": 3, "priority": 10, - "right_gravity": true, + "virt_text_repeat_linebreak": false, + "ns_id": 3, "virt_text": [ - [ - " ", - "OpencodeMessageRoleSystem" - ], - [ - " " - ], - [ - "SYSTEM", - "OpencodeMessageRoleSystem" - ], - [ - "", - "OpencodeHint" - ], - [ - " [question-display-message]", - "OpencodeHint" - ] + [" ", "OpencodeMessageRoleSystem"], + [" "], + ["SYSTEM", "OpencodeMessageRoleSystem"], + ["", "OpencodeHint"], + [" [question-display-message]", "OpencodeHint"] ], - "virt_text_hide": false, "virt_text_pos": "win_col", - "virt_text_repeat_linebreak": false, - "virt_text_win_col": -3 + "virt_text_hide": false, + "virt_text_win_col": -3, + "right_gravity": true } ], [ @@ -249,10 +203,10 @@ 17, 0, { - "line_hl_group": "OpencodeQuestionTitle", - "ns_id": 3, "priority": 4096, - "right_gravity": true + "right_gravity": true, + "ns_id": 3, + "line_hl_group": "OpencodeQuestionTitle" } ], [ @@ -260,19 +214,14 @@ 17, 0, { - "ns_id": 3, "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeQuestionBorder" - ] - ], - "virt_text_hide": false, - "virt_text_pos": "win_col", "virt_text_repeat_linebreak": true, - "virt_text_win_col": -2 + "ns_id": 3, + "virt_text": [["▌", "OpencodeQuestionBorder"]], + "virt_text_pos": "win_col", + "virt_text_hide": false, + "virt_text_win_col": -2, + "right_gravity": true } ], [ @@ -280,19 +229,14 @@ 18, 0, { - "ns_id": 3, "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeQuestionBorder" - ] - ], - "virt_text_hide": false, - "virt_text_pos": "win_col", "virt_text_repeat_linebreak": true, - "virt_text_win_col": -2 + "ns_id": 3, + "virt_text": [["▌", "OpencodeQuestionBorder"]], + "virt_text_pos": "win_col", + "virt_text_hide": false, + "virt_text_win_col": -2, + "right_gravity": true } ], [ @@ -300,19 +244,14 @@ 19, 0, { - "ns_id": 3, "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeQuestionBorder" - ] - ], - "virt_text_hide": false, - "virt_text_pos": "win_col", "virt_text_repeat_linebreak": true, - "virt_text_win_col": -2 + "ns_id": 3, + "virt_text": [["▌", "OpencodeQuestionBorder"]], + "virt_text_pos": "win_col", + "virt_text_hide": false, + "virt_text_win_col": -2, + "right_gravity": true } ], [ @@ -320,19 +259,14 @@ 20, 0, { - "ns_id": 3, "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeQuestionBorder" - ] - ], - "virt_text_hide": false, - "virt_text_pos": "win_col", "virt_text_repeat_linebreak": true, - "virt_text_win_col": -2 + "ns_id": 3, + "virt_text": [["▌", "OpencodeQuestionBorder"]], + "virt_text_pos": "win_col", + "virt_text_hide": false, + "virt_text_win_col": -2, + "right_gravity": true } ], [ @@ -340,10 +274,10 @@ 21, 0, { - "line_hl_group": "OpencodeDialogOptionHover", - "ns_id": 3, "priority": 4096, - "right_gravity": true + "right_gravity": true, + "ns_id": 3, + "line_hl_group": "OpencodeDialogOptionHover" } ], [ @@ -351,19 +285,14 @@ 21, 0, { - "ns_id": 3, "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeQuestionBorder" - ] - ], - "virt_text_hide": false, - "virt_text_pos": "win_col", "virt_text_repeat_linebreak": true, - "virt_text_win_col": -2 + "ns_id": 3, + "virt_text": [["▌", "OpencodeQuestionBorder"]], + "virt_text_pos": "win_col", + "virt_text_hide": false, + "virt_text_win_col": -2, + "right_gravity": true } ], [ @@ -371,18 +300,13 @@ 21, 2, { - "ns_id": 3, - "priority": 4096, + "virt_text_repeat_linebreak": false, "right_gravity": true, - "virt_text": [ - [ - "› ", - "OpencodeDialogOptionHover" - ] - ], + "priority": 4096, + "virt_text": [["› ", "OpencodeDialogOptionHover"]], "virt_text_hide": false, "virt_text_pos": "overlay", - "virt_text_repeat_linebreak": false + "ns_id": 3 } ], [ @@ -390,19 +314,14 @@ 22, 0, { - "ns_id": 3, "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeQuestionBorder" - ] - ], - "virt_text_hide": false, - "virt_text_pos": "win_col", "virt_text_repeat_linebreak": true, - "virt_text_win_col": -2 + "ns_id": 3, + "virt_text": [["▌", "OpencodeQuestionBorder"]], + "virt_text_pos": "win_col", + "virt_text_hide": false, + "virt_text_win_col": -2, + "right_gravity": true } ], [ @@ -410,19 +329,14 @@ 23, 0, { - "ns_id": 3, "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeQuestionBorder" - ] - ], - "virt_text_hide": false, - "virt_text_pos": "win_col", "virt_text_repeat_linebreak": true, - "virt_text_win_col": -2 + "ns_id": 3, + "virt_text": [["▌", "OpencodeQuestionBorder"]], + "virt_text_pos": "win_col", + "virt_text_hide": false, + "virt_text_win_col": -2, + "right_gravity": true } ], [ @@ -430,19 +344,14 @@ 24, 0, { - "ns_id": 3, "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeQuestionBorder" - ] - ], - "virt_text_hide": false, - "virt_text_pos": "win_col", "virt_text_repeat_linebreak": true, - "virt_text_win_col": -2 + "ns_id": 3, + "virt_text": [["▌", "OpencodeQuestionBorder"]], + "virt_text_pos": "win_col", + "virt_text_hide": false, + "virt_text_win_col": -2, + "right_gravity": true } ], [ @@ -450,19 +359,14 @@ 25, 0, { - "ns_id": 3, "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeQuestionBorder" - ] - ], - "virt_text_hide": false, - "virt_text_pos": "win_col", "virt_text_repeat_linebreak": true, - "virt_text_win_col": -2 + "ns_id": 3, + "virt_text": [["▌", "OpencodeQuestionBorder"]], + "virt_text_pos": "win_col", + "virt_text_hide": false, + "virt_text_win_col": -2, + "right_gravity": true } ], [ @@ -470,52 +374,16 @@ 26, 0, { - "ns_id": 3, "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeQuestionBorder" - ] - ], - "virt_text_hide": false, - "virt_text_pos": "win_col", "virt_text_repeat_linebreak": true, - "virt_text_win_col": -2 + "ns_id": 3, + "virt_text": [["▌", "OpencodeQuestionBorder"]], + "virt_text_pos": "win_col", + "virt_text_hide": false, + "virt_text_win_col": -2, + "right_gravity": true } ] ], - "lines": [ - "----", - "", - "", - "[`docker-compose.yaml`](docker-compose.yaml)", - "", - "starting today, minIO is no longer maintained. can we migrate to something else, e.g. rustfs?", - "", - "----", - "", - "", - "I need to clarify a couple of things before we proceed with the migration:", - "", - "** question** ", - "", - "----", - "", - "", - " Question", - "", - "Did you mean RustyFS or perhaps a different storage solution? RustyFS appears to be a FUSE filesystem implementation. For S3-compatible object storage (MinIO replacement), common alternatives are SeaweedFS, LocalStack S3, or simply using local filesystem storage. Which would you prefer?", - "", - " 1. Local filesystem storage - Store files directly on disk, simpler setup, no S3 protocol needed ", - " 2. SeaweedFS - S3-compatible distributed storage, good MinIO alternative", - " 3. LocalStack S3 - AWS S3 emulator for local development", - " 4. Other S3-compatible solution - Different S3-compatible storage backend", - "", - "Navigate: `j`/`k` or `↑`/`↓` Select: `` or `1-4` Dismiss: ``", - "", - "" - ], - "timestamp": 1771097948 -} \ No newline at end of file + "timestamp": 1774895736 +} diff --git a/tests/data/question-ask.expected.json b/tests/data/question-ask.expected.json index bce8f336..bade2f1f 100644 --- a/tests/data/question-ask.expected.json +++ b/tests/data/question-ask.expected.json @@ -1,5 +1,34 @@ { "actions": [], + "lines": [ + "----", + "", + "", + "[`lua/opencode/ui/renderer.lua`](lua/opencode/ui/renderer.lua)", + "", + "can you use the question tool and ask me a question with an other choice", + "", + "----", + "", + "", + "** question** ", + "", + "----", + "", + "", + " Question", + "", + "What is your favorite color?", + "", + " 1. Red - The color red ", + " 2. Blue - The color blue", + " 3. Other - Any color not listed", + "", + "Navigate: `j`/`k` or `↑`/`↓` Select: `` or `1-3` Dismiss: ``", + "", + "" + ], + "timestamp": 1774895736, "extmarks": [ [ 1, @@ -8,30 +37,16 @@ { "ns_id": 3, "priority": 10, + "virt_text_pos": "win_col", + "virt_text_hide": false, "right_gravity": true, "virt_text": [ - [ - "▌󰭻 ", - "OpencodeMessageRoleUser" - ], - [ - " " - ], - [ - "USER", - "OpencodeMessageRoleUser" - ], - [ - "", - "OpencodeHint" - ], - [ - " [msg_bfaa078a2001RUYhlKZhSlyWeF]", - "OpencodeHint" - ] + ["▌󰭻 ", "OpencodeMessageRoleUser"], + [" "], + ["USER", "OpencodeMessageRoleUser"], + ["", "OpencodeHint"], + [" [msg_bfaa078a2001RUYhlKZhSlyWeF]", "OpencodeHint"] ], - "virt_text_hide": false, - "virt_text_pos": "win_col", "virt_text_repeat_linebreak": false, "virt_text_win_col": -3 } @@ -42,17 +57,12 @@ 0, { "ns_id": 3, - "priority": 9, - "right_gravity": true, - "virt_text": [ - [ - " 2026-01-26 14:06:19", - "OpencodeHint" - ] - ], - "virt_text_hide": false, "virt_text_pos": "right_align", - "virt_text_repeat_linebreak": false + "virt_text_hide": false, + "right_gravity": true, + "priority": 9, + "virt_text_repeat_linebreak": false, + "virt_text": [[" 2026-01-26 14:06:19", "OpencodeHint"]] } ], [ @@ -62,15 +72,10 @@ { "ns_id": 3, "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeMessageRoleUser" - ] - ], - "virt_text_hide": false, "virt_text_pos": "win_col", + "virt_text_hide": false, + "right_gravity": true, + "virt_text": [["▌", "OpencodeMessageRoleUser"]], "virt_text_repeat_linebreak": true, "virt_text_win_col": -3 } @@ -82,15 +87,10 @@ { "ns_id": 3, "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeMessageRoleUser" - ] - ], - "virt_text_hide": false, "virt_text_pos": "win_col", + "virt_text_hide": false, + "right_gravity": true, + "virt_text": [["▌", "OpencodeMessageRoleUser"]], "virt_text_repeat_linebreak": true, "virt_text_win_col": -3 } @@ -102,15 +102,10 @@ { "ns_id": 3, "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeMessageRoleUser" - ] - ], - "virt_text_hide": false, "virt_text_pos": "win_col", + "virt_text_hide": false, + "right_gravity": true, + "virt_text": [["▌", "OpencodeMessageRoleUser"]], "virt_text_repeat_linebreak": true, "virt_text_win_col": -3 } @@ -122,15 +117,10 @@ { "ns_id": 3, "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeMessageRoleUser" - ] - ], - "virt_text_hide": false, "virt_text_pos": "win_col", + "virt_text_hide": false, + "right_gravity": true, + "virt_text": [["▌", "OpencodeMessageRoleUser"]], "virt_text_repeat_linebreak": true, "virt_text_win_col": -3 } @@ -142,15 +132,10 @@ { "ns_id": 3, "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeMessageRoleUser" - ] - ], - "virt_text_hide": false, "virt_text_pos": "win_col", + "virt_text_hide": false, + "right_gravity": true, + "virt_text": [["▌", "OpencodeMessageRoleUser"]], "virt_text_repeat_linebreak": true, "virt_text_win_col": -3 } @@ -162,30 +147,16 @@ { "ns_id": 3, "priority": 10, + "virt_text_pos": "win_col", + "virt_text_hide": false, "right_gravity": true, "virt_text": [ - [ - " ", - "OpencodeMessageRoleAssistant" - ], - [ - " " - ], - [ - "BUILD", - "OpencodeMessageRoleAssistant" - ], - [ - " gpt-4.1", - "OpencodeHint" - ], - [ - " [msg_bfaa078e7001qc33BqBlqzHv8X]", - "OpencodeHint" - ] + [" ", "OpencodeMessageRoleAssistant"], + [" "], + ["BUILD", "OpencodeMessageRoleAssistant"], + [" gpt-4.1", "OpencodeHint"], + [" [msg_bfaa078e7001qc33BqBlqzHv8X]", "OpencodeHint"] ], - "virt_text_hide": false, - "virt_text_pos": "win_col", "virt_text_repeat_linebreak": false, "virt_text_win_col": -3 } @@ -196,17 +167,12 @@ 0, { "ns_id": 3, - "priority": 9, - "right_gravity": true, - "virt_text": [ - [ - " 2026-01-26 14:06:19", - "OpencodeHint" - ] - ], - "virt_text_hide": false, "virt_text_pos": "right_align", - "virt_text_repeat_linebreak": false + "virt_text_hide": false, + "right_gravity": true, + "priority": 9, + "virt_text_repeat_linebreak": false, + "virt_text": [[" 2026-01-26 14:06:19", "OpencodeHint"]] } ], [ @@ -216,30 +182,16 @@ { "ns_id": 3, "priority": 10, + "virt_text_pos": "win_col", + "virt_text_hide": false, "right_gravity": true, "virt_text": [ - [ - " ", - "OpencodeMessageRoleSystem" - ], - [ - " " - ], - [ - "SYSTEM", - "OpencodeMessageRoleSystem" - ], - [ - "", - "OpencodeHint" - ], - [ - " [question-display-message]", - "OpencodeHint" - ] + [" ", "OpencodeMessageRoleSystem"], + [" "], + ["SYSTEM", "OpencodeMessageRoleSystem"], + ["", "OpencodeHint"], + [" [question-display-message]", "OpencodeHint"] ], - "virt_text_hide": false, - "virt_text_pos": "win_col", "virt_text_repeat_linebreak": false, "virt_text_win_col": -3 } @@ -249,9 +201,9 @@ 15, 0, { - "line_hl_group": "OpencodeQuestionTitle", "ns_id": 3, "priority": 4096, + "line_hl_group": "OpencodeQuestionTitle", "right_gravity": true } ], @@ -262,15 +214,10 @@ { "ns_id": 3, "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeQuestionBorder" - ] - ], - "virt_text_hide": false, "virt_text_pos": "win_col", + "virt_text_hide": false, + "right_gravity": true, + "virt_text": [["▌", "OpencodeQuestionBorder"]], "virt_text_repeat_linebreak": true, "virt_text_win_col": -2 } @@ -282,15 +229,10 @@ { "ns_id": 3, "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeQuestionBorder" - ] - ], - "virt_text_hide": false, "virt_text_pos": "win_col", + "virt_text_hide": false, + "right_gravity": true, + "virt_text": [["▌", "OpencodeQuestionBorder"]], "virt_text_repeat_linebreak": true, "virt_text_win_col": -2 } @@ -302,15 +244,10 @@ { "ns_id": 3, "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeQuestionBorder" - ] - ], - "virt_text_hide": false, "virt_text_pos": "win_col", + "virt_text_hide": false, + "right_gravity": true, + "virt_text": [["▌", "OpencodeQuestionBorder"]], "virt_text_repeat_linebreak": true, "virt_text_win_col": -2 } @@ -322,15 +259,10 @@ { "ns_id": 3, "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeQuestionBorder" - ] - ], - "virt_text_hide": false, "virt_text_pos": "win_col", + "virt_text_hide": false, + "right_gravity": true, + "virt_text": [["▌", "OpencodeQuestionBorder"]], "virt_text_repeat_linebreak": true, "virt_text_win_col": -2 } @@ -340,9 +272,9 @@ 19, 0, { - "line_hl_group": "OpencodeDialogOptionHover", "ns_id": 3, "priority": 4096, + "line_hl_group": "OpencodeDialogOptionHover", "right_gravity": true } ], @@ -353,15 +285,10 @@ { "ns_id": 3, "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeQuestionBorder" - ] - ], - "virt_text_hide": false, "virt_text_pos": "win_col", + "virt_text_hide": false, + "right_gravity": true, + "virt_text": [["▌", "OpencodeQuestionBorder"]], "virt_text_repeat_linebreak": true, "virt_text_win_col": -2 } @@ -372,17 +299,12 @@ 2, { "ns_id": 3, - "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "› ", - "OpencodeDialogOptionHover" - ] - ], - "virt_text_hide": false, "virt_text_pos": "overlay", - "virt_text_repeat_linebreak": false + "virt_text_hide": false, + "right_gravity": true, + "priority": 4096, + "virt_text_repeat_linebreak": false, + "virt_text": [["› ", "OpencodeDialogOptionHover"]] } ], [ @@ -392,15 +314,10 @@ { "ns_id": 3, "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeQuestionBorder" - ] - ], - "virt_text_hide": false, "virt_text_pos": "win_col", + "virt_text_hide": false, + "right_gravity": true, + "virt_text": [["▌", "OpencodeQuestionBorder"]], "virt_text_repeat_linebreak": true, "virt_text_win_col": -2 } @@ -412,15 +329,10 @@ { "ns_id": 3, "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeQuestionBorder" - ] - ], - "virt_text_hide": false, "virt_text_pos": "win_col", + "virt_text_hide": false, + "right_gravity": true, + "virt_text": [["▌", "OpencodeQuestionBorder"]], "virt_text_repeat_linebreak": true, "virt_text_win_col": -2 } @@ -432,15 +344,10 @@ { "ns_id": 3, "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeQuestionBorder" - ] - ], - "virt_text_hide": false, "virt_text_pos": "win_col", + "virt_text_hide": false, + "right_gravity": true, + "virt_text": [["▌", "OpencodeQuestionBorder"]], "virt_text_repeat_linebreak": true, "virt_text_win_col": -2 } @@ -452,47 +359,13 @@ { "ns_id": 3, "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeQuestionBorder" - ] - ], - "virt_text_hide": false, "virt_text_pos": "win_col", + "virt_text_hide": false, + "right_gravity": true, + "virt_text": [["▌", "OpencodeQuestionBorder"]], "virt_text_repeat_linebreak": true, "virt_text_win_col": -2 } ] - ], - "lines": [ - "----", - "", - "", - "[`lua/opencode/ui/renderer.lua`](lua/opencode/ui/renderer.lua)", - "", - "can you use the question tool and ask me a question with an other choice", - "", - "----", - "", - "", - "** question** ", - "", - "----", - "", - "", - " Question", - "", - "What is your favorite color?", - "", - " 1. Red - The color red ", - " 2. Blue - The color blue", - " 3. Other - Any color not listed", - "", - "Navigate: `j`/`k` or `↑`/`↓` Select: `` or `1-3` Dismiss: ``", - "", - "" - ], - "timestamp": 1770935240 -} \ No newline at end of file + ] +} diff --git a/tests/data/redo-all.expected.json b/tests/data/redo-all.expected.json index 5c7f07fe..004f37bf 100644 --- a/tests/data/redo-all.expected.json +++ b/tests/data/redo-all.expected.json @@ -1 +1,1353 @@ -{"extmarks":[[1,1,0,{"virt_text_pos":"win_col","virt_text":[["▌󰭻 ","OpencodeMessageRoleUser"],[" "],["USER","OpencodeMessageRoleUser"],["","OpencodeHint"],[" [msg_a0234c0b7001y2o9S1jMaNVZar]","OpencodeHint"]],"ns_id":3,"virt_text_win_col":-3,"priority":10,"virt_text_hide":false,"virt_text_repeat_linebreak":false,"right_gravity":true}],[2,1,0,{"virt_text":[[" 2025-10-20 15:20:02","OpencodeHint"]],"ns_id":3,"virt_text_pos":"right_align","priority":9,"virt_text_hide":false,"virt_text_repeat_linebreak":false,"right_gravity":true}],[3,2,0,{"virt_text_pos":"win_col","virt_text":[["▌","OpencodeMessageRoleUser"]],"ns_id":3,"virt_text_win_col":-3,"priority":4096,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true}],[4,3,0,{"virt_text_pos":"win_col","virt_text":[["▌","OpencodeMessageRoleUser"]],"ns_id":3,"virt_text_win_col":-3,"priority":4096,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true}],[5,4,0,{"virt_text_pos":"win_col","virt_text":[["▌","OpencodeMessageRoleUser"]],"ns_id":3,"virt_text_win_col":-3,"priority":4096,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true}],[6,5,0,{"virt_text_pos":"win_col","virt_text":[["▌","OpencodeMessageRoleUser"]],"ns_id":3,"virt_text_win_col":-3,"priority":4096,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true}],[7,8,0,{"virt_text_pos":"win_col","virt_text":[[" ","OpencodeMessageRoleAssistant"],[" "],["BUILD","OpencodeMessageRoleAssistant"],[" gpt-5-mini","OpencodeHint"],[" [msg_a0234c7960011LTxTvD94hfWCi]","OpencodeHint"]],"ns_id":3,"virt_text_win_col":-3,"priority":10,"virt_text_hide":false,"virt_text_repeat_linebreak":false,"right_gravity":true}],[8,8,0,{"virt_text":[[" 2025-10-20 15:20:04","OpencodeHint"]],"ns_id":3,"virt_text_pos":"right_align","priority":9,"virt_text_hide":false,"virt_text_repeat_linebreak":false,"right_gravity":true}],[9,12,0,{"virt_text_pos":"win_col","virt_text":[["▌","OpencodeToolBorder"]],"ns_id":3,"virt_text_win_col":-1,"priority":4096,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true}],[10,13,0,{"virt_text_pos":"win_col","virt_text":[["▌","OpencodeToolBorder"]],"ns_id":3,"virt_text_win_col":-1,"priority":4096,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true}],[11,14,0,{"virt_text_pos":"win_col","virt_text":[["▌","OpencodeToolBorder"]],"ns_id":3,"virt_text_win_col":-1,"priority":4096,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true}],[12,15,0,{"hl_group":"OpencodeDiffDelete","virt_text":[["1","OpencodeDiffDeleteGutter"],["-","OpencodeDiffDeleteGutter"],[" ","OpencodeDiffDeleteGutter"]],"end_col":0,"priority":5000,"right_gravity":true,"virt_text_pos":"overlay","ns_id":3,"end_row":16,"end_right_gravity":false,"virt_text_hide":false,"hl_eol":true,"virt_text_repeat_linebreak":false}],[13,15,0,{"virt_text_pos":"win_col","virt_text":[["▌","OpencodeToolBorder"]],"ns_id":3,"virt_text_win_col":-1,"priority":4096,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true}],[14,16,0,{"hl_group":"OpencodeDiffAdd","virt_text":[["1","OpencodeDiffAddGutter"],["+","OpencodeDiffAddGutter"],[" ","OpencodeDiffAddGutter"]],"end_col":0,"priority":5000,"right_gravity":true,"virt_text_pos":"overlay","ns_id":3,"end_row":17,"end_right_gravity":false,"virt_text_hide":false,"hl_eol":true,"virt_text_repeat_linebreak":false}],[15,16,0,{"virt_text_pos":"win_col","virt_text":[["▌","OpencodeToolBorder"]],"ns_id":3,"virt_text_win_col":-1,"priority":4096,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true}],[16,17,0,{"virt_text":[["2","OpencodeDiffGutter"],[" ","OpencodeDiffGutter"],[" ","OpencodeDiffGutter"]],"end_col":0,"priority":5000,"right_gravity":true,"ns_id":3,"end_row":18,"end_right_gravity":false,"virt_text_hide":false,"virt_text_pos":"overlay","virt_text_repeat_linebreak":false}],[17,17,0,{"virt_text_pos":"win_col","virt_text":[["▌","OpencodeToolBorder"]],"ns_id":3,"virt_text_win_col":-1,"priority":4096,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true}],[18,18,0,{"virt_text":[["3","OpencodeDiffGutter"],[" ","OpencodeDiffGutter"],[" ","OpencodeDiffGutter"]],"end_col":0,"priority":5000,"right_gravity":true,"ns_id":3,"end_row":19,"end_right_gravity":false,"virt_text_hide":false,"virt_text_pos":"overlay","virt_text_repeat_linebreak":false}],[19,18,0,{"virt_text_pos":"win_col","virt_text":[["▌","OpencodeToolBorder"]],"ns_id":3,"virt_text_win_col":-1,"priority":4096,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true}],[20,19,0,{"virt_text_pos":"win_col","virt_text":[["▌","OpencodeToolBorder"]],"ns_id":3,"virt_text_win_col":-1,"priority":4096,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true}],[21,20,0,{"virt_text_pos":"win_col","virt_text":[["▌","OpencodeToolBorder"]],"ns_id":3,"virt_text_win_col":-1,"priority":4096,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true}],[22,25,0,{"virt_text_pos":"win_col","virt_text":[[" ","OpencodeMessageRoleAssistant"],[" "],["BUILD","OpencodeMessageRoleAssistant"],[" gpt-5-mini","OpencodeHint"],[" [msg_a0234d8fb001SXyngLjuKSuxOY]","OpencodeHint"]],"ns_id":3,"virt_text_win_col":-3,"priority":10,"virt_text_hide":false,"virt_text_repeat_linebreak":false,"right_gravity":true}],[23,25,0,{"virt_text":[[" 2025-10-20 15:20:09","OpencodeHint"]],"ns_id":3,"virt_text_pos":"right_align","priority":9,"virt_text_hide":false,"virt_text_repeat_linebreak":false,"right_gravity":true}],[24,30,0,{"virt_text_pos":"win_col","virt_text":[["▌󰭻 ","OpencodeMessageRoleUser"],[" "],["USER","OpencodeMessageRoleUser"],["","OpencodeHint"],[" [msg_a0234e308001SKl5bQUibp5gtI]","OpencodeHint"]],"ns_id":3,"virt_text_win_col":-3,"priority":10,"virt_text_hide":false,"virt_text_repeat_linebreak":false,"right_gravity":true}],[25,30,0,{"virt_text":[[" 2025-10-20 15:20:11","OpencodeHint"]],"ns_id":3,"virt_text_pos":"right_align","priority":9,"virt_text_hide":false,"virt_text_repeat_linebreak":false,"right_gravity":true}],[26,31,0,{"virt_text_pos":"win_col","virt_text":[["▌","OpencodeMessageRoleUser"]],"ns_id":3,"virt_text_win_col":-3,"priority":4096,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true}],[27,32,0,{"virt_text_pos":"win_col","virt_text":[["▌","OpencodeMessageRoleUser"]],"ns_id":3,"virt_text_win_col":-3,"priority":4096,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true}],[28,35,0,{"virt_text_pos":"win_col","virt_text":[[" ","OpencodeMessageRoleAssistant"],[" "],["BUILD","OpencodeMessageRoleAssistant"],[" gpt-5-mini","OpencodeHint"],[" [msg_a0234e31f001m4EsQdPmY3PTtS]","OpencodeHint"]],"ns_id":3,"virt_text_win_col":-3,"priority":10,"virt_text_hide":false,"virt_text_repeat_linebreak":false,"right_gravity":true}],[29,35,0,{"virt_text":[[" 2025-10-20 15:20:11","OpencodeHint"]],"ns_id":3,"virt_text_pos":"right_align","priority":9,"virt_text_hide":false,"virt_text_repeat_linebreak":false,"right_gravity":true}],[30,42,0,{"virt_text_pos":"win_col","virt_text":[[" ","OpencodeMessageRoleAssistant"],[" "],["BUILD","OpencodeMessageRoleAssistant"],[" gpt-5-mini","OpencodeHint"],[" [msg_a0234f482001PQbMjWc6W8s0eF]","OpencodeHint"]],"ns_id":3,"virt_text_win_col":-3,"priority":10,"virt_text_hide":false,"virt_text_repeat_linebreak":false,"right_gravity":true}],[31,42,0,{"virt_text":[[" 2025-10-20 15:20:16","OpencodeHint"]],"ns_id":3,"virt_text_pos":"right_align","priority":9,"virt_text_hide":false,"virt_text_repeat_linebreak":false,"right_gravity":true}],[32,46,0,{"virt_text_pos":"win_col","virt_text":[["▌","OpencodeToolBorder"]],"ns_id":3,"virt_text_win_col":-1,"priority":4096,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true}],[33,47,0,{"virt_text_pos":"win_col","virt_text":[["▌","OpencodeToolBorder"]],"ns_id":3,"virt_text_win_col":-1,"priority":4096,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true}],[34,48,0,{"virt_text_pos":"win_col","virt_text":[["▌","OpencodeToolBorder"]],"ns_id":3,"virt_text_win_col":-1,"priority":4096,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true}],[35,49,0,{"hl_group":"OpencodeDiffDelete","virt_text":[["1","OpencodeDiffDeleteGutter"],["-","OpencodeDiffDeleteGutter"],[" ","OpencodeDiffDeleteGutter"]],"end_col":0,"priority":5000,"right_gravity":true,"virt_text_pos":"overlay","ns_id":3,"end_row":50,"end_right_gravity":false,"virt_text_hide":false,"hl_eol":true,"virt_text_repeat_linebreak":false}],[36,49,0,{"virt_text_pos":"win_col","virt_text":[["▌","OpencodeToolBorder"]],"ns_id":3,"virt_text_win_col":-1,"priority":4096,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true}],[37,50,0,{"hl_group":"OpencodeDiffAdd","virt_text":[["1","OpencodeDiffAddGutter"],["+","OpencodeDiffAddGutter"],[" ","OpencodeDiffAddGutter"]],"end_col":0,"priority":5000,"right_gravity":true,"virt_text_pos":"overlay","ns_id":3,"end_row":51,"end_right_gravity":false,"virt_text_hide":false,"hl_eol":true,"virt_text_repeat_linebreak":false}],[38,50,0,{"virt_text_pos":"win_col","virt_text":[["▌","OpencodeToolBorder"]],"ns_id":3,"virt_text_win_col":-1,"priority":4096,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true}],[39,51,0,{"virt_text":[["2","OpencodeDiffGutter"],[" ","OpencodeDiffGutter"],[" ","OpencodeDiffGutter"]],"end_col":0,"priority":5000,"right_gravity":true,"ns_id":3,"end_row":52,"end_right_gravity":false,"virt_text_hide":false,"virt_text_pos":"overlay","virt_text_repeat_linebreak":false}],[40,51,0,{"virt_text_pos":"win_col","virt_text":[["▌","OpencodeToolBorder"]],"ns_id":3,"virt_text_win_col":-1,"priority":4096,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true}],[41,52,0,{"virt_text":[["3","OpencodeDiffGutter"],[" ","OpencodeDiffGutter"],[" ","OpencodeDiffGutter"]],"end_col":0,"priority":5000,"right_gravity":true,"ns_id":3,"end_row":53,"end_right_gravity":false,"virt_text_hide":false,"virt_text_pos":"overlay","virt_text_repeat_linebreak":false}],[42,52,0,{"virt_text_pos":"win_col","virt_text":[["▌","OpencodeToolBorder"]],"ns_id":3,"virt_text_win_col":-1,"priority":4096,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true}],[43,53,0,{"virt_text_pos":"win_col","virt_text":[["▌","OpencodeToolBorder"]],"ns_id":3,"virt_text_win_col":-1,"priority":4096,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true}],[44,54,0,{"virt_text_pos":"win_col","virt_text":[["▌","OpencodeToolBorder"]],"ns_id":3,"virt_text_win_col":-1,"priority":4096,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true}],[45,59,0,{"virt_text_pos":"win_col","virt_text":[[" ","OpencodeMessageRoleAssistant"],[" "],["BUILD","OpencodeMessageRoleAssistant"],[" gpt-5-mini","OpencodeHint"],[" [msg_a0234f9c6001JCKYaca1HHwwx6]","OpencodeHint"]],"ns_id":3,"virt_text_win_col":-3,"priority":10,"virt_text_hide":false,"virt_text_repeat_linebreak":false,"right_gravity":true}],[46,59,0,{"virt_text":[[" 2025-10-20 15:20:17","OpencodeHint"]],"ns_id":3,"virt_text_pos":"right_align","priority":9,"virt_text_hide":false,"virt_text_repeat_linebreak":false,"right_gravity":true}],[47,64,0,{"virt_text_pos":"win_col","virt_text":[["▌󰭻 ","OpencodeMessageRoleUser"],[" "],["USER","OpencodeMessageRoleUser"],["","OpencodeHint"],[" [msg_a0236fd1c001TlwqL8fwvq529i]","OpencodeHint"]],"ns_id":3,"virt_text_win_col":-3,"priority":10,"virt_text_hide":false,"virt_text_repeat_linebreak":false,"right_gravity":true}],[48,64,0,{"virt_text":[[" 2025-10-20 15:22:29","OpencodeHint"]],"ns_id":3,"virt_text_pos":"right_align","priority":9,"virt_text_hide":false,"virt_text_repeat_linebreak":false,"right_gravity":true}],[49,65,0,{"virt_text_pos":"win_col","virt_text":[["▌","OpencodeMessageRoleUser"]],"ns_id":3,"virt_text_win_col":-3,"priority":4096,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true}],[50,66,0,{"virt_text_pos":"win_col","virt_text":[["▌","OpencodeMessageRoleUser"]],"ns_id":3,"virt_text_win_col":-3,"priority":4096,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true}],[51,69,0,{"virt_text_pos":"win_col","virt_text":[[" ","OpencodeMessageRoleAssistant"],[" "],["BUILD","OpencodeMessageRoleAssistant"],[" gpt-5-mini","OpencodeHint"],[" [msg_a0236fd57001pTnTjSBdFlleCb]","OpencodeHint"]],"ns_id":3,"virt_text_win_col":-3,"priority":10,"virt_text_hide":false,"virt_text_repeat_linebreak":false,"right_gravity":true}],[52,69,0,{"virt_text":[[" 2025-10-20 15:22:29","OpencodeHint"]],"ns_id":3,"virt_text_pos":"right_align","priority":9,"virt_text_hide":false,"virt_text_repeat_linebreak":false,"right_gravity":true}],[53,76,0,{"virt_text_pos":"win_col","virt_text":[[" ","OpencodeMessageRoleAssistant"],[" "],["BUILD","OpencodeMessageRoleAssistant"],[" gpt-5-mini","OpencodeHint"],[" [msg_a02371241001PBQAsr8Oc9hqNI]","OpencodeHint"]],"ns_id":3,"virt_text_win_col":-3,"priority":10,"virt_text_hide":false,"virt_text_repeat_linebreak":false,"right_gravity":true}],[54,76,0,{"virt_text":[[" 2025-10-20 15:22:34","OpencodeHint"]],"ns_id":3,"virt_text_pos":"right_align","priority":9,"virt_text_hide":false,"virt_text_repeat_linebreak":false,"right_gravity":true}],[55,80,0,{"virt_text_pos":"win_col","virt_text":[["▌","OpencodeToolBorder"]],"ns_id":3,"virt_text_win_col":-1,"priority":4096,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true}],[56,81,0,{"virt_text_pos":"win_col","virt_text":[["▌","OpencodeToolBorder"]],"ns_id":3,"virt_text_win_col":-1,"priority":4096,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true}],[57,82,0,{"virt_text_pos":"win_col","virt_text":[["▌","OpencodeToolBorder"]],"ns_id":3,"virt_text_win_col":-1,"priority":4096,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true}],[58,83,0,{"hl_group":"OpencodeDiffDelete","virt_text":[["1","OpencodeDiffDeleteGutter"],["-","OpencodeDiffDeleteGutter"],[" ","OpencodeDiffDeleteGutter"]],"end_col":0,"priority":5000,"right_gravity":true,"virt_text_pos":"overlay","ns_id":3,"end_row":84,"end_right_gravity":false,"virt_text_hide":false,"hl_eol":true,"virt_text_repeat_linebreak":false}],[59,83,0,{"virt_text_pos":"win_col","virt_text":[["▌","OpencodeToolBorder"]],"ns_id":3,"virt_text_win_col":-1,"priority":4096,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true}],[60,84,0,{"hl_group":"OpencodeDiffAdd","virt_text":[["1","OpencodeDiffAddGutter"],["+","OpencodeDiffAddGutter"],[" ","OpencodeDiffAddGutter"]],"end_col":0,"priority":5000,"right_gravity":true,"virt_text_pos":"overlay","ns_id":3,"end_row":85,"end_right_gravity":false,"virt_text_hide":false,"hl_eol":true,"virt_text_repeat_linebreak":false}],[61,84,0,{"virt_text_pos":"win_col","virt_text":[["▌","OpencodeToolBorder"]],"ns_id":3,"virt_text_win_col":-1,"priority":4096,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true}],[62,85,0,{"virt_text":[["2","OpencodeDiffGutter"],[" ","OpencodeDiffGutter"],[" ","OpencodeDiffGutter"]],"end_col":0,"priority":5000,"right_gravity":true,"ns_id":3,"end_row":86,"end_right_gravity":false,"virt_text_hide":false,"virt_text_pos":"overlay","virt_text_repeat_linebreak":false}],[63,85,0,{"virt_text_pos":"win_col","virt_text":[["▌","OpencodeToolBorder"]],"ns_id":3,"virt_text_win_col":-1,"priority":4096,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true}],[64,86,0,{"virt_text":[["3","OpencodeDiffGutter"],[" ","OpencodeDiffGutter"],[" ","OpencodeDiffGutter"]],"end_col":0,"priority":5000,"right_gravity":true,"ns_id":3,"end_row":87,"end_right_gravity":false,"virt_text_hide":false,"virt_text_pos":"overlay","virt_text_repeat_linebreak":false}],[65,86,0,{"virt_text_pos":"win_col","virt_text":[["▌","OpencodeToolBorder"]],"ns_id":3,"virt_text_win_col":-1,"priority":4096,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true}],[66,87,0,{"virt_text_pos":"win_col","virt_text":[["▌","OpencodeToolBorder"]],"ns_id":3,"virt_text_win_col":-1,"priority":4096,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true}],[67,88,0,{"virt_text_pos":"win_col","virt_text":[["▌","OpencodeToolBorder"]],"ns_id":3,"virt_text_win_col":-1,"priority":4096,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true}],[68,93,0,{"virt_text_pos":"win_col","virt_text":[[" ","OpencodeMessageRoleAssistant"],[" "],["BUILD","OpencodeMessageRoleAssistant"],[" gpt-5-mini","OpencodeHint"],[" [msg_a023723d0001r87MaJThFssUw1]","OpencodeHint"]],"ns_id":3,"virt_text_win_col":-3,"priority":10,"virt_text_hide":false,"virt_text_repeat_linebreak":false,"right_gravity":true}],[69,93,0,{"virt_text":[[" 2025-10-20 15:22:39","OpencodeHint"]],"ns_id":3,"virt_text_pos":"right_align","priority":9,"virt_text_hide":false,"virt_text_repeat_linebreak":false,"right_gravity":true}]],"lines":["----","","","add another word","","[`test.txt`](test.txt)","","----","","","I'll append a single word (\"again\") to the first line of  `test.txt`. Applying a precise edit to the existing line now.","","** edit** `/home/francis/Projects/_nvim/opencode.nvim/test.txt`","","`````txt"," tangram quiver saffron nebula cobalt murmur plinth zephyr ember lattice cadenza another yet extra more"," tangram quiver saffron nebula cobalt murmur plinth zephyr ember lattice cadenza another yet extra more again"," "," ","","`````","","**󰻛 Created Snapshot** `1b6ba655`","","----","","","**Done:** added the word `again` to  `test.txt`.","","----","","","add another word","","----","","","I'll read  `test.txt` to get the current first-line content, then append one word (\"again2\") to that line. Proceeding to read the file.","","** read** `/home/francis/Projects/_nvim/opencode.nvim/test.txt`","","----","","","Now I'll append the word \"again2\" to the first line. I'll apply an exact in-place edit to update that line.","","** edit** `/home/francis/Projects/_nvim/opencode.nvim/test.txt`","","`````txt"," tangram quiver saffron nebula cobalt murmur plinth zephyr ember lattice cadenza another yet extra more again"," tangram quiver saffron nebula cobalt murmur plinth zephyr ember lattice cadenza another yet extra more again again2"," "," ","","`````","","**󰻛 Created Snapshot** `57d83f55`","","----","","","**Done:** appended the word `again2` to  `test.txt`.","","----","","","add another word","","----","","","I'll read  `test.txt` to get the current first-line content, then append the word `again3`. Proceeding to read the file.","","** read** `/home/francis/Projects/_nvim/opencode.nvim/test.txt`","","----","","","I'll append the word \"again3\" to the first line of  `test.txt` with an exact in-place edit. Applying the change now.","","** edit** `/home/francis/Projects/_nvim/opencode.nvim/test.txt`","","`````txt"," tangram quiver saffron nebula cobalt murmur plinth zephyr ember lattice cadenza another yet extra more again again2"," tangram quiver saffron nebula cobalt murmur plinth zephyr ember lattice cadenza another yet extra more again again2 again3"," "," ","","`````","","**󰻛 Created Snapshot** `d988cc85`","","----","","","**Done:** appended the word `again3` to  `test.txt`.","",""],"timestamp":1773947607,"actions":[{"display_line":90,"args":["d988cc85565b99017d40ad8baea20225165be9d5"],"type":"diff_revert_selected_file","text":"[R]evert file","range":{"to":90,"from":90},"key":"R"},{"display_line":90,"args":["d988cc85565b99017d40ad8baea20225165be9d5"],"type":"diff_revert_all","text":"Revert [A]ll","range":{"to":90,"from":90},"key":"A"},{"display_line":90,"args":["d988cc85565b99017d40ad8baea20225165be9d5"],"type":"diff_open","text":"[D]iff","range":{"to":90,"from":90},"key":"D"},{"display_line":56,"args":["57d83f5596cb1f142fbc681d3d93b7184f7f73cd"],"type":"diff_revert_selected_file","text":"[R]evert file","range":{"to":56,"from":56},"key":"R"},{"display_line":56,"args":["57d83f5596cb1f142fbc681d3d93b7184f7f73cd"],"type":"diff_revert_all","text":"Revert [A]ll","range":{"to":56,"from":56},"key":"A"},{"display_line":56,"args":["57d83f5596cb1f142fbc681d3d93b7184f7f73cd"],"type":"diff_open","text":"[D]iff","range":{"to":56,"from":56},"key":"D"},{"display_line":22,"args":["1b6ba655c6c0d899965adff278ac6320d5fc3b12"],"type":"diff_revert_selected_file","text":"[R]evert file","range":{"to":22,"from":22},"key":"R"},{"display_line":22,"args":["1b6ba655c6c0d899965adff278ac6320d5fc3b12"],"type":"diff_revert_all","text":"Revert [A]ll","range":{"to":22,"from":22},"key":"A"},{"display_line":22,"args":["1b6ba655c6c0d899965adff278ac6320d5fc3b12"],"type":"diff_open","text":"[D]iff","range":{"to":22,"from":22},"key":"D"}]} \ No newline at end of file +{ + "timestamp": 1774894778, + "lines": [ + "----", + "", + "", + "add another word", + "", + "[`test.txt`](test.txt)", + "", + "----", + "", + "", + "I'll append a single word (\"again\") to the first line of  `test.txt`. Applying a precise edit to the existing line now.", + "", + "** edit** `/home/francis/Projects/_nvim/opencode.nvim/test.txt`", + "", + "`````txt", + " tangram quiver saffron nebula cobalt murmur plinth zephyr ember lattice cadenza another yet extra more", + " tangram quiver saffron nebula cobalt murmur plinth zephyr ember lattice cadenza another yet extra more again", + " ", + " ", + "", + "`````", + "", + "**󰻛 Created Snapshot** `1b6ba655`", + "", + "----", + "", + "", + "**Done:** added the word `again` to  `test.txt`.", + "", + "----", + "", + "", + "add another word", + "", + "----", + "", + "", + "I'll read  `test.txt` to get the current first-line content, then append one word (\"again2\") to that line. Proceeding to read the file.", + "", + "** read** `/home/francis/Projects/_nvim/opencode.nvim/test.txt`", + "", + "----", + "", + "", + "Now I'll append the word \"again2\" to the first line. I'll apply an exact in-place edit to update that line.", + "", + "** edit** `/home/francis/Projects/_nvim/opencode.nvim/test.txt`", + "", + "`````txt", + " tangram quiver saffron nebula cobalt murmur plinth zephyr ember lattice cadenza another yet extra more again", + " tangram quiver saffron nebula cobalt murmur plinth zephyr ember lattice cadenza another yet extra more again again2", + " ", + " ", + "", + "`````", + "", + "**󰻛 Created Snapshot** `57d83f55`", + "", + "----", + "", + "", + "**Done:** appended the word `again2` to  `test.txt`.", + "", + "----", + "", + "", + "add another word", + "", + "----", + "", + "", + "I'll read  `test.txt` to get the current first-line content, then append the word `again3`. Proceeding to read the file.", + "", + "** read** `/home/francis/Projects/_nvim/opencode.nvim/test.txt`", + "", + "----", + "", + "", + "I'll append the word \"again3\" to the first line of  `test.txt` with an exact in-place edit. Applying the change now.", + "", + "** edit** `/home/francis/Projects/_nvim/opencode.nvim/test.txt`", + "", + "`````txt", + " tangram quiver saffron nebula cobalt murmur plinth zephyr ember lattice cadenza another yet extra more again again2", + " tangram quiver saffron nebula cobalt murmur plinth zephyr ember lattice cadenza another yet extra more again again2 again3", + " ", + " ", + "", + "`````", + "", + "**󰻛 Created Snapshot** `d988cc85`", + "", + "----", + "", + "", + "**Done:** appended the word `again3` to  `test.txt`.", + "", + "" + ], + "actions": [ + { + "key": "R", + "display_line": 90, + "args": ["d988cc85565b99017d40ad8baea20225165be9d5"], + "type": "diff_revert_selected_file", + "range": { "from": 90, "to": 90 }, + "text": "[R]evert file" + }, + { + "key": "A", + "display_line": 90, + "args": ["d988cc85565b99017d40ad8baea20225165be9d5"], + "type": "diff_revert_all", + "range": { "from": 90, "to": 90 }, + "text": "Revert [A]ll" + }, + { + "key": "D", + "display_line": 90, + "args": ["d988cc85565b99017d40ad8baea20225165be9d5"], + "type": "diff_open", + "range": { "from": 90, "to": 90 }, + "text": "[D]iff" + }, + { + "key": "R", + "display_line": 22, + "args": ["1b6ba655c6c0d899965adff278ac6320d5fc3b12"], + "type": "diff_revert_selected_file", + "range": { "from": 22, "to": 22 }, + "text": "[R]evert file" + }, + { + "key": "A", + "display_line": 22, + "args": ["1b6ba655c6c0d899965adff278ac6320d5fc3b12"], + "type": "diff_revert_all", + "range": { "from": 22, "to": 22 }, + "text": "Revert [A]ll" + }, + { + "key": "D", + "display_line": 22, + "args": ["1b6ba655c6c0d899965adff278ac6320d5fc3b12"], + "type": "diff_open", + "range": { "from": 22, "to": 22 }, + "text": "[D]iff" + }, + { + "key": "R", + "display_line": 56, + "args": ["57d83f5596cb1f142fbc681d3d93b7184f7f73cd"], + "type": "diff_revert_selected_file", + "range": { "from": 56, "to": 56 }, + "text": "[R]evert file" + }, + { + "key": "A", + "display_line": 56, + "args": ["57d83f5596cb1f142fbc681d3d93b7184f7f73cd"], + "type": "diff_revert_all", + "range": { "from": 56, "to": 56 }, + "text": "Revert [A]ll" + }, + { + "key": "D", + "display_line": 56, + "args": ["57d83f5596cb1f142fbc681d3d93b7184f7f73cd"], + "type": "diff_open", + "range": { "from": 56, "to": 56 }, + "text": "[D]iff" + } + ], + "extmarks": [ + [ + 1, + 1, + 0, + { + "priority": 10, + "ns_id": 3, + "virt_text_win_col": -3, + "virt_text": [ + ["▌󰭻 ", "OpencodeMessageRoleUser"], + [" "], + ["USER", "OpencodeMessageRoleUser"], + ["", "OpencodeHint"], + [" [msg_a0234c0b7001y2o9S1jMaNVZar]", "OpencodeHint"] + ], + "virt_text_pos": "win_col", + "virt_text_hide": false, + "virt_text_repeat_linebreak": false, + "right_gravity": true + } + ], + [ + 2, + 1, + 0, + { + "priority": 9, + "ns_id": 3, + "virt_text": [[" 2025-10-20 15:20:02", "OpencodeHint"]], + "virt_text_pos": "right_align", + "virt_text_hide": false, + "virt_text_repeat_linebreak": false, + "right_gravity": true + } + ], + [ + 3, + 2, + 0, + { + "priority": 4096, + "ns_id": 3, + "virt_text_win_col": -3, + "virt_text": [["▌", "OpencodeMessageRoleUser"]], + "virt_text_pos": "win_col", + "virt_text_hide": false, + "virt_text_repeat_linebreak": true, + "right_gravity": true + } + ], + [ + 4, + 3, + 0, + { + "priority": 4096, + "ns_id": 3, + "virt_text_win_col": -3, + "virt_text": [["▌", "OpencodeMessageRoleUser"]], + "virt_text_pos": "win_col", + "virt_text_hide": false, + "virt_text_repeat_linebreak": true, + "right_gravity": true + } + ], + [ + 5, + 4, + 0, + { + "priority": 4096, + "ns_id": 3, + "virt_text_win_col": -3, + "virt_text": [["▌", "OpencodeMessageRoleUser"]], + "virt_text_pos": "win_col", + "virt_text_hide": false, + "virt_text_repeat_linebreak": true, + "right_gravity": true + } + ], + [ + 6, + 5, + 0, + { + "priority": 4096, + "ns_id": 3, + "virt_text_win_col": -3, + "virt_text": [["▌", "OpencodeMessageRoleUser"]], + "virt_text_pos": "win_col", + "virt_text_hide": false, + "virt_text_repeat_linebreak": true, + "right_gravity": true + } + ], + [ + 7, + 8, + 0, + { + "priority": 10, + "ns_id": 3, + "virt_text_win_col": -3, + "virt_text": [ + [" ", "OpencodeMessageRoleAssistant"], + [" "], + ["BUILD", "OpencodeMessageRoleAssistant"], + [" gpt-5-mini", "OpencodeHint"], + [" [msg_a0234c7960011LTxTvD94hfWCi]", "OpencodeHint"] + ], + "virt_text_pos": "win_col", + "virt_text_hide": false, + "virt_text_repeat_linebreak": false, + "right_gravity": true + } + ], + [ + 8, + 8, + 0, + { + "priority": 9, + "ns_id": 3, + "virt_text": [[" 2025-10-20 15:20:04", "OpencodeHint"]], + "virt_text_pos": "right_align", + "virt_text_hide": false, + "virt_text_repeat_linebreak": false, + "right_gravity": true + } + ], + [ + 9, + 12, + 0, + { + "priority": 4096, + "ns_id": 3, + "virt_text_win_col": -1, + "virt_text": [["▌", "OpencodeToolBorder"]], + "virt_text_pos": "win_col", + "virt_text_hide": false, + "virt_text_repeat_linebreak": true, + "right_gravity": true + } + ], + [ + 10, + 13, + 0, + { + "priority": 4096, + "ns_id": 3, + "virt_text_win_col": -1, + "virt_text": [["▌", "OpencodeToolBorder"]], + "virt_text_pos": "win_col", + "virt_text_hide": false, + "virt_text_repeat_linebreak": true, + "right_gravity": true + } + ], + [ + 11, + 14, + 0, + { + "priority": 4096, + "ns_id": 3, + "virt_text_win_col": -1, + "virt_text": [["▌", "OpencodeToolBorder"]], + "virt_text_pos": "win_col", + "virt_text_hide": false, + "virt_text_repeat_linebreak": true, + "right_gravity": true + } + ], + [ + 12, + 15, + 0, + { + "priority": 5000, + "ns_id": 3, + "virt_text": [ + ["1", "OpencodeDiffDeleteGutter"], + ["-", "OpencodeDiffDeleteGutter"], + [" ", "OpencodeDiffDeleteGutter"] + ], + "virt_text_pos": "overlay", + "right_gravity": true, + "end_col": 0, + "hl_eol": true, + "virt_text_hide": false, + "end_right_gravity": false, + "virt_text_repeat_linebreak": false, + "hl_group": "OpencodeDiffDelete", + "end_row": 16 + } + ], + [ + 13, + 15, + 0, + { + "priority": 4096, + "ns_id": 3, + "virt_text_win_col": -1, + "virt_text": [["▌", "OpencodeToolBorder"]], + "virt_text_pos": "win_col", + "virt_text_hide": false, + "virt_text_repeat_linebreak": true, + "right_gravity": true + } + ], + [ + 14, + 16, + 0, + { + "priority": 5000, + "ns_id": 3, + "virt_text": [ + ["1", "OpencodeDiffAddGutter"], + ["+", "OpencodeDiffAddGutter"], + [" ", "OpencodeDiffAddGutter"] + ], + "virt_text_pos": "overlay", + "right_gravity": true, + "end_col": 0, + "hl_eol": true, + "virt_text_hide": false, + "end_right_gravity": false, + "virt_text_repeat_linebreak": false, + "hl_group": "OpencodeDiffAdd", + "end_row": 17 + } + ], + [ + 15, + 16, + 0, + { + "priority": 4096, + "ns_id": 3, + "virt_text_win_col": -1, + "virt_text": [["▌", "OpencodeToolBorder"]], + "virt_text_pos": "win_col", + "virt_text_hide": false, + "virt_text_repeat_linebreak": true, + "right_gravity": true + } + ], + [ + 16, + 17, + 0, + { + "priority": 5000, + "ns_id": 3, + "end_row": 18, + "virt_text_pos": "overlay", + "right_gravity": true, + "end_col": 0, + "end_right_gravity": false, + "virt_text_hide": false, + "virt_text_repeat_linebreak": false, + "virt_text": [ + ["2", "OpencodeDiffGutter"], + [" ", "OpencodeDiffGutter"], + [" ", "OpencodeDiffGutter"] + ] + } + ], + [ + 17, + 17, + 0, + { + "priority": 4096, + "ns_id": 3, + "virt_text_win_col": -1, + "virt_text": [["▌", "OpencodeToolBorder"]], + "virt_text_pos": "win_col", + "virt_text_hide": false, + "virt_text_repeat_linebreak": true, + "right_gravity": true + } + ], + [ + 18, + 18, + 0, + { + "priority": 5000, + "ns_id": 3, + "end_row": 19, + "virt_text_pos": "overlay", + "right_gravity": true, + "end_col": 0, + "end_right_gravity": false, + "virt_text_hide": false, + "virt_text_repeat_linebreak": false, + "virt_text": [ + ["3", "OpencodeDiffGutter"], + [" ", "OpencodeDiffGutter"], + [" ", "OpencodeDiffGutter"] + ] + } + ], + [ + 19, + 18, + 0, + { + "priority": 4096, + "ns_id": 3, + "virt_text_win_col": -1, + "virt_text": [["▌", "OpencodeToolBorder"]], + "virt_text_pos": "win_col", + "virt_text_hide": false, + "virt_text_repeat_linebreak": true, + "right_gravity": true + } + ], + [ + 20, + 19, + 0, + { + "priority": 4096, + "ns_id": 3, + "virt_text_win_col": -1, + "virt_text": [["▌", "OpencodeToolBorder"]], + "virt_text_pos": "win_col", + "virt_text_hide": false, + "virt_text_repeat_linebreak": true, + "right_gravity": true + } + ], + [ + 21, + 20, + 0, + { + "priority": 4096, + "ns_id": 3, + "virt_text_win_col": -1, + "virt_text": [["▌", "OpencodeToolBorder"]], + "virt_text_pos": "win_col", + "virt_text_hide": false, + "virt_text_repeat_linebreak": true, + "right_gravity": true + } + ], + [ + 22, + 25, + 0, + { + "priority": 10, + "ns_id": 3, + "virt_text_win_col": -3, + "virt_text": [ + [" ", "OpencodeMessageRoleAssistant"], + [" "], + ["BUILD", "OpencodeMessageRoleAssistant"], + [" gpt-5-mini", "OpencodeHint"], + [" [msg_a0234d8fb001SXyngLjuKSuxOY]", "OpencodeHint"] + ], + "virt_text_pos": "win_col", + "virt_text_hide": false, + "virt_text_repeat_linebreak": false, + "right_gravity": true + } + ], + [ + 23, + 25, + 0, + { + "priority": 9, + "ns_id": 3, + "virt_text": [[" 2025-10-20 15:20:09", "OpencodeHint"]], + "virt_text_pos": "right_align", + "virt_text_hide": false, + "virt_text_repeat_linebreak": false, + "right_gravity": true + } + ], + [ + 24, + 30, + 0, + { + "priority": 10, + "ns_id": 3, + "virt_text_win_col": -3, + "virt_text": [ + ["▌󰭻 ", "OpencodeMessageRoleUser"], + [" "], + ["USER", "OpencodeMessageRoleUser"], + ["", "OpencodeHint"], + [" [msg_a0234e308001SKl5bQUibp5gtI]", "OpencodeHint"] + ], + "virt_text_pos": "win_col", + "virt_text_hide": false, + "virt_text_repeat_linebreak": false, + "right_gravity": true + } + ], + [ + 25, + 30, + 0, + { + "priority": 9, + "ns_id": 3, + "virt_text": [[" 2025-10-20 15:20:11", "OpencodeHint"]], + "virt_text_pos": "right_align", + "virt_text_hide": false, + "virt_text_repeat_linebreak": false, + "right_gravity": true + } + ], + [ + 26, + 31, + 0, + { + "priority": 4096, + "ns_id": 3, + "virt_text_win_col": -3, + "virt_text": [["▌", "OpencodeMessageRoleUser"]], + "virt_text_pos": "win_col", + "virt_text_hide": false, + "virt_text_repeat_linebreak": true, + "right_gravity": true + } + ], + [ + 27, + 32, + 0, + { + "priority": 4096, + "ns_id": 3, + "virt_text_win_col": -3, + "virt_text": [["▌", "OpencodeMessageRoleUser"]], + "virt_text_pos": "win_col", + "virt_text_hide": false, + "virt_text_repeat_linebreak": true, + "right_gravity": true + } + ], + [ + 28, + 35, + 0, + { + "priority": 10, + "ns_id": 3, + "virt_text_win_col": -3, + "virt_text": [ + [" ", "OpencodeMessageRoleAssistant"], + [" "], + ["BUILD", "OpencodeMessageRoleAssistant"], + [" gpt-5-mini", "OpencodeHint"], + [" [msg_a0234e31f001m4EsQdPmY3PTtS]", "OpencodeHint"] + ], + "virt_text_pos": "win_col", + "virt_text_hide": false, + "virt_text_repeat_linebreak": false, + "right_gravity": true + } + ], + [ + 29, + 35, + 0, + { + "priority": 9, + "ns_id": 3, + "virt_text": [[" 2025-10-20 15:20:11", "OpencodeHint"]], + "virt_text_pos": "right_align", + "virt_text_hide": false, + "virt_text_repeat_linebreak": false, + "right_gravity": true + } + ], + [ + 30, + 42, + 0, + { + "priority": 10, + "ns_id": 3, + "virt_text_win_col": -3, + "virt_text": [ + [" ", "OpencodeMessageRoleAssistant"], + [" "], + ["BUILD", "OpencodeMessageRoleAssistant"], + [" gpt-5-mini", "OpencodeHint"], + [" [msg_a0234f482001PQbMjWc6W8s0eF]", "OpencodeHint"] + ], + "virt_text_pos": "win_col", + "virt_text_hide": false, + "virt_text_repeat_linebreak": false, + "right_gravity": true + } + ], + [ + 31, + 42, + 0, + { + "priority": 9, + "ns_id": 3, + "virt_text": [[" 2025-10-20 15:20:16", "OpencodeHint"]], + "virt_text_pos": "right_align", + "virt_text_hide": false, + "virt_text_repeat_linebreak": false, + "right_gravity": true + } + ], + [ + 32, + 46, + 0, + { + "priority": 4096, + "ns_id": 3, + "virt_text_win_col": -1, + "virt_text": [["▌", "OpencodeToolBorder"]], + "virt_text_pos": "win_col", + "virt_text_hide": false, + "virt_text_repeat_linebreak": true, + "right_gravity": true + } + ], + [ + 33, + 47, + 0, + { + "priority": 4096, + "ns_id": 3, + "virt_text_win_col": -1, + "virt_text": [["▌", "OpencodeToolBorder"]], + "virt_text_pos": "win_col", + "virt_text_hide": false, + "virt_text_repeat_linebreak": true, + "right_gravity": true + } + ], + [ + 34, + 48, + 0, + { + "priority": 4096, + "ns_id": 3, + "virt_text_win_col": -1, + "virt_text": [["▌", "OpencodeToolBorder"]], + "virt_text_pos": "win_col", + "virt_text_hide": false, + "virt_text_repeat_linebreak": true, + "right_gravity": true + } + ], + [ + 35, + 49, + 0, + { + "priority": 5000, + "ns_id": 3, + "virt_text": [ + ["1", "OpencodeDiffDeleteGutter"], + ["-", "OpencodeDiffDeleteGutter"], + [" ", "OpencodeDiffDeleteGutter"] + ], + "virt_text_pos": "overlay", + "right_gravity": true, + "end_col": 0, + "hl_eol": true, + "virt_text_hide": false, + "end_right_gravity": false, + "virt_text_repeat_linebreak": false, + "hl_group": "OpencodeDiffDelete", + "end_row": 50 + } + ], + [ + 36, + 49, + 0, + { + "priority": 4096, + "ns_id": 3, + "virt_text_win_col": -1, + "virt_text": [["▌", "OpencodeToolBorder"]], + "virt_text_pos": "win_col", + "virt_text_hide": false, + "virt_text_repeat_linebreak": true, + "right_gravity": true + } + ], + [ + 37, + 50, + 0, + { + "priority": 5000, + "ns_id": 3, + "virt_text": [ + ["1", "OpencodeDiffAddGutter"], + ["+", "OpencodeDiffAddGutter"], + [" ", "OpencodeDiffAddGutter"] + ], + "virt_text_pos": "overlay", + "right_gravity": true, + "end_col": 0, + "hl_eol": true, + "virt_text_hide": false, + "end_right_gravity": false, + "virt_text_repeat_linebreak": false, + "hl_group": "OpencodeDiffAdd", + "end_row": 51 + } + ], + [ + 38, + 50, + 0, + { + "priority": 4096, + "ns_id": 3, + "virt_text_win_col": -1, + "virt_text": [["▌", "OpencodeToolBorder"]], + "virt_text_pos": "win_col", + "virt_text_hide": false, + "virt_text_repeat_linebreak": true, + "right_gravity": true + } + ], + [ + 39, + 51, + 0, + { + "priority": 5000, + "ns_id": 3, + "end_row": 52, + "virt_text_pos": "overlay", + "right_gravity": true, + "end_col": 0, + "end_right_gravity": false, + "virt_text_hide": false, + "virt_text_repeat_linebreak": false, + "virt_text": [ + ["2", "OpencodeDiffGutter"], + [" ", "OpencodeDiffGutter"], + [" ", "OpencodeDiffGutter"] + ] + } + ], + [ + 40, + 51, + 0, + { + "priority": 4096, + "ns_id": 3, + "virt_text_win_col": -1, + "virt_text": [["▌", "OpencodeToolBorder"]], + "virt_text_pos": "win_col", + "virt_text_hide": false, + "virt_text_repeat_linebreak": true, + "right_gravity": true + } + ], + [ + 41, + 52, + 0, + { + "priority": 5000, + "ns_id": 3, + "end_row": 53, + "virt_text_pos": "overlay", + "right_gravity": true, + "end_col": 0, + "end_right_gravity": false, + "virt_text_hide": false, + "virt_text_repeat_linebreak": false, + "virt_text": [ + ["3", "OpencodeDiffGutter"], + [" ", "OpencodeDiffGutter"], + [" ", "OpencodeDiffGutter"] + ] + } + ], + [ + 42, + 52, + 0, + { + "priority": 4096, + "ns_id": 3, + "virt_text_win_col": -1, + "virt_text": [["▌", "OpencodeToolBorder"]], + "virt_text_pos": "win_col", + "virt_text_hide": false, + "virt_text_repeat_linebreak": true, + "right_gravity": true + } + ], + [ + 43, + 53, + 0, + { + "priority": 4096, + "ns_id": 3, + "virt_text_win_col": -1, + "virt_text": [["▌", "OpencodeToolBorder"]], + "virt_text_pos": "win_col", + "virt_text_hide": false, + "virt_text_repeat_linebreak": true, + "right_gravity": true + } + ], + [ + 44, + 54, + 0, + { + "priority": 4096, + "ns_id": 3, + "virt_text_win_col": -1, + "virt_text": [["▌", "OpencodeToolBorder"]], + "virt_text_pos": "win_col", + "virt_text_hide": false, + "virt_text_repeat_linebreak": true, + "right_gravity": true + } + ], + [ + 45, + 59, + 0, + { + "priority": 10, + "ns_id": 3, + "virt_text_win_col": -3, + "virt_text": [ + [" ", "OpencodeMessageRoleAssistant"], + [" "], + ["BUILD", "OpencodeMessageRoleAssistant"], + [" gpt-5-mini", "OpencodeHint"], + [" [msg_a0234f9c6001JCKYaca1HHwwx6]", "OpencodeHint"] + ], + "virt_text_pos": "win_col", + "virt_text_hide": false, + "virt_text_repeat_linebreak": false, + "right_gravity": true + } + ], + [ + 46, + 59, + 0, + { + "priority": 9, + "ns_id": 3, + "virt_text": [[" 2025-10-20 15:20:17", "OpencodeHint"]], + "virt_text_pos": "right_align", + "virt_text_hide": false, + "virt_text_repeat_linebreak": false, + "right_gravity": true + } + ], + [ + 47, + 64, + 0, + { + "priority": 10, + "ns_id": 3, + "virt_text_win_col": -3, + "virt_text": [ + ["▌󰭻 ", "OpencodeMessageRoleUser"], + [" "], + ["USER", "OpencodeMessageRoleUser"], + ["", "OpencodeHint"], + [" [msg_a0236fd1c001TlwqL8fwvq529i]", "OpencodeHint"] + ], + "virt_text_pos": "win_col", + "virt_text_hide": false, + "virt_text_repeat_linebreak": false, + "right_gravity": true + } + ], + [ + 48, + 64, + 0, + { + "priority": 9, + "ns_id": 3, + "virt_text": [[" 2025-10-20 15:22:29", "OpencodeHint"]], + "virt_text_pos": "right_align", + "virt_text_hide": false, + "virt_text_repeat_linebreak": false, + "right_gravity": true + } + ], + [ + 49, + 65, + 0, + { + "priority": 4096, + "ns_id": 3, + "virt_text_win_col": -3, + "virt_text": [["▌", "OpencodeMessageRoleUser"]], + "virt_text_pos": "win_col", + "virt_text_hide": false, + "virt_text_repeat_linebreak": true, + "right_gravity": true + } + ], + [ + 50, + 66, + 0, + { + "priority": 4096, + "ns_id": 3, + "virt_text_win_col": -3, + "virt_text": [["▌", "OpencodeMessageRoleUser"]], + "virt_text_pos": "win_col", + "virt_text_hide": false, + "virt_text_repeat_linebreak": true, + "right_gravity": true + } + ], + [ + 51, + 69, + 0, + { + "priority": 10, + "ns_id": 3, + "virt_text_win_col": -3, + "virt_text": [ + [" ", "OpencodeMessageRoleAssistant"], + [" "], + ["BUILD", "OpencodeMessageRoleAssistant"], + [" gpt-5-mini", "OpencodeHint"], + [" [msg_a0236fd57001pTnTjSBdFlleCb]", "OpencodeHint"] + ], + "virt_text_pos": "win_col", + "virt_text_hide": false, + "virt_text_repeat_linebreak": false, + "right_gravity": true + } + ], + [ + 52, + 69, + 0, + { + "priority": 9, + "ns_id": 3, + "virt_text": [[" 2025-10-20 15:22:29", "OpencodeHint"]], + "virt_text_pos": "right_align", + "virt_text_hide": false, + "virt_text_repeat_linebreak": false, + "right_gravity": true + } + ], + [ + 53, + 76, + 0, + { + "priority": 10, + "ns_id": 3, + "virt_text_win_col": -3, + "virt_text": [ + [" ", "OpencodeMessageRoleAssistant"], + [" "], + ["BUILD", "OpencodeMessageRoleAssistant"], + [" gpt-5-mini", "OpencodeHint"], + [" [msg_a02371241001PBQAsr8Oc9hqNI]", "OpencodeHint"] + ], + "virt_text_pos": "win_col", + "virt_text_hide": false, + "virt_text_repeat_linebreak": false, + "right_gravity": true + } + ], + [ + 54, + 76, + 0, + { + "priority": 9, + "ns_id": 3, + "virt_text": [[" 2025-10-20 15:22:34", "OpencodeHint"]], + "virt_text_pos": "right_align", + "virt_text_hide": false, + "virt_text_repeat_linebreak": false, + "right_gravity": true + } + ], + [ + 55, + 80, + 0, + { + "priority": 4096, + "ns_id": 3, + "virt_text_win_col": -1, + "virt_text": [["▌", "OpencodeToolBorder"]], + "virt_text_pos": "win_col", + "virt_text_hide": false, + "virt_text_repeat_linebreak": true, + "right_gravity": true + } + ], + [ + 56, + 81, + 0, + { + "priority": 4096, + "ns_id": 3, + "virt_text_win_col": -1, + "virt_text": [["▌", "OpencodeToolBorder"]], + "virt_text_pos": "win_col", + "virt_text_hide": false, + "virt_text_repeat_linebreak": true, + "right_gravity": true + } + ], + [ + 57, + 82, + 0, + { + "priority": 4096, + "ns_id": 3, + "virt_text_win_col": -1, + "virt_text": [["▌", "OpencodeToolBorder"]], + "virt_text_pos": "win_col", + "virt_text_hide": false, + "virt_text_repeat_linebreak": true, + "right_gravity": true + } + ], + [ + 58, + 83, + 0, + { + "priority": 5000, + "ns_id": 3, + "virt_text": [ + ["1", "OpencodeDiffDeleteGutter"], + ["-", "OpencodeDiffDeleteGutter"], + [" ", "OpencodeDiffDeleteGutter"] + ], + "virt_text_pos": "overlay", + "right_gravity": true, + "end_col": 0, + "hl_eol": true, + "virt_text_hide": false, + "end_right_gravity": false, + "virt_text_repeat_linebreak": false, + "hl_group": "OpencodeDiffDelete", + "end_row": 84 + } + ], + [ + 59, + 83, + 0, + { + "priority": 4096, + "ns_id": 3, + "virt_text_win_col": -1, + "virt_text": [["▌", "OpencodeToolBorder"]], + "virt_text_pos": "win_col", + "virt_text_hide": false, + "virt_text_repeat_linebreak": true, + "right_gravity": true + } + ], + [ + 60, + 84, + 0, + { + "priority": 5000, + "ns_id": 3, + "virt_text": [ + ["1", "OpencodeDiffAddGutter"], + ["+", "OpencodeDiffAddGutter"], + [" ", "OpencodeDiffAddGutter"] + ], + "virt_text_pos": "overlay", + "right_gravity": true, + "end_col": 0, + "hl_eol": true, + "virt_text_hide": false, + "end_right_gravity": false, + "virt_text_repeat_linebreak": false, + "hl_group": "OpencodeDiffAdd", + "end_row": 85 + } + ], + [ + 61, + 84, + 0, + { + "priority": 4096, + "ns_id": 3, + "virt_text_win_col": -1, + "virt_text": [["▌", "OpencodeToolBorder"]], + "virt_text_pos": "win_col", + "virt_text_hide": false, + "virt_text_repeat_linebreak": true, + "right_gravity": true + } + ], + [ + 62, + 85, + 0, + { + "priority": 5000, + "ns_id": 3, + "end_row": 86, + "virt_text_pos": "overlay", + "right_gravity": true, + "end_col": 0, + "end_right_gravity": false, + "virt_text_hide": false, + "virt_text_repeat_linebreak": false, + "virt_text": [ + ["2", "OpencodeDiffGutter"], + [" ", "OpencodeDiffGutter"], + [" ", "OpencodeDiffGutter"] + ] + } + ], + [ + 63, + 85, + 0, + { + "priority": 4096, + "ns_id": 3, + "virt_text_win_col": -1, + "virt_text": [["▌", "OpencodeToolBorder"]], + "virt_text_pos": "win_col", + "virt_text_hide": false, + "virt_text_repeat_linebreak": true, + "right_gravity": true + } + ], + [ + 64, + 86, + 0, + { + "priority": 5000, + "ns_id": 3, + "end_row": 87, + "virt_text_pos": "overlay", + "right_gravity": true, + "end_col": 0, + "end_right_gravity": false, + "virt_text_hide": false, + "virt_text_repeat_linebreak": false, + "virt_text": [ + ["3", "OpencodeDiffGutter"], + [" ", "OpencodeDiffGutter"], + [" ", "OpencodeDiffGutter"] + ] + } + ], + [ + 65, + 86, + 0, + { + "priority": 4096, + "ns_id": 3, + "virt_text_win_col": -1, + "virt_text": [["▌", "OpencodeToolBorder"]], + "virt_text_pos": "win_col", + "virt_text_hide": false, + "virt_text_repeat_linebreak": true, + "right_gravity": true + } + ], + [ + 66, + 87, + 0, + { + "priority": 4096, + "ns_id": 3, + "virt_text_win_col": -1, + "virt_text": [["▌", "OpencodeToolBorder"]], + "virt_text_pos": "win_col", + "virt_text_hide": false, + "virt_text_repeat_linebreak": true, + "right_gravity": true + } + ], + [ + 67, + 88, + 0, + { + "priority": 4096, + "ns_id": 3, + "virt_text_win_col": -1, + "virt_text": [["▌", "OpencodeToolBorder"]], + "virt_text_pos": "win_col", + "virt_text_hide": false, + "virt_text_repeat_linebreak": true, + "right_gravity": true + } + ], + [ + 68, + 93, + 0, + { + "priority": 10, + "ns_id": 3, + "virt_text_win_col": -3, + "virt_text": [ + [" ", "OpencodeMessageRoleAssistant"], + [" "], + ["BUILD", "OpencodeMessageRoleAssistant"], + [" gpt-5-mini", "OpencodeHint"], + [" [msg_a023723d0001r87MaJThFssUw1]", "OpencodeHint"] + ], + "virt_text_pos": "win_col", + "virt_text_hide": false, + "virt_text_repeat_linebreak": false, + "right_gravity": true + } + ], + [ + 69, + 93, + 0, + { + "priority": 9, + "ns_id": 3, + "virt_text": [[" 2025-10-20 15:22:39", "OpencodeHint"]], + "virt_text_pos": "right_align", + "virt_text_hide": false, + "virt_text_repeat_linebreak": false, + "right_gravity": true + } + ] + ] +} diff --git a/tests/data/redo-once.expected.json b/tests/data/redo-once.expected.json index 70ca72fc..3cd1c028 100644 --- a/tests/data/redo-once.expected.json +++ b/tests/data/redo-once.expected.json @@ -1 +1,940 @@ -{"lines":["----","","","add another word","","[`test.txt`](test.txt)","","----","","","I'll append a single word (\"again\") to the first line of  `test.txt`. Applying a precise edit to the existing line now.","","** edit** `/home/francis/Projects/_nvim/opencode.nvim/test.txt`","","`````txt"," tangram quiver saffron nebula cobalt murmur plinth zephyr ember lattice cadenza another yet extra more"," tangram quiver saffron nebula cobalt murmur plinth zephyr ember lattice cadenza another yet extra more again"," "," ","","`````","","**󰻛 Created Snapshot** `1b6ba655`","","----","","","**Done:** added the word `again` to  `test.txt`.","","----","","","add another word","","----","","","I'll read  `test.txt` to get the current first-line content, then append one word (\"again2\") to that line. Proceeding to read the file.","","** read** `/home/francis/Projects/_nvim/opencode.nvim/test.txt`","","----","","","Now I'll append the word \"again2\" to the first line. I'll apply an exact in-place edit to update that line.","","** edit** `/home/francis/Projects/_nvim/opencode.nvim/test.txt`","","`````txt"," tangram quiver saffron nebula cobalt murmur plinth zephyr ember lattice cadenza another yet extra more again"," tangram quiver saffron nebula cobalt murmur plinth zephyr ember lattice cadenza another yet extra more again again2"," "," ","","`````","","**󰻛 Created Snapshot** `57d83f55`","","----","","","**Done:** appended the word `again2` to  `test.txt`.","","----","","> 1 message reverted, 2 tool calls reverted",">","> type `/redo` to restore.",""," test.txt: +1 -1",""],"extmarks":[[1,1,0,{"right_gravity":true,"virt_text_repeat_linebreak":false,"virt_text":[["▌󰭻 ","OpencodeMessageRoleUser"],[" "],["USER","OpencodeMessageRoleUser"],["","OpencodeHint"],[" [msg_a0234c0b7001y2o9S1jMaNVZar]","OpencodeHint"]],"virt_text_hide":false,"ns_id":3,"virt_text_pos":"win_col","virt_text_win_col":-3,"priority":10}],[2,1,0,{"right_gravity":true,"virt_text":[[" 2025-10-20 15:20:02","OpencodeHint"]],"virt_text_hide":false,"ns_id":3,"virt_text_pos":"right_align","virt_text_repeat_linebreak":false,"priority":9}],[3,2,0,{"right_gravity":true,"virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_hide":false,"ns_id":3,"virt_text_pos":"win_col","virt_text_win_col":-3,"priority":4096}],[4,3,0,{"right_gravity":true,"virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_hide":false,"ns_id":3,"virt_text_pos":"win_col","virt_text_win_col":-3,"priority":4096}],[5,4,0,{"right_gravity":true,"virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_hide":false,"ns_id":3,"virt_text_pos":"win_col","virt_text_win_col":-3,"priority":4096}],[6,5,0,{"right_gravity":true,"virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_hide":false,"ns_id":3,"virt_text_pos":"win_col","virt_text_win_col":-3,"priority":4096}],[7,8,0,{"right_gravity":true,"virt_text_repeat_linebreak":false,"virt_text":[[" ","OpencodeMessageRoleAssistant"],[" "],["BUILD","OpencodeMessageRoleAssistant"],[" gpt-5-mini","OpencodeHint"],[" [msg_a0234c7960011LTxTvD94hfWCi]","OpencodeHint"]],"virt_text_hide":false,"ns_id":3,"virt_text_pos":"win_col","virt_text_win_col":-3,"priority":10}],[8,8,0,{"right_gravity":true,"virt_text":[[" 2025-10-20 15:20:04","OpencodeHint"]],"virt_text_hide":false,"ns_id":3,"virt_text_pos":"right_align","virt_text_repeat_linebreak":false,"priority":9}],[9,12,0,{"right_gravity":true,"virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodeToolBorder"]],"virt_text_hide":false,"ns_id":3,"virt_text_pos":"win_col","virt_text_win_col":-1,"priority":4096}],[10,13,0,{"right_gravity":true,"virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodeToolBorder"]],"virt_text_hide":false,"ns_id":3,"virt_text_pos":"win_col","virt_text_win_col":-1,"priority":4096}],[11,14,0,{"right_gravity":true,"virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodeToolBorder"]],"virt_text_hide":false,"ns_id":3,"virt_text_pos":"win_col","virt_text_win_col":-1,"priority":4096}],[12,15,0,{"right_gravity":true,"end_row":16,"end_col":0,"virt_text_hide":false,"ns_id":3,"hl_eol":true,"end_right_gravity":false,"hl_group":"OpencodeDiffDelete","virt_text":[["1","OpencodeDiffDeleteGutter"],["-","OpencodeDiffDeleteGutter"],[" ","OpencodeDiffDeleteGutter"]],"virt_text_pos":"overlay","virt_text_repeat_linebreak":false,"priority":5000}],[13,15,0,{"right_gravity":true,"virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodeToolBorder"]],"virt_text_hide":false,"ns_id":3,"virt_text_pos":"win_col","virt_text_win_col":-1,"priority":4096}],[14,16,0,{"right_gravity":true,"end_row":17,"end_col":0,"virt_text_hide":false,"ns_id":3,"hl_eol":true,"end_right_gravity":false,"hl_group":"OpencodeDiffAdd","virt_text":[["1","OpencodeDiffAddGutter"],["+","OpencodeDiffAddGutter"],[" ","OpencodeDiffAddGutter"]],"virt_text_pos":"overlay","virt_text_repeat_linebreak":false,"priority":5000}],[15,16,0,{"right_gravity":true,"virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodeToolBorder"]],"virt_text_hide":false,"ns_id":3,"virt_text_pos":"win_col","virt_text_win_col":-1,"priority":4096}],[16,17,0,{"right_gravity":true,"end_row":18,"end_col":0,"virt_text_hide":false,"ns_id":3,"end_right_gravity":false,"virt_text":[["2","OpencodeDiffGutter"],[" ","OpencodeDiffGutter"],[" ","OpencodeDiffGutter"]],"virt_text_pos":"overlay","virt_text_repeat_linebreak":false,"priority":5000}],[17,17,0,{"right_gravity":true,"virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodeToolBorder"]],"virt_text_hide":false,"ns_id":3,"virt_text_pos":"win_col","virt_text_win_col":-1,"priority":4096}],[18,18,0,{"right_gravity":true,"end_row":19,"end_col":0,"virt_text_hide":false,"ns_id":3,"end_right_gravity":false,"virt_text":[["3","OpencodeDiffGutter"],[" ","OpencodeDiffGutter"],[" ","OpencodeDiffGutter"]],"virt_text_pos":"overlay","virt_text_repeat_linebreak":false,"priority":5000}],[19,18,0,{"right_gravity":true,"virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodeToolBorder"]],"virt_text_hide":false,"ns_id":3,"virt_text_pos":"win_col","virt_text_win_col":-1,"priority":4096}],[20,19,0,{"right_gravity":true,"virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodeToolBorder"]],"virt_text_hide":false,"ns_id":3,"virt_text_pos":"win_col","virt_text_win_col":-1,"priority":4096}],[21,20,0,{"right_gravity":true,"virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodeToolBorder"]],"virt_text_hide":false,"ns_id":3,"virt_text_pos":"win_col","virt_text_win_col":-1,"priority":4096}],[22,25,0,{"right_gravity":true,"virt_text_repeat_linebreak":false,"virt_text":[[" ","OpencodeMessageRoleAssistant"],[" "],["BUILD","OpencodeMessageRoleAssistant"],[" gpt-5-mini","OpencodeHint"],[" [msg_a0234d8fb001SXyngLjuKSuxOY]","OpencodeHint"]],"virt_text_hide":false,"ns_id":3,"virt_text_pos":"win_col","virt_text_win_col":-3,"priority":10}],[23,25,0,{"right_gravity":true,"virt_text":[[" 2025-10-20 15:20:09","OpencodeHint"]],"virt_text_hide":false,"ns_id":3,"virt_text_pos":"right_align","virt_text_repeat_linebreak":false,"priority":9}],[24,30,0,{"right_gravity":true,"virt_text_repeat_linebreak":false,"virt_text":[["▌󰭻 ","OpencodeMessageRoleUser"],[" "],["USER","OpencodeMessageRoleUser"],["","OpencodeHint"],[" [msg_a0234e308001SKl5bQUibp5gtI]","OpencodeHint"]],"virt_text_hide":false,"ns_id":3,"virt_text_pos":"win_col","virt_text_win_col":-3,"priority":10}],[25,30,0,{"right_gravity":true,"virt_text":[[" 2025-10-20 15:20:11","OpencodeHint"]],"virt_text_hide":false,"ns_id":3,"virt_text_pos":"right_align","virt_text_repeat_linebreak":false,"priority":9}],[26,31,0,{"right_gravity":true,"virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_hide":false,"ns_id":3,"virt_text_pos":"win_col","virt_text_win_col":-3,"priority":4096}],[27,32,0,{"right_gravity":true,"virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_hide":false,"ns_id":3,"virt_text_pos":"win_col","virt_text_win_col":-3,"priority":4096}],[28,35,0,{"right_gravity":true,"virt_text_repeat_linebreak":false,"virt_text":[[" ","OpencodeMessageRoleAssistant"],[" "],["BUILD","OpencodeMessageRoleAssistant"],[" gpt-5-mini","OpencodeHint"],[" [msg_a0234e31f001m4EsQdPmY3PTtS]","OpencodeHint"]],"virt_text_hide":false,"ns_id":3,"virt_text_pos":"win_col","virt_text_win_col":-3,"priority":10}],[29,35,0,{"right_gravity":true,"virt_text":[[" 2025-10-20 15:20:11","OpencodeHint"]],"virt_text_hide":false,"ns_id":3,"virt_text_pos":"right_align","virt_text_repeat_linebreak":false,"priority":9}],[30,42,0,{"right_gravity":true,"virt_text_repeat_linebreak":false,"virt_text":[[" ","OpencodeMessageRoleAssistant"],[" "],["BUILD","OpencodeMessageRoleAssistant"],[" gpt-5-mini","OpencodeHint"],[" [msg_a0234f482001PQbMjWc6W8s0eF]","OpencodeHint"]],"virt_text_hide":false,"ns_id":3,"virt_text_pos":"win_col","virt_text_win_col":-3,"priority":10}],[31,42,0,{"right_gravity":true,"virt_text":[[" 2025-10-20 15:20:16","OpencodeHint"]],"virt_text_hide":false,"ns_id":3,"virt_text_pos":"right_align","virt_text_repeat_linebreak":false,"priority":9}],[32,46,0,{"right_gravity":true,"virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodeToolBorder"]],"virt_text_hide":false,"ns_id":3,"virt_text_pos":"win_col","virt_text_win_col":-1,"priority":4096}],[33,47,0,{"right_gravity":true,"virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodeToolBorder"]],"virt_text_hide":false,"ns_id":3,"virt_text_pos":"win_col","virt_text_win_col":-1,"priority":4096}],[34,48,0,{"right_gravity":true,"virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodeToolBorder"]],"virt_text_hide":false,"ns_id":3,"virt_text_pos":"win_col","virt_text_win_col":-1,"priority":4096}],[35,49,0,{"right_gravity":true,"end_row":50,"end_col":0,"virt_text_hide":false,"ns_id":3,"hl_eol":true,"end_right_gravity":false,"hl_group":"OpencodeDiffDelete","virt_text":[["1","OpencodeDiffDeleteGutter"],["-","OpencodeDiffDeleteGutter"],[" ","OpencodeDiffDeleteGutter"]],"virt_text_pos":"overlay","virt_text_repeat_linebreak":false,"priority":5000}],[36,49,0,{"right_gravity":true,"virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodeToolBorder"]],"virt_text_hide":false,"ns_id":3,"virt_text_pos":"win_col","virt_text_win_col":-1,"priority":4096}],[37,50,0,{"right_gravity":true,"end_row":51,"end_col":0,"virt_text_hide":false,"ns_id":3,"hl_eol":true,"end_right_gravity":false,"hl_group":"OpencodeDiffAdd","virt_text":[["1","OpencodeDiffAddGutter"],["+","OpencodeDiffAddGutter"],[" ","OpencodeDiffAddGutter"]],"virt_text_pos":"overlay","virt_text_repeat_linebreak":false,"priority":5000}],[38,50,0,{"right_gravity":true,"virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodeToolBorder"]],"virt_text_hide":false,"ns_id":3,"virt_text_pos":"win_col","virt_text_win_col":-1,"priority":4096}],[39,51,0,{"right_gravity":true,"end_row":52,"end_col":0,"virt_text_hide":false,"ns_id":3,"end_right_gravity":false,"virt_text":[["2","OpencodeDiffGutter"],[" ","OpencodeDiffGutter"],[" ","OpencodeDiffGutter"]],"virt_text_pos":"overlay","virt_text_repeat_linebreak":false,"priority":5000}],[40,51,0,{"right_gravity":true,"virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodeToolBorder"]],"virt_text_hide":false,"ns_id":3,"virt_text_pos":"win_col","virt_text_win_col":-1,"priority":4096}],[41,52,0,{"right_gravity":true,"end_row":53,"end_col":0,"virt_text_hide":false,"ns_id":3,"end_right_gravity":false,"virt_text":[["3","OpencodeDiffGutter"],[" ","OpencodeDiffGutter"],[" ","OpencodeDiffGutter"]],"virt_text_pos":"overlay","virt_text_repeat_linebreak":false,"priority":5000}],[42,52,0,{"right_gravity":true,"virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodeToolBorder"]],"virt_text_hide":false,"ns_id":3,"virt_text_pos":"win_col","virt_text_win_col":-1,"priority":4096}],[43,53,0,{"right_gravity":true,"virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodeToolBorder"]],"virt_text_hide":false,"ns_id":3,"virt_text_pos":"win_col","virt_text_win_col":-1,"priority":4096}],[44,54,0,{"right_gravity":true,"virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodeToolBorder"]],"virt_text_hide":false,"ns_id":3,"virt_text_pos":"win_col","virt_text_win_col":-1,"priority":4096}],[45,59,0,{"right_gravity":true,"virt_text_repeat_linebreak":false,"virt_text":[[" ","OpencodeMessageRoleAssistant"],[" "],["BUILD","OpencodeMessageRoleAssistant"],[" gpt-5-mini","OpencodeHint"],[" [msg_a0234f9c6001JCKYaca1HHwwx6]","OpencodeHint"]],"virt_text_hide":false,"ns_id":3,"virt_text_pos":"win_col","virt_text_win_col":-3,"priority":10}],[46,59,0,{"right_gravity":true,"virt_text":[[" 2025-10-20 15:20:17","OpencodeHint"]],"virt_text_hide":false,"ns_id":3,"virt_text_pos":"right_align","virt_text_repeat_linebreak":false,"priority":9}],[47,69,0,{"right_gravity":true,"virt_text_repeat_linebreak":false,"virt_text":[["+1","OpencodeDiffAddText"]],"virt_text_hide":false,"ns_id":3,"virt_text_pos":"win_col","virt_text_win_col":12,"priority":1000}],[48,69,0,{"right_gravity":true,"virt_text_repeat_linebreak":false,"virt_text":[["-1","OpencodeDiffDeleteText"]],"virt_text_hide":false,"ns_id":3,"virt_text_pos":"win_col","virt_text_win_col":15,"priority":1000}]],"actions":[{"type":"diff_revert_selected_file","key":"R","text":"[R]evert file","range":{"from":56,"to":56},"args":["57d83f5596cb1f142fbc681d3d93b7184f7f73cd"],"display_line":56},{"type":"diff_revert_all","key":"A","text":"Revert [A]ll","range":{"from":56,"to":56},"args":["57d83f5596cb1f142fbc681d3d93b7184f7f73cd"],"display_line":56},{"type":"diff_open","key":"D","text":"[D]iff","range":{"from":56,"to":56},"args":["57d83f5596cb1f142fbc681d3d93b7184f7f73cd"],"display_line":56},{"type":"diff_revert_selected_file","key":"R","text":"[R]evert file","range":{"from":22,"to":22},"args":["1b6ba655c6c0d899965adff278ac6320d5fc3b12"],"display_line":22},{"type":"diff_revert_all","key":"A","text":"Revert [A]ll","range":{"from":22,"to":22},"args":["1b6ba655c6c0d899965adff278ac6320d5fc3b12"],"display_line":22},{"type":"diff_open","key":"D","text":"[D]iff","range":{"from":22,"to":22},"args":["1b6ba655c6c0d899965adff278ac6320d5fc3b12"],"display_line":22}],"timestamp":1773947601} \ No newline at end of file +{ + "extmarks": [ + [ + 1, + 1, + 0, + { + "virt_text_repeat_linebreak": false, + "right_gravity": true, + "virt_text_win_col": -3, + "ns_id": 3, + "virt_text_pos": "win_col", + "priority": 10, + "virt_text_hide": false, + "virt_text": [ + ["▌󰭻 ", "OpencodeMessageRoleUser"], + [" "], + ["USER", "OpencodeMessageRoleUser"], + ["", "OpencodeHint"], + [" [msg_a0234c0b7001y2o9S1jMaNVZar]", "OpencodeHint"] + ] + } + ], + [ + 2, + 1, + 0, + { + "virt_text_repeat_linebreak": false, + "right_gravity": true, + "ns_id": 3, + "virt_text_pos": "right_align", + "priority": 9, + "virt_text_hide": false, + "virt_text": [[" 2025-10-20 15:20:02", "OpencodeHint"]] + } + ], + [ + 3, + 2, + 0, + { + "virt_text_repeat_linebreak": true, + "right_gravity": true, + "virt_text_win_col": -3, + "ns_id": 3, + "virt_text_pos": "win_col", + "priority": 4096, + "virt_text_hide": false, + "virt_text": [["▌", "OpencodeMessageRoleUser"]] + } + ], + [ + 4, + 3, + 0, + { + "virt_text_repeat_linebreak": true, + "right_gravity": true, + "virt_text_win_col": -3, + "ns_id": 3, + "virt_text_pos": "win_col", + "priority": 4096, + "virt_text_hide": false, + "virt_text": [["▌", "OpencodeMessageRoleUser"]] + } + ], + [ + 5, + 4, + 0, + { + "virt_text_repeat_linebreak": true, + "right_gravity": true, + "virt_text_win_col": -3, + "ns_id": 3, + "virt_text_pos": "win_col", + "priority": 4096, + "virt_text_hide": false, + "virt_text": [["▌", "OpencodeMessageRoleUser"]] + } + ], + [ + 6, + 5, + 0, + { + "virt_text_repeat_linebreak": true, + "right_gravity": true, + "virt_text_win_col": -3, + "ns_id": 3, + "virt_text_pos": "win_col", + "priority": 4096, + "virt_text_hide": false, + "virt_text": [["▌", "OpencodeMessageRoleUser"]] + } + ], + [ + 7, + 8, + 0, + { + "virt_text_repeat_linebreak": false, + "right_gravity": true, + "virt_text_win_col": -3, + "ns_id": 3, + "virt_text_pos": "win_col", + "priority": 10, + "virt_text_hide": false, + "virt_text": [ + [" ", "OpencodeMessageRoleAssistant"], + [" "], + ["BUILD", "OpencodeMessageRoleAssistant"], + [" gpt-5-mini", "OpencodeHint"], + [" [msg_a0234c7960011LTxTvD94hfWCi]", "OpencodeHint"] + ] + } + ], + [ + 8, + 8, + 0, + { + "virt_text_repeat_linebreak": false, + "right_gravity": true, + "ns_id": 3, + "virt_text_pos": "right_align", + "priority": 9, + "virt_text_hide": false, + "virt_text": [[" 2025-10-20 15:20:04", "OpencodeHint"]] + } + ], + [ + 9, + 12, + 0, + { + "virt_text_repeat_linebreak": true, + "right_gravity": true, + "virt_text_win_col": -1, + "ns_id": 3, + "virt_text_pos": "win_col", + "priority": 4096, + "virt_text_hide": false, + "virt_text": [["▌", "OpencodeToolBorder"]] + } + ], + [ + 10, + 13, + 0, + { + "virt_text_repeat_linebreak": true, + "right_gravity": true, + "virt_text_win_col": -1, + "ns_id": 3, + "virt_text_pos": "win_col", + "priority": 4096, + "virt_text_hide": false, + "virt_text": [["▌", "OpencodeToolBorder"]] + } + ], + [ + 11, + 14, + 0, + { + "virt_text_repeat_linebreak": true, + "right_gravity": true, + "virt_text_win_col": -1, + "ns_id": 3, + "virt_text_pos": "win_col", + "priority": 4096, + "virt_text_hide": false, + "virt_text": [["▌", "OpencodeToolBorder"]] + } + ], + [ + 12, + 15, + 0, + { + "end_row": 16, + "hl_group": "OpencodeDiffDelete", + "end_right_gravity": false, + "virt_text_hide": false, + "end_col": 0, + "right_gravity": true, + "virt_text_repeat_linebreak": false, + "ns_id": 3, + "virt_text_pos": "overlay", + "priority": 5000, + "hl_eol": true, + "virt_text": [ + ["1", "OpencodeDiffDeleteGutter"], + ["-", "OpencodeDiffDeleteGutter"], + [" ", "OpencodeDiffDeleteGutter"] + ] + } + ], + [ + 13, + 15, + 0, + { + "virt_text_repeat_linebreak": true, + "right_gravity": true, + "virt_text_win_col": -1, + "ns_id": 3, + "virt_text_pos": "win_col", + "priority": 4096, + "virt_text_hide": false, + "virt_text": [["▌", "OpencodeToolBorder"]] + } + ], + [ + 14, + 16, + 0, + { + "end_row": 17, + "hl_group": "OpencodeDiffAdd", + "end_right_gravity": false, + "virt_text_hide": false, + "end_col": 0, + "right_gravity": true, + "virt_text_repeat_linebreak": false, + "ns_id": 3, + "virt_text_pos": "overlay", + "priority": 5000, + "hl_eol": true, + "virt_text": [ + ["1", "OpencodeDiffAddGutter"], + ["+", "OpencodeDiffAddGutter"], + [" ", "OpencodeDiffAddGutter"] + ] + } + ], + [ + 15, + 16, + 0, + { + "virt_text_repeat_linebreak": true, + "right_gravity": true, + "virt_text_win_col": -1, + "ns_id": 3, + "virt_text_pos": "win_col", + "priority": 4096, + "virt_text_hide": false, + "virt_text": [["▌", "OpencodeToolBorder"]] + } + ], + [ + 16, + 17, + 0, + { + "end_row": 18, + "virt_text_pos": "overlay", + "end_right_gravity": false, + "virt_text_hide": false, + "end_col": 0, + "right_gravity": true, + "ns_id": 3, + "priority": 5000, + "virt_text_repeat_linebreak": false, + "virt_text": [ + ["2", "OpencodeDiffGutter"], + [" ", "OpencodeDiffGutter"], + [" ", "OpencodeDiffGutter"] + ] + } + ], + [ + 17, + 17, + 0, + { + "virt_text_repeat_linebreak": true, + "right_gravity": true, + "virt_text_win_col": -1, + "ns_id": 3, + "virt_text_pos": "win_col", + "priority": 4096, + "virt_text_hide": false, + "virt_text": [["▌", "OpencodeToolBorder"]] + } + ], + [ + 18, + 18, + 0, + { + "end_row": 19, + "virt_text_pos": "overlay", + "end_right_gravity": false, + "virt_text_hide": false, + "end_col": 0, + "right_gravity": true, + "ns_id": 3, + "priority": 5000, + "virt_text_repeat_linebreak": false, + "virt_text": [ + ["3", "OpencodeDiffGutter"], + [" ", "OpencodeDiffGutter"], + [" ", "OpencodeDiffGutter"] + ] + } + ], + [ + 19, + 18, + 0, + { + "virt_text_repeat_linebreak": true, + "right_gravity": true, + "virt_text_win_col": -1, + "ns_id": 3, + "virt_text_pos": "win_col", + "priority": 4096, + "virt_text_hide": false, + "virt_text": [["▌", "OpencodeToolBorder"]] + } + ], + [ + 20, + 19, + 0, + { + "virt_text_repeat_linebreak": true, + "right_gravity": true, + "virt_text_win_col": -1, + "ns_id": 3, + "virt_text_pos": "win_col", + "priority": 4096, + "virt_text_hide": false, + "virt_text": [["▌", "OpencodeToolBorder"]] + } + ], + [ + 21, + 20, + 0, + { + "virt_text_repeat_linebreak": true, + "right_gravity": true, + "virt_text_win_col": -1, + "ns_id": 3, + "virt_text_pos": "win_col", + "priority": 4096, + "virt_text_hide": false, + "virt_text": [["▌", "OpencodeToolBorder"]] + } + ], + [ + 22, + 25, + 0, + { + "virt_text_repeat_linebreak": false, + "right_gravity": true, + "virt_text_win_col": -3, + "ns_id": 3, + "virt_text_pos": "win_col", + "priority": 10, + "virt_text_hide": false, + "virt_text": [ + [" ", "OpencodeMessageRoleAssistant"], + [" "], + ["BUILD", "OpencodeMessageRoleAssistant"], + [" gpt-5-mini", "OpencodeHint"], + [" [msg_a0234d8fb001SXyngLjuKSuxOY]", "OpencodeHint"] + ] + } + ], + [ + 23, + 25, + 0, + { + "virt_text_repeat_linebreak": false, + "right_gravity": true, + "ns_id": 3, + "virt_text_pos": "right_align", + "priority": 9, + "virt_text_hide": false, + "virt_text": [[" 2025-10-20 15:20:09", "OpencodeHint"]] + } + ], + [ + 24, + 30, + 0, + { + "virt_text_repeat_linebreak": false, + "right_gravity": true, + "virt_text_win_col": -3, + "ns_id": 3, + "virt_text_pos": "win_col", + "priority": 10, + "virt_text_hide": false, + "virt_text": [ + ["▌󰭻 ", "OpencodeMessageRoleUser"], + [" "], + ["USER", "OpencodeMessageRoleUser"], + ["", "OpencodeHint"], + [" [msg_a0234e308001SKl5bQUibp5gtI]", "OpencodeHint"] + ] + } + ], + [ + 25, + 30, + 0, + { + "virt_text_repeat_linebreak": false, + "right_gravity": true, + "ns_id": 3, + "virt_text_pos": "right_align", + "priority": 9, + "virt_text_hide": false, + "virt_text": [[" 2025-10-20 15:20:11", "OpencodeHint"]] + } + ], + [ + 26, + 31, + 0, + { + "virt_text_repeat_linebreak": true, + "right_gravity": true, + "virt_text_win_col": -3, + "ns_id": 3, + "virt_text_pos": "win_col", + "priority": 4096, + "virt_text_hide": false, + "virt_text": [["▌", "OpencodeMessageRoleUser"]] + } + ], + [ + 27, + 32, + 0, + { + "virt_text_repeat_linebreak": true, + "right_gravity": true, + "virt_text_win_col": -3, + "ns_id": 3, + "virt_text_pos": "win_col", + "priority": 4096, + "virt_text_hide": false, + "virt_text": [["▌", "OpencodeMessageRoleUser"]] + } + ], + [ + 28, + 35, + 0, + { + "virt_text_repeat_linebreak": false, + "right_gravity": true, + "virt_text_win_col": -3, + "ns_id": 3, + "virt_text_pos": "win_col", + "priority": 10, + "virt_text_hide": false, + "virt_text": [ + [" ", "OpencodeMessageRoleAssistant"], + [" "], + ["BUILD", "OpencodeMessageRoleAssistant"], + [" gpt-5-mini", "OpencodeHint"], + [" [msg_a0234e31f001m4EsQdPmY3PTtS]", "OpencodeHint"] + ] + } + ], + [ + 29, + 35, + 0, + { + "virt_text_repeat_linebreak": false, + "right_gravity": true, + "ns_id": 3, + "virt_text_pos": "right_align", + "priority": 9, + "virt_text_hide": false, + "virt_text": [[" 2025-10-20 15:20:11", "OpencodeHint"]] + } + ], + [ + 30, + 42, + 0, + { + "virt_text_repeat_linebreak": false, + "right_gravity": true, + "virt_text_win_col": -3, + "ns_id": 3, + "virt_text_pos": "win_col", + "priority": 10, + "virt_text_hide": false, + "virt_text": [ + [" ", "OpencodeMessageRoleAssistant"], + [" "], + ["BUILD", "OpencodeMessageRoleAssistant"], + [" gpt-5-mini", "OpencodeHint"], + [" [msg_a0234f482001PQbMjWc6W8s0eF]", "OpencodeHint"] + ] + } + ], + [ + 31, + 42, + 0, + { + "virt_text_repeat_linebreak": false, + "right_gravity": true, + "ns_id": 3, + "virt_text_pos": "right_align", + "priority": 9, + "virt_text_hide": false, + "virt_text": [[" 2025-10-20 15:20:16", "OpencodeHint"]] + } + ], + [ + 32, + 46, + 0, + { + "virt_text_repeat_linebreak": true, + "right_gravity": true, + "virt_text_win_col": -1, + "ns_id": 3, + "virt_text_pos": "win_col", + "priority": 4096, + "virt_text_hide": false, + "virt_text": [["▌", "OpencodeToolBorder"]] + } + ], + [ + 33, + 47, + 0, + { + "virt_text_repeat_linebreak": true, + "right_gravity": true, + "virt_text_win_col": -1, + "ns_id": 3, + "virt_text_pos": "win_col", + "priority": 4096, + "virt_text_hide": false, + "virt_text": [["▌", "OpencodeToolBorder"]] + } + ], + [ + 34, + 48, + 0, + { + "virt_text_repeat_linebreak": true, + "right_gravity": true, + "virt_text_win_col": -1, + "ns_id": 3, + "virt_text_pos": "win_col", + "priority": 4096, + "virt_text_hide": false, + "virt_text": [["▌", "OpencodeToolBorder"]] + } + ], + [ + 35, + 49, + 0, + { + "end_row": 50, + "hl_group": "OpencodeDiffDelete", + "end_right_gravity": false, + "virt_text_hide": false, + "end_col": 0, + "right_gravity": true, + "virt_text_repeat_linebreak": false, + "ns_id": 3, + "virt_text_pos": "overlay", + "priority": 5000, + "hl_eol": true, + "virt_text": [ + ["1", "OpencodeDiffDeleteGutter"], + ["-", "OpencodeDiffDeleteGutter"], + [" ", "OpencodeDiffDeleteGutter"] + ] + } + ], + [ + 36, + 49, + 0, + { + "virt_text_repeat_linebreak": true, + "right_gravity": true, + "virt_text_win_col": -1, + "ns_id": 3, + "virt_text_pos": "win_col", + "priority": 4096, + "virt_text_hide": false, + "virt_text": [["▌", "OpencodeToolBorder"]] + } + ], + [ + 37, + 50, + 0, + { + "end_row": 51, + "hl_group": "OpencodeDiffAdd", + "end_right_gravity": false, + "virt_text_hide": false, + "end_col": 0, + "right_gravity": true, + "virt_text_repeat_linebreak": false, + "ns_id": 3, + "virt_text_pos": "overlay", + "priority": 5000, + "hl_eol": true, + "virt_text": [ + ["1", "OpencodeDiffAddGutter"], + ["+", "OpencodeDiffAddGutter"], + [" ", "OpencodeDiffAddGutter"] + ] + } + ], + [ + 38, + 50, + 0, + { + "virt_text_repeat_linebreak": true, + "right_gravity": true, + "virt_text_win_col": -1, + "ns_id": 3, + "virt_text_pos": "win_col", + "priority": 4096, + "virt_text_hide": false, + "virt_text": [["▌", "OpencodeToolBorder"]] + } + ], + [ + 39, + 51, + 0, + { + "end_row": 52, + "virt_text_pos": "overlay", + "end_right_gravity": false, + "virt_text_hide": false, + "end_col": 0, + "right_gravity": true, + "ns_id": 3, + "priority": 5000, + "virt_text_repeat_linebreak": false, + "virt_text": [ + ["2", "OpencodeDiffGutter"], + [" ", "OpencodeDiffGutter"], + [" ", "OpencodeDiffGutter"] + ] + } + ], + [ + 40, + 51, + 0, + { + "virt_text_repeat_linebreak": true, + "right_gravity": true, + "virt_text_win_col": -1, + "ns_id": 3, + "virt_text_pos": "win_col", + "priority": 4096, + "virt_text_hide": false, + "virt_text": [["▌", "OpencodeToolBorder"]] + } + ], + [ + 41, + 52, + 0, + { + "end_row": 53, + "virt_text_pos": "overlay", + "end_right_gravity": false, + "virt_text_hide": false, + "end_col": 0, + "right_gravity": true, + "ns_id": 3, + "priority": 5000, + "virt_text_repeat_linebreak": false, + "virt_text": [ + ["3", "OpencodeDiffGutter"], + [" ", "OpencodeDiffGutter"], + [" ", "OpencodeDiffGutter"] + ] + } + ], + [ + 42, + 52, + 0, + { + "virt_text_repeat_linebreak": true, + "right_gravity": true, + "virt_text_win_col": -1, + "ns_id": 3, + "virt_text_pos": "win_col", + "priority": 4096, + "virt_text_hide": false, + "virt_text": [["▌", "OpencodeToolBorder"]] + } + ], + [ + 43, + 53, + 0, + { + "virt_text_repeat_linebreak": true, + "right_gravity": true, + "virt_text_win_col": -1, + "ns_id": 3, + "virt_text_pos": "win_col", + "priority": 4096, + "virt_text_hide": false, + "virt_text": [["▌", "OpencodeToolBorder"]] + } + ], + [ + 44, + 54, + 0, + { + "virt_text_repeat_linebreak": true, + "right_gravity": true, + "virt_text_win_col": -1, + "ns_id": 3, + "virt_text_pos": "win_col", + "priority": 4096, + "virt_text_hide": false, + "virt_text": [["▌", "OpencodeToolBorder"]] + } + ], + [ + 45, + 59, + 0, + { + "virt_text_repeat_linebreak": false, + "right_gravity": true, + "virt_text_win_col": -3, + "ns_id": 3, + "virt_text_pos": "win_col", + "priority": 10, + "virt_text_hide": false, + "virt_text": [ + [" ", "OpencodeMessageRoleAssistant"], + [" "], + ["BUILD", "OpencodeMessageRoleAssistant"], + [" gpt-5-mini", "OpencodeHint"], + [" [msg_a0234f9c6001JCKYaca1HHwwx6]", "OpencodeHint"] + ] + } + ], + [ + 46, + 59, + 0, + { + "virt_text_repeat_linebreak": false, + "right_gravity": true, + "ns_id": 3, + "virt_text_pos": "right_align", + "priority": 9, + "virt_text_hide": false, + "virt_text": [[" 2025-10-20 15:20:17", "OpencodeHint"]] + } + ], + [ + 47, + 69, + 0, + { + "virt_text_repeat_linebreak": false, + "right_gravity": true, + "virt_text_win_col": 12, + "ns_id": 3, + "virt_text_pos": "win_col", + "priority": 1000, + "virt_text_hide": false, + "virt_text": [["+1", "OpencodeDiffAddText"]] + } + ], + [ + 48, + 69, + 0, + { + "virt_text_repeat_linebreak": false, + "right_gravity": true, + "virt_text_win_col": 15, + "ns_id": 3, + "virt_text_pos": "win_col", + "priority": 1000, + "virt_text_hide": false, + "virt_text": [["-1", "OpencodeDiffDeleteText"]] + } + ] + ], + "actions": [ + { + "text": "[R]evert file", + "range": { "from": 22, "to": 22 }, + "key": "R", + "display_line": 22, + "args": ["1b6ba655c6c0d899965adff278ac6320d5fc3b12"], + "type": "diff_revert_selected_file" + }, + { + "text": "Revert [A]ll", + "range": { "from": 22, "to": 22 }, + "key": "A", + "display_line": 22, + "args": ["1b6ba655c6c0d899965adff278ac6320d5fc3b12"], + "type": "diff_revert_all" + }, + { + "text": "[D]iff", + "range": { "from": 22, "to": 22 }, + "key": "D", + "display_line": 22, + "args": ["1b6ba655c6c0d899965adff278ac6320d5fc3b12"], + "type": "diff_open" + }, + { + "text": "[R]evert file", + "range": { "from": 56, "to": 56 }, + "key": "R", + "display_line": 56, + "args": ["57d83f5596cb1f142fbc681d3d93b7184f7f73cd"], + "type": "diff_revert_selected_file" + }, + { + "text": "Revert [A]ll", + "range": { "from": 56, "to": 56 }, + "key": "A", + "display_line": 56, + "args": ["57d83f5596cb1f142fbc681d3d93b7184f7f73cd"], + "type": "diff_revert_all" + }, + { + "text": "[D]iff", + "range": { "from": 56, "to": 56 }, + "key": "D", + "display_line": 56, + "args": ["57d83f5596cb1f142fbc681d3d93b7184f7f73cd"], + "type": "diff_open" + } + ], + "timestamp": 1774894778, + "lines": [ + "----", + "", + "", + "add another word", + "", + "[`test.txt`](test.txt)", + "", + "----", + "", + "", + "I'll append a single word (\"again\") to the first line of  `test.txt`. Applying a precise edit to the existing line now.", + "", + "** edit** `/home/francis/Projects/_nvim/opencode.nvim/test.txt`", + "", + "`````txt", + " tangram quiver saffron nebula cobalt murmur plinth zephyr ember lattice cadenza another yet extra more", + " tangram quiver saffron nebula cobalt murmur plinth zephyr ember lattice cadenza another yet extra more again", + " ", + " ", + "", + "`````", + "", + "**󰻛 Created Snapshot** `1b6ba655`", + "", + "----", + "", + "", + "**Done:** added the word `again` to  `test.txt`.", + "", + "----", + "", + "", + "add another word", + "", + "----", + "", + "", + "I'll read  `test.txt` to get the current first-line content, then append one word (\"again2\") to that line. Proceeding to read the file.", + "", + "** read** `/home/francis/Projects/_nvim/opencode.nvim/test.txt`", + "", + "----", + "", + "", + "Now I'll append the word \"again2\" to the first line. I'll apply an exact in-place edit to update that line.", + "", + "** edit** `/home/francis/Projects/_nvim/opencode.nvim/test.txt`", + "", + "`````txt", + " tangram quiver saffron nebula cobalt murmur plinth zephyr ember lattice cadenza another yet extra more again", + " tangram quiver saffron nebula cobalt murmur plinth zephyr ember lattice cadenza another yet extra more again again2", + " ", + " ", + "", + "`````", + "", + "**󰻛 Created Snapshot** `57d83f55`", + "", + "----", + "", + "", + "**Done:** appended the word `again2` to  `test.txt`.", + "", + "----", + "", + "> 1 message reverted, 2 tool calls reverted", + ">", + "> type `/redo` to restore.", + "", + " test.txt: +1 -1", + "", + "" + ] +} diff --git a/tests/data/revert.expected.json b/tests/data/revert.expected.json index d8cb1c04..8cd7c95d 100644 --- a/tests/data/revert.expected.json +++ b/tests/data/revert.expected.json @@ -1,79 +1,121 @@ { "actions": [ { - "args": [ - "c410b2b4024de020aea223c5248eec89216de53f" - ], - "display_line": 53, + "range": { "from": 53, "to": 53 }, "key": "R", - "range": { - "from": 53, - "to": 53 - }, "text": "[R]evert file", - "type": "diff_revert_selected_file" + "args": ["c410b2b4024de020aea223c5248eec89216de53f"], + "type": "diff_revert_selected_file", + "display_line": 53 }, { - "args": [ - "c410b2b4024de020aea223c5248eec89216de53f" - ], - "display_line": 53, + "range": { "from": 53, "to": 53 }, "key": "A", - "range": { - "from": 53, - "to": 53 - }, "text": "Revert [A]ll", - "type": "diff_revert_all" + "args": ["c410b2b4024de020aea223c5248eec89216de53f"], + "type": "diff_revert_all", + "display_line": 53 }, { - "args": [ - "c410b2b4024de020aea223c5248eec89216de53f" - ], - "display_line": 53, + "range": { "from": 53, "to": 53 }, "key": "D", - "range": { - "from": 53, - "to": 53 - }, "text": "[D]iff", - "type": "diff_open" + "args": ["c410b2b4024de020aea223c5248eec89216de53f"], + "type": "diff_open", + "display_line": 53 } ], + "lines": [ + "----", + "", + "", + "write 10 random words", + "", + "[`poem.md`](poem.md)", + "", + "----", + "", + "", + "Here are 10 random words:", + "", + "1. Lantern ", + "2. Whisper ", + "3. Velvet ", + "4. Orbit ", + "5. Timber ", + "6. Quiver ", + "7. Mosaic ", + "8. Ember ", + "9. Spiral ", + "10. Glimmer", + "", + "Let me know if you need them in a specific format or want to use them in a file!", + "", + "----", + "", + "", + "write 10 random words to the file", + "", + "----", + "", + "", + "I will write 10 random words to poem.md, each on a new line.", + "", + "Proceeding to update the file now.", + "", + "** write** `/home/francis/Projects/_nvim/opencode.nvim/poem.md`", + "", + "`````markdown", + "Lantern", + "Whisper", + "Velvet", + "Orbit", + "Timber", + "Quiver", + "Mosaic", + "Ember", + "Spiral", + "Glimmer", + "", + "`````", + "", + "**󰻛 Created Snapshot** `c410b2b4`", + "", + "----", + "", + "", + "The file poem.md has been updated with 10 random words, each on a new line. Task complete! If you need anything else, let me know.", + "", + "----", + "", + "> 2 messages reverted, 4 tool calls reverted", + ">", + "> type `/redo` to restore.", + "", + " poem.md: -20", + "", + "" + ], "extmarks": [ [ 1, 1, 0, { - "ns_id": 3, + "virt_text_pos": "win_col", + "virt_text_repeat_linebreak": false, + "virt_text_win_col": -3, "priority": 10, - "right_gravity": true, + "ns_id": 3, "virt_text": [ - [ - "▌󰭻 ", - "OpencodeMessageRoleUser" - ], - [ - " " - ], - [ - "USER", - "OpencodeMessageRoleUser" - ], - [ - "", - "OpencodeHint" - ], - [ - " [msg_9fd985573001fk1Xlot7uyDgTo]", - "OpencodeHint" - ] + ["▌󰭻 ", "OpencodeMessageRoleUser"], + [" "], + ["USER", "OpencodeMessageRoleUser"], + ["", "OpencodeHint"], + [" [msg_9fd985573001fk1Xlot7uyDgTo]", "OpencodeHint"] ], - "virt_text_hide": false, - "virt_text_pos": "win_col", - "virt_text_repeat_linebreak": false, - "virt_text_win_col": -3 + "right_gravity": true, + "virt_text_hide": false } ], [ @@ -81,18 +123,13 @@ 1, 0, { - "ns_id": 3, "priority": 9, - "right_gravity": true, - "virt_text": [ - [ - " 2025-10-19 17:50:43", - "OpencodeHint" - ] - ], - "virt_text_hide": false, "virt_text_pos": "right_align", - "virt_text_repeat_linebreak": false + "virt_text_repeat_linebreak": false, + "ns_id": 3, + "virt_text": [[" 2025-10-19 17:50:43", "OpencodeHint"]], + "right_gravity": true, + "virt_text_hide": false } ], [ @@ -100,19 +137,14 @@ 2, 0, { - "ns_id": 3, - "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeMessageRoleUser" - ] - ], - "virt_text_hide": false, "virt_text_pos": "win_col", "virt_text_repeat_linebreak": true, - "virt_text_win_col": -3 + "virt_text_win_col": -3, + "priority": 4096, + "ns_id": 3, + "virt_text": [["▌", "OpencodeMessageRoleUser"]], + "right_gravity": true, + "virt_text_hide": false } ], [ @@ -120,19 +152,14 @@ 3, 0, { - "ns_id": 3, - "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeMessageRoleUser" - ] - ], - "virt_text_hide": false, "virt_text_pos": "win_col", "virt_text_repeat_linebreak": true, - "virt_text_win_col": -3 + "virt_text_win_col": -3, + "priority": 4096, + "ns_id": 3, + "virt_text": [["▌", "OpencodeMessageRoleUser"]], + "right_gravity": true, + "virt_text_hide": false } ], [ @@ -140,19 +167,14 @@ 4, 0, { - "ns_id": 3, - "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeMessageRoleUser" - ] - ], - "virt_text_hide": false, "virt_text_pos": "win_col", "virt_text_repeat_linebreak": true, - "virt_text_win_col": -3 + "virt_text_win_col": -3, + "priority": 4096, + "ns_id": 3, + "virt_text": [["▌", "OpencodeMessageRoleUser"]], + "right_gravity": true, + "virt_text_hide": false } ], [ @@ -160,19 +182,14 @@ 5, 0, { - "ns_id": 3, - "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeMessageRoleUser" - ] - ], - "virt_text_hide": false, "virt_text_pos": "win_col", "virt_text_repeat_linebreak": true, - "virt_text_win_col": -3 + "virt_text_win_col": -3, + "priority": 4096, + "ns_id": 3, + "virt_text": [["▌", "OpencodeMessageRoleUser"]], + "right_gravity": true, + "virt_text_hide": false } ], [ @@ -180,34 +197,20 @@ 8, 0, { - "ns_id": 3, + "virt_text_pos": "win_col", + "virt_text_repeat_linebreak": false, + "virt_text_win_col": -3, "priority": 10, - "right_gravity": true, + "ns_id": 3, "virt_text": [ - [ - " ", - "OpencodeMessageRoleAssistant" - ], - [ - " " - ], - [ - "BUILD", - "OpencodeMessageRoleAssistant" - ], - [ - " gpt-4.1", - "OpencodeHint" - ], - [ - " [msg_9fd985a4d001wOX3Op7CpFiCTq]", - "OpencodeHint" - ] + [" ", "OpencodeMessageRoleAssistant"], + [" "], + ["BUILD", "OpencodeMessageRoleAssistant"], + [" gpt-4.1", "OpencodeHint"], + [" [msg_9fd985a4d001wOX3Op7CpFiCTq]", "OpencodeHint"] ], - "virt_text_hide": false, - "virt_text_pos": "win_col", - "virt_text_repeat_linebreak": false, - "virt_text_win_col": -3 + "right_gravity": true, + "virt_text_hide": false } ], [ @@ -215,18 +218,13 @@ 8, 0, { - "ns_id": 3, "priority": 9, - "right_gravity": true, - "virt_text": [ - [ - " 2025-10-19 17:50:44", - "OpencodeHint" - ] - ], - "virt_text_hide": false, "virt_text_pos": "right_align", - "virt_text_repeat_linebreak": false + "virt_text_repeat_linebreak": false, + "ns_id": 3, + "virt_text": [[" 2025-10-19 17:50:44", "OpencodeHint"]], + "right_gravity": true, + "virt_text_hide": false } ], [ @@ -234,34 +232,20 @@ 26, 0, { - "ns_id": 3, + "virt_text_pos": "win_col", + "virt_text_repeat_linebreak": false, + "virt_text_win_col": -3, "priority": 10, - "right_gravity": true, + "ns_id": 3, "virt_text": [ - [ - "▌󰭻 ", - "OpencodeMessageRoleUser" - ], - [ - " " - ], - [ - "USER", - "OpencodeMessageRoleUser" - ], - [ - "", - "OpencodeHint" - ], - [ - " [msg_9fd988c92001w0IZCVPQsN6xa9]", - "OpencodeHint" - ] + ["▌󰭻 ", "OpencodeMessageRoleUser"], + [" "], + ["USER", "OpencodeMessageRoleUser"], + ["", "OpencodeHint"], + [" [msg_9fd988c92001w0IZCVPQsN6xa9]", "OpencodeHint"] ], - "virt_text_hide": false, - "virt_text_pos": "win_col", - "virt_text_repeat_linebreak": false, - "virt_text_win_col": -3 + "right_gravity": true, + "virt_text_hide": false } ], [ @@ -269,18 +253,13 @@ 26, 0, { - "ns_id": 3, "priority": 9, - "right_gravity": true, - "virt_text": [ - [ - " 2025-10-19 17:50:57", - "OpencodeHint" - ] - ], - "virt_text_hide": false, "virt_text_pos": "right_align", - "virt_text_repeat_linebreak": false + "virt_text_repeat_linebreak": false, + "ns_id": 3, + "virt_text": [[" 2025-10-19 17:50:57", "OpencodeHint"]], + "right_gravity": true, + "virt_text_hide": false } ], [ @@ -288,19 +267,14 @@ 27, 0, { - "ns_id": 3, - "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeMessageRoleUser" - ] - ], - "virt_text_hide": false, "virt_text_pos": "win_col", "virt_text_repeat_linebreak": true, - "virt_text_win_col": -3 + "virt_text_win_col": -3, + "priority": 4096, + "ns_id": 3, + "virt_text": [["▌", "OpencodeMessageRoleUser"]], + "right_gravity": true, + "virt_text_hide": false } ], [ @@ -308,19 +282,14 @@ 28, 0, { - "ns_id": 3, - "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeMessageRoleUser" - ] - ], - "virt_text_hide": false, "virt_text_pos": "win_col", "virt_text_repeat_linebreak": true, - "virt_text_win_col": -3 + "virt_text_win_col": -3, + "priority": 4096, + "ns_id": 3, + "virt_text": [["▌", "OpencodeMessageRoleUser"]], + "right_gravity": true, + "virt_text_hide": false } ], [ @@ -328,34 +297,20 @@ 31, 0, { - "ns_id": 3, + "virt_text_pos": "win_col", + "virt_text_repeat_linebreak": false, + "virt_text_win_col": -3, "priority": 10, - "right_gravity": true, + "ns_id": 3, "virt_text": [ - [ - " ", - "OpencodeMessageRoleAssistant" - ], - [ - " " - ], - [ - "BUILD", - "OpencodeMessageRoleAssistant" - ], - [ - " gpt-4.1", - "OpencodeHint" - ], - [ - " [msg_9fd988ca7001lgaGttpI4YeGSA]", - "OpencodeHint" - ] + [" ", "OpencodeMessageRoleAssistant"], + [" "], + ["BUILD", "OpencodeMessageRoleAssistant"], + [" gpt-4.1", "OpencodeHint"], + [" [msg_9fd988ca7001lgaGttpI4YeGSA]", "OpencodeHint"] ], - "virt_text_hide": false, - "virt_text_pos": "win_col", - "virt_text_repeat_linebreak": false, - "virt_text_win_col": -3 + "right_gravity": true, + "virt_text_hide": false } ], [ @@ -363,18 +318,13 @@ 31, 0, { - "ns_id": 3, "priority": 9, - "right_gravity": true, - "virt_text": [ - [ - " 2025-10-19 17:50:57", - "OpencodeHint" - ] - ], - "virt_text_hide": false, "virt_text_pos": "right_align", - "virt_text_repeat_linebreak": false + "virt_text_repeat_linebreak": false, + "ns_id": 3, + "virt_text": [[" 2025-10-19 17:50:57", "OpencodeHint"]], + "right_gravity": true, + "virt_text_hide": false } ], [ @@ -382,19 +332,14 @@ 37, 0, { - "ns_id": 3, - "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeToolBorder" - ] - ], - "virt_text_hide": false, "virt_text_pos": "win_col", "virt_text_repeat_linebreak": true, - "virt_text_win_col": -1 + "virt_text_win_col": -1, + "priority": 4096, + "ns_id": 3, + "virt_text": [["▌", "OpencodeToolBorder"]], + "right_gravity": true, + "virt_text_hide": false } ], [ @@ -402,19 +347,14 @@ 38, 0, { - "ns_id": 3, - "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeToolBorder" - ] - ], - "virt_text_hide": false, "virt_text_pos": "win_col", "virt_text_repeat_linebreak": true, - "virt_text_win_col": -1 + "virt_text_win_col": -1, + "priority": 4096, + "ns_id": 3, + "virt_text": [["▌", "OpencodeToolBorder"]], + "right_gravity": true, + "virt_text_hide": false } ], [ @@ -422,19 +362,14 @@ 39, 0, { - "ns_id": 3, - "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeToolBorder" - ] - ], - "virt_text_hide": false, "virt_text_pos": "win_col", "virt_text_repeat_linebreak": true, - "virt_text_win_col": -1 + "virt_text_win_col": -1, + "priority": 4096, + "ns_id": 3, + "virt_text": [["▌", "OpencodeToolBorder"]], + "right_gravity": true, + "virt_text_hide": false } ], [ @@ -442,19 +377,14 @@ 40, 0, { - "ns_id": 3, - "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeToolBorder" - ] - ], - "virt_text_hide": false, "virt_text_pos": "win_col", "virt_text_repeat_linebreak": true, - "virt_text_win_col": -1 + "virt_text_win_col": -1, + "priority": 4096, + "ns_id": 3, + "virt_text": [["▌", "OpencodeToolBorder"]], + "right_gravity": true, + "virt_text_hide": false } ], [ @@ -462,19 +392,14 @@ 41, 0, { - "ns_id": 3, - "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeToolBorder" - ] - ], - "virt_text_hide": false, "virt_text_pos": "win_col", "virt_text_repeat_linebreak": true, - "virt_text_win_col": -1 + "virt_text_win_col": -1, + "priority": 4096, + "ns_id": 3, + "virt_text": [["▌", "OpencodeToolBorder"]], + "right_gravity": true, + "virt_text_hide": false } ], [ @@ -482,19 +407,14 @@ 42, 0, { - "ns_id": 3, - "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeToolBorder" - ] - ], - "virt_text_hide": false, "virt_text_pos": "win_col", "virt_text_repeat_linebreak": true, - "virt_text_win_col": -1 + "virt_text_win_col": -1, + "priority": 4096, + "ns_id": 3, + "virt_text": [["▌", "OpencodeToolBorder"]], + "right_gravity": true, + "virt_text_hide": false } ], [ @@ -502,19 +422,14 @@ 43, 0, { - "ns_id": 3, - "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeToolBorder" - ] - ], - "virt_text_hide": false, "virt_text_pos": "win_col", "virt_text_repeat_linebreak": true, - "virt_text_win_col": -1 + "virt_text_win_col": -1, + "priority": 4096, + "ns_id": 3, + "virt_text": [["▌", "OpencodeToolBorder"]], + "right_gravity": true, + "virt_text_hide": false } ], [ @@ -522,19 +437,14 @@ 44, 0, { - "ns_id": 3, - "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeToolBorder" - ] - ], - "virt_text_hide": false, "virt_text_pos": "win_col", "virt_text_repeat_linebreak": true, - "virt_text_win_col": -1 + "virt_text_win_col": -1, + "priority": 4096, + "ns_id": 3, + "virt_text": [["▌", "OpencodeToolBorder"]], + "right_gravity": true, + "virt_text_hide": false } ], [ @@ -542,19 +452,14 @@ 45, 0, { - "ns_id": 3, - "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeToolBorder" - ] - ], - "virt_text_hide": false, "virt_text_pos": "win_col", "virt_text_repeat_linebreak": true, - "virt_text_win_col": -1 + "virt_text_win_col": -1, + "priority": 4096, + "ns_id": 3, + "virt_text": [["▌", "OpencodeToolBorder"]], + "right_gravity": true, + "virt_text_hide": false } ], [ @@ -562,19 +467,14 @@ 46, 0, { - "ns_id": 3, - "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeToolBorder" - ] - ], - "virt_text_hide": false, "virt_text_pos": "win_col", "virt_text_repeat_linebreak": true, - "virt_text_win_col": -1 + "virt_text_win_col": -1, + "priority": 4096, + "ns_id": 3, + "virt_text": [["▌", "OpencodeToolBorder"]], + "right_gravity": true, + "virt_text_hide": false } ], [ @@ -582,19 +482,14 @@ 47, 0, { - "ns_id": 3, - "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeToolBorder" - ] - ], - "virt_text_hide": false, "virt_text_pos": "win_col", "virt_text_repeat_linebreak": true, - "virt_text_win_col": -1 + "virt_text_win_col": -1, + "priority": 4096, + "ns_id": 3, + "virt_text": [["▌", "OpencodeToolBorder"]], + "right_gravity": true, + "virt_text_hide": false } ], [ @@ -602,19 +497,14 @@ 48, 0, { - "ns_id": 3, - "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeToolBorder" - ] - ], - "virt_text_hide": false, "virt_text_pos": "win_col", "virt_text_repeat_linebreak": true, - "virt_text_win_col": -1 + "virt_text_win_col": -1, + "priority": 4096, + "ns_id": 3, + "virt_text": [["▌", "OpencodeToolBorder"]], + "right_gravity": true, + "virt_text_hide": false } ], [ @@ -622,19 +512,14 @@ 49, 0, { - "ns_id": 3, - "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeToolBorder" - ] - ], - "virt_text_hide": false, "virt_text_pos": "win_col", "virt_text_repeat_linebreak": true, - "virt_text_win_col": -1 + "virt_text_win_col": -1, + "priority": 4096, + "ns_id": 3, + "virt_text": [["▌", "OpencodeToolBorder"]], + "right_gravity": true, + "virt_text_hide": false } ], [ @@ -642,19 +527,14 @@ 50, 0, { - "ns_id": 3, - "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeToolBorder" - ] - ], - "virt_text_hide": false, "virt_text_pos": "win_col", "virt_text_repeat_linebreak": true, - "virt_text_win_col": -1 + "virt_text_win_col": -1, + "priority": 4096, + "ns_id": 3, + "virt_text": [["▌", "OpencodeToolBorder"]], + "right_gravity": true, + "virt_text_hide": false } ], [ @@ -662,19 +542,14 @@ 51, 0, { - "ns_id": 3, - "priority": 4096, - "right_gravity": true, - "virt_text": [ - [ - "▌", - "OpencodeToolBorder" - ] - ], - "virt_text_hide": false, "virt_text_pos": "win_col", "virt_text_repeat_linebreak": true, - "virt_text_win_col": -1 + "virt_text_win_col": -1, + "priority": 4096, + "ns_id": 3, + "virt_text": [["▌", "OpencodeToolBorder"]], + "right_gravity": true, + "virt_text_hide": false } ], [ @@ -682,34 +557,20 @@ 56, 0, { - "ns_id": 3, + "virt_text_pos": "win_col", + "virt_text_repeat_linebreak": false, + "virt_text_win_col": -3, "priority": 10, - "right_gravity": true, + "ns_id": 3, "virt_text": [ - [ - " ", - "OpencodeMessageRoleAssistant" - ], - [ - " " - ], - [ - "BUILD", - "OpencodeMessageRoleAssistant" - ], - [ - " gpt-4.1", - "OpencodeHint" - ], - [ - " [msg_9fd98942d001elqd2sd8CZeOoA]", - "OpencodeHint" - ] + [" ", "OpencodeMessageRoleAssistant"], + [" "], + ["BUILD", "OpencodeMessageRoleAssistant"], + [" gpt-4.1", "OpencodeHint"], + [" [msg_9fd98942d001elqd2sd8CZeOoA]", "OpencodeHint"] ], - "virt_text_hide": false, - "virt_text_pos": "win_col", - "virt_text_repeat_linebreak": false, - "virt_text_win_col": -3 + "right_gravity": true, + "virt_text_hide": false } ], [ @@ -717,18 +578,13 @@ 56, 0, { - "ns_id": 3, "priority": 9, - "right_gravity": true, - "virt_text": [ - [ - " 2025-10-19 17:50:59", - "OpencodeHint" - ] - ], - "virt_text_hide": false, "virt_text_pos": "right_align", - "virt_text_repeat_linebreak": false + "virt_text_repeat_linebreak": false, + "ns_id": 3, + "virt_text": [[" 2025-10-19 17:50:59", "OpencodeHint"]], + "right_gravity": true, + "virt_text_hide": false } ], [ @@ -736,91 +592,16 @@ 66, 0, { - "ns_id": 3, - "priority": 1000, - "right_gravity": true, - "virt_text": [ - [ - "-20", - "OpencodeDiffDeleteText" - ] - ], - "virt_text_hide": false, "virt_text_pos": "win_col", "virt_text_repeat_linebreak": false, - "virt_text_win_col": 11 + "virt_text_win_col": 11, + "priority": 1000, + "ns_id": 3, + "virt_text": [["-20", "OpencodeDiffDeleteText"]], + "right_gravity": true, + "virt_text_hide": false } ] ], - "lines": [ - "----", - "", - "", - "write 10 random words", - "", - "[`poem.md`](poem.md)", - "", - "----", - "", - "", - "Here are 10 random words:", - "", - "1. Lantern ", - "2. Whisper ", - "3. Velvet ", - "4. Orbit ", - "5. Timber ", - "6. Quiver ", - "7. Mosaic ", - "8. Ember ", - "9. Spiral ", - "10. Glimmer", - "", - "Let me know if you need them in a specific format or want to use them in a file!", - "", - "----", - "", - "", - "write 10 random words to the file", - "", - "----", - "", - "", - "I will write 10 random words to poem.md, each on a new line.", - "", - "Proceeding to update the file now.", - "", - "** write** `/home/francis/Projects/_nvim/opencode.nvim/poem.md`", - "", - "`````markdown", - "Lantern", - "Whisper", - "Velvet", - "Orbit", - "Timber", - "Quiver", - "Mosaic", - "Ember", - "Spiral", - "Glimmer", - "", - "`````", - "", - "**󰻛 Created Snapshot** `c410b2b4`", - "", - "----", - "", - "", - "The file poem.md has been updated with 10 random words, each on a new line. Task complete! If you need anything else, let me know.", - "", - "----", - "", - "> 2 messages reverted, 4 tool calls reverted", - ">", - "> type `/redo` to restore.", - "", - " poem.md: -20", - "" - ], - "timestamp": 1770935242 -} \ No newline at end of file + "timestamp": 1774894779 +} diff --git a/tests/data/shifting-and-multiple-perms.expected.json b/tests/data/shifting-and-multiple-perms.expected.json index c7d60a84..832d0cba 100644 --- a/tests/data/shifting-and-multiple-perms.expected.json +++ b/tests/data/shifting-and-multiple-perms.expected.json @@ -1 +1,1083 @@ -{"extmarks":[[1,1,0,{"ns_id":3,"virt_text_win_col":-3,"priority":10,"virt_text_hide":false,"virt_text_repeat_linebreak":false,"right_gravity":true,"virt_text_pos":"win_col","virt_text":[["▌󰭻 ","OpencodeMessageRoleUser"],[" "],["USER","OpencodeMessageRoleUser"],["","OpencodeHint"],[" [msg_9efb39d68001J2h30a50B2774b]","OpencodeHint"]]}],[2,1,0,{"ns_id":3,"priority":9,"virt_text_hide":false,"virt_text_pos":"right_align","right_gravity":true,"virt_text_repeat_linebreak":false,"virt_text":[[" 2025-10-17 01:05:49","OpencodeHint"]]}],[3,2,0,{"ns_id":3,"virt_text_win_col":-3,"priority":4096,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text_pos":"win_col","virt_text":[["▌","OpencodeMessageRoleUser"]]}],[4,3,0,{"ns_id":3,"virt_text_win_col":-3,"priority":4096,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text_pos":"win_col","virt_text":[["▌","OpencodeMessageRoleUser"]]}],[5,4,0,{"ns_id":3,"virt_text_win_col":-3,"priority":4096,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text_pos":"win_col","virt_text":[["▌","OpencodeMessageRoleUser"]]}],[6,5,0,{"ns_id":3,"virt_text_win_col":-3,"priority":4096,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text_pos":"win_col","virt_text":[["▌","OpencodeMessageRoleUser"]]}],[7,8,0,{"ns_id":3,"virt_text_win_col":-3,"priority":10,"virt_text_hide":false,"virt_text_repeat_linebreak":false,"right_gravity":true,"virt_text_pos":"win_col","virt_text":[[" ","OpencodeMessageRoleAssistant"],[" "],["PLAN","OpencodeMessageRoleAssistant"],[" claude-sonnet-4.5","OpencodeHint"],[" [msg_9efb39dc3002f81rMRqF2WO1UU]","OpencodeHint"]]}],[8,8,0,{"ns_id":3,"priority":9,"virt_text_hide":false,"virt_text_pos":"right_align","right_gravity":true,"virt_text_repeat_linebreak":false,"virt_text":[[" 2025-10-17 01:05:50","OpencodeHint"]]}],[9,83,0,{"ns_id":3,"virt_text_win_col":-3,"priority":10,"virt_text_hide":false,"virt_text_repeat_linebreak":false,"right_gravity":true,"virt_text_pos":"win_col","virt_text":[["▌󰭻 ","OpencodeMessageRoleUser"],[" "],["USER","OpencodeMessageRoleUser"],["","OpencodeHint"],[" [msg_9efb50a0b001WFK7AMDV45cF8Z]","OpencodeHint"]]}],[10,83,0,{"ns_id":3,"priority":9,"virt_text_hide":false,"virt_text_pos":"right_align","right_gravity":true,"virt_text_repeat_linebreak":false,"virt_text":[[" 2025-10-17 01:07:23","OpencodeHint"]]}],[11,84,0,{"ns_id":3,"virt_text_win_col":-3,"priority":4096,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text_pos":"win_col","virt_text":[["▌","OpencodeMessageRoleUser"]]}],[12,85,0,{"ns_id":3,"virt_text_win_col":-3,"priority":4096,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text_pos":"win_col","virt_text":[["▌","OpencodeMessageRoleUser"]]}],[13,88,0,{"ns_id":3,"virt_text_win_col":-3,"priority":10,"virt_text_hide":false,"virt_text_repeat_linebreak":false,"right_gravity":true,"virt_text_pos":"win_col","virt_text":[[" ","OpencodeMessageRoleAssistant"],[" "],["PLAN","OpencodeMessageRoleAssistant"],[" claude-sonnet-4.5","OpencodeHint"],[" [msg_9efb50a2a002dzMgbQnasd86o1]","OpencodeHint"]]}],[14,88,0,{"ns_id":3,"priority":9,"virt_text_hide":false,"virt_text_pos":"right_align","right_gravity":true,"virt_text_repeat_linebreak":false,"virt_text":[[" 2025-10-17 01:07:23","OpencodeHint"]]}],[15,111,0,{"ns_id":3,"virt_text_win_col":-3,"priority":10,"virt_text_hide":false,"virt_text_repeat_linebreak":false,"right_gravity":true,"virt_text_pos":"win_col","virt_text":[["▌󰭻 ","OpencodeMessageRoleUser"],[" "],["USER","OpencodeMessageRoleUser"],["","OpencodeHint"],[" [msg_9efb59d93001LSm9y0DS9p8cP6]","OpencodeHint"]]}],[16,111,0,{"ns_id":3,"priority":9,"virt_text_hide":false,"virt_text_pos":"right_align","right_gravity":true,"virt_text_repeat_linebreak":false,"virt_text":[[" 2025-10-17 01:08:01","OpencodeHint"]]}],[17,112,0,{"ns_id":3,"virt_text_win_col":-3,"priority":4096,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text_pos":"win_col","virt_text":[["▌","OpencodeMessageRoleUser"]]}],[18,113,0,{"ns_id":3,"virt_text_win_col":-3,"priority":4096,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text_pos":"win_col","virt_text":[["▌","OpencodeMessageRoleUser"]]}],[19,116,0,{"ns_id":3,"virt_text_win_col":-3,"priority":10,"virt_text_hide":false,"virt_text_repeat_linebreak":false,"right_gravity":true,"virt_text_pos":"win_col","virt_text":[[" ","OpencodeMessageRoleAssistant"],[" "],["PLAN","OpencodeMessageRoleAssistant"],[" claude-sonnet-4.5","OpencodeHint"],[" [msg_9efb59db4002uWmyFRTjRIhIaQ]","OpencodeHint"]]}],[20,116,0,{"ns_id":3,"priority":9,"virt_text_hide":false,"virt_text_pos":"right_align","right_gravity":true,"virt_text_repeat_linebreak":false,"virt_text":[[" 2025-10-17 01:08:01","OpencodeHint"]]}],[21,125,0,{"ns_id":3,"virt_text_win_col":-3,"priority":10,"virt_text_hide":false,"virt_text_repeat_linebreak":false,"right_gravity":true,"virt_text_pos":"win_col","virt_text":[[" ","OpencodeMessageRoleSystem"],[" "],["SYSTEM","OpencodeMessageRoleSystem"],["","OpencodeHint"],[" [permission-display-message]","OpencodeHint"]]}],[22,127,0,{"ns_id":3,"right_gravity":true,"line_hl_group":"OpencodePermissionTitle","priority":4096}],[23,127,0,{"ns_id":3,"virt_text_win_col":-2,"priority":4096,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text_pos":"win_col","virt_text":[["▌","OpencodePermissionBorder"]]}],[24,128,0,{"ns_id":3,"virt_text_win_col":-2,"priority":4096,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text_pos":"win_col","virt_text":[["▌","OpencodePermissionBorder"]]}],[25,129,0,{"ns_id":3,"virt_text_win_col":-2,"priority":4096,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text_pos":"win_col","virt_text":[["▌","OpencodePermissionBorder"]]}],[26,130,0,{"ns_id":3,"virt_text_win_col":-2,"priority":4096,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text_pos":"win_col","virt_text":[["▌","OpencodePermissionBorder"]]}],[27,131,0,{"ns_id":3,"virt_text_win_col":-2,"priority":4096,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text_pos":"win_col","virt_text":[["▌","OpencodePermissionBorder"]]}],[28,132,0,{"ns_id":3,"virt_text_win_col":-2,"priority":4096,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text_pos":"win_col","virt_text":[["▌","OpencodePermissionBorder"]]}],[29,133,0,{"virt_text_repeat_linebreak":false,"priority":5000,"end_col":0,"ns_id":3,"virt_text_hide":false,"end_row":134,"virt_text_pos":"overlay","right_gravity":true,"end_right_gravity":false,"virt_text":[["11","OpencodeDiffGutter"],[" ","OpencodeDiffGutter"],[" ","OpencodeDiffGutter"]]}],[30,133,0,{"ns_id":3,"virt_text_win_col":-2,"priority":4096,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text_pos":"win_col","virt_text":[["▌","OpencodePermissionBorder"]]}],[31,134,0,{"virt_text_repeat_linebreak":false,"priority":5000,"end_col":0,"ns_id":3,"virt_text_hide":false,"end_row":135,"virt_text_pos":"overlay","right_gravity":true,"end_right_gravity":false,"virt_text":[["12","OpencodeDiffGutter"],[" ","OpencodeDiffGutter"],[" ","OpencodeDiffGutter"]]}],[32,134,0,{"ns_id":3,"virt_text_win_col":-2,"priority":4096,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text_pos":"win_col","virt_text":[["▌","OpencodePermissionBorder"]]}],[33,135,0,{"virt_text_repeat_linebreak":false,"priority":5000,"end_col":0,"ns_id":3,"virt_text_hide":false,"end_row":136,"virt_text_pos":"overlay","right_gravity":true,"end_right_gravity":false,"virt_text":[["13","OpencodeDiffGutter"],[" ","OpencodeDiffGutter"],[" ","OpencodeDiffGutter"]]}],[34,135,0,{"ns_id":3,"virt_text_win_col":-2,"priority":4096,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text_pos":"win_col","virt_text":[["▌","OpencodePermissionBorder"]]}],[35,136,0,{"virt_text_repeat_linebreak":false,"priority":5000,"end_col":0,"ns_id":3,"virt_text_hide":false,"end_row":137,"virt_text_pos":"overlay","right_gravity":true,"end_right_gravity":false,"virt_text":[["14","OpencodeDiffGutter"],[" ","OpencodeDiffGutter"],[" ","OpencodeDiffGutter"]]}],[36,136,0,{"ns_id":3,"virt_text_win_col":-2,"priority":4096,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text_pos":"win_col","virt_text":[["▌","OpencodePermissionBorder"]]}],[37,137,0,{"hl_eol":true,"priority":5000,"end_col":0,"virt_text_hide":false,"ns_id":3,"hl_group":"OpencodeDiffAdd","end_row":138,"virt_text_repeat_linebreak":false,"virt_text_pos":"overlay","right_gravity":true,"end_right_gravity":false,"virt_text":[["15","OpencodeDiffAddGutter"],["+","OpencodeDiffAddGutter"],[" ","OpencodeDiffAddGutter"]]}],[38,137,0,{"ns_id":3,"virt_text_win_col":-2,"priority":4096,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text_pos":"win_col","virt_text":[["▌","OpencodePermissionBorder"]]}],[39,138,0,{"virt_text_repeat_linebreak":false,"priority":5000,"end_col":0,"ns_id":3,"virt_text_hide":false,"end_row":139,"virt_text_pos":"overlay","right_gravity":true,"end_right_gravity":false,"virt_text":[["16","OpencodeDiffGutter"],[" ","OpencodeDiffGutter"],[" ","OpencodeDiffGutter"]]}],[40,138,0,{"ns_id":3,"virt_text_win_col":-2,"priority":4096,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text_pos":"win_col","virt_text":[["▌","OpencodePermissionBorder"]]}],[41,139,0,{"virt_text_repeat_linebreak":false,"priority":5000,"end_col":0,"ns_id":3,"virt_text_hide":false,"end_row":140,"virt_text_pos":"overlay","right_gravity":true,"end_right_gravity":false,"virt_text":[["17","OpencodeDiffGutter"],[" ","OpencodeDiffGutter"],[" ","OpencodeDiffGutter"]]}],[42,139,0,{"ns_id":3,"virt_text_win_col":-2,"priority":4096,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text_pos":"win_col","virt_text":[["▌","OpencodePermissionBorder"]]}],[43,140,0,{"virt_text_repeat_linebreak":false,"priority":5000,"end_col":0,"ns_id":3,"virt_text_hide":false,"end_row":141,"virt_text_pos":"overlay","right_gravity":true,"end_right_gravity":false,"virt_text":[["18","OpencodeDiffGutter"],[" ","OpencodeDiffGutter"],[" ","OpencodeDiffGutter"]]}],[44,140,0,{"ns_id":3,"virt_text_win_col":-2,"priority":4096,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text_pos":"win_col","virt_text":[["▌","OpencodePermissionBorder"]]}],[45,141,0,{"virt_text_repeat_linebreak":false,"priority":5000,"end_col":0,"ns_id":3,"virt_text_hide":false,"end_row":142,"virt_text_pos":"overlay","right_gravity":true,"end_right_gravity":false,"virt_text":[["19","OpencodeDiffGutter"],[" ","OpencodeDiffGutter"],[" ","OpencodeDiffGutter"]]}],[46,141,0,{"ns_id":3,"virt_text_win_col":-2,"priority":4096,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text_pos":"win_col","virt_text":[["▌","OpencodePermissionBorder"]]}],[47,142,0,{"ns_id":3,"virt_text_win_col":-2,"priority":4096,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text_pos":"win_col","virt_text":[["▌","OpencodePermissionBorder"]]}],[48,143,0,{"ns_id":3,"virt_text_win_col":-2,"priority":4096,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text_pos":"win_col","virt_text":[["▌","OpencodePermissionBorder"]]}],[49,144,0,{"ns_id":3,"virt_text_win_col":-2,"priority":4096,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text_pos":"win_col","virt_text":[["▌","OpencodePermissionBorder"]]}],[50,145,0,{"ns_id":3,"right_gravity":true,"line_hl_group":"OpencodeDialogOptionHover","priority":4096}],[51,145,0,{"ns_id":3,"virt_text_win_col":-2,"priority":4096,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text_pos":"win_col","virt_text":[["▌","OpencodePermissionBorder"]]}],[52,145,2,{"ns_id":3,"priority":4096,"virt_text_hide":false,"virt_text_pos":"overlay","right_gravity":true,"virt_text_repeat_linebreak":false,"virt_text":[["› ","OpencodeDialogOptionHover"]]}],[53,146,0,{"ns_id":3,"virt_text_win_col":-2,"priority":4096,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text_pos":"win_col","virt_text":[["▌","OpencodePermissionBorder"]]}],[54,147,0,{"ns_id":3,"virt_text_win_col":-2,"priority":4096,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text_pos":"win_col","virt_text":[["▌","OpencodePermissionBorder"]]}],[55,148,0,{"ns_id":3,"virt_text_win_col":-2,"priority":4096,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text_pos":"win_col","virt_text":[["▌","OpencodePermissionBorder"]]}],[56,149,0,{"ns_id":3,"virt_text_win_col":-2,"priority":4096,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text_pos":"win_col","virt_text":[["▌","OpencodePermissionBorder"]]}]],"lines":["----","","","no, i want the extra line added when i've streamed the most recent part but i don't want it still there when i add the next part. i.e. i want an extra blank line at the end","","[`lua/opencode/ui/renderer.lua`](lua/opencode/ui/renderer.lua)","","----","","","Ah, I understand now! You want:","1. An extra blank line at the very end of the buffer when content has been streamed","2. But when **new** content is about to be added, remove that extra blank line first (so the new content doesn't have an awkward gap above it)","","This is a \"trailing spacer\" that should only exist when we're \"done\" with the current output, but gets removed before adding more.","","Here's the approach:","","## Solution","","Add tracking for whether a trailing blank line exists, and:","- **Add** a blank line after writing new content (in `_write_formatted_data`)","- **Remove** the trailing blank line before writing new content (also in `_write_formatted_data`)","","### Changes needed in  `renderer.lua`:","","1. **Add state tracking** (after line 14):"," ```lua"," M._has_trailing_line = false"," ```","","2. **Reset the flag** in `M.reset()` (after line 21):"," ```lua"," M._has_trailing_line = false"," ```","","3. **Modify `_write_formatted_data`** (around line 247-276):"," - Before writing: remove trailing line if it exists"," - After writing: add a new trailing line"," "," ```lua"," function M._write_formatted_data(formatted_data)"," local buf = state.windows.output_buf"," local start_line = output_window.get_buf_line_count()"," "," -- Remove trailing blank line before adding new content"," if M._has_trailing_line and start_line > 0 then"," output_window.set_lines({}, start_line - 1, start_line)"," start_line = start_line - 1"," M._has_trailing_line = false"," end"," "," local new_lines = formatted_data.lines"," local extmarks = formatted_data.extmarks"," "," if #new_lines == 0 or not buf then"," return nil"," end"," "," -- [existing action extraction code...]"," "," output_window.set_lines(new_lines, start_line)"," output_window.set_extmarks(extmarks, start_line)"," "," -- Add trailing blank line after writing"," local end_line = start_line + #new_lines"," output_window.set_lines({''}, end_line)"," M._has_trailing_line = true"," "," return {"," line_start = start_line,"," line_end = start_line + #new_lines - 1,"," }"," end"," ```","","This way:","- ✅ Extra blank line appears after streaming content","- ✅ Gets removed automatically before next content is added","- ✅ No gap between messages","- ✅ Always has visual spacing at the bottom","","----","","","but i'd only want to do that for things added to the end","","----","","","Ah yes, you're right! The trailing blank line should only be added/removed when writing to the **end** of the buffer, not when doing replacements in the middle (like `_replace_part_in_buffer`).","","Let me trace through the code:","","1. **`_write_formatted_data`** - writes at end (line 247-276)","2. **`_insert_part_to_buffer`** - calls `_write_formatted_data`, so writes at end (line 282-300)","3. **`_replace_part_in_buffer`** - replaces in middle of buffer (line 307-353)","","So the logic should be:","- In **`_write_formatted_data`**: Remove trailing line before writing (if exists), then add it back after","- In **`_replace_part_in_buffer`**: Don't touch the trailing line at all","- In **`render_output`**: This sets the entire buffer, so no trailing line management needed","","The key insight is that `_write_formatted_data` is specifically for appending to the end (line 249: `start_line = output_window.get_buf_line_count()`), so that's the only place where we need to:","1. Remove the trailing blank line first (if it exists)","2. Write the new content","3. Add a new trailing blank line","","Does that match what you want? The trailing line only exists at the very end, and only gets managed when appending new content to the end.","","----","","","yes","","----","","","** edit** `/Users/cam/Dev/neovim-dev/opencode.nvim/lua/opencode/ui/renderer.lua`","","** edit** `/Users/cam/Dev/neovim-dev/opencode.nvim/lua/opencode/ui/renderer.lua`","","** edit** `/Users/cam/Dev/neovim-dev/opencode.nvim/lua/opencode/ui/renderer.lua`","","----","",""," Permission Required (1/3)",""," *edit* `Edit this file: /Users/cam/Dev/neovim-dev/opencode.nvim/lua/opencode/ui/renderer.lua`","","","`````"," M._part_cache = {}"," M._prev_line_count = 0"," M._message_map = MessageMap.new()"," M._actions = {}"," M._has_trailing_line = false"," "," ---Reset renderer state"," function M.reset()"," M._part_cache = {}","","`````",""," 1. Allow once "," 2. Reject"," 3. Allow always","","Navigate: `j`/`k` or `↑`/`↓` Select: `` or `1-3`","",""],"timestamp":1773947569,"actions":[]} \ No newline at end of file +{ + "actions": [], + "extmarks": [ + [ + 1, + 1, + 0, + { + "virt_text": [ + ["▌󰭻 ", "OpencodeMessageRoleUser"], + [" "], + ["USER", "OpencodeMessageRoleUser"], + ["", "OpencodeHint"], + [" [msg_9efb39d68001J2h30a50B2774b]", "OpencodeHint"] + ], + "priority": 10, + "ns_id": 3, + "virt_text_hide": false, + "virt_text_repeat_linebreak": false, + "virt_text_pos": "win_col", + "virt_text_win_col": -3, + "right_gravity": true + } + ], + [ + 2, + 1, + 0, + { + "virt_text": [[" 2025-10-17 01:05:49", "OpencodeHint"]], + "ns_id": 3, + "virt_text_hide": false, + "right_gravity": true, + "priority": 9, + "virt_text_pos": "right_align", + "virt_text_repeat_linebreak": false + } + ], + [ + 3, + 2, + 0, + { + "virt_text": [["▌", "OpencodeMessageRoleUser"]], + "priority": 4096, + "ns_id": 3, + "virt_text_hide": false, + "virt_text_repeat_linebreak": true, + "virt_text_pos": "win_col", + "virt_text_win_col": -3, + "right_gravity": true + } + ], + [ + 4, + 3, + 0, + { + "virt_text": [["▌", "OpencodeMessageRoleUser"]], + "priority": 4096, + "ns_id": 3, + "virt_text_hide": false, + "virt_text_repeat_linebreak": true, + "virt_text_pos": "win_col", + "virt_text_win_col": -3, + "right_gravity": true + } + ], + [ + 5, + 4, + 0, + { + "virt_text": [["▌", "OpencodeMessageRoleUser"]], + "priority": 4096, + "ns_id": 3, + "virt_text_hide": false, + "virt_text_repeat_linebreak": true, + "virt_text_pos": "win_col", + "virt_text_win_col": -3, + "right_gravity": true + } + ], + [ + 6, + 5, + 0, + { + "virt_text": [["▌", "OpencodeMessageRoleUser"]], + "priority": 4096, + "ns_id": 3, + "virt_text_hide": false, + "virt_text_repeat_linebreak": true, + "virt_text_pos": "win_col", + "virt_text_win_col": -3, + "right_gravity": true + } + ], + [ + 7, + 8, + 0, + { + "virt_text": [ + [" ", "OpencodeMessageRoleAssistant"], + [" "], + ["PLAN", "OpencodeMessageRoleAssistant"], + [" claude-sonnet-4.5", "OpencodeHint"], + [" [msg_9efb39dc3002f81rMRqF2WO1UU]", "OpencodeHint"] + ], + "priority": 10, + "ns_id": 3, + "virt_text_hide": false, + "virt_text_repeat_linebreak": false, + "virt_text_pos": "win_col", + "virt_text_win_col": -3, + "right_gravity": true + } + ], + [ + 8, + 8, + 0, + { + "virt_text": [[" 2025-10-17 01:05:50", "OpencodeHint"]], + "ns_id": 3, + "virt_text_hide": false, + "right_gravity": true, + "priority": 9, + "virt_text_pos": "right_align", + "virt_text_repeat_linebreak": false + } + ], + [ + 9, + 83, + 0, + { + "virt_text": [ + ["▌󰭻 ", "OpencodeMessageRoleUser"], + [" "], + ["USER", "OpencodeMessageRoleUser"], + ["", "OpencodeHint"], + [" [msg_9efb50a0b001WFK7AMDV45cF8Z]", "OpencodeHint"] + ], + "priority": 10, + "ns_id": 3, + "virt_text_hide": false, + "virt_text_repeat_linebreak": false, + "virt_text_pos": "win_col", + "virt_text_win_col": -3, + "right_gravity": true + } + ], + [ + 10, + 83, + 0, + { + "virt_text": [[" 2025-10-17 01:07:23", "OpencodeHint"]], + "ns_id": 3, + "virt_text_hide": false, + "right_gravity": true, + "priority": 9, + "virt_text_pos": "right_align", + "virt_text_repeat_linebreak": false + } + ], + [ + 11, + 84, + 0, + { + "virt_text": [["▌", "OpencodeMessageRoleUser"]], + "priority": 4096, + "ns_id": 3, + "virt_text_hide": false, + "virt_text_repeat_linebreak": true, + "virt_text_pos": "win_col", + "virt_text_win_col": -3, + "right_gravity": true + } + ], + [ + 12, + 85, + 0, + { + "virt_text": [["▌", "OpencodeMessageRoleUser"]], + "priority": 4096, + "ns_id": 3, + "virt_text_hide": false, + "virt_text_repeat_linebreak": true, + "virt_text_pos": "win_col", + "virt_text_win_col": -3, + "right_gravity": true + } + ], + [ + 13, + 88, + 0, + { + "virt_text": [ + [" ", "OpencodeMessageRoleAssistant"], + [" "], + ["PLAN", "OpencodeMessageRoleAssistant"], + [" claude-sonnet-4.5", "OpencodeHint"], + [" [msg_9efb50a2a002dzMgbQnasd86o1]", "OpencodeHint"] + ], + "priority": 10, + "ns_id": 3, + "virt_text_hide": false, + "virt_text_repeat_linebreak": false, + "virt_text_pos": "win_col", + "virt_text_win_col": -3, + "right_gravity": true + } + ], + [ + 14, + 88, + 0, + { + "virt_text": [[" 2025-10-17 01:07:23", "OpencodeHint"]], + "ns_id": 3, + "virt_text_hide": false, + "right_gravity": true, + "priority": 9, + "virt_text_pos": "right_align", + "virt_text_repeat_linebreak": false + } + ], + [ + 15, + 111, + 0, + { + "virt_text": [ + ["▌󰭻 ", "OpencodeMessageRoleUser"], + [" "], + ["USER", "OpencodeMessageRoleUser"], + ["", "OpencodeHint"], + [" [msg_9efb59d93001LSm9y0DS9p8cP6]", "OpencodeHint"] + ], + "priority": 10, + "ns_id": 3, + "virt_text_hide": false, + "virt_text_repeat_linebreak": false, + "virt_text_pos": "win_col", + "virt_text_win_col": -3, + "right_gravity": true + } + ], + [ + 16, + 111, + 0, + { + "virt_text": [[" 2025-10-17 01:08:01", "OpencodeHint"]], + "ns_id": 3, + "virt_text_hide": false, + "right_gravity": true, + "priority": 9, + "virt_text_pos": "right_align", + "virt_text_repeat_linebreak": false + } + ], + [ + 17, + 112, + 0, + { + "virt_text": [["▌", "OpencodeMessageRoleUser"]], + "priority": 4096, + "ns_id": 3, + "virt_text_hide": false, + "virt_text_repeat_linebreak": true, + "virt_text_pos": "win_col", + "virt_text_win_col": -3, + "right_gravity": true + } + ], + [ + 18, + 113, + 0, + { + "virt_text": [["▌", "OpencodeMessageRoleUser"]], + "priority": 4096, + "ns_id": 3, + "virt_text_hide": false, + "virt_text_repeat_linebreak": true, + "virt_text_pos": "win_col", + "virt_text_win_col": -3, + "right_gravity": true + } + ], + [ + 19, + 116, + 0, + { + "virt_text": [ + [" ", "OpencodeMessageRoleAssistant"], + [" "], + ["PLAN", "OpencodeMessageRoleAssistant"], + [" claude-sonnet-4.5", "OpencodeHint"], + [" [msg_9efb59db4002uWmyFRTjRIhIaQ]", "OpencodeHint"] + ], + "priority": 10, + "ns_id": 3, + "virt_text_hide": false, + "virt_text_repeat_linebreak": false, + "virt_text_pos": "win_col", + "virt_text_win_col": -3, + "right_gravity": true + } + ], + [ + 20, + 116, + 0, + { + "virt_text": [[" 2025-10-17 01:08:01", "OpencodeHint"]], + "ns_id": 3, + "virt_text_hide": false, + "right_gravity": true, + "priority": 9, + "virt_text_pos": "right_align", + "virt_text_repeat_linebreak": false + } + ], + [ + 21, + 125, + 0, + { + "virt_text": [ + [" ", "OpencodeMessageRoleSystem"], + [" "], + ["SYSTEM", "OpencodeMessageRoleSystem"], + ["", "OpencodeHint"], + [" [permission-display-message]", "OpencodeHint"] + ], + "priority": 10, + "ns_id": 3, + "virt_text_hide": false, + "virt_text_repeat_linebreak": false, + "virt_text_pos": "win_col", + "virt_text_win_col": -3, + "right_gravity": true + } + ], + [ + 22, + 127, + 0, + { + "right_gravity": true, + "ns_id": 3, + "line_hl_group": "OpencodePermissionTitle", + "priority": 4096 + } + ], + [ + 23, + 127, + 0, + { + "virt_text": [["▌", "OpencodePermissionBorder"]], + "priority": 4096, + "ns_id": 3, + "virt_text_hide": false, + "virt_text_repeat_linebreak": true, + "virt_text_pos": "win_col", + "virt_text_win_col": -2, + "right_gravity": true + } + ], + [ + 24, + 128, + 0, + { + "virt_text": [["▌", "OpencodePermissionBorder"]], + "priority": 4096, + "ns_id": 3, + "virt_text_hide": false, + "virt_text_repeat_linebreak": true, + "virt_text_pos": "win_col", + "virt_text_win_col": -2, + "right_gravity": true + } + ], + [ + 25, + 129, + 0, + { + "virt_text": [["▌", "OpencodePermissionBorder"]], + "priority": 4096, + "ns_id": 3, + "virt_text_hide": false, + "virt_text_repeat_linebreak": true, + "virt_text_pos": "win_col", + "virt_text_win_col": -2, + "right_gravity": true + } + ], + [ + 26, + 130, + 0, + { + "virt_text": [["▌", "OpencodePermissionBorder"]], + "priority": 4096, + "ns_id": 3, + "virt_text_hide": false, + "virt_text_repeat_linebreak": true, + "virt_text_pos": "win_col", + "virt_text_win_col": -2, + "right_gravity": true + } + ], + [ + 27, + 131, + 0, + { + "virt_text": [["▌", "OpencodePermissionBorder"]], + "priority": 4096, + "ns_id": 3, + "virt_text_hide": false, + "virt_text_repeat_linebreak": true, + "virt_text_pos": "win_col", + "virt_text_win_col": -2, + "right_gravity": true + } + ], + [ + 28, + 132, + 0, + { + "virt_text": [["▌", "OpencodePermissionBorder"]], + "priority": 4096, + "ns_id": 3, + "virt_text_hide": false, + "virt_text_repeat_linebreak": true, + "virt_text_pos": "win_col", + "virt_text_win_col": -2, + "right_gravity": true + } + ], + [ + 29, + 133, + 0, + { + "virt_text": [ + ["11", "OpencodeDiffGutter"], + [" ", "OpencodeDiffGutter"], + [" ", "OpencodeDiffGutter"] + ], + "ns_id": 3, + "virt_text_hide": false, + "virt_text_repeat_linebreak": false, + "priority": 5000, + "virt_text_pos": "overlay", + "right_gravity": true, + "end_row": 134, + "end_col": 0, + "end_right_gravity": false + } + ], + [ + 30, + 133, + 0, + { + "virt_text": [["▌", "OpencodePermissionBorder"]], + "priority": 4096, + "ns_id": 3, + "virt_text_hide": false, + "virt_text_repeat_linebreak": true, + "virt_text_pos": "win_col", + "virt_text_win_col": -2, + "right_gravity": true + } + ], + [ + 31, + 134, + 0, + { + "virt_text": [ + ["12", "OpencodeDiffGutter"], + [" ", "OpencodeDiffGutter"], + [" ", "OpencodeDiffGutter"] + ], + "ns_id": 3, + "virt_text_hide": false, + "virt_text_repeat_linebreak": false, + "priority": 5000, + "virt_text_pos": "overlay", + "right_gravity": true, + "end_row": 135, + "end_col": 0, + "end_right_gravity": false + } + ], + [ + 32, + 134, + 0, + { + "virt_text": [["▌", "OpencodePermissionBorder"]], + "priority": 4096, + "ns_id": 3, + "virt_text_hide": false, + "virt_text_repeat_linebreak": true, + "virt_text_pos": "win_col", + "virt_text_win_col": -2, + "right_gravity": true + } + ], + [ + 33, + 135, + 0, + { + "virt_text": [ + ["13", "OpencodeDiffGutter"], + [" ", "OpencodeDiffGutter"], + [" ", "OpencodeDiffGutter"] + ], + "ns_id": 3, + "virt_text_hide": false, + "virt_text_repeat_linebreak": false, + "priority": 5000, + "virt_text_pos": "overlay", + "right_gravity": true, + "end_row": 136, + "end_col": 0, + "end_right_gravity": false + } + ], + [ + 34, + 135, + 0, + { + "virt_text": [["▌", "OpencodePermissionBorder"]], + "priority": 4096, + "ns_id": 3, + "virt_text_hide": false, + "virt_text_repeat_linebreak": true, + "virt_text_pos": "win_col", + "virt_text_win_col": -2, + "right_gravity": true + } + ], + [ + 35, + 136, + 0, + { + "virt_text": [ + ["14", "OpencodeDiffGutter"], + [" ", "OpencodeDiffGutter"], + [" ", "OpencodeDiffGutter"] + ], + "ns_id": 3, + "virt_text_hide": false, + "virt_text_repeat_linebreak": false, + "priority": 5000, + "virt_text_pos": "overlay", + "right_gravity": true, + "end_row": 137, + "end_col": 0, + "end_right_gravity": false + } + ], + [ + 36, + 136, + 0, + { + "virt_text": [["▌", "OpencodePermissionBorder"]], + "priority": 4096, + "ns_id": 3, + "virt_text_hide": false, + "virt_text_repeat_linebreak": true, + "virt_text_pos": "win_col", + "virt_text_win_col": -2, + "right_gravity": true + } + ], + [ + 37, + 137, + 0, + { + "virt_text": [ + ["15", "OpencodeDiffAddGutter"], + ["+", "OpencodeDiffAddGutter"], + [" ", "OpencodeDiffAddGutter"] + ], + "ns_id": 3, + "virt_text_hide": false, + "hl_group": "OpencodeDiffAdd", + "virt_text_pos": "overlay", + "end_right_gravity": false, + "priority": 5000, + "virt_text_repeat_linebreak": false, + "right_gravity": true, + "end_row": 138, + "end_col": 0, + "hl_eol": true + } + ], + [ + 38, + 137, + 0, + { + "virt_text": [["▌", "OpencodePermissionBorder"]], + "priority": 4096, + "ns_id": 3, + "virt_text_hide": false, + "virt_text_repeat_linebreak": true, + "virt_text_pos": "win_col", + "virt_text_win_col": -2, + "right_gravity": true + } + ], + [ + 39, + 138, + 0, + { + "virt_text": [ + ["16", "OpencodeDiffGutter"], + [" ", "OpencodeDiffGutter"], + [" ", "OpencodeDiffGutter"] + ], + "ns_id": 3, + "virt_text_hide": false, + "virt_text_repeat_linebreak": false, + "priority": 5000, + "virt_text_pos": "overlay", + "right_gravity": true, + "end_row": 139, + "end_col": 0, + "end_right_gravity": false + } + ], + [ + 40, + 138, + 0, + { + "virt_text": [["▌", "OpencodePermissionBorder"]], + "priority": 4096, + "ns_id": 3, + "virt_text_hide": false, + "virt_text_repeat_linebreak": true, + "virt_text_pos": "win_col", + "virt_text_win_col": -2, + "right_gravity": true + } + ], + [ + 41, + 139, + 0, + { + "virt_text": [ + ["17", "OpencodeDiffGutter"], + [" ", "OpencodeDiffGutter"], + [" ", "OpencodeDiffGutter"] + ], + "ns_id": 3, + "virt_text_hide": false, + "virt_text_repeat_linebreak": false, + "priority": 5000, + "virt_text_pos": "overlay", + "right_gravity": true, + "end_row": 140, + "end_col": 0, + "end_right_gravity": false + } + ], + [ + 42, + 139, + 0, + { + "virt_text": [["▌", "OpencodePermissionBorder"]], + "priority": 4096, + "ns_id": 3, + "virt_text_hide": false, + "virt_text_repeat_linebreak": true, + "virt_text_pos": "win_col", + "virt_text_win_col": -2, + "right_gravity": true + } + ], + [ + 43, + 140, + 0, + { + "virt_text": [ + ["18", "OpencodeDiffGutter"], + [" ", "OpencodeDiffGutter"], + [" ", "OpencodeDiffGutter"] + ], + "ns_id": 3, + "virt_text_hide": false, + "virt_text_repeat_linebreak": false, + "priority": 5000, + "virt_text_pos": "overlay", + "right_gravity": true, + "end_row": 141, + "end_col": 0, + "end_right_gravity": false + } + ], + [ + 44, + 140, + 0, + { + "virt_text": [["▌", "OpencodePermissionBorder"]], + "priority": 4096, + "ns_id": 3, + "virt_text_hide": false, + "virt_text_repeat_linebreak": true, + "virt_text_pos": "win_col", + "virt_text_win_col": -2, + "right_gravity": true + } + ], + [ + 45, + 141, + 0, + { + "virt_text": [ + ["19", "OpencodeDiffGutter"], + [" ", "OpencodeDiffGutter"], + [" ", "OpencodeDiffGutter"] + ], + "ns_id": 3, + "virt_text_hide": false, + "virt_text_repeat_linebreak": false, + "priority": 5000, + "virt_text_pos": "overlay", + "right_gravity": true, + "end_row": 142, + "end_col": 0, + "end_right_gravity": false + } + ], + [ + 46, + 141, + 0, + { + "virt_text": [["▌", "OpencodePermissionBorder"]], + "priority": 4096, + "ns_id": 3, + "virt_text_hide": false, + "virt_text_repeat_linebreak": true, + "virt_text_pos": "win_col", + "virt_text_win_col": -2, + "right_gravity": true + } + ], + [ + 47, + 142, + 0, + { + "virt_text": [["▌", "OpencodePermissionBorder"]], + "priority": 4096, + "ns_id": 3, + "virt_text_hide": false, + "virt_text_repeat_linebreak": true, + "virt_text_pos": "win_col", + "virt_text_win_col": -2, + "right_gravity": true + } + ], + [ + 48, + 143, + 0, + { + "virt_text": [["▌", "OpencodePermissionBorder"]], + "priority": 4096, + "ns_id": 3, + "virt_text_hide": false, + "virt_text_repeat_linebreak": true, + "virt_text_pos": "win_col", + "virt_text_win_col": -2, + "right_gravity": true + } + ], + [ + 49, + 144, + 0, + { + "virt_text": [["▌", "OpencodePermissionBorder"]], + "priority": 4096, + "ns_id": 3, + "virt_text_hide": false, + "virt_text_repeat_linebreak": true, + "virt_text_pos": "win_col", + "virt_text_win_col": -2, + "right_gravity": true + } + ], + [ + 50, + 145, + 0, + { + "right_gravity": true, + "ns_id": 3, + "line_hl_group": "OpencodeDialogOptionHover", + "priority": 4096 + } + ], + [ + 51, + 145, + 0, + { + "virt_text": [["▌", "OpencodePermissionBorder"]], + "priority": 4096, + "ns_id": 3, + "virt_text_hide": false, + "virt_text_repeat_linebreak": true, + "virt_text_pos": "win_col", + "virt_text_win_col": -2, + "right_gravity": true + } + ], + [ + 52, + 145, + 2, + { + "virt_text": [["› ", "OpencodeDialogOptionHover"]], + "ns_id": 3, + "virt_text_hide": false, + "right_gravity": true, + "priority": 4096, + "virt_text_pos": "overlay", + "virt_text_repeat_linebreak": false + } + ], + [ + 53, + 146, + 0, + { + "virt_text": [["▌", "OpencodePermissionBorder"]], + "priority": 4096, + "ns_id": 3, + "virt_text_hide": false, + "virt_text_repeat_linebreak": true, + "virt_text_pos": "win_col", + "virt_text_win_col": -2, + "right_gravity": true + } + ], + [ + 54, + 147, + 0, + { + "virt_text": [["▌", "OpencodePermissionBorder"]], + "priority": 4096, + "ns_id": 3, + "virt_text_hide": false, + "virt_text_repeat_linebreak": true, + "virt_text_pos": "win_col", + "virt_text_win_col": -2, + "right_gravity": true + } + ], + [ + 55, + 148, + 0, + { + "virt_text": [["▌", "OpencodePermissionBorder"]], + "priority": 4096, + "ns_id": 3, + "virt_text_hide": false, + "virt_text_repeat_linebreak": true, + "virt_text_pos": "win_col", + "virt_text_win_col": -2, + "right_gravity": true + } + ], + [ + 56, + 149, + 0, + { + "virt_text": [["▌", "OpencodePermissionBorder"]], + "priority": 4096, + "ns_id": 3, + "virt_text_hide": false, + "virt_text_repeat_linebreak": true, + "virt_text_pos": "win_col", + "virt_text_win_col": -2, + "right_gravity": true + } + ] + ], + "lines": [ + "----", + "", + "", + "no, i want the extra line added when i've streamed the most recent part but i don't want it still there when i add the next part. i.e. i want an extra blank line at the end", + "", + "[`lua/opencode/ui/renderer.lua`](lua/opencode/ui/renderer.lua)", + "", + "----", + "", + "", + "Ah, I understand now! You want:", + "1. An extra blank line at the very end of the buffer when content has been streamed", + "2. But when **new** content is about to be added, remove that extra blank line first (so the new content doesn't have an awkward gap above it)", + "", + "This is a \"trailing spacer\" that should only exist when we're \"done\" with the current output, but gets removed before adding more.", + "", + "Here's the approach:", + "", + "## Solution", + "", + "Add tracking for whether a trailing blank line exists, and:", + "- **Add** a blank line after writing new content (in `_write_formatted_data`)", + "- **Remove** the trailing blank line before writing new content (also in `_write_formatted_data`)", + "", + "### Changes needed in  `renderer.lua`:", + "", + "1. **Add state tracking** (after line 14):", + " ```lua", + " M._has_trailing_line = false", + " ```", + "", + "2. **Reset the flag** in `M.reset()` (after line 21):", + " ```lua", + " M._has_trailing_line = false", + " ```", + "", + "3. **Modify `_write_formatted_data`** (around line 247-276):", + " - Before writing: remove trailing line if it exists", + " - After writing: add a new trailing line", + " ", + " ```lua", + " function M._write_formatted_data(formatted_data)", + " local buf = state.windows.output_buf", + " local start_line = output_window.get_buf_line_count()", + " ", + " -- Remove trailing blank line before adding new content", + " if M._has_trailing_line and start_line > 0 then", + " output_window.set_lines({}, start_line - 1, start_line)", + " start_line = start_line - 1", + " M._has_trailing_line = false", + " end", + " ", + " local new_lines = formatted_data.lines", + " local extmarks = formatted_data.extmarks", + " ", + " if #new_lines == 0 or not buf then", + " return nil", + " end", + " ", + " -- [existing action extraction code...]", + " ", + " output_window.set_lines(new_lines, start_line)", + " output_window.set_extmarks(extmarks, start_line)", + " ", + " -- Add trailing blank line after writing", + " local end_line = start_line + #new_lines", + " output_window.set_lines({''}, end_line)", + " M._has_trailing_line = true", + " ", + " return {", + " line_start = start_line,", + " line_end = start_line + #new_lines - 1,", + " }", + " end", + " ```", + "", + "This way:", + "- ✅ Extra blank line appears after streaming content", + "- ✅ Gets removed automatically before next content is added", + "- ✅ No gap between messages", + "- ✅ Always has visual spacing at the bottom", + "", + "----", + "", + "", + "but i'd only want to do that for things added to the end", + "", + "----", + "", + "", + "Ah yes, you're right! The trailing blank line should only be added/removed when writing to the **end** of the buffer, not when doing replacements in the middle (like `_replace_part_in_buffer`).", + "", + "Let me trace through the code:", + "", + "1. **`_write_formatted_data`** - writes at end (line 247-276)", + "2. **`_insert_part_to_buffer`** - calls `_write_formatted_data`, so writes at end (line 282-300)", + "3. **`_replace_part_in_buffer`** - replaces in middle of buffer (line 307-353)", + "", + "So the logic should be:", + "- In **`_write_formatted_data`**: Remove trailing line before writing (if exists), then add it back after", + "- In **`_replace_part_in_buffer`**: Don't touch the trailing line at all", + "- In **`render_output`**: This sets the entire buffer, so no trailing line management needed", + "", + "The key insight is that `_write_formatted_data` is specifically for appending to the end (line 249: `start_line = output_window.get_buf_line_count()`), so that's the only place where we need to:", + "1. Remove the trailing blank line first (if it exists)", + "2. Write the new content", + "3. Add a new trailing blank line", + "", + "Does that match what you want? The trailing line only exists at the very end, and only gets managed when appending new content to the end.", + "", + "----", + "", + "", + "yes", + "", + "----", + "", + "", + "** edit** `/Users/cam/Dev/neovim-dev/opencode.nvim/lua/opencode/ui/renderer.lua`", + "", + "** edit** `/Users/cam/Dev/neovim-dev/opencode.nvim/lua/opencode/ui/renderer.lua`", + "", + "** edit** `/Users/cam/Dev/neovim-dev/opencode.nvim/lua/opencode/ui/renderer.lua`", + "", + "----", + "", + "", + " Permission Required (1/3)", + "", + " *edit* `Edit this file: /Users/cam/Dev/neovim-dev/opencode.nvim/lua/opencode/ui/renderer.lua`", + "", + "", + "`````", + " M._part_cache = {}", + " M._prev_line_count = 0", + " M._message_map = MessageMap.new()", + " M._actions = {}", + " M._has_trailing_line = false", + " ", + " ---Reset renderer state", + " function M.reset()", + " M._part_cache = {}", + "", + "`````", + "", + " 1. Allow once ", + " 2. Reject", + " 3. Allow always", + "", + "Navigate: `j`/`k` or `↑`/`↓` Select: `` or `1-3`", + "", + "" + ], + "timestamp": 1774895737 +} diff --git a/tests/helpers.lua b/tests/helpers.lua index 1ec1a9c6..2bb729ce 100644 --- a/tests/helpers.lua +++ b/tests/helpers.lua @@ -11,12 +11,28 @@ function M.replay_setup() local state = require('opencode.state') local ui = require('opencode.ui.ui') local renderer = require('opencode.ui.renderer') + local permission_window = require('opencode.ui.permission_window') + local question_window = require('opencode.ui.question_window') + local reference_picker = require('opencode.ui.reference_picker') local empty_promise = require('opencode.promise').new():resolve(nil) config_file.config_promise = empty_promise config_file.project_promise = empty_promise config_file.providers_promise = empty_promise + if state.windows then + ui.close_windows(state.windows) + end + + renderer.reset() + permission_window.clear_all() + question_window._clear_dialog() + question_window._current_question = nil + question_window._current_question_index = 1 + question_window._collected_answers = {} + question_window._answering = false + reference_picker.clear_all() + ---@diagnostic disable-next-line: duplicate-set-field require('opencode.session').project_id = function() return nil @@ -323,9 +339,28 @@ function M.normalize_namespace_ids(extmarks) end function M.capture_output(output_buf, namespace) + local extmarks = vim.api.nvim_buf_get_extmarks(output_buf, namespace, 0, -1, { details = true }) or {} + table.sort(extmarks, function(a, b) + if a[2] ~= b[2] then + return a[2] < b[2] + end + + if a[3] ~= b[3] then + return a[3] < b[3] + end + + local a_priority = a[4] and a[4].priority or 0 + local b_priority = b[4] and b[4].priority or 0 + if a_priority ~= b_priority then + return a_priority > b_priority + end + + return a[1] < b[1] + end) + return { lines = vim.api.nvim_buf_get_lines(output_buf, 0, -1, false) or {}, - extmarks = vim.api.nvim_buf_get_extmarks(output_buf, namespace, 0, -1, { details = true }) or {}, + extmarks = extmarks, actions = vim.deepcopy(require('opencode.ui.renderer.ctx').render_state:get_all_actions()), } end diff --git a/tests/minimal/plugin_spec.lua b/tests/minimal/plugin_spec.lua index 09e17bb0..61310368 100644 --- a/tests/minimal/plugin_spec.lua +++ b/tests/minimal/plugin_spec.lua @@ -12,7 +12,9 @@ describe('opencode.nvim plugin', function() before_each(function() original_schedule = vim.schedule - vim.schedule = function(fn) fn() end + vim.schedule = function(fn) + fn() + end -- Mock vim.system for opencode version check original_system = vim.system @@ -34,7 +36,12 @@ describe('opencode.nvim plugin', function() local server_job = require('opencode.server_job') original_ensure_server = server_job.ensure_server server_job.ensure_server = function() - return { url = 'http://localhost:9000', is_running = function() return true end } + return { + url = 'http://localhost:9000', + is_running = function() + return true + end, + } end -- Stub api_client constructor to return mock with needed methods @@ -55,7 +62,9 @@ describe('opencode.nvim plugin', function() create_message = function(_, _id, _params) return Promise.new():resolve({ id = 'm1' }) end, - abort_session = function() return Promise.new():resolve(true) end, + abort_session = function() + return Promise.new():resolve(true) + end, } end end) diff --git a/tests/replay/renderer_spec.lua b/tests/replay/renderer_spec.lua index ca2fd2e9..265ea24c 100644 --- a/tests/replay/renderer_spec.lua +++ b/tests/replay/renderer_spec.lua @@ -195,6 +195,36 @@ describe('renderer unit tests', function() render_stub:revert() end) + it('inserts a single synthetic revert message during full session render', function() + local renderer = require('opencode.ui.renderer') + + helpers.replay_setup() + + state.session.set_active({ + id = 'ses_123', + title = 'Session', + time = { created = 1, updated = 1 }, + revert = { messageID = 'msg_1', snapshot = 'a', diff = '' }, + }) + + renderer._render_full_session_data({ + { + info = { + id = 'msg_1', + role = 'assistant', + sessionID = 'ses_123', + }, + parts = {}, + }, + }) + + local revert_messages = vim.tbl_filter(function(message) + return message.info and message.info.id == '__opencode_revert_message__' + end, state.messages or {}) + + assert.are.equal(1, #revert_messages) + end) + it('ignores session.updated for non-active session IDs', function() local renderer = require('opencode.ui.renderer') @@ -280,6 +310,8 @@ describe('renderer functional tests', function() if not vim.tbl_contains(skip_full_session, name) then it('replays ' .. name .. ' correctly (session)', function() local renderer = require('opencode.ui.renderer') + local flush = require('opencode.ui.renderer.flush') + local ctx = require('opencode.ui.renderer.ctx') local events = helpers.load_test_data(filepath) state.session.set_active(helpers.get_session_from_events(events, true)) local expected = helpers.load_test_data(expected_path) @@ -287,6 +319,14 @@ describe('renderer functional tests', function() local session_data = helpers.load_session_from_events(events) renderer._render_full_session_data(session_data) + -- If bulk mode is active (async writing), wait for it to complete + -- by forcing synchronous completion + if ctx.bulk_mode then + -- Force synchronous completion by calling end_bulk_mode directly + -- This ensures all content is written before we check + flush.end_bulk_mode() + end + local actual = helpers.capture_output(state.windows and state.windows.output_buf, output_window.namespace) assert_output_matches(expected, actual, name) end) diff --git a/tests/unit/completion_lsp_spec.lua b/tests/unit/completion_lsp_spec.lua index 43b39a32..a16375c8 100644 --- a/tests/unit/completion_lsp_spec.lua +++ b/tests/unit/completion_lsp_spec.lua @@ -1019,136 +1019,136 @@ describe('opencode LSP completion', function() end) it('embeds the original item in data._opencode_item', function() - package.loaded['blink.cmp'] = nil - package.loaded['opencode.lsp.opencode_ls'] = nil - ls = require('opencode.lsp.opencode_ls') - - package.loaded['opencode.ui.completion'] = nil - local completion = require('opencode.ui.completion') - completion._sources = {} - - local original_item = { - label = 'OriginalItem', - kind = 'file', - kind_icon = '', - insert_text = 'OriginalItem', - source_name = 'data_test_source', - data = { custom = 'value' }, - } - - completion.register_source({ - name = 'data_test_source', - priority = 1, - complete = function() - return Promise.new():resolve({ original_item }) - end, - get_trigger_character = function() - return '@' - end, - }) - - vim.api.nvim_buf_get_lines = function() - return { '@test' } - end - vim.api.nvim_get_current_line = function() - return '@test' - end - vim.api.nvim_win_get_cursor = function() - return { 1, 5 } - end - - local config_obj = ls.create_config() - local server = config_obj.cmd({}, {}) - - local done = false - local callback_result = nil - server.request('textDocument/completion', { - position = { line = 0, character = 5 }, - }, function(err, result) - callback_result = result - done = true - end) - - vim.wait(200, function() - return done - end) - - assert.is_not_nil(callback_result) - assert.are.equal(1, #callback_result.items) - local lsp_item = callback_result.items[1] - assert.is_not_nil(lsp_item.data) - assert.is_not_nil(lsp_item.data._opencode_item) - assert.are.equal(original_item.label, lsp_item.data._opencode_item.label) - assert.are.equal('value', lsp_item.data._opencode_item.data.custom) - end) - - it('calculates correct textEdit range to replace typed word', function() - package.loaded['blink.cmp'] = nil - package.loaded['opencode.lsp.opencode_ls'] = nil - ls = require('opencode.lsp.opencode_ls') - - package.loaded['opencode.ui.completion'] = nil - local completion = require('opencode.ui.completion') - completion._sources = {} - - local item_for_range_test = { - label = 'tests', - kind = 'file', - kind_icon = '', - insert_text = 'tests', - source_name = 'range_test_source', - data = {}, - } - - completion.register_source({ - name = 'range_test_source', - priority = 1, - complete = function() - return Promise.new():resolve({ item_for_range_test }) - end, - get_trigger_character = function() - return '@' - end, - }) - - vim.api.nvim_buf_get_lines = function() - return { '@te' } - end - vim.api.nvim_get_current_line = function() - return '@te' - end - vim.api.nvim_win_get_cursor = function() - return { 1, 3 } - end - - local config_obj = ls.create_config() - local server = config_obj.cmd({}, {}) - - local done = false - local callback_result = nil - server.request('textDocument/completion', { - position = { line = 0, character = 3 }, - }, function(err, result) - callback_result = result - done = true - end) - - vim.wait(200, function() - return done - end) - - assert.is_not_nil(callback_result) - assert.are.equal(1, #callback_result.items) - local lsp_item = callback_result.items[1] - assert.is_not_nil(lsp_item.textEdit) - assert.is_not_nil(lsp_item.textEdit.range) - - local range = lsp_item.textEdit.range - assert.are.equal(1, range.start.character, 'start should be at trigger char position + 1') - assert.are.equal(3, range['end'].character, 'end should be at cursor position') - assert.are.equal(0, range.start.line, 'line should be 0') - assert.are.equal(0, range['end'].line, 'line should be 0') - end) + package.loaded['blink.cmp'] = nil + package.loaded['opencode.lsp.opencode_ls'] = nil + ls = require('opencode.lsp.opencode_ls') + + package.loaded['opencode.ui.completion'] = nil + local completion = require('opencode.ui.completion') + completion._sources = {} + + local original_item = { + label = 'OriginalItem', + kind = 'file', + kind_icon = '', + insert_text = 'OriginalItem', + source_name = 'data_test_source', + data = { custom = 'value' }, + } + + completion.register_source({ + name = 'data_test_source', + priority = 1, + complete = function() + return Promise.new():resolve({ original_item }) + end, + get_trigger_character = function() + return '@' + end, + }) + + vim.api.nvim_buf_get_lines = function() + return { '@test' } + end + vim.api.nvim_get_current_line = function() + return '@test' + end + vim.api.nvim_win_get_cursor = function() + return { 1, 5 } + end + + local config_obj = ls.create_config() + local server = config_obj.cmd({}, {}) + + local done = false + local callback_result = nil + server.request('textDocument/completion', { + position = { line = 0, character = 5 }, + }, function(err, result) + callback_result = result + done = true + end) + + vim.wait(200, function() + return done + end) + + assert.is_not_nil(callback_result) + assert.are.equal(1, #callback_result.items) + local lsp_item = callback_result.items[1] + assert.is_not_nil(lsp_item.data) + assert.is_not_nil(lsp_item.data._opencode_item) + assert.are.equal(original_item.label, lsp_item.data._opencode_item.label) + assert.are.equal('value', lsp_item.data._opencode_item.data.custom) + end) + + it('calculates correct textEdit range to replace typed word', function() + package.loaded['blink.cmp'] = nil + package.loaded['opencode.lsp.opencode_ls'] = nil + ls = require('opencode.lsp.opencode_ls') + + package.loaded['opencode.ui.completion'] = nil + local completion = require('opencode.ui.completion') + completion._sources = {} + + local item_for_range_test = { + label = 'tests', + kind = 'file', + kind_icon = '', + insert_text = 'tests', + source_name = 'range_test_source', + data = {}, + } + + completion.register_source({ + name = 'range_test_source', + priority = 1, + complete = function() + return Promise.new():resolve({ item_for_range_test }) + end, + get_trigger_character = function() + return '@' + end, + }) + + vim.api.nvim_buf_get_lines = function() + return { '@te' } + end + vim.api.nvim_get_current_line = function() + return '@te' + end + vim.api.nvim_win_get_cursor = function() + return { 1, 3 } + end + + local config_obj = ls.create_config() + local server = config_obj.cmd({}, {}) + + local done = false + local callback_result = nil + server.request('textDocument/completion', { + position = { line = 0, character = 3 }, + }, function(err, result) + callback_result = result + done = true + end) + + vim.wait(200, function() + return done + end) + + assert.is_not_nil(callback_result) + assert.are.equal(1, #callback_result.items) + local lsp_item = callback_result.items[1] + assert.is_not_nil(lsp_item.textEdit) + assert.is_not_nil(lsp_item.textEdit.range) + + local range = lsp_item.textEdit.range + assert.are.equal(1, range.start.character, 'start should be at trigger char position + 1') + assert.are.equal(3, range['end'].character, 'end should be at cursor position') + assert.are.equal(0, range.start.line, 'line should be 0') + assert.are.equal(0, range['end'].line, 'line should be 0') + end) end) end) end) diff --git a/tests/unit/completion_spec.lua b/tests/unit/completion_spec.lua index c23cf132..97663bc5 100644 --- a/tests/unit/completion_spec.lua +++ b/tests/unit/completion_spec.lua @@ -161,5 +161,4 @@ describe('opencode.ui.completion', function() assert.are.equal(4, #sources) end) end) - end) diff --git a/tests/unit/config_file_spec.lua b/tests/unit/config_file_spec.lua index c25ca472..40083372 100644 --- a/tests/unit/config_file_spec.lua +++ b/tests/unit/config_file_spec.lua @@ -72,7 +72,13 @@ describe('config_file.setup', function() Promise.spawn(function() state.jobs.set_api_client({ get_config = function() - return Promise.new():resolve({ agent = { ['custom'] = { mode = 'primary' }, ['build'] = { disable = true }, ['plan'] = { disable = false } } }) + 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' }) diff --git a/tests/unit/context_completion_spec.lua b/tests/unit/context_completion_spec.lua index 08ecdf20..cf8b3980 100644 --- a/tests/unit/context_completion_spec.lua +++ b/tests/unit/context_completion_spec.lua @@ -54,7 +54,12 @@ describe('context completion', function() remove_subagent = function() end, remove_selection = function() end, toggle_context = function(type) - if not vim.tbl_contains({ 'current_file', 'selection', 'diagnostics', 'cursor_data', 'buffer', 'git_diff' }, type) then + if + not vim.tbl_contains( + { 'current_file', 'selection', 'diagnostics', 'cursor_data', 'buffer', 'git_diff' }, + type + ) + then return nil end mock_state.current_context_config = mock_state.current_context_config or {} @@ -65,24 +70,24 @@ describe('context completion', function() get_context = function() return { current_file = { extension = 'lua', name = 'test.lua', path = '/test/test.lua' }, - selections = { - { + selections = { + { content = 'local x = 1', file = { extension = 'lua', name = 'test.lua' }, - lines = '1-1' - } + lines = '1-1', + }, }, mentioned_files = { '/path/to/file1.lua', '/path/to/file2.lua' }, mentioned_subagents = { 'review', 'analyze' }, linter_errors = { { severity = 1, msg = 'Test error message', pos = '1:10' }, - { severity = 2, msg = 'Test warning message', pos = '2:15' } + { severity = 2, msg = 'Test warning message', pos = '2:15' }, }, cursor_data = { line = 42, column = 10, - line_content = 'local test = "hello"' - } + line_content = 'local test = "hello"', + }, } end, context = { diff --git a/tests/unit/core_spec.lua b/tests/unit/core_spec.lua index fbda0982..37792027 100644 --- a/tests/unit/core_spec.lua +++ b/tests/unit/core_spec.lua @@ -7,6 +7,7 @@ local session = require('opencode.session') local Promise = require('opencode.promise') local stub = require('luassert.stub') local assert = require('luassert') +local flush = require('opencode.ui.renderer.flush') -- Provide a mock api_client for tests that need it local function mock_api_client() @@ -431,6 +432,101 @@ describe('opencode.core', function() end) end) + describe('_on_user_message_count_change', function() + it('flushes deferred markdown render when thinking completes', function() + local flush_stub = stub(flush, 'flush_pending_on_data_rendered') + + core._on_user_message_count_change(nil, { sess1 = 0 }, { sess1 = 1 }):wait() + + assert.stub(flush_stub).was_called() + flush_stub:revert() + end) + + it('restores a pending question after a full session render', function() + local renderer = require('opencode.ui.renderer') + local question_window = require('opencode.ui.question_window') + + state.session.set_active({ id = 'sess1' }) + state.ui.set_windows({ output_buf = 1, output_win = 2 }) + + local mounted_stub = stub(require('opencode.ui.output_window'), 'mounted').returns(true) + local fetch_stub = stub(session, 'get_messages').invokes(function() + return Promise.new():resolve({}) + end) + local render_stub = stub(renderer, '_render_full_session_data') + local list_questions_stub = stub(state.api_client, 'list_questions').invokes(function() + return Promise.new():resolve({ + { + id = 'q1', + sessionID = 'sess1', + questions = { + { + question = 'Pick one', + header = 'Test', + options = { { label = 'One', description = 'first' } }, + }, + }, + }, + }) + end) + local show_stub = stub(question_window, 'show_question') + + renderer.render_full_session():wait() + + assert.stub(show_stub).was_called() + + show_stub:revert() + list_questions_stub:revert() + render_stub:revert() + fetch_stub:revert() + mounted_stub:revert() + state.ui.set_windows(nil) + end) + end) + + describe('markdown rendering metadata', function() + it('stores the markdown namespace on the output buffer before rendering', function() + local output_window = require('opencode.ui.output_window') + local buf = vim.api.nvim_create_buf(false, true) + local win = vim.api.nvim_open_win(buf, false, { + relative = 'editor', + width = 20, + height = 5, + row = 0, + col = 0, + style = 'minimal', + }) + + state.ui.set_windows({ output_buf = buf, output_win = win }) + vim.api.nvim_buf_set_var(buf, 'opencode_markdown_namespace', 0) + + local defer_stub = stub(vim, 'defer_fn').invokes(function(cb) + cb() + return 1 + end) + local original_exists = vim.fn.exists + vim.fn.exists = function(name) + if name == ':RenderMarkdown' then + return 2 + end + return original_exists(name) + end + local cmd_stub = stub(vim, 'cmd') + + flush.trigger_on_data_rendered() + + assert.equals(output_window.markdown_namespace, vim.b[buf].opencode_markdown_namespace) + assert.stub(cmd_stub).was_called_with(':RenderMarkdown') + + cmd_stub:revert() + defer_stub:revert() + vim.fn.exists = original_exists + state.ui.set_windows(nil) + pcall(vim.api.nvim_win_close, win, true) + pcall(vim.api.nvim_buf_delete, buf, { force = true }) + end) + end) + describe('cancel', function() it('aborts running session even when ui is not visible', function() state.ui.set_windows(nil) diff --git a/tests/unit/cursor_tracking_spec.lua b/tests/unit/cursor_tracking_spec.lua index 5c276145..fde97b2e 100644 --- a/tests/unit/cursor_tracking_spec.lua +++ b/tests/unit/cursor_tracking_spec.lua @@ -18,18 +18,11 @@ describe('cursor persistence (state)', function() renderer.reset() buf = vim.api.nvim_create_buf(false, true) - vim.api.nvim_buf_set_lines(buf, 0, -1, false, { - 'line 1', - 'line 2', - 'line 3', - 'line 4', - 'line 5', - 'line 6', - 'line 7', - 'line 8', - 'line 9', - 'line 10', - }) + local lines = {} + for i = 1, 20 do + lines[i] = 'line ' .. i + end + vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) win = vim.api.nvim_open_win(buf, true, { relative = 'editor', @@ -41,7 +34,7 @@ describe('cursor persistence (state)', function() state.ui.set_windows({ output_win = win, output_buf = buf }) vim.api.nvim_set_current_win(win) - vim.api.nvim_win_set_cursor(win, { 10, 0 }) + vim.api.nvim_win_set_cursor(win, { 20, 0 }) end) after_each(function() @@ -54,18 +47,22 @@ describe('cursor persistence (state)', function() it('auto-scrolls when cursor was at previous bottom and buffer grows', function() renderer.scroll_to_bottom() - vim.api.nvim_buf_set_lines(buf, 10, 10, false, { 'line 11', 'line 12' }) + vim.api.nvim_buf_set_lines(buf, 20, 20, false, { 'line 21', 'line 22' }) renderer.scroll_to_bottom() local cursor = vim.api.nvim_win_get_cursor(win) - assert.equals(12, cursor[1]) + assert.equals(22, cursor[1]) end) - it('does not auto-scroll when user moved away from previous bottom before growth', function() + it('does not auto-scroll when user scrolled away from bottom before growth', function() renderer.scroll_to_bottom() + -- Simulate user scrolling away (moves viewport, which fires WinScrolled → sync_cursor_with_viewport) vim.api.nvim_win_set_cursor(win, { 5, 0 }) - vim.api.nvim_buf_set_lines(buf, 10, 10, false, { 'line 11', 'line 12' }) + local output_window = require('opencode.ui.output_window') + output_window.sync_cursor_with_viewport(win) + + vim.api.nvim_buf_set_lines(buf, 20, 20, false, { 'line 21', 'line 22' }) renderer.scroll_to_bottom() local cursor = vim.api.nvim_win_get_cursor(win) @@ -81,11 +78,11 @@ describe('cursor persistence (state)', function() vim.api.nvim_win_set_buf(input_win, input_buf) vim.api.nvim_set_current_win(input_win) - vim.api.nvim_buf_set_lines(buf, 10, 10, false, { 'line 11' }) + vim.api.nvim_buf_set_lines(buf, 20, 20, false, { 'line 21' }) renderer.scroll_to_bottom() local cursor = vim.api.nvim_win_get_cursor(win) - assert.equals(11, cursor[1]) + assert.equals(21, cursor[1]) pcall(vim.api.nvim_win_close, input_win, true) pcall(vim.api.nvim_buf_delete, input_buf, { force = true }) @@ -223,18 +220,28 @@ describe('output_window.is_at_bottom', function() assert.is_true(output_window.is_at_bottom(win)) end) - it('returns false when cursor is on second-to-last line', function() - vim.api.nvim_win_set_cursor(win, { 49, 0 }) + it('returns false when _was_at_bottom_by_win flag is explicitly false', function() + -- Simulate user having scrolled away: flag is set to false + output_window._was_at_bottom_by_win[win] = false assert.is_false(output_window.is_at_bottom(win)) end) - it('returns false when cursor is far from bottom', function() + it('returns false when cursor is far from bottom (viewport not showing last line)', function() vim.api.nvim_win_set_cursor(win, { 1, 0 }) assert.is_false(output_window.is_at_bottom(win)) end) - it('returns false when cursor is a few lines above bottom', function() - vim.api.nvim_win_set_cursor(win, { 45, 0 }) + it('returns false when user has scrolled viewport away from bottom', function() + -- Simulate scrolling to bottom then user scrolling away + local scroll = require('opencode.ui.renderer.scroll') + scroll.scroll_win_to_bottom(win, buf) + assert.is_true(output_window.is_at_bottom(win)) + + -- Simulate WinScrolled: user scrolls viewport up + pcall(vim.api.nvim_win_call, win, function() + vim.fn.winrestview({ topline = 1 }) + end) + output_window.sync_cursor_with_viewport(win) assert.is_false(output_window.is_at_bottom(win)) end) @@ -271,18 +278,102 @@ describe('output_window.is_at_bottom', function() pcall(vim.api.nvim_buf_delete, empty_buf, { force = true }) end) - it('cursor-based: scrolling viewport without moving cursor does NOT change result', function() - vim.api.nvim_win_set_cursor(win, { 50, 0 }) + it('viewport-based: scrolling viewport up stops auto-scroll even when cursor stays at last line', function() + -- Scroll to bottom so _was_at_bottom_by_win is set to true + local scroll = require('opencode.ui.renderer.scroll') + scroll.scroll_win_to_bottom(win, buf) assert.is_true(output_window.is_at_bottom(win)) - -- Scroll viewport up via winrestview, cursor stays at line 50 + -- Scroll the viewport up without touching the cursor. + -- WinScrolled fires → sync_cursor_with_viewport → _was_at_bottom_by_win = false pcall(vim.api.nvim_win_call, win, function() vim.fn.winrestview({ topline = 1 }) end) + output_window.sync_cursor_with_viewport(win) - -- Cursor is still at 50, so is_at_bottom should still be true - -- This is the key behavioral difference from viewport-based check - assert.is_true(output_window.is_at_bottom(win)) + -- Even though cursor is still at line 50, viewport has scrolled away + assert.is_false(output_window.is_at_bottom(win)) + end) + + it('reports the actual visible bottom line in wrapped windows', function() + local long_line = string.rep('x', 180) + + vim.api.nvim_win_set_width(win, 20) + vim.api.nvim_set_option_value('wrap', true, { win = win, scope = 'local' }) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { 'line 1', 'line 2', long_line, 'line 4', 'line 5' }) + vim.api.nvim_win_set_cursor(win, { 5, 0 }) + pcall(vim.api.nvim_win_call, win, function() + vim.fn.winrestview({ topline = 1 }) + end) + + local visible_bottom = output_window.get_visible_bottom_line(win) + -- With topline=1, height=10, wrap=true, width=20: + -- line 1 (1 row), line 2 (1 row), long_line (180/20=9 rows). + -- In headless Neovim the visible bottom is line 3 (the long wrapped line) + -- or line 2 depending on the environment's redraw behaviour. + -- The important property is that it is not the last buffer line (5). + assert.is_true(visible_bottom ~= nil) + assert.is_true(visible_bottom < 5) + end) +end) + +describe('output_window.sync_cursor_with_viewport', function() + local output_window = require('opencode.ui.output_window') + local buf, win + + before_each(function() + config.setup({}) + buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { + 'line 1', + 'line 2', + string.rep('x', 180), + 'line 4', + 'line 5', + }) + + win = vim.api.nvim_open_win(buf, true, { + relative = 'editor', + width = 20, + height = 5, + row = 0, + col = 0, + }) + + vim.api.nvim_set_option_value('wrap', true, { win = win, scope = 'local' }) + state.ui.set_windows({ output_win = win, output_buf = buf }) + output_window.reset_scroll_tracking(win) + end) + + after_each(function() + output_window.reset_scroll_tracking(win) + pcall(vim.api.nvim_win_close, win, true) + pcall(vim.api.nvim_buf_delete, buf, { force = true }) + state.ui.set_windows(nil) + end) + + it('sets _was_at_bottom_by_win to false when viewport scrolls away from bottom', function() + -- Start with viewport and cursor at last line + vim.api.nvim_win_set_cursor(win, { 5, 0 }) + local scroll = require('opencode.ui.renderer.scroll') + scroll.scroll_win_to_bottom(win, buf) + assert.is_true(output_window._was_at_bottom_by_win[win]) + + -- Scroll the viewport up (simulate mouse wheel scroll) + pcall(vim.api.nvim_win_call, win, function() + vim.fn.winrestview({ topline = 1 }) + end) + output_window.sync_cursor_with_viewport(win) + + assert.is_false(output_window._was_at_bottom_by_win[win]) + end) + + it('does not move the cursor when the user is already reading earlier content', function() + vim.api.nvim_win_set_cursor(win, { 2, 0 }) + output_window.sync_cursor_with_viewport(win) + + local cursor = vim.api.nvim_win_get_cursor(win) + assert.equals(2, cursor[1]) end) end) @@ -318,12 +409,13 @@ describe('renderer.scroll_to_bottom', function() pcall(vim.api.nvim_buf_delete, buf, { force = true }) state.ui.set_windows(nil) ctx.prev_line_count = 0 - output_window.viewport_at_bottom = nil + output_window.reset_scroll_tracking(win) end) - it('does not force-scroll when user cursor is above previous bottom', function() + it('does not force-scroll when viewport has scrolled away from bottom', function() + -- cursor at line 10, viewport shows lines 1-10, buffer has 50 lines + -- _was_at_bottom_by_win is unset → fallback live check: visible_bottom(10) < 51 → false vim.api.nvim_win_set_cursor(win, { 10, 0 }) - output_window.viewport_at_bottom = true vim.api.nvim_buf_set_lines(buf, -1, -1, false, { 'line 51' }) renderer.scroll_to_bottom() diff --git a/tests/unit/dialog_spec.lua b/tests/unit/dialog_spec.lua index f853e4c2..a1f186b2 100644 --- a/tests/unit/dialog_spec.lua +++ b/tests/unit/dialog_spec.lua @@ -1,4 +1,5 @@ local Dialog = require('opencode.ui.dialog') +local Output = require('opencode.ui.output') local state = require('opencode.state') local config = require('opencode.config') @@ -291,3 +292,36 @@ describe('Dialog', function() end) end) end) + +describe('Dialog formatting', function() + it('places selection extmarks on the selected option line', function() + local dialog = Dialog.new({ + buffer = 0, + on_select = function() end, + get_option_count = function() + return 3 + end, + }) + + dialog:set_selection(2) + + local output = Output.new() + dialog:format_options(output, { + { label = 'First' }, + { label = 'Second' }, + { label = 'Third' }, + }) + + assert.are.same({ + ' 1. First', + ' 2. Second ', + ' 3. Third', + }, output:get_lines()) + + assert.is_nil(output.extmarks[0]) + assert.is_not_nil(output.extmarks[1]) + assert.is_nil(output.extmarks[2]) + assert.are.equal('OpencodeDialogOptionHover', output.extmarks[1][1].line_hl_group) + assert.are.same({ { '› ', 'OpencodeDialogOptionHover' } }, output.extmarks[1][2].virt_text) + end) +end) diff --git a/tests/unit/formatter_spec.lua b/tests/unit/formatter_spec.lua index fdebc62a..1f49a965 100644 --- a/tests/unit/formatter_spec.lua +++ b/tests/unit/formatter_spec.lua @@ -2,6 +2,7 @@ local assert = require('luassert') local config = require('opencode.config') local formatter = require('opencode.ui.formatter') local Output = require('opencode.ui.output') +local state = require('opencode.state') describe('formatter', function() before_each(function() @@ -199,4 +200,164 @@ describe('formatter', function() assert.are.equal('+', add_mark.virt_text[2][1]) assert.are.equal('OpencodeDiffAddGutter', add_mark.virt_text[1][2]) end) + + it('formats grep tools when streamed input contains vim.NIL placeholders', function() + local message = { + info = { + id = 'msg_1', + role = 'assistant', + sessionID = 'ses_1', + }, + parts = {}, + } + + local part = { + id = 'prt_grep_1', + type = 'tool', + tool = 'grep', + messageID = 'msg_1', + sessionID = 'ses_1', + state = { + status = 'completed', + input = { + path = vim.NIL, + include = '*.lua', + pattern = 'eventignore', + }, + metadata = { + matches = 3, + }, + time = { + start = 1, + ['end'] = 2, + }, + }, + } + + local output = formatter.format_part(part, message, true) + + assert.are.equal('** grep** `*.lua eventignore` 1s', output.lines[1]) + assert.are.equal('Found `3` matches', output.lines[2]) + end) + + it('anchors snapshot actions to the snapshot and restore lines', function() + local snapshot = require('opencode.snapshot') + local original_get_restore_points_by_parent = snapshot.get_restore_points_by_parent + + snapshot.get_restore_points_by_parent = function(hash) + if hash == 'abcdef123456' then + return { + { + id = 'restore123456', + created_at = 1, + }, + } + end + return {} + end + + local message = { + info = { + id = 'msg_1', + role = 'assistant', + sessionID = 'ses_1', + }, + parts = {}, + } + + local part = { + id = 'prt_patch_1', + type = 'patch', + hash = 'abcdef123456', + messageID = 'msg_1', + sessionID = 'ses_1', + } + + local output = formatter.format_part(part, message, true) + + snapshot.get_restore_points_by_parent = original_get_restore_points_by_parent + + assert.are.same({ 0, 0, 0, 1, 1 }, vim.tbl_map(function(action) + return action.display_line + end, output.actions)) + end) + + it('falls back to current mode for assistant messages without a stamped mode', function() + state.model.set_mode('build') + local output = formatter.format_message_header({ + info = { + id = 'msg_current', + role = 'assistant', + sessionID = 'ses_1', + }, + parts = {}, + }) + + assert.are.equal('BUILD', output.extmarks[1][1].virt_text[3][1]) + end) + + it('anchors task child-session action to the rendered task block', function() + local message = { + info = { + id = 'msg_1', + role = 'assistant', + sessionID = 'ses_1', + }, + parts = {}, + } + + local part = { + id = 'prt_task_1', + type = 'tool', + tool = 'task', + messageID = 'msg_1', + sessionID = 'ses_1', + state = { + status = 'completed', + input = { + description = 'review changes', + subagent_type = 'explore', + }, + metadata = { + sessionId = 'ses_child', + }, + time = { + start = 1, + ['end'] = 2, + }, + }, + } + + local child_parts = { + { + id = 'prt_child_1', + type = 'tool', + tool = 'read', + messageID = 'msg_child_1', + sessionID = 'ses_child', + state = { + status = 'completed', + input = { + filePath = '/tmp/project', + }, + }, + }, + } + + local output = formatter.format_part(part, message, true, function(session_id) + if session_id == 'ses_child' then + return child_parts + end + return nil + end) + + assert.are.same({ + text = '[S]elect Child Session', + type = 'select_child_session', + args = {}, + key = 'S', + display_line = 1, + range = { from = 2, to = 5 }, + }, output.actions[1]) + end) end) diff --git a/tests/unit/id_spec.lua b/tests/unit/id_spec.lua index 51f551ce..06a42c7f 100644 --- a/tests/unit/id_spec.lua +++ b/tests/unit/id_spec.lua @@ -66,4 +66,3 @@ describe('ID module', function() assert.is_true(#session_id >= 20) -- At least prefix + some content end) end) - diff --git a/tests/unit/loading_animation_spec.lua b/tests/unit/loading_animation_spec.lua index a3a68e38..eae65762 100644 --- a/tests/unit/loading_animation_spec.lua +++ b/tests/unit/loading_animation_spec.lua @@ -44,7 +44,7 @@ describe('loading_animation status text', function() end) it('ignores status updates for non-active sessions', function() - state.session.set_active({ id = "ses_active" }) + state.session.set_active({ id = 'ses_active' }) loading_animation._animation.status_data = nil loading_animation.on_session_status({ diff --git a/tests/unit/output_window_spec.lua b/tests/unit/output_window_spec.lua index d4a464d9..fcdd8434 100644 --- a/tests/unit/output_window_spec.lua +++ b/tests/unit/output_window_spec.lua @@ -1,5 +1,8 @@ local config = require('opencode.config') +local state = require('opencode.state') local output_window = require('opencode.ui.output_window') +local flush = require('opencode.ui.renderer.flush') +local stub = require('luassert.stub') describe('output_window.create_buf', function() local original_config @@ -40,3 +43,252 @@ describe('output_window.create_buf', function() pcall(vim.api.nvim_buf_delete, buf, { force = true }) end) end) + +describe('output_window.highlight_changed_lines', function() + local original_config + local buf + local defer_stub + local scheduled_cb + + before_each(function() + original_config = vim.deepcopy(config.values) + config.values = vim.deepcopy(config.defaults) + buf = vim.api.nvim_create_buf(false, true) + state.ui.set_windows({ output_buf = buf }) + vim.api.nvim_buf_clear_namespace(buf, output_window.debug_namespace, 0, -1) + scheduled_cb = nil + defer_stub = stub(vim, 'defer_fn').invokes(function(cb) + scheduled_cb = cb + return 1 + end) + end) + + after_each(function() + if defer_stub then + defer_stub:revert() + end + state.ui.set_windows(nil) + pcall(vim.api.nvim_buf_delete, buf, { force = true }) + config.values = original_config + end) + + it('adds and clears debug line highlights when enabled', function() + config.setup({ + debug = { + highlight_changed_lines = true, + highlight_changed_lines_timeout_ms = 500, + }, + }) + + output_window.highlight_changed_lines(0, 1) + + local marks = vim.api.nvim_buf_get_extmarks(buf, output_window.debug_namespace, 0, -1, { details = true }) + assert.equals(2, #marks) + assert.equals('OpencodeChangedLines', marks[1][4].line_hl_group) + assert.is_function(scheduled_cb) + + scheduled_cb() + + local cleared = vim.api.nvim_buf_get_extmarks(buf, output_window.debug_namespace, 0, -1, {}) + assert.equals(0, #cleared) + end) + + it('does nothing when debug highlights are disabled', function() + config.setup({ + debug = { + highlight_changed_lines = false, + }, + }) + + output_window.highlight_changed_lines(0, 1) + + local marks = vim.api.nvim_buf_get_extmarks(buf, output_window.debug_namespace, 0, -1, {}) + assert.equals(0, #marks) + end) +end) + +describe('output_window namespaces', function() + it('exposes a dedicated markdown namespace', function() + assert.is_number(output_window.markdown_namespace) + assert.is_not.equals(output_window.namespace, output_window.markdown_namespace) + assert.is_not.equals(output_window.debug_namespace, output_window.markdown_namespace) + end) +end) + +describe('output_window.setup', function() + local buf + local win + + before_each(function() + buf = vim.api.nvim_create_buf(false, true) + win = vim.api.nvim_open_win(buf, true, { + relative = 'editor', + width = 80, + height = 10, + row = 0, + col = 0, + }) + state.ui.set_windows({ output_buf = buf, output_win = win }) + end) + + after_each(function() + state.ui.set_windows(nil) + pcall(vim.api.nvim_win_close, win, true) + pcall(vim.api.nvim_buf_delete, buf, { force = true }) + end) + + it('disables cursorline to avoid misleading dialog selection highlight', function() + output_window.setup({ output_buf = buf, output_win = win }) + + local cursorline = vim.api.nvim_get_option_value('cursorline', { win = win }) + assert.is_false(cursorline) + end) +end) + +describe('output_window extmarks', function() + local buf + + before_each(function() + buf = vim.api.nvim_create_buf(false, true) + state.ui.set_windows({ output_buf = buf }) + output_window.set_lines({ '', '' }) + end) + + after_each(function() + state.ui.set_windows(nil) + pcall(vim.api.nvim_buf_delete, buf, { force = true }) + end) + + it('applies extmarks on negative line indexes without offsetting them away', function() + output_window.set_extmarks({ + [-1] = { + { + virt_text = { { 'x', 'Normal' } }, + virt_text_pos = 'overlay', + }, + }, + [0] = { + { + virt_text = { { 'y', 'Normal' } }, + virt_text_pos = 'overlay', + }, + }, + }, 1) + + local marks = vim.api.nvim_buf_get_extmarks(buf, output_window.namespace, 0, -1, { details = true }) + assert.equals(2, #marks) + assert.equals(0, marks[1][2]) + assert.equals(1, marks[2][2]) + end) +end) + +describe('renderer flush cleanup', function() + local buf + local win + local original_eventignore + local original_eventignorewin + local has_eventignorewin + local begin_update_stub + local end_update_stub + local set_lines_stub + local set_extmarks_stub + + before_each(function() + buf = vim.api.nvim_create_buf(false, true) + win = vim.api.nvim_open_win(buf, true, { + relative = 'editor', + width = 80, + height = 10, + row = 0, + col = 0, + }) + state.ui.set_windows({ output_buf = buf, output_win = win }) + original_eventignore = vim.o.eventignore + -- 'eventignorewin' may not exist on older/newer Neovim versions; probe safely + local ok, val = pcall(vim.api.nvim_get_option_value, 'eventignorewin', { win = win }) + has_eventignorewin = ok + original_eventignorewin = ok and val or nil + begin_update_stub = stub(output_window, 'begin_update').returns(true) + end_update_stub = stub(output_window, 'end_update') + set_lines_stub = stub(output_window, 'set_lines').invokes(function() + error('boom') + end) + set_extmarks_stub = stub(output_window, 'set_extmarks') + end) + + after_each(function() + if set_extmarks_stub then + set_extmarks_stub:revert() + end + if set_lines_stub then + set_lines_stub:revert() + end + if end_update_stub then + end_update_stub:revert() + end + if begin_update_stub then + begin_update_stub:revert() + end + vim.o.eventignore = original_eventignore + if win and vim.api.nvim_win_is_valid(win) and has_eventignorewin then + -- only restore if the option exists on this Neovim build + vim.api.nvim_set_option_value('eventignorewin', original_eventignorewin, { win = win, scope = 'local' }) + end + state.ui.set_windows(nil) + pcall(vim.api.nvim_win_close, win, true) + pcall(vim.api.nvim_buf_delete, buf, { force = true }) + end) + + it('restores output window eventignorewin and ends updates when bulk writes fail', function() + flush.begin_bulk_mode() + local ctx = require('opencode.ui.renderer.ctx') + ctx.bulk_buffer_lines = { 'line 1' } + + local ok, err = pcall(flush.end_bulk_mode) + + assert.is_false(ok) + assert.matches('boom', err) + assert.equals(original_eventignore, vim.o.eventignore) + if has_eventignorewin then + assert.equals(original_eventignorewin, vim.api.nvim_get_option_value('eventignorewin', { win = win })) + end + assert.stub(begin_update_stub).was_called(1) + assert.stub(end_update_stub).was_called(1) + assert.is_false(ctx.bulk_mode) + assert.stub(set_extmarks_stub).was_not_called() + end) +end) + +describe('renderer bulk flush extmarks', function() + local buf + + before_each(function() + buf = vim.api.nvim_create_buf(false, true) + state.ui.set_windows({ output_buf = buf }) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { 'old header', 'old body', '' }) + vim.api.nvim_buf_set_extmark(buf, output_window.namespace, 2, 0, { + virt_text = { { 'OLD', 'Normal' } }, + virt_text_pos = 'overlay', + }) + end) + + after_each(function() + local ctx = require('opencode.ui.renderer.ctx') + ctx:reset() + state.ui.set_windows(nil) + pcall(vim.api.nvim_buf_delete, buf, { force = true }) + end) + + it('clears stale extmarks before replaying bulk extmarks', function() + local ctx = require('opencode.ui.renderer.ctx') + + flush.begin_bulk_mode() + ctx.bulk_buffer_lines = { 'new header' } + + flush.end_bulk_mode() + + local marks = vim.api.nvim_buf_get_extmarks(buf, output_window.namespace, 0, -1, { details = true }) + assert.equals(0, #marks) + assert.same({ 'new header', '' }, vim.api.nvim_buf_get_lines(buf, 0, -1, false)) + end) +end) diff --git a/tests/unit/permission_integration_spec.lua b/tests/unit/permission_integration_spec.lua index f3745852..eeab4c94 100644 --- a/tests/unit/permission_integration_spec.lua +++ b/tests/unit/permission_integration_spec.lua @@ -2,6 +2,9 @@ local state = require('opencode.state') local permission_window = require('opencode.ui.permission_window') local events = require('opencode.ui.renderer.events') local ctx = require('opencode.ui.renderer.ctx') +local output_window = require('opencode.ui.output_window') +local flush = require('opencode.ui.renderer.flush') +local helpers = require('tests.helpers') describe('permission_integration', function() local mock_update_permission_from_part @@ -402,3 +405,104 @@ describe('permission_integration', function() end) end) end) + +describe('permission and question display ordering', function() + before_each(function() + helpers.replay_setup() + state.session.set_active({ id = 'session_123' }) + end) + + after_each(function() + if state.windows then + require('opencode.ui.ui').close_windows(state.windows) + end + end) + + it('keeps the permission display pinned below later messages', function() + events.on_message_updated({ + info = { + id = 'msg_user', + sessionID = 'session_123', + role = 'user', + }, + }) + events.on_part_updated({ + part = { + id = 'part_user', + messageID = 'msg_user', + sessionID = 'session_123', + type = 'text', + text = 'first', + }, + }) + + events.on_permission_updated({ + id = 'perm_1', + permission = 'bash', + title = 'Run command', + metadata = {}, + }) + + events.on_message_updated({ + info = { + id = 'msg_assistant', + sessionID = 'session_123', + role = 'assistant', + }, + }) + events.on_part_updated({ + part = { + id = 'part_assistant', + messageID = 'msg_assistant', + sessionID = 'session_123', + type = 'text', + text = 'later message', + }, + }) + + flush.flush() + + local actual = helpers.capture_output(state.windows.output_buf, output_window.namespace) + local permission_line = nil + local assistant_line = nil + for i, line in ipairs(actual.lines) do + if line:find('Permission Required', 1, true) then + permission_line = i + elseif line == 'later message' then + assistant_line = i + end + end + + assert.is_not_nil(permission_line) + assert.is_not_nil(assistant_line) + assert.is_true(permission_line > assistant_line) + end) +end) + +describe('permission prompt rendering', function() + before_each(function() + state.renderer.set_messages({}) + state.renderer.set_pending_permissions({}) + state.session.set_active({ id = 'session_123' }) + + permission_window._permission_queue = {} + permission_window._dialog = nil + permission_window._processing = false + + ctx.render_state:reset() + ctx.prev_line_count = 0 + end) + + it('tracks and renders permissions without message correlation metadata', function() + events.on_permission_updated({ + id = 'perm_no_meta', + permission = 'bash', + title = 'Run command', + metadata = {}, + }) + + assert.are.equal(1, #state.pending_permissions) + assert.are.equal('perm_no_meta', state.pending_permissions[1].id) + assert.are.equal(1, permission_window.get_permission_count()) + end) +end) diff --git a/tests/unit/question_window_spec.lua b/tests/unit/question_window_spec.lua new file mode 100644 index 00000000..b10ab2a0 --- /dev/null +++ b/tests/unit/question_window_spec.lua @@ -0,0 +1,38 @@ +local question_window = require('opencode.ui.question_window') +local Output = require('opencode.ui.output') + +describe('question_window', function() + after_each(function() + question_window._current_question = nil + question_window._current_question_index = 1 + question_window._collected_answers = {} + question_window._answering = false + question_window._dialog = nil + end) + + it('adds the Other option when missing', function() + local captured_opts = nil + question_window._current_question = { + id = 'q1', + questions = { + { + question = 'How should tests run?', + options = { + { label = 'On save', description = 'Run tests automatically' }, + }, + }, + }, + } + question_window._dialog = { + format_dialog = function(_, _, opts) + captured_opts = opts + end, + } + + question_window.format_display(Output.new()) + + assert.is_not_nil(captured_opts) + assert.are.equal('On save', captured_opts.options[1].label) + assert.are.equal('Other', captured_opts.options[2].label) + end) +end) diff --git a/tests/unit/render_state_spec.lua b/tests/unit/render_state_spec.lua index 9530e458..8b70898b 100644 --- a/tests/unit/render_state_spec.lua +++ b/tests/unit/render_state_spec.lua @@ -120,6 +120,21 @@ describe('RenderState', function() assert.equals('part1', render_state:get_task_part_by_child_session('child-1')) end) + + it('stores child session parts independently', function() + local part = { + id = 'child-part-1', + messageID = 'msg-child', + sessionID = 'child-1', + tool = 'question', + } + + render_state:upsert_child_session_part('child-1', part) + + local child_parts = render_state:get_child_session_parts('child-1') + assert.equals(1, #child_parts) + assert.equals('child-part-1', child_parts[1].id) + end) end) describe('get_part_at_line', function() diff --git a/tests/unit/renderer_buffer_spec.lua b/tests/unit/renderer_buffer_spec.lua new file mode 100644 index 00000000..93977ed8 --- /dev/null +++ b/tests/unit/renderer_buffer_spec.lua @@ -0,0 +1,128 @@ +local buffer = require('opencode.ui.renderer.buffer') +local ctx = require('opencode.ui.renderer.ctx') +local output_window = require('opencode.ui.output_window') +local stub = require('luassert.stub') + +local function assert_called_before(call_order, first_name, second_name) + local first_idx + local second_idx + + for idx, name in ipairs(call_order) do + if name == first_name and not first_idx then + first_idx = idx + end + if name == second_name and not second_idx then + second_idx = idx + end + end + + assert.is_truthy(first_idx, 'expected ' .. first_name .. ' to be called') + assert.is_truthy(second_idx, 'expected ' .. second_name .. ' to be called') + assert.is_true(first_idx < second_idx) +end + +describe('renderer.buffer extmarks', function() + local set_lines_stub + local clear_extmarks_stub + local set_extmarks_stub + local highlight_changed_lines_stub + local call_order + + before_each(function() + ctx:reset() + call_order = {} + set_lines_stub = stub(output_window, 'set_lines').invokes(function() + call_order[#call_order + 1] = 'set_lines' + end) + clear_extmarks_stub = stub(output_window, 'clear_extmarks').invokes(function() + call_order[#call_order + 1] = 'clear_extmarks' + end) + set_extmarks_stub = stub(output_window, 'set_extmarks') + highlight_changed_lines_stub = stub(output_window, 'highlight_changed_lines') + end) + + after_each(function() + set_lines_stub:revert() + clear_extmarks_stub:revert() + set_extmarks_stub:revert() + highlight_changed_lines_stub:revert() + ctx:reset() + end) + + it('reapplies extmarks on the first changed line when updating a part', function() + ctx.render_state:set_part({ id = 'part_1', messageID = 'msg_1', type = 'text' }, 10, 11) + + buffer.upsert_part_now('part_1', 'msg_1', { + lines = { 'alpha', 'gamma' }, + extmarks = { + [1] = { + { line_hl_group = 'OpencodeReasoningText' }, + }, + }, + actions = {}, + }, { + lines = { 'alpha', 'beta' }, + extmarks = {}, + actions = {}, + }) + + assert.stub(clear_extmarks_stub).was_called_with(11, 12) + assert.stub(set_extmarks_stub).was_called_with({ + [0] = { + { line_hl_group = 'OpencodeReasoningText' }, + }, + }, 11) + assert_called_before(call_order, 'clear_extmarks', 'set_lines') + end) + + it('reapplies extmarks at the correct line after unchanged leading lines', function() + ctx.render_state:set_part({ id = 'part_1', messageID = 'msg_1', type = 'text' }, 20, 24) + + buffer.upsert_part_now('part_1', 'msg_1', { + lines = { 'title', '', 'question', ' 1. One', ' 2. Two ' }, + extmarks = { + [4] = { + { line_hl_group = 'OpencodeDialogOptionHover' }, + }, + }, + actions = {}, + }, { + lines = { 'title', '', 'question', ' 1. One', ' 2. Two' }, + extmarks = { + [4] = { + { line_hl_group = 'OpencodeDialogOptionHover' }, + }, + }, + actions = {}, + }) + + assert.stub(clear_extmarks_stub).was_called_with(24, 25) + assert.stub(set_extmarks_stub).was_called_with({ + [0] = { + { line_hl_group = 'OpencodeDialogOptionHover' }, + }, + }, 24) + assert_called_before(call_order, 'clear_extmarks', 'set_lines') + end) + + it('clears extmarks before rewriting a message', function() + ctx.render_state:set_message({ info = { id = 'msg_1' } }, 30, 31) + + buffer.upsert_message_now('msg_1', { + lines = { 'alpha', '' }, + extmarks = {}, + actions = {}, + }, { + lines = { 'alpha', 'beta' }, + extmarks = { + [1] = { + { virt_text = { { 'OLD', 'Normal' } }, virt_text_pos = 'overlay' }, + }, + }, + actions = {}, + }) + + assert.stub(clear_extmarks_stub).was_called() + assert_called_before(call_order, 'clear_extmarks', 'set_lines') + end) +end) diff --git a/tests/unit/session_spec.lua b/tests/unit/session_spec.lua index ced1265b..ffecc6d9 100644 --- a/tests/unit/session_spec.lua +++ b/tests/unit/session_spec.lua @@ -316,7 +316,7 @@ describe('opencode.session', function() if result then assert.equal('new-8', result.id) end - + -- Restore if original_get_session then state.api_client.get_session = original_get_session diff --git a/tests/unit/zoom_spec.lua b/tests/unit/zoom_spec.lua index a6ee11ed..8105caed 100644 --- a/tests/unit/zoom_spec.lua +++ b/tests/unit/zoom_spec.lua @@ -101,7 +101,7 @@ describe('ui zoom state', function() describe('input_window.update_dimensions', function() it('does not change input window width', function() local original_width = vim.api.nvim_win_get_width(windows.input_win) - + input_window.update_dimensions(windows) local actual_width = vim.api.nvim_win_get_width(windows.input_win)