From efccfa262eceeb1a43d5749663fc6ff19321c329 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Thu, 26 Mar 2026 12:30:14 -0400 Subject: [PATCH 01/21] refactor(renderer): add batched flush, append and scroll subsystems Introduce a renderer.flush scheduler to batch dirty/removed message --- lua/opencode/ui/formatter.lua | 6 + lua/opencode/ui/renderer.lua | 60 ++-- lua/opencode/ui/renderer/append.lua | 43 +++ lua/opencode/ui/renderer/buffer.lua | 456 +++++++++++----------------- lua/opencode/ui/renderer/ctx.lua | 30 ++ lua/opencode/ui/renderer/events.lua | 86 ++++-- lua/opencode/ui/renderer/flush.lua | 295 ++++++++++++++++++ lua/opencode/ui/renderer/scroll.lua | 79 +++++ 8 files changed, 707 insertions(+), 348 deletions(-) create mode 100644 lua/opencode/ui/renderer/append.lua create mode 100644 lua/opencode/ui/renderer/flush.lua create mode 100644 lua/opencode/ui/renderer/scroll.lua diff --git a/lua/opencode/ui/formatter.lua b/lua/opencode/ui/formatter.lua index dc025960..d4daf6f9 100644 --- a/lua/opencode/ui/formatter.lua +++ b/lua/opencode/ui/formatter.lua @@ -522,6 +522,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/renderer.lua b/lua/opencode/ui/renderer.lua index fd3fd77d..c92c9478 100644 --- a/lua/opencode/ui/renderer.lua +++ b/lua/opencode/ui/renderer.lua @@ -1,12 +1,11 @@ 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 M = {} @@ -14,23 +13,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 +27,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 @@ -152,9 +134,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, + }, + }, + }, + } + + table.insert(state.messages, revert_message) + events.on_message_updated(revert_message) + events.on_part_updated({ part = revert_message.parts[1] }) end + flush.flush() + if set_mode_from_messages then set_model_and_mode_from_messages() end @@ -192,6 +197,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 @@ -218,15 +224,13 @@ function M.scroll_to_bottom(force) local prev_line_count = ctx.prev_line_count ctx.prev_line_count = line_count - trigger_on_data_rendered() + local ok_cursor, cursor = pcall(vim.api.nvim_win_get_cursor, output_win) 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)() + or (ok_cursor and cursor and cursor[1] >= prev_line_count) + or output_window.is_at_bottom(output_win) if should_scroll then local last_line = vim.api.nvim_buf_get_lines(output_buf, line_count - 1, line_count, false)[1] or '' @@ -242,8 +246,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..1793b663 100644 --- a/lua/opencode/ui/renderer/buffer.lua +++ b/lua/opencode/ui/renderer/buffer.lua @@ -1,6 +1,5 @@ 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 = {} @@ -13,95 +12,52 @@ 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 - break - end - prefix = prefix + 1 +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 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 + 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 - suffix = suffix + 1 end - return prefix, suffix -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 + if not message_index then + return output_window.get_buf_line_count() 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 ----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 - end - for _, part in ipairs(message.parts) do - if part.type == 'text' and not part.synthetic then - return part.id + 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 + 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 - return nil -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 output_window.get_buf_line_count() 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) +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 +65,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 +78,206 @@ 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 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 +local function write_at(lines, start_line, end_line) + output_window.set_lines(lines, start_line, end_line) + return { + line_start = start_line, + line_end = start_line + #lines - 1, + } +end + +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 + 1) + 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() +local function apply_part_extmarks(part_id, formatted_data, line_start, line_end) + output_window.clear_extmarks(line_start - 1, line_end + 1) - 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) + if has_extmarks(formatted_data.extmarks) then + output_window.set_extmarks(formatted_data.extmarks, line_start) end - if part_id and formatted_data.actions then - ctx.render_state:add_actions(part_id, formatted_data.actions, target_line) + 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 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 +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 - return { line_start = target_line, line_end = target_line + #new_lines - 1 } +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 ----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 formatted_data Output ----@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 +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 + +function M.upsert_message_now(message_id, formatted_data) + local cached = ctx.render_state:get_message(message_id) + if cached and cached.line_start and cached.line_end then + output_window.clear_extmarks(cached.line_start, cached.line_end + 1) + output_window.set_lines(formatted_data.lines, cached.line_start, cached.line_end + 1) + if has_extmarks(formatted_data.extmarks) then + output_window.set_extmarks(formatted_data.extmarks, cached.line_start) + end + + local old_line_end = cached.line_end + local new_line_end = cached.line_start + #formatted_data.lines - 1 + 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 + return true end - if #formatted_data.lines == 0 then + 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 + + 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 - local is_current_message = state.current_message - and state.current_message.info - and state.current_message.info.id == cached.message_id + return false +end + +function M.upsert_part_now(part_id, message_id, formatted_data) + local cached = ctx.render_state:get_part(part_id) + if cached and cached.line_start and cached.line_end then + output_window.set_lines(formatted_data.lines, cached.line_start, cached.line_end + 1) + + local new_line_end = cached.line_start + #formatted_data.lines - 1 + apply_part_actions(part_id, formatted_data, cached.line_start) - if is_current_message then - local range = M.write_formatted_data(formatted_data, part_id) - if not range then - return false + if new_line_end ~= cached.line_end then + ctx.render_state:update_part_lines(part_id, cached.line_start, new_line_end) 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 } + apply_part_extmarks(part_id, formatted_data, cached.line_start, new_line_end) 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 + local insert_at = get_part_insertion_line(part_id, message_id) + if not insert_at then return false end - local range = M.write_formatted_data(formatted_data, part_id, insertion_line) - if not range then - return false + 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) + apply_part_extmarks(part_id, formatted_data, 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 formatted_data Output ----@return boolean -function M.replace_part(part_id, formatted_data) +function M.append_part_now(part_id, extra_lines, extra_extmarks) local cached = ctx.render_state:get_part(part_id) - if not cached or not cached.line_start or not cached.line_end then + 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 - 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 - end + local insert_at = cached.line_end + 1 + output_window.set_lines(extra_lines, insert_at, insert_at) - 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 + local new_line_end = cached.line_end + #extra_lines + ctx.render_state:update_part_lines(part_id, cached.line_start, new_line_end) - if had_actions or next_has_actions then - ctx.render_state:clear_actions(part_id) + local formatted_data = ctx.formatted_parts[part_id] + if formatted_data then + apply_part_actions(part_id, formatted_data, cached.line_start) + apply_part_extmarks(part_id, formatted_data, cached.line_start, new_line_end) + elseif has_extmarks(extra_extmarks) then + output_window.set_extmarks(extra_extmarks, insert_at) 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) - end - output_window.set_lines(lines_to_write, write_start, write_end) + return true +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) +function M.write_formatted_data(formatted_data) + local new_lines = formatted_data.lines or {} + if #new_lines == 0 then + return nil 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) - end + local target_line = output_window.get_buf_line_count() + 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) - 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) - end + if has_extmarks(formatted_data.extmarks) then + output_window.set_extmarks(formatted_data.extmarks, target_line) end - ctx.last_part_formatted = { part_id = part_id, formatted_data = formatted_data } - return true + return { + line_start = target_line, + line_end = target_line + #new_lines - 1, + } end ----Remove a part and its extmarks from the buffer ----@param part_id string -function M.remove_part(part_id) +function M.remove_part_now(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 + 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_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 ----@return boolean -function M.replace_message(message_id, formatted_data) +function M.remove_message_now(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 false + ctx.render_state:remove_message(message_id) + return 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 old_line_end = cached.line_end - local new_line_end = cached.line_start + new_line_count - 1 - - 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 - - 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 - return - end - if cached.line_start == 0 and cached.line_end == 0 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_message(message_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 - return - end - - local rendered_message = ctx.render_state:get_message(cached.message_id) - if not rendered_message or not rendered_message.message then - 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 -end - return M diff --git a/lua/opencode/ui/renderer/ctx.lua b/lua/opencode/ui/renderer/ctx.lua index 7cbeddee..ea078a48 100644 --- a/lua/opencode/ui/renderer/ctx.lua +++ b/lua/opencode/ui/renderer/ctx.lua @@ -10,12 +10,42 @@ local ctx = { 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, } 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 end return ctx diff --git a/lua/opencode/ui/renderer/events.lua b/lua/opencode/ui/renderer/events.lua index e46231b1..3a702eab 100644 --- a/lua/opencode/ui/renderer/events.lua +++ b/lua/opencode/ui/renderer/events.lua @@ -1,9 +1,33 @@ 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') + +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 + +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 local function scroll(force) @@ -33,8 +57,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 +79,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 +92,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 +124,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 +165,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 +205,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 +236,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 +256,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 +302,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 @@ -333,7 +353,7 @@ function M.on_part_removed(properties) end end - buf.remove_part(part_id) + flush.queue_part_removal(part_id) end ---Handle session.updated — re-render the full session if the revert state changed @@ -429,8 +449,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..e3df3efd --- /dev/null +++ b/lua/opencode/ui/renderer/flush.lua @@ -0,0 +1,295 @@ +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 = {} + +local function enqueue_once(order, lookup, id) + if lookup[id] then + return + end + order[#order + 1] = id +end + +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 + +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 + +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 + M.schedule() +end + +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 + +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 + +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 + +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 + +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 + +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 formatted = formatter.format_message_header(message) + ctx.formatted_messages[message_id] = formatted + return formatted +end + +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 + +local function apply_message(message_id) + local formatted = format_message(message_id) + if not formatted then + return + end + buffer.upsert_message_now(message_id, formatted) +end + +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) + ) + return + end + + buffer.upsert_part_now(part_id, message_id, formatted) +end + +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) + output_window.begin_update() + + 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 + + output_window.end_update() + scroll.post_flush(scroll_snapshot, buf) + return true +end + +local function 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 + 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(trigger_on_data_rendered, config.ui.output.rendering.markdown_debounce_ms or 250) + +function M.flush() + local pending = snapshot_pending() + local applied = apply_pending(pending) + if applied 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 ok, line_count = pcall(vim.api.nvim_buf_line_count, output_buf) + if ok then + ctx.prev_line_count = line_count + end + end + M.trigger_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..7126f202 --- /dev/null +++ b/lua/opencode/ui/renderer/scroll.lua @@ -0,0 +1,79 @@ +local config = require('opencode.config') +local state = require('opencode.state') + +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 + +---@param win integer +---@param buf integer +---@return boolean +local function is_at_bottom(win, buf) + if config.ui.output.always_scroll_to_bottom then + return true + end + + local ok_count, line_count = pcall(vim.api.nvim_buf_line_count, buf) + if not ok_count or line_count == 0 then + return true + end + + local ok_view, view = pcall(vim.api.nvim_win_call, win, vim.fn.winsaveview) + if not ok_view or type(view) ~= 'table' then + return false + end + + local topline = view.topline or 1 + local botline = view.botline or topline + return botline >= line_count or topline > line_count +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 = is_at_bottom(win, buf), + } +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 + + 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(snapshot.win, { line_count, #last_line }) + vim.api.nvim_win_call(snapshot.win, function() + vim.cmd('normal! zb') + end) +end + +return M From ed7286179939bd83aa233e4fa298f89914de268d Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Thu, 26 Mar 2026 13:45:32 -0400 Subject: [PATCH 02/21] feat(renderer): incremental rendering, markdown-on-idle, and changed-line highlights --- lua/opencode/config.lua | 7 ++ lua/opencode/core.lua | 2 + lua/opencode/types.lua | 3 + lua/opencode/ui/highlight.lua | 2 + lua/opencode/ui/output_window.lua | 67 +++++++++++++++++- lua/opencode/ui/renderer/buffer.lua | 103 ++++++++++++++++++++++------ lua/opencode/ui/renderer/ctx.lua | 2 + lua/opencode/ui/renderer/flush.lua | 103 ++++++++++++++++++++++++++-- lua/opencode/ui/renderer/scroll.lua | 26 +------ tests/unit/core_spec.lua | 55 +++++++++++++++ tests/unit/output_window_spec.lua | 73 ++++++++++++++++++++ 11 files changed, 394 insertions(+), 49 deletions(-) diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index 295b20de..bc010aaf 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 = true, + 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/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/highlight.lua b/lua/opencode/ui/highlight.lua index 84e26c9a..6762792d 100644 --- a/lua/opencode/ui/highlight.lua +++ b/lua/opencode/ui/highlight.lua @@ -47,6 +47,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 +91,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..a25f8824 100644 --- a/lua/opencode/ui/output_window.lua +++ b/lua/opencode/ui/output_window.lua @@ -4,6 +4,8 @@ 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') local _update_depth = 0 local _update_buf = nil @@ -107,7 +109,12 @@ function M.is_at_bottom(win) end 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('number', false, windows.output_win, { save_original = true }) @@ -117,6 +124,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 }) @@ -188,6 +197,28 @@ function M.set_lines(lines, start_line, end_line) start_line = start_line or 0 end_line = end_line or -1 + -- Avoid rewriting unchanged lines to prevent flashing/flicker when + -- re-rendering formatted parts (e.g. markdown). Compare the target range + -- with the existing buffer lines and skip the write when identical. + local ok, existing = pcall(vim.api.nvim_buf_get_lines, buf, start_line, end_line, false) + if ok and existing then + local same = true + if #existing ~= #lines then + same = false + else + for i = 1, #lines do + if existing[i] ~= lines[i] then + same = false + break + end + end + end + + if same then + return + 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) @@ -246,6 +277,40 @@ function M.set_extmarks(extmarks, line_offset) 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 + function M.focus_output(should_stop_insert) if not M.mounted() then return diff --git a/lua/opencode/ui/renderer/buffer.lua b/lua/opencode/ui/renderer/buffer.lua index 1793b663..abf8288d 100644 --- a/lua/opencode/ui/renderer/buffer.lua +++ b/lua/opencode/ui/renderer/buffer.lua @@ -12,6 +12,59 @@ local function has_actions(actions) return type(actions) == 'table' and #actions > 0 end +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_len = i + end + + return prefix_len +end + +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 + +local function slice_extmarks(extmarks, start_line) + local slice = {} + for line_idx, marks in pairs(extmarks or {}) do + if line_idx >= start_line + 1 then + slice[line_idx - start_line] = vim.deepcopy(marks) + end + end + return slice +end + +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 + +local function apply_extmarks(previous_formatted, formatted_data, line_start, old_line_end, new_line_end) + local prefix_len = unchanged_prefix_len(previous_formatted, formatted_data) + local clear_start = line_start + prefix_len + local clear_end = math.max(old_line_end, new_line_end) + 1 + + output_window.clear_extmarks(clear_start, clear_end) + + local extmarks = slice_extmarks(formatted_data.extmarks, prefix_len) + if has_extmarks(extmarks) then + output_window.set_extmarks(extmarks, clear_start) + end +end + 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 @@ -80,6 +133,7 @@ end 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, @@ -100,13 +154,7 @@ local function apply_part_actions(part_id, formatted_data, line_start) end end -local function apply_part_extmarks(part_id, formatted_data, line_start, line_end) - output_window.clear_extmarks(line_start - 1, line_end + 1) - - if has_extmarks(formatted_data.extmarks) then - output_window.set_extmarks(formatted_data.extmarks, line_start) - end - +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) @@ -142,17 +190,19 @@ 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 -function M.upsert_message_now(message_id, formatted_data) +function M.upsert_message_now(message_id, formatted_data, previous_formatted) local cached = ctx.render_state:get_message(message_id) if cached and cached.line_start and cached.line_end then - output_window.clear_extmarks(cached.line_start, cached.line_end + 1) - output_window.set_lines(formatted_data.lines, cached.line_start, cached.line_end + 1) - if has_extmarks(formatted_data.extmarks) then - output_window.set_extmarks(formatted_data.extmarks, cached.line_start) - end - 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) + + 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) ctx.render_state:set_message(cached.message, cached.line_start, new_line_end) local delta = new_line_end - old_line_end @@ -178,10 +228,16 @@ function M.upsert_message_now(message_id, formatted_data) return false end -function M.upsert_part_now(part_id, message_id, formatted_data) +function M.upsert_part_now(part_id, message_id, formatted_data, previous_formatted) local cached = ctx.render_state:get_part(part_id) if cached and cached.line_start and cached.line_end then - output_window.set_lines(formatted_data.lines, cached.line_start, cached.line_end + 1) + 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) + + 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) @@ -189,7 +245,8 @@ function M.upsert_part_now(part_id, message_id, formatted_data) if new_line_end ~= cached.line_end then ctx.render_state:update_part_lines(part_id, cached.line_start, new_line_end) end - apply_part_extmarks(part_id, formatted_data, cached.line_start, new_line_end) + apply_extmarks(previous_formatted, formatted_data, cached.line_start, old_line_end, new_line_end) + set_part_extmark_state(part_id, formatted_data) return true end @@ -204,21 +261,26 @@ function M.upsert_part_now(part_id, message_id, formatted_data) 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) - apply_part_extmarks(part_id, formatted_data, range.line_start, range.line_end) + 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 return false end -function M.append_part_now(part_id, extra_lines, extra_extmarks) +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 insert_at = cached.line_end + 1 + local old_line_end = cached.line_end output_window.set_lines(extra_lines, insert_at, insert_at) + highlight_written_lines(insert_at, extra_lines) local new_line_end = cached.line_end + #extra_lines ctx.render_state:update_part_lines(part_id, cached.line_start, new_line_end) @@ -226,7 +288,8 @@ function M.append_part_now(part_id, extra_lines, extra_extmarks) local formatted_data = ctx.formatted_parts[part_id] if formatted_data then apply_part_actions(part_id, formatted_data, cached.line_start) - apply_part_extmarks(part_id, formatted_data, cached.line_start, new_line_end) + 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 diff --git a/lua/opencode/ui/renderer/ctx.lua b/lua/opencode/ui/renderer/ctx.lua index ea078a48..f358d4c3 100644 --- a/lua/opencode/ui/renderer/ctx.lua +++ b/lua/opencode/ui/renderer/ctx.lua @@ -26,6 +26,7 @@ local ctx = { removed_messages = {}, }, flush_scheduled = false, + markdown_render_scheduled = false, } function ctx:reset() @@ -46,6 +47,7 @@ function ctx:reset() removed_messages = {}, } self.flush_scheduled = false + self.markdown_render_scheduled = false end return ctx diff --git a/lua/opencode/ui/renderer/flush.lua b/lua/opencode/ui/renderer/flush.lua index e3df3efd..cf75c471 100644 --- a/lua/opencode/ui/renderer/flush.lua +++ b/lua/opencode/ui/renderer/flush.lua @@ -9,6 +9,25 @@ local append = require('opencode.ui.renderer.append') local M = {} +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 + local function enqueue_once(order, lookup, id) if lookup[id] then return @@ -133,7 +152,61 @@ local function format_message(message_id) return nil end + local prev = ctx.formatted_messages[message_id] local formatted = formatter.format_message_header(message) + + -- compare lines + 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 + + 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 then + return false + end + if #va ~= #vb then + return false + end + for i = 1, #va do + local am = type(va[i]) == 'function' and va[i]() or va[i] + local bm = type(vb[i]) == 'function' and vb[i]() or vb[i] + if (am.virt_text and vim.inspect(am.virt_text) or nil) ~= (bm.virt_text and vim.inspect(bm.virt_text) or nil) + or (am.virt_text_pos or nil) ~= (bm.virt_text_pos or nil) + or (am.virt_text_win_col or nil) ~= (bm.virt_text_win_col or nil) + or (am.line_hl_group or nil) ~= (bm.line_hl_group or nil) + or (am.end_row or nil) ~= (bm.end_row or nil) + then + return false + end + end + end + for k, _ in pairs(b) do + if not a[k] then + return false + end + end + return true + end + + 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 @@ -159,11 +232,12 @@ local function format_part(part_id) end 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) + buffer.upsert_message_now(message_id, formatted, previous) end local function apply_part(part_id, message_id) @@ -188,12 +262,13 @@ local function apply_part(part_id, message_id) buffer.append_part_now( part_id, append.tail_lines(previous.lines or {}, formatted.lines or {}), - append.tail_extmarks(#(previous.lines or {}), formatted.extmarks) + append.tail_extmarks(#(previous.lines or {}), formatted.extmarks), + previous ) return end - buffer.upsert_part_now(part_id, message_id, formatted) + buffer.upsert_part_now(part_id, message_id, formatted, previous) end local function apply_pending(pending) @@ -265,6 +340,7 @@ local function trigger_on_data_rendered() 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 @@ -276,6 +352,25 @@ end M.trigger_on_data_rendered = require('opencode.util').debounce(trigger_on_data_rendered, config.ui.output.rendering.markdown_debounce_ms or 250) +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 + +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 + function M.flush() local pending = snapshot_pending() local applied = apply_pending(pending) @@ -288,7 +383,7 @@ function M.flush() ctx.prev_line_count = line_count end end - M.trigger_on_data_rendered() + M.request_on_data_rendered() end end diff --git a/lua/opencode/ui/renderer/scroll.lua b/lua/opencode/ui/renderer/scroll.lua index 7126f202..f224e0f4 100644 --- a/lua/opencode/ui/renderer/scroll.lua +++ b/lua/opencode/ui/renderer/scroll.lua @@ -1,5 +1,6 @@ local config = require('opencode.config') local state = require('opencode.state') +local output_window = require('opencode.ui.output_window') local M = {} @@ -13,29 +14,6 @@ function M.get_output_win() return win end ----@param win integer ----@param buf integer ----@return boolean -local function is_at_bottom(win, buf) - if config.ui.output.always_scroll_to_bottom then - return true - end - - local ok_count, line_count = pcall(vim.api.nvim_buf_line_count, buf) - if not ok_count or line_count == 0 then - return true - end - - local ok_view, view = pcall(vim.api.nvim_win_call, win, vim.fn.winsaveview) - if not ok_view or type(view) ~= 'table' then - return false - end - - local topline = view.topline or 1 - local botline = view.botline or topline - return botline >= line_count or topline > line_count -end - ---@param buf integer|nil ---@return { win: integer, follow: boolean }|nil function M.pre_flush(buf) @@ -50,7 +28,7 @@ function M.pre_flush(buf) return { win = win, - follow = is_at_bottom(win, buf), + follow = output_window.is_at_bottom(win), } end diff --git a/tests/unit/core_spec.lua b/tests/unit/core_spec.lua index fbda0982..1bb4e463 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,60 @@ 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) + 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/output_window_spec.lua b/tests/unit/output_window_spec.lua index d4a464d9..e99a0cc0 100644 --- a/tests/unit/output_window_spec.lua +++ b/tests/unit/output_window_spec.lua @@ -1,5 +1,7 @@ local config = require('opencode.config') +local state = require('opencode.state') local output_window = require('opencode.ui.output_window') +local stub = require('luassert.stub') describe('output_window.create_buf', function() local original_config @@ -40,3 +42,74 @@ 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) From 55d1241dd8830f7eeafc6d8428e58894c2883600 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Thu, 26 Mar 2026 14:42:35 -0400 Subject: [PATCH 03/21] fix: cursor scrolling --- lua/opencode/ui/output_window.lua | 127 +++++++++++++++++++++------- tests/unit/cursor_tracking_spec.lua | 70 +++++++++++++++ 2 files changed, 167 insertions(+), 30 deletions(-) diff --git a/lua/opencode/ui/output_window.lua b/lua/opencode/ui/output_window.lua index a25f8824..3b3e0bd8 100644 --- a/lua/opencode/ui/output_window.lua +++ b/lua/opencode/ui/output_window.lua @@ -6,6 +6,8 @@ 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._viewport_cursor_tracking_by_win = {} local _update_depth = 0 local _update_buf = nil @@ -108,6 +110,97 @@ function M.is_at_bottom(win) return cursor[1] >= 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 buf = vim.api.nvim_win_get_buf(win) + if not buf or not vim.api.nvim_buf_is_valid(buf) then + return nil + end + + local line_count = vim.api.nvim_buf_line_count(buf) + if line_count == 0 then + return nil + end + + local ok_view, view = pcall(vim.api.nvim_win_call, win, vim.fn.winsaveview) + if not ok_view or type(view) ~= 'table' then + return nil + end + + local topline = math.max(1, view.topline or 1) + local remaining_height = vim.api.nvim_win_get_height(win) + for line = topline, line_count do + local ok_height, result = pcall(vim.api.nvim_win_text_height, win, { + start_row = line - 1, + end_row = line - 1, + }) + local line_height = ok_height and result and result.all or 1 + remaining_height = remaining_height - line_height + if remaining_height <= 0 then + return line + end + end + + return line_count +end + +---@param win? integer +function M.reset_scroll_tracking(win) + if win then + M._last_visible_bottom_by_win[win] = nil + M._viewport_cursor_tracking_by_win[win] = nil + return + end + + M._last_visible_bottom_by_win = {} + M._viewport_cursor_tracking_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_cursor, cursor = pcall(vim.api.nvim_win_get_cursor, win) + local ok_count, line_count = pcall(vim.api.nvim_buf_line_count, buf) + local visible_bottom = M.get_visible_bottom_line(win) + if not ok_cursor or not cursor or not ok_count or not line_count or line_count == 0 or not visible_bottom then + return + end + + local last_visible_bottom = M._last_visible_bottom_by_win[win] + local tracking = M._viewport_cursor_tracking_by_win[win] == true + local anchored_to_viewport_bottom = tracking and last_visible_bottom and cursor[1] == last_visible_bottom + + if cursor[1] > visible_bottom or (anchored_to_viewport_bottom and cursor[1] ~= visible_bottom) then + M._viewport_cursor_tracking_by_win[win] = true + pcall(vim.api.nvim_win_set_cursor, win, { math.min(visible_bottom, line_count), 0 }) + local pos = state.ui.get_window_cursor(win) + if pos then + state.ui.set_cursor_position('output', pos) + end + elseif not anchored_to_viewport_bottom then + M._viewport_cursor_tracking_by_win[win] = false + end + + M._last_visible_bottom_by_win[win] = visible_bottom +end + function M.setup(windows) window_options.set_window_option( 'winhighlight', @@ -137,6 +230,8 @@ 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 @@ -329,6 +424,7 @@ function M.close() 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 @@ -378,36 +474,7 @@ 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 diff --git a/tests/unit/cursor_tracking_spec.lua b/tests/unit/cursor_tracking_spec.lua index 5c276145..5cbd1131 100644 --- a/tests/unit/cursor_tracking_spec.lua +++ b/tests/unit/cursor_tracking_spec.lua @@ -284,6 +284,76 @@ describe('output_window.is_at_bottom', function() -- This is the key behavioral difference from viewport-based check assert.is_true(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) + assert.equals(3, visible_bottom) + 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('keeps the cursor aligned with the actual viewport bottom while scrolling', function() + vim.api.nvim_win_set_cursor(win, { 5, 0 }) + pcall(vim.api.nvim_win_call, win, function() + vim.fn.winrestview({ topline = 1 }) + end) + output_window.sync_cursor_with_viewport(win) + + local cursor = vim.api.nvim_win_get_cursor(win) + assert.equals(3, cursor[1]) + 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) describe('renderer.scroll_to_bottom', function() From 41941284c461ef0ef0d112609eed8bd25b4e5bfd Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Thu, 26 Mar 2026 14:50:24 -0400 Subject: [PATCH 04/21] fix(renderer/buffer): include first line when slicing extmarks --- lua/opencode/ui/renderer/buffer.lua | 2 +- tests/unit/renderer_buffer_spec.lua | 52 +++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 tests/unit/renderer_buffer_spec.lua diff --git a/lua/opencode/ui/renderer/buffer.lua b/lua/opencode/ui/renderer/buffer.lua index abf8288d..c9afa773 100644 --- a/lua/opencode/ui/renderer/buffer.lua +++ b/lua/opencode/ui/renderer/buffer.lua @@ -38,7 +38,7 @@ end local function slice_extmarks(extmarks, start_line) local slice = {} for line_idx, marks in pairs(extmarks or {}) do - if line_idx >= start_line + 1 then + if line_idx >= start_line then slice[line_idx - start_line] = vim.deepcopy(marks) end end diff --git a/tests/unit/renderer_buffer_spec.lua b/tests/unit/renderer_buffer_spec.lua new file mode 100644 index 00000000..77729929 --- /dev/null +++ b/tests/unit/renderer_buffer_spec.lua @@ -0,0 +1,52 @@ +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') + +describe('renderer.buffer extmarks', function() + local set_lines_stub + local clear_extmarks_stub + local set_extmarks_stub + local highlight_changed_lines_stub + + before_each(function() + ctx:reset() + set_lines_stub = stub(output_window, 'set_lines') + clear_extmarks_stub = stub(output_window, 'clear_extmarks') + 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) + end) +end) From b5325d59049ed047ae05413780141a52d1155f2b Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Fri, 27 Mar 2026 16:02:06 -0400 Subject: [PATCH 05/21] perf(renderer): suppress TextChanged during flushes, reduce deepcopy and extmark allocs - Wrap apply_pending and end_bulk_mode writes in eventignore='all' so render-markdown only fires from the explicit request_on_data_rendered call, not once per nvim_buf_set_lines during streaming or session load - set_lines noautocmd opts removed; bulk caller sets eventignore directly via begin_update/end_update, keeping the API clean - set_extmarks: skip deepcopy for marks with no start_col and no end_row offset needed (~99% of extmarks), eliminating 125k copies per session load - add_vertical_border: build extmark_opts once per call instead of per line - get_markdown_filetype: memoize vim.filetype.match results by filename - accumulate_bulk_extmarks helper deduplicates 3x copy-pasted extmark loop - Remove dead bulk_message_positions/bulk_part_positions tracking fields - Remove unused write_formatted_data export and dead append_part_now bulk branch - Hoist lines_equal/extmarks_equal out of format_message closure; replace vim.inspect comparison with vim.deep_equal --- lua/opencode/ui/formatter.lua | 147 +++++++++++++++++++++----- lua/opencode/ui/output_window.lua | 59 ++++++----- lua/opencode/ui/renderer/buffer.lua | 154 +++++++++++++++++++++++----- lua/opencode/ui/renderer/ctx.lua | 10 ++ lua/opencode/ui/renderer/events.lua | 10 +- lua/opencode/ui/renderer/flush.lua | 146 +++++++++++++++++--------- lua/opencode/util.lua | 38 +++---- 7 files changed, 423 insertions(+), 141 deletions(-) diff --git a/lua/opencode/ui/formatter.lua b/lua/opencode/ui/formatter.lua index d4daf6f9..4d72f812 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,12 +94,14 @@ function M._format_revert_message(session_data, start_idx) end end end + + output:add_empty_line() return output end 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 + line = (line or output:get_line_count()) - 2 output:add_action({ text = text, type = action_type, @@ -154,6 +155,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') @@ -167,18 +173,13 @@ function M.format_message_header(message) local display_name if role == 'assistant' then local mode = message.info.mode - if mode and mode ~= '' then - display_name = 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 + 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 display_name = 'ASSISTANT' end - end else display_name = role:upper() end @@ -291,11 +292,32 @@ 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 +381,75 @@ function M._format_diagnostics_context(output, part) M.add_vertical_border(output, start_line, end_line, 'OpencodeMessageRoleUser', -3) end +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 + +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 +541,14 @@ 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) + 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 - 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 - output:add_extmark(line - 1, extmark_opts --[[@as OutputExtmark]]) end end @@ -486,9 +572,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 +584,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 diff --git a/lua/opencode/ui/output_window.lua b/lua/opencode/ui/output_window.lua index 3b3e0bd8..f21a1d6e 100644 --- a/lua/opencode/ui/output_window.lua +++ b/lua/opencode/ui/output_window.lua @@ -292,25 +292,23 @@ function M.set_lines(lines, start_line, end_line) start_line = start_line or 0 end_line = end_line or -1 - -- Avoid rewriting unchanged lines to prevent flashing/flicker when - -- re-rendering formatted parts (e.g. markdown). Compare the target range - -- with the existing buffer lines and skip the write when identical. - local ok, existing = pcall(vim.api.nvim_buf_get_lines, buf, start_line, end_line, false) - if ok and existing then - local same = true - if #existing ~= #lines then - same = false - else + -- 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 - end - - if same then - return + if same then + return + end end end @@ -355,19 +353,34 @@ 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 diff --git a/lua/opencode/ui/renderer/buffer.lua b/lua/opencode/ui/renderer/buffer.lua index c9afa773..da069832 100644 --- a/lua/opencode/ui/renderer/buffer.lua +++ b/lua/opencode/ui/renderer/buffer.lua @@ -8,6 +8,24 @@ local function has_extmarks(extmarks) return type(extmarks) == 'table' and next(extmarks) ~= nil end +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 + local function has_actions(actions) return type(actions) == 'table' and #actions > 0 end @@ -45,6 +63,47 @@ local function slice_extmarks(extmarks, start_line) return slice end +local function resolve_mark(mark) + return type(mark) == 'function' and mark() or mark +end + +local function marks_equal(a, b) + a = a or {} + b = b or {} + + if #a ~= #b then + return false + end + + for i = 1, #a do + if not vim.deep_equal(resolve_mark(a[i]), resolve_mark(b[i])) then + return false + end + end + + return true +end + +local function unchanged_extmark_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 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 + local function highlight_written_lines(start_line, lines) if #lines == 0 then return @@ -53,7 +112,10 @@ local function highlight_written_lines(start_line, lines) end local function apply_extmarks(previous_formatted, formatted_data, line_start, old_line_end, new_line_end) - local prefix_len = unchanged_prefix_len(previous_formatted, formatted_data) + local prefix_len = math.min( + unchanged_prefix_len(previous_formatted, formatted_data), + unchanged_extmark_prefix_len(previous_formatted, formatted_data) + ) local clear_start = line_start + prefix_len local clear_end = math.max(old_line_end, new_line_end) + 1 @@ -71,6 +133,19 @@ local function get_message_insert_line(message_id) 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 @@ -81,7 +156,7 @@ local function get_message_insert_line(message_id) end if not message_index then - return output_window.get_buf_line_count() + return append_at end for i = message_index + 1, #messages do @@ -94,7 +169,7 @@ local function get_message_insert_line(message_id) end end - return output_window.get_buf_line_count() + return append_at end local function get_part_insertion_line(part_id, message_id) @@ -191,6 +266,25 @@ function M.find_part_by_call_id(call_id, message_id) 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 + + return true + end + 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 @@ -229,6 +323,26 @@ function M.upsert_message_now(message_id, formatted_data, previous_formatted) 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 + + 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 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 + + return true + end + 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 @@ -297,29 +411,14 @@ function M.append_part_now(part_id, extra_lines, extra_extmarks, previous_format return true end -function M.write_formatted_data(formatted_data) - local new_lines = formatted_data.lines or {} - if #new_lines == 0 then - return nil - end - - local target_line = output_window.get_buf_line_count() - 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) - - if has_extmarks(formatted_data.extmarks) then - output_window.set_extmarks(formatted_data.extmarks, target_line) +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 - return { - line_start = target_line, - line_end = target_line + #new_lines - 1, - } -end - -function M.remove_part_now(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 ctx.render_state:remove_part(part_id) @@ -332,6 +431,13 @@ function M.remove_part_now(part_id) end 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 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) diff --git a/lua/opencode/ui/renderer/ctx.lua b/lua/opencode/ui/renderer/ctx.lua index f358d4c3..0f097389 100644 --- a/lua/opencode/ui/renderer/ctx.lua +++ b/lua/opencode/ui/renderer/ctx.lua @@ -27,6 +27,9 @@ local ctx = { }, flush_scheduled = false, markdown_render_scheduled = false, + bulk_mode = false, + bulk_buffer_lines = {}, + bulk_extmarks_by_line = {}, } function ctx:reset() @@ -48,6 +51,13 @@ function ctx:reset() } self.flush_scheduled = false self.markdown_render_scheduled = false + self:bulk_reset() +end + +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 3a702eab..cae58015 100644 --- a/lua/opencode/ui/renderer/events.lua +++ b/lua/opencode/ui/renderer/events.lua @@ -341,8 +341,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 @@ -354,6 +355,11 @@ function M.on_part_removed(properties) end 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 diff --git a/lua/opencode/ui/renderer/flush.lua b/lua/opencode/ui/renderer/flush.lua index cf75c471..48be8788 100644 --- a/lua/opencode/ui/renderer/flush.lua +++ b/lua/opencode/ui/renderer/flush.lua @@ -9,6 +9,46 @@ local append = require('opencode.ui.renderer.append') local M = {} +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 + +local function resolve_mark(m) + return type(m) == 'function' and m() or m +end + +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 + local function is_markdown_render_deferred() if not config.ui.output.rendering.markdown_on_idle then return false @@ -66,6 +106,8 @@ function M.mark_message_dirty(message_id) 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 @@ -155,53 +197,6 @@ local function format_message(message_id) local prev = ctx.formatted_messages[message_id] local formatted = formatter.format_message_header(message) - -- compare lines - 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 - - 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 then - return false - end - if #va ~= #vb then - return false - end - for i = 1, #va do - local am = type(va[i]) == 'function' and va[i]() or va[i] - local bm = type(vb[i]) == 'function' and vb[i]() or vb[i] - if (am.virt_text and vim.inspect(am.virt_text) or nil) ~= (bm.virt_text and vim.inspect(bm.virt_text) or nil) - or (am.virt_text_pos or nil) ~= (bm.virt_text_pos or nil) - or (am.virt_text_win_col or nil) ~= (bm.virt_text_win_col or nil) - or (am.line_hl_group or nil) ~= (bm.line_hl_group or nil) - or (am.end_row or nil) ~= (bm.end_row or nil) - then - return false - end - end - end - for k, _ in pairs(b) do - if not a[k] then - return false - end - end - return true - end - if prev and lines_equal(prev.lines, formatted.lines) and extmarks_equal(prev.extmarks, formatted.extmarks) then -- no visible change return nil @@ -287,6 +282,8 @@ local function apply_pending(pending) end local scroll_snapshot = scroll.pre_flush(buf) + local saved_eventignore = vim.o.eventignore + vim.o.eventignore = 'all' output_window.begin_update() for _, part_id in ipairs(pending.removed_part_order) do @@ -328,11 +325,12 @@ local function apply_pending(pending) end output_window.end_update() + vim.o.eventignore = saved_eventignore scroll.post_flush(scroll_snapshot, buf) return true end -local function trigger_on_data_rendered() +local function do_trigger_on_data_rendered() local cb_type = type(config.ui.output.rendering.on_data_rendered) if cb_type == 'boolean' then return @@ -350,7 +348,7 @@ local function trigger_on_data_rendered() end end -M.trigger_on_data_rendered = require('opencode.util').debounce(trigger_on_data_rendered, config.ui.output.rendering.markdown_debounce_ms or 250) +M.trigger_on_data_rendered = require('opencode.util').debounce(do_trigger_on_data_rendered, config.ui.output.rendering.markdown_debounce_ms or 250) function M.request_on_data_rendered(force) if force or not is_markdown_render_deferred() then @@ -371,6 +369,52 @@ function M.flush_pending_on_data_rendered() M.trigger_on_data_rendered() end +function M.begin_bulk_mode() + ctx:bulk_reset() + ctx.bulk_mode = true +end + +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; we trigger them explicitly via vim.schedule + -- below. begin_update/end_update handles the modifiable toggle. + local saved_eventignore = vim.o.eventignore + vim.o.eventignore = 'all' + output_window.begin_update() + output_window.set_lines(lines, 0, -1) + output_window.end_update() + vim.o.eventignore = saved_eventignore + + if next(ctx.bulk_extmarks_by_line) then + output_window.set_extmarks(ctx.bulk_extmarks_by_line, 0) + end + + ctx:bulk_reset() + + vim.schedule(function() + M.request_on_data_rendered(true) + end) +end + function M.flush() local pending = snapshot_pending() local applied = apply_pending(pending) @@ -383,7 +427,9 @@ function M.flush() ctx.prev_line_count = line_count end end - M.request_on_data_rendered() + if not ctx.bulk_mode then + M.request_on_data_rendered() + end end end diff --git a/lua/opencode/util.lua b/lua/opencode/util.lua index cde1b4ee..96e3cc03 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,18 @@ 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) From d5144febf9d0e962a783cf9f3b532b0a446404f4 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Fri, 27 Mar 2026 16:09:07 -0400 Subject: [PATCH 06/21] refactor(renderer): remove prev_line_count; extract scrolling logic and simplify flush Remove unused ctx.prev_line_count from the renderer context and reset. Simplify M.flush() to only request data-rendered when changes were applied and the renderer is not in bulk_mode. Extract window-scrolling into M.scroll_win_to_bottom() and reuse it from post_flush(). tests: make replay deterministic and robust for bulk mode Sort extmarks deterministically in test helpers, clear/reset UI and renderer state during replay setup, and force synchronous completion (flush.end_bulk_mode()) in replay tests when ctx.bulk_mode is active so assertions run against final output. --- lua/opencode/ui/renderer/ctx.lua | 3 --- lua/opencode/ui/renderer/flush.lua | 14 ++--------- lua/opencode/ui/renderer/scroll.lua | 27 +++++++++++--------- tests/helpers.lua | 37 +++++++++++++++++++++++++++- tests/replay/renderer_spec.lua | 38 ++++++++++++++++++----------- 5 files changed, 78 insertions(+), 41 deletions(-) diff --git a/lua/opencode/ui/renderer/ctx.lua b/lua/opencode/ui/renderer/ctx.lua index 0f097389..19ea6a15 100644 --- a/lua/opencode/ui/renderer/ctx.lua +++ b/lua/opencode/ui/renderer/ctx.lua @@ -6,8 +6,6 @@ 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 @@ -34,7 +32,6 @@ local ctx = { 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 = {} diff --git a/lua/opencode/ui/renderer/flush.lua b/lua/opencode/ui/renderer/flush.lua index 48be8788..2ba19555 100644 --- a/lua/opencode/ui/renderer/flush.lua +++ b/lua/opencode/ui/renderer/flush.lua @@ -418,18 +418,8 @@ end function M.flush() local pending = snapshot_pending() local applied = apply_pending(pending) - if applied 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 ok, line_count = pcall(vim.api.nvim_buf_line_count, output_buf) - if ok then - ctx.prev_line_count = line_count - end - end - if not ctx.bulk_mode then - M.request_on_data_rendered() - end + if applied and not ctx.bulk_mode then + M.request_on_data_rendered() end end diff --git a/lua/opencode/ui/renderer/scroll.lua b/lua/opencode/ui/renderer/scroll.lua index f224e0f4..ed0bb8f2 100644 --- a/lua/opencode/ui/renderer/scroll.lua +++ b/lua/opencode/ui/renderer/scroll.lua @@ -14,6 +14,21 @@ function M.get_output_win() return win end +---Move the cursor in `win` to the last line of `buf` and scroll so it's visible. +---@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) +end + ---@param buf integer|nil ---@return { win: integer, follow: boolean }|nil function M.pre_flush(buf) @@ -41,17 +56,7 @@ function M.post_flush(snapshot, buf) if not vim.api.nvim_win_is_valid(snapshot.win) or vim.api.nvim_win_get_buf(snapshot.win) ~= buf then return end - - 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(snapshot.win, { line_count, #last_line }) - vim.api.nvim_win_call(snapshot.win, function() - vim.cmd('normal! zb') - end) + M.scroll_win_to_bottom(snapshot.win, buf) end return M 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/replay/renderer_spec.lua b/tests/replay/renderer_spec.lua index ca2fd2e9..fe6db602 100644 --- a/tests/replay/renderer_spec.lua +++ b/tests/replay/renderer_spec.lua @@ -277,20 +277,30 @@ describe('renderer functional tests', function() ) end - if not vim.tbl_contains(skip_full_session, name) then - it('replays ' .. name .. ' correctly (session)', function() - local renderer = require('opencode.ui.renderer') - 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) - - local session_data = helpers.load_session_from_events(events) - renderer._render_full_session_data(session_data) - - local actual = helpers.capture_output(state.windows and state.windows.output_buf, output_window.namespace) - assert_output_matches(expected, actual, name) - end) - end + 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) + + 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) + end end end end From debf63b0b0bdd203ed0115d521d35289776ed985 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Fri, 27 Mar 2026 16:10:03 -0400 Subject: [PATCH 07/21] feat(ui): refactor renderer and scrolling - dialog: add support to extend vertical border to trailing blank via config.extend_border_to_trailing_blank - renderer: wrap full-session rendering in bulk flush (begin/end) and delegate scroll-to-bottom to scroll module - output_window: simplify visible-bottom-line calculation to use vim.fn.line('w$') - ui: guard treesitter language registration with pcall to avoid errors in minimal builds --- lua/opencode/ui/dialog.lua | 6 +++++- lua/opencode/ui/output_window.lua | 33 ++----------------------------- lua/opencode/ui/renderer.lua | 28 ++++++-------------------- lua/opencode/ui/ui.lua | 10 ++++++++-- 4 files changed, 21 insertions(+), 56 deletions(-) diff --git a/lua/opencode/ui/dialog.lua b/lua/opencode/ui/dialog.lua index 2a0893db..0ab064ed 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('') diff --git a/lua/opencode/ui/output_window.lua b/lua/opencode/ui/output_window.lua index f21a1d6e..6a5541a0 100644 --- a/lua/opencode/ui/output_window.lua +++ b/lua/opencode/ui/output_window.lua @@ -117,37 +117,8 @@ function M.get_visible_bottom_line(win) if not win or not vim.api.nvim_win_is_valid(win) then return nil end - - local buf = vim.api.nvim_win_get_buf(win) - if not buf or not vim.api.nvim_buf_is_valid(buf) then - return nil - end - - local line_count = vim.api.nvim_buf_line_count(buf) - if line_count == 0 then - return nil - end - - local ok_view, view = pcall(vim.api.nvim_win_call, win, vim.fn.winsaveview) - if not ok_view or type(view) ~= 'table' then - return nil - end - - local topline = math.max(1, view.topline or 1) - local remaining_height = vim.api.nvim_win_get_height(win) - for line = topline, line_count do - local ok_height, result = pcall(vim.api.nvim_win_text_height, win, { - start_row = line - 1, - end_row = line - 1, - }) - local line_height = ok_height and result and result.all or 1 - remaining_height = remaining_height - line_height - if remaining_height <= 0 then - return line - end - end - - return line_count + local ok, line = pcall(vim.fn.line, 'w$', win) + return (ok and line and line > 0) and line or nil end ---@param win? integer diff --git a/lua/opencode/ui/renderer.lua b/lua/opencode/ui/renderer.lua index c92c9478..6154b3f3 100644 --- a/lua/opencode/ui/renderer.lua +++ b/lua/opencode/ui/renderer.lua @@ -6,6 +6,7 @@ local Promise = require('opencode.promise') local ctx = require('opencode.ui.renderer.ctx') local events = require('opencode.ui.renderer.events') local flush = require('opencode.ui.renderer.flush') +local scroll = require('opencode.ui.renderer.scroll') local M = {} @@ -123,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 @@ -159,6 +162,7 @@ function M._render_full_session_data(session_data) end flush.flush() + flush.end_bulk_mode() if set_mode_from_messages then set_model_and_mode_from_messages() @@ -216,28 +220,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 - - local ok_cursor, cursor = pcall(vim.api.nvim_win_get_cursor, output_win) - - local should_scroll = force - or prev_line_count == 0 - or config.ui.output.always_scroll_to_bottom - or (ok_cursor and cursor and cursor[1] >= prev_line_count) - or output_window.is_at_bottom(output_win) - - 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 diff --git a/lua/opencode/ui/ui.lua b/lua/opencode/ui/ui.lua index b99d41e6..6ba7089f 100644 --- a/lua/opencode/ui/ui.lua +++ b/lua/opencode/ui/ui.lua @@ -335,8 +335,14 @@ end 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') From cf438f96f6c981ea3455f8ac19f8ec3189c0a206 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Fri, 27 Mar 2026 16:49:06 -0400 Subject: [PATCH 08/21] Update lua/opencode/config.lua Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- lua/opencode/config.lua | 2 +- lua/opencode/id.lua | 1 - lua/opencode/ui/dialog.lua | 98 ++++++---- lua/opencode/ui/formatter.lua | 20 +- lua/opencode/ui/loading_animation.lua | 63 +++++- lua/opencode/ui/output_window.lua | 1 + lua/opencode/ui/permission_window.lua | 1 + lua/opencode/ui/question_window.lua | 74 +++++++ lua/opencode/ui/renderer.lua | 9 +- lua/opencode/ui/renderer/buffer.lua | 58 +++++- lua/opencode/ui/renderer/flush.lua | 3 +- lua/opencode/util.lua | 4 +- tests/minimal/plugin_spec.lua | 15 +- tests/replay/renderer_spec.lua | 46 ++--- tests/unit/completion_lsp_spec.lua | 260 ++++++++++++------------- tests/unit/completion_spec.lua | 1 - tests/unit/config_file_spec.lua | 8 +- tests/unit/context_completion_spec.lua | 21 +- tests/unit/core_spec.lua | 41 ++++ tests/unit/dialog_spec.lua | 34 ++++ tests/unit/id_spec.lua | 1 - tests/unit/loading_animation_spec.lua | 50 ++++- tests/unit/output_window_spec.lua | 89 +++++++-- tests/unit/permission_window_spec.lua | 1 + tests/unit/question_window_spec.lua | 39 ++++ tests/unit/render_state_spec.lua | 15 ++ tests/unit/renderer_buffer_spec.lua | 29 +++ tests/unit/session_spec.lua | 2 +- tests/unit/zoom_spec.lua | 2 +- 29 files changed, 751 insertions(+), 237 deletions(-) create mode 100644 tests/unit/question_window_spec.lua diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index bc010aaf..b0d226fa 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -260,7 +260,7 @@ M.defaults = { enabled = false, capture_streamed_events = false, show_ids = true, - highlight_changed_lines = true, + highlight_changed_lines = false, highlight_changed_lines_timeout_ms = 120, quick_chat = { keep_session = false, 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/ui/dialog.lua b/lua/opencode/ui/dialog.lua index 0ab064ed..6c3e56a5 100644 --- a/lua/opencode/ui/dialog.lua +++ b/lua/opencode/ui/dialog.lua @@ -281,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', @@ -313,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 @@ -326,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 @@ -359,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 4d72f812..cc8105c6 100644 --- a/lua/opencode/ui/formatter.lua +++ b/lua/opencode/ui/formatter.lua @@ -173,13 +173,13 @@ function M.format_message_header(message) local display_name if role == 'assistant' then 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 - display_name = 'ASSISTANT' - end + 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 + display_name = 'ASSISTANT' + end else display_name = role:upper() end @@ -307,7 +307,10 @@ function M._format_selection_context(output, part) 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 + 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() @@ -548,6 +551,7 @@ function M.add_vertical_border(output, start_line, end_line, hl_group, 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 diff --git a/lua/opencode/ui/loading_animation.lua b/lua/opencode/ui/loading_animation.lua index 44da2f86..845709d9 100644 --- a/lua/opencode/ui/loading_animation.lua +++ b/lua/opencode/ui/loading_animation.lua @@ -85,6 +85,28 @@ local function subscribe_session_status_event(manager) M._animation.status_event_manager = manager end +local function is_active_session_busy() + local active_session = state.active_session + local session_id = active_session and active_session.id + if session_id and ((state.user_message_count or {})[session_id] or 0) > 0 then + return true + end + + local ok, question_window = pcall(require, 'opencode.ui.question_window') + if ok and question_window.has_question and question_window.belongs_to_active_session then + local current_question = question_window._current_question + if question_window.has_question() and question_window.belongs_to_active_session(current_question) then + return true + end + end + + if M._animation.status_data and M._animation.status_data.type ~= 'idle' then + return true + end + + return state.jobs.is_running() +end + function M.on_session_status(properties) if not properties or type(properties) ~= 'table' then return @@ -104,6 +126,27 @@ local function on_active_session_change(_, new_session, old_session) local old_id = old_session and old_session.id if new_id ~= old_id then M._animation.status_data = nil + if is_active_session_busy() then + M.start(state.windows) + else + M.stop() + end + end +end + +local function on_user_message_count_change() + if not state.windows then + return + end + + if is_active_session_busy() then + if not M.is_running() then + M.start(state.windows) + else + M.render(state.windows) + end + else + M.stop() end end @@ -138,7 +181,7 @@ M.render = vim.schedule_wrap(function(windows) return false end - if not state.jobs.is_running() then + if not is_active_session_busy() then M.stop() return false end @@ -168,7 +211,7 @@ function M._start_animation_timer(windows) on_tick = function() M._animation.current_frame = M._next_frame() M.render(state.windows) - if state.jobs.is_running() then + if is_active_session_busy() then return true else M.stop() @@ -214,8 +257,12 @@ local function on_running_change(_, new_value) return end - if not M.is_running() and new_value and new_value > 0 then - M.start(state.windows) + if (new_value and new_value > 0) or is_active_session_busy() then + if not M.is_running() then + M.start(state.windows) + else + M.render(state.windows) + end else M.stop() end @@ -223,13 +270,21 @@ end function M.setup() state.store.subscribe('job_count', on_running_change) + state.store.subscribe('user_message_count', on_user_message_count_change) state.store.subscribe('active_session', on_active_session_change) state.store.subscribe('event_manager', on_event_manager_change) subscribe_session_status_event(state.event_manager) + + if is_active_session_busy() then + M.start(state.windows) + else + M.stop() + end end function M.teardown() state.store.unsubscribe('job_count', on_running_change) + state.store.unsubscribe('user_message_count', on_user_message_count_change) state.store.unsubscribe('active_session', on_active_session_change) state.store.unsubscribe('event_manager', on_event_manager_change) unsubscribe_session_status_event(M._animation.status_event_manager) diff --git a/lua/opencode/ui/output_window.lua b/lua/opencode/ui/output_window.lua index 6a5541a0..1bde9379 100644 --- a/lua/opencode/ui/output_window.lua +++ b/lua/opencode/ui/output_window.lua @@ -181,6 +181,7 @@ function M.setup(windows) ) 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) diff --git a/lua/opencode/ui/permission_window.lua b/lua/opencode/ui/permission_window.lua index af01fab8..d0f515ba 100644 --- a/lua/opencode/ui/permission_window.lua +++ b/lua/opencode/ui/permission_window.lua @@ -174,6 +174,7 @@ function M.format_display(output) title = icons.get('warning') .. ' Permission Required' .. progress, title_hl = 'OpencodePermissionTitle', border_hl = 'OpencodePermissionBorder', + extend_border_to_trailing_blank = true, content = render_content and nil or content, render_content = render_content, options = options, diff --git a/lua/opencode/ui/question_window.lua b/lua/opencode/ui/question_window.lua index 11505e66..25292ef7 100644 --- a/lua/opencode/ui/question_window.lua +++ b/lua/opencode/ui/question_window.lua @@ -12,6 +12,45 @@ 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 + local function render_question() require('opencode.ui.renderer.events').render_question_display() end @@ -38,6 +77,40 @@ 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 + function M.clear_question() M._clear_dialog() M._current_question = nil @@ -179,6 +252,7 @@ function M.format_display(output) title = icons.get('question') .. ' Question' .. progress, title_hl = 'OpencodeQuestionTitle', border_hl = 'OpencodeQuestionBorder', + extend_border_to_trailing_blank = true, content = vim.split(question_info.question, '\n'), options = options, unfocused_message = 'Focus Opencode window to answer question', diff --git a/lua/opencode/ui/renderer.lua b/lua/opencode/ui/renderer.lua index 6154b3f3..ff7e81ec 100644 --- a/lua/opencode/ui/renderer.lua +++ b/lua/opencode/ui/renderer.lua @@ -181,7 +181,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 diff --git a/lua/opencode/ui/renderer/buffer.lua b/lua/opencode/ui/renderer/buffer.lua index da069832..0f04a0eb 100644 --- a/lua/opencode/ui/renderer/buffer.lua +++ b/lua/opencode/ui/renderer/buffer.lua @@ -56,7 +56,9 @@ end local function slice_extmarks(extmarks, start_line) local slice = {} for line_idx, marks in pairs(extmarks or {}) do - if line_idx >= start_line then + 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 end @@ -85,13 +87,29 @@ local function marks_equal(a, b) end 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 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 + 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 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 @@ -116,12 +134,44 @@ local function apply_extmarks(previous_formatted, formatted_data, line_start, ol unchanged_prefix_len(previous_formatted, formatted_data), unchanged_extmark_prefix_len(previous_formatted, formatted_data) ) + + 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 + + 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 clear_end = math.max(old_line_end, new_line_end) + 1 + 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 output_window.clear_extmarks(clear_start, clear_end) - local extmarks = slice_extmarks(formatted_data.extmarks, prefix_len) + 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 diff --git a/lua/opencode/ui/renderer/flush.lua b/lua/opencode/ui/renderer/flush.lua index 2ba19555..b1d3d768 100644 --- a/lua/opencode/ui/renderer/flush.lua +++ b/lua/opencode/ui/renderer/flush.lua @@ -348,7 +348,8 @@ local function do_trigger_on_data_rendered() 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) +M.trigger_on_data_rendered = + require('opencode.util').debounce(do_trigger_on_data_rendered, config.ui.output.rendering.markdown_debounce_ms or 250) function M.request_on_data_rendered(force) if force or not is_markdown_render_deferred() then diff --git a/lua/opencode/util.lua b/lua/opencode/util.lua index 96e3cc03..1f51a76f 100644 --- a/lua/opencode/util.lua +++ b/lua/opencode/util.lua @@ -526,9 +526,7 @@ function M.get_markdown_filetype(filename) 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') + local result = _filetype_overrides[file_type] or (file_type ~= '' and file_type) or vim.fn.fnamemodify(filename, ':e') _filetype_cache[filename] = result return result 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 fe6db602..72bfd6b4 100644 --- a/tests/replay/renderer_spec.lua +++ b/tests/replay/renderer_spec.lua @@ -277,30 +277,30 @@ describe('renderer functional tests', function() ) end - 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) - - 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 + 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) + + 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) - end + local actual = helpers.capture_output(state.windows and state.windows.output_buf, output_window.namespace) + assert_output_matches(expected, actual, name) + end) + end end end 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 1bb4e463..37792027 100644 --- a/tests/unit/core_spec.lua +++ b/tests/unit/core_spec.lua @@ -441,6 +441,47 @@ describe('opencode.core', function() 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() 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/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..b6078d42 100644 --- a/tests/unit/loading_animation_spec.lua +++ b/tests/unit/loading_animation_spec.lua @@ -3,17 +3,22 @@ local loading_animation = require('opencode.ui.loading_animation') describe('loading_animation status text', function() local original_time + local original_windows before_each(function() original_time = os.time + original_windows = state.windows loading_animation._animation.status_data = nil state.session.clear_active() + state.session.set_user_message_count({}) end) after_each(function() os.time = original_time loading_animation._animation.status_data = nil state.session.clear_active() + state.session.set_user_message_count({}) + state.ui.set_windows(original_windows) end) it('renders busy as thinking text', function() @@ -44,7 +49,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({ @@ -54,4 +59,47 @@ describe('loading_animation status text', function() assert.is_nil(loading_animation._animation.status_data) end) + + it('treats pending work on the active session as busy', function() + state.session.set_active({ id = 'ses_active' }) + state.session.set_user_message_count({ ses_active = 1 }) + + assert.is_true(loading_animation._get_display_text():find('Thinking', 1, true) ~= nil) + end) + + it('starts when the active session still has pending work after reopening', function() + local output_buf = vim.api.nvim_create_buf(false, true) + local footer_buf = vim.api.nvim_create_buf(false, true) + state.ui.set_windows({ output_buf = output_buf, footer_buf = footer_buf }) + state.session.set_active({ id = 'ses_active' }) + state.session.set_user_message_count({ ses_active = 1 }) + + loading_animation.setup() + + assert.is_true(loading_animation.is_running()) + + loading_animation.teardown() + pcall(vim.api.nvim_buf_delete, output_buf, { force = true }) + pcall(vim.api.nvim_buf_delete, footer_buf, { force = true }) + end) + + it('keeps spinner active while the active session has a pending question', function() + local question_window = require('opencode.ui.question_window') + state.session.set_active({ id = 'ses_active' }) + question_window._current_question = { + id = 'q1', + sessionID = 'ses_active', + questions = { + { + question = 'Pick one', + header = 'Test', + options = { { label = 'One', description = 'first' } }, + }, + }, + } + + assert.is_true(loading_animation._get_display_text():find('Thinking', 1, true) ~= nil) + + question_window._current_question = nil + end) end) diff --git a/tests/unit/output_window_spec.lua b/tests/unit/output_window_spec.lua index e99a0cc0..d876b5d6 100644 --- a/tests/unit/output_window_spec.lua +++ b/tests/unit/output_window_spec.lua @@ -49,17 +49,17 @@ describe('output_window.highlight_changed_lines', function() 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) + 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() @@ -113,3 +113,70 @@ describe('output_window namespaces', function() 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) diff --git a/tests/unit/permission_window_spec.lua b/tests/unit/permission_window_spec.lua index 4f4ca547..372a0b6c 100644 --- a/tests/unit/permission_window_spec.lua +++ b/tests/unit/permission_window_spec.lua @@ -41,6 +41,7 @@ describe('permission_window', function() permission_window.format_display(output) assert.is_not_nil(captured_opts) + assert.is_true(captured_opts.extend_border_to_trailing_blank) assert.is_not_nil(captured_opts.content) assert.are.equal(7, #captured_opts.content) assert.is_true(captured_opts.content[1]:find('*bash*', 1, true) ~= nil) diff --git a/tests/unit/question_window_spec.lua b/tests/unit/question_window_spec.lua new file mode 100644 index 00000000..3d1760c4 --- /dev/null +++ b/tests/unit/question_window_spec.lua @@ -0,0 +1,39 @@ +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('extends the question border through the trailing spacer line', 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.is_true(captured_opts.extend_border_to_trailing_blank) + 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 index 77729929..54dc2d3f 100644 --- a/tests/unit/renderer_buffer_spec.lua +++ b/tests/unit/renderer_buffer_spec.lua @@ -49,4 +49,33 @@ describe('renderer.buffer extmarks', function() }, }, 11) 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) + 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) From 09ab292bf4ce4102ebd1a3f495ab0f2fdb149d5d Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Mon, 30 Mar 2026 11:38:26 -0400 Subject: [PATCH 09/21] revert: disable session.status subscription in loading animation (avoid regressions) --- lua/opencode/ui/loading_animation.lua | 46 +++++++-------------------- 1 file changed, 12 insertions(+), 34 deletions(-) diff --git a/lua/opencode/ui/loading_animation.lua b/lua/opencode/ui/loading_animation.lua index 845709d9..d189ed31 100644 --- a/lua/opencode/ui/loading_animation.lua +++ b/lua/opencode/ui/loading_animation.lua @@ -62,27 +62,17 @@ function M._format_status_text(status) end local function unsubscribe_session_status_event(manager) - if manager and M._animation.status_event_manager == manager then - manager:unsubscribe('session.status', M.on_session_status) - M._animation.status_event_manager = nil - end + -- No-op: disable session.status event subscription introduced recently. + -- Reverting session.status handling to avoid interfering with existing + -- behavior. This keeps the loading animation logic focused on job_count + -- and user_message_count as before. + return end local function subscribe_session_status_event(manager) - if not manager then - return - end - - if M._animation.status_event_manager and M._animation.status_event_manager ~= manager then - unsubscribe_session_status_event(M._animation.status_event_manager) - end - - if M._animation.status_event_manager == manager then - return - end - - manager:subscribe('session.status', M.on_session_status) - M._animation.status_event_manager = manager + -- No-op: do not subscribe to session.status events. See note in + -- unsubscribe_session_status_event for rationale. + return end local function is_active_session_busy() @@ -108,17 +98,10 @@ local function is_active_session_busy() end function M.on_session_status(properties) - if not properties or type(properties) ~= 'table' then - return - end - - local active_session = state.active_session - if active_session and active_session.id and properties.sessionID ~= active_session.id then - return - end - - M._animation.status_data = properties.status - M.render(state.windows) + -- Disabled: Ignore session.status updates to keep loading animation + -- behavior stable. Previously this updated status_data and triggered + -- a render which caused regressions in some environments. + return end local function on_active_session_change(_, new_session, old_session) @@ -272,9 +255,6 @@ function M.setup() state.store.subscribe('job_count', on_running_change) state.store.subscribe('user_message_count', on_user_message_count_change) state.store.subscribe('active_session', on_active_session_change) - state.store.subscribe('event_manager', on_event_manager_change) - subscribe_session_status_event(state.event_manager) - if is_active_session_busy() then M.start(state.windows) else @@ -286,8 +266,6 @@ function M.teardown() state.store.unsubscribe('job_count', on_running_change) state.store.unsubscribe('user_message_count', on_user_message_count_change) state.store.unsubscribe('active_session', on_active_session_change) - state.store.unsubscribe('event_manager', on_event_manager_change) - unsubscribe_session_status_event(M._animation.status_event_manager) M._animation.status_data = nil end From 22c4b84e2be0c9ce95adf94ded695079d1948db8 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Mon, 30 Mar 2026 13:51:10 -0400 Subject: [PATCH 10/21] refactor(ui/output): use sticky at-bottom viewport flag for auto-scroll Replace cursor-based viewport tracking with a persistent _was_at_bottom_by_win flag so output windows correctly decide whether to auto-scroll when the buffer grows. is_at_bottom() now prefers the sticky flag and falls back to a live visible-bottom check on first use. sync_cursor_with_viewport() updates the flag when the viewport moves away from the last line and scroll_win_to_bottom() sets the flag when explicitly scrolling to the end. Tests updated to exercise viewport-driven behavior and to simulate WinScrolled events. This makes tail-following robust against mouse/viewport scrolling where the cursor may not move. --- lua/opencode/ui/output_window.lua | 60 ++++++++++-------- lua/opencode/ui/renderer/scroll.lua | 3 + tests/unit/cursor_tracking_spec.lua | 96 ++++++++++++++++++----------- 3 files changed, 96 insertions(+), 63 deletions(-) diff --git a/lua/opencode/ui/output_window.lua b/lua/opencode/ui/output_window.lua index 1bde9379..2a9cb805 100644 --- a/lua/opencode/ui/output_window.lua +++ b/lua/opencode/ui/output_window.lua @@ -7,7 +7,7 @@ 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._viewport_cursor_tracking_by_win = {} +M._was_at_bottom_by_win = {} local _update_depth = 0 local _update_buf = nil @@ -79,9 +79,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 @@ -102,12 +114,18 @@ 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 @@ -125,12 +143,12 @@ end function M.reset_scroll_tracking(win) if win then M._last_visible_bottom_by_win[win] = nil - M._viewport_cursor_tracking_by_win[win] = nil + M._was_at_bottom_by_win[win] = nil return end M._last_visible_bottom_by_win = {} - M._viewport_cursor_tracking_by_win = {} + M._was_at_bottom_by_win = {} end ---@param win? integer @@ -147,29 +165,19 @@ function M.sync_cursor_with_viewport(win) return end - local ok_cursor, cursor = pcall(vim.api.nvim_win_get_cursor, win) - local ok_count, line_count = pcall(vim.api.nvim_buf_line_count, buf) + local ok, line_count = pcall(vim.api.nvim_buf_line_count, buf) local visible_bottom = M.get_visible_bottom_line(win) - if not ok_cursor or not cursor or not ok_count or not line_count or line_count == 0 or not visible_bottom then + if not ok or not line_count or line_count == 0 or not visible_bottom then return end - local last_visible_bottom = M._last_visible_bottom_by_win[win] - local tracking = M._viewport_cursor_tracking_by_win[win] == true - local anchored_to_viewport_bottom = tracking and last_visible_bottom and cursor[1] == last_visible_bottom - - if cursor[1] > visible_bottom or (anchored_to_viewport_bottom and cursor[1] ~= visible_bottom) then - M._viewport_cursor_tracking_by_win[win] = true - pcall(vim.api.nvim_win_set_cursor, win, { math.min(visible_bottom, line_count), 0 }) - local pos = state.ui.get_window_cursor(win) - if pos then - state.ui.set_cursor_position('output', pos) - end - elseif not anchored_to_viewport_bottom then - M._viewport_cursor_tracking_by_win[win] = false - 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 function M.setup(windows) diff --git a/lua/opencode/ui/renderer/scroll.lua b/lua/opencode/ui/renderer/scroll.lua index ed0bb8f2..80f2346b 100644 --- a/lua/opencode/ui/renderer/scroll.lua +++ b/lua/opencode/ui/renderer/scroll.lua @@ -15,6 +15,8 @@ function M.get_output_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) @@ -27,6 +29,7 @@ function M.scroll_win_to_bottom(win, buf) 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 diff --git a/tests/unit/cursor_tracking_spec.lua b/tests/unit/cursor_tracking_spec.lua index 5cbd1131..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,21 @@ 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() @@ -297,7 +307,13 @@ describe('output_window.is_at_bottom', function() end) local visible_bottom = output_window.get_visible_bottom_line(win) - assert.equals(3, visible_bottom) + -- 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) @@ -336,15 +352,20 @@ describe('output_window.sync_cursor_with_viewport', function() state.ui.set_windows(nil) end) - it('keeps the cursor aligned with the actual viewport bottom while scrolling', function() + 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) - local cursor = vim.api.nvim_win_get_cursor(win) - assert.equals(3, cursor[1]) + 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() @@ -388,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() From 7a9b428a6b318cf56eea76292fbd6c6a7182642d Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Mon, 30 Mar 2026 14:58:44 -0400 Subject: [PATCH 11/21] refactor(ui/renderer/tests): remove trailing-border flags, filter internal messages, normalize extmarks - Remove extend_border_to_trailing_blank from permission and question window displays. - Filter messages with ids starting with __opencode_ before calling full-session render. - Normalize extmark serialization (move virt_text_pos, virt_text_hide, virt_text_win_col, priority into consistent ordering) and update expected test fixtures/timestamps. - Update unit tests to match new display options. --- lua/opencode/ui/permission_window.lua | 1 - lua/opencode/ui/question_window.lua | 1 - lua/opencode/ui/renderer/events.lua | 5 +- tests/data/message-removal.expected.json | 255 ++-- .../data/multiple-question-ask.expected.json | 431 ++---- tests/data/permission-ask-new.expected.json | 549 +++---- tests/data/permission-prompt.expected.json | 313 ++-- tests/data/question-ask-other.expected.json | 474 +++--- tests/data/question-ask.expected.json | 349 ++--- tests/data/redo-all.expected.json | 1354 ++++++++++++++++- tests/data/redo-once.expected.json | 941 +++++++++++- tests/data/revert.expected.json | 813 ++++------ .../shifting-and-multiple-perms.expected.json | 1084 ++++++++++++- tests/unit/permission_window_spec.lua | 1 - tests/unit/question_window_spec.lua | 3 +- 15 files changed, 4472 insertions(+), 2102 deletions(-) diff --git a/lua/opencode/ui/permission_window.lua b/lua/opencode/ui/permission_window.lua index d0f515ba..af01fab8 100644 --- a/lua/opencode/ui/permission_window.lua +++ b/lua/opencode/ui/permission_window.lua @@ -174,7 +174,6 @@ function M.format_display(output) title = icons.get('warning') .. ' Permission Required' .. progress, title_hl = 'OpencodePermissionTitle', border_hl = 'OpencodePermissionBorder', - extend_border_to_trailing_blank = true, content = render_content and nil or content, render_content = render_content, options = options, diff --git a/lua/opencode/ui/question_window.lua b/lua/opencode/ui/question_window.lua index 25292ef7..f5497447 100644 --- a/lua/opencode/ui/question_window.lua +++ b/lua/opencode/ui/question_window.lua @@ -252,7 +252,6 @@ function M.format_display(output) title = icons.get('question') .. ' Question' .. progress, title_hl = 'OpencodeQuestionTitle', border_hl = 'OpencodeQuestionBorder', - extend_border_to_trailing_blank = true, content = vim.split(question_info.question, '\n'), options = options, unfocused_message = 'Focus Opencode window to answer question', diff --git a/lua/opencode/ui/renderer/events.lua b/lua/opencode/ui/renderer/events.lua index cae58015..79e9b3b2 100644 --- a/lua/opencode/ui/renderer/events.lua +++ b/lua/opencode/ui/renderer/events.lua @@ -383,7 +383,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 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/unit/permission_window_spec.lua b/tests/unit/permission_window_spec.lua index 372a0b6c..4f4ca547 100644 --- a/tests/unit/permission_window_spec.lua +++ b/tests/unit/permission_window_spec.lua @@ -41,7 +41,6 @@ describe('permission_window', function() permission_window.format_display(output) assert.is_not_nil(captured_opts) - assert.is_true(captured_opts.extend_border_to_trailing_blank) assert.is_not_nil(captured_opts.content) assert.are.equal(7, #captured_opts.content) assert.is_true(captured_opts.content[1]:find('*bash*', 1, true) ~= nil) diff --git a/tests/unit/question_window_spec.lua b/tests/unit/question_window_spec.lua index 3d1760c4..b10ab2a0 100644 --- a/tests/unit/question_window_spec.lua +++ b/tests/unit/question_window_spec.lua @@ -10,7 +10,7 @@ describe('question_window', function() question_window._dialog = nil end) - it('extends the question border through the trailing spacer line', function() + it('adds the Other option when missing', function() local captured_opts = nil question_window._current_question = { id = 'q1', @@ -32,7 +32,6 @@ describe('question_window', function() question_window.format_display(Output.new()) assert.is_not_nil(captured_opts) - assert.is_true(captured_opts.extend_border_to_trailing_blank) assert.are.equal('On save', captured_opts.options[1].label) assert.are.equal('Other', captured_opts.options[2].label) end) From 9394badec35a782914aefcc7366194411154b158 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Tue, 31 Mar 2026 11:44:31 -0400 Subject: [PATCH 12/21] fix(events): ignore server heartbeat and avoid draining empty queue Add an ignore list for server events (e.g. "server.heartbeat") so noisy heartbeats don't trigger rendering. Also guard ThrottlingEmitter:_drain to skip calling the process function when there are no items to process, avoiding unnecessary work. --- lua/opencode/throttling_emitter.lua | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 From 6a86b999bb30eda31a51a313fb88695f28d62051 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Wed, 1 Apr 2026 08:27:14 -0400 Subject: [PATCH 13/21] refactor(renderer/flush): suppress output-window autocmds and ensure update cleanup Add a with_suppressed_output_autocmds helper that temporarily sets eventignorewin on the output window, wraps output_window.begin_update / end_update with xpcall to guarantee cleanup, and restores the original state even if the wrapped operation errors. Use the helper in apply_pending and end_bulk_mode so writes and part/message updates won't trigger autocmds mid-write and cleanup always runs. Re-throw errors after restoring state. Add unit tests exercising failure during bulk writes to verify eventignore is restored, end_update is called, and bulk mode state is cleared. --- lua/opencode/ui/renderer/flush.lua | 126 ++++++++++++++++++----------- tests/unit/output_window_spec.lua | 71 ++++++++++++++++ 2 files changed, 152 insertions(+), 45 deletions(-) diff --git a/lua/opencode/ui/renderer/flush.lua b/lua/opencode/ui/renderer/flush.lua index b1d3d768..f983170b 100644 --- a/lua/opencode/ui/renderer/flush.lua +++ b/lua/opencode/ui/renderer/flush.lua @@ -9,6 +9,44 @@ local append = require('opencode.ui.renderer.append') local M = {} +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) + local saved_eventignorewin = has_output_win and vim.api.nvim_get_option_value('eventignorewin', { win = output_win }) + or nil + + if has_output_win then + vim.api.nvim_set_option_value('eventignorewin', 'all', { win = output_win, scope = 'local' }) + end + + local begin_ok, began_update = xpcall(output_window.begin_update, debug.traceback) + if not begin_ok then + if has_output_win then + 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 then + 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 + local function lines_equal(a, b) a = a or {} b = b or {} @@ -282,50 +320,46 @@ local function apply_pending(pending) end local scroll_snapshot = scroll.pre_flush(buf) - local saved_eventignore = vim.o.eventignore - vim.o.eventignore = 'all' - output_window.begin_update() - - for _, part_id in ipairs(pending.removed_part_order) do - if pending.removed_parts[part_id] then - buffer.remove_part_now(part_id) + 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 - end - for _, message_id in ipairs(pending.removed_message_order) do - if pending.removed_messages[message_id] then - buffer.remove_message_now(message_id) + 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 - end - for _, message_id in ipairs(pending.dirty_message_order) do - if pending.dirty_messages[message_id] then - apply_message(message_id) - 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 + 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 - 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) + 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 + end) - output_window.end_update() - vim.o.eventignore = saved_eventignore scroll.post_flush(scroll_snapshot, buf) return true end @@ -396,21 +430,23 @@ function M.end_bulk_mode() end -- Write all lines at once. Suppress autocmds so render-markdown and similar - -- plugins don't fire mid-write; we trigger them explicitly via vim.schedule - -- below. begin_update/end_update handles the modifiable toggle. - local saved_eventignore = vim.o.eventignore - vim.o.eventignore = 'all' - output_window.begin_update() - output_window.set_lines(lines, 0, -1) - output_window.end_update() - vim.o.eventignore = saved_eventignore - - if next(ctx.bulk_extmarks_by_line) then - output_window.set_extmarks(ctx.bulk_extmarks_by_line, 0) - end + -- 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) + + 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) diff --git a/tests/unit/output_window_spec.lua b/tests/unit/output_window_spec.lua index d876b5d6..108779ee 100644 --- a/tests/unit/output_window_spec.lua +++ b/tests/unit/output_window_spec.lua @@ -1,6 +1,7 @@ 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() @@ -180,3 +181,73 @@ describe('output_window extmarks', function() assert.equals(1, marks[2][2]) end) end) + +describe('renderer flush cleanup', function() + local buf + local win + local original_eventignore + local original_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 + original_eventignorewin = vim.api.nvim_get_option_value('eventignorewin', { win = win }) + 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) then + 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) + assert.equals(original_eventignorewin, vim.api.nvim_get_option_value('eventignorewin', { win = win })) + 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) From 6639e32f6329fe854afbb4a944705195585651ba Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Wed, 1 Apr 2026 08:28:38 -0400 Subject: [PATCH 14/21] refactor(ui/loading_animation): simplify session status handling Revert to simpler jobs_count logic --- lua/opencode/ui/loading_animation.lua | 95 +++++++++------------------ tests/unit/loading_animation_spec.lua | 48 -------------- 2 files changed, 31 insertions(+), 112 deletions(-) diff --git a/lua/opencode/ui/loading_animation.lua b/lua/opencode/ui/loading_animation.lua index d189ed31..44da2f86 100644 --- a/lua/opencode/ui/loading_animation.lua +++ b/lua/opencode/ui/loading_animation.lua @@ -62,46 +62,41 @@ function M._format_status_text(status) end local function unsubscribe_session_status_event(manager) - -- No-op: disable session.status event subscription introduced recently. - -- Reverting session.status handling to avoid interfering with existing - -- behavior. This keeps the loading animation logic focused on job_count - -- and user_message_count as before. - return + if manager and M._animation.status_event_manager == manager then + manager:unsubscribe('session.status', M.on_session_status) + M._animation.status_event_manager = nil + end end local function subscribe_session_status_event(manager) - -- No-op: do not subscribe to session.status events. See note in - -- unsubscribe_session_status_event for rationale. - return -end - -local function is_active_session_busy() - local active_session = state.active_session - local session_id = active_session and active_session.id - if session_id and ((state.user_message_count or {})[session_id] or 0) > 0 then - return true + if not manager then + return end - local ok, question_window = pcall(require, 'opencode.ui.question_window') - if ok and question_window.has_question and question_window.belongs_to_active_session then - local current_question = question_window._current_question - if question_window.has_question() and question_window.belongs_to_active_session(current_question) then - return true - end + if M._animation.status_event_manager and M._animation.status_event_manager ~= manager then + unsubscribe_session_status_event(M._animation.status_event_manager) end - if M._animation.status_data and M._animation.status_data.type ~= 'idle' then - return true + if M._animation.status_event_manager == manager then + return end - return state.jobs.is_running() + manager:subscribe('session.status', M.on_session_status) + M._animation.status_event_manager = manager end function M.on_session_status(properties) - -- Disabled: Ignore session.status updates to keep loading animation - -- behavior stable. Previously this updated status_data and triggered - -- a render which caused regressions in some environments. - return + if not properties or type(properties) ~= 'table' then + return + end + + local active_session = state.active_session + if active_session and active_session.id and properties.sessionID ~= active_session.id then + return + end + + M._animation.status_data = properties.status + M.render(state.windows) end local function on_active_session_change(_, new_session, old_session) @@ -109,27 +104,6 @@ local function on_active_session_change(_, new_session, old_session) local old_id = old_session and old_session.id if new_id ~= old_id then M._animation.status_data = nil - if is_active_session_busy() then - M.start(state.windows) - else - M.stop() - end - end -end - -local function on_user_message_count_change() - if not state.windows then - return - end - - if is_active_session_busy() then - if not M.is_running() then - M.start(state.windows) - else - M.render(state.windows) - end - else - M.stop() end end @@ -164,7 +138,7 @@ M.render = vim.schedule_wrap(function(windows) return false end - if not is_active_session_busy() then + if not state.jobs.is_running() then M.stop() return false end @@ -194,7 +168,7 @@ function M._start_animation_timer(windows) on_tick = function() M._animation.current_frame = M._next_frame() M.render(state.windows) - if is_active_session_busy() then + if state.jobs.is_running() then return true else M.stop() @@ -240,12 +214,8 @@ local function on_running_change(_, new_value) return end - if (new_value and new_value > 0) or is_active_session_busy() then - if not M.is_running() then - M.start(state.windows) - else - M.render(state.windows) - end + if not M.is_running() and new_value and new_value > 0 then + M.start(state.windows) else M.stop() end @@ -253,19 +223,16 @@ end function M.setup() state.store.subscribe('job_count', on_running_change) - state.store.subscribe('user_message_count', on_user_message_count_change) state.store.subscribe('active_session', on_active_session_change) - if is_active_session_busy() then - M.start(state.windows) - else - M.stop() - end + state.store.subscribe('event_manager', on_event_manager_change) + subscribe_session_status_event(state.event_manager) end function M.teardown() state.store.unsubscribe('job_count', on_running_change) - state.store.unsubscribe('user_message_count', on_user_message_count_change) state.store.unsubscribe('active_session', on_active_session_change) + state.store.unsubscribe('event_manager', on_event_manager_change) + unsubscribe_session_status_event(M._animation.status_event_manager) M._animation.status_data = nil end diff --git a/tests/unit/loading_animation_spec.lua b/tests/unit/loading_animation_spec.lua index b6078d42..eae65762 100644 --- a/tests/unit/loading_animation_spec.lua +++ b/tests/unit/loading_animation_spec.lua @@ -3,22 +3,17 @@ local loading_animation = require('opencode.ui.loading_animation') describe('loading_animation status text', function() local original_time - local original_windows before_each(function() original_time = os.time - original_windows = state.windows loading_animation._animation.status_data = nil state.session.clear_active() - state.session.set_user_message_count({}) end) after_each(function() os.time = original_time loading_animation._animation.status_data = nil state.session.clear_active() - state.session.set_user_message_count({}) - state.ui.set_windows(original_windows) end) it('renders busy as thinking text', function() @@ -59,47 +54,4 @@ describe('loading_animation status text', function() assert.is_nil(loading_animation._animation.status_data) end) - - it('treats pending work on the active session as busy', function() - state.session.set_active({ id = 'ses_active' }) - state.session.set_user_message_count({ ses_active = 1 }) - - assert.is_true(loading_animation._get_display_text():find('Thinking', 1, true) ~= nil) - end) - - it('starts when the active session still has pending work after reopening', function() - local output_buf = vim.api.nvim_create_buf(false, true) - local footer_buf = vim.api.nvim_create_buf(false, true) - state.ui.set_windows({ output_buf = output_buf, footer_buf = footer_buf }) - state.session.set_active({ id = 'ses_active' }) - state.session.set_user_message_count({ ses_active = 1 }) - - loading_animation.setup() - - assert.is_true(loading_animation.is_running()) - - loading_animation.teardown() - pcall(vim.api.nvim_buf_delete, output_buf, { force = true }) - pcall(vim.api.nvim_buf_delete, footer_buf, { force = true }) - end) - - it('keeps spinner active while the active session has a pending question', function() - local question_window = require('opencode.ui.question_window') - state.session.set_active({ id = 'ses_active' }) - question_window._current_question = { - id = 'q1', - sessionID = 'ses_active', - questions = { - { - question = 'Pick one', - header = 'Test', - options = { { label = 'One', description = 'first' } }, - }, - }, - } - - assert.is_true(loading_animation._get_display_text():find('Thinking', 1, true) ~= nil) - - question_window._current_question = nil - end) end) From e80116e3edcd3af75e804875f489a0fcafa2cede Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Wed, 1 Apr 2026 08:55:37 -0400 Subject: [PATCH 15/21] fix(formatter/grep): normalize grep input handling Normalize parts in resolve_grep_string to safely convert nil, vim.NIL, numbers, and booleans to strings and prefer input.path over input.include when present. This prevents invalid grep strings from non-string inputs. --- lua/opencode/ui/formatter/tools/grep.lua | 23 ++++++++++++-- tests/unit/formatter_spec.lua | 39 ++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/lua/opencode/ui/formatter/tools/grep.lua b/lua/opencode/ui/formatter/tools/grep.lua index 2dcc4338..1f592851 100644 --- a/lua/opencode/ui/formatter/tools/grep.lua +++ b/lua/opencode/ui/formatter/tools/grep.lua @@ -4,11 +4,30 @@ local M = {} ---@param input GrepToolInput|nil ---@return string local function resolve_grep_string(input) + 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 + 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/tests/unit/formatter_spec.lua b/tests/unit/formatter_spec.lua index fdebc62a..544fb51d 100644 --- a/tests/unit/formatter_spec.lua +++ b/tests/unit/formatter_spec.lua @@ -199,4 +199,43 @@ 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) end) From afc7c49d0cacbaddc9182ec32806ad39dd7ffa3f Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Wed, 1 Apr 2026 08:56:22 -0400 Subject: [PATCH 16/21] refactor(renderer): extract extmark_clear_range and avoid redundant clears Extract extmark_clear_range to compute the range of extmarks to clear and return clear_start/clear_end. Change apply_extmarks to accept a skip_clear flag so callers can control when clearing happens. Clear extmarks once before writing lines in upsert_message_now and upsert_part_now, and during bulk flush. Add unit tests to assert extmarks are cleared before replay and that clear_extmarks is called before set_lines. --- lua/opencode/ui/renderer/buffer.lua | 36 ++++++++++++++++---- lua/opencode/ui/renderer/flush.lua | 2 ++ tests/unit/output_window_spec.lua | 34 +++++++++++++++++++ tests/unit/renderer_buffer_spec.lua | 51 +++++++++++++++++++++++++++-- 4 files changed, 115 insertions(+), 8 deletions(-) diff --git a/lua/opencode/ui/renderer/buffer.lua b/lua/opencode/ui/renderer/buffer.lua index 0f04a0eb..dfe2957f 100644 --- a/lua/opencode/ui/renderer/buffer.lua +++ b/lua/opencode/ui/renderer/buffer.lua @@ -129,7 +129,7 @@ local function highlight_written_lines(start_line, lines) output_window.highlight_changed_lines(start_line, start_line + #lines - 1) end -local function apply_extmarks(previous_formatted, formatted_data, line_start, old_line_end, new_line_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) @@ -162,13 +162,21 @@ local function apply_extmarks(previous_formatted, formatted_data, line_start, ol 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 - output_window.clear_extmarks(clear_start, clear_end) + return clear_start, clear_end +end + +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) @@ -341,12 +349,20 @@ function M.upsert_message_now(message_id, formatted_data, previous_formatted) 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) + 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 @@ -399,7 +415,15 @@ function M.upsert_part_now(part_id, message_id, formatted_data, previous_formatt 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) @@ -409,7 +433,7 @@ function M.upsert_part_now(part_id, message_id, formatted_data, previous_formatt 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) + 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 diff --git a/lua/opencode/ui/renderer/flush.lua b/lua/opencode/ui/renderer/flush.lua index f983170b..2e96baa1 100644 --- a/lua/opencode/ui/renderer/flush.lua +++ b/lua/opencode/ui/renderer/flush.lua @@ -436,6 +436,8 @@ function M.end_bulk_mode() 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 diff --git a/tests/unit/output_window_spec.lua b/tests/unit/output_window_spec.lua index 108779ee..b5203fc9 100644 --- a/tests/unit/output_window_spec.lua +++ b/tests/unit/output_window_spec.lua @@ -251,3 +251,37 @@ describe('renderer flush cleanup', function() 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/renderer_buffer_spec.lua b/tests/unit/renderer_buffer_spec.lua index 54dc2d3f..93977ed8 100644 --- a/tests/unit/renderer_buffer_spec.lua +++ b/tests/unit/renderer_buffer_spec.lua @@ -3,16 +3,40 @@ 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() - set_lines_stub = stub(output_window, 'set_lines') - clear_extmarks_stub = stub(output_window, 'clear_extmarks') + 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) @@ -48,6 +72,7 @@ describe('renderer.buffer extmarks', function() { 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() @@ -77,5 +102,27 @@ describe('renderer.buffer extmarks', function() { 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) From 0556f70f56a67927bb9c12dc478a6e8c18cd99b8 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Wed, 1 Apr 2026 09:21:29 -0400 Subject: [PATCH 17/21] fix(ui): correct action & task line offsets Adjust indexing used when anchoring actions and task ranges so actions and extmarks align with their rendered lines: - formatter.add_action: use (line or output:get_line_count()) - 1 - formatter/tools/task: set display_line = start_line and range = { from = start_line + 1, to = end_line + 1 } - renderer/buffer: pass line_start to add_actions instead of line_start + 1 - renderer: avoid inserting synthetic revert message into state.messages; notify via events only Add tests: - unit tests for formatter anchoring and assistant-mode fallback - replay test asserting a single synthetic revert message is produced Fixes off-by-one rendering/anchor issues and adds tests to prevent regressions. --- lua/opencode/ui/formatter.lua | 2 +- lua/opencode/ui/formatter/tools/task.lua | 4 +- lua/opencode/ui/renderer.lua | 1 - lua/opencode/ui/renderer/buffer.lua | 2 +- tests/replay/renderer_spec.lua | 30 ++++++ tests/unit/formatter_spec.lua | 122 +++++++++++++++++++++++ 6 files changed, 156 insertions(+), 5 deletions(-) diff --git a/lua/opencode/ui/formatter.lua b/lua/opencode/ui/formatter.lua index cc8105c6..53bd5f23 100644 --- a/lua/opencode/ui/formatter.lua +++ b/lua/opencode/ui/formatter.lua @@ -101,7 +101,7 @@ end 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()) - 2 + line = (line or output:get_line_count()) - 1 output:add_action({ text = text, type = action_type, 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/renderer.lua b/lua/opencode/ui/renderer.lua index ff7e81ec..98e25c38 100644 --- a/lua/opencode/ui/renderer.lua +++ b/lua/opencode/ui/renderer.lua @@ -156,7 +156,6 @@ function M._render_full_session_data(session_data) }, } - table.insert(state.messages, revert_message) events.on_message_updated(revert_message) events.on_part_updated({ part = revert_message.parts[1] }) end diff --git a/lua/opencode/ui/renderer/buffer.lua b/lua/opencode/ui/renderer/buffer.lua index dfe2957f..6e728ced 100644 --- a/lua/opencode/ui/renderer/buffer.lua +++ b/lua/opencode/ui/renderer/buffer.lua @@ -276,7 +276,7 @@ end 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 + 1) + ctx.render_state:add_actions(part_id, vim.deepcopy(formatted_data.actions), line_start) else ctx.render_state:clear_actions(part_id) end diff --git a/tests/replay/renderer_spec.lua b/tests/replay/renderer_spec.lua index 72bfd6b4..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') diff --git a/tests/unit/formatter_spec.lua b/tests/unit/formatter_spec.lua index 544fb51d..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() @@ -238,4 +239,125 @@ describe('formatter', function() 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) From 30ea5db138a6a8e505fc0b9a17c8316db07f3f50 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Wed, 1 Apr 2026 10:13:25 -0400 Subject: [PATCH 18/21] fix(renderer/flush): guard 'eventignorewin' usage on older Neovim Use pcall to probe for support of the 'eventignorewin' option and only set/restore it when available. Prevents runtime errors on older Neovim versions (e.g. in CI) while keeping the output window update logic intact. --- lua/opencode/ui/renderer/flush.lua | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/lua/opencode/ui/renderer/flush.lua b/lua/opencode/ui/renderer/flush.lua index 2e96baa1..267844db 100644 --- a/lua/opencode/ui/renderer/flush.lua +++ b/lua/opencode/ui/renderer/flush.lua @@ -12,17 +12,23 @@ local M = {} 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) - local saved_eventignorewin = has_output_win and vim.api.nvim_get_option_value('eventignorewin', { win = output_win }) - or nil - + -- '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 - vim.api.nvim_set_option_value('eventignorewin', 'all', { win = output_win, scope = 'local' }) + 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 then - vim.api.nvim_set_option_value('eventignorewin', saved_eventignorewin, { win = output_win, scope = 'local' }) + 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 @@ -33,8 +39,8 @@ local function with_suppressed_output_autocmds(fn) if began_update then end_ok, end_err = xpcall(output_window.end_update, debug.traceback) end - if has_output_win then - vim.api.nvim_set_option_value('eventignorewin', saved_eventignorewin, { win = output_win, scope = 'local' }) + 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 From 837a11d6c31f24cada35900fc3d04e37fc2726e1 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Wed, 1 Apr 2026 10:30:16 -0400 Subject: [PATCH 19/21] refactor(ui): add type annotations and clarifying comments Add Lua annotations (@param, @return) and short descriptive comments across many UI and renderer modules (base_picker, formatter, grep tool, highlight, output_window, question_window, renderer/buffer, renderer/ctx, renderer/events, renderer/flush, ui). These changes improve readability and editor tooling support. No behavioral changes. --- lua/opencode/ui/base_picker.lua | 8 +++ lua/opencode/ui/formatter.lua | 12 ++++ lua/opencode/ui/formatter/tools/grep.lua | 32 +++++----- lua/opencode/ui/highlight.lua | 1 + lua/opencode/ui/output_window.lua | 11 ++++ lua/opencode/ui/question_window.lua | 17 +++++ lua/opencode/ui/renderer/buffer.lua | 80 ++++++++++++++++++++++++ lua/opencode/ui/renderer/ctx.lua | 2 + lua/opencode/ui/renderer/events.lua | 5 ++ lua/opencode/ui/renderer/flush.lua | 42 +++++++++++++ lua/opencode/ui/ui.lua | 25 ++++++++ 11 files changed, 220 insertions(+), 15 deletions(-) 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/formatter.lua b/lua/opencode/ui/formatter.lua index 53bd5f23..835331ab 100644 --- a/lua/opencode/ui/formatter.lua +++ b/lua/opencode/ui/formatter.lua @@ -99,6 +99,12 @@ function M._format_revert_message(session_data, start_idx) 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 @@ -384,6 +390,8 @@ 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 @@ -417,6 +425,10 @@ local function get_visible_user_part_kind(part) 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 diff --git a/lua/opencode/ui/formatter/tools/grep.lua b/lua/opencode/ui/formatter/tools/grep.lua index 1f592851..131c9d3c 100644 --- a/lua/opencode/ui/formatter/tools/grep.lua +++ b/lua/opencode/ui/formatter/tools/grep.lua @@ -1,25 +1,27 @@ local icons = require('opencode.ui.icons') local M = {} ----@param input GrepToolInput|nil +---@param value any ---@return string -local function resolve_grep_string(input) - 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 - +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 diff --git a/lua/opencode/ui/highlight.lua b/lua/opencode/ui/highlight.lua index 6762792d..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' diff --git a/lua/opencode/ui/output_window.lua b/lua/opencode/ui/output_window.lua index 2a9cb805..8857cf4d 100644 --- a/lua/opencode/ui/output_window.lua +++ b/lua/opencode/ui/output_window.lua @@ -40,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' @@ -53,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', @@ -180,6 +182,7 @@ function M.sync_cursor_with_viewport(win) 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', @@ -215,6 +218,7 @@ function M.setup(windows) M.setup_keymaps(windows) end +---@param windows OpencodeWindowState? function M.update_dimensions(windows) if config.ui.position == 'current' then return @@ -250,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 @@ -399,6 +404,7 @@ function M.highlight_changed_lines(start_line, end_line) 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 @@ -411,6 +417,7 @@ 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 @@ -422,11 +429,14 @@ function M.close() 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, @@ -472,6 +482,7 @@ function M.setup_autocmds(windows, group) }) 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 f5497447..2a701e03 100644 --- a/lua/opencode/ui/question_window.lua +++ b/lua/opencode/ui/question_window.lua @@ -51,10 +51,12 @@ function M.belongs_to_active_session(question_request) 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 @@ -111,6 +113,7 @@ function M.restore_pending_question(session_id) end) end +---Reset the current question state and remove any dialog UI. function M.clear_question() M._clear_dialog() M._current_question = nil @@ -161,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 @@ -170,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) @@ -198,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 @@ -210,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 @@ -258,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 @@ -272,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 @@ -289,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 @@ -298,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 @@ -320,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/buffer.lua b/lua/opencode/ui/renderer/buffer.lua index 6e728ced..ba8fdea7 100644 --- a/lua/opencode/ui/renderer/buffer.lua +++ b/lua/opencode/ui/renderer/buffer.lua @@ -4,10 +4,14 @@ local output_window = require('opencode.ui.output_window') local M = {} +---@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 @@ -26,10 +30,15 @@ local function accumulate_bulk_extmarks(extmarks, line_start) end end +---@param actions OutputAction[]|nil +---@return boolean local function has_actions(actions) return type(actions) == 'table' and #actions > 0 end +---@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 {} @@ -45,6 +54,9 @@ local function unchanged_prefix_len(previous_formatted, formatted_data) 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 @@ -53,6 +65,9 @@ local function slice_lines(lines, start_idx) 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 @@ -65,10 +80,15 @@ local function slice_extmarks(extmarks, start_line) return slice end +---@param mark OutputExtmark|fun(): OutputExtmark +---@return OutputExtmark local function resolve_mark(mark) return type(mark) == 'function' and mark() or mark end +---@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 {} @@ -86,6 +106,9 @@ local function marks_equal(a, b) return true end +---@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 {} @@ -122,6 +145,8 @@ local function unchanged_extmark_prefix_len(previous_formatted, formatted_data) return prefix_len end +---@param start_line integer +---@param lines string[] local function highlight_written_lines(start_line, lines) if #lines == 0 then return @@ -129,12 +154,21 @@ local function highlight_written_lines(start_line, lines) 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 @@ -145,6 +179,9 @@ local function extmark_clear_range(previous_formatted, formatted_data, line_star 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 @@ -172,6 +209,12 @@ local function extmark_clear_range(previous_formatted, formatted_data, line_star 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 @@ -185,6 +228,8 @@ local function apply_extmarks(previous_formatted, formatted_data, line_start, ol end end +---@param message_id string +---@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 @@ -230,6 +275,9 @@ local function get_message_insert_line(message_id) return append_at end +---@param part_id string +---@param message_id string +---@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 or not rendered_message.line_end then @@ -264,6 +312,10 @@ local function get_part_insertion_line(part_id, message_id) return insertion_line end +---@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) @@ -273,6 +325,9 @@ local function write_at(lines, start_line, end_line) } end +---@param part_id string +---@param formatted_data Output +---@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) @@ -287,6 +342,8 @@ local function apply_part_actions(part_id, formatted_data, line_start) end end +---@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 @@ -294,6 +351,8 @@ local function set_part_extmark_state(part_id, formatted_data) end end +---@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 @@ -307,6 +366,8 @@ function M.get_last_part_for_message(message) return nil end +---@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 @@ -319,10 +380,17 @@ function M.find_text_part_for_message(message) return nil end +---@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 +---@param message_id string +---@param formatted_data Output +---@param previous_formatted Output|nil +---@return boolean function M.upsert_message_now(message_id, formatted_data, previous_formatted) if ctx.bulk_mode then local line_start = #ctx.bulk_buffer_lines @@ -388,6 +456,11 @@ function M.upsert_message_now(message_id, formatted_data, previous_formatted) return false end +---@param part_id string +---@param message_id string +---@param formatted_data Output +---@param previous_formatted Output|nil +---@return boolean 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 @@ -459,6 +532,11 @@ function M.upsert_part_now(part_id, message_id, formatted_data, previous_formatt return false end +---@param part_id string +---@param extra_lines string[] +---@param extra_extmarks table|nil +---@param previous_formatted Output|nil +---@return boolean 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 @@ -485,6 +563,7 @@ function M.append_part_now(part_id, extra_lines, extra_extmarks, previous_format return true end +---@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 @@ -504,6 +583,7 @@ function M.remove_part_now(part_id) ctx.render_state:remove_part(part_id) end +---@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 diff --git a/lua/opencode/ui/renderer/ctx.lua b/lua/opencode/ui/renderer/ctx.lua index 19ea6a15..41983eb0 100644 --- a/lua/opencode/ui/renderer/ctx.lua +++ b/lua/opencode/ui/renderer/ctx.lua @@ -30,6 +30,7 @@ local ctx = { bulk_extmarks_by_line = {}, } +---Reset all renderer caches and pending state. function ctx:reset() self.render_state:reset() self.last_part_formatted = { part_id = nil, formatted_data = nil } @@ -51,6 +52,7 @@ function ctx:reset() self:bulk_reset() end +---Reset the temporary bulk-render accumulators. function ctx:bulk_reset() self.bulk_mode = false self.bulk_buffer_lines = {} diff --git a/lua/opencode/ui/renderer/events.lua b/lua/opencode/ui/renderer/events.lua index 79e9b3b2..52b1b1f6 100644 --- a/lua/opencode/ui/renderer/events.lua +++ b/lua/opencode/ui/renderer/events.lua @@ -4,6 +4,8 @@ local ctx = require('opencode.ui.renderer.ctx') 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 @@ -17,6 +19,8 @@ local function get_last_part_for_message(message) 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 @@ -30,6 +34,7 @@ local function find_text_part_for_message(message) 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 diff --git a/lua/opencode/ui/renderer/flush.lua b/lua/opencode/ui/renderer/flush.lua index 267844db..40de9d4d 100644 --- a/lua/opencode/ui/renderer/flush.lua +++ b/lua/opencode/ui/renderer/flush.lua @@ -9,6 +9,9 @@ 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) @@ -53,6 +56,9 @@ local function with_suppressed_output_autocmds(fn) 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 {} @@ -67,10 +73,15 @@ local function lines_equal(a, b) 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 {} @@ -93,6 +104,7 @@ local function extmarks_equal(a, b) return true end +---@return boolean local function is_markdown_render_deferred() if not config.ui.output.rendering.markdown_on_idle then return false @@ -112,6 +124,9 @@ local function is_markdown_render_deferred() 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 @@ -119,6 +134,8 @@ local function enqueue_once(order, lookup, id) 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 @@ -132,6 +149,8 @@ local function track_message_for_part(message_id, part_id) 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 @@ -143,6 +162,7 @@ local function untrack_message_for_part(message_id, part_id) end end +---@param message_id string|nil function M.mark_message_dirty(message_id) if not message_id then return @@ -155,6 +175,8 @@ function M.mark_message_dirty(message_id) 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 @@ -173,6 +195,7 @@ function M.mark_part_dirty(part_id, message_id) M.schedule() end +---@param part_id string|nil function M.queue_part_removal(part_id) if not part_id then return @@ -190,6 +213,7 @@ function M.queue_part_removal(part_id) M.schedule() end +---@param message_id string|nil function M.queue_message_removal(message_id) if not message_id then return @@ -203,6 +227,7 @@ function M.queue_message_removal(message_id) M.schedule() end +---Schedule a renderer flush on the next event loop tick. function M.schedule() if ctx.flush_scheduled then return @@ -215,6 +240,7 @@ function M.schedule() end) end +---@return RendererCtx['pending'] local function snapshot_pending() local pending = ctx.pending ctx.pending = { @@ -231,6 +257,8 @@ local function snapshot_pending() 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 @@ -250,6 +278,9 @@ local function format_message(message_id) 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 @@ -270,6 +301,7 @@ local function format_part(part_id) 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) @@ -279,6 +311,8 @@ local function apply_message(message_id) 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 @@ -310,6 +344,8 @@ local function apply_part(part_id, message_id) 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 @@ -370,6 +406,7 @@ local function apply_pending(pending) 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 @@ -391,6 +428,7 @@ 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 @@ -401,6 +439,7 @@ function M.request_on_data_rendered(force) 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 @@ -410,11 +449,13 @@ function M.flush_pending_on_data_rendered() 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 @@ -460,6 +501,7 @@ function M.end_bulk_mode() end) end +---Flush all pending renderer changes to the output buffer. function M.flush() local pending = snapshot_pending() local applied = apply_pending(pending) diff --git a/lua/opencode/ui/ui.lua b/lua/opencode/ui/ui.lua index 6ba7089f..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,6 +343,7 @@ 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 local ok, treesitter = pcall(function() @@ -374,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 @@ -404,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 @@ -418,6 +431,7 @@ function M.focus_output(opts) end end +---@return boolean function M.is_opencode_focused() if not state.windows then return false @@ -426,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 @@ -434,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 @@ -443,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() @@ -463,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') @@ -499,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 @@ -508,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' @@ -521,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 @@ -537,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 From 53d586417c12a1b78ab8ef2042d80e2a187dfa7c Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Wed, 1 Apr 2026 10:34:45 -0400 Subject: [PATCH 20/21] test(renderer): make eventignorewin handling robust across Neovim versions Probe 'eventignorewin' with pcall and record presence in has_eventignorewin. Only restore and assert the window-local option when it exists to avoid failures on Neovim builds that don't expose 'eventignorewin'. Add explanatory comments and a small refactor in tests/unit/output_window_spec.lua. --- tests/unit/output_window_spec.lua | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/tests/unit/output_window_spec.lua b/tests/unit/output_window_spec.lua index b5203fc9..fcdd8434 100644 --- a/tests/unit/output_window_spec.lua +++ b/tests/unit/output_window_spec.lua @@ -187,6 +187,7 @@ describe('renderer flush cleanup', function() local win local original_eventignore local original_eventignorewin + local has_eventignorewin local begin_update_stub local end_update_stub local set_lines_stub @@ -203,7 +204,10 @@ describe('renderer flush cleanup', function() }) state.ui.set_windows({ output_buf = buf, output_win = win }) original_eventignore = vim.o.eventignore - original_eventignorewin = vim.api.nvim_get_option_value('eventignorewin', { win = win }) + -- '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() @@ -226,7 +230,8 @@ describe('renderer flush cleanup', function() begin_update_stub:revert() end vim.o.eventignore = original_eventignore - if win and vim.api.nvim_win_is_valid(win) then + 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) @@ -244,7 +249,9 @@ describe('renderer flush cleanup', function() assert.is_false(ok) assert.matches('boom', err) assert.equals(original_eventignore, vim.o.eventignore) - assert.equals(original_eventignorewin, vim.api.nvim_get_option_value('eventignorewin', { win = win })) + 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) From 51922dd5fecce4b0ded15470b3b140ca0083dd4a Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Thu, 2 Apr 2026 11:10:15 -0400 Subject: [PATCH 21/21] feat(renderer): pin permission/question displays to bottom and add tests Add pinned_bottom_message_ids and is_pinned_bottom_message to the buffer renderer so permission-display-message and question-display-message are kept pinned below later messages when rendering. Update get_message_insert_line logic to respect pinned messages and to prefer rendered pinned positions when available. --- lua/opencode/ui/renderer/buffer.lua | 40 ++++++++ lua/opencode/ui/renderer/events.lua | 8 +- tests/unit/permission_integration_spec.lua | 104 +++++++++++++++++++++ 3 files changed, 148 insertions(+), 4 deletions(-) diff --git a/lua/opencode/ui/renderer/buffer.lua b/lua/opencode/ui/renderer/buffer.lua index ba8fdea7..3287dda1 100644 --- a/lua/opencode/ui/renderer/buffer.lua +++ b/lua/opencode/ui/renderer/buffer.lua @@ -4,6 +4,17 @@ 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) @@ -259,12 +270,34 @@ local function get_message_insert_line(message_id) 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 @@ -272,6 +305,13 @@ local function get_message_insert_line(message_id) 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 diff --git a/lua/opencode/ui/renderer/events.lua b/lua/opencode/ui/renderer/events.lua index 52b1b1f6..13d48d23 100644 --- a/lua/opencode/ui/renderer/events.lua +++ b/lua/opencode/ui/renderer/events.lua @@ -414,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 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)