From 28676295af10da1953315cdee9cfda01d67ccfca Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Mon, 26 Jan 2026 09:52:30 -0500 Subject: [PATCH 1/2] refactor(question-window): replace question picker - Replace question picker with a custom dialog in the chat window --- lua/opencode/api.lua | 15 + lua/opencode/types.lua | 11 +- lua/opencode/ui/formatter.lua | 35 + lua/opencode/ui/highlight.lua | 8 + lua/opencode/ui/question_picker.lua | 181 ---- lua/opencode/ui/question_window.lua | 342 +++++++ lua/opencode/ui/renderer.lua | 66 +- ...tiple-question-ask-reply-all.expected.json | 1 + .../data/multiple-question-ask-reply-all.json | 1 + .../data/multiple-question-ask.expected.json | 1 + tests/data/multiple-question-ask.json | 1 + tests/data/question-ask-replied.expected.json | 1 + tests/data/question-ask-replied.json | 1 + tests/data/question-ask.expected.json | 1 + tests/data/question-ask.json | 1 + tests/data/question-replied.json | 873 ++++++++++++++++++ tests/manual/renderer_replay.lua | 2 + tests/replay/renderer_spec.lua | 4 + 18 files changed, 1355 insertions(+), 190 deletions(-) delete mode 100644 lua/opencode/ui/question_picker.lua create mode 100644 lua/opencode/ui/question_window.lua create mode 100644 tests/data/multiple-question-ask-reply-all.expected.json create mode 100644 tests/data/multiple-question-ask-reply-all.json create mode 100644 tests/data/multiple-question-ask.expected.json create mode 100644 tests/data/multiple-question-ask.json create mode 100644 tests/data/question-ask-replied.expected.json create mode 100644 tests/data/question-ask-replied.json create mode 100644 tests/data/question-ask.expected.json create mode 100644 tests/data/question-ask.json create mode 100644 tests/data/question-replied.json diff --git a/lua/opencode/api.lua b/lua/opencode/api.lua index b8c57abc..fb3548b8 100644 --- a/lua/opencode/api.lua +++ b/lua/opencode/api.lua @@ -935,6 +935,21 @@ function M.permission_deny(permission) M.respond_to_permission('reject', permission) end +function M.question_answer() + local question_window = require('opencode.ui.question_window') + local question_info = question_window.get_current_question_info() + if question_info and question_info.options and question_info.options[1] then + question_window._answer_with_option(1) + end +end + +function M.question_other() + local question_window = require('opencode.ui.question_window') + if question_window.has_question() then + question_window._answer_with_custom() + end +end + function M.toggle_tool_output() local action_text = config.ui.output.tools.show_output and 'Hiding' or 'Showing' vim.notify(action_text .. ' tool output display', vim.log.levels.INFO) diff --git a/lua/opencode/types.lua b/lua/opencode/types.lua index ea3e5578..ab65a792 100644 --- a/lua/opencode/types.lua +++ b/lua/opencode/types.lua @@ -208,8 +208,8 @@ ---@field quick_chat OpencodeQuickChatConfig ---@class MessagePartState ----@field input TaskToolInput|BashToolInput|FileToolInput|TodoToolInput|GlobToolInput|GrepToolInput|WebFetchToolInput|ListToolInput Input data for the tool ----@field metadata TaskToolMetadata|ToolMetadataBase|WebFetchToolMetadata|BashToolMetadata|FileToolMetadata|GlobToolMetadata|GrepToolMetadata|ListToolMetadata Metadata about the tool execution +---@field input TaskToolInput|BashToolInput|FileToolInput|TodoToolInput|GlobToolInput|GrepToolInput|WebFetchToolInput|ListToolInput|QuestionToolInput Input data for the tool +---@field metadata TaskToolMetadata|ToolMetadataBase|WebFetchToolMetadata|BashToolMetadata|FileToolMetadata|GlobToolMetadata|GrepToolMetadata|ListToolMetadata|QuestionToolMetadata Metadata about the tool execution ---@field time { start: number, end: number } Timestamps for tool use ---@field status string Status of the tool use (e.g., 'running', 'completed', 'failed') ---@field title string Title of the tool use @@ -307,6 +307,13 @@ ---@field questions OpencodeQuestionInfo[] Questions to ask ---@field tool? { messageID: string, callID: string } +---@class QuestionToolInput +---@field questions OpencodeQuestionInfo[] Questions that were asked + +---@class QuestionToolMetadata: ToolMetadataBase +---@field answers string[][] Array of answer arrays (one per question) +---@field truncated boolean Whether the results were truncated + ---@class MessageTokenCount ---@field reasoning number ---@field input number diff --git a/lua/opencode/ui/formatter.lua b/lua/opencode/ui/formatter.lua index c0b265a4..418a61a7 100644 --- a/lua/opencode/ui/formatter.lua +++ b/lua/opencode/ui/formatter.lua @@ -608,6 +608,30 @@ function M._format_list_tool(output, input, metadata, tool_output) end end +---@param output Output Output object to write to +---@param input QuestionToolInput Question tool input data +---@param metadata QuestionToolMetadata Question tool metadata +---@param status string Status of the tool execution +function M._format_question_tool(output, input, metadata, status) + M.format_action(output, icons.get('question') .. ' question', '') + output:add_empty_line() + if not config.ui.output.tools.show_output or status ~= 'completed' then + return + end + + local questions = input and input.questions or {} + local answers = metadata and metadata.answers or {} + + for i, question in ipairs(questions) do + output:add_line(string.format('**Q%d:** %s', i, question.question or question.header or '')) + local answer = answers[i] and answers[i][1] or 'No answer' + output:add_line(string.format('**A%d:** %s', i, answer)) + if i < #questions then + output:add_line('') + end + end +end + ---@param output Output Output object to write to ---@param part OpencodeMessagePart function M._format_tool(output, part) @@ -637,6 +661,13 @@ function M._format_tool(output, part) M._format_webfetch_tool(output, input --[[@as WebFetchToolInput]]) elseif tool == 'task' then M._format_task_tool(output, input --[[@as TaskToolInput]], metadata --[[@as TaskToolMetadata]], tool_output) + elseif tool == 'question' then + M._format_question_tool( + output, + input --[[@as QuestionToolInput]], + metadata --[[@as QuestionToolMetadata]], + part.state.status + ) else M.format_action(output, icons.get('tool') .. ' tool', tool) end @@ -849,6 +880,10 @@ function M.format_part(part, message, is_last_part) if part.type == 'permissions-display' then permission_window.format_display(output) content_added = true + elseif part.type == 'questions-display' then + local question_window = require('opencode.ui.question_window') + question_window.format_display(output) + content_added = true end end diff --git a/lua/opencode/ui/highlight.lua b/lua/opencode/ui/highlight.lua index 77b047aa..50a4d3de 100644 --- a/lua/opencode/ui/highlight.lua +++ b/lua/opencode/ui/highlight.lua @@ -40,6 +40,10 @@ function M.setup() vim.api.nvim_set_hl(0, 'OpencodeReference', { fg = '#1976D2', default = true }) vim.api.nvim_set_hl(0, 'OpencodeReasoningText', { link = 'Comment', default = true }) vim.api.nvim_set_hl(0, 'OpencodePermissionTitle', { fg = '#FF9E3B', default = true }) + vim.api.nvim_set_hl(0, 'OpencodeQuestionOption', { link = 'Normal', default = true }) + vim.api.nvim_set_hl(0, 'OpencodeQuestionOptionHover', { bg = '#E3F2FD', fg = '#1976D2', 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 }) else vim.api.nvim_set_hl(0, 'OpencodeBorder', { fg = '#616161', default = true }) vim.api.nvim_set_hl(0, 'OpencodeBackground', { link = 'Normal', default = true }) @@ -76,6 +80,10 @@ function M.setup() vim.api.nvim_set_hl(0, 'OpencodeReference', { fg = '#7AA2F7', default = true }) vim.api.nvim_set_hl(0, 'OpencodeReasoningText', { link = 'Comment', default = true }) vim.api.nvim_set_hl(0, 'OpencodePermissionTitle', { fg = '#FF9E3B', default = true }) + vim.api.nvim_set_hl(0, 'OpencodeQuestionOption', { link = 'Normal', default = true }) + vim.api.nvim_set_hl(0, 'OpencodeQuestionOptionHover', { bg = '#2B3A5A', fg = '#61AFEF', 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 }) end end diff --git a/lua/opencode/ui/question_picker.lua b/lua/opencode/ui/question_picker.lua deleted file mode 100644 index fdfaafe9..00000000 --- a/lua/opencode/ui/question_picker.lua +++ /dev/null @@ -1,181 +0,0 @@ --- Question picker UI for handling question tool requests -local state = require('opencode.state') -local icons = require('opencode.ui.icons') -local base_picker = require('opencode.ui.base_picker') -local config = require('opencode.config') - -local M = {} - -M.current_question = nil - ---- Format a question option for the picker ----@param item table Question option item ----@param width? number Optional width ----@return PickerItem -local function format_option(item, width) - local text = item.label - if item.description and item.description ~= '' then - text = text .. ' - ' .. item.description - end - return base_picker.create_time_picker_item(text, nil, nil, width) -end - ---- Show a question picker for the user to answer ---- @param question OpencodeQuestionRequest -function M.show(question) - if not question or not question.questions or #question.questions == 0 then - return - end - - M.current_question = question - - M._show_question(question, 1, {}) -end - ---- Show a single question from the request ---- @param request OpencodeQuestionRequest ---- @param index number Current question index (1-based) ---- @param collected_answers string[][] Answers collected so far -function M._show_question(request, index, collected_answers) - local questions = request.questions - if index > #questions then - -- All questions answered, send reply - M._send_reply(request.id, collected_answers) - return - end - - local q = questions[index] - local items = {} - - for _, opt in ipairs(q.options or {}) do - table.insert(items, { - label = opt.label, - description = opt.description or '', - }) - end - - table.insert(items, { - label = 'Other', - description = 'Provide custom response', - is_other = true, - }) - - -- Build title with full question text (no icon) - local progress = #questions > 1 and string.format(' (%d/%d)', index, #questions) or '' - local title = q.question .. progress - - local actions = {} - - if q.multiple then - -- For multi-select, override the default confirm action to collect all selections - actions.confirm = { - key = { '', mode = { 'i', 'n' } }, - label = 'confirm', - multi_selection = true, - fn = function(selected, opts) - -- Handle the selection - local selections = type(selected) == 'table' and selected.label == nil and selected or { selected } - - -- Check for "Other" option - local has_other = false - local answers = {} - for _, item in ipairs(selections) do - if item.is_other then - has_other = true - else - table.insert(answers, item.label) - end - end - - if has_other and #answers == 0 then - -- Only "Other" selected, prompt for input - vim.ui.input({ prompt = 'Enter your response: ' }, function(input) - if input and input ~= '' then - table.insert(collected_answers, { input }) - M._show_question(request, index + 1, collected_answers) - else - M._send_reject(request.id) - end - end) - elseif #answers > 0 then - table.insert(collected_answers, answers) - M._show_question(request, index + 1, collected_answers) - else - vim.notify('Please select at least one option', vim.log.levels.WARN) - end - - return nil -- Don't reload - end, - } - end - - -- Show full question as notification for context - local question_icon = icons.get('question') or '?' - vim.notify(question_icon .. ' ' .. q.question, vim.log.levels.INFO, { title = 'OpenCode Question' }) - - -- Use base_picker - base_picker.pick({ - items = items, - format_fn = format_option, - title = title, - actions = actions, - width = config.ui.picker_width or 80, - callback = function(selected) - if not selected then - -- User cancelled - M._send_reject(request.id) - return - end - - -- For single-select (no multi action defined), handle here - if not q.multiple then - if selected.is_other then - vim.ui.input({ prompt = 'Enter your response: ' }, function(input) - if input and input ~= '' then - table.insert(collected_answers, { input }) - M._show_question(request, index + 1, collected_answers) - else - M._send_reject(request.id) - end - end) - else - table.insert(collected_answers, { selected.label }) - M._show_question(request, index + 1, collected_answers) - end - end - -- Multi-select is handled by the confirm_multi action - end, - }) -end - ---- Send reply to the question ---- @param request_id string ---- @param answers string[][] -function M._send_reply(request_id, answers) - M.current_question = nil - if state.api_client then - state.api_client:reply_question(request_id, answers):catch(function(err) - vim.notify('Failed to reply to question: ' .. vim.inspect(err), vim.log.levels.ERROR) - end) - end -end - ---- Send reject to the question ---- @param request_id string -function M._send_reject(request_id) - M.current_question = nil - if state.api_client then - state.api_client:reject_question(request_id):catch(function(err) - vim.notify('Failed to reject question: ' .. vim.inspect(err), vim.log.levels.ERROR) - end) - end -end - ---- Check if there's a pending question for the given session ---- @param session_id string ---- @return boolean -function M.has_pending_question(session_id) - return M.current_question ~= nil and M.current_question.sessionID == session_id -end - -return M diff --git a/lua/opencode/ui/question_window.lua b/lua/opencode/ui/question_window.lua new file mode 100644 index 00000000..ee749dff --- /dev/null +++ b/lua/opencode/ui/question_window.lua @@ -0,0 +1,342 @@ +local state = require('opencode.state') +local icons = require('opencode.ui.icons') + +local M = {} + +M._current_question = nil +M._current_question_index = 1 +M._collected_answers = {} +M._key_capture_ns = nil +M._hovered_option = 1 +M._option_line_map = {} +M._answering = false +M._question_keymaps = {} + +local function toggle_input_window(show) + require('opencode.ui.input_window')[show and '_show' or '_hide']() +end + +local function render_question() + require('opencode.ui.renderer').render_question_display() +end + +local function clear_question() + require('opencode.ui.renderer').clear_question_display() +end + +---@param question_request OpencodeQuestionRequest +function M.show_question(question_request) + if not question_request or not question_request.questions or #question_request.questions == 0 then + return + end + + M._current_question = question_request + M._current_question_index = 1 + M._collected_answers = {} + M._hovered_option = 1 + M._setup_key_capture() + render_question() + toggle_input_window(false) +end + +function M.clear_question() + M._clear_key_capture() + M._current_question = nil + M._current_question_index = 1 + M._collected_answers = {} + M._hovered_option = 1 + M._option_line_map = {} + M._answering = false + render_question() + toggle_input_window(true) +end + +---@return OpencodeQuestionInfo|nil +function M.get_current_question_info() + if not M._current_question or not M._current_question.questions then + return nil + end + local questions = M._current_question.questions + local idx = M._current_question_index + return (idx > 0 and idx <= #questions) and questions[idx] or nil +end + +---@return boolean +function M.has_question() + return M._current_question ~= nil and M.get_current_question_info() ~= nil +end + +---@param answer_value string|string[] +local function answer_current_question(answer_value) + local request = M._current_question + if not request then + return + end + + table.insert(M._collected_answers, type(answer_value) == 'table' and answer_value or { answer_value }) + M._current_question_index = M._current_question_index + 1 + + if M._current_question_index > #request.questions then + M._send_reply(request.id, M._collected_answers) + M.clear_question() + else + M._clear_key_capture() + M._hovered_option = 1 + M._setup_key_capture() + end + render_question() +end + +local function find_other_option(options) + for i, opt in ipairs(options) do + if vim.startswith(opt.label:lower(), 'other') then + return i + end + end + return nil +end + +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) +end + +---@param option_index number +function M._answer_with_option(option_index) + local question_info = M.get_current_question_info() + if not question_info or not question_info.options then + return + end + + local other_index = find_other_option(question_info.options) + local total_options = get_total_options(question_info) + + if option_index < 1 or option_index > total_options then + vim.notify('Invalid option selected', vim.log.levels.WARN) + return + end + + if (not other_index and option_index == total_options) or (other_index and option_index == other_index) then + M._answer_with_custom() + return + end + + answer_current_question(question_info.options[option_index].label) +end + +function M._answer_with_custom() + vim.ui.input({ prompt = 'Enter your response: ' }, function(input) + if input and input ~= '' then + answer_current_question(input) + elseif M._current_question.id then + M._send_reject(M._current_question.id) + M.clear_question() + render_question() + end + end) +end + +local function add_other_if_missing(options) + for _, opt in ipairs(options) do + if opt.label:lower() == 'other' then + return options + end + end + local result = vim.deepcopy(options) + table.insert(result, { label = 'Other', description = 'Type your own answer' }) + return result +end + +---@param output Output +function M.format_display(output) + if not M.has_question() then + return + end + + local question_info = M.get_current_question_info() + if not question_info then + return + end + + local formatter = require('opencode.ui.formatter') + local start_line = output:get_line_count() + + local progress = '' + if M._current_question and #M._current_question.questions > 1 then + progress = string.format(' (%d/%d)', M._current_question_index, #M._current_question.questions) + end + + output:add_line(icons.get('question') .. ' Question' .. progress) + output:add_extmark(start_line, { line_hl_group = 'OpencodeQuestionTitle' } --[[@as OutputExtmark]]) + output:add_line('') + output:add_line(question_info.question) + output:add_line('') + + local options_to_display = add_other_if_missing(question_info.options) + local num_options = #options_to_display + + M._option_line_map = {} + for i, option in ipairs(options_to_display) do + local label = option.label + if option.description and option.description ~= '' then + label = label .. ' - ' .. option.description + end + + local line_idx = output:get_line_count() + local line_text = M._hovered_option == i and string.format(' %d. %s ', i, label) + or string.format(' %d. %s', i, label) + + output:add_line(line_text) + M._option_line_map[i] = line_idx + + if M._hovered_option == i then + output:add_extmark(line_idx, { line_hl_group = 'OpencodeQuestionOptionHover' } --[[@as OutputExtmark]]) + output:add_extmark(line_idx, { + start_col = 2, + virt_text = { { '› ', 'OpencodeQuestionOptionHover' } }, + virt_text_pos = 'overlay', + } --[[@as OutputExtmark]]) + end + end + + output:add_line('') + + if M.has_question() and not M._answering then + local ui = require('opencode.ui.ui') + if ui.is_opencode_focused() then + output:add_line('Navigate: `j`/`k` or `↑`/`↓` Select: `` or `1-' .. num_options .. '` Dismiss: ``') + else + output:add_line('Focus Opencode window to answer question') + end + end + + local end_line = output:get_line_count() + formatter.add_vertical_border(output, start_line + 1, end_line, 'OpencodeQuestionBorder', -2) + output:add_line('') +end + +function M._setup_key_capture() + if not M.has_question() then + return + end + + M._clear_key_capture() + + local question_info = M.get_current_question_info() + if not question_info or not state.windows or not state.windows.output_buf then + return + end + + local buf = state.windows.output_buf + local total_options = get_total_options(question_info) + + local function check_focused() + local ui = require('opencode.ui.ui') + return ui.is_opencode_focused() and M.has_question() + end + + local function navigate(delta) + return function() + if not check_focused() then + return + end + M._hovered_option = M._hovered_option + delta + if M._hovered_option < 1 then + M._hovered_option = total_options + elseif M._hovered_option > total_options then + M._hovered_option = 1 + end + render_question() + end + end + + local function select_option() + if not check_focused() then + return + end + M._answering = true + render_question() + M._clear_key_capture() + vim.defer_fn(function() + M._answer_with_option(M._hovered_option) + M._answering = false + end, 100) + end + + local function dismiss_question() + if not check_focused() then + return + end + M._send_reject(M._current_question.id) + M.clear_question() + render_question() + end + + M._question_keymaps = { + vim.keymap.set('n', 'k', navigate(-1), { buffer = buf, silent = true }), + vim.keymap.set('n', 'j', navigate(1), { buffer = buf, silent = true }), + vim.keymap.set('n', '', navigate(-1), { buffer = buf, silent = true }), + vim.keymap.set('n', '', navigate(1), { buffer = buf, silent = true }), + vim.keymap.set('n', '', select_option, { buffer = buf, silent = true }), + vim.keymap.set('n', '', dismiss_question, { buffer = buf, silent = true }), + } + + M._key_capture_ns = vim.api.nvim_create_namespace('opencode_question_keys') + + vim.on_key(function(key, typed) + if not check_focused() or typed == '' then + return + end + + if not M.has_question() then + M._clear_key_capture() + end + + local num = tonumber(key) + if num and num >= 1 and num <= 9 and num <= total_options then + M._answering = true + M._hovered_option = num + render_question() + M._clear_key_capture() + vim.defer_fn(function() + M._answer_with_option(num) + M._answering = false + end, 100) + end + end, M._key_capture_ns) +end + +function M._clear_key_capture() + if M._question_keymaps then + for _, keymap_id in ipairs(M._question_keymaps) do + pcall(vim.keymap.del, 'n', keymap_id, { buffer = state.windows.output_buf }) + end + M._question_keymaps = {} + end + + if M._key_capture_ns then + vim.on_key(nil, M._key_capture_ns) + M._key_capture_ns = nil + end +end + +---@param request_id string +---@param answers string[][] +function M._send_reply(request_id, answers) + if state.api_client then + state.api_client:reply_question(request_id, answers):catch(function(err) + vim.notify('Failed to reply to question: ' .. vim.inspect(err), vim.log.levels.ERROR) + end) + end +end + +---@param request_id string +function M._send_reject(request_id) + if state.api_client then + state.api_client:reject_question(request_id):catch(function(err) + vim.notify('Failed to reject question: ' .. vim.inspect(err), vim.log.levels.ERROR) + end) + end +end + +return M diff --git a/lua/opencode/ui/renderer.lua b/lua/opencode/ui/renderer.lua index eeb6f2b3..35237259 100644 --- a/lua/opencode/ui/renderer.lua +++ b/lua/opencode/ui/renderer.lua @@ -88,6 +88,8 @@ function M.setup_subscriptions(subscribe) { 'permission.asked', M.on_permission_updated }, { 'permission.replied', M.on_permission_replied }, { 'question.asked', M.on_question_asked }, + { 'question.replied', M.clear_question_display }, + { 'question.rejected', M.clear_question_display }, { 'file.edited', M.on_file_edited }, { 'custom.restore_point.created', M.on_restore_points }, { 'custom.emit_events.finished', M.on_emit_events_finished }, @@ -170,7 +172,7 @@ function M._render_full_session_data(session_data) end ---Append permissions display as a fake part at the end -function M._append_permissions_display() +function M.render_permissions_display() local fake_message = { info = { id = 'permission-display-message', @@ -179,7 +181,7 @@ function M._append_permissions_display() }, parts = {}, } - M.on_message_updated(fake_message) + M.on_message_updated(fake_message --[[@as OpencodeMessage]]) local fake_part = { id = 'permission-display-part', @@ -194,6 +196,58 @@ function M._append_permissions_display() end end +function M.clear_question_display() + local question_window = require('opencode.ui.question_window') + question_window.clear_question() + M._remove_part_from_buffer('question-display-part') + M._remove_message_from_buffer('question-display-message') +end + +---Render question display as a fake part +function M.render_question_display() + local question_window = require('opencode.ui.question_window') + + local current_question = question_window._current_question + + if not question_window.has_question() or not current_question or not current_question.id then + M._remove_part_from_buffer('question-display-part') + M._remove_message_from_buffer('question-display-message') + return + end + + local message_id = 'question-display-message' + local part_id = 'question-display-part' + + local fake_message = { + info = { + id = message_id, + sessionID = state.active_session and state.active_session.id or '', + role = 'system', + }, + parts = {}, + } + M.on_message_updated(fake_message --[[@as OpencodeMessage]]) + + local fake_part = { + id = part_id, + messageID = message_id, + sessionID = state.active_session and state.active_session.id or '', + type = 'questions-display', + } + + M.on_part_updated({ part = fake_part }) + M._redraw() + M.scroll_to_bottom(true) +end + +function M._redraw() + if state.windows and state.windows.output_win and vim.api.nvim_win_is_valid(state.windows.output_win) then + vim.api.nvim_win_call(state.windows.output_win, function() + vim.cmd('redraw') + end) + end +end + ---Render lines as the entire output buffer ---@param lines any function M.render_lines(lines) @@ -847,7 +901,7 @@ function M.on_permission_updated(permission) permission_window.add_permission(permission) - M._append_permissions_display() + M.render_permissions_display() M._rerender_part('permission-display-part') M.scroll_to_bottom() @@ -886,10 +940,8 @@ function M.on_question_asked(properties) return end - vim.schedule(function() - local question_picker = require('opencode.ui.question_picker') - question_picker.show(properties) - end) + local question_window = require('opencode.ui.question_window') + question_window.show_question(properties) end function M.on_file_edited(properties) diff --git a/tests/data/multiple-question-ask-reply-all.expected.json b/tests/data/multiple-question-ask-reply-all.expected.json new file mode 100644 index 00000000..fac194c2 --- /dev/null +++ b/tests/data/multiple-question-ask-reply-all.expected.json @@ -0,0 +1 @@ +{"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** ","","**Q1:** Which Lua testing framework do you use or prefer for your Neovim Lua plugins?","**A1:** busted","","**Q2:** How do you usually run your tests in this codebase?","**A2:** run_tests.sh (Recommended)","","**󰻛 Created Snapshot** `be64b620`","","----","","","Thanks for your answers! You've indicated:","","- Preferred testing framework: **busted**","- Typical test running method: ** run_tests.sh (Recommended)**","","I have this in mind for any future testing, troubleshooting, or automation tasks you request for this Neovim Lua codebase. If you have any follow-up questions or tasks (such as test writing, modification, running, debugging, or reporting), let me know—I'll tailor my workflow to these preferences for maximal compatibility and consistency!","",""],"timestamp":1769438913,"actions":[{"type":"diff_revert_selected_file","key":"R","range":{"from":18,"to":18},"display_line":18,"args":["be64b6205f0da4c9240df2476dc35495429161b5"],"text":"[R]evert file"},{"type":"diff_revert_all","key":"A","range":{"from":18,"to":18},"display_line":18,"args":["be64b6205f0da4c9240df2476dc35495429161b5"],"text":"Revert [A]ll"},{"type":"diff_open","key":"D","range":{"from":18,"to":18},"display_line":18,"args":["be64b6205f0da4c9240df2476dc35495429161b5"],"text":"[D]iff"}],"extmarks":[[1,1,0,{"right_gravity":true,"priority":10,"virt_text":[["▌󰭻 ","OpencodeMessageRoleUser"],[" "],["USER","OpencodeMessageRoleUser"],["","OpencodeHint"],[" (2026-01-26 14:30:46)","OpencodeHint"],[" [msg_bfab6dbf3001ZOVHTFKR1CMUE5]","OpencodeHint"]],"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_repeat_linebreak":false,"virt_text_win_col":-3,"ns_id":3}],[2,2,0,{"right_gravity":true,"priority":4096,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_repeat_linebreak":true,"virt_text_win_col":-3,"ns_id":3}],[3,3,0,{"right_gravity":true,"priority":4096,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_repeat_linebreak":true,"virt_text_win_col":-3,"ns_id":3}],[4,4,0,{"right_gravity":true,"priority":4096,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_repeat_linebreak":true,"virt_text_win_col":-3,"ns_id":3}],[5,5,0,{"right_gravity":true,"priority":4096,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_repeat_linebreak":true,"virt_text_win_col":-3,"ns_id":3}],[6,6,0,{"right_gravity":true,"priority":4096,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_repeat_linebreak":true,"virt_text_win_col":-3,"ns_id":3}],[7,8,0,{"right_gravity":true,"priority":10,"virt_text":[[" ","OpencodeMessageRoleAssistant"],[" "],["BUILD","OpencodeMessageRoleAssistant"],[" gpt-4.1","OpencodeHint"],[" (2026-01-26 14:30:46)","OpencodeHint"],[" [msg_bfab6dc80001FueCN7E2691J2R]","OpencodeHint"]],"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_repeat_linebreak":false,"virt_text_win_col":-3,"ns_id":3}],[8,10,0,{"right_gravity":true,"priority":4096,"virt_text":[["▌","OpencodeToolBorder"]],"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_repeat_linebreak":true,"virt_text_win_col":-1,"ns_id":3}],[9,11,0,{"right_gravity":true,"priority":4096,"virt_text":[["▌","OpencodeToolBorder"]],"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_repeat_linebreak":true,"virt_text_win_col":-1,"ns_id":3}],[10,12,0,{"right_gravity":true,"priority":4096,"virt_text":[["▌","OpencodeToolBorder"]],"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_repeat_linebreak":true,"virt_text_win_col":-1,"ns_id":3}],[11,13,0,{"right_gravity":true,"priority":4096,"virt_text":[["▌","OpencodeToolBorder"]],"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_repeat_linebreak":true,"virt_text_win_col":-1,"ns_id":3}],[12,14,0,{"right_gravity":true,"priority":4096,"virt_text":[["▌","OpencodeToolBorder"]],"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_repeat_linebreak":true,"virt_text_win_col":-1,"ns_id":3}],[13,15,0,{"right_gravity":true,"priority":4096,"virt_text":[["▌","OpencodeToolBorder"]],"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_repeat_linebreak":true,"virt_text_win_col":-1,"ns_id":3}],[14,16,0,{"right_gravity":true,"priority":4096,"virt_text":[["▌","OpencodeToolBorder"]],"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_repeat_linebreak":true,"virt_text_win_col":-1,"ns_id":3}],[15,21,0,{"right_gravity":true,"priority":10,"virt_text":[[" ","OpencodeMessageRoleAssistant"],[" "],["BUILD","OpencodeMessageRoleAssistant"],[" gpt-4.1","OpencodeHint"],[" (2026-01-26 14:31:55)","OpencodeHint"],[" [msg_bfab7e7fd0018kA9yLHMkJM3fA]","OpencodeHint"]],"virt_text_pos":"win_col","virt_text_hide":false,"virt_text_repeat_linebreak":false,"virt_text_win_col":-3,"ns_id":3}]]} \ No newline at end of file diff --git a/tests/data/multiple-question-ask-reply-all.json b/tests/data/multiple-question-ask-reply-all.json new file mode 100644 index 00000000..4992ab94 --- /dev/null +++ b/tests/data/multiple-question-ask-reply-all.json @@ -0,0 +1 @@ +[{"type":"session.updated","properties":{"info":{"slug":"happy-harbor","time":{"created":1769437845874,"updated":1769437845874},"projectID":"29d43526f88157cd4edd071b899dd01f240b771b","id":"ses_40549268dffehKpDd14uHIMQaT","version":"1.1.31","title":"New session - 2026-01-26T14:30:45.874Z","directory":"/home/francis/Projects/_nvim/opencode.nvim"}}},{"type":"custom.emit_events.finished","properties":[]},{"type":"message.updated","properties":{"info":{"id":"msg_bfab6dbf3001ZOVHTFKR1CMUE5","time":{"created":1769437846515},"role":"user","agent":"build","sessionID":"ses_40549268dffehKpDd14uHIMQaT","model":{"providerID":"github-copilot","modelID":"gpt-4.1"}}}},{"type":"message.part.updated","properties":{"part":{"type":"text","text":"Called the Read tool with the following input: {\"filePath\":\"/home/francis/Projects/_nvim/opencode.nvim/tests/replay/renderer_spec.lua\"}","sessionID":"ses_40549268dffehKpDd14uHIMQaT","messageID":"msg_bfab6dbf3001ZOVHTFKR1CMUE5","id":"prt_bfab6dbf4002ydMd2JojY4Nda0","synthetic":true}}},{"type":"message.part.updated","properties":{"part":{"type":"text","text":"\n00001| local state = require('opencode.state')\n00002| local ui = require('opencode.ui.ui')\n00003| local helpers = require('tests.helpers')\n00004| local output_window = require('opencode.ui.output_window')\n00005| local assert = require('luassert')\n00006| local config = require('opencode.config')\n00007| \n00008| local function assert_output_matches(expected, actual, name)\n00009| local normalized_extmarks = helpers.normalize_namespace_ids(actual.extmarks)\n00010| \n00011| assert.are.equal(\n00012| #expected.lines,\n00013| #actual.lines,\n00014| string.format(\n00015| 'Line count mismatch: expected %d, got %d.\\nFirst difference at index %d:\\n Expected: %s\\n Actual: %s',\n00016| #expected.lines,\n00017| #actual.lines,\n00018| math.min(#expected.lines, #actual.lines) + 1,\n00019| vim.inspect(expected.lines[math.min(#expected.lines, #actual.lines) + 1]),\n00020| vim.inspect(actual.lines[math.min(#expected.lines, #actual.lines) + 1])\n00021| )\n00022| )\n00023| \n00024| for i = 1, #expected.lines do\n00025| assert.are.equal(\n00026| expected.lines[i],\n00027| actual.lines[i],\n00028| string.format(\n00029| 'Line %d mismatch:\\n Expected: %s\\n Actual: %s',\n00030| i,\n00031| vim.inspect(expected.lines[i]),\n00032| vim.inspect(actual.lines[i])\n00033| )\n00034| )\n00035| end\n00036| \n00037| assert.are.equal(\n00038| #expected.extmarks,\n00039| #normalized_extmarks,\n00040| string.format(\n00041| 'Extmark count mismatch: expected %d, got %d.\\nFirst difference at index %d:\\n Expected: %s\\n Actual: %s',\n00042| #expected.extmarks,\n00043| #normalized_extmarks,\n00044| math.min(#expected.extmarks, #normalized_extmarks) + 1,\n00045| vim.inspect(expected.extmarks[math.min(#expected.extmarks, #normalized_extmarks) + 1]),\n00046| vim.inspect(normalized_extmarks[math.min(#expected.extmarks, #normalized_extmarks) + 1])\n00047| )\n00048| )\n00049| \n00050| for i = 1, #expected.extmarks do\n00051| assert.are.same(\n00052| expected.extmarks[i],\n00053| normalized_extmarks[i],\n00054| string.format(\n00055| 'Extmark %d mismatch:\\n Expected: %s\\n Actual: %s',\n00056| i,\n00057| vim.inspect(expected.extmarks[i]),\n00058| vim.inspect(normalized_extmarks[i])\n00059| )\n00060| )\n00061| end\n00062| \n00063| local expected_action_count = expected.actions and #expected.actions or 0\n00064| local actual_action_count = actual.actions and #actual.actions or 0\n00065| \n00066| assert.are.equal(\n00067| expected_action_count,\n00068| actual_action_count,\n00069| string.format('Action count mismatch: expected %d, got %d', expected_action_count, actual_action_count)\n00070| )\n00071| \n00072| if expected.actions then\n00073| -- Sort both arrays for consistent comparison since order doesn't matter\n00074| local function sort_actions(actions)\n00075| local sorted = vim.deepcopy(actions)\n00076| table.sort(sorted, function(a, b)\n00077| return vim.inspect(a) < vim.inspect(b)\n00078| end)\n00079| return sorted\n00080| end\n00081| \n00082| assert.same(\n00083| sort_actions(expected.actions),\n00084| sort_actions(actual.actions),\n00085| string.format(\n00086| 'Actions mismatch:\\n Expected: %s\\n Actual: %s',\n00087| vim.inspect(expected.actions),\n00088| vim.inspect(actual.actions)\n00089| )\n00090| )\n00091| end\n00092| end\n00093| \n00094| describe('renderer unit tests', function()\n00095| local event_subscriptions = {\n00096| 'session.updated',\n00097| 'session.compacted',\n00098| 'session.error',\n00099| 'message.updated',\n00100| 'message.removed',\n00101| 'message.part.updated',\n00102| 'message.part.removed',\n00103| 'permission.updated',\n00104| 'permission.replied',\n00105| 'question.replied',\n00106| 'question.asked',\n00107| 'file.edited',\n00108| 'custom.restore_point.created',\n00109| 'custom.emit_events.finished',\n00110| }\n00111| \n00112| before_each(function()\n00113| require('opencode.event_manager').setup()\n00114| end)\n00115| \n00116| it('subsribes to events correctly', function()\n00117| local renderer = require('opencode.ui.renderer')\n00118| local event_manager = state.event_manager\n00119| \n00120| event_manager.events = {}\n00121| \n00122| renderer.setup_subscriptions()\n00123| \n00124| for _, event_name in ipairs(event_subscriptions) do\n00125| assert.is_true(\n00126| event_manager.events[event_name] ~= nil,\n00127| string.format('Renderer did not subscribe to event: %s', event_name)\n00128| )\n00129| end\n00130| end)\n00131| \n00132| it('unsubsribes from events correctly', function()\n00133| local renderer = require('opencode.ui.renderer')\n00134| local event_manager = state.event_manager\n00135| \n00136| renderer.setup_subscriptions()\n00137| \n00138| renderer.setup_subscriptions(false)\n00139| \n00140| for _, event_name in ipairs(event_subscriptions) do\n00141| assert.is_true(\n00142| vim.tbl_isempty(event_manager.events[event_name]),\n00143| string.format('Renderer did not unsubscribe from event: %s', event_name)\n00144| )\n00145| end\n00146| end)\n00147| end)\n00148| \n00149| describe('renderer functional tests', function()\n00150| config.debug.show_ids = true\n00151| \n00152| before_each(function()\n00153| helpers.replay_setup()\n00154| end)\n00155| \n00156| after_each(function()\n00157| if state.windows then\n00158| ui.close_windows(state.windows)\n00159| end\n00160| end)\n00161| \n00162| local json_files = vim.fn.glob('tests/data/*.json', false, true)\n00163| \n00164| -- Don't do the full session test on these files, usually\n00165| -- because they involve permission prompts\n00166| local skip_full_session = {\n00167| 'permission-prompt',\n00168| 'permission-ask-new',\n00169| 'question-ask',\n00170| 'shifting-and-multiple-perms',\n00171| 'message-removal',\n00172| }\n00173| \n00174| for _, filepath in ipairs(json_files) do\n00175| local name = vim.fn.fnamemodify(filepath, ':t:r')\n00176| \n00177| if not name:match('%.expected$') then\n00178| local expected_path = 'tests/data/' .. name .. '.expected.json'\n00179| \n00180| if vim.fn.filereadable(expected_path) == 1 then\n00181| for i = 1, 2 do\n00182| config.ui.output.rendering.event_collapsing = i == 1 and true or false\n00183| it(\n00184| 'replays '\n00185| .. name\n00186| .. ' correctly (event-by-event, '\n00187| .. (config.ui.output.rendering.event_collapsing and 'collapsing' or 'no collapsing')\n00188| .. ')',\n00189| function()\n00190| local events = helpers.load_test_data(filepath)\n00191| state.active_session = helpers.get_session_from_events(events)\n00192| local expected = helpers.load_test_data(expected_path)\n00193| \n00194| helpers.replay_events(events)\n00195| vim.wait(1000, function()\n00196| return vim.tbl_isempty(state.event_manager.throttling_emitter.queue)\n00197| end)\n00198| \n00199| local actual = helpers.capture_output(state.windows and state.windows.output_buf, output_window.namespace)\n00200| assert_output_matches(expected, actual, name)\n00201| end\n00202| )\n00203| end\n00204| \n00205| if not vim.tbl_contains(skip_full_session, name) then\n00206| it('replays ' .. name .. ' correctly (session)', function()\n00207| local renderer = require('opencode.ui.renderer')\n00208| local events = helpers.load_test_data(filepath)\n00209| state.active_session = helpers.get_session_from_events(events, true)\n00210| local expected = helpers.load_test_data(expected_path)\n00211| \n00212| local session_data = helpers.load_session_from_events(events)\n00213| renderer._render_full_session_data(session_data)\n00214| \n00215| local actual = helpers.capture_output(state.windows and state.windows.output_buf, output_window.namespace)\n00216| assert_output_matches(expected, actual, name)\n00217| end)\n00218| end\n00219| end\n00220| end\n00221| end\n00222| end)\n00223| \n\n(End of file - total 223 lines)\n","sessionID":"ses_40549268dffehKpDd14uHIMQaT","messageID":"msg_bfab6dbf3001ZOVHTFKR1CMUE5","id":"prt_bfab6dbf80012C7P5ugGXl7Kbv","synthetic":true}}},{"type":"message.part.updated","properties":{"part":{"mime":"text/plain","type":"file","sessionID":"ses_40549268dffehKpDd14uHIMQaT","id":"prt_bfab6dbf9001Kebk9O9AoUc4yI","messageID":"msg_bfab6dbf3001ZOVHTFKR1CMUE5","filename":"tests/replay/renderer_spec.lua","url":"file:///home/francis/Projects/_nvim/opencode.nvim/tests/replay/renderer_spec.lua"}}},{"type":"message.part.updated","properties":{"part":{"type":"text","text":"can you use the question tool and ask me a couple of questions","sessionID":"ses_40549268dffehKpDd14uHIMQaT","messageID":"msg_bfab6dbf3001ZOVHTFKR1CMUE5","id":"prt_bfab6dbf4001xrYv3BzlgWGgkv"}}},{"type":"session.updated","properties":{"info":{"slug":"happy-harbor","time":{"created":1769437845874,"updated":1769437846547},"projectID":"29d43526f88157cd4edd071b899dd01f240b771b","id":"ses_40549268dffehKpDd14uHIMQaT","version":"1.1.31","title":"New session - 2026-01-26T14:30:45.874Z","directory":"/home/francis/Projects/_nvim/opencode.nvim"}}},{"type":"custom.emit_events.finished","properties":[]},{"type":"message.updated","properties":{"info":{"id":"msg_bfab6dc80001FueCN7E2691J2R","modelID":"gpt-4.1","providerID":"github-copilot","cost":0,"sessionID":"ses_40549268dffehKpDd14uHIMQaT","mode":"build","time":{"created":1769437846657},"role":"assistant","path":{"cwd":"/home/francis/Projects/_nvim/opencode.nvim","root":"/home/francis/Projects/_nvim/opencode.nvim"},"agent":"build","tokens":{"reasoning":0,"input":0,"output":0,"cache":{"write":0,"read":0}},"parentID":"msg_bfab6dbf3001ZOVHTFKR1CMUE5"}}},{"type":"custom.emit_events.finished","properties":[]},{"type":"message.updated","properties":{"info":{"id":"msg_bfab6dbf3001ZOVHTFKR1CMUE5","time":{"created":1769437846515},"role":"user","agent":"build","sessionID":"ses_40549268dffehKpDd14uHIMQaT","summary":{"diffs":[]},"model":{"providerID":"github-copilot","modelID":"gpt-4.1"}}}},{"type":"session.updated","properties":{"info":{"id":"ses_40549268dffehKpDd14uHIMQaT","slug":"happy-harbor","directory":"/home/francis/Projects/_nvim/opencode.nvim","projectID":"29d43526f88157cd4edd071b899dd01f240b771b","title":"New session - 2026-01-26T14:30:45.874Z","version":"1.1.31","summary":{"additions":0,"deletions":0,"files":0},"time":{"created":1769437845874,"updated":1769437846728}}}},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"message.part.updated","properties":{"part":{"type":"step-start","snapshot":"be64b6205f0da4c9240df2476dc35495429161b5","sessionID":"ses_40549268dffehKpDd14uHIMQaT","messageID":"msg_bfab6dc80001FueCN7E2691J2R","id":"prt_bfab6e0c10013qo2ZThCt7FqLA"}}},{"type":"message.part.updated","properties":{"part":{"id":"prt_bfab6e0c4001ou3kcu0UICsKQq","tool":"question","sessionID":"ses_40549268dffehKpDd14uHIMQaT","callID":"call_wHoqSXKi7juWQVtpwv4tMMTj","messageID":"msg_bfab6dc80001FueCN7E2691J2R","type":"tool","state":{"raw":"","input":{},"status":"pending"}}}},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"question.asked","properties":{"questions":[{"question":"Which Lua testing framework do you use or prefer for your Neovim Lua plugins?","header":"Testing Framework?","options":[{"label":"busted","description":"Busted is the most common Lua testing framework."},{"label":"plenary.nvim","description":"Plenary.nvim provides unit test utilities for Neovim plugins."},{"label":"other","description":"Other or custom test framework"}]},{"question":"How do you usually run your tests in this codebase?","header":"Run Tests Approach","options":[{"label":"run_tests.sh (Recommended)","description":"Use run_tests.sh script provided in repo root."},{"label":"Direct test runner","description":"Use individual test commands (e.g., invoke Busted/Plenary directly)."},{"label":"Other","description":"Other custom scripts or workflow"}]}],"id":"que_bfab6ef4f0010bzEmrR38ndiIA","sessionID":"ses_40549268dffehKpDd14uHIMQaT","tool":{"messageID":"msg_bfab6dc80001FueCN7E2691J2R","callID":"call_wHoqSXKi7juWQVtpwv4tMMTj"}}},{"type":"message.part.updated","properties":{"part":{"id":"prt_bfab6e0c4001ou3kcu0UICsKQq","tool":"question","sessionID":"ses_40549268dffehKpDd14uHIMQaT","callID":"call_wHoqSXKi7juWQVtpwv4tMMTj","messageID":"msg_bfab6dc80001FueCN7E2691J2R","type":"tool","state":{"input":{"questions":[{"question":"Which Lua testing framework do you use or prefer for your Neovim Lua plugins?","header":"Testing Framework?","options":[{"label":"busted","description":"Busted is the most common Lua testing framework."},{"label":"plenary.nvim","description":"Plenary.nvim provides unit test utilities for Neovim plugins."},{"label":"other","description":"Other or custom test framework"}]},{"question":"How do you usually run your tests in this codebase?","header":"Run Tests Approach","options":[{"label":"run_tests.sh (Recommended)","description":"Use run_tests.sh script provided in repo root."},{"label":"Direct test runner","description":"Use individual test commands (e.g., invoke Busted/Plenary directly)."},{"label":"Other","description":"Other custom scripts or workflow"}]}]},"time":{"start":1769437851471},"status":"running"}}}},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"message.updated","properties":{"info":{"id":"msg_bfab6dbf3001ZOVHTFKR1CMUE5","time":{"created":1769437846515},"role":"user","agent":"build","sessionID":"ses_40549268dffehKpDd14uHIMQaT","summary":{"title":"Ask me two questions","diffs":[]},"model":{"providerID":"github-copilot","modelID":"gpt-4.1"}}}},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"session.updated","properties":{"info":{"id":"ses_40549268dffehKpDd14uHIMQaT","slug":"happy-harbor","directory":"/home/francis/Projects/_nvim/opencode.nvim","projectID":"29d43526f88157cd4edd071b899dd01f240b771b","title":"Ask questions about renderer_spec.lua","version":"1.1.31","summary":{"additions":0,"deletions":0,"files":0},"time":{"created":1769437845874,"updated":1769437846728}}}},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"question.replied","properties":{"requestID":"que_bfab6ef4f0010bzEmrR38ndiIA","sessionID":"ses_40549268dffehKpDd14uHIMQaT","answers":[["busted"],["run_tests.sh (Recommended)"]]}},{"type":"message.part.updated","properties":{"part":{"id":"prt_bfab6e0c4001ou3kcu0UICsKQq","tool":"question","sessionID":"ses_40549268dffehKpDd14uHIMQaT","callID":"call_wHoqSXKi7juWQVtpwv4tMMTj","messageID":"msg_bfab6dc80001FueCN7E2691J2R","type":"tool","state":{"metadata":{"answers":[["busted"],["run_tests.sh (Recommended)"]],"truncated":false},"output":"User has answered your questions: \"Which Lua testing framework do you use or prefer for your Neovim Lua plugins?\"=\"busted\", \"How do you usually run your tests in this codebase?\"=\"run_tests.sh (Recommended)\". You can now continue with the user's answers in mind.","time":{"start":1769437851471,"end":1769437915067},"input":{"questions":[{"question":"Which Lua testing framework do you use or prefer for your Neovim Lua plugins?","header":"Testing Framework?","options":[{"label":"busted","description":"Busted is the most common Lua testing framework."},{"label":"plenary.nvim","description":"Plenary.nvim provides unit test utilities for Neovim plugins."},{"label":"other","description":"Other or custom test framework"}]},{"question":"How do you usually run your tests in this codebase?","header":"Run Tests Approach","options":[{"label":"run_tests.sh (Recommended)","description":"Use run_tests.sh script provided in repo root."},{"label":"Direct test runner","description":"Use individual test commands (e.g., invoke Busted/Plenary directly)."},{"label":"Other","description":"Other custom scripts or workflow"}]}]},"title":"Asked 2 questions","status":"completed"}}}},{"type":"message.part.updated","properties":{"part":{"id":"prt_bfab7e7c1001GZ9QrtUEO7l0zh","type":"step-finish","snapshot":"bef5baca5e1828492c87ba3e7d702707c782f6ec","sessionID":"ses_40549268dffehKpDd14uHIMQaT","cost":0,"reason":"tool-calls","tokens":{"reasoning":0,"input":2789,"output":176,"cache":{"write":0,"read":9984}},"messageID":"msg_bfab6dc80001FueCN7E2691J2R"}}},{"type":"message.updated","properties":{"info":{"id":"msg_bfab6dc80001FueCN7E2691J2R","modelID":"gpt-4.1","providerID":"github-copilot","cost":0,"sessionID":"ses_40549268dffehKpDd14uHIMQaT","mode":"build","agent":"build","time":{"created":1769437846657},"role":"assistant","path":{"cwd":"/home/francis/Projects/_nvim/opencode.nvim","root":"/home/francis/Projects/_nvim/opencode.nvim"},"parentID":"msg_bfab6dbf3001ZOVHTFKR1CMUE5","tokens":{"reasoning":0,"input":2789,"output":176,"cache":{"write":0,"read":9984}},"finish":"tool-calls"}}},{"type":"custom.emit_events.finished","properties":[]},{"type":"message.part.updated","properties":{"part":{"type":"patch","sessionID":"ses_40549268dffehKpDd14uHIMQaT","id":"prt_bfab7e7f30018PKHf93IEAI3AX","messageID":"msg_bfab6dc80001FueCN7E2691J2R","hash":"be64b6205f0da4c9240df2476dc35495429161b5","files":["/home/francis/Projects/_nvim/opencode.nvim/tests/data/multiple-question-ask.json"]}}},{"type":"message.updated","properties":{"info":{"id":"msg_bfab6dc80001FueCN7E2691J2R","modelID":"gpt-4.1","providerID":"github-copilot","cost":0,"sessionID":"ses_40549268dffehKpDd14uHIMQaT","mode":"build","agent":"build","time":{"created":1769437846657,"completed":1769437915127},"role":"assistant","path":{"cwd":"/home/francis/Projects/_nvim/opencode.nvim","root":"/home/francis/Projects/_nvim/opencode.nvim"},"parentID":"msg_bfab6dbf3001ZOVHTFKR1CMUE5","tokens":{"reasoning":0,"input":2789,"output":176,"cache":{"write":0,"read":9984}},"finish":"tool-calls"}}},{"type":"message.updated","properties":{"info":{"id":"msg_bfab7e7fd0018kA9yLHMkJM3fA","modelID":"gpt-4.1","providerID":"github-copilot","cost":0,"sessionID":"ses_40549268dffehKpDd14uHIMQaT","mode":"build","time":{"created":1769437915134},"role":"assistant","path":{"cwd":"/home/francis/Projects/_nvim/opencode.nvim","root":"/home/francis/Projects/_nvim/opencode.nvim"},"agent":"build","tokens":{"reasoning":0,"input":0,"output":0,"cache":{"write":0,"read":0}},"parentID":"msg_bfab6dbf3001ZOVHTFKR1CMUE5"}}},{"type":"custom.emit_events.finished","properties":[]},{"type":"message.updated","properties":{"info":{"id":"msg_bfab6dbf3001ZOVHTFKR1CMUE5","time":{"created":1769437846515},"role":"user","agent":"build","sessionID":"ses_40549268dffehKpDd14uHIMQaT","summary":{"title":"Ask me two questions","diffs":[{"before":"[DEBUG ERROR] git show be64b6205f0da4c9240df2476dc35495429161b5:tests/data/multiple-question-ask.json failed: fatal: path 'tests/data/multiple-question-ask.json' exists on disk, but not in 'be64b6205f0da4c9240df2476dc35495429161b5'\n","after":"[{\"type\":\"session.updated\",\"properties\":{\"info\":{\"slug\":\"happy-harbor\",\"time\":{\"created\":1769437845874,\"updated\":1769437845874},\"projectID\":\"29d43526f88157cd4edd071b899dd01f240b771b\",\"id\":\"ses_40549268dffehKpDd14uHIMQaT\",\"version\":\"1.1.31\",\"title\":\"New session - 2026-01-26T14:30:45.874Z\",\"directory\":\"/home/francis/Projects/_nvim/opencode.nvim\"}}},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"message.updated\",\"properties\":{\"info\":{\"id\":\"msg_bfab6dbf3001ZOVHTFKR1CMUE5\",\"time\":{\"created\":1769437846515},\"role\":\"user\",\"agent\":\"build\",\"sessionID\":\"ses_40549268dffehKpDd14uHIMQaT\",\"model\":{\"providerID\":\"github-copilot\",\"modelID\":\"gpt-4.1\"}}}},{\"type\":\"message.part.updated\",\"properties\":{\"part\":{\"type\":\"text\",\"text\":\"Called the Read tool with the following input: {\\\"filePath\\\":\\\"/home/francis/Projects/_nvim/opencode.nvim/tests/replay/renderer_spec.lua\\\"}\",\"sessionID\":\"ses_40549268dffehKpDd14uHIMQaT\",\"messageID\":\"msg_bfab6dbf3001ZOVHTFKR1CMUE5\",\"id\":\"prt_bfab6dbf4002ydMd2JojY4Nda0\",\"synthetic\":true}}},{\"type\":\"message.part.updated\",\"properties\":{\"part\":{\"type\":\"text\",\"text\":\"\\n00001| local state = require('opencode.state')\\n00002| local ui = require('opencode.ui.ui')\\n00003| local helpers = require('tests.helpers')\\n00004| local output_window = require('opencode.ui.output_window')\\n00005| local assert = require('luassert')\\n00006| local config = require('opencode.config')\\n00007| \\n00008| local function assert_output_matches(expected, actual, name)\\n00009| local normalized_extmarks = helpers.normalize_namespace_ids(actual.extmarks)\\n00010| \\n00011| assert.are.equal(\\n00012| #expected.lines,\\n00013| #actual.lines,\\n00014| string.format(\\n00015| 'Line count mismatch: expected %d, got %d.\\\\nFirst difference at index %d:\\\\n Expected: %s\\\\n Actual: %s',\\n00016| #expected.lines,\\n00017| #actual.lines,\\n00018| math.min(#expected.lines, #actual.lines) + 1,\\n00019| vim.inspect(expected.lines[math.min(#expected.lines, #actual.lines) + 1]),\\n00020| vim.inspect(actual.lines[math.min(#expected.lines, #actual.lines) + 1])\\n00021| )\\n00022| )\\n00023| \\n00024| for i = 1, #expected.lines do\\n00025| assert.are.equal(\\n00026| expected.lines[i],\\n00027| actual.lines[i],\\n00028| string.format(\\n00029| 'Line %d mismatch:\\\\n Expected: %s\\\\n Actual: %s',\\n00030| i,\\n00031| vim.inspect(expected.lines[i]),\\n00032| vim.inspect(actual.lines[i])\\n00033| )\\n00034| )\\n00035| end\\n00036| \\n00037| assert.are.equal(\\n00038| #expected.extmarks,\\n00039| #normalized_extmarks,\\n00040| string.format(\\n00041| 'Extmark count mismatch: expected %d, got %d.\\\\nFirst difference at index %d:\\\\n Expected: %s\\\\n Actual: %s',\\n00042| #expected.extmarks,\\n00043| #normalized_extmarks,\\n00044| math.min(#expected.extmarks, #normalized_extmarks) + 1,\\n00045| vim.inspect(expected.extmarks[math.min(#expected.extmarks, #normalized_extmarks) + 1]),\\n00046| vim.inspect(normalized_extmarks[math.min(#expected.extmarks, #normalized_extmarks) + 1])\\n00047| )\\n00048| )\\n00049| \\n00050| for i = 1, #expected.extmarks do\\n00051| assert.are.same(\\n00052| expected.extmarks[i],\\n00053| normalized_extmarks[i],\\n00054| string.format(\\n00055| 'Extmark %d mismatch:\\\\n Expected: %s\\\\n Actual: %s',\\n00056| i,\\n00057| vim.inspect(expected.extmarks[i]),\\n00058| vim.inspect(normalized_extmarks[i])\\n00059| )\\n00060| )\\n00061| end\\n00062| \\n00063| local expected_action_count = expected.actions and #expected.actions or 0\\n00064| local actual_action_count = actual.actions and #actual.actions or 0\\n00065| \\n00066| assert.are.equal(\\n00067| expected_action_count,\\n00068| actual_action_count,\\n00069| string.format('Action count mismatch: expected %d, got %d', expected_action_count, actual_action_count)\\n00070| )\\n00071| \\n00072| if expected.actions then\\n00073| -- Sort both arrays for consistent comparison since order doesn't matter\\n00074| local function sort_actions(actions)\\n00075| local sorted = vim.deepcopy(actions)\\n00076| table.sort(sorted, function(a, b)\\n00077| return vim.inspect(a) < vim.inspect(b)\\n00078| end)\\n00079| return sorted\\n00080| end\\n00081| \\n00082| assert.same(\\n00083| sort_actions(expected.actions),\\n00084| sort_actions(actual.actions),\\n00085| string.format(\\n00086| 'Actions mismatch:\\\\n Expected: %s\\\\n Actual: %s',\\n00087| vim.inspect(expected.actions),\\n00088| vim.inspect(actual.actions)\\n00089| )\\n00090| )\\n00091| end\\n00092| end\\n00093| \\n00094| describe('renderer unit tests', function()\\n00095| local event_subscriptions = {\\n00096| 'session.updated',\\n00097| 'session.compacted',\\n00098| 'session.error',\\n00099| 'message.updated',\\n00100| 'message.removed',\\n00101| 'message.part.updated',\\n00102| 'message.part.removed',\\n00103| 'permission.updated',\\n00104| 'permission.replied',\\n00105| 'question.replied',\\n00106| 'question.asked',\\n00107| 'file.edited',\\n00108| 'custom.restore_point.created',\\n00109| 'custom.emit_events.finished',\\n00110| }\\n00111| \\n00112| before_each(function()\\n00113| require('opencode.event_manager').setup()\\n00114| end)\\n00115| \\n00116| it('subsribes to events correctly', function()\\n00117| local renderer = require('opencode.ui.renderer')\\n00118| local event_manager = state.event_manager\\n00119| \\n00120| event_manager.events = {}\\n00121| \\n00122| renderer.setup_subscriptions()\\n00123| \\n00124| for _, event_name in ipairs(event_subscriptions) do\\n00125| assert.is_true(\\n00126| event_manager.events[event_name] ~= nil,\\n00127| string.format('Renderer did not subscribe to event: %s', event_name)\\n00128| )\\n00129| end\\n00130| end)\\n00131| \\n00132| it('unsubsribes from events correctly', function()\\n00133| local renderer = require('opencode.ui.renderer')\\n00134| local event_manager = state.event_manager\\n00135| \\n00136| renderer.setup_subscriptions()\\n00137| \\n00138| renderer.setup_subscriptions(false)\\n00139| \\n00140| for _, event_name in ipairs(event_subscriptions) do\\n00141| assert.is_true(\\n00142| vim.tbl_isempty(event_manager.events[event_name]),\\n00143| string.format('Renderer did not unsubscribe from event: %s', event_name)\\n00144| )\\n00145| end\\n00146| end)\\n00147| end)\\n00148| \\n00149| describe('renderer functional tests', function()\\n00150| config.debug.show_ids = true\\n00151| \\n00152| before_each(function()\\n00153| helpers.replay_setup()\\n00154| end)\\n00155| \\n00156| after_each(function()\\n00157| if state.windows then\\n00158| ui.close_windows(state.windows)\\n00159| end\\n00160| end)\\n00161| \\n00162| local json_files = vim.fn.glob('tests/data/*.json', false, true)\\n00163| \\n00164| -- Don't do the full session test on these files, usually\\n00165| -- because they involve permission prompts\\n00166| local skip_full_session = {\\n00167| 'permission-prompt',\\n00168| 'permission-ask-new',\\n00169| 'question-ask',\\n00170| 'shifting-and-multiple-perms',\\n00171| 'message-removal',\\n00172| }\\n00173| \\n00174| for _, filepath in ipairs(json_files) do\\n00175| local name = vim.fn.fnamemodify(filepath, ':t:r')\\n00176| \\n00177| if not name:match('%.expected$') then\\n00178| local expected_path = 'tests/data/' .. name .. '.expected.json'\\n00179| \\n00180| if vim.fn.filereadable(expected_path) == 1 then\\n00181| for i = 1, 2 do\\n00182| config.ui.output.rendering.event_collapsing = i == 1 and true or false\\n00183| it(\\n00184| 'replays '\\n00185| .. name\\n00186| .. ' correctly (event-by-event, '\\n00187| .. (config.ui.output.rendering.event_collapsing and 'collapsing' or 'no collapsing')\\n00188| .. ')',\\n00189| function()\\n00190| local events = helpers.load_test_data(filepath)\\n00191| state.active_session = helpers.get_session_from_events(events)\\n00192| local expected = helpers.load_test_data(expected_path)\\n00193| \\n00194| helpers.replay_events(events)\\n00195| vim.wait(1000, function()\\n00196| return vim.tbl_isempty(state.event_manager.throttling_emitter.queue)\\n00197| end)\\n00198| \\n00199| local actual = helpers.capture_output(state.windows and state.windows.output_buf, output_window.namespace)\\n00200| assert_output_matches(expected, actual, name)\\n00201| end\\n00202| )\\n00203| end\\n00204| \\n00205| if not vim.tbl_contains(skip_full_session, name) then\\n00206| it('replays ' .. name .. ' correctly (session)', function()\\n00207| local renderer = require('opencode.ui.renderer')\\n00208| local events = helpers.load_test_data(filepath)\\n00209| state.active_session = helpers.get_session_from_events(events, true)\\n00210| local expected = helpers.load_test_data(expected_path)\\n00211| \\n00212| local session_data = helpers.load_session_from_events(events)\\n00213| renderer._render_full_session_data(session_data)\\n00214| \\n00215| local actual = helpers.capture_output(state.windows and state.windows.output_buf, output_window.namespace)\\n00216| assert_output_matches(expected, actual, name)\\n00217| end)\\n00218| end\\n00219| end\\n00220| end\\n00221| end\\n00222| end)\\n00223| \\n\\n(End of file - total 223 lines)\\n\",\"sessionID\":\"ses_40549268dffehKpDd14uHIMQaT\",\"messageID\":\"msg_bfab6dbf3001ZOVHTFKR1CMUE5\",\"id\":\"prt_bfab6dbf80012C7P5ugGXl7Kbv\",\"synthetic\":true}}},{\"type\":\"message.part.updated\",\"properties\":{\"part\":{\"mime\":\"text/plain\",\"type\":\"file\",\"sessionID\":\"ses_40549268dffehKpDd14uHIMQaT\",\"id\":\"prt_bfab6dbf9001Kebk9O9AoUc4yI\",\"messageID\":\"msg_bfab6dbf3001ZOVHTFKR1CMUE5\",\"filename\":\"tests/replay/renderer_spec.lua\",\"url\":\"file:///home/francis/Projects/_nvim/opencode.nvim/tests/replay/renderer_spec.lua\"}}},{\"type\":\"message.part.updated\",\"properties\":{\"part\":{\"type\":\"text\",\"text\":\"can you use the question tool and ask me a couple of questions\",\"sessionID\":\"ses_40549268dffehKpDd14uHIMQaT\",\"messageID\":\"msg_bfab6dbf3001ZOVHTFKR1CMUE5\",\"id\":\"prt_bfab6dbf4001xrYv3BzlgWGgkv\"}}},{\"type\":\"session.updated\",\"properties\":{\"info\":{\"slug\":\"happy-harbor\",\"time\":{\"created\":1769437845874,\"updated\":1769437846547},\"projectID\":\"29d43526f88157cd4edd071b899dd01f240b771b\",\"id\":\"ses_40549268dffehKpDd14uHIMQaT\",\"version\":\"1.1.31\",\"title\":\"New session - 2026-01-26T14:30:45.874Z\",\"directory\":\"/home/francis/Projects/_nvim/opencode.nvim\"}}},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"message.updated\",\"properties\":{\"info\":{\"id\":\"msg_bfab6dc80001FueCN7E2691J2R\",\"modelID\":\"gpt-4.1\",\"providerID\":\"github-copilot\",\"cost\":0,\"sessionID\":\"ses_40549268dffehKpDd14uHIMQaT\",\"mode\":\"build\",\"time\":{\"created\":1769437846657},\"role\":\"assistant\",\"path\":{\"cwd\":\"/home/francis/Projects/_nvim/opencode.nvim\",\"root\":\"/home/francis/Projects/_nvim/opencode.nvim\"},\"agent\":\"build\",\"tokens\":{\"reasoning\":0,\"input\":0,\"output\":0,\"cache\":{\"write\":0,\"read\":0}},\"parentID\":\"msg_bfab6dbf3001ZOVHTFKR1CMUE5\"}}},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"message.updated\",\"properties\":{\"info\":{\"id\":\"msg_bfab6dbf3001ZOVHTFKR1CMUE5\",\"time\":{\"created\":1769437846515},\"role\":\"user\",\"agent\":\"build\",\"sessionID\":\"ses_40549268dffehKpDd14uHIMQaT\",\"summary\":{\"diffs\":[]},\"model\":{\"providerID\":\"github-copilot\",\"modelID\":\"gpt-4.1\"}}}},{\"type\":\"session.updated\",\"properties\":{\"info\":{\"id\":\"ses_40549268dffehKpDd14uHIMQaT\",\"slug\":\"happy-harbor\",\"directory\":\"/home/francis/Projects/_nvim/opencode.nvim\",\"projectID\":\"29d43526f88157cd4edd071b899dd01f240b771b\",\"title\":\"New session - 2026-01-26T14:30:45.874Z\",\"version\":\"1.1.31\",\"summary\":{\"additions\":0,\"deletions\":0,\"files\":0},\"time\":{\"created\":1769437845874,\"updated\":1769437846728}}}},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"message.part.updated\",\"properties\":{\"part\":{\"type\":\"step-start\",\"snapshot\":\"be64b6205f0da4c9240df2476dc35495429161b5\",\"sessionID\":\"ses_40549268dffehKpDd14uHIMQaT\",\"messageID\":\"msg_bfab6dc80001FueCN7E2691J2R\",\"id\":\"prt_bfab6e0c10013qo2ZThCt7FqLA\"}}},{\"type\":\"message.part.updated\",\"properties\":{\"part\":{\"id\":\"prt_bfab6e0c4001ou3kcu0UICsKQq\",\"tool\":\"question\",\"sessionID\":\"ses_40549268dffehKpDd14uHIMQaT\",\"callID\":\"call_wHoqSXKi7juWQVtpwv4tMMTj\",\"messageID\":\"msg_bfab6dc80001FueCN7E2691J2R\",\"type\":\"tool\",\"state\":{\"raw\":\"\",\"input\":{},\"status\":\"pending\"}}}},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"question.asked\",\"properties\":{\"questions\":[{\"question\":\"Which Lua testing framework do you use or prefer for your Neovim Lua plugins?\",\"header\":\"Testing Framework?\",\"options\":[{\"label\":\"busted\",\"description\":\"Busted is the most common Lua testing framework.\"},{\"label\":\"plenary.nvim\",\"description\":\"Plenary.nvim provides unit test utilities for Neovim plugins.\"},{\"label\":\"other\",\"description\":\"Other or custom test framework\"}]},{\"question\":\"How do you usually run your tests in this codebase?\",\"header\":\"Run Tests Approach\",\"options\":[{\"label\":\"run_tests.sh (Recommended)\",\"description\":\"Use run_tests.sh script provided in repo root.\"},{\"label\":\"Direct test runner\",\"description\":\"Use individual test commands (e.g., invoke Busted/Plenary directly).\"},{\"label\":\"Other\",\"description\":\"Other custom scripts or workflow\"}]}],\"id\":\"que_bfab6ef4f0010bzEmrR38ndiIA\",\"sessionID\":\"ses_40549268dffehKpDd14uHIMQaT\",\"tool\":{\"messageID\":\"msg_bfab6dc80001FueCN7E2691J2R\",\"callID\":\"call_wHoqSXKi7juWQVtpwv4tMMTj\"}}},{\"type\":\"message.part.updated\",\"properties\":{\"part\":{\"id\":\"prt_bfab6e0c4001ou3kcu0UICsKQq\",\"tool\":\"question\",\"sessionID\":\"ses_40549268dffehKpDd14uHIMQaT\",\"callID\":\"call_wHoqSXKi7juWQVtpwv4tMMTj\",\"messageID\":\"msg_bfab6dc80001FueCN7E2691J2R\",\"type\":\"tool\",\"state\":{\"input\":{\"questions\":[{\"question\":\"Which Lua testing framework do you use or prefer for your Neovim Lua plugins?\",\"header\":\"Testing Framework?\",\"options\":[{\"label\":\"busted\",\"description\":\"Busted is the most common Lua testing framework.\"},{\"label\":\"plenary.nvim\",\"description\":\"Plenary.nvim provides unit test utilities for Neovim plugins.\"},{\"label\":\"other\",\"description\":\"Other or custom test framework\"}]},{\"question\":\"How do you usually run your tests in this codebase?\",\"header\":\"Run Tests Approach\",\"options\":[{\"label\":\"run_tests.sh (Recommended)\",\"description\":\"Use run_tests.sh script provided in repo root.\"},{\"label\":\"Direct test runner\",\"description\":\"Use individual test commands (e.g., invoke Busted/Plenary directly).\"},{\"label\":\"Other\",\"description\":\"Other custom scripts or workflow\"}]}]},\"time\":{\"start\":1769437851471},\"status\":\"running\"}}}},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"message.updated\",\"properties\":{\"info\":{\"id\":\"msg_bfab6dbf3001ZOVHTFKR1CMUE5\",\"time\":{\"created\":1769437846515},\"role\":\"user\",\"agent\":\"build\",\"sessionID\":\"ses_40549268dffehKpDd14uHIMQaT\",\"summary\":{\"title\":\"Ask me two questions\",\"diffs\":[]},\"model\":{\"providerID\":\"github-copilot\",\"modelID\":\"gpt-4.1\"}}}},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"session.updated\",\"properties\":{\"info\":{\"id\":\"ses_40549268dffehKpDd14uHIMQaT\",\"slug\":\"happy-harbor\",\"directory\":\"/home/francis/Projects/_nvim/opencode.nvim\",\"projectID\":\"29d43526f88157cd4edd071b899dd01f240b771b\",\"title\":\"Ask questions about renderer_spec.lua\",\"version\":\"1.1.31\",\"summary\":{\"additions\":0,\"deletions\":0,\"files\":0},\"time\":{\"created\":1769437845874,\"updated\":1769437846728}}}},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]}]\n","deletions":0,"additions":1,"file":"tests/data/multiple-question-ask.json"}]},"model":{"providerID":"github-copilot","modelID":"gpt-4.1"}}}},{"type":"session.updated","properties":{"info":{"id":"ses_40549268dffehKpDd14uHIMQaT","slug":"happy-harbor","directory":"/home/francis/Projects/_nvim/opencode.nvim","projectID":"29d43526f88157cd4edd071b899dd01f240b771b","title":"Ask questions about renderer_spec.lua","version":"1.1.31","summary":{"additions":1,"deletions":0,"files":1},"time":{"created":1769437845874,"updated":1769437915176}}}},{"type":"custom.emit_events.finished","properties":[]},{"type":"message.part.updated","properties":{"part":{"type":"step-start","snapshot":"bef5baca5e1828492c87ba3e7d702707c782f6ec","sessionID":"ses_40549268dffehKpDd14uHIMQaT","messageID":"msg_bfab7e7fd0018kA9yLHMkJM3fA","id":"prt_bfab7eb3f001iDZa7Ffqv7nqd7"}}},{"type":"message.part.updated","properties":{"part":{"type":"text","text":"Thanks for your answers","sessionID":"ses_40549268dffehKpDd14uHIMQaT","messageID":"msg_bfab7e7fd0018kA9yLHMkJM3fA","id":"prt_bfab7eb42001Vm8SgJBX4UFE0B","time":{"start":1769437915970}},"delta":" answers"}},{"type":"custom.emit_events.finished","properties":[]},{"type":"message.part.updated","properties":{"part":{"type":"text","text":"Thanks for your answers! You've indicated:\n\n- Preferred testing framework","sessionID":"ses_40549268dffehKpDd14uHIMQaT","messageID":"msg_bfab7e7fd0018kA9yLHMkJM3fA","id":"prt_bfab7eb42001Vm8SgJBX4UFE0B","time":{"start":1769437915970}},"delta":" framework"}},{"type":"custom.emit_events.finished","properties":[]},{"type":"message.part.updated","properties":{"part":{"type":"text","text":"Thanks for your answers! You've indicated:\n\n- Preferred testing framework: **busted**\n-","sessionID":"ses_40549268dffehKpDd14uHIMQaT","messageID":"msg_bfab7e7fd0018kA9yLHMkJM3fA","id":"prt_bfab7eb42001Vm8SgJBX4UFE0B","time":{"start":1769437915970}},"delta":"-"}},{"type":"custom.emit_events.finished","properties":[]},{"type":"message.part.updated","properties":{"part":{"type":"text","text":"Thanks for your answers! You've indicated:\n\n- Preferred testing framework: **busted**\n- Typical test running method: **run_tests","sessionID":"ses_40549268dffehKpDd14uHIMQaT","messageID":"msg_bfab7e7fd0018kA9yLHMkJM3fA","id":"prt_bfab7eb42001Vm8SgJBX4UFE0B","time":{"start":1769437915970}},"delta":"_tests"}},{"type":"custom.emit_events.finished","properties":[]},{"type":"message.part.updated","properties":{"part":{"type":"text","text":"Thanks for your answers! You've indicated:\n\n- Preferred testing framework: **busted**\n- Typical test running method: **run_tests.sh (Recommended)**\n\nI","sessionID":"ses_40549268dffehKpDd14uHIMQaT","messageID":"msg_bfab7e7fd0018kA9yLHMkJM3fA","id":"prt_bfab7eb42001Vm8SgJBX4UFE0B","time":{"start":1769437915970}},"delta":"I"}},{"type":"custom.emit_events.finished","properties":[]},{"type":"message.part.updated","properties":{"part":{"type":"text","text":"Thanks for your answers! You've indicated:\n\n- Preferred testing framework: **busted**\n- Typical test running method: **run_tests.sh (Recommended)**\n\nI have this in mind for any","sessionID":"ses_40549268dffehKpDd14uHIMQaT","messageID":"msg_bfab7e7fd0018kA9yLHMkJM3fA","id":"prt_bfab7eb42001Vm8SgJBX4UFE0B","time":{"start":1769437915970}},"delta":" any"}},{"type":"custom.emit_events.finished","properties":[]},{"type":"message.part.updated","properties":{"part":{"type":"text","text":"Thanks for your answers! You've indicated:\n\n- Preferred testing framework: **busted**\n- Typical test running method: **run_tests.sh (Recommended)**\n\nI have this in mind for any future testing, troubleshooting, or","sessionID":"ses_40549268dffehKpDd14uHIMQaT","messageID":"msg_bfab7e7fd0018kA9yLHMkJM3fA","id":"prt_bfab7eb42001Vm8SgJBX4UFE0B","time":{"start":1769437915970}},"delta":" or"}},{"type":"custom.emit_events.finished","properties":[]},{"type":"message.part.updated","properties":{"part":{"type":"text","text":"Thanks for your answers! You've indicated:\n\n- Preferred testing framework: **busted**\n- Typical test running method: **run_tests.sh (Recommended)**\n\nI have this in mind for any future testing, troubleshooting, or automation tasks","sessionID":"ses_40549268dffehKpDd14uHIMQaT","messageID":"msg_bfab7e7fd0018kA9yLHMkJM3fA","id":"prt_bfab7eb42001Vm8SgJBX4UFE0B","time":{"start":1769437915970}},"delta":" tasks"}},{"type":"custom.emit_events.finished","properties":[]},{"type":"message.part.updated","properties":{"part":{"type":"text","text":"Thanks for your answers! You've indicated:\n\n- Preferred testing framework: **busted**\n- Typical test running method: **run_tests.sh (Recommended)**\n\nI have this in mind for any future testing, troubleshooting, or automation tasks you request for this Neov","sessionID":"ses_40549268dffehKpDd14uHIMQaT","messageID":"msg_bfab7e7fd0018kA9yLHMkJM3fA","id":"prt_bfab7eb42001Vm8SgJBX4UFE0B","time":{"start":1769437915970}},"delta":"ov"}},{"type":"custom.emit_events.finished","properties":[]},{"type":"message.part.updated","properties":{"part":{"type":"text","text":"Thanks for your answers! You've indicated:\n\n- Preferred testing framework: **busted**\n- Typical test running method: **run_tests.sh (Recommended)**\n\nI have this in mind for any future testing, troubleshooting, or automation tasks you request for this Neovim Lua codebase.","sessionID":"ses_40549268dffehKpDd14uHIMQaT","messageID":"msg_bfab7e7fd0018kA9yLHMkJM3fA","id":"prt_bfab7eb42001Vm8SgJBX4UFE0B","time":{"start":1769437915970}},"delta":"."}},{"type":"custom.emit_events.finished","properties":[]},{"type":"message.part.updated","properties":{"part":{"type":"text","text":"Thanks for your answers! You've indicated:\n\n- Preferred testing framework: **busted**\n- Typical test running method: **run_tests.sh (Recommended)**\n\nI have this in mind for any future testing, troubleshooting, or automation tasks you request for this Neovim Lua codebase. If you have","sessionID":"ses_40549268dffehKpDd14uHIMQaT","messageID":"msg_bfab7e7fd0018kA9yLHMkJM3fA","id":"prt_bfab7eb42001Vm8SgJBX4UFE0B","time":{"start":1769437915970}},"delta":" have"}},{"type":"custom.emit_events.finished","properties":[]},{"type":"message.part.updated","properties":{"part":{"type":"text","text":"Thanks for your answers! You've indicated:\n\n- Preferred testing framework: **busted**\n- Typical test running method: **run_tests.sh (Recommended)**\n\nI have this in mind for any future testing, troubleshooting, or automation tasks you request for this Neovim Lua codebase. If you have any follow-up questions or","sessionID":"ses_40549268dffehKpDd14uHIMQaT","messageID":"msg_bfab7e7fd0018kA9yLHMkJM3fA","id":"prt_bfab7eb42001Vm8SgJBX4UFE0B","time":{"start":1769437915970}},"delta":" or"}},{"type":"custom.emit_events.finished","properties":[]},{"type":"message.part.updated","properties":{"part":{"type":"text","text":"Thanks for your answers! You've indicated:\n\n- Preferred testing framework: **busted**\n- Typical test running method: **run_tests.sh (Recommended)**\n\nI have this in mind for any future testing, troubleshooting, or automation tasks you request for this Neovim Lua codebase. If you have any follow-up questions or tasks (such as","sessionID":"ses_40549268dffehKpDd14uHIMQaT","messageID":"msg_bfab7e7fd0018kA9yLHMkJM3fA","id":"prt_bfab7eb42001Vm8SgJBX4UFE0B","time":{"start":1769437915970}},"delta":" as"}},{"type":"custom.emit_events.finished","properties":[]},{"type":"message.part.updated","properties":{"part":{"type":"text","text":"Thanks for your answers! You've indicated:\n\n- Preferred testing framework: **busted**\n- Typical test running method: **run_tests.sh (Recommended)**\n\nI have this in mind for any future testing, troubleshooting, or automation tasks you request for this Neovim Lua codebase. If you have any follow-up questions or tasks (such as test writing, modification,","sessionID":"ses_40549268dffehKpDd14uHIMQaT","messageID":"msg_bfab7e7fd0018kA9yLHMkJM3fA","id":"prt_bfab7eb42001Vm8SgJBX4UFE0B","time":{"start":1769437915970}},"delta":","}},{"type":"custom.emit_events.finished","properties":[]},{"type":"message.part.updated","properties":{"part":{"type":"text","text":"Thanks for your answers! You've indicated:\n\n- Preferred testing framework: **busted**\n- Typical test running method: **run_tests.sh (Recommended)**\n\nI have this in mind for any future testing, troubleshooting, or automation tasks you request for this Neovim Lua codebase. If you have any follow-up questions or tasks (such as test writing, modification, running, debugging","sessionID":"ses_40549268dffehKpDd14uHIMQaT","messageID":"msg_bfab7e7fd0018kA9yLHMkJM3fA","id":"prt_bfab7eb42001Vm8SgJBX4UFE0B","time":{"start":1769437915970}},"delta":" debugging"}},{"type":"custom.emit_events.finished","properties":[]},{"type":"message.part.updated","properties":{"part":{"type":"text","text":"Thanks for your answers! You've indicated:\n\n- Preferred testing framework: **busted**\n- Typical test running method: **run_tests.sh (Recommended)**\n\nI have this in mind for any future testing, troubleshooting, or automation tasks you request for this Neovim Lua codebase. If you have any follow-up questions or tasks (such as test writing, modification, running, debugging, or reporting), let","sessionID":"ses_40549268dffehKpDd14uHIMQaT","messageID":"msg_bfab7e7fd0018kA9yLHMkJM3fA","id":"prt_bfab7eb42001Vm8SgJBX4UFE0B","time":{"start":1769437915970}},"delta":" let"}},{"type":"custom.emit_events.finished","properties":[]},{"type":"message.part.updated","properties":{"part":{"type":"text","text":"Thanks for your answers! You've indicated:\n\n- Preferred testing framework: **busted**\n- Typical test running method: **run_tests.sh (Recommended)**\n\nI have this in mind for any future testing, troubleshooting, or automation tasks you request for this Neovim Lua codebase. If you have any follow-up questions or tasks (such as test writing, modification, running, debugging, or reporting), let me know—I'll tailor","sessionID":"ses_40549268dffehKpDd14uHIMQaT","messageID":"msg_bfab7e7fd0018kA9yLHMkJM3fA","id":"prt_bfab7eb42001Vm8SgJBX4UFE0B","time":{"start":1769437915970}},"delta":" tailor"}},{"type":"custom.emit_events.finished","properties":[]},{"type":"message.part.updated","properties":{"part":{"type":"text","text":"Thanks for your answers! You've indicated:\n\n- Preferred testing framework: **busted**\n- Typical test running method: **run_tests.sh (Recommended)**\n\nI have this in mind for any future testing, troubleshooting, or automation tasks you request for this Neovim Lua codebase. If you have any follow-up questions or tasks (such as test writing, modification, running, debugging, or reporting), let me know—I'll tailor my workflow to these preferences","sessionID":"ses_40549268dffehKpDd14uHIMQaT","messageID":"msg_bfab7e7fd0018kA9yLHMkJM3fA","id":"prt_bfab7eb42001Vm8SgJBX4UFE0B","time":{"start":1769437915970}},"delta":" preferences"}},{"type":"custom.emit_events.finished","properties":[]},{"type":"message.part.updated","properties":{"part":{"type":"text","text":"Thanks for your answers! You've indicated:\n\n- Preferred testing framework: **busted**\n- Typical test running method: **run_tests.sh (Recommended)**\n\nI have this in mind for any future testing, troubleshooting, or automation tasks you request for this Neovim Lua codebase. If you have any follow-up questions or tasks (such as test writing, modification, running, debugging, or reporting), let me know—I'll tailor my workflow to these preferences for","sessionID":"ses_40549268dffehKpDd14uHIMQaT","messageID":"msg_bfab7e7fd0018kA9yLHMkJM3fA","id":"prt_bfab7eb42001Vm8SgJBX4UFE0B","time":{"start":1769437915970}},"delta":" for"}},{"type":"custom.emit_events.finished","properties":[]},{"type":"message.part.updated","properties":{"part":{"type":"text","text":"Thanks for your answers! You've indicated:\n\n- Preferred testing framework: **busted**\n- Typical test running method: **run_tests.sh (Recommended)**\n\nI have this in mind for any future testing, troubleshooting, or automation tasks you request for this Neovim Lua codebase. If you have any follow-up questions or tasks (such as test writing, modification, running, debugging, or reporting), let me know—I'll tailor my workflow to these preferences for maximal compatibility and consistency!","sessionID":"ses_40549268dffehKpDd14uHIMQaT","messageID":"msg_bfab7e7fd0018kA9yLHMkJM3fA","id":"prt_bfab7eb42001Vm8SgJBX4UFE0B","time":{"start":1769437915970}},"delta":"!"}},{"type":"custom.emit_events.finished","properties":[]},{"type":"message.part.updated","properties":{"part":{"type":"text","text":"Thanks for your answers! You've indicated:\n\n- Preferred testing framework: **busted**\n- Typical test running method: **run_tests.sh (Recommended)**\n\nI have this in mind for any future testing, troubleshooting, or automation tasks you request for this Neovim Lua codebase. If you have any follow-up questions or tasks (such as test writing, modification, running, debugging, or reporting), let me know—I'll tailor my workflow to these preferences for maximal compatibility and consistency!","sessionID":"ses_40549268dffehKpDd14uHIMQaT","messageID":"msg_bfab7e7fd0018kA9yLHMkJM3fA","id":"prt_bfab7eb42001Vm8SgJBX4UFE0B","time":{"start":1769437917971,"end":1769437917971}}}},{"type":"message.part.updated","properties":{"part":{"id":"prt_bfab7f314001BD9Kv6fskeX4QN","type":"step-finish","snapshot":"bef5baca5e1828492c87ba3e7d702707c782f6ec","sessionID":"ses_40549268dffehKpDd14uHIMQaT","cost":0,"reason":"stop","tokens":{"reasoning":0,"input":88,"output":99,"cache":{"write":0,"read":12928}},"messageID":"msg_bfab7e7fd0018kA9yLHMkJM3fA"}}},{"type":"message.updated","properties":{"info":{"id":"msg_bfab7e7fd0018kA9yLHMkJM3fA","modelID":"gpt-4.1","providerID":"github-copilot","cost":0,"sessionID":"ses_40549268dffehKpDd14uHIMQaT","mode":"build","agent":"build","time":{"created":1769437915134},"role":"assistant","path":{"cwd":"/home/francis/Projects/_nvim/opencode.nvim","root":"/home/francis/Projects/_nvim/opencode.nvim"},"parentID":"msg_bfab6dbf3001ZOVHTFKR1CMUE5","tokens":{"reasoning":0,"input":88,"output":99,"cache":{"write":0,"read":12928}},"finish":"stop"}}},{"type":"message.updated","properties":{"info":{"id":"msg_bfab7e7fd0018kA9yLHMkJM3fA","modelID":"gpt-4.1","providerID":"github-copilot","cost":0,"sessionID":"ses_40549268dffehKpDd14uHIMQaT","mode":"build","agent":"build","time":{"created":1769437915134,"completed":1769437918003},"role":"assistant","path":{"cwd":"/home/francis/Projects/_nvim/opencode.nvim","root":"/home/francis/Projects/_nvim/opencode.nvim"},"parentID":"msg_bfab6dbf3001ZOVHTFKR1CMUE5","tokens":{"reasoning":0,"input":88,"output":99,"cache":{"write":0,"read":12928}},"finish":"stop"}}},{"type":"custom.emit_events.finished","properties":[]},{"type":"session.idle","properties":{"sessionID":"ses_40549268dffehKpDd14uHIMQaT"}},{"type":"message.updated","properties":{"info":{"id":"msg_bfab6dbf3001ZOVHTFKR1CMUE5","time":{"created":1769437846515},"role":"user","agent":"build","sessionID":"ses_40549268dffehKpDd14uHIMQaT","summary":{"title":"Ask me two questions","diffs":[{"before":"[DEBUG ERROR] git show be64b6205f0da4c9240df2476dc35495429161b5:tests/data/multiple-question-ask.json failed: fatal: path 'tests/data/multiple-question-ask.json' exists on disk, but not in 'be64b6205f0da4c9240df2476dc35495429161b5'\n","after":"[{\"type\":\"session.updated\",\"properties\":{\"info\":{\"slug\":\"happy-harbor\",\"time\":{\"created\":1769437845874,\"updated\":1769437845874},\"projectID\":\"29d43526f88157cd4edd071b899dd01f240b771b\",\"id\":\"ses_40549268dffehKpDd14uHIMQaT\",\"version\":\"1.1.31\",\"title\":\"New session - 2026-01-26T14:30:45.874Z\",\"directory\":\"/home/francis/Projects/_nvim/opencode.nvim\"}}},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"message.updated\",\"properties\":{\"info\":{\"id\":\"msg_bfab6dbf3001ZOVHTFKR1CMUE5\",\"time\":{\"created\":1769437846515},\"role\":\"user\",\"agent\":\"build\",\"sessionID\":\"ses_40549268dffehKpDd14uHIMQaT\",\"model\":{\"providerID\":\"github-copilot\",\"modelID\":\"gpt-4.1\"}}}},{\"type\":\"message.part.updated\",\"properties\":{\"part\":{\"type\":\"text\",\"text\":\"Called the Read tool with the following input: {\\\"filePath\\\":\\\"/home/francis/Projects/_nvim/opencode.nvim/tests/replay/renderer_spec.lua\\\"}\",\"sessionID\":\"ses_40549268dffehKpDd14uHIMQaT\",\"messageID\":\"msg_bfab6dbf3001ZOVHTFKR1CMUE5\",\"id\":\"prt_bfab6dbf4002ydMd2JojY4Nda0\",\"synthetic\":true}}},{\"type\":\"message.part.updated\",\"properties\":{\"part\":{\"type\":\"text\",\"text\":\"\\n00001| local state = require('opencode.state')\\n00002| local ui = require('opencode.ui.ui')\\n00003| local helpers = require('tests.helpers')\\n00004| local output_window = require('opencode.ui.output_window')\\n00005| local assert = require('luassert')\\n00006| local config = require('opencode.config')\\n00007| \\n00008| local function assert_output_matches(expected, actual, name)\\n00009| local normalized_extmarks = helpers.normalize_namespace_ids(actual.extmarks)\\n00010| \\n00011| assert.are.equal(\\n00012| #expected.lines,\\n00013| #actual.lines,\\n00014| string.format(\\n00015| 'Line count mismatch: expected %d, got %d.\\\\nFirst difference at index %d:\\\\n Expected: %s\\\\n Actual: %s',\\n00016| #expected.lines,\\n00017| #actual.lines,\\n00018| math.min(#expected.lines, #actual.lines) + 1,\\n00019| vim.inspect(expected.lines[math.min(#expected.lines, #actual.lines) + 1]),\\n00020| vim.inspect(actual.lines[math.min(#expected.lines, #actual.lines) + 1])\\n00021| )\\n00022| )\\n00023| \\n00024| for i = 1, #expected.lines do\\n00025| assert.are.equal(\\n00026| expected.lines[i],\\n00027| actual.lines[i],\\n00028| string.format(\\n00029| 'Line %d mismatch:\\\\n Expected: %s\\\\n Actual: %s',\\n00030| i,\\n00031| vim.inspect(expected.lines[i]),\\n00032| vim.inspect(actual.lines[i])\\n00033| )\\n00034| )\\n00035| end\\n00036| \\n00037| assert.are.equal(\\n00038| #expected.extmarks,\\n00039| #normalized_extmarks,\\n00040| string.format(\\n00041| 'Extmark count mismatch: expected %d, got %d.\\\\nFirst difference at index %d:\\\\n Expected: %s\\\\n Actual: %s',\\n00042| #expected.extmarks,\\n00043| #normalized_extmarks,\\n00044| math.min(#expected.extmarks, #normalized_extmarks) + 1,\\n00045| vim.inspect(expected.extmarks[math.min(#expected.extmarks, #normalized_extmarks) + 1]),\\n00046| vim.inspect(normalized_extmarks[math.min(#expected.extmarks, #normalized_extmarks) + 1])\\n00047| )\\n00048| )\\n00049| \\n00050| for i = 1, #expected.extmarks do\\n00051| assert.are.same(\\n00052| expected.extmarks[i],\\n00053| normalized_extmarks[i],\\n00054| string.format(\\n00055| 'Extmark %d mismatch:\\\\n Expected: %s\\\\n Actual: %s',\\n00056| i,\\n00057| vim.inspect(expected.extmarks[i]),\\n00058| vim.inspect(normalized_extmarks[i])\\n00059| )\\n00060| )\\n00061| end\\n00062| \\n00063| local expected_action_count = expected.actions and #expected.actions or 0\\n00064| local actual_action_count = actual.actions and #actual.actions or 0\\n00065| \\n00066| assert.are.equal(\\n00067| expected_action_count,\\n00068| actual_action_count,\\n00069| string.format('Action count mismatch: expected %d, got %d', expected_action_count, actual_action_count)\\n00070| )\\n00071| \\n00072| if expected.actions then\\n00073| -- Sort both arrays for consistent comparison since order doesn't matter\\n00074| local function sort_actions(actions)\\n00075| local sorted = vim.deepcopy(actions)\\n00076| table.sort(sorted, function(a, b)\\n00077| return vim.inspect(a) < vim.inspect(b)\\n00078| end)\\n00079| return sorted\\n00080| end\\n00081| \\n00082| assert.same(\\n00083| sort_actions(expected.actions),\\n00084| sort_actions(actual.actions),\\n00085| string.format(\\n00086| 'Actions mismatch:\\\\n Expected: %s\\\\n Actual: %s',\\n00087| vim.inspect(expected.actions),\\n00088| vim.inspect(actual.actions)\\n00089| )\\n00090| )\\n00091| end\\n00092| end\\n00093| \\n00094| describe('renderer unit tests', function()\\n00095| local event_subscriptions = {\\n00096| 'session.updated',\\n00097| 'session.compacted',\\n00098| 'session.error',\\n00099| 'message.updated',\\n00100| 'message.removed',\\n00101| 'message.part.updated',\\n00102| 'message.part.removed',\\n00103| 'permission.updated',\\n00104| 'permission.replied',\\n00105| 'question.replied',\\n00106| 'question.asked',\\n00107| 'file.edited',\\n00108| 'custom.restore_point.created',\\n00109| 'custom.emit_events.finished',\\n00110| }\\n00111| \\n00112| before_each(function()\\n00113| require('opencode.event_manager').setup()\\n00114| end)\\n00115| \\n00116| it('subsribes to events correctly', function()\\n00117| local renderer = require('opencode.ui.renderer')\\n00118| local event_manager = state.event_manager\\n00119| \\n00120| event_manager.events = {}\\n00121| \\n00122| renderer.setup_subscriptions()\\n00123| \\n00124| for _, event_name in ipairs(event_subscriptions) do\\n00125| assert.is_true(\\n00126| event_manager.events[event_name] ~= nil,\\n00127| string.format('Renderer did not subscribe to event: %s', event_name)\\n00128| )\\n00129| end\\n00130| end)\\n00131| \\n00132| it('unsubsribes from events correctly', function()\\n00133| local renderer = require('opencode.ui.renderer')\\n00134| local event_manager = state.event_manager\\n00135| \\n00136| renderer.setup_subscriptions()\\n00137| \\n00138| renderer.setup_subscriptions(false)\\n00139| \\n00140| for _, event_name in ipairs(event_subscriptions) do\\n00141| assert.is_true(\\n00142| vim.tbl_isempty(event_manager.events[event_name]),\\n00143| string.format('Renderer did not unsubscribe from event: %s', event_name)\\n00144| )\\n00145| end\\n00146| end)\\n00147| end)\\n00148| \\n00149| describe('renderer functional tests', function()\\n00150| config.debug.show_ids = true\\n00151| \\n00152| before_each(function()\\n00153| helpers.replay_setup()\\n00154| end)\\n00155| \\n00156| after_each(function()\\n00157| if state.windows then\\n00158| ui.close_windows(state.windows)\\n00159| end\\n00160| end)\\n00161| \\n00162| local json_files = vim.fn.glob('tests/data/*.json', false, true)\\n00163| \\n00164| -- Don't do the full session test on these files, usually\\n00165| -- because they involve permission prompts\\n00166| local skip_full_session = {\\n00167| 'permission-prompt',\\n00168| 'permission-ask-new',\\n00169| 'question-ask',\\n00170| 'shifting-and-multiple-perms',\\n00171| 'message-removal',\\n00172| }\\n00173| \\n00174| for _, filepath in ipairs(json_files) do\\n00175| local name = vim.fn.fnamemodify(filepath, ':t:r')\\n00176| \\n00177| if not name:match('%.expected$') then\\n00178| local expected_path = 'tests/data/' .. name .. '.expected.json'\\n00179| \\n00180| if vim.fn.filereadable(expected_path) == 1 then\\n00181| for i = 1, 2 do\\n00182| config.ui.output.rendering.event_collapsing = i == 1 and true or false\\n00183| it(\\n00184| 'replays '\\n00185| .. name\\n00186| .. ' correctly (event-by-event, '\\n00187| .. (config.ui.output.rendering.event_collapsing and 'collapsing' or 'no collapsing')\\n00188| .. ')',\\n00189| function()\\n00190| local events = helpers.load_test_data(filepath)\\n00191| state.active_session = helpers.get_session_from_events(events)\\n00192| local expected = helpers.load_test_data(expected_path)\\n00193| \\n00194| helpers.replay_events(events)\\n00195| vim.wait(1000, function()\\n00196| return vim.tbl_isempty(state.event_manager.throttling_emitter.queue)\\n00197| end)\\n00198| \\n00199| local actual = helpers.capture_output(state.windows and state.windows.output_buf, output_window.namespace)\\n00200| assert_output_matches(expected, actual, name)\\n00201| end\\n00202| )\\n00203| end\\n00204| \\n00205| if not vim.tbl_contains(skip_full_session, name) then\\n00206| it('replays ' .. name .. ' correctly (session)', function()\\n00207| local renderer = require('opencode.ui.renderer')\\n00208| local events = helpers.load_test_data(filepath)\\n00209| state.active_session = helpers.get_session_from_events(events, true)\\n00210| local expected = helpers.load_test_data(expected_path)\\n00211| \\n00212| local session_data = helpers.load_session_from_events(events)\\n00213| renderer._render_full_session_data(session_data)\\n00214| \\n00215| local actual = helpers.capture_output(state.windows and state.windows.output_buf, output_window.namespace)\\n00216| assert_output_matches(expected, actual, name)\\n00217| end)\\n00218| end\\n00219| end\\n00220| end\\n00221| end\\n00222| end)\\n00223| \\n\\n(End of file - total 223 lines)\\n\",\"sessionID\":\"ses_40549268dffehKpDd14uHIMQaT\",\"messageID\":\"msg_bfab6dbf3001ZOVHTFKR1CMUE5\",\"id\":\"prt_bfab6dbf80012C7P5ugGXl7Kbv\",\"synthetic\":true}}},{\"type\":\"message.part.updated\",\"properties\":{\"part\":{\"mime\":\"text/plain\",\"type\":\"file\",\"sessionID\":\"ses_40549268dffehKpDd14uHIMQaT\",\"id\":\"prt_bfab6dbf9001Kebk9O9AoUc4yI\",\"messageID\":\"msg_bfab6dbf3001ZOVHTFKR1CMUE5\",\"filename\":\"tests/replay/renderer_spec.lua\",\"url\":\"file:///home/francis/Projects/_nvim/opencode.nvim/tests/replay/renderer_spec.lua\"}}},{\"type\":\"message.part.updated\",\"properties\":{\"part\":{\"type\":\"text\",\"text\":\"can you use the question tool and ask me a couple of questions\",\"sessionID\":\"ses_40549268dffehKpDd14uHIMQaT\",\"messageID\":\"msg_bfab6dbf3001ZOVHTFKR1CMUE5\",\"id\":\"prt_bfab6dbf4001xrYv3BzlgWGgkv\"}}},{\"type\":\"session.updated\",\"properties\":{\"info\":{\"slug\":\"happy-harbor\",\"time\":{\"created\":1769437845874,\"updated\":1769437846547},\"projectID\":\"29d43526f88157cd4edd071b899dd01f240b771b\",\"id\":\"ses_40549268dffehKpDd14uHIMQaT\",\"version\":\"1.1.31\",\"title\":\"New session - 2026-01-26T14:30:45.874Z\",\"directory\":\"/home/francis/Projects/_nvim/opencode.nvim\"}}},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"message.updated\",\"properties\":{\"info\":{\"id\":\"msg_bfab6dc80001FueCN7E2691J2R\",\"modelID\":\"gpt-4.1\",\"providerID\":\"github-copilot\",\"cost\":0,\"sessionID\":\"ses_40549268dffehKpDd14uHIMQaT\",\"mode\":\"build\",\"time\":{\"created\":1769437846657},\"role\":\"assistant\",\"path\":{\"cwd\":\"/home/francis/Projects/_nvim/opencode.nvim\",\"root\":\"/home/francis/Projects/_nvim/opencode.nvim\"},\"agent\":\"build\",\"tokens\":{\"reasoning\":0,\"input\":0,\"output\":0,\"cache\":{\"write\":0,\"read\":0}},\"parentID\":\"msg_bfab6dbf3001ZOVHTFKR1CMUE5\"}}},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"message.updated\",\"properties\":{\"info\":{\"id\":\"msg_bfab6dbf3001ZOVHTFKR1CMUE5\",\"time\":{\"created\":1769437846515},\"role\":\"user\",\"agent\":\"build\",\"sessionID\":\"ses_40549268dffehKpDd14uHIMQaT\",\"summary\":{\"diffs\":[]},\"model\":{\"providerID\":\"github-copilot\",\"modelID\":\"gpt-4.1\"}}}},{\"type\":\"session.updated\",\"properties\":{\"info\":{\"id\":\"ses_40549268dffehKpDd14uHIMQaT\",\"slug\":\"happy-harbor\",\"directory\":\"/home/francis/Projects/_nvim/opencode.nvim\",\"projectID\":\"29d43526f88157cd4edd071b899dd01f240b771b\",\"title\":\"New session - 2026-01-26T14:30:45.874Z\",\"version\":\"1.1.31\",\"summary\":{\"additions\":0,\"deletions\":0,\"files\":0},\"time\":{\"created\":1769437845874,\"updated\":1769437846728}}}},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"message.part.updated\",\"properties\":{\"part\":{\"type\":\"step-start\",\"snapshot\":\"be64b6205f0da4c9240df2476dc35495429161b5\",\"sessionID\":\"ses_40549268dffehKpDd14uHIMQaT\",\"messageID\":\"msg_bfab6dc80001FueCN7E2691J2R\",\"id\":\"prt_bfab6e0c10013qo2ZThCt7FqLA\"}}},{\"type\":\"message.part.updated\",\"properties\":{\"part\":{\"id\":\"prt_bfab6e0c4001ou3kcu0UICsKQq\",\"tool\":\"question\",\"sessionID\":\"ses_40549268dffehKpDd14uHIMQaT\",\"callID\":\"call_wHoqSXKi7juWQVtpwv4tMMTj\",\"messageID\":\"msg_bfab6dc80001FueCN7E2691J2R\",\"type\":\"tool\",\"state\":{\"raw\":\"\",\"input\":{},\"status\":\"pending\"}}}},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"question.asked\",\"properties\":{\"questions\":[{\"question\":\"Which Lua testing framework do you use or prefer for your Neovim Lua plugins?\",\"header\":\"Testing Framework?\",\"options\":[{\"label\":\"busted\",\"description\":\"Busted is the most common Lua testing framework.\"},{\"label\":\"plenary.nvim\",\"description\":\"Plenary.nvim provides unit test utilities for Neovim plugins.\"},{\"label\":\"other\",\"description\":\"Other or custom test framework\"}]},{\"question\":\"How do you usually run your tests in this codebase?\",\"header\":\"Run Tests Approach\",\"options\":[{\"label\":\"run_tests.sh (Recommended)\",\"description\":\"Use run_tests.sh script provided in repo root.\"},{\"label\":\"Direct test runner\",\"description\":\"Use individual test commands (e.g., invoke Busted/Plenary directly).\"},{\"label\":\"Other\",\"description\":\"Other custom scripts or workflow\"}]}],\"id\":\"que_bfab6ef4f0010bzEmrR38ndiIA\",\"sessionID\":\"ses_40549268dffehKpDd14uHIMQaT\",\"tool\":{\"messageID\":\"msg_bfab6dc80001FueCN7E2691J2R\",\"callID\":\"call_wHoqSXKi7juWQVtpwv4tMMTj\"}}},{\"type\":\"message.part.updated\",\"properties\":{\"part\":{\"id\":\"prt_bfab6e0c4001ou3kcu0UICsKQq\",\"tool\":\"question\",\"sessionID\":\"ses_40549268dffehKpDd14uHIMQaT\",\"callID\":\"call_wHoqSXKi7juWQVtpwv4tMMTj\",\"messageID\":\"msg_bfab6dc80001FueCN7E2691J2R\",\"type\":\"tool\",\"state\":{\"input\":{\"questions\":[{\"question\":\"Which Lua testing framework do you use or prefer for your Neovim Lua plugins?\",\"header\":\"Testing Framework?\",\"options\":[{\"label\":\"busted\",\"description\":\"Busted is the most common Lua testing framework.\"},{\"label\":\"plenary.nvim\",\"description\":\"Plenary.nvim provides unit test utilities for Neovim plugins.\"},{\"label\":\"other\",\"description\":\"Other or custom test framework\"}]},{\"question\":\"How do you usually run your tests in this codebase?\",\"header\":\"Run Tests Approach\",\"options\":[{\"label\":\"run_tests.sh (Recommended)\",\"description\":\"Use run_tests.sh script provided in repo root.\"},{\"label\":\"Direct test runner\",\"description\":\"Use individual test commands (e.g., invoke Busted/Plenary directly).\"},{\"label\":\"Other\",\"description\":\"Other custom scripts or workflow\"}]}]},\"time\":{\"start\":1769437851471},\"status\":\"running\"}}}},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"message.updated\",\"properties\":{\"info\":{\"id\":\"msg_bfab6dbf3001ZOVHTFKR1CMUE5\",\"time\":{\"created\":1769437846515},\"role\":\"user\",\"agent\":\"build\",\"sessionID\":\"ses_40549268dffehKpDd14uHIMQaT\",\"summary\":{\"title\":\"Ask me two questions\",\"diffs\":[]},\"model\":{\"providerID\":\"github-copilot\",\"modelID\":\"gpt-4.1\"}}}},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"session.updated\",\"properties\":{\"info\":{\"id\":\"ses_40549268dffehKpDd14uHIMQaT\",\"slug\":\"happy-harbor\",\"directory\":\"/home/francis/Projects/_nvim/opencode.nvim\",\"projectID\":\"29d43526f88157cd4edd071b899dd01f240b771b\",\"title\":\"Ask questions about renderer_spec.lua\",\"version\":\"1.1.31\",\"summary\":{\"additions\":0,\"deletions\":0,\"files\":0},\"time\":{\"created\":1769437845874,\"updated\":1769437846728}}}},{\"type\":\"custom.emit_events.finished\",\"properties\":[]},{\"type\":\"custom.emit_events.finished\",\"properties\":[]}]\n","deletions":0,"additions":1,"file":"tests/data/multiple-question-ask.json"}]},"model":{"providerID":"github-copilot","modelID":"gpt-4.1"}}}},{"type":"session.updated","properties":{"info":{"id":"ses_40549268dffehKpDd14uHIMQaT","slug":"happy-harbor","directory":"/home/francis/Projects/_nvim/opencode.nvim","projectID":"29d43526f88157cd4edd071b899dd01f240b771b","title":"Ask questions about renderer_spec.lua","version":"1.1.31","summary":{"additions":1,"deletions":0,"files":1},"time":{"created":1769437845874,"updated":1769437918033}}}},{"type":"custom.emit_events.finished","properties":[]}] diff --git a/tests/data/multiple-question-ask.expected.json b/tests/data/multiple-question-ask.expected.json new file mode 100644 index 00000000..7163e444 --- /dev/null +++ b/tests/data/multiple-question-ask.expected.json @@ -0,0 +1 @@ +{"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: ``","",""],"extmarks":[[1,1,0,{"virt_text_hide":false,"virt_text_win_col":-3,"priority":10,"virt_text_repeat_linebreak":false,"right_gravity":true,"ns_id":3,"virt_text":[["▌󰭻 ","OpencodeMessageRoleUser"],[" "],["USER","OpencodeMessageRoleUser"],["","OpencodeHint"],[" (2026-01-26 14:30:46)","OpencodeHint"],[" [msg_bfab6dbf3001ZOVHTFKR1CMUE5]","OpencodeHint"]],"virt_text_pos":"win_col"}],[2,2,0,{"virt_text_hide":false,"virt_text_win_col":-3,"priority":4096,"virt_text_repeat_linebreak":true,"right_gravity":true,"ns_id":3,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_pos":"win_col"}],[3,3,0,{"virt_text_hide":false,"virt_text_win_col":-3,"priority":4096,"virt_text_repeat_linebreak":true,"right_gravity":true,"ns_id":3,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_pos":"win_col"}],[4,4,0,{"virt_text_hide":false,"virt_text_win_col":-3,"priority":4096,"virt_text_repeat_linebreak":true,"right_gravity":true,"ns_id":3,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_pos":"win_col"}],[5,5,0,{"virt_text_hide":false,"virt_text_win_col":-3,"priority":4096,"virt_text_repeat_linebreak":true,"right_gravity":true,"ns_id":3,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_pos":"win_col"}],[6,6,0,{"virt_text_hide":false,"virt_text_win_col":-3,"priority":4096,"virt_text_repeat_linebreak":true,"right_gravity":true,"ns_id":3,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_pos":"win_col"}],[7,8,0,{"virt_text_hide":false,"virt_text_win_col":-3,"priority":10,"virt_text_repeat_linebreak":false,"right_gravity":true,"ns_id":3,"virt_text":[[" ","OpencodeMessageRoleAssistant"],[" "],["BUILD","OpencodeMessageRoleAssistant"],[" gpt-4.1","OpencodeHint"],[" (2026-01-26 14:30:46)","OpencodeHint"],[" [msg_bfab6dc80001FueCN7E2691J2R]","OpencodeHint"]],"virt_text_pos":"win_col"}],[8,13,0,{"virt_text_hide":false,"virt_text_win_col":-3,"priority":10,"virt_text_repeat_linebreak":false,"right_gravity":true,"ns_id":3,"virt_text":[[" ","OpencodeMessageRoleSystem"],[" "],["SYSTEM","OpencodeMessageRoleSystem"],["","OpencodeHint"],["","OpencodeHint"],[" [question-display-message]","OpencodeHint"]],"virt_text_pos":"win_col"}],[9,15,0,{"right_gravity":true,"ns_id":3,"priority":4096,"line_hl_group":"OpencodeQuestionTitle"}],[10,15,0,{"virt_text_hide":false,"virt_text_win_col":-2,"priority":4096,"virt_text_repeat_linebreak":true,"right_gravity":true,"ns_id":3,"virt_text":[["▌","OpencodeQuestionBorder"]],"virt_text_pos":"win_col"}],[11,16,0,{"virt_text_hide":false,"virt_text_win_col":-2,"priority":4096,"virt_text_repeat_linebreak":true,"right_gravity":true,"ns_id":3,"virt_text":[["▌","OpencodeQuestionBorder"]],"virt_text_pos":"win_col"}],[12,17,0,{"virt_text_hide":false,"virt_text_win_col":-2,"priority":4096,"virt_text_repeat_linebreak":true,"right_gravity":true,"ns_id":3,"virt_text":[["▌","OpencodeQuestionBorder"]],"virt_text_pos":"win_col"}],[13,18,0,{"virt_text_hide":false,"virt_text_win_col":-2,"priority":4096,"virt_text_repeat_linebreak":true,"right_gravity":true,"ns_id":3,"virt_text":[["▌","OpencodeQuestionBorder"]],"virt_text_pos":"win_col"}],[14,19,0,{"right_gravity":true,"ns_id":3,"priority":4096,"line_hl_group":"OpencodeQuestionOptionHover"}],[15,19,0,{"virt_text_hide":false,"virt_text_win_col":-2,"priority":4096,"virt_text_repeat_linebreak":true,"right_gravity":true,"ns_id":3,"virt_text":[["▌","OpencodeQuestionBorder"]],"virt_text_pos":"win_col"}],[16,19,2,{"virt_text_hide":false,"priority":4096,"virt_text_repeat_linebreak":false,"right_gravity":true,"ns_id":3,"virt_text":[["› ","OpencodeQuestionOptionHover"]],"virt_text_pos":"overlay"}],[17,20,0,{"virt_text_hide":false,"virt_text_win_col":-2,"priority":4096,"virt_text_repeat_linebreak":true,"right_gravity":true,"ns_id":3,"virt_text":[["▌","OpencodeQuestionBorder"]],"virt_text_pos":"win_col"}],[18,21,0,{"virt_text_hide":false,"virt_text_win_col":-2,"priority":4096,"virt_text_repeat_linebreak":true,"right_gravity":true,"ns_id":3,"virt_text":[["▌","OpencodeQuestionBorder"]],"virt_text_pos":"win_col"}],[19,22,0,{"virt_text_hide":false,"virt_text_win_col":-2,"priority":4096,"virt_text_repeat_linebreak":true,"right_gravity":true,"ns_id":3,"virt_text":[["▌","OpencodeQuestionBorder"]],"virt_text_pos":"win_col"}],[20,23,0,{"virt_text_hide":false,"virt_text_win_col":-2,"priority":4096,"virt_text_repeat_linebreak":true,"right_gravity":true,"ns_id":3,"virt_text":[["▌","OpencodeQuestionBorder"]],"virt_text_pos":"win_col"}]],"actions":[],"timestamp":1769438058} \ No newline at end of file diff --git a/tests/data/multiple-question-ask.json b/tests/data/multiple-question-ask.json new file mode 100644 index 00000000..4a4da6ac --- /dev/null +++ b/tests/data/multiple-question-ask.json @@ -0,0 +1 @@ +[{"type":"session.updated","properties":{"info":{"slug":"happy-harbor","time":{"created":1769437845874,"updated":1769437845874},"projectID":"29d43526f88157cd4edd071b899dd01f240b771b","id":"ses_40549268dffehKpDd14uHIMQaT","version":"1.1.31","title":"New session - 2026-01-26T14:30:45.874Z","directory":"/home/francis/Projects/_nvim/opencode.nvim"}}},{"type":"custom.emit_events.finished","properties":[]},{"type":"message.updated","properties":{"info":{"id":"msg_bfab6dbf3001ZOVHTFKR1CMUE5","time":{"created":1769437846515},"role":"user","agent":"build","sessionID":"ses_40549268dffehKpDd14uHIMQaT","model":{"providerID":"github-copilot","modelID":"gpt-4.1"}}}},{"type":"message.part.updated","properties":{"part":{"type":"text","text":"Called the Read tool with the following input: {\"filePath\":\"/home/francis/Projects/_nvim/opencode.nvim/tests/replay/renderer_spec.lua\"}","sessionID":"ses_40549268dffehKpDd14uHIMQaT","messageID":"msg_bfab6dbf3001ZOVHTFKR1CMUE5","id":"prt_bfab6dbf4002ydMd2JojY4Nda0","synthetic":true}}},{"type":"message.part.updated","properties":{"part":{"type":"text","text":"\n00001| local state = require('opencode.state')\n00002| local ui = require('opencode.ui.ui')\n00003| local helpers = require('tests.helpers')\n00004| local output_window = require('opencode.ui.output_window')\n00005| local assert = require('luassert')\n00006| local config = require('opencode.config')\n00007| \n00008| local function assert_output_matches(expected, actual, name)\n00009| local normalized_extmarks = helpers.normalize_namespace_ids(actual.extmarks)\n00010| \n00011| assert.are.equal(\n00012| #expected.lines,\n00013| #actual.lines,\n00014| string.format(\n00015| 'Line count mismatch: expected %d, got %d.\\nFirst difference at index %d:\\n Expected: %s\\n Actual: %s',\n00016| #expected.lines,\n00017| #actual.lines,\n00018| math.min(#expected.lines, #actual.lines) + 1,\n00019| vim.inspect(expected.lines[math.min(#expected.lines, #actual.lines) + 1]),\n00020| vim.inspect(actual.lines[math.min(#expected.lines, #actual.lines) + 1])\n00021| )\n00022| )\n00023| \n00024| for i = 1, #expected.lines do\n00025| assert.are.equal(\n00026| expected.lines[i],\n00027| actual.lines[i],\n00028| string.format(\n00029| 'Line %d mismatch:\\n Expected: %s\\n Actual: %s',\n00030| i,\n00031| vim.inspect(expected.lines[i]),\n00032| vim.inspect(actual.lines[i])\n00033| )\n00034| )\n00035| end\n00036| \n00037| assert.are.equal(\n00038| #expected.extmarks,\n00039| #normalized_extmarks,\n00040| string.format(\n00041| 'Extmark count mismatch: expected %d, got %d.\\nFirst difference at index %d:\\n Expected: %s\\n Actual: %s',\n00042| #expected.extmarks,\n00043| #normalized_extmarks,\n00044| math.min(#expected.extmarks, #normalized_extmarks) + 1,\n00045| vim.inspect(expected.extmarks[math.min(#expected.extmarks, #normalized_extmarks) + 1]),\n00046| vim.inspect(normalized_extmarks[math.min(#expected.extmarks, #normalized_extmarks) + 1])\n00047| )\n00048| )\n00049| \n00050| for i = 1, #expected.extmarks do\n00051| assert.are.same(\n00052| expected.extmarks[i],\n00053| normalized_extmarks[i],\n00054| string.format(\n00055| 'Extmark %d mismatch:\\n Expected: %s\\n Actual: %s',\n00056| i,\n00057| vim.inspect(expected.extmarks[i]),\n00058| vim.inspect(normalized_extmarks[i])\n00059| )\n00060| )\n00061| end\n00062| \n00063| local expected_action_count = expected.actions and #expected.actions or 0\n00064| local actual_action_count = actual.actions and #actual.actions or 0\n00065| \n00066| assert.are.equal(\n00067| expected_action_count,\n00068| actual_action_count,\n00069| string.format('Action count mismatch: expected %d, got %d', expected_action_count, actual_action_count)\n00070| )\n00071| \n00072| if expected.actions then\n00073| -- Sort both arrays for consistent comparison since order doesn't matter\n00074| local function sort_actions(actions)\n00075| local sorted = vim.deepcopy(actions)\n00076| table.sort(sorted, function(a, b)\n00077| return vim.inspect(a) < vim.inspect(b)\n00078| end)\n00079| return sorted\n00080| end\n00081| \n00082| assert.same(\n00083| sort_actions(expected.actions),\n00084| sort_actions(actual.actions),\n00085| string.format(\n00086| 'Actions mismatch:\\n Expected: %s\\n Actual: %s',\n00087| vim.inspect(expected.actions),\n00088| vim.inspect(actual.actions)\n00089| )\n00090| )\n00091| end\n00092| end\n00093| \n00094| describe('renderer unit tests', function()\n00095| local event_subscriptions = {\n00096| 'session.updated',\n00097| 'session.compacted',\n00098| 'session.error',\n00099| 'message.updated',\n00100| 'message.removed',\n00101| 'message.part.updated',\n00102| 'message.part.removed',\n00103| 'permission.updated',\n00104| 'permission.replied',\n00105| 'question.replied',\n00106| 'question.asked',\n00107| 'file.edited',\n00108| 'custom.restore_point.created',\n00109| 'custom.emit_events.finished',\n00110| }\n00111| \n00112| before_each(function()\n00113| require('opencode.event_manager').setup()\n00114| end)\n00115| \n00116| it('subsribes to events correctly', function()\n00117| local renderer = require('opencode.ui.renderer')\n00118| local event_manager = state.event_manager\n00119| \n00120| event_manager.events = {}\n00121| \n00122| renderer.setup_subscriptions()\n00123| \n00124| for _, event_name in ipairs(event_subscriptions) do\n00125| assert.is_true(\n00126| event_manager.events[event_name] ~= nil,\n00127| string.format('Renderer did not subscribe to event: %s', event_name)\n00128| )\n00129| end\n00130| end)\n00131| \n00132| it('unsubsribes from events correctly', function()\n00133| local renderer = require('opencode.ui.renderer')\n00134| local event_manager = state.event_manager\n00135| \n00136| renderer.setup_subscriptions()\n00137| \n00138| renderer.setup_subscriptions(false)\n00139| \n00140| for _, event_name in ipairs(event_subscriptions) do\n00141| assert.is_true(\n00142| vim.tbl_isempty(event_manager.events[event_name]),\n00143| string.format('Renderer did not unsubscribe from event: %s', event_name)\n00144| )\n00145| end\n00146| end)\n00147| end)\n00148| \n00149| describe('renderer functional tests', function()\n00150| config.debug.show_ids = true\n00151| \n00152| before_each(function()\n00153| helpers.replay_setup()\n00154| end)\n00155| \n00156| after_each(function()\n00157| if state.windows then\n00158| ui.close_windows(state.windows)\n00159| end\n00160| end)\n00161| \n00162| local json_files = vim.fn.glob('tests/data/*.json', false, true)\n00163| \n00164| -- Don't do the full session test on these files, usually\n00165| -- because they involve permission prompts\n00166| local skip_full_session = {\n00167| 'permission-prompt',\n00168| 'permission-ask-new',\n00169| 'question-ask',\n00170| 'shifting-and-multiple-perms',\n00171| 'message-removal',\n00172| }\n00173| \n00174| for _, filepath in ipairs(json_files) do\n00175| local name = vim.fn.fnamemodify(filepath, ':t:r')\n00176| \n00177| if not name:match('%.expected$') then\n00178| local expected_path = 'tests/data/' .. name .. '.expected.json'\n00179| \n00180| if vim.fn.filereadable(expected_path) == 1 then\n00181| for i = 1, 2 do\n00182| config.ui.output.rendering.event_collapsing = i == 1 and true or false\n00183| it(\n00184| 'replays '\n00185| .. name\n00186| .. ' correctly (event-by-event, '\n00187| .. (config.ui.output.rendering.event_collapsing and 'collapsing' or 'no collapsing')\n00188| .. ')',\n00189| function()\n00190| local events = helpers.load_test_data(filepath)\n00191| state.active_session = helpers.get_session_from_events(events)\n00192| local expected = helpers.load_test_data(expected_path)\n00193| \n00194| helpers.replay_events(events)\n00195| vim.wait(1000, function()\n00196| return vim.tbl_isempty(state.event_manager.throttling_emitter.queue)\n00197| end)\n00198| \n00199| local actual = helpers.capture_output(state.windows and state.windows.output_buf, output_window.namespace)\n00200| assert_output_matches(expected, actual, name)\n00201| end\n00202| )\n00203| end\n00204| \n00205| if not vim.tbl_contains(skip_full_session, name) then\n00206| it('replays ' .. name .. ' correctly (session)', function()\n00207| local renderer = require('opencode.ui.renderer')\n00208| local events = helpers.load_test_data(filepath)\n00209| state.active_session = helpers.get_session_from_events(events, true)\n00210| local expected = helpers.load_test_data(expected_path)\n00211| \n00212| local session_data = helpers.load_session_from_events(events)\n00213| renderer._render_full_session_data(session_data)\n00214| \n00215| local actual = helpers.capture_output(state.windows and state.windows.output_buf, output_window.namespace)\n00216| assert_output_matches(expected, actual, name)\n00217| end)\n00218| end\n00219| end\n00220| end\n00221| end\n00222| end)\n00223| \n\n(End of file - total 223 lines)\n","sessionID":"ses_40549268dffehKpDd14uHIMQaT","messageID":"msg_bfab6dbf3001ZOVHTFKR1CMUE5","id":"prt_bfab6dbf80012C7P5ugGXl7Kbv","synthetic":true}}},{"type":"message.part.updated","properties":{"part":{"mime":"text/plain","type":"file","sessionID":"ses_40549268dffehKpDd14uHIMQaT","id":"prt_bfab6dbf9001Kebk9O9AoUc4yI","messageID":"msg_bfab6dbf3001ZOVHTFKR1CMUE5","filename":"tests/replay/renderer_spec.lua","url":"file:///home/francis/Projects/_nvim/opencode.nvim/tests/replay/renderer_spec.lua"}}},{"type":"message.part.updated","properties":{"part":{"type":"text","text":"can you use the question tool and ask me a couple of questions","sessionID":"ses_40549268dffehKpDd14uHIMQaT","messageID":"msg_bfab6dbf3001ZOVHTFKR1CMUE5","id":"prt_bfab6dbf4001xrYv3BzlgWGgkv"}}},{"type":"session.updated","properties":{"info":{"slug":"happy-harbor","time":{"created":1769437845874,"updated":1769437846547},"projectID":"29d43526f88157cd4edd071b899dd01f240b771b","id":"ses_40549268dffehKpDd14uHIMQaT","version":"1.1.31","title":"New session - 2026-01-26T14:30:45.874Z","directory":"/home/francis/Projects/_nvim/opencode.nvim"}}},{"type":"custom.emit_events.finished","properties":[]},{"type":"message.updated","properties":{"info":{"id":"msg_bfab6dc80001FueCN7E2691J2R","modelID":"gpt-4.1","providerID":"github-copilot","cost":0,"sessionID":"ses_40549268dffehKpDd14uHIMQaT","mode":"build","time":{"created":1769437846657},"role":"assistant","path":{"cwd":"/home/francis/Projects/_nvim/opencode.nvim","root":"/home/francis/Projects/_nvim/opencode.nvim"},"agent":"build","tokens":{"reasoning":0,"input":0,"output":0,"cache":{"write":0,"read":0}},"parentID":"msg_bfab6dbf3001ZOVHTFKR1CMUE5"}}},{"type":"custom.emit_events.finished","properties":[]},{"type":"message.updated","properties":{"info":{"id":"msg_bfab6dbf3001ZOVHTFKR1CMUE5","time":{"created":1769437846515},"role":"user","agent":"build","sessionID":"ses_40549268dffehKpDd14uHIMQaT","summary":{"diffs":[]},"model":{"providerID":"github-copilot","modelID":"gpt-4.1"}}}},{"type":"session.updated","properties":{"info":{"id":"ses_40549268dffehKpDd14uHIMQaT","slug":"happy-harbor","directory":"/home/francis/Projects/_nvim/opencode.nvim","projectID":"29d43526f88157cd4edd071b899dd01f240b771b","title":"New session - 2026-01-26T14:30:45.874Z","version":"1.1.31","summary":{"additions":0,"deletions":0,"files":0},"time":{"created":1769437845874,"updated":1769437846728}}}},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"message.part.updated","properties":{"part":{"type":"step-start","snapshot":"be64b6205f0da4c9240df2476dc35495429161b5","sessionID":"ses_40549268dffehKpDd14uHIMQaT","messageID":"msg_bfab6dc80001FueCN7E2691J2R","id":"prt_bfab6e0c10013qo2ZThCt7FqLA"}}},{"type":"message.part.updated","properties":{"part":{"id":"prt_bfab6e0c4001ou3kcu0UICsKQq","tool":"question","sessionID":"ses_40549268dffehKpDd14uHIMQaT","callID":"call_wHoqSXKi7juWQVtpwv4tMMTj","messageID":"msg_bfab6dc80001FueCN7E2691J2R","type":"tool","state":{"raw":"","input":{},"status":"pending"}}}},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"question.asked","properties":{"questions":[{"question":"Which Lua testing framework do you use or prefer for your Neovim Lua plugins?","header":"Testing Framework?","options":[{"label":"busted","description":"Busted is the most common Lua testing framework."},{"label":"plenary.nvim","description":"Plenary.nvim provides unit test utilities for Neovim plugins."},{"label":"other","description":"Other or custom test framework"}]},{"question":"How do you usually run your tests in this codebase?","header":"Run Tests Approach","options":[{"label":"run_tests.sh (Recommended)","description":"Use run_tests.sh script provided in repo root."},{"label":"Direct test runner","description":"Use individual test commands (e.g., invoke Busted/Plenary directly)."},{"label":"Other","description":"Other custom scripts or workflow"}]}],"id":"que_bfab6ef4f0010bzEmrR38ndiIA","sessionID":"ses_40549268dffehKpDd14uHIMQaT","tool":{"messageID":"msg_bfab6dc80001FueCN7E2691J2R","callID":"call_wHoqSXKi7juWQVtpwv4tMMTj"}}},{"type":"message.part.updated","properties":{"part":{"id":"prt_bfab6e0c4001ou3kcu0UICsKQq","tool":"question","sessionID":"ses_40549268dffehKpDd14uHIMQaT","callID":"call_wHoqSXKi7juWQVtpwv4tMMTj","messageID":"msg_bfab6dc80001FueCN7E2691J2R","type":"tool","state":{"input":{"questions":[{"question":"Which Lua testing framework do you use or prefer for your Neovim Lua plugins?","header":"Testing Framework?","options":[{"label":"busted","description":"Busted is the most common Lua testing framework."},{"label":"plenary.nvim","description":"Plenary.nvim provides unit test utilities for Neovim plugins."},{"label":"other","description":"Other or custom test framework"}]},{"question":"How do you usually run your tests in this codebase?","header":"Run Tests Approach","options":[{"label":"run_tests.sh (Recommended)","description":"Use run_tests.sh script provided in repo root."},{"label":"Direct test runner","description":"Use individual test commands (e.g., invoke Busted/Plenary directly)."},{"label":"Other","description":"Other custom scripts or workflow"}]}]},"time":{"start":1769437851471},"status":"running"}}}},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"message.updated","properties":{"info":{"id":"msg_bfab6dbf3001ZOVHTFKR1CMUE5","time":{"created":1769437846515},"role":"user","agent":"build","sessionID":"ses_40549268dffehKpDd14uHIMQaT","summary":{"title":"Ask me two questions","diffs":[]},"model":{"providerID":"github-copilot","modelID":"gpt-4.1"}}}},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"session.updated","properties":{"info":{"id":"ses_40549268dffehKpDd14uHIMQaT","slug":"happy-harbor","directory":"/home/francis/Projects/_nvim/opencode.nvim","projectID":"29d43526f88157cd4edd071b899dd01f240b771b","title":"Ask questions about renderer_spec.lua","version":"1.1.31","summary":{"additions":0,"deletions":0,"files":0},"time":{"created":1769437845874,"updated":1769437846728}}}},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]}] diff --git a/tests/data/question-ask-replied.expected.json b/tests/data/question-ask-replied.expected.json new file mode 100644 index 00000000..436073fb --- /dev/null +++ b/tests/data/question-ask-replied.expected.json @@ -0,0 +1 @@ +{"extmarks":[[1,1,0,{"priority":10,"virt_text_pos":"win_col","virt_text":[["▌󰭻 ","OpencodeMessageRoleUser"],[" "],["USER","OpencodeMessageRoleUser"],["","OpencodeHint"],[" (2026-01-26 14:23:12)","OpencodeHint"],[" [msg_bfaafef41001700DjtCwvhFxse]","OpencodeHint"]],"ns_id":3,"virt_text_hide":false,"virt_text_win_col":-3,"right_gravity":true,"virt_text_repeat_linebreak":false}],[2,2,0,{"priority":4096,"virt_text_pos":"win_col","virt_text":[["▌","OpencodeMessageRoleUser"]],"ns_id":3,"virt_text_hide":false,"virt_text_win_col":-3,"right_gravity":true,"virt_text_repeat_linebreak":true}],[3,3,0,{"priority":4096,"virt_text_pos":"win_col","virt_text":[["▌","OpencodeMessageRoleUser"]],"ns_id":3,"virt_text_hide":false,"virt_text_win_col":-3,"right_gravity":true,"virt_text_repeat_linebreak":true}],[4,4,0,{"priority":4096,"virt_text_pos":"win_col","virt_text":[["▌","OpencodeMessageRoleUser"]],"ns_id":3,"virt_text_hide":false,"virt_text_win_col":-3,"right_gravity":true,"virt_text_repeat_linebreak":true}],[5,5,0,{"priority":4096,"virt_text_pos":"win_col","virt_text":[["▌","OpencodeMessageRoleUser"]],"ns_id":3,"virt_text_hide":false,"virt_text_win_col":-3,"right_gravity":true,"virt_text_repeat_linebreak":true}],[6,6,0,{"priority":4096,"virt_text_pos":"win_col","virt_text":[["▌","OpencodeMessageRoleUser"]],"ns_id":3,"virt_text_hide":false,"virt_text_win_col":-3,"right_gravity":true,"virt_text_repeat_linebreak":true}],[7,8,0,{"priority":10,"virt_text_pos":"win_col","virt_text":[[" ","OpencodeMessageRoleAssistant"],[" "],["BUILD","OpencodeMessageRoleAssistant"],[" gpt-4.1","OpencodeHint"],[" (2026-01-26 14:23:12)","OpencodeHint"],[" [msg_bfaafefb3001iBSmqfzFffDjCS]","OpencodeHint"]],"ns_id":3,"virt_text_hide":false,"virt_text_win_col":-3,"right_gravity":true,"virt_text_repeat_linebreak":false}],[8,10,0,{"priority":4096,"virt_text_pos":"win_col","virt_text":[["▌","OpencodeToolBorder"]],"ns_id":3,"virt_text_hide":false,"virt_text_win_col":-1,"right_gravity":true,"virt_text_repeat_linebreak":true}],[9,11,0,{"priority":4096,"virt_text_pos":"win_col","virt_text":[["▌","OpencodeToolBorder"]],"ns_id":3,"virt_text_hide":false,"virt_text_win_col":-1,"right_gravity":true,"virt_text_repeat_linebreak":true}],[10,12,0,{"priority":4096,"virt_text_pos":"win_col","virt_text":[["▌","OpencodeToolBorder"]],"ns_id":3,"virt_text_hide":false,"virt_text_win_col":-1,"right_gravity":true,"virt_text_repeat_linebreak":true}],[11,13,0,{"priority":4096,"virt_text_pos":"win_col","virt_text":[["▌","OpencodeToolBorder"]],"ns_id":3,"virt_text_hide":false,"virt_text_win_col":-1,"right_gravity":true,"virt_text_repeat_linebreak":true}],[12,16,0,{"priority":10,"virt_text_pos":"win_col","virt_text":[[" ","OpencodeMessageRoleAssistant"],[" "],["BUILD","OpencodeMessageRoleAssistant"],[" gpt-4.1","OpencodeHint"],[" (2026-01-26 14:23:22)","OpencodeHint"],[" [msg_bfab0156d001NyMxaTnj6Cml0c]","OpencodeHint"]],"ns_id":3,"virt_text_hide":false,"virt_text_win_col":-3,"right_gravity":true,"virt_text_repeat_linebreak":false}]],"timestamp":1769437626,"lines":["----","","","[tests/replay/renderer_spec.lua](tests/replay/renderer_spec.lua)","","can you use the question tool and ask me a question with an other choice","","----","","","** question** ","","**Q1:** What is your favorite color?","**A1:** Red","","----","","","Thank you! You chose \"Red\" as your favorite color. If you have a specific task, question, or want to see this preference used in an example or feature, please let me know how you'd like to proceed!","",""],"actions":[]} \ No newline at end of file diff --git a/tests/data/question-ask-replied.json b/tests/data/question-ask-replied.json new file mode 100644 index 00000000..b9276f67 --- /dev/null +++ b/tests/data/question-ask-replied.json @@ -0,0 +1 @@ +[{"type":"session.updated","properties":{"info":{"time":{"created":1769437382680,"updated":1769437382680},"title":"New session - 2026-01-26T14:23:02.680Z","version":"1.1.31","projectID":"29d43526f88157cd4edd071b899dd01f240b771b","slug":"sunny-planet","id":"ses_4055037e7ffe7iiIdzWGnWwd0H","directory":"/home/francis/Projects/_nvim/opencode.nvim"}}},{"type":"custom.emit_events.finished","properties":[]},{"type":"message.updated","properties":{"info":{"model":{"providerID":"github-copilot","modelID":"gpt-4.1"},"time":{"created":1769437392705},"role":"user","sessionID":"ses_4055037e7ffe7iiIdzWGnWwd0H","id":"msg_bfaafef41001700DjtCwvhFxse","agent":"build"}}},{"type":"message.part.updated","properties":{"part":{"type":"text","synthetic":true,"text":"Called the Read tool with the following input: {\"filePath\":\"/home/francis/Projects/_nvim/opencode.nvim/tests/replay/renderer_spec.lua\"}","sessionID":"ses_4055037e7ffe7iiIdzWGnWwd0H","id":"prt_bfaafef42002jckWu6xA2ZV3zg","messageID":"msg_bfaafef41001700DjtCwvhFxse"}}},{"type":"message.part.updated","properties":{"part":{"type":"text","synthetic":true,"text":"\n00001| local state = require('opencode.state')\n00002| local ui = require('opencode.ui.ui')\n00003| local helpers = require('tests.helpers')\n00004| local output_window = require('opencode.ui.output_window')\n00005| local assert = require('luassert')\n00006| local config = require('opencode.config')\n00007| \n00008| local function assert_output_matches(expected, actual, name)\n00009| local normalized_extmarks = helpers.normalize_namespace_ids(actual.extmarks)\n00010| \n00011| assert.are.equal(\n00012| #expected.lines,\n00013| #actual.lines,\n00014| string.format(\n00015| 'Line count mismatch: expected %d, got %d.\\nFirst difference at index %d:\\n Expected: %s\\n Actual: %s',\n00016| #expected.lines,\n00017| #actual.lines,\n00018| math.min(#expected.lines, #actual.lines) + 1,\n00019| vim.inspect(expected.lines[math.min(#expected.lines, #actual.lines) + 1]),\n00020| vim.inspect(actual.lines[math.min(#expected.lines, #actual.lines) + 1])\n00021| )\n00022| )\n00023| \n00024| for i = 1, #expected.lines do\n00025| assert.are.equal(\n00026| expected.lines[i],\n00027| actual.lines[i],\n00028| string.format(\n00029| 'Line %d mismatch:\\n Expected: %s\\n Actual: %s',\n00030| i,\n00031| vim.inspect(expected.lines[i]),\n00032| vim.inspect(actual.lines[i])\n00033| )\n00034| )\n00035| end\n00036| \n00037| assert.are.equal(\n00038| #expected.extmarks,\n00039| #normalized_extmarks,\n00040| string.format(\n00041| 'Extmark count mismatch: expected %d, got %d.\\nFirst difference at index %d:\\n Expected: %s\\n Actual: %s',\n00042| #expected.extmarks,\n00043| #normalized_extmarks,\n00044| math.min(#expected.extmarks, #normalized_extmarks) + 1,\n00045| vim.inspect(expected.extmarks[math.min(#expected.extmarks, #normalized_extmarks) + 1]),\n00046| vim.inspect(normalized_extmarks[math.min(#expected.extmarks, #normalized_extmarks) + 1])\n00047| )\n00048| )\n00049| \n00050| for i = 1, #expected.extmarks do\n00051| assert.are.same(\n00052| expected.extmarks[i],\n00053| normalized_extmarks[i],\n00054| string.format(\n00055| 'Extmark %d mismatch:\\n Expected: %s\\n Actual: %s',\n00056| i,\n00057| vim.inspect(expected.extmarks[i]),\n00058| vim.inspect(normalized_extmarks[i])\n00059| )\n00060| )\n00061| end\n00062| \n00063| local expected_action_count = expected.actions and #expected.actions or 0\n00064| local actual_action_count = actual.actions and #actual.actions or 0\n00065| \n00066| assert.are.equal(\n00067| expected_action_count,\n00068| actual_action_count,\n00069| string.format('Action count mismatch: expected %d, got %d', expected_action_count, actual_action_count)\n00070| )\n00071| \n00072| if expected.actions then\n00073| -- Sort both arrays for consistent comparison since order doesn't matter\n00074| local function sort_actions(actions)\n00075| local sorted = vim.deepcopy(actions)\n00076| table.sort(sorted, function(a, b)\n00077| return vim.inspect(a) < vim.inspect(b)\n00078| end)\n00079| return sorted\n00080| end\n00081| \n00082| assert.same(\n00083| sort_actions(expected.actions),\n00084| sort_actions(actual.actions),\n00085| string.format(\n00086| 'Actions mismatch:\\n Expected: %s\\n Actual: %s',\n00087| vim.inspect(expected.actions),\n00088| vim.inspect(actual.actions)\n00089| )\n00090| )\n00091| end\n00092| end\n00093| \n00094| describe('renderer unit tests', function()\n00095| local event_subscriptions = {\n00096| 'session.updated',\n00097| 'session.compacted',\n00098| 'session.error',\n00099| 'message.updated',\n00100| 'message.removed',\n00101| 'message.part.updated',\n00102| 'message.part.removed',\n00103| 'permission.updated',\n00104| 'permission.replied',\n00105| 'question.replied',\n00106| 'question.asked',\n00107| 'file.edited',\n00108| 'custom.restore_point.created',\n00109| 'custom.emit_events.finished',\n00110| }\n00111| \n00112| before_each(function()\n00113| require('opencode.event_manager').setup()\n00114| end)\n00115| \n00116| it('subsribes to events correctly', function()\n00117| local renderer = require('opencode.ui.renderer')\n00118| local event_manager = state.event_manager\n00119| \n00120| event_manager.events = {}\n00121| \n00122| renderer.setup_subscriptions()\n00123| \n00124| for _, event_name in ipairs(event_subscriptions) do\n00125| assert.is_true(\n00126| event_manager.events[event_name] ~= nil,\n00127| string.format('Renderer did not subscribe to event: %s', event_name)\n00128| )\n00129| end\n00130| end)\n00131| \n00132| it('unsubsribes from events correctly', function()\n00133| local renderer = require('opencode.ui.renderer')\n00134| local event_manager = state.event_manager\n00135| \n00136| renderer.setup_subscriptions()\n00137| \n00138| renderer.setup_subscriptions(false)\n00139| \n00140| for _, event_name in ipairs(event_subscriptions) do\n00141| assert.is_true(\n00142| vim.tbl_isempty(event_manager.events[event_name]),\n00143| string.format('Renderer did not unsubscribe from event: %s', event_name)\n00144| )\n00145| end\n00146| end)\n00147| end)\n00148| \n00149| describe('renderer functional tests', function()\n00150| config.debug.show_ids = true\n00151| \n00152| before_each(function()\n00153| helpers.replay_setup()\n00154| end)\n00155| \n00156| after_each(function()\n00157| if state.windows then\n00158| ui.close_windows(state.windows)\n00159| end\n00160| end)\n00161| \n00162| local json_files = vim.fn.glob('tests/data/*.json', false, true)\n00163| \n00164| -- Don't do the full session test on these files, usually\n00165| -- because they involve permission prompts\n00166| local skip_full_session = {\n00167| 'permission-prompt',\n00168| 'permission-ask-new',\n00169| 'question-ask',\n00170| 'shifting-and-multiple-perms',\n00171| 'message-removal',\n00172| }\n00173| \n00174| for _, filepath in ipairs(json_files) do\n00175| local name = vim.fn.fnamemodify(filepath, ':t:r')\n00176| \n00177| if not name:match('%.expected$') then\n00178| local expected_path = 'tests/data/' .. name .. '.expected.json'\n00179| \n00180| if vim.fn.filereadable(expected_path) == 1 then\n00181| for i = 1, 2 do\n00182| config.ui.output.rendering.event_collapsing = i == 1 and true or false\n00183| it(\n00184| 'replays '\n00185| .. name\n00186| .. ' correctly (event-by-event, '\n00187| .. (config.ui.output.rendering.event_collapsing and 'collapsing' or 'no collapsing')\n00188| .. ')',\n00189| function()\n00190| local events = helpers.load_test_data(filepath)\n00191| state.active_session = helpers.get_session_from_events(events)\n00192| local expected = helpers.load_test_data(expected_path)\n00193| \n00194| helpers.replay_events(events)\n00195| vim.wait(1000, function()\n00196| return vim.tbl_isempty(state.event_manager.throttling_emitter.queue)\n00197| end)\n00198| \n00199| local actual = helpers.capture_output(state.windows and state.windows.output_buf, output_window.namespace)\n00200| assert_output_matches(expected, actual, name)\n00201| end\n00202| )\n00203| end\n00204| \n00205| if not vim.tbl_contains(skip_full_session, name) then\n00206| it('replays ' .. name .. ' correctly (session)', function()\n00207| local renderer = require('opencode.ui.renderer')\n00208| local events = helpers.load_test_data(filepath)\n00209| state.active_session = helpers.get_session_from_events(events, true)\n00210| local expected = helpers.load_test_data(expected_path)\n00211| \n00212| local session_data = helpers.load_session_from_events(events)\n00213| renderer._render_full_session_data(session_data)\n00214| \n00215| local actual = helpers.capture_output(state.windows and state.windows.output_buf, output_window.namespace)\n00216| assert_output_matches(expected, actual, name)\n00217| end)\n00218| end\n00219| end\n00220| end\n00221| end\n00222| end)\n00223| \n\n(End of file - total 223 lines)\n","sessionID":"ses_4055037e7ffe7iiIdzWGnWwd0H","id":"prt_bfaafef47001HR3fZ3Ct7nScuT","messageID":"msg_bfaafef41001700DjtCwvhFxse"}}},{"type":"message.part.updated","properties":{"part":{"type":"file","messageID":"msg_bfaafef41001700DjtCwvhFxse","url":"file:///home/francis/Projects/_nvim/opencode.nvim/tests/replay/renderer_spec.lua","sessionID":"ses_4055037e7ffe7iiIdzWGnWwd0H","filename":"tests/replay/renderer_spec.lua","id":"prt_bfaafef47002OES5db7H5px77j","mime":"text/plain"}}},{"type":"message.part.updated","properties":{"part":{"type":"text","text":"can you use the question tool and ask me a question with an other choice","sessionID":"ses_4055037e7ffe7iiIdzWGnWwd0H","id":"prt_bfaafef42001XawiVVGpNU0t5E","messageID":"msg_bfaafef41001700DjtCwvhFxse"}}},{"type":"session.updated","properties":{"info":{"time":{"created":1769437382680,"updated":1769437392735},"title":"New session - 2026-01-26T14:23:02.680Z","version":"1.1.31","projectID":"29d43526f88157cd4edd071b899dd01f240b771b","slug":"sunny-planet","id":"ses_4055037e7ffe7iiIdzWGnWwd0H","directory":"/home/francis/Projects/_nvim/opencode.nvim"}}},{"type":"custom.emit_events.finished","properties":[]},{"type":"message.updated","properties":{"info":{"time":{"created":1769437392819},"modelID":"gpt-4.1","providerID":"github-copilot","sessionID":"ses_4055037e7ffe7iiIdzWGnWwd0H","mode":"build","cost":0,"role":"assistant","tokens":{"input":0,"output":0,"reasoning":0,"cache":{"read":0,"write":0}},"path":{"root":"/home/francis/Projects/_nvim/opencode.nvim","cwd":"/home/francis/Projects/_nvim/opencode.nvim"},"parentID":"msg_bfaafef41001700DjtCwvhFxse","id":"msg_bfaafefb3001iBSmqfzFffDjCS","agent":"build"}}},{"type":"message.updated","properties":{"info":{"model":{"providerID":"github-copilot","modelID":"gpt-4.1"},"time":{"created":1769437392705},"role":"user","summary":{"diffs":[]},"sessionID":"ses_4055037e7ffe7iiIdzWGnWwd0H","id":"msg_bfaafef41001700DjtCwvhFxse","agent":"build"}}},{"type":"session.updated","properties":{"info":{"projectID":"29d43526f88157cd4edd071b899dd01f240b771b","time":{"created":1769437382680,"updated":1769437392883},"title":"New session - 2026-01-26T14:23:02.680Z","version":"1.1.31","directory":"/home/francis/Projects/_nvim/opencode.nvim","slug":"sunny-planet","id":"ses_4055037e7ffe7iiIdzWGnWwd0H","summary":{"deletions":0,"additions":0,"files":0}}}},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"message.part.updated","properties":{"part":{"type":"step-start","snapshot":"617ad65111b912d010a8ea89957a9c5986bc54f0","sessionID":"ses_4055037e7ffe7iiIdzWGnWwd0H","id":"prt_bfaaff532001RIiCvjxiMht1EK","messageID":"msg_bfaafefb3001iBSmqfzFffDjCS"}}},{"type":"message.part.updated","properties":{"part":{"type":"tool","callID":"call_b4hwTxyb1alO8r2uMlxeB873","tool":"question","state":{"status":"pending","raw":"","input":{}},"sessionID":"ses_4055037e7ffe7iiIdzWGnWwd0H","id":"prt_bfaaff539001jCeh626xIXgw9u","messageID":"msg_bfaafefb3001iBSmqfzFffDjCS"}}},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"question.asked","properties":{"questions":[{"question":"What is your favorite color?","header":"Choose your preferred color","options":[{"description":"A bold, vibrant choice for those who like attention.","label":"Red"},{"description":"A calm, reliable color suitable for most environments.","label":"Blue"},{"description":"Symbolizes growth, harmony, and renewal.","label":"Green"},{"description":"Type your own favorite color.","label":"Other"}]}],"sessionID":"ses_4055037e7ffe7iiIdzWGnWwd0H","tool":{"messageID":"msg_bfaafefb3001iBSmqfzFffDjCS","callID":"call_b4hwTxyb1alO8r2uMlxeB873"},"id":"que_bfaaff9cc001DnGuMPilJYG9ya"}},{"type":"message.part.updated","properties":{"part":{"type":"tool","callID":"call_b4hwTxyb1alO8r2uMlxeB873","tool":"question","state":{"status":"running","time":{"start":1769437395404},"input":{"questions":[{"question":"What is your favorite color?","header":"Choose your preferred color","options":[{"description":"A bold, vibrant choice for those who like attention.","label":"Red"},{"description":"A calm, reliable color suitable for most environments.","label":"Blue"},{"description":"Symbolizes growth, harmony, and renewal.","label":"Green"},{"description":"Type your own favorite color.","label":"Other"}]}]}},"sessionID":"ses_4055037e7ffe7iiIdzWGnWwd0H","id":"prt_bfaaff539001jCeh626xIXgw9u","messageID":"msg_bfaafefb3001iBSmqfzFffDjCS"}}},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"message.updated","properties":{"info":{"model":{"providerID":"github-copilot","modelID":"gpt-4.1"},"time":{"created":1769437392705},"role":"user","summary":{"title":"Question with Other choice","diffs":[]},"sessionID":"ses_4055037e7ffe7iiIdzWGnWwd0H","id":"msg_bfaafef41001700DjtCwvhFxse","agent":"build"}}},{"type":"custom.emit_events.finished","properties":[]},{"type":"question.replied","properties":{"answers":[["Red"]],"sessionID":"ses_4055037e7ffe7iiIdzWGnWwd0H","requestID":"que_bfaaff9cc001DnGuMPilJYG9ya"}},{"type":"message.part.updated","properties":{"part":{"type":"tool","callID":"call_b4hwTxyb1alO8r2uMlxeB873","tool":"question","state":{"metadata":{"answers":[["Red"]],"truncated":false},"time":{"start":1769437395404,"end":1769437402416},"title":"Asked 1 question","status":"completed","output":"User has answered your questions: \"What is your favorite color?\"=\"Red\". You can now continue with the user's answers in mind.","input":{"questions":[{"question":"What is your favorite color?","header":"Choose your preferred color","options":[{"description":"A bold, vibrant choice for those who like attention.","label":"Red"},{"description":"A calm, reliable color suitable for most environments.","label":"Blue"},{"description":"Symbolizes growth, harmony, and renewal.","label":"Green"},{"description":"Type your own favorite color.","label":"Other"}]}]}},"sessionID":"ses_4055037e7ffe7iiIdzWGnWwd0H","id":"prt_bfaaff539001jCeh626xIXgw9u","messageID":"msg_bfaafefb3001iBSmqfzFffDjCS"}}},{"type":"message.part.updated","properties":{"part":{"type":"step-finish","tokens":{"input":12775,"output":150,"reasoning":0,"cache":{"read":0,"write":0}},"cost":0,"reason":"tool-calls","snapshot":"617ad65111b912d010a8ea89957a9c5986bc54f0","sessionID":"ses_4055037e7ffe7iiIdzWGnWwd0H","id":"prt_bfab01535001wSIZq9GBd5DM3p","messageID":"msg_bfaafefb3001iBSmqfzFffDjCS"}}},{"type":"message.updated","properties":{"info":{"time":{"created":1769437392819},"modelID":"gpt-4.1","providerID":"github-copilot","sessionID":"ses_4055037e7ffe7iiIdzWGnWwd0H","tokens":{"input":12775,"output":150,"reasoning":0,"cache":{"read":0,"write":0}},"mode":"build","id":"msg_bfaafefb3001iBSmqfzFffDjCS","role":"assistant","cost":0,"path":{"root":"/home/francis/Projects/_nvim/opencode.nvim","cwd":"/home/francis/Projects/_nvim/opencode.nvim"},"parentID":"msg_bfaafef41001700DjtCwvhFxse","finish":"tool-calls","agent":"build"}}},{"type":"message.updated","properties":{"info":{"time":{"created":1769437392819,"completed":1769437402460},"modelID":"gpt-4.1","providerID":"github-copilot","sessionID":"ses_4055037e7ffe7iiIdzWGnWwd0H","tokens":{"input":12775,"output":150,"reasoning":0,"cache":{"read":0,"write":0}},"mode":"build","id":"msg_bfaafefb3001iBSmqfzFffDjCS","role":"assistant","cost":0,"path":{"root":"/home/francis/Projects/_nvim/opencode.nvim","cwd":"/home/francis/Projects/_nvim/opencode.nvim"},"parentID":"msg_bfaafef41001700DjtCwvhFxse","finish":"tool-calls","agent":"build"}}},{"type":"custom.emit_events.finished","properties":[]},{"type":"message.updated","properties":{"info":{"time":{"created":1769437402477},"modelID":"gpt-4.1","providerID":"github-copilot","sessionID":"ses_4055037e7ffe7iiIdzWGnWwd0H","mode":"build","cost":0,"role":"assistant","tokens":{"input":0,"output":0,"reasoning":0,"cache":{"read":0,"write":0}},"path":{"root":"/home/francis/Projects/_nvim/opencode.nvim","cwd":"/home/francis/Projects/_nvim/opencode.nvim"},"parentID":"msg_bfaafef41001700DjtCwvhFxse","id":"msg_bfab0156d001NyMxaTnj6Cml0c","agent":"build"}}},{"type":"message.updated","properties":{"info":{"model":{"providerID":"github-copilot","modelID":"gpt-4.1"},"time":{"created":1769437392705},"role":"user","summary":{"title":"Question with Other choice","diffs":[]},"sessionID":"ses_4055037e7ffe7iiIdzWGnWwd0H","id":"msg_bfaafef41001700DjtCwvhFxse","agent":"build"}}},{"type":"session.updated","properties":{"info":{"projectID":"29d43526f88157cd4edd071b899dd01f240b771b","time":{"created":1769437382680,"updated":1769437402502},"title":"New session - 2026-01-26T14:23:02.680Z","version":"1.1.31","directory":"/home/francis/Projects/_nvim/opencode.nvim","slug":"sunny-planet","id":"ses_4055037e7ffe7iiIdzWGnWwd0H","summary":{"deletions":0,"additions":0,"files":0}}}},{"type":"custom.emit_events.finished","properties":[]},{"type":"message.part.updated","properties":{"part":{"type":"step-start","snapshot":"617ad65111b912d010a8ea89957a9c5986bc54f0","sessionID":"ses_4055037e7ffe7iiIdzWGnWwd0H","id":"prt_bfab01cd9001JINkAnbcXksgNO","messageID":"msg_bfab0156d001NyMxaTnj6Cml0c"}}},{"type":"message.part.updated","properties":{"part":{"type":"text","time":{"start":1769437404381},"text":"Thank you","sessionID":"ses_4055037e7ffe7iiIdzWGnWwd0H","id":"prt_bfab01cdd001Y6UnA89vh364ZV","messageID":"msg_bfab0156d001NyMxaTnj6Cml0c"},"delta":" you"}},{"type":"custom.emit_events.finished","properties":[]},{"type":"message.part.updated","properties":{"part":{"type":"text","time":{"start":1769437404381},"text":"Thank you! You chose \"Red\" as your favorite color","sessionID":"ses_4055037e7ffe7iiIdzWGnWwd0H","id":"prt_bfab01cdd001Y6UnA89vh364ZV","messageID":"msg_bfab0156d001NyMxaTnj6Cml0c"},"delta":" color"}},{"type":"custom.emit_events.finished","properties":[]},{"type":"message.part.updated","properties":{"part":{"type":"text","time":{"start":1769437404381},"text":"Thank you! You chose \"Red\" as your favorite color. If you have a specific task, question,","sessionID":"ses_4055037e7ffe7iiIdzWGnWwd0H","id":"prt_bfab01cdd001Y6UnA89vh364ZV","messageID":"msg_bfab0156d001NyMxaTnj6Cml0c"},"delta":","}},{"type":"custom.emit_events.finished","properties":[]},{"type":"message.part.updated","properties":{"part":{"type":"text","time":{"start":1769437404381},"text":"Thank you! You chose \"Red\" as your favorite color. If you have a specific task, question, or want to see this preference used in an example","sessionID":"ses_4055037e7ffe7iiIdzWGnWwd0H","id":"prt_bfab01cdd001Y6UnA89vh364ZV","messageID":"msg_bfab0156d001NyMxaTnj6Cml0c"},"delta":" example"}},{"type":"custom.emit_events.finished","properties":[]},{"type":"message.part.updated","properties":{"part":{"type":"text","time":{"start":1769437404381},"text":"Thank you! You chose \"Red\" as your favorite color. If you have a specific task, question, or want to see this preference used in an example or feature, please let me know how you'd like","sessionID":"ses_4055037e7ffe7iiIdzWGnWwd0H","id":"prt_bfab01cdd001Y6UnA89vh364ZV","messageID":"msg_bfab0156d001NyMxaTnj6Cml0c"},"delta":" like"}},{"type":"custom.emit_events.finished","properties":[]},{"type":"message.part.updated","properties":{"part":{"type":"text","time":{"start":1769437404381},"text":"Thank you! You chose \"Red\" as your favorite color. If you have a specific task, question, or want to see this preference used in an example or feature, please let me know how you'd like to proceed","sessionID":"ses_4055037e7ffe7iiIdzWGnWwd0H","id":"prt_bfab01cdd001Y6UnA89vh364ZV","messageID":"msg_bfab0156d001NyMxaTnj6Cml0c"},"delta":" proceed"}},{"type":"custom.emit_events.finished","properties":[]},{"type":"message.part.updated","properties":{"part":{"type":"text","time":{"start":1769437404381},"text":"Thank you! You chose \"Red\" as your favorite color. If you have a specific task, question, or want to see this preference used in an example or feature, please let me know how you'd like to proceed!","sessionID":"ses_4055037e7ffe7iiIdzWGnWwd0H","id":"prt_bfab01cdd001Y6UnA89vh364ZV","messageID":"msg_bfab0156d001NyMxaTnj6Cml0c"},"delta":"!"}},{"type":"custom.emit_events.finished","properties":[]},{"type":"message.part.updated","properties":{"part":{"type":"text","time":{"start":1769437405694,"end":1769437405694},"text":"Thank you! You chose \"Red\" as your favorite color. If you have a specific task, question, or want to see this preference used in an example or feature, please let me know how you'd like to proceed!","sessionID":"ses_4055037e7ffe7iiIdzWGnWwd0H","id":"prt_bfab01cdd001Y6UnA89vh364ZV","messageID":"msg_bfab0156d001NyMxaTnj6Cml0c"}}},{"type":"message.part.updated","properties":{"part":{"type":"step-finish","tokens":{"input":874,"output":47,"reasoning":0,"cache":{"read":12032,"write":0}},"cost":0,"reason":"stop","snapshot":"617ad65111b912d010a8ea89957a9c5986bc54f0","sessionID":"ses_4055037e7ffe7iiIdzWGnWwd0H","id":"prt_bfab021ff001EzP7TpA5rv7aTl","messageID":"msg_bfab0156d001NyMxaTnj6Cml0c"}}},{"type":"message.updated","properties":{"info":{"time":{"created":1769437402477},"modelID":"gpt-4.1","providerID":"github-copilot","sessionID":"ses_4055037e7ffe7iiIdzWGnWwd0H","tokens":{"input":874,"output":47,"reasoning":0,"cache":{"read":12032,"write":0}},"mode":"build","id":"msg_bfab0156d001NyMxaTnj6Cml0c","role":"assistant","cost":0,"path":{"root":"/home/francis/Projects/_nvim/opencode.nvim","cwd":"/home/francis/Projects/_nvim/opencode.nvim"},"parentID":"msg_bfaafef41001700DjtCwvhFxse","finish":"stop","agent":"build"}}},{"type":"message.updated","properties":{"info":{"time":{"created":1769437402477,"completed":1769437405729},"modelID":"gpt-4.1","providerID":"github-copilot","sessionID":"ses_4055037e7ffe7iiIdzWGnWwd0H","tokens":{"input":874,"output":47,"reasoning":0,"cache":{"read":12032,"write":0}},"mode":"build","id":"msg_bfab0156d001NyMxaTnj6Cml0c","role":"assistant","cost":0,"path":{"root":"/home/francis/Projects/_nvim/opencode.nvim","cwd":"/home/francis/Projects/_nvim/opencode.nvim"},"parentID":"msg_bfaafef41001700DjtCwvhFxse","finish":"stop","agent":"build"}}},{"type":"custom.emit_events.finished","properties":[]},{"type":"message.updated","properties":{"info":{"model":{"providerID":"github-copilot","modelID":"gpt-4.1"},"time":{"created":1769437392705},"role":"user","summary":{"title":"Question with Other choice","diffs":[]},"sessionID":"ses_4055037e7ffe7iiIdzWGnWwd0H","id":"msg_bfaafef41001700DjtCwvhFxse","agent":"build"}}},{"type":"session.idle","properties":{"sessionID":"ses_4055037e7ffe7iiIdzWGnWwd0H"}},{"type":"session.updated","properties":{"info":{"projectID":"29d43526f88157cd4edd071b899dd01f240b771b","time":{"created":1769437382680,"updated":1769437405755},"title":"New session - 2026-01-26T14:23:02.680Z","version":"1.1.31","directory":"/home/francis/Projects/_nvim/opencode.nvim","slug":"sunny-planet","id":"ses_4055037e7ffe7iiIdzWGnWwd0H","summary":{"deletions":0,"additions":0,"files":0}}}},{"type":"custom.emit_events.finished","properties":[]},{"type":"session.updated","properties":{"info":{"projectID":"29d43526f88157cd4edd071b899dd01f240b771b","time":{"created":1769437382680,"updated":1769437405755},"title":"Question with other choice for renderer_spec.lua","version":"1.1.31","directory":"/home/francis/Projects/_nvim/opencode.nvim","slug":"sunny-planet","id":"ses_4055037e7ffe7iiIdzWGnWwd0H","summary":{"deletions":0,"additions":0,"files":0}}}},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]}] diff --git a/tests/data/question-ask.expected.json b/tests/data/question-ask.expected.json new file mode 100644 index 00000000..ee078621 --- /dev/null +++ b/tests/data/question-ask.expected.json @@ -0,0 +1 @@ +{"timestamp":1769437004,"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: ``","",""],"extmarks":[[1,1,0,{"right_gravity":true,"virt_text_hide":false,"virt_text_repeat_linebreak":false,"priority":10,"ns_id":3,"virt_text_win_col":-3,"virt_text":[["▌󰭻 ","OpencodeMessageRoleUser"],[" "],["USER","OpencodeMessageRoleUser"],["","OpencodeHint"],[" (2026-01-26 14:06:19)","OpencodeHint"],[" [msg_bfaa078a2001RUYhlKZhSlyWeF]","OpencodeHint"]],"virt_text_pos":"win_col"}],[2,2,0,{"right_gravity":true,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"priority":4096,"ns_id":3,"virt_text_win_col":-3,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_pos":"win_col"}],[3,3,0,{"right_gravity":true,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"priority":4096,"ns_id":3,"virt_text_win_col":-3,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_pos":"win_col"}],[4,4,0,{"right_gravity":true,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"priority":4096,"ns_id":3,"virt_text_win_col":-3,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_pos":"win_col"}],[5,5,0,{"right_gravity":true,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"priority":4096,"ns_id":3,"virt_text_win_col":-3,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_pos":"win_col"}],[6,6,0,{"right_gravity":true,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"priority":4096,"ns_id":3,"virt_text_win_col":-3,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_pos":"win_col"}],[7,8,0,{"right_gravity":true,"virt_text_hide":false,"virt_text_repeat_linebreak":false,"priority":10,"ns_id":3,"virt_text_win_col":-3,"virt_text":[[" ","OpencodeMessageRoleAssistant"],[" "],["BUILD","OpencodeMessageRoleAssistant"],[" gpt-4.1","OpencodeHint"],[" (2026-01-26 14:06:19)","OpencodeHint"],[" [msg_bfaa078e7001qc33BqBlqzHv8X]","OpencodeHint"]],"virt_text_pos":"win_col"}],[8,13,0,{"right_gravity":true,"virt_text_hide":false,"virt_text_repeat_linebreak":false,"priority":10,"ns_id":3,"virt_text_win_col":-3,"virt_text":[[" ","OpencodeMessageRoleSystem"],[" "],["SYSTEM","OpencodeMessageRoleSystem"],["","OpencodeHint"],["","OpencodeHint"],[" [question-display-message]","OpencodeHint"]],"virt_text_pos":"win_col"}],[9,15,0,{"ns_id":3,"priority":4096,"right_gravity":true,"line_hl_group":"OpencodeQuestionTitle"}],[10,15,0,{"right_gravity":true,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"priority":4096,"ns_id":3,"virt_text_win_col":-2,"virt_text":[["▌","OpencodeQuestionBorder"]],"virt_text_pos":"win_col"}],[11,16,0,{"right_gravity":true,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"priority":4096,"ns_id":3,"virt_text_win_col":-2,"virt_text":[["▌","OpencodeQuestionBorder"]],"virt_text_pos":"win_col"}],[12,17,0,{"right_gravity":true,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"priority":4096,"ns_id":3,"virt_text_win_col":-2,"virt_text":[["▌","OpencodeQuestionBorder"]],"virt_text_pos":"win_col"}],[13,18,0,{"right_gravity":true,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"priority":4096,"ns_id":3,"virt_text_win_col":-2,"virt_text":[["▌","OpencodeQuestionBorder"]],"virt_text_pos":"win_col"}],[14,19,0,{"ns_id":3,"priority":4096,"right_gravity":true,"line_hl_group":"OpencodeQuestionOptionHover"}],[15,19,0,{"right_gravity":true,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"priority":4096,"ns_id":3,"virt_text_win_col":-2,"virt_text":[["▌","OpencodeQuestionBorder"]],"virt_text_pos":"win_col"}],[16,19,2,{"virt_text_hide":false,"virt_text_repeat_linebreak":false,"right_gravity":true,"ns_id":3,"priority":4096,"virt_text":[["› ","OpencodeQuestionOptionHover"]],"virt_text_pos":"overlay"}],[17,20,0,{"right_gravity":true,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"priority":4096,"ns_id":3,"virt_text_win_col":-2,"virt_text":[["▌","OpencodeQuestionBorder"]],"virt_text_pos":"win_col"}],[18,21,0,{"right_gravity":true,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"priority":4096,"ns_id":3,"virt_text_win_col":-2,"virt_text":[["▌","OpencodeQuestionBorder"]],"virt_text_pos":"win_col"}],[19,22,0,{"right_gravity":true,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"priority":4096,"ns_id":3,"virt_text_win_col":-2,"virt_text":[["▌","OpencodeQuestionBorder"]],"virt_text_pos":"win_col"}],[20,23,0,{"right_gravity":true,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"priority":4096,"ns_id":3,"virt_text_win_col":-2,"virt_text":[["▌","OpencodeQuestionBorder"]],"virt_text_pos":"win_col"}]],"actions":[]} \ No newline at end of file diff --git a/tests/data/question-ask.json b/tests/data/question-ask.json new file mode 100644 index 00000000..f3249bba --- /dev/null +++ b/tests/data/question-ask.json @@ -0,0 +1 @@ +[{"type":"session.updated","properties":{"info":{"directory":"/home/francis/Projects/_nvim/opencode.nvim","version":"1.1.31","slug":"calm-lagoon","id":"ses_4055f967bffeIHf3DnP0xntmxD","title":"New session - 2026-01-26T14:06:15.428Z","projectID":"29d43526f88157cd4edd071b899dd01f240b771b","time":{"created":1769436375428,"updated":1769436375428}}}},{"type":"custom.emit_events.finished","properties":[]},{"type":"message.updated","properties":{"info":{"time":{"created":1769436379298},"id":"msg_bfaa078a2001RUYhlKZhSlyWeF","sessionID":"ses_4055f967bffeIHf3DnP0xntmxD","role":"user","model":{"modelID":"gpt-4.1","providerID":"github-copilot"},"agent":"build"}}},{"type":"message.part.updated","properties":{"part":{"synthetic":true,"messageID":"msg_bfaa078a2001RUYhlKZhSlyWeF","sessionID":"ses_4055f967bffeIHf3DnP0xntmxD","text":"Called the Read tool with the following input: {\"filePath\":\"/home/francis/Projects/_nvim/opencode.nvim/lua/opencode/ui/renderer.lua\"}","type":"text","id":"prt_bfaa078a5001Pni4eaodfXL6o6"}}},{"type":"message.part.updated","properties":{"part":{"synthetic":true,"messageID":"msg_bfaa078a2001RUYhlKZhSlyWeF","sessionID":"ses_4055f967bffeIHf3DnP0xntmxD","text":"\n00001| local state = require('opencode.state')\n00002| local config = require('opencode.config')\n00003| local formatter = require('opencode.ui.formatter')\n00004| local output_window = require('opencode.ui.output_window')\n00005| local permission_window = require('opencode.ui.permission_window')\n00006| local Promise = require('opencode.promise')\n00007| local RenderState = require('opencode.ui.render_state')\n00008| \n00009| local M = {\n00010| _prev_line_count = 0,\n00011| _render_state = RenderState.new(),\n00012| _last_part_formatted = {\n00013| part_id = nil,\n00014| formatted_data = nil --[[@as Output|nil]],\n00015| },\n00016| }\n00017| \n00018| local trigger_on_data_rendered = require('opencode.util').debounce(function()\n00019| local cb_type = type(config.ui.output.rendering.on_data_rendered)\n00020| \n00021| if cb_type == 'boolean' then\n00022| return\n00023| end\n00024| \n00025| if not state.windows or not state.windows.output_buf or not state.windows.output_win then\n00026| return\n00027| end\n00028| \n00029| if cb_type == 'function' then\n00030| pcall(config.ui.output.rendering.on_data_rendered, state.windows.output_buf, state.windows.output_win)\n00031| elseif vim.fn.exists(':RenderMarkdown') > 0 then\n00032| vim.cmd(':RenderMarkdown')\n00033| elseif vim.fn.exists(':Markview') > 0 then\n00034| vim.cmd(':Markview render ' .. state.windows.output_buf)\n00035| end\n00036| end, config.ui.output.rendering.markdown_debounce_ms or 250)\n00037| \n00038| ---Reset renderer state\n00039| function M.reset()\n00040| M._prev_line_count = 0\n00041| M._render_state:reset()\n00042| M._last_part_formatted = { part_id = nil, formatted_data = nil }\n00043| \n00044| output_window.clear()\n00045| \n00046| state.messages = {}\n00047| state.last_user_message = nil\n00048| state.tokens_count = 0\n00049| \n00050| local permissions = state.pending_permissions or {}\n00051| if #permissions and state.api_client then\n00052| for _, permission in ipairs(permissions) do\n00053| require('opencode.api').permission_deny(permission)\n00054| end\n00055| end\n00056| permission_window.clear_all()\n00057| state.pending_permissions = {}\n00058| \n00059| trigger_on_data_rendered()\n00060| end\n00061| \n00062| ---Set up event subscriptions\n00063| ---@param subscribe? boolean false to unsubscribe\n00064| function M.setup_subscriptions(subscribe)\n00065| subscribe = subscribe == nil and true or subscribe\n00066| \n00067| if subscribe then\n00068| state.subscribe('is_opencode_focused', M.on_focus_changed)\n00069| state.subscribe('active_session', M.on_session_changed)\n00070| else\n00071| state.unsubscribe('is_opencode_focused', M.on_focus_changed)\n00072| state.unsubscribe('active_session', M.on_session_changed)\n00073| end\n00074| \n00075| if not state.event_manager then\n00076| return\n00077| end\n00078| \n00079| local event_subscriptions = {\n00080| { 'session.updated', M.on_session_updated },\n00081| { 'session.compacted', M.on_session_compacted },\n00082| { 'session.error', M.on_session_error },\n00083| { 'message.updated', M.on_message_updated },\n00084| { 'message.removed', M.on_message_removed },\n00085| { 'message.part.updated', M.on_part_updated },\n00086| { 'message.part.removed', M.on_part_removed },\n00087| { 'permission.updated', M.on_permission_updated },\n00088| { 'permission.asked', M.on_permission_updated },\n00089| { 'permission.replied', M.on_permission_replied },\n00090| { 'question.asked', M.on_question_asked },\n00091| { 'question.replied', M.clear_question_display },\n00092| { 'question.rejected', M.clear_question_display },\n00093| { 'file.edited', M.on_file_edited },\n00094| { 'custom.restore_point.created', M.on_restore_points },\n00095| { 'custom.emit_events.finished', M.on_emit_events_finished },\n00096| }\n00097| \n00098| for _, sub in ipairs(event_subscriptions) do\n00099| if subscribe then\n00100| state.event_manager:subscribe(sub[1], sub[2])\n00101| else\n00102| state.event_manager:unsubscribe(sub[1], sub[2])\n00103| end\n00104| end\n00105| end\n00106| \n00107| ---Clean up and teardown renderer. Unsubscribes from all events\n00108| function M.teardown()\n00109| M.setup_subscriptions(false)\n00110| M.reset()\n00111| end\n00112| \n00113| ---Fetch full session messages from server\n00114| ---@return Promise Promise resolving to list of OpencodeMessage\n00115| local function fetch_session()\n00116| local session = state.active_session\n00117| if not session or not session or session == '' then\n00118| return Promise.new():resolve(nil)\n00119| end\n00120| \n00121| state.last_user_message = nil\n00122| return require('opencode.session').get_messages(session)\n00123| end\n00124| \n00125| ---Request all of the session data from the opencode server and render it\n00126| ---@return Promise\n00127| function M.render_full_session()\n00128| if not output_window.mounted() or not state.api_client then\n00129| return Promise.new():resolve(nil)\n00130| end\n00131| \n00132| return fetch_session():and_then(M._render_full_session_data)\n00133| end\n00134| \n00135| function M._render_full_session_data(session_data)\n00136| M.reset()\n00137| \n00138| if not state.active_session or not state.messages then\n00139| return\n00140| end\n00141| \n00142| local revert_index = nil\n00143| \n00144| -- if we're loading a session and there's no currently selected model, set it\n00145| -- from the messages\n00146| local set_mode_from_messages = not state.current_model\n00147| \n00148| for i, msg in ipairs(session_data) do\n00149| if state.active_session.revert and state.active_session.revert.messageID == msg.info.id then\n00150| revert_index = i\n00151| end\n00152| \n00153| M.on_message_updated({ info = msg.info }, revert_index)\n00154| \n00155| for _, part in ipairs(msg.parts or {}) do\n00156| M.on_part_updated({ part = part }, revert_index)\n00157| end\n00158| end\n00159| \n00160| if revert_index then\n00161| M._write_formatted_data(formatter._format_revert_message(state.messages, revert_index))\n00162| end\n00163| \n00164| if set_mode_from_messages then\n00165| M._set_model_and_mode_from_messages()\n00166| end\n00167| M.scroll_to_bottom()\n00168| \n00169| if config.hooks and config.hooks.on_session_loaded then\n00170| pcall(config.hooks.on_session_loaded, state.active_session)\n00171| end\n00172| end\n00173| \n00174| ---Append permissions display as a fake part at the end\n00175| function M.render_permissions_display()\n00176| local fake_message = {\n00177| info = {\n00178| id = 'permission-display-message',\n00179| sessionID = state.active_session and state.active_session.id or '',\n00180| role = 'system',\n00181| },\n00182| parts = {},\n00183| }\n00184| M.on_message_updated(fake_message)\n00185| \n00186| local fake_part = {\n00187| id = 'permission-display-part',\n00188| messageID = 'permission-display-message',\n00189| sessionID = state.active_session and state.active_session.id or '',\n00190| type = 'permissions-display',\n00191| }\n00192| \n00193| local permissions = permission_window.get_all_permissions()\n00194| if #permissions > 0 then\n00195| M.on_part_updated({ part = fake_part })\n00196| end\n00197| end\n00198| \n00199| function M.clear_question_display()\n00200| local question_window = require('opencode.ui.question_window')\n00201| question_window.clear_question()\n00202| M._remove_part_from_buffer('question-display-part')\n00203| M._remove_message_from_buffer('question-display-message')\n00204| end\n00205| \n00206| ---Render question display as a fake part\n00207| function M.render_question_display()\n00208| local question_window = require('opencode.ui.question_window')\n00209| \n00210| local current_question = question_window._current_question\n00211| \n00212| if not question_window.has_question() or not current_question or not current_question.id then\n00213| M._remove_part_from_buffer('question-display-part')\n00214| M._remove_message_from_buffer('question-display-message')\n00215| return\n00216| end\n00217| \n00218| local message_id = 'question-display-message'\n00219| local part_id = 'question-display-part'\n00220| \n00221| local fake_message = {\n00222| info = {\n00223| id = message_id,\n00224| sessionID = state.active_session and state.active_session.id or '',\n00225| role = 'system',\n00226| },\n00227| parts = {},\n00228| }\n00229| M.on_message_updated(fake_message)\n00230| \n00231| local fake_part = {\n00232| id = part_id,\n00233| messageID = message_id,\n00234| sessionID = state.active_session and state.active_session.id or '',\n00235| type = 'questions-display',\n00236| }\n00237| \n00238| M.on_part_updated({ part = fake_part })\n00239| M._redraw()\n00240| M.scroll_to_bottom(true)\n00241| end\n00242| \n00243| function M._redraw()\n00244| if state.windows and state.windows.output_win and vim.api.nvim_win_is_valid(state.windows.output_win) then\n00245| vim.api.nvim_win_call(state.windows.output_win, function()\n00246| vim.cmd('redraw')\n00247| end)\n00248| end\n00249| end\n00250| \n00251| ---Render lines as the entire output buffer\n00252| ---@param lines any\n00253| function M.render_lines(lines)\n00254| local output = require('opencode.ui.output'):new()\n00255| output.lines = lines\n00256| M.render_output(output)\n00257| end\n00258| \n00259| ---Sets the entire output buffer based on output_data\n00260| ---@param output_data Output Output object from formatter\n00261| function M.render_output(output_data)\n00262| if not output_window.mounted() then\n00263| return\n00264| end\n00265| \n00266| local lines = output_data.lines or {}\n00267| \n00268| output_window.set_lines(lines)\n00269| output_window.clear_extmarks()\n00270| output_window.set_extmarks(output_data.extmarks)\n00271| M.scroll_to_bottom()\n00272| end\n00273| \n00274| ---Called when EventManager has finished emitting a batch of events\n00275| function M.on_emit_events_finished()\n00276| M.scroll_to_bottom()\n00277| end\n00278| \n00279| ---Find the most recently used model from the messages\n00280| function M._set_model_and_mode_from_messages()\n00281| if not state.messages then\n00282| return\n00283| end\n00284| \n00285| for i = #state.messages, 1, -1 do\n00286| local message = state.messages[i]\n00287| \n00288| if message and message.info then\n00289| if message.info.modelID and message.info.providerID then\n00290| state.current_model = message.info.providerID .. '/' .. message.info.modelID\n00291| if message.info.mode then\n00292| state.current_mode = message.info.mode\n00293| end\n00294| return\n00295| end\n00296| end\n00297| end\n00298| \n00299| -- we didn't find a model from any messages, set it to the default\n00300| require('opencode.core').initialize_current_model()\n00301| end\n00302| \n00303| ---Auto-scroll to bottom if user was already at bottom\n00304| ---Respects cursor position if user has scrolled up\n00305| ---@param force? boolean If true, scroll regardless of current position\n00306| function M.scroll_to_bottom(force)\n00307| if not state.windows or not state.windows.output_buf or not state.windows.output_win then\n00308| return\n00309| end\n00310| \n00311| if not vim.api.nvim_win_is_valid(state.windows.output_win) then\n00312| return\n00313| end\n00314| \n00315| local ok, line_count = pcall(vim.api.nvim_buf_line_count, state.windows.output_buf)\n00316| if not ok or line_count == 0 then\n00317| return\n00318| end\n00319| \n00320| local prev_line_count = M._prev_line_count or 0\n00321| \n00322| ---@cast line_count integer\n00323| M._prev_line_count = line_count\n00324| \n00325| trigger_on_data_rendered()\n00326| \n00327| -- Determine if we should scroll to bottom\n00328| local should_scroll = force == true\n00329| \n00330| if not should_scroll then\n00331| -- Always scroll on initial render\n00332| if prev_line_count == 0 then\n00333| should_scroll = true\n00334| -- Scroll if user is at bottom (respects manual scroll position)\n00335| elseif output_window.viewport_at_bottom then\n00336| should_scroll = true\n00337| end\n00338| end\n00339| \n00340| if should_scroll then\n00341| vim.api.nvim_win_set_cursor(state.windows.output_win, { line_count, 0 })\n00342| -- Use zb to position the cursor line at the bottom of the visible window\n00343| vim.api.nvim_win_call(state.windows.output_win, function()\n00344| vim.cmd('normal! zb')\n00345| end)\n00346| output_window.viewport_at_bottom = true\n00347| else\n00348| -- User has scrolled up, don't scroll\n00349| output_window.viewport_at_bottom = false\n00350| end\n00351| end\n00352| \n00353| ---Write data to output_buf, including normal text and extmarks\n00354| ---@param formatted_data Output Formatted data as Output object\n00355| ---@param part_id? string Optional part ID to store actions\n00356| ---@param start_line? integer Optional line to insert at (shifts content down). If nil, appends to end of buffer.\n00357| ---@return {line_start: integer, line_end: integer}? Range where data was written\n00358| function M._write_formatted_data(formatted_data, part_id, start_line)\n00359| if not state.windows or not state.windows.output_buf then\n00360| return\n00361| end\n00362| \n00363| local buf = state.windows.output_buf\n00364| local is_insertion = start_line ~= nil\n00365| local target_line = start_line or output_window.get_buf_line_count()\n00366| local new_lines = formatted_data.lines\n00367| local extmarks = formatted_data.extmarks\n00368| \n00369| if #new_lines == 0 or not buf then\n00370| return nil\n00371| end\n00372| \n00373| if is_insertion then\n00374| output_window.set_lines(new_lines, target_line, target_line)\n00375| else\n00376| local extra_newline = vim.tbl_extend('keep', {}, new_lines)\n00377| table.insert(extra_newline, '')\n00378| target_line = target_line - 1\n00379| output_window.set_lines(extra_newline, target_line)\n00380| end\n00381| \n00382| -- update actions and extmarks after the insertion because that may\n00383| -- adjust target_line (e.g. when we we're replacing the double newline at\n00384| -- the end)\n00385| \n00386| if part_id and formatted_data.actions then\n00387| M._render_state:add_actions(part_id, formatted_data.actions, target_line)\n00388| end\n00389| \n00390| output_window.set_extmarks(extmarks, target_line)\n00391| \n00392| return {\n00393| line_start = target_line,\n00394| line_end = target_line + #new_lines - 1,\n00395| }\n00396| end\n00397| \n00398| ---Insert new part, either at end of buffer or in the middle for out-of-order parts\n00399| ---@param part_id string Part ID\n00400| ---@param formatted_data Output Formatted data as Output object\n00401| ---@return boolean Success status\n00402| function M._insert_part_to_buffer(part_id, formatted_data)\n00403| local cached = M._render_state:get_part(part_id)\n00404| if not cached then\n00405| return false\n00406| end\n00407| \n00408| if #formatted_data.lines == 0 then\n00409| return true\n00410| end\n00411| \n00412| local is_current_message = state.current_message\n00413| and state.current_message.info\n00414| and state.current_message.info.id == cached.message_id\n00415| \n00416| if is_current_message then\n00417| -- NOTE: we're inserting a part for the current message, just add it to the end\n00418| \n00419| local range = M._write_formatted_data(formatted_data, part_id)\n00420| if not range then\n00421| return false\n00422| end\n00423| \n00424| M._render_state:set_part(cached.part, range.line_start, range.line_end)\n00425| \n00426| M._last_part_formatted = { part_id = part_id, formatted_data = formatted_data }\n00427| \n00428| return true\n00429| end\n00430| \n00431| -- NOTE: We're inserting a part for the first time for a previous message. We need to find\n00432| -- the insertion line (after the last part of this message or after the message header if\n00433| -- no parts).\n00434| local insertion_line = M._get_insertion_point_for_part(part_id, cached.message_id)\n00435| if not insertion_line then\n00436| return false\n00437| end\n00438| \n00439| local range = M._write_formatted_data(formatted_data, part_id, insertion_line)\n00440| if not range then\n00441| return false\n00442| end\n00443| \n00444| local line_count = #formatted_data.lines\n00445| M._render_state:shift_all(insertion_line, line_count)\n00446| \n00447| M._render_state:set_part(cached.part, range.line_start, range.line_end)\n00448| \n00449| return true\n00450| end\n00451| \n00452| ---Replace existing part in buffer\n00453| ---Adjusts line positions of subsequent parts if line count changes\n00454| ---@param part_id string Part ID\n00455| ---@param formatted_data Output Formatted data as Output object\n00456| ---@return boolean Success status\n00457| function M._replace_part_in_buffer(part_id, formatted_data)\n00458| local cached = M._render_state:get_part(part_id)\n00459| if not cached or not cached.line_start or not cached.line_end then\n00460| return false\n00461| end\n00462| \n00463| local new_lines = formatted_data.lines\n00464| local new_line_count = #new_lines\n00465| \n00466| local old_formatted = M._last_part_formatted\n00467| local can_optimize = old_formatted\n00468| and old_formatted.part_id == part_id\n00469| and old_formatted.formatted_data\n00470| and old_formatted.formatted_data.lines\n00471| \n00472| local lines_to_write = new_lines\n00473| local write_start_line = cached.line_start\n00474| \n00475| if can_optimize then\n00476| -- NOTE: This is an optimization to only replace the lines that are different\n00477| -- if we're replacing the most recently formatted part.\n00478| \n00479| ---@cast old_formatted { formatted_data: { lines: string[] } }\n00480| local old_lines = old_formatted.formatted_data.lines\n00481| local first_diff_line = nil\n00482| \n00483| -- Find the first line that's different\n00484| for i = 1, math.min(#old_lines, new_line_count) do\n00485| if old_lines[i] ~= new_lines[i] then\n00486| first_diff_line = i\n00487| break\n00488| end\n00489| end\n00490| \n00491| if not first_diff_line and new_line_count > #old_lines then\n00492| -- The old lines all matched but maybe there are more new lines\n00493| first_diff_line = #old_lines + 1\n00494| end\n00495| \n00496| if first_diff_line then\n00497| lines_to_write = vim.list_slice(new_lines, first_diff_line, new_line_count)\n00498| write_start_line = cached.line_start + first_diff_line - 1\n00499| elseif new_line_count == #old_lines then\n00500| -- Nothing was different, so we're done\n00501| M._last_part_formatted = { part_id = part_id, formatted_data = formatted_data }\n00502| return true\n00503| end\n00504| end\n00505| \n00506| M._render_state:clear_actions(part_id)\n00507| \n00508| output_window.clear_extmarks(cached.line_start - 1, cached.line_end + 1)\n00509| output_window.set_lines(lines_to_write, write_start_line, cached.line_end + 1)\n00510| \n00511| local new_line_end = cached.line_start + new_line_count - 1\n00512| \n00513| output_window.set_extmarks(formatted_data.extmarks, cached.line_start)\n00514| \n00515| if formatted_data.actions then\n00516| M._render_state:add_actions(part_id, formatted_data.actions, cached.line_start + 1)\n00517| end\n00518| \n00519| M._render_state:update_part_lines(part_id, cached.line_start, new_line_end)\n00520| \n00521| M._last_part_formatted = { part_id = part_id, formatted_data = formatted_data }\n00522| \n00523| return true\n00524| end\n00525| \n00526| ---Remove part from buffer and adjust subsequent line positions\n00527| ---@param part_id string Part ID\n00528| function M._remove_part_from_buffer(part_id)\n00529| local cached = M._render_state:get_part(part_id)\n00530| if not cached or not cached.line_start or not cached.line_end then\n00531| return\n00532| end\n00533| \n00534| if not state.windows or not state.windows.output_buf then\n00535| return\n00536| end\n00537| \n00538| output_window.clear_extmarks(cached.line_start - 1, cached.line_end)\n00539| output_window.set_lines({}, cached.line_start - 1, cached.line_end)\n00540| \n00541| M._render_state:remove_part(part_id)\n00542| end\n00543| \n00544| ---Remove message header from buffer and adjust subsequent line positions\n00545| ---@param message_id string Message ID\n00546| function M._remove_message_from_buffer(message_id)\n00547| local cached = M._render_state:get_message(message_id)\n00548| if not cached or not cached.line_start or not cached.line_end then\n00549| return\n00550| end\n00551| \n00552| if not state.windows or not state.windows.output_buf then\n00553| return\n00554| end\n00555| \n00556| output_window.clear_extmarks(cached.line_start - 1, cached.line_end)\n00557| output_window.set_lines({}, cached.line_start - 1, cached.line_end)\n00558| \n00559| M._render_state:remove_message(message_id)\n00560| end\n00561| \n00562| ---Adds a message (most likely just a header) to the buffer\n00563| ---@param message OpencodeMessage Message to add\n00564| function M._add_message_to_buffer(message)\n00565| local header_data = formatter.format_message_header(message)\n00566| local range = M._write_formatted_data(header_data)\n00567| \n00568| if range then\n00569| M._render_state:set_message(message, range.line_start, range.line_end)\n00570| end\n00571| end\n00572| \n00573| ---Replace existing message header in buffer\n00574| ---@param message_id string Message ID\n00575| ---@param formatted_data Output Formatted header as Output object\n00576| ---@return boolean Success status\n00577| function M._replace_message_in_buffer(message_id, formatted_data)\n00578| local cached = M._render_state:get_message(message_id)\n00579| if not cached or not cached.line_start or not cached.line_end then\n00580| return false\n00581| end\n00582| \n00583| local new_lines = formatted_data.lines\n00584| local new_line_count = #new_lines\n00585| \n00586| output_window.clear_extmarks(cached.line_start, cached.line_end + 1)\n00587| output_window.set_lines(new_lines, cached.line_start, cached.line_end + 1)\n00588| output_window.set_extmarks(formatted_data.extmarks, cached.line_start)\n00589| \n00590| local old_line_end = cached.line_end\n00591| local new_line_end = cached.line_start + new_line_count - 1\n00592| \n00593| M._render_state:set_message(cached.message, cached.line_start, new_line_end)\n00594| \n00595| local delta = new_line_end - old_line_end\n00596| if delta ~= 0 then\n00597| M._render_state:shift_all(old_line_end + 1, delta)\n00598| end\n00599| \n00600| return true\n00601| end\n00602| \n00603| ---Event handler for message.updated events\n00604| ---Creates new message or updates existing message info\n00605| ---@param message {info: MessageInfo} Event properties\n00606| ---@param revert_index? integer Revert index in session, if applicable\n00607| function M.on_message_updated(message, revert_index)\n00608| if not state.active_session or not state.messages then\n00609| return\n00610| end\n00611| \n00612| local msg = message --[[@as OpencodeMessage]]\n00613| if not msg or not msg.info or not msg.info.id or not msg.info.sessionID then\n00614| return\n00615| end\n00616| \n00617| if state.active_session.id ~= msg.info.sessionID then\n00618| ---@TODO This is probably a child session message, handle differently?\n00619| -- vim.notify('Session id does not match, discarding message: ' .. vim.inspect(message), vim.log.levels.WARN)\n00620| return\n00621| end\n00622| \n00623| local rendered_message = M._render_state:get_message(msg.info.id)\n00624| local found_msg = rendered_message and rendered_message.message\n00625| \n00626| if revert_index then\n00627| if not found_msg then\n00628| table.insert(state.messages, msg)\n00629| end\n00630| M._render_state:set_message(msg, 0, 0)\n00631| return\n00632| end\n00633| \n00634| if found_msg then\n00635| local error_changed = not vim.deep_equal(found_msg.info.error, msg.info.error)\n00636| \n00637| found_msg.info = msg.info\n00638| \n00639| --- NOTE: error handling is a bit messy because errors come in on messages\n00640| --- but we want to display the error at the end. In this case, we an error\n00641| --- was added to this message. We find the last part and re-render it to\n00642| --- display the message. If there are no parts, we'll re-render the message\n00643| \n00644| if error_changed and not revert_index then\n00645| local last_part_id = M._get_last_part_for_message(found_msg)\n00646| if last_part_id then\n00647| M._rerender_part(last_part_id)\n00648| else\n00649| local header_data = formatter.format_message_header(found_msg)\n00650| M._replace_message_in_buffer(msg.info.id, header_data)\n00651| end\n00652| end\n00653| else\n00654| table.insert(state.messages, msg)\n00655| \n00656| M._add_message_to_buffer(msg)\n00657| \n00658| state.current_message = msg\n00659| if message.info.role == 'user' then\n00660| state.last_user_message = msg\n00661| end\n00662| end\n00663| \n00664| M._update_stats_from_message(msg)\n00665| end\n00666| \n00667| ---Event handler for message.part.updated events\n00668| ---Inserts new parts or replaces existing parts in buffer\n00669| ---@param properties {part: OpencodeMessagePart} Event properties\n00670| ---@param revert_index? integer Revert index in session, if applicable\n00671| function M.on_part_updated(properties, revert_index)\n00672| if not properties or not properties.part or not state.active_session then\n00673| return\n00674| end\n00675| \n00676| local part = properties.part\n00677| if not part.id or not part.messageID or not part.sessionID then\n00678| return\n00679| end\n00680| \n00681| if state.active_session.id ~= part.sessionID then\n00682| ---@TODO This is probably a child session part, handle differently?\n00683| -- vim.notify('Session id does not match, discarding part: ' .. vim.inspect(part), vim.log.levels.WARN)\n00684| return\n00685| end\n00686| \n00687| local rendered_message = M._render_state:get_message(part.messageID)\n00688| if not rendered_message or not rendered_message.message then\n00689| vim.notify('Could not find message for part: ' .. vim.inspect(part), vim.log.levels.WARN)\n00690| return\n00691| end\n00692| \n00693| local message = rendered_message.message\n00694| \n00695| message.parts = message.parts or {}\n00696| \n00697| local part_data = M._render_state:get_part(part.id)\n00698| local is_new_part = not part_data\n00699| \n00700| local prev_last_part_id = M._get_last_part_for_message(message)\n00701| local is_last_part = is_new_part or (prev_last_part_id == part.id)\n00702| \n00703| if is_new_part then\n00704| table.insert(message.parts, part)\n00705| else\n00706| for i = #message.parts, 1, -1 do\n00707| if message.parts[i].id == part.id then\n00708| message.parts[i] = part\n00709| break\n00710| end\n00711| end\n00712| end\n00713| \n00714| if part.type == 'step-start' or part.type == 'step-finish' then\n00715| return\n00716| end\n00717| \n00718| if is_new_part then\n00719| M._render_state:set_part(part)\n00720| else\n00721| local rendered_part = M._render_state:update_part_data(part)\n00722| -- NOTE: This isn't the first time we've seen the part but we haven't rendered it previously\n00723| -- so try and render it this time by setting is_new_part = true (otherwise we'd call\n00724| -- _replace_message_in_buffer and it wouldn't do anything because the part hasn't been rendered)\n00725| if not rendered_part or (not rendered_part.line_start and not rendered_part.line_end) then\n00726| is_new_part = true\n00727| end\n00728| end\n00729| \n00730| local formatted = formatter.format_part(part, message, is_last_part)\n00731| \n00732| if revert_index and is_new_part then\n00733| return\n00734| end\n00735| \n00736| if is_new_part then\n00737| M._insert_part_to_buffer(part.id, formatted)\n00738| \n00739| if message.info.error then\n00740| --- NOTE: More error display code. As mentioned above, errors come in on messages\n00741| --- but we want to display them after parts so we tack the error onto the last\n00742| --- part. When a part is added and there's an error, we need to rerender\n00743| --- previous last part so it doesn't also display the message. If there was no previous\n00744| --- part, then we need to rerender the header so it doesn't display the error\n00745| \n00746| if not prev_last_part_id then\n00747| -- no previous part, we're the first part, re-render the message header\n00748| -- so it doesn't also display the error\n00749| local header_data = formatter.format_message_header(message)\n00750| M._replace_message_in_buffer(part.messageID, header_data)\n00751| elseif prev_last_part_id ~= part.id then\n00752| M._rerender_part(prev_last_part_id)\n00753| end\n00754| end\n00755| else\n00756| M._replace_part_in_buffer(part.id, formatted)\n00757| end\n00758| \n00759| if (part.type == 'file' or part.type == 'agent') and part.source then\n00760| -- we have a mention, we need to rerender the early part to highlight\n00761| -- the mention.\n00762| local text_part_id = M._find_text_part_for_message(message)\n00763| if text_part_id then\n00764| M._rerender_part(text_part_id)\n00765| end\n00766| end\n00767| end\n00768| \n00769| ---Event handler for message.part.removed events\n00770| ---@param properties {sessionID: string, messageID: string, partID: string} Event properties\n00771| function M.on_part_removed(properties)\n00772| if not properties then\n00773| return\n00774| end\n00775| \n00776| local part_id = properties.partID\n00777| if not part_id then\n00778| return\n00779| end\n00780| \n00781| local cached = M._render_state:get_part(part_id)\n00782| if cached and cached.message_id then\n00783| local rendered_message = M._render_state:get_message(cached.message_id)\n00784| if rendered_message and rendered_message.message then\n00785| local message = rendered_message.message\n00786| if message.parts then\n00787| for i, part in ipairs(message.parts) do\n00788| if part.id == part_id then\n00789| table.remove(message.parts, i)\n00790| break\n00791| end\n00792| end\n00793| end\n00794| end\n00795| end\n00796| \n00797| M._remove_part_from_buffer(part_id)\n00798| end\n00799| \n00800| ---Event handler for message.removed events\n00801| ---Removes message and all its parts from buffer\n00802| ---@param properties {sessionID: string, messageID: string} Event properties\n00803| function M.on_message_removed(properties)\n00804| if not properties or not state.messages then\n00805| return\n00806| end\n00807| \n00808| local message_id = properties.messageID\n00809| if not message_id then\n00810| return\n00811| end\n00812| \n00813| local rendered_message = M._render_state:get_message(message_id)\n00814| if not rendered_message or not rendered_message.message then\n00815| return\n00816| end\n00817| \n00818| local message = rendered_message.message\n00819| for _, part in ipairs(message.parts or {}) do\n00820| if part.id then\n00821| M._remove_part_from_buffer(part.id)\n00822| end\n00823| end\n00824| \n00825| M._remove_message_from_buffer(message_id)\n00826| \n00827| for i, msg in ipairs(state.messages or {}) do\n00828| if msg.info.id == message_id then\n00829| table.remove(state.messages, i)\n00830| break\n00831| end\n00832| end\n00833| end\n00834| \n00835| ---Event handler for session.compacted events\n00836| ---@param properties {sessionID: string} Event properties\n00837| function M.on_session_compacted(properties)\n00838| vim.notify('Session has been compacted')\n00839| end\n00840| \n00841| ---Event handler for session.updated events\n00842| ---@param properties {info: Session}\n00843| function M.on_session_updated(properties)\n00844| if not properties or not properties.info or not state.active_session then\n00845| return\n00846| end\n00847| if not vim.deep_equal(state.active_session.revert, properties.info.revert) then\n00848| state.active_session.revert = properties.info.revert\n00849| M._render_full_session_data(state.messages)\n00850| end\n00851| end\n00852| \n00853| ---Event handler for session.error events\n00854| ---@param properties {sessionID: string, error: table} Event properties\n00855| function M.on_session_error(properties)\n00856| if not properties or not properties.error then\n00857| return\n00858| end\n00859| \n00860| -- NOTE: we're handling message errors so session errors seem duplicative\n00861| if config.debug.enabled then\n00862| vim.notify('Session error: ' .. vim.inspect(properties.error))\n00863| end\n00864| end\n00865| \n00866| ---Event handler for permission.updated events\n00867| ---Re-renders part that requires permission and adds to permission window\n00868| ---@param permission OpencodePermission Event properties\n00869| function M.on_permission_updated(permission)\n00870| local tool = permission.tool\n00871| \n00872| ---@TODO this is for backward compatibility, remove later\n00873| local callID = tool and tool.callID or permission.callID\n00874| local messageID = tool and tool.messageID or permission.messageID\n00875| \n00876| if not permission or not messageID or not callID then\n00877| return\n00878| end\n00879| \n00880| -- Add permission to pending queue\n00881| if not state.pending_permissions then\n00882| state.pending_permissions = {}\n00883| end\n00884| \n00885| -- Check if permission already exists in queue\n00886| local existing_index = nil\n00887| for i, existing in ipairs(state.pending_permissions) do\n00888| if existing.id == permission.id then\n00889| existing_index = i\n00890| break\n00891| end\n00892| end\n00893| \n00894| local permissions = vim.deepcopy(state.pending_permissions)\n00895| if existing_index then\n00896| permissions[existing_index] = permission\n00897| else\n00898| table.insert(permissions, permission)\n00899| end\n00900| state.pending_permissions = permissions\n00901| \n00902| permission_window.add_permission(permission)\n00903| \n00904| M.render_permissions_display()\n00905| \n00906| M._rerender_part('permission-display-part')\n00907| M.scroll_to_bottom()\n00908| end\n00909| \n00910| ---Event handler for permission.replied events\n00911| ---Re-renders part after permission is resolved and removes from window\n00912| ---@param properties {sessionID: string, permissionID?: string,requestID?: string, response: string}|{} Event properties\n00913| function M.on_permission_replied(properties)\n00914| if not properties then\n00915| return\n00916| end\n00917| \n00918| local permission_id = properties.permissionID or properties.requestID\n00919| \n00920| if permission_id then\n00921| permission_window.remove_permission(permission_id)\n00922| state.pending_permissions = vim.deepcopy(permission_window.get_all_permissions())\n00923| if #state.pending_permissions == 0 then\n00924| M._remove_part_from_buffer('permission-display-part')\n00925| M._remove_message_from_buffer('permission-display-message')\n00926| end\n00927| M._rerender_part('permission-display-part')\n00928| end\n00929| end\n00930| \n00931| ---Event handler for question.asked events\n00932| ---Shows the question picker UI for the user to answer\n00933| ---@param properties OpencodeQuestionRequest Event properties\n00934| function M.on_question_asked(properties)\n00935| if not properties or not properties.id or not properties.questions then\n00936| return\n00937| end\n00938| \n00939| if not state.active_session or properties.sessionID ~= state.active_session.id then\n00940| return\n00941| end\n00942| \n00943| local question_window = require('opencode.ui.question_window')\n00944| question_window.show_question(properties)\n00945| end\n00946| \n00947| function M.on_file_edited(properties)\n00948| vim.cmd('checktime')\n00949| if config.hooks and config.hooks.on_file_edited then\n00950| pcall(config.hooks.on_file_edited, properties.file)\n00951| end\n00952| end\n00953| \n00954| ---@param properties RestorePointCreatedEvent\n00955| function M.on_restore_points(properties)\n00956| state.append('restore_points', properties.restore_point)\n00957| if not properties or not properties.restore_point or not properties.restore_point.from_snapshot_id then\n00958| return\n00959| end\n00960| local part = M._render_state:get_part_by_snapshot_id(properties.restore_point.from_snapshot_id)\n00961| if part then\n00962| M.on_part_updated({ part = part })\n00963| end\n00964| end\n00965| \n00966| ---Find part ID by call ID and message ID\n00967| ---Useful for finding a part for a permission\n00968| ---@param call_id string Call ID to search for\n00969| ---@param message_id string Message ID to check the parts of\n00970| ---@return string? part_id Part ID if found, nil otherwise\n00971| function M._find_part_by_call_id(call_id, message_id)\n00972| return M._render_state:get_part_by_call_id(call_id, message_id)\n00973| end\n00974| \n00975| ---Find the text part in a message\n00976| ---@param message OpencodeMessage The message containing the parts\n00977| ---@return string? text_part_id The ID of the text part\n00978| function M._find_text_part_for_message(message)\n00979| if not message or not message.parts then\n00980| return nil\n00981| end\n00982| \n00983| for _, part in ipairs(message.parts) do\n00984| if part.type == 'text' and not part.synthetic then\n00985| return part.id\n00986| end\n00987| end\n00988| \n00989| return nil\n00990| end\n00991| \n00992| ---Find the last part in a message\n00993| ---@param message OpencodeMessage The message containing the parts\n00994| ---@return string? last_part_id The ID of the last part\n00995| function M._get_last_part_for_message(message)\n00996| if not message or not message.parts or #message.parts == 0 then\n00997| return nil\n00998| end\n00999| \n01000| for i = #message.parts, 1, -1 do\n01001| local part = message.parts[i]\n01002| if part.type ~= 'step-start' and part.type ~= 'step-finish' and part.id then\n01003| return part.id\n01004| end\n01005| end\n01006| \n01007| return nil\n01008| end\n01009| \n01010| ---Get insertion point for an out-of-order part\n01011| ---@param part_id string The part ID to insert\n01012| ---@param message_id string The message ID the part belongs to\n01013| ---@return integer? insertion_line The line to insert at (1-indexed), or nil on error\n01014| function M._get_insertion_point_for_part(part_id, message_id)\n01015| local rendered_message = M._render_state:get_message(message_id)\n01016| if not rendered_message or not rendered_message.message then\n01017| return nil\n01018| end\n01019| \n01020| local message = rendered_message.message\n01021| \n01022| local insertion_line = rendered_message.line_end and (rendered_message.line_end + 1)\n01023| if not insertion_line then\n01024| return nil\n01025| end\n01026| \n01027| local current_part_index = nil\n01028| if message.parts then\n01029| for i, part in ipairs(message.parts) do\n01030| if part.id == part_id then\n01031| current_part_index = i\n01032| break\n01033| end\n01034| end\n01035| end\n01036| \n01037| if not current_part_index then\n01038| return insertion_line\n01039| end\n01040| \n01041| for i = current_part_index - 1, 1, -1 do\n01042| local prev_part = message.parts[i]\n01043| if prev_part and prev_part.id then\n01044| local prev_rendered = M._render_state:get_part(prev_part.id)\n01045| \n01046| if prev_rendered and prev_rendered.line_end then\n01047| return prev_rendered.line_end + 1\n01048| end\n01049| end\n01050| end\n01051| \n01052| return insertion_line\n01053| end\n01054| \n01055| ---Re-render existing part with current state\n01056| ---Used for permission updates and other dynamic changes\n01057| ---@param part_id string Part ID to re-render\n01058| function M._rerender_part(part_id)\n01059| local cached = M._render_state:get_part(part_id)\n01060| if not cached or not cached.part then\n01061| return\n01062| end\n01063| \n01064| local part = cached.part\n01065| local rendered_message = M._render_state:get_message(cached.message_id)\n01066| if not rendered_message or not rendered_message.message then\n01067| return\n01068| end\n01069| \n01070| local message = rendered_message.message\n01071| local last_part_id = M._get_last_part_for_message(message)\n01072| local is_last_part = (last_part_id == part_id)\n01073| local formatted = formatter.format_part(part, message, is_last_part)\n01074| \n01075| M._replace_part_in_buffer(part_id, formatted)\n01076| end\n01077| \n01078| ---Event handler for focus changes\n01079| ---Re-renders part associated with current permission for displaying global shortcuts or buffer-local ones\n01080| function M.on_focus_changed()\n01081| -- Check permission window first, fallback to state\n01082| local current_permission = permission_window.get_all_permissions()[1]\n01083| \n01084| if not current_permission then\n01085| return\n01086| end\n01087| \n01088| M._rerender_part('permission-display-part')\n01089| trigger_on_data_rendered()\n01090| end\n01091| \n01092| function M.on_session_changed(_, new, _)\n01093| M.reset()\n01094| if new then\n01095| M.render_full_session()\n01096| end\n01097| end\n01098| \n01099| ---Get all actions available at a specific line\n01100| ---@param line integer 0-indexed line number\n01101| ---@return table[] List of actions available at that line\n01102| function M.get_actions_for_line(line)\n01103| return M._render_state:get_actions_at_line(line)\n01104| end\n01105| \n01106| ---Update display stats from a single message\n01107| ---@param message OpencodeMessage\n01108| function M._update_stats_from_message(message)\n01109| if not state.current_model and message.info.providerID and message.info.providerID ~= '' then\n01110| state.current_model = message.info.providerID .. '/' .. message.info.modelID\n01111| end\n01112| \n01113| local tokens = message.info.tokens\n01114| if tokens and tokens.input > 0 then\n01115| state.tokens_count = tokens.input + tokens.output + tokens.cache.read + tokens.cache.write\n01116| end\n01117| \n01118| if message.info.cost and type(message.info.cost) == 'number' then\n01119| state.cost = message.info.cost\n01120| end\n01121| end\n01122| \n01123| ---Get rendered message by ID\n01124| ---@param message_id string Message ID\n01125| ---@return RenderedMessage|nil Rendered message or nil if not found\n01126| function M.get_rendered_message(message_id)\n01127| local rendered_msg = M._render_state:get_message(message_id)\n01128| if rendered_msg then\n01129| return rendered_msg\n01130| end\n01131| return nil\n01132| end\n01133| \n01134| return M\n01135| \n\n(End of file - total 1135 lines)\n","type":"text","id":"prt_bfaa078ae001zhO39Uz3en7btW"}}},{"type":"message.part.updated","properties":{"part":{"filename":"lua/opencode/ui/renderer.lua","messageID":"msg_bfaa078a2001RUYhlKZhSlyWeF","type":"file","id":"prt_bfaa078ae002XvKak6z08HB5LV","url":"file:///home/francis/Projects/_nvim/opencode.nvim/lua/opencode/ui/renderer.lua","mime":"text/plain","sessionID":"ses_4055f967bffeIHf3DnP0xntmxD"}}},{"type":"message.part.updated","properties":{"part":{"messageID":"msg_bfaa078a2001RUYhlKZhSlyWeF","text":"can you use the question tool and ask me a question with an other choice","id":"prt_bfaa078a4001OxXpwZJxaFmqUo","type":"text","sessionID":"ses_4055f967bffeIHf3DnP0xntmxD"}}},{"type":"session.updated","properties":{"info":{"directory":"/home/francis/Projects/_nvim/opencode.nvim","version":"1.1.31","slug":"calm-lagoon","id":"ses_4055f967bffeIHf3DnP0xntmxD","title":"New session - 2026-01-26T14:06:15.428Z","projectID":"29d43526f88157cd4edd071b899dd01f240b771b","time":{"created":1769436375428,"updated":1769436379349}}}},{"type":"custom.emit_events.finished","properties":[]},{"type":"message.updated","properties":{"info":{"path":{"root":"/home/francis/Projects/_nvim/opencode.nvim","cwd":"/home/francis/Projects/_nvim/opencode.nvim"},"modelID":"gpt-4.1","providerID":"github-copilot","id":"msg_bfaa078e7001qc33BqBlqzHv8X","agent":"build","mode":"build","tokens":{"reasoning":0,"input":0,"cache":{"read":0,"write":0},"output":0},"parentID":"msg_bfaa078a2001RUYhlKZhSlyWeF","sessionID":"ses_4055f967bffeIHf3DnP0xntmxD","role":"assistant","cost":0,"time":{"created":1769436379367}}}},{"type":"custom.emit_events.finished","properties":[]},{"type":"message.updated","properties":{"info":{"time":{"created":1769436379298},"id":"msg_bfaa078a2001RUYhlKZhSlyWeF","agent":"build","sessionID":"ses_4055f967bffeIHf3DnP0xntmxD","role":"user","model":{"modelID":"gpt-4.1","providerID":"github-copilot"},"summary":{"diffs":[]}}}},{"type":"session.updated","properties":{"info":{"directory":"/home/francis/Projects/_nvim/opencode.nvim","version":"1.1.31","title":"New session - 2026-01-26T14:06:15.428Z","slug":"calm-lagoon","id":"ses_4055f967bffeIHf3DnP0xntmxD","time":{"created":1769436375428,"updated":1769436379539},"projectID":"29d43526f88157cd4edd071b899dd01f240b771b","summary":{"deletions":0,"files":0,"additions":0}}}},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"message.part.updated","properties":{"part":{"messageID":"msg_bfaa078e7001qc33BqBlqzHv8X","id":"prt_bfaa07ecf0011yakDSUtAyStvx","sessionID":"ses_4055f967bffeIHf3DnP0xntmxD","type":"step-start","snapshot":"56238d5c6bb499e9e265444377a201fba6b1a880"}}},{"type":"message.part.updated","properties":{"part":{"id":"prt_bfaa07ed3001RYplcvPxb660OT","type":"tool","tool":"question","state":{"raw":"","status":"pending","input":{}},"sessionID":"ses_4055f967bffeIHf3DnP0xntmxD","messageID":"msg_bfaa078e7001qc33BqBlqzHv8X","callID":"call_IpPBuTk1riUXZDmDUDghZEpB"}}},{"type":"custom.emit_events.finished","properties":[]},{"type":"question.asked","properties":{"id":"que_bfaa0833a001jKE5x0BCsGLOYX","sessionID":"ses_4055f967bffeIHf3DnP0xntmxD","questions":[{"header":"Favorite color","question":"What is your favorite color?","options":[{"label":"Red","description":"The color red"},{"label":"Blue","description":"The color blue"},{"label":"Other","description":"Any color not listed"}]}],"tool":{"messageID":"msg_bfaa078e7001qc33BqBlqzHv8X","callID":"call_IpPBuTk1riUXZDmDUDghZEpB"}}},{"type":"message.part.updated","properties":{"part":{"id":"prt_bfaa07ed3001RYplcvPxb660OT","type":"tool","tool":"question","state":{"status":"running","input":{"questions":[{"header":"Favorite color","question":"What is your favorite color?","options":[{"label":"Red","description":"The color red"},{"label":"Blue","description":"The color blue"},{"label":"Other","description":"Any color not listed"}]}]},"time":{"start":1769436382010}},"sessionID":"ses_4055f967bffeIHf3DnP0xntmxD","messageID":"msg_bfaa078e7001qc33BqBlqzHv8X","callID":"call_IpPBuTk1riUXZDmDUDghZEpB"}}},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"custom.emit_events.finished","properties":[]},{"type":"message.updated","properties":{"info":{"time":{"created":1769436379298},"id":"msg_bfaa078a2001RUYhlKZhSlyWeF","agent":"build","sessionID":"ses_4055f967bffeIHf3DnP0xntmxD","role":"user","model":{"modelID":"gpt-4.1","providerID":"github-copilot"},"summary":{"diffs":[],"title":"Question including Other option"}}}},{"type":"custom.emit_events.finished","properties":[]},{"type":"session.updated","properties":{"info":{"directory":"/home/francis/Projects/_nvim/opencode.nvim","version":"1.1.31","title":"Question about opencode/ui/renderer.lua","slug":"calm-lagoon","id":"ses_4055f967bffeIHf3DnP0xntmxD","time":{"created":1769436375428,"updated":1769436379539},"projectID":"29d43526f88157cd4edd071b899dd01f240b771b","summary":{"deletions":0,"files":0,"additions":0}}}},{"type":"custom.emit_events.finished","properties":[]}] diff --git a/tests/data/question-replied.json b/tests/data/question-replied.json new file mode 100644 index 00000000..2740a4a4 --- /dev/null +++ b/tests/data/question-replied.json @@ -0,0 +1,873 @@ +[ + { "type": "custom.emit_events.finished", "properties": [] }, + { + "type": "message.updated", + "properties": { + "info": { + "time": { "created": 1769198322822 }, + "model": { "providerID": "github-copilot", "modelID": "gpt-4.1" }, + "id": "msg_bec700486001b1nUph5xg7dVAO", + "role": "user", + "agent": "build", + "sessionID": "ses_4138ffceeffeMvrz0wckcMDrXT" + } + } + }, + { + "type": "message.part.updated", + "properties": { + "part": { + "sessionID": "ses_4138ffceeffeMvrz0wckcMDrXT", + "id": "prt_bec7004870026BAMj0yPOVLQir", + "synthetic": true, + "text": "Called the Read tool with the following input: {\"filePath\":\"/home/francis/Projects/_nvim/opencode.nvim/lua/opencode/ui/renderer.lua\"}", + "type": "text", + "messageID": "msg_bec700486001b1nUph5xg7dVAO" + } + } + }, + { + "type": "message.part.updated", + "properties": { + "part": { + "sessionID": "ses_4138ffceeffeMvrz0wckcMDrXT", + "id": "prt_bec70048e001lHNrZgYJN748Vw", + "synthetic": true, + "text": "\n00001| local state = require('opencode.state')\n00002| local config = require('opencode.config')\n00003| local formatter = require('opencode.ui.formatter')\n00004| local output_window = require('opencode.ui.output_window')\n00005| local permission_window = require('opencode.ui.permission_window')\n00006| local Promise = require('opencode.promise')\n00007| local RenderState = require('opencode.ui.render_state')\n00008| \n00009| local M = {\n00010| _prev_line_count = 0,\n00011| _render_state = RenderState.new(),\n00012| _last_part_formatted = {\n00013| part_id = nil,\n00014| formatted_data = nil --[[@as Output|nil]],\n00015| },\n00016| }\n00017| \n00018| local trigger_on_data_rendered = require('opencode.util').debounce(function()\n00019| local cb_type = type(config.ui.output.rendering.on_data_rendered)\n00020| \n00021| if cb_type == 'boolean' then\n00022| return\n00023| end\n00024| \n00025| if not state.windows or not state.windows.output_buf or not state.windows.output_win then\n00026| return\n00027| end\n00028| \n00029| if cb_type == 'function' then\n00030| pcall(config.ui.output.rendering.on_data_rendered, state.windows.output_buf, state.windows.output_win)\n00031| elseif vim.fn.exists(':RenderMarkdown') > 0 then\n00032| vim.cmd(':RenderMarkdown')\n00033| elseif vim.fn.exists(':Markview') > 0 then\n00034| vim.cmd(':Markview render ' .. state.windows.output_buf)\n00035| end\n00036| end, config.ui.output.rendering.markdown_debounce_ms or 250)\n00037| \n00038| ---Reset renderer state\n00039| function M.reset()\n00040| M._prev_line_count = 0\n00041| M._render_state:reset()\n00042| M._last_part_formatted = { part_id = nil, formatted_data = nil }\n00043| \n00044| output_window.clear()\n00045| \n00046| state.messages = {}\n00047| state.last_user_message = nil\n00048| state.tokens_count = 0\n00049| \n00050| local permissions = state.pending_permissions or {}\n00051| if #permissions and state.api_client then\n00052| for _, permission in ipairs(permissions) do\n00053| require('opencode.api').permission_deny(permission)\n00054| end\n00055| end\n00056| permission_window.clear_all()\n00057| state.pending_permissions = {}\n00058| \n00059| trigger_on_data_rendered()\n00060| end\n00061| \n00062| ---Set up event subscriptions\n00063| ---@param subscribe? boolean false to unsubscribe\n00064| function M.setup_subscriptions(subscribe)\n00065| subscribe = subscribe == nil and true or subscribe\n00066| \n00067| if subscribe then\n00068| state.subscribe('is_opencode_focused', M.on_focus_changed)\n00069| state.subscribe('active_session', M.on_session_changed)\n00070| else\n00071| state.unsubscribe('is_opencode_focused', M.on_focus_changed)\n00072| state.unsubscribe('active_session', M.on_session_changed)\n00073| end\n00074| \n00075| if not state.event_manager then\n00076| return\n00077| end\n00078| \n00079| local event_subscriptions = {\n00080| { 'session.updated', M.on_session_updated },\n00081| { 'session.compacted', M.on_session_compacted },\n00082| { 'session.error', M.on_session_error },\n00083| { 'message.updated', M.on_message_updated },\n00084| { 'message.removed', M.on_message_removed },\n00085| { 'message.part.updated', M.on_part_updated },\n00086| { 'message.part.removed', M.on_part_removed },\n00087| { 'permission.updated', M.on_permission_updated },\n00088| { 'permission.asked', M.on_permission_updated },\n00089| { 'permission.replied', M.on_permission_replied },\n00090| { 'question.asked', M.on_question_asked },\n00091| { 'question.replied', M.clear_question_display },\n00092| { 'question.rejected', M.render_question_display },\n00093| { 'file.edited', M.on_file_edited },\n00094| { 'custom.restore_point.created', M.on_restore_points },\n00095| { 'custom.emit_events.finished', M.on_emit_events_finished },\n00096| }\n00097| \n00098| for _, sub in ipairs(event_subscriptions) do\n00099| if subscribe then\n00100| state.event_manager:subscribe(sub[1], sub[2])\n00101| else\n00102| state.event_manager:unsubscribe(sub[1], sub[2])\n00103| end\n00104| end\n00105| end\n00106| \n00107| ---Clean up and teardown renderer. Unsubscribes from all events\n00108| function M.teardown()\n00109| M.setup_subscriptions(false)\n00110| M.reset()\n00111| end\n00112| \n00113| ---Fetch full session messages from server\n00114| ---@return Promise Promise resolving to list of OpencodeMessage\n00115| local function fetch_session()\n00116| local session = state.active_session\n00117| if not session or not session or session == '' then\n00118| return Promise.new():resolve(nil)\n00119| end\n00120| \n00121| state.last_user_message = nil\n00122| return require('opencode.session').get_messages(session)\n00123| end\n00124| \n00125| ---Request all of the session data from the opencode server and render it\n00126| ---@return Promise\n00127| function M.render_full_session()\n00128| if not output_window.mounted() or not state.api_client then\n00129| return Promise.new():resolve(nil)\n00130| end\n00131| \n00132| return fetch_session():and_then(M._render_full_session_data)\n00133| end\n00134| \n00135| function M._render_full_session_data(session_data)\n00136| M.reset()\n00137| \n00138| if not state.active_session or not state.messages then\n00139| return\n00140| end\n00141| \n00142| local revert_index = nil\n00143| \n00144| -- if we're loading a session and there's no currently selected model, set it\n00145| -- from the messages\n00146| local set_mode_from_messages = not state.current_model\n00147| \n00148| for i, msg in ipairs(session_data) do\n00149| if state.active_session.revert and state.active_session.revert.messageID == msg.info.id then\n00150| revert_index = i\n00151| end\n00152| \n00153| M.on_message_updated({ info = msg.info }, revert_index)\n00154| \n00155| for _, part in ipairs(msg.parts or {}) do\n00156| M.on_part_updated({ part = part }, revert_index)\n00157| end\n00158| end\n00159| \n00160| if revert_index then\n00161| M._write_formatted_data(formatter._format_revert_message(state.messages, revert_index))\n00162| end\n00163| \n00164| if set_mode_from_messages then\n00165| M._set_model_and_mode_from_messages()\n00166| end\n00167| M.scroll_to_bottom()\n00168| \n00169| if config.hooks and config.hooks.on_session_loaded then\n00170| pcall(config.hooks.on_session_loaded, state.active_session)\n00171| end\n00172| end\n00173| \n00174| ---Append permissions display as a fake part at the end\n00175| function M._append_permissions_display()\n00176| local fake_message = {\n00177| info = {\n00178| id = 'permission-display-message',\n00179| sessionID = state.active_session and state.active_session.id or '',\n00180| role = 'system',\n00181| },\n00182| parts = {},\n00183| }\n00184| M.on_message_updated(fake_message)\n00185| \n00186| local fake_part = {\n00187| id = 'permission-display-part',\n00188| messageID = 'permission-display-message',\n00189| sessionID = state.active_session and state.active_session.id or '',\n00190| type = 'permissions-display',\n00191| }\n00192| \n00193| local permissions = permission_window.get_all_permissions()\n00194| if #permissions > 0 then\n00195| M.on_part_updated({ part = fake_part })\n00196| end\n00197| end\n00198| \n00199| function M.clear_question_display()\n00200| vim.print('⭕ ❱ renderer.lua:199 ❱ ƒ(M.clear_question_display) ❱ ')\n00201| M._remove_part_from_buffer('question-display-part')\n00202| M._remove_message_from_buffer('question-display-message')\n00203| end\n00204| \n00205| ---Render question display as a fake part\n00206| function M.render_question_display()\n00207| local question_window = require('opencode.ui.question_window')\n00208| \n00209| local current_question = question_window._current_question\n00210| \n00211| if not question_window.has_question() or not current_question or not current_question.id then\n00212| M._remove_part_from_buffer('question-display-part')\n00213| M._remove_message_from_buffer('question-display-message')\n00214| return\n00215| end\n00216| \n00217| local message_id = 'question-display-message'\n00218| local part_id = 'question-display-part'\n00219| \n00220| local existing_part = M._render_state:get_part(part_id)\n00221| \n00222| local fake_message = {\n00223| info = {\n00224| id = message_id,\n00225| sessionID = state.active_session and state.active_session.id or '',\n00226| role = 'system',\n00227| time = { created = os.time(), completed = os.time() },\n00228| tokens = { reasoning = 0, input = 0, output = 0, cache = { write = 0, read = 0 } },\n00229| system = {},\n00230| path = { cwd = vim.fn.getcwd(), root = vim.fn.getcwd() },\n00231| modelID = '',\n00232| providerID = '',\n00233| error = {},\n00234| },\n00235| parts = {},\n00236| }\n00237| M.on_message_updated(fake_message)\n00238| \n00239| local fake_part = {\n00240| id = part_id,\n00241| messageID = message_id,\n00242| sessionID = state.active_session and state.active_session.id or '',\n00243| type = 'questions-display',\n00244| }\n00245| \n00246| M.on_part_updated({ part = fake_part })\n00247| \n00248| if existing_part then\n00249| M._rerender_part(part_id)\n00250| end\n00251| \n00252| M.scroll_to_bottom(true)\n00253| \n00254| -- Force a redraw to ensure the update is visible immediately\n00255| if state.windows and state.windows.output_win and vim.api.nvim_win_is_valid(state.windows.output_win) then\n00256| vim.api.nvim_win_call(state.windows.output_win, function()\n00257| vim.cmd('redraw')\n00258| end)\n00259| end\n00260| end\n00261| \n00262| ---Render lines as the entire output buffer\n00263| ---@param lines any\n00264| function M.render_lines(lines)\n00265| local output = require('opencode.ui.output'):new()\n00266| output.lines = lines\n00267| M.render_output(output)\n00268| end\n00269| \n00270| ---Sets the entire output buffer based on output_data\n00271| ---@param output_data Output Output object from formatter\n00272| function M.render_output(output_data)\n00273| if not output_window.mounted() then\n00274| return\n00275| end\n00276| \n00277| local lines = output_data.lines or {}\n00278| \n00279| output_window.set_lines(lines)\n00280| output_window.clear_extmarks()\n00281| output_window.set_extmarks(output_data.extmarks)\n00282| M.scroll_to_bottom()\n00283| end\n00284| \n00285| ---Called when EventManager has finished emitting a batch of events\n00286| function M.on_emit_events_finished()\n00287| M.scroll_to_bottom()\n00288| end\n00289| \n00290| ---Find the most recently used model from the messages\n00291| function M._set_model_and_mode_from_messages()\n00292| if not state.messages then\n00293| return\n00294| end\n00295| \n00296| for i = #state.messages, 1, -1 do\n00297| local message = state.messages[i]\n00298| \n00299| if message and message.info then\n00300| if message.info.modelID and message.info.providerID then\n00301| state.current_model = message.info.providerID .. '/' .. message.info.modelID\n00302| if message.info.mode then\n00303| state.current_mode = message.info.mode\n00304| end\n00305| return\n00306| end\n00307| end\n00308| end\n00309| \n00310| -- we didn't find a model from any messages, set it to the default\n00311| require('opencode.core').initialize_current_model()\n00312| end\n00313| \n00314| ---Auto-scroll to bottom if user was already at bottom\n00315| ---Respects cursor position if user has scrolled up\n00316| ---@param force? boolean If true, scroll regardless of current position\n00317| function M.scroll_to_bottom(force)\n00318| if not state.windows or not state.windows.output_buf or not state.windows.output_win then\n00319| return\n00320| end\n00321| \n00322| if not vim.api.nvim_win_is_valid(state.windows.output_win) then\n00323| return\n00324| end\n00325| \n00326| local ok, line_count = pcall(vim.api.nvim_buf_line_count, state.windows.output_buf)\n00327| if not ok or line_count == 0 then\n00328| return\n00329| end\n00330| \n00331| local prev_line_count = M._prev_line_count or 0\n00332| \n00333| ---@cast line_count integer\n00334| M._prev_line_count = line_count\n00335| \n00336| trigger_on_data_rendered()\n00337| \n00338| -- Determine if we should scroll to bottom\n00339| local should_scroll = force == true\n00340| \n00341| if not should_scroll then\n00342| -- Always scroll on initial render\n00343| if prev_line_count == 0 then\n00344| should_scroll = true\n00345| -- Scroll if user is at bottom (respects manual scroll position)\n00346| elseif output_window.viewport_at_bottom then\n00347| should_scroll = true\n00348| end\n00349| end\n00350| \n00351| if should_scroll then\n00352| vim.api.nvim_win_set_cursor(state.windows.output_win, { line_count, 0 })\n00353| -- Use zb to position the cursor line at the bottom of the visible window\n00354| vim.api.nvim_win_call(state.windows.output_win, function()\n00355| vim.cmd('normal! zb')\n00356| end)\n00357| output_window.viewport_at_bottom = true\n00358| else\n00359| -- User has scrolled up, don't scroll\n00360| output_window.viewport_at_bottom = false\n00361| end\n00362| end\n00363| \n00364| ---Write data to output_buf, including normal text and extmarks\n00365| ---@param formatted_data Output Formatted data as Output object\n00366| ---@param part_id? string Optional part ID to store actions\n00367| ---@param start_line? integer Optional line to insert at (shifts content down). If nil, appends to end of buffer.\n00368| ---@return {line_start: integer, line_end: integer}? Range where data was written\n00369| function M._write_formatted_data(formatted_data, part_id, start_line)\n00370| if not state.windows or not state.windows.output_buf then\n00371| return\n00372| end\n00373| \n00374| local buf = state.windows.output_buf\n00375| local is_insertion = start_line ~= nil\n00376| local target_line = start_line or output_window.get_buf_line_count()\n00377| local new_lines = formatted_data.lines\n00378| local extmarks = formatted_data.extmarks\n00379| \n00380| if #new_lines == 0 or not buf then\n00381| return nil\n00382| end\n00383| \n00384| if is_insertion then\n00385| output_window.set_lines(new_lines, target_line, target_line)\n00386| else\n00387| local extra_newline = vim.tbl_extend('keep', {}, new_lines)\n00388| table.insert(extra_newline, '')\n00389| target_line = target_line - 1\n00390| output_window.set_lines(extra_newline, target_line)\n00391| end\n00392| \n00393| -- update actions and extmarks after the insertion because that may\n00394| -- adjust target_line (e.g. when we we're replacing the double newline at\n00395| -- the end)\n00396| \n00397| if part_id and formatted_data.actions then\n00398| M._render_state:add_actions(part_id, formatted_data.actions, target_line)\n00399| end\n00400| \n00401| output_window.set_extmarks(extmarks, target_line)\n00402| \n00403| return {\n00404| line_start = target_line,\n00405| line_end = target_line + #new_lines - 1,\n00406| }\n00407| end\n00408| \n00409| ---Insert new part, either at end of buffer or in the middle for out-of-order parts\n00410| ---@param part_id string Part ID\n00411| ---@param formatted_data Output Formatted data as Output object\n00412| ---@return boolean Success status\n00413| function M._insert_part_to_buffer(part_id, formatted_data)\n00414| local cached = M._render_state:get_part(part_id)\n00415| if not cached then\n00416| return false\n00417| end\n00418| \n00419| if #formatted_data.lines == 0 then\n00420| return true\n00421| end\n00422| \n00423| local is_current_message = state.current_message\n00424| and state.current_message.info\n00425| and state.current_message.info.id == cached.message_id\n00426| \n00427| if is_current_message then\n00428| -- NOTE: we're inserting a part for the current message, just add it to the end\n00429| \n00430| local range = M._write_formatted_data(formatted_data, part_id)\n00431| if not range then\n00432| return false\n00433| end\n00434| \n00435| M._render_state:set_part(cached.part, range.line_start, range.line_end)\n00436| \n00437| M._last_part_formatted = { part_id = part_id, formatted_data = formatted_data }\n00438| \n00439| return true\n00440| end\n00441| \n00442| -- NOTE: We're inserting a part for the first time for a previous message. We need to find\n00443| -- the insertion line (after the last part of this message or after the message header if\n00444| -- no parts).\n00445| local insertion_line = M._get_insertion_point_for_part(part_id, cached.message_id)\n00446| if not insertion_line then\n00447| return false\n00448| end\n00449| \n00450| local range = M._write_formatted_data(formatted_data, part_id, insertion_line)\n00451| if not range then\n00452| return false\n00453| end\n00454| \n00455| local line_count = #formatted_data.lines\n00456| M._render_state:shift_all(insertion_line, line_count)\n00457| \n00458| M._render_state:set_part(cached.part, range.line_start, range.line_end)\n00459| \n00460| return true\n00461| end\n00462| \n00463| ---Replace existing part in buffer\n00464| ---Adjusts line positions of subsequent parts if line count changes\n00465| ---@param part_id string Part ID\n00466| ---@param formatted_data Output Formatted data as Output object\n00467| ---@return boolean Success status\n00468| function M._replace_part_in_buffer(part_id, formatted_data)\n00469| local cached = M._render_state:get_part(part_id)\n00470| if not cached or not cached.line_start or not cached.line_end then\n00471| return false\n00472| end\n00473| \n00474| local new_lines = formatted_data.lines\n00475| local new_line_count = #new_lines\n00476| \n00477| local old_formatted = M._last_part_formatted\n00478| local can_optimize = old_formatted\n00479| and old_formatted.part_id == part_id\n00480| and old_formatted.formatted_data\n00481| and old_formatted.formatted_data.lines\n00482| \n00483| local lines_to_write = new_lines\n00484| local write_start_line = cached.line_start\n00485| \n00486| if can_optimize then\n00487| -- NOTE: This is an optimization to only replace the lines that are different\n00488| -- if we're replacing the most recently formatted part.\n00489| \n00490| ---@cast old_formatted { formatted_data: { lines: string[] } }\n00491| local old_lines = old_formatted.formatted_data.lines\n00492| local first_diff_line = nil\n00493| \n00494| -- Find the first line that's different\n00495| for i = 1, math.min(#old_lines, new_line_count) do\n00496| if old_lines[i] ~= new_lines[i] then\n00497| first_diff_line = i\n00498| break\n00499| end\n00500| end\n00501| \n00502| if not first_diff_line and new_line_count > #old_lines then\n00503| -- The old lines all matched but maybe there are more new lines\n00504| first_diff_line = #old_lines + 1\n00505| end\n00506| \n00507| if first_diff_line then\n00508| lines_to_write = vim.list_slice(new_lines, first_diff_line, new_line_count)\n00509| write_start_line = cached.line_start + first_diff_line - 1\n00510| elseif new_line_count == #old_lines then\n00511| -- Nothing was different, so we're done\n00512| M._last_part_formatted = { part_id = part_id, formatted_data = formatted_data }\n00513| return true\n00514| end\n00515| end\n00516| \n00517| M._render_state:clear_actions(part_id)\n00518| \n00519| output_window.clear_extmarks(cached.line_start - 1, cached.line_end + 1)\n00520| output_window.set_lines(lines_to_write, write_start_line, cached.line_end + 1)\n00521| \n00522| local new_line_end = cached.line_start + new_line_count - 1\n00523| \n00524| output_window.set_extmarks(formatted_data.extmarks, cached.line_start)\n00525| \n00526| if formatted_data.actions then\n00527| M._render_state:add_actions(part_id, formatted_data.actions, cached.line_start + 1)\n00528| end\n00529| \n00530| M._render_state:update_part_lines(part_id, cached.line_start, new_line_end)\n00531| \n00532| M._last_part_formatted = { part_id = part_id, formatted_data = formatted_data }\n00533| \n00534| return true\n00535| end\n00536| \n00537| ---Remove part from buffer and adjust subsequent line positions\n00538| ---@param part_id string Part ID\n00539| function M._remove_part_from_buffer(part_id)\n00540| local cached = M._render_state:get_part(part_id)\n00541| if not cached or not cached.line_start or not cached.line_end then\n00542| return\n00543| end\n00544| \n00545| if not state.windows or not state.windows.output_buf then\n00546| return\n00547| end\n00548| \n00549| output_window.clear_extmarks(cached.line_start - 1, cached.line_end)\n00550| output_window.set_lines({}, cached.line_start - 1, cached.line_end)\n00551| \n00552| M._render_state:remove_part(part_id)\n00553| end\n00554| \n00555| ---Remove message header from buffer and adjust subsequent line positions\n00556| ---@param message_id string Message ID\n00557| function M._remove_message_from_buffer(message_id)\n00558| local cached = M._render_state:get_message(message_id)\n00559| if not cached or not cached.line_start or not cached.line_end then\n00560| return\n00561| end\n00562| \n00563| if not state.windows or not state.windows.output_buf then\n00564| return\n00565| end\n00566| \n00567| output_window.clear_extmarks(cached.line_start - 1, cached.line_end)\n00568| output_window.set_lines({}, cached.line_start - 1, cached.line_end)\n00569| \n00570| M._render_state:remove_message(message_id)\n00571| end\n00572| \n00573| ---Adds a message (most likely just a header) to the buffer\n00574| ---@param message OpencodeMessage Message to add\n00575| function M._add_message_to_buffer(message)\n00576| local header_data = formatter.format_message_header(message)\n00577| local range = M._write_formatted_data(header_data)\n00578| \n00579| if range then\n00580| M._render_state:set_message(message, range.line_start, range.line_end)\n00581| end\n00582| end\n00583| \n00584| ---Replace existing message header in buffer\n00585| ---@param message_id string Message ID\n00586| ---@param formatted_data Output Formatted header as Output object\n00587| ---@return boolean Success status\n00588| function M._replace_message_in_buffer(message_id, formatted_data)\n00589| local cached = M._render_state:get_message(message_id)\n00590| if not cached or not cached.line_start or not cached.line_end then\n00591| return false\n00592| end\n00593| \n00594| local new_lines = formatted_data.lines\n00595| local new_line_count = #new_lines\n00596| \n00597| output_window.clear_extmarks(cached.line_start, cached.line_end + 1)\n00598| output_window.set_lines(new_lines, cached.line_start, cached.line_end + 1)\n00599| output_window.set_extmarks(formatted_data.extmarks, cached.line_start)\n00600| \n00601| local old_line_end = cached.line_end\n00602| local new_line_end = cached.line_start + new_line_count - 1\n00603| \n00604| M._render_state:set_message(cached.message, cached.line_start, new_line_end)\n00605| \n00606| local delta = new_line_end - old_line_end\n00607| if delta ~= 0 then\n00608| M._render_state:shift_all(old_line_end + 1, delta)\n00609| end\n00610| \n00611| return true\n00612| end\n00613| \n00614| ---Event handler for message.updated events\n00615| ---Creates new message or updates existing message info\n00616| ---@param message {info: MessageInfo} Event properties\n00617| ---@param revert_index? integer Revert index in session, if applicable\n00618| function M.on_message_updated(message, revert_index)\n00619| if not state.active_session or not state.messages then\n00620| return\n00621| end\n00622| \n00623| local msg = message --[[@as OpencodeMessage]]\n00624| if not msg or not msg.info or not msg.info.id or not msg.info.sessionID then\n00625| return\n00626| end\n00627| \n00628| if state.active_session.id ~= msg.info.sessionID then\n00629| ---@TODO This is probably a child session message, handle differently?\n00630| -- vim.notify('Session id does not match, discarding message: ' .. vim.inspect(message), vim.log.levels.WARN)\n00631| return\n00632| end\n00633| \n00634| local rendered_message = M._render_state:get_message(msg.info.id)\n00635| local found_msg = rendered_message and rendered_message.message\n00636| \n00637| if revert_index then\n00638| if not found_msg then\n00639| table.insert(state.messages, msg)\n00640| end\n00641| M._render_state:set_message(msg, 0, 0)\n00642| return\n00643| end\n00644| \n00645| if found_msg then\n00646| local error_changed = not vim.deep_equal(found_msg.info.error, msg.info.error)\n00647| \n00648| found_msg.info = msg.info\n00649| \n00650| --- NOTE: error handling is a bit messy because errors come in on messages\n00651| --- but we want to display the error at the end. In this case, we an error\n00652| --- was added to this message. We find the last part and re-render it to\n00653| --- display the message. If there are no parts, we'll re-render the message\n00654| \n00655| if error_changed and not revert_index then\n00656| local last_part_id = M._get_last_part_for_message(found_msg)\n00657| if last_part_id then\n00658| M._rerender_part(last_part_id)\n00659| else\n00660| local header_data = formatter.format_message_header(found_msg)\n00661| M._replace_message_in_buffer(msg.info.id, header_data)\n00662| end\n00663| end\n00664| else\n00665| table.insert(state.messages, msg)\n00666| \n00667| M._add_message_to_buffer(msg)\n00668| \n00669| state.current_message = msg\n00670| if message.info.role == 'user' then\n00671| state.last_user_message = msg\n00672| end\n00673| end\n00674| \n00675| M._update_stats_from_message(msg)\n00676| end\n00677| \n00678| ---Event handler for message.part.updated events\n00679| ---Inserts new parts or replaces existing parts in buffer\n00680| ---@param properties {part: OpencodeMessagePart} Event properties\n00681| ---@param revert_index? integer Revert index in session, if applicable\n00682| function M.on_part_updated(properties, revert_index)\n00683| if not properties or not properties.part or not state.active_session then\n00684| return\n00685| end\n00686| \n00687| local part = properties.part\n00688| if not part.id or not part.messageID or not part.sessionID then\n00689| return\n00690| end\n00691| \n00692| if state.active_session.id ~= part.sessionID then\n00693| ---@TODO This is probably a child session part, handle differently?\n00694| -- vim.notify('Session id does not match, discarding part: ' .. vim.inspect(part), vim.log.levels.WARN)\n00695| return\n00696| end\n00697| \n00698| local rendered_message = M._render_state:get_message(part.messageID)\n00699| if not rendered_message or not rendered_message.message then\n00700| vim.notify('Could not find message for part: ' .. vim.inspect(part), vim.log.levels.WARN)\n00701| return\n00702| end\n00703| \n00704| local message = rendered_message.message\n00705| \n00706| message.parts = message.parts or {}\n00707| \n00708| local part_data = M._render_state:get_part(part.id)\n00709| local is_new_part = not part_data\n00710| \n00711| local prev_last_part_id = M._get_last_part_for_message(message)\n00712| local is_last_part = is_new_part or (prev_last_part_id == part.id)\n00713| \n00714| if is_new_part then\n00715| table.insert(message.parts, part)\n00716| else\n00717| for i = #message.parts, 1, -1 do\n00718| if message.parts[i].id == part.id then\n00719| message.parts[i] = part\n00720| break\n00721| end\n00722| end\n00723| end\n00724| \n00725| if part.type == 'step-start' or part.type == 'step-finish' then\n00726| return\n00727| end\n00728| \n00729| if is_new_part then\n00730| M._render_state:set_part(part)\n00731| else\n00732| local rendered_part = M._render_state:update_part_data(part)\n00733| -- NOTE: This isn't the first time we've seen the part but we haven't rendered it previously\n00734| -- so try and render it this time by setting is_new_part = true (otherwise we'd call\n00735| -- _replace_message_in_buffer and it wouldn't do anything because the part hasn't been rendered)\n00736| if not rendered_part or (not rendered_part.line_start and not rendered_part.line_end) then\n00737| is_new_part = true\n00738| end\n00739| end\n00740| \n00741| local formatted = formatter.format_part(part, message, is_last_part)\n00742| \n00743| if revert_index and is_new_part then\n00744| return\n00745| end\n00746| \n00747| if is_new_part then\n00748| M._insert_part_to_buffer(part.id, formatted)\n00749| \n00750| if message.info.error then\n00751| --- NOTE: More error display code. As mentioned above, errors come in on messages\n00752| --- but we want to display them after parts so we tack the error onto the last\n00753| --- part. When a part is added and there's an error, we need to rerender\n00754| --- previous last part so it doesn't also display the message. If there was no previous\n00755| --- part, then we need to rerender the header so it doesn't display the error\n00756| \n00757| if not prev_last_part_id then\n00758| -- no previous part, we're the first part, re-render the message header\n00759| -- so it doesn't also display the error\n00760| local header_data = formatter.format_message_header(message)\n00761| M._replace_message_in_buffer(part.messageID, header_data)\n00762| elseif prev_last_part_id ~= part.id then\n00763| M._rerender_part(prev_last_part_id)\n00764| end\n00765| end\n00766| else\n00767| M._replace_part_in_buffer(part.id, formatted)\n00768| end\n00769| \n00770| if (part.type == 'file' or part.type == 'agent') and part.source then\n00771| -- we have a mention, we need to rerender the early part to highlight\n00772| -- the mention.\n00773| local text_part_id = M._find_text_part_for_message(message)\n00774| if text_part_id then\n00775| M._rerender_part(text_part_id)\n00776| end\n00777| end\n00778| end\n00779| \n00780| ---Event handler for message.part.removed events\n00781| ---@param properties {sessionID: string, messageID: string, partID: string} Event properties\n00782| function M.on_part_removed(properties)\n00783| if not properties then\n00784| return\n00785| end\n00786| \n00787| local part_id = properties.partID\n00788| if not part_id then\n00789| return\n00790| end\n00791| \n00792| local cached = M._render_state:get_part(part_id)\n00793| if cached and cached.message_id then\n00794| local rendered_message = M._render_state:get_message(cached.message_id)\n00795| if rendered_message and rendered_message.message then\n00796| local message = rendered_message.message\n00797| if message.parts then\n00798| for i, part in ipairs(message.parts) do\n00799| if part.id == part_id then\n00800| table.remove(message.parts, i)\n00801| break\n00802| end\n00803| end\n00804| end\n00805| end\n00806| end\n00807| \n00808| M._remove_part_from_buffer(part_id)\n00809| end\n00810| \n00811| ---Event handler for message.removed events\n00812| ---Removes message and all its parts from buffer\n00813| ---@param properties {sessionID: string, messageID: string} Event properties\n00814| function M.on_message_removed(properties)\n00815| if not properties or not state.messages then\n00816| return\n00817| end\n00818| \n00819| local message_id = properties.messageID\n00820| if not message_id then\n00821| return\n00822| end\n00823| \n00824| local rendered_message = M._render_state:get_message(message_id)\n00825| if not rendered_message or not rendered_message.message then\n00826| return\n00827| end\n00828| \n00829| local message = rendered_message.message\n00830| for _, part in ipairs(message.parts or {}) do\n00831| if part.id then\n00832| M._remove_part_from_buffer(part.id)\n00833| end\n00834| end\n00835| \n00836| M._remove_message_from_buffer(message_id)\n00837| \n00838| for i, msg in ipairs(state.messages or {}) do\n00839| if msg.info.id == message_id then\n00840| table.remove(state.messages, i)\n00841| break\n00842| end\n00843| end\n00844| end\n00845| \n00846| ---Event handler for session.compacted events\n00847| ---@param properties {sessionID: string} Event properties\n00848| function M.on_session_compacted(properties)\n00849| vim.notify('Session has been compacted')\n00850| end\n00851| \n00852| ---Event handler for session.updated events\n00853| ---@param properties {info: Session}\n00854| function M.on_session_updated(properties)\n00855| if not properties or not properties.info or not state.active_session then\n00856| return\n00857| end\n00858| if not vim.deep_equal(state.active_session.revert, properties.info.revert) then\n00859| state.active_session.revert = properties.info.revert\n00860| M._render_full_session_data(state.messages)\n00861| end\n00862| end\n00863| \n00864| ---Event handler for session.error events\n00865| ---@param properties {sessionID: string, error: table} Event properties\n00866| function M.on_session_error(properties)\n00867| if not properties or not properties.error then\n00868| return\n00869| end\n00870| \n00871| -- NOTE: we're handling message errors so session errors seem duplicative\n00872| if config.debug.enabled then\n00873| vim.notify('Session error: ' .. vim.inspect(properties.error))\n00874| end\n00875| end\n00876| \n00877| ---Event handler for permission.updated events\n00878| ---Re-renders part that requires permission and adds to permission window\n00879| ---@param permission OpencodePermission Event properties\n00880| function M.on_permission_updated(permission)\n00881| local tool = permission.tool\n00882| \n00883| ---@TODO this is for backward compatibility, remove later\n00884| local callID = tool and tool.callID or permission.callID\n00885| local messageID = tool and tool.messageID or permission.messageID\n00886| \n00887| if not permission or not messageID or not callID then\n00888| return\n00889| end\n00890| \n00891| -- Add permission to pending queue\n00892| if not state.pending_permissions then\n00893| state.pending_permissions = {}\n00894| end\n00895| \n00896| -- Check if permission already exists in queue\n00897| local existing_index = nil\n00898| for i, existing in ipairs(state.pending_permissions) do\n00899| if existing.id == permission.id then\n00900| existing_index = i\n00901| break\n00902| end\n00903| end\n00904| \n00905| local permissions = vim.deepcopy(state.pending_permissions)\n00906| if existing_index then\n00907| permissions[existing_index] = permission\n00908| else\n00909| table.insert(permissions, permission)\n00910| end\n00911| state.pending_permissions = permissions\n00912| \n00913| permission_window.add_permission(permission)\n00914| \n00915| M._append_permissions_display()\n00916| \n00917| M._rerender_part('permission-display-part')\n00918| M.scroll_to_bottom()\n00919| end\n00920| \n00921| ---Event handler for permission.replied events\n00922| ---Re-renders part after permission is resolved and removes from window\n00923| ---@param properties {sessionID: string, permissionID?: string,requestID?: string, response: string}|{} Event properties\n00924| function M.on_permission_replied(properties)\n00925| if not properties then\n00926| return\n00927| end\n00928| \n00929| local permission_id = properties.permissionID or properties.requestID\n00930| \n00931| if permission_id then\n00932| permission_window.remove_permission(permission_id)\n00933| state.pending_permissions = vim.deepcopy(permission_window.get_all_permissions())\n00934| if #state.pending_permissions == 0 then\n00935| M._remove_part_from_buffer('permission-display-part')\n00936| M._remove_message_from_buffer('permission-display-message')\n00937| end\n00938| M._rerender_part('permission-display-part')\n00939| end\n00940| end\n00941| \n00942| ---Event handler for question.asked events\n00943| ---Shows the question picker UI for the user to answer\n00944| ---@param properties OpencodeQuestionRequest Event properties\n00945| function M.on_question_asked(properties)\n00946| if not properties or not properties.id or not properties.questions then\n00947| return\n00948| end\n00949| \n00950| if not state.active_session or properties.sessionID ~= state.active_session.id then\n00951| return\n00952| end\n00953| \n00954| vim.schedule(function()\n00955| local question_window = require('opencode.ui.question_window')\n00956| question_window.show_question(properties)\n00957| M.render_question_display()\n00958| end)\n00959| end\n00960| \n00961| function M.on_file_edited(properties)\n00962| vim.cmd('checktime')\n00963| if config.hooks and config.hooks.on_file_edited then\n00964| pcall(config.hooks.on_file_edited, properties.file)\n00965| end\n00966| end\n00967| \n00968| ---@param properties RestorePointCreatedEvent\n00969| function M.on_restore_points(properties)\n00970| state.append('restore_points', properties.restore_point)\n00971| if not properties or not properties.restore_point or not properties.restore_point.from_snapshot_id then\n00972| return\n00973| end\n00974| local part = M._render_state:get_part_by_snapshot_id(properties.restore_point.from_snapshot_id)\n00975| if part then\n00976| M.on_part_updated({ part = part })\n00977| end\n00978| end\n00979| \n00980| ---Find part ID by call ID and message ID\n00981| ---Useful for finding a part for a permission\n00982| ---@param call_id string Call ID to search for\n00983| ---@param message_id string Message ID to check the parts of\n00984| ---@return string? part_id Part ID if found, nil otherwise\n00985| function M._find_part_by_call_id(call_id, message_id)\n00986| return M._render_state:get_part_by_call_id(call_id, message_id)\n00987| end\n00988| \n00989| ---Find the text part in a message\n00990| ---@param message OpencodeMessage The message containing the parts\n00991| ---@return string? text_part_id The ID of the text part\n00992| function M._find_text_part_for_message(message)\n00993| if not message or not message.parts then\n00994| return nil\n00995| end\n00996| \n00997| for _, part in ipairs(message.parts) do\n00998| if part.type == 'text' and not part.synthetic then\n00999| return part.id\n01000| end\n01001| end\n01002| \n01003| return nil\n01004| end\n01005| \n01006| ---Find the last part in a message\n01007| ---@param message OpencodeMessage The message containing the parts\n01008| ---@return string? last_part_id The ID of the last part\n01009| function M._get_last_part_for_message(message)\n01010| if not message or not message.parts or #message.parts == 0 then\n01011| return nil\n01012| end\n01013| \n01014| for i = #message.parts, 1, -1 do\n01015| local part = message.parts[i]\n01016| if part.type ~= 'step-start' and part.type ~= 'step-finish' and part.id then\n01017| return part.id\n01018| end\n01019| end\n01020| \n01021| return nil\n01022| end\n01023| \n01024| ---Get insertion point for an out-of-order part\n01025| ---@param part_id string The part ID to insert\n01026| ---@param message_id string The message ID the part belongs to\n01027| ---@return integer? insertion_line The line to insert at (1-indexed), or nil on error\n01028| function M._get_insertion_point_for_part(part_id, message_id)\n01029| local rendered_message = M._render_state:get_message(message_id)\n01030| if not rendered_message or not rendered_message.message then\n01031| return nil\n01032| end\n01033| \n01034| local message = rendered_message.message\n01035| \n01036| local insertion_line = rendered_message.line_end and (rendered_message.line_end + 1)\n01037| if not insertion_line then\n01038| return nil\n01039| end\n01040| \n01041| local current_part_index = nil\n01042| if message.parts then\n01043| for i, part in ipairs(message.parts) do\n01044| if part.id == part_id then\n01045| current_part_index = i\n01046| break\n01047| end\n01048| end\n01049| end\n01050| \n01051| if not current_part_index then\n01052| return insertion_line\n01053| end\n01054| \n01055| for i = current_part_index - 1, 1, -1 do\n01056| local prev_part = message.parts[i]\n01057| if prev_part and prev_part.id then\n01058| local prev_rendered = M._render_state:get_part(prev_part.id)\n01059| \n01060| if prev_rendered and prev_rendered.line_end then\n01061| return prev_rendered.line_end + 1\n01062| end\n01063| end\n01064| end\n01065| \n01066| return insertion_line\n01067| end\n01068| \n01069| ---Re-render existing part with current state\n01070| ---Used for permission updates and other dynamic changes\n01071| ---@param part_id string Part ID to re-render\n01072| function M._rerender_part(part_id)\n01073| local cached = M._render_state:get_part(part_id)\n01074| if not cached or not cached.part then\n01075| return\n01076| end\n01077| \n01078| local part = cached.part\n01079| local rendered_message = M._render_state:get_message(cached.message_id)\n01080| if not rendered_message or not rendered_message.message then\n01081| return\n01082| end\n01083| \n01084| local message = rendered_message.message\n01085| local last_part_id = M._get_last_part_for_message(message)\n01086| local is_last_part = (last_part_id == part_id)\n01087| local formatted = formatter.format_part(part, message, is_last_part)\n01088| \n01089| M._replace_part_in_buffer(part_id, formatted)\n01090| end\n01091| \n01092| ---Event handler for focus changes\n01093| ---Re-renders part associated with current permission for displaying global shortcuts or buffer-local ones\n01094| function M.on_focus_changed()\n01095| -- Check permission window first, fallback to state\n01096| local current_permission = permission_window.get_all_permissions()[1]\n01097| \n01098| if not current_permission then\n01099| return\n01100| end\n01101| \n01102| M._rerender_part('permission-display-part')\n01103| trigger_on_data_rendered()\n01104| end\n01105| \n01106| function M.on_session_changed(_, new, _)\n01107| M.reset()\n01108| if new then\n01109| M.render_full_session()\n01110| end\n01111| end\n01112| \n01113| ---Get all actions available at a specific line\n01114| ---@param line integer 0-indexed line number\n01115| ---@return table[] List of actions available at that line\n01116| function M.get_actions_for_line(line)\n01117| return M._render_state:get_actions_at_line(line)\n01118| end\n01119| \n01120| ---Update display stats from a single message\n01121| ---@param message OpencodeMessage\n01122| function M._update_stats_from_message(message)\n01123| if not state.current_model and message.info.providerID and message.info.providerID ~= '' then\n01124| state.current_model = message.info.providerID .. '/' .. message.info.modelID\n01125| end\n01126| \n01127| local tokens = message.info.tokens\n01128| if tokens and tokens.input > 0 then\n01129| state.tokens_count = tokens.input + tokens.output + tokens.cache.read + tokens.cache.write\n01130| end\n01131| \n01132| if message.info.cost and type(message.info.cost) == 'number' then\n01133| state.cost = message.info.cost\n01134| end\n01135| end\n01136| \n01137| ---Get rendered message by ID\n01138| ---@param message_id string Message ID\n01139| ---@return RenderedMessage|nil Rendered message or nil if not found\n01140| function M.get_rendered_message(message_id)\n01141| local rendered_msg = M._render_state:get_message(message_id)\n01142| if rendered_msg then\n01143| return rendered_msg\n01144| end\n01145| return nil\n01146| end\n01147| \n01148| return M\n01149| \n\n(End of file - total 1149 lines)\n", + "type": "text", + "messageID": "msg_bec700486001b1nUph5xg7dVAO" + } + } + }, + { + "type": "message.part.updated", + "properties": { + "part": { + "mime": "text/plain", + "sessionID": "ses_4138ffceeffeMvrz0wckcMDrXT", + "id": "prt_bec70048e0020OzBtNbBep209b", + "messageID": "msg_bec700486001b1nUph5xg7dVAO", + "filename": "lua/opencode/ui/renderer.lua", + "type": "file", + "url": "file:///home/francis/Projects/_nvim/opencode.nvim/lua/opencode/ui/renderer.lua" + } + } + }, + { + "type": "message.part.updated", + "properties": { + "part": { + "id": "prt_bec700487001WuKleCk1c5VoKo", + "sessionID": "ses_4138ffceeffeMvrz0wckcMDrXT", + "messageID": "msg_bec700486001b1nUph5xg7dVAO", + "type": "text", + "text": "can you use the question tool and ask me a question with an other choice" + } + } + }, + { + "type": "session.updated", + "properties": { + "info": { + "slug": "swift-lagoon", + "time": { "created": 1769198322449, "updated": 1769198322848 }, + "title": "New session - 2026-01-23T19:58:42.449Z", + "id": "ses_4138ffceeffeMvrz0wckcMDrXT", + "version": "1.1.31", + "projectID": "29d43526f88157cd4edd071b899dd01f240b771b", + "directory": "/home/francis/Projects/_nvim/opencode.nvim" + } + } + }, + { "type": "custom.emit_events.finished", "properties": [] }, + { + "type": "message.updated", + "properties": { + "info": { + "mode": "build", + "tokens": { + "output": 0, + "cache": { "read": 0, "write": 0 }, + "input": 0, + "reasoning": 0 + }, + "parentID": "msg_bec700486001b1nUph5xg7dVAO", + "id": "msg_bec7004af001ReI5P3VAVOzVzM", + "sessionID": "ses_4138ffceeffeMvrz0wckcMDrXT", + "time": { "created": 1769198322863 }, + "role": "assistant", + "modelID": "gpt-4.1", + "providerID": "github-copilot", + "path": { + "cwd": "/home/francis/Projects/_nvim/opencode.nvim", + "root": "/home/francis/Projects/_nvim/opencode.nvim" + }, + "agent": "build", + "cost": 0 + } + } + }, + { + "type": "message.updated", + "properties": { + "info": { + "time": { "created": 1769198322822 }, + "model": { "providerID": "github-copilot", "modelID": "gpt-4.1" }, + "id": "msg_bec700486001b1nUph5xg7dVAO", + "role": "user", + "sessionID": "ses_4138ffceeffeMvrz0wckcMDrXT", + "agent": "build", + "summary": { "diffs": [] } + } + } + }, + { + "type": "session.updated", + "properties": { + "info": { + "slug": "swift-lagoon", + "time": { "created": 1769198322449, "updated": 1769198323000 }, + "directory": "/home/francis/Projects/_nvim/opencode.nvim", + "id": "ses_4138ffceeffeMvrz0wckcMDrXT", + "projectID": "29d43526f88157cd4edd071b899dd01f240b771b", + "version": "1.1.31", + "title": "New session - 2026-01-23T19:58:42.449Z", + "summary": { "deletions": 0, "files": 0, "additions": 0 } + } + } + }, + { "type": "custom.emit_events.finished", "properties": [] }, + { "type": "custom.emit_events.finished", "properties": [] }, + { "type": "custom.emit_events.finished", "properties": [] }, + { "type": "custom.emit_events.finished", "properties": [] }, + { "type": "custom.emit_events.finished", "properties": [] }, + { "type": "custom.emit_events.finished", "properties": [] }, + { "type": "custom.emit_events.finished", "properties": [] }, + { "type": "custom.emit_events.finished", "properties": [] }, + { "type": "custom.emit_events.finished", "properties": [] }, + { "type": "custom.emit_events.finished", "properties": [] }, + { "type": "custom.emit_events.finished", "properties": [] }, + { + "type": "message.part.updated", + "properties": { + "part": { + "snapshot": "d7ab3f36b93ab4a4945b1d4a429559a808bdbb06", + "id": "prt_bec701297001un4PeyHrxwYRn3", + "messageID": "msg_bec7004af001ReI5P3VAVOzVzM", + "type": "step-start", + "sessionID": "ses_4138ffceeffeMvrz0wckcMDrXT" + } + } + }, + { + "type": "message.part.updated", + "properties": { + "part": { + "sessionID": "ses_4138ffceeffeMvrz0wckcMDrXT", + "type": "tool", + "id": "prt_bec70129e001DR2RBU86ZBYYFS", + "state": { "input": {}, "raw": "", "status": "pending" }, + "messageID": "msg_bec7004af001ReI5P3VAVOzVzM", + "callID": "call_BpayNRjIsH1yt9pxkkI9L1iV", + "tool": "question" + } + } + }, + { "type": "custom.emit_events.finished", "properties": [] }, + { "type": "custom.emit_events.finished", "properties": [] }, + { "type": "custom.emit_events.finished", "properties": [] }, + { "type": "custom.emit_events.finished", "properties": [] }, + { "type": "custom.emit_events.finished", "properties": [] }, + { "type": "custom.emit_events.finished", "properties": [] }, + { "type": "custom.emit_events.finished", "properties": [] }, + { "type": "custom.emit_events.finished", "properties": [] }, + { "type": "custom.emit_events.finished", "properties": [] }, + { "type": "custom.emit_events.finished", "properties": [] }, + { "type": "custom.emit_events.finished", "properties": [] }, + { "type": "custom.emit_events.finished", "properties": [] }, + { "type": "custom.emit_events.finished", "properties": [] }, + { "type": "custom.emit_events.finished", "properties": [] }, + { "type": "custom.emit_events.finished", "properties": [] }, + { "type": "custom.emit_events.finished", "properties": [] }, + { + "type": "question.asked", + "properties": { + "id": "que_bec701676001sb0J4cQwQ4NRDP", + "sessionID": "ses_4138ffceeffeMvrz0wckcMDrXT", + "questions": [ + { + "header": "Pick your favorite fruit", + "question": "Which of the following is your favorite fruit?", + "options": [ + { + "label": "Apple", + "description": "Crisp and sweet, great for snacking." + }, + { + "label": "Banana", + "description": "Soft and sweet, good for energy." + }, + { "label": "Orange", "description": "Juicy and citrusy." }, + { + "label": "Other", + "description": "Choose this if your favorite is not listed." + } + ] + } + ], + "tool": { + "callID": "call_BpayNRjIsH1yt9pxkkI9L1iV", + "messageID": "msg_bec7004af001ReI5P3VAVOzVzM" + } + } + }, + { + "type": "message.part.updated", + "properties": { + "part": { + "sessionID": "ses_4138ffceeffeMvrz0wckcMDrXT", + "type": "tool", + "id": "prt_bec70129e001DR2RBU86ZBYYFS", + "state": { + "time": { "start": 1769198327415 }, + "status": "running", + "input": { + "questions": [ + { + "header": "Pick your favorite fruit", + "question": "Which of the following is your favorite fruit?", + "options": [ + { + "label": "Apple", + "description": "Crisp and sweet, great for snacking." + }, + { + "label": "Banana", + "description": "Soft and sweet, good for energy." + }, + { "label": "Orange", "description": "Juicy and citrusy." }, + { + "label": "Other", + "description": "Choose this if your favorite is not listed." + } + ] + } + ] + } + }, + "messageID": "msg_bec7004af001ReI5P3VAVOzVzM", + "callID": "call_BpayNRjIsH1yt9pxkkI9L1iV", + "tool": "question" + } + } + }, + { "type": "custom.emit_events.finished", "properties": [] }, + { "type": "custom.emit_events.finished", "properties": [] }, + { "type": "custom.emit_events.finished", "properties": [] }, + { "type": "custom.emit_events.finished", "properties": [] }, + { "type": "custom.emit_events.finished", "properties": [] }, + { "type": "custom.emit_events.finished", "properties": [] }, + { "type": "custom.emit_events.finished", "properties": [] }, + { "type": "custom.emit_events.finished", "properties": [] }, + { "type": "custom.emit_events.finished", "properties": [] }, + { "type": "custom.emit_events.finished", "properties": [] }, + { "type": "custom.emit_events.finished", "properties": [] }, + { "type": "custom.emit_events.finished", "properties": [] }, + { "type": "custom.emit_events.finished", "properties": [] }, + { "type": "custom.emit_events.finished", "properties": [] }, + { "type": "custom.emit_events.finished", "properties": [] }, + { + "type": "message.updated", + "properties": { + "info": { + "time": { "created": 1769198322822 }, + "model": { "providerID": "github-copilot", "modelID": "gpt-4.1" }, + "id": "msg_bec700486001b1nUph5xg7dVAO", + "role": "user", + "sessionID": "ses_4138ffceeffeMvrz0wckcMDrXT", + "agent": "build", + "summary": { "title": "Question with Other choice", "diffs": [] } + } + } + }, + { "type": "custom.emit_events.finished", "properties": [] }, + { + "type": "session.updated", + "properties": { + "info": { + "slug": "swift-lagoon", + "time": { "created": 1769198322449, "updated": 1769198323000 }, + "directory": "/home/francis/Projects/_nvim/opencode.nvim", + "id": "ses_4138ffceeffeMvrz0wckcMDrXT", + "projectID": "29d43526f88157cd4edd071b899dd01f240b771b", + "version": "1.1.31", + "title": "Ask question with Other option for renderer.lua", + "summary": { "deletions": 0, "files": 0, "additions": 0 } + } + } + }, + { "type": "custom.emit_events.finished", "properties": [] }, + { + "type": "question.replied", + "properties": { + "requestID": "que_bec701676001sb0J4cQwQ4NRDP", + "answers": [["Banana"]], + "sessionID": "ses_4138ffceeffeMvrz0wckcMDrXT" + } + }, + { + "type": "message.part.updated", + "properties": { + "part": { + "sessionID": "ses_4138ffceeffeMvrz0wckcMDrXT", + "type": "tool", + "id": "prt_bec70129e001DR2RBU86ZBYYFS", + "state": { + "time": { "start": 1769198327415, "end": 1769198332822 }, + "status": "completed", + "title": "Asked 1 question", + "input": { + "questions": [ + { + "header": "Pick your favorite fruit", + "question": "Which of the following is your favorite fruit?", + "options": [ + { + "label": "Apple", + "description": "Crisp and sweet, great for snacking." + }, + { + "label": "Banana", + "description": "Soft and sweet, good for energy." + }, + { "label": "Orange", "description": "Juicy and citrusy." }, + { + "label": "Other", + "description": "Choose this if your favorite is not listed." + } + ] + } + ] + }, + "output": "User has answered your questions: \"Which of the following is your favorite fruit?\"=\"Banana\". You can now continue with the user's answers in mind.", + "metadata": { "answers": [["Banana"]], "truncated": false } + }, + "messageID": "msg_bec7004af001ReI5P3VAVOzVzM", + "callID": "call_BpayNRjIsH1yt9pxkkI9L1iV", + "tool": "question" + } + } + }, + { + "type": "message.part.updated", + "properties": { + "part": { + "sessionID": "ses_4138ffceeffeMvrz0wckcMDrXT", + "snapshot": "d7ab3f36b93ab4a4945b1d4a429559a808bdbb06", + "type": "step-finish", + "id": "prt_bec702b99001xR2YCLVxSsFh4v", + "reason": "tool-calls", + "messageID": "msg_bec7004af001ReI5P3VAVOzVzM", + "cost": 0, + "tokens": { + "output": 99, + "cache": { "read": 11008, "write": 0 }, + "input": 11617, + "reasoning": 0 + } + } + } + }, + { + "type": "message.updated", + "properties": { + "info": { + "mode": "build", + "tokens": { + "output": 99, + "cache": { "read": 11008, "write": 0 }, + "input": 11617, + "reasoning": 0 + }, + "parentID": "msg_bec700486001b1nUph5xg7dVAO", + "id": "msg_bec7004af001ReI5P3VAVOzVzM", + "finish": "tool-calls", + "sessionID": "ses_4138ffceeffeMvrz0wckcMDrXT", + "time": { "created": 1769198322863 }, + "role": "assistant", + "modelID": "gpt-4.1", + "providerID": "github-copilot", + "cost": 0, + "agent": "build", + "path": { + "cwd": "/home/francis/Projects/_nvim/opencode.nvim", + "root": "/home/francis/Projects/_nvim/opencode.nvim" + } + } + } + }, + { + "type": "message.updated", + "properties": { + "info": { + "mode": "build", + "tokens": { + "output": 99, + "cache": { "read": 11008, "write": 0 }, + "input": 11617, + "reasoning": 0 + }, + "parentID": "msg_bec700486001b1nUph5xg7dVAO", + "id": "msg_bec7004af001ReI5P3VAVOzVzM", + "finish": "tool-calls", + "sessionID": "ses_4138ffceeffeMvrz0wckcMDrXT", + "time": { "created": 1769198322863, "completed": 1769198332860 }, + "role": "assistant", + "modelID": "gpt-4.1", + "providerID": "github-copilot", + "cost": 0, + "agent": "build", + "path": { + "cwd": "/home/francis/Projects/_nvim/opencode.nvim", + "root": "/home/francis/Projects/_nvim/opencode.nvim" + } + } + } + }, + { "type": "custom.emit_events.finished", "properties": [] }, + { + "type": "message.updated", + "properties": { + "info": { + "mode": "build", + "tokens": { + "output": 0, + "cache": { "read": 0, "write": 0 }, + "input": 0, + "reasoning": 0 + }, + "parentID": "msg_bec700486001b1nUph5xg7dVAO", + "id": "msg_bec702bc2001pQ5y1fXB7AuEhw", + "sessionID": "ses_4138ffceeffeMvrz0wckcMDrXT", + "time": { "created": 1769198332866 }, + "role": "assistant", + "modelID": "gpt-4.1", + "providerID": "github-copilot", + "path": { + "cwd": "/home/francis/Projects/_nvim/opencode.nvim", + "root": "/home/francis/Projects/_nvim/opencode.nvim" + }, + "agent": "build", + "cost": 0 + } + } + }, + { + "type": "message.updated", + "properties": { + "info": { + "time": { "created": 1769198322822 }, + "model": { "providerID": "github-copilot", "modelID": "gpt-4.1" }, + "id": "msg_bec700486001b1nUph5xg7dVAO", + "role": "user", + "sessionID": "ses_4138ffceeffeMvrz0wckcMDrXT", + "agent": "build", + "summary": { "title": "Question with Other choice", "diffs": [] } + } + } + }, + { + "type": "session.updated", + "properties": { + "info": { + "slug": "swift-lagoon", + "time": { "created": 1769198322449, "updated": 1769198332872 }, + "directory": "/home/francis/Projects/_nvim/opencode.nvim", + "id": "ses_4138ffceeffeMvrz0wckcMDrXT", + "projectID": "29d43526f88157cd4edd071b899dd01f240b771b", + "version": "1.1.31", + "title": "Ask question with Other option for renderer.lua", + "summary": { "deletions": 0, "files": 0, "additions": 0 } + } + } + }, + { "type": "custom.emit_events.finished", "properties": [] }, + { + "type": "message.part.updated", + "properties": { + "part": { + "snapshot": "d7ab3f36b93ab4a4945b1d4a429559a808bdbb06", + "id": "prt_bec702f490014gY0NPqlgGEtFc", + "messageID": "msg_bec702bc2001pQ5y1fXB7AuEhw", + "type": "step-start", + "sessionID": "ses_4138ffceeffeMvrz0wckcMDrXT" + } + } + }, + { + "type": "message.part.updated", + "properties": { + "part": { + "time": { "start": 1769198333779 }, + "id": "prt_bec702f53001LbrJ51jQcGVqBq", + "sessionID": "ses_4138ffceeffeMvrz0wckcMDrXT", + "messageID": "msg_bec702bc2001pQ5y1fXB7AuEhw", + "type": "text", + "text": "Thank you for" + }, + "delta": " for" + } + }, + { "type": "custom.emit_events.finished", "properties": [] }, + { + "type": "message.part.updated", + "properties": { + "part": { + "time": { "start": 1769198333779 }, + "id": "prt_bec702f53001LbrJ51jQcGVqBq", + "sessionID": "ses_4138ffceeffeMvrz0wckcMDrXT", + "messageID": "msg_bec702bc2001pQ5y1fXB7AuEhw", + "type": "text", + "text": "Thank you for answering! If you have a" + }, + "delta": " a" + } + }, + { "type": "custom.emit_events.finished", "properties": [] }, + { + "type": "message.part.updated", + "properties": { + "part": { + "time": { "start": 1769198333779 }, + "id": "prt_bec702f53001LbrJ51jQcGVqBq", + "sessionID": "ses_4138ffceeffeMvrz0wckcMDrXT", + "messageID": "msg_bec702bc2001pQ5y1fXB7AuEhw", + "type": "text", + "text": "Thank you for answering! If you have a context where you’d like this" + }, + "delta": " this" + } + }, + { "type": "custom.emit_events.finished", "properties": [] }, + { + "type": "message.part.updated", + "properties": { + "part": { + "time": { "start": 1769198333779 }, + "id": "prt_bec702f53001LbrJ51jQcGVqBq", + "sessionID": "ses_4138ffceeffeMvrz0wckcMDrXT", + "messageID": "msg_bec702bc2001pQ5y1fXB7AuEhw", + "type": "text", + "text": "Thank you for answering! If you have a context where you’d like this choice (Banana) to be used" + }, + "delta": " used" + } + }, + { "type": "custom.emit_events.finished", "properties": [] }, + { + "type": "message.part.updated", + "properties": { + "part": { + "time": { "start": 1769198333779 }, + "id": "prt_bec702f53001LbrJ51jQcGVqBq", + "sessionID": "ses_4138ffceeffeMvrz0wckcMDrXT", + "messageID": "msg_bec702bc2001pQ5y1fXB7AuEhw", + "type": "text", + "text": "Thank you for answering! If you have a context where you’d like this choice (Banana) to be used—such as for testing, an example" + }, + "delta": " example" + } + }, + { "type": "custom.emit_events.finished", "properties": [] }, + { + "type": "message.part.updated", + "properties": { + "part": { + "time": { "start": 1769198333779 }, + "id": "prt_bec702f53001LbrJ51jQcGVqBq", + "sessionID": "ses_4138ffceeffeMvrz0wckcMDrXT", + "messageID": "msg_bec702bc2001pQ5y1fXB7AuEhw", + "type": "text", + "text": "Thank you for answering! If you have a context where you’d like this choice (Banana) to be used—such as for testing, an example prompt," + }, + "delta": "," + } + }, + { "type": "custom.emit_events.finished", "properties": [] }, + { + "type": "message.part.updated", + "properties": { + "part": { + "time": { "start": 1769198333779 }, + "id": "prt_bec702f53001LbrJ51jQcGVqBq", + "sessionID": "ses_4138ffceeffeMvrz0wckcMDrXT", + "messageID": "msg_bec702bc2001pQ5y1fXB7AuEhw", + "type": "text", + "text": "Thank you for answering! If you have a context where you’d like this choice (Banana) to be used—such as for testing, an example prompt, or customizing a feature—just let me know." + }, + "delta": "." + } + }, + { "type": "custom.emit_events.finished", "properties": [] }, + { + "type": "message.part.updated", + "properties": { + "part": { + "time": { "start": 1769198333779 }, + "id": "prt_bec702f53001LbrJ51jQcGVqBq", + "sessionID": "ses_4138ffceeffeMvrz0wckcMDrXT", + "messageID": "msg_bec702bc2001pQ5y1fXB7AuEhw", + "type": "text", + "text": "Thank you for answering! If you have a context where you’d like this choice (Banana) to be used—such as for testing, an example prompt, or customizing a feature—just let me know. The question tool presents an \"Other\"" + }, + "delta": "\"" + } + }, + { "type": "custom.emit_events.finished", "properties": [] }, + { + "type": "message.part.updated", + "properties": { + "part": { + "time": { "start": 1769198333779 }, + "id": "prt_bec702f53001LbrJ51jQcGVqBq", + "sessionID": "ses_4138ffceeffeMvrz0wckcMDrXT", + "messageID": "msg_bec702bc2001pQ5y1fXB7AuEhw", + "type": "text", + "text": "Thank you for answering! If you have a context where you’d like this choice (Banana) to be used—such as for testing, an example prompt, or customizing a feature—just let me know. The question tool presents an \"Other\" option by default (as" + }, + "delta": "as" + } + }, + { "type": "custom.emit_events.finished", "properties": [] }, + { + "type": "message.part.updated", + "properties": { + "part": { + "time": { "start": 1769198333779 }, + "id": "prt_bec702f53001LbrJ51jQcGVqBq", + "sessionID": "ses_4138ffceeffeMvrz0wckcMDrXT", + "messageID": "msg_bec702bc2001pQ5y1fXB7AuEhw", + "type": "text", + "text": "Thank you for answering! If you have a context where you’d like this choice (Banana) to be used—such as for testing, an example prompt, or customizing a feature—just let me know. The question tool presents an \"Other\" option by default (as required), ensuring users can" + }, + "delta": " can" + } + }, + { "type": "custom.emit_events.finished", "properties": [] }, + { + "type": "message.part.updated", + "properties": { + "part": { + "time": { "start": 1769198333779 }, + "id": "prt_bec702f53001LbrJ51jQcGVqBq", + "sessionID": "ses_4138ffceeffeMvrz0wckcMDrXT", + "messageID": "msg_bec702bc2001pQ5y1fXB7AuEhw", + "type": "text", + "text": "Thank you for answering! If you have a context where you’d like this choice (Banana) to be used—such as for testing, an example prompt, or customizing a feature—just let me know. The question tool presents an \"Other\" option by default (as required), ensuring users can always supply their own answer" + }, + "delta": " answer" + } + }, + { "type": "custom.emit_events.finished", "properties": [] }, + { + "type": "message.part.updated", + "properties": { + "part": { + "time": { "start": 1769198333779 }, + "id": "prt_bec702f53001LbrJ51jQcGVqBq", + "sessionID": "ses_4138ffceeffeMvrz0wckcMDrXT", + "messageID": "msg_bec702bc2001pQ5y1fXB7AuEhw", + "type": "text", + "text": "Thank you for answering! If you have a context where you’d like this choice (Banana) to be used—such as for testing, an example prompt, or customizing a feature—just let me know. The question tool presents an \"Other\" option by default (as required), ensuring users can always supply their own answer. If you have more" + }, + "delta": " more" + } + }, + { "type": "custom.emit_events.finished", "properties": [] }, + { + "type": "message.part.updated", + "properties": { + "part": { + "time": { "start": 1769198333779 }, + "id": "prt_bec702f53001LbrJ51jQcGVqBq", + "sessionID": "ses_4138ffceeffeMvrz0wckcMDrXT", + "messageID": "msg_bec702bc2001pQ5y1fXB7AuEhw", + "type": "text", + "text": "Thank you for answering! If you have a context where you’d like this choice (Banana) to be used—such as for testing, an example prompt, or customizing a feature—just let me know. The question tool presents an \"Other\" option by default (as required), ensuring users can always supply their own answer. If you have more questions, want a demo" + }, + "delta": " demo" + } + }, + { "type": "custom.emit_events.finished", "properties": [] }, + { + "type": "message.part.updated", + "properties": { + "part": { + "time": { "start": 1769198333779 }, + "id": "prt_bec702f53001LbrJ51jQcGVqBq", + "sessionID": "ses_4138ffceeffeMvrz0wckcMDrXT", + "messageID": "msg_bec702bc2001pQ5y1fXB7AuEhw", + "type": "text", + "text": "Thank you for answering! If you have a context where you’d like this choice (Banana) to be used—such as for testing, an example prompt, or customizing a feature—just let me know. The question tool presents an \"Other\" option by default (as required), ensuring users can always supply their own answer. If you have more questions, want a demo of question handling" + }, + "delta": " handling" + } + }, + { "type": "custom.emit_events.finished", "properties": [] }, + { + "type": "message.part.updated", + "properties": { + "part": { + "time": { "start": 1769198333779 }, + "id": "prt_bec702f53001LbrJ51jQcGVqBq", + "sessionID": "ses_4138ffceeffeMvrz0wckcMDrXT", + "messageID": "msg_bec702bc2001pQ5y1fXB7AuEhw", + "type": "text", + "text": "Thank you for answering! If you have a context where you’d like this choice (Banana) to be used—such as for testing, an example prompt, or customizing a feature—just let me know. The question tool presents an \"Other\" option by default (as required), ensuring users can always supply their own answer. If you have more questions, want a demo of question handling in your codebase," + }, + "delta": "," + } + }, + { "type": "custom.emit_events.finished", "properties": [] }, + { + "type": "message.part.updated", + "properties": { + "part": { + "time": { "start": 1769198333779 }, + "id": "prt_bec702f53001LbrJ51jQcGVqBq", + "sessionID": "ses_4138ffceeffeMvrz0wckcMDrXT", + "messageID": "msg_bec702bc2001pQ5y1fXB7AuEhw", + "type": "text", + "text": "Thank you for answering! If you have a context where you’d like this choice (Banana) to be used—such as for testing, an example prompt, or customizing a feature—just let me know. The question tool presents an \"Other\" option by default (as required), ensuring users can always supply their own answer. If you have more questions, want a demo of question handling in your codebase, or need help with anything else," + }, + "delta": "," + } + }, + { "type": "custom.emit_events.finished", "properties": [] }, + { + "type": "message.part.updated", + "properties": { + "part": { + "time": { "start": 1769198335156, "end": 1769198335156 }, + "id": "prt_bec702f53001LbrJ51jQcGVqBq", + "sessionID": "ses_4138ffceeffeMvrz0wckcMDrXT", + "messageID": "msg_bec702bc2001pQ5y1fXB7AuEhw", + "type": "text", + "text": "Thank you for answering! If you have a context where you’d like this choice (Banana) to be used—such as for testing, an example prompt, or customizing a feature—just let me know. The question tool presents an \"Other\" option by default (as required), ensuring users can always supply their own answer. If you have more questions, want a demo of question handling in your codebase, or need help with anything else, just ask!" + } + } + }, + { + "type": "message.part.updated", + "properties": { + "part": { + "sessionID": "ses_4138ffceeffeMvrz0wckcMDrXT", + "snapshot": "d7ab3f36b93ab4a4945b1d4a429559a808bdbb06", + "type": "step-finish", + "id": "prt_bec7034b5001eVR3MVHhpTKq3W", + "reason": "stop", + "messageID": "msg_bec702bc2001pQ5y1fXB7AuEhw", + "cost": 0, + "tokens": { + "output": 95, + "cache": { "read": 22656, "write": 0 }, + "input": 106, + "reasoning": 0 + } + } + } + }, + { + "type": "message.updated", + "properties": { + "info": { + "mode": "build", + "tokens": { + "output": 95, + "cache": { "read": 22656, "write": 0 }, + "input": 106, + "reasoning": 0 + }, + "parentID": "msg_bec700486001b1nUph5xg7dVAO", + "id": "msg_bec702bc2001pQ5y1fXB7AuEhw", + "finish": "stop", + "sessionID": "ses_4138ffceeffeMvrz0wckcMDrXT", + "time": { "created": 1769198332866 }, + "role": "assistant", + "modelID": "gpt-4.1", + "providerID": "github-copilot", + "cost": 0, + "agent": "build", + "path": { + "cwd": "/home/francis/Projects/_nvim/opencode.nvim", + "root": "/home/francis/Projects/_nvim/opencode.nvim" + } + } + } + }, + { + "type": "message.updated", + "properties": { + "info": { + "mode": "build", + "tokens": { + "output": 95, + "cache": { "read": 22656, "write": 0 }, + "input": 106, + "reasoning": 0 + }, + "parentID": "msg_bec700486001b1nUph5xg7dVAO", + "id": "msg_bec702bc2001pQ5y1fXB7AuEhw", + "finish": "stop", + "sessionID": "ses_4138ffceeffeMvrz0wckcMDrXT", + "time": { "created": 1769198332866, "completed": 1769198335180 }, + "role": "assistant", + "modelID": "gpt-4.1", + "providerID": "github-copilot", + "cost": 0, + "agent": "build", + "path": { + "cwd": "/home/francis/Projects/_nvim/opencode.nvim", + "root": "/home/francis/Projects/_nvim/opencode.nvim" + } + } + } + }, + { + "type": "session.idle", + "properties": { "sessionID": "ses_4138ffceeffeMvrz0wckcMDrXT" } + }, + { + "type": "message.updated", + "properties": { + "info": { + "time": { "created": 1769198322822 }, + "model": { "providerID": "github-copilot", "modelID": "gpt-4.1" }, + "id": "msg_bec700486001b1nUph5xg7dVAO", + "role": "user", + "sessionID": "ses_4138ffceeffeMvrz0wckcMDrXT", + "agent": "build", + "summary": { "title": "Question with Other choice", "diffs": [] } + } + } + }, + { + "type": "session.updated", + "properties": { + "info": { + "slug": "swift-lagoon", + "time": { "created": 1769198322449, "updated": 1769198335189 }, + "directory": "/home/francis/Projects/_nvim/opencode.nvim", + "id": "ses_4138ffceeffeMvrz0wckcMDrXT", + "projectID": "29d43526f88157cd4edd071b899dd01f240b771b", + "version": "1.1.31", + "title": "Ask question with Other option for renderer.lua", + "summary": { "deletions": 0, "files": 0, "additions": 0 } + } + } + }, + { "type": "custom.emit_events.finished", "properties": [] }, + { "type": "custom.emit_events.finished", "properties": [] }, + { "type": "custom.emit_events.finished", "properties": [] }, + { "type": "custom.emit_events.finished", "properties": [] } +] diff --git a/tests/manual/renderer_replay.lua b/tests/manual/renderer_replay.lua index eadeee3d..28ef9060 100644 --- a/tests/manual/renderer_replay.lua +++ b/tests/manual/renderer_replay.lua @@ -400,6 +400,8 @@ function M.start(opts) 'message.removed', 'permission.updated', 'permission.replied', + 'question.replied', + 'question.asked', 'file.edited', 'server.connected', } diff --git a/tests/replay/renderer_spec.lua b/tests/replay/renderer_spec.lua index 55b2c634..5764b1cd 100644 --- a/tests/replay/renderer_spec.lua +++ b/tests/replay/renderer_spec.lua @@ -102,6 +102,8 @@ describe('renderer unit tests', function() 'message.part.removed', 'permission.updated', 'permission.replied', + 'question.replied', + 'question.asked', 'file.edited', 'custom.restore_point.created', 'custom.emit_events.finished', @@ -164,6 +166,8 @@ describe('renderer functional tests', function() local skip_full_session = { 'permission-prompt', 'permission-ask-new', + 'question-ask', + 'multiple-question-ask', 'shifting-and-multiple-perms', 'message-removal', } From 2282c5e4312ceb74584b5d9510a7e2e1cc31c98b Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Mon, 26 Jan 2026 12:25:19 -0500 Subject: [PATCH 2/2] refactor(permisson-window): behave like the question-window - Refactor permission_window to use the same dialog as question-window --- README.md | 12 +- lua/opencode/config.lua | 8 - lua/opencode/types.lua | 6 - lua/opencode/ui/dialog.lua | 366 ++++++++++++++++++ lua/opencode/ui/highlight.lua | 4 +- lua/opencode/ui/permission_window.lua | 303 +++++---------- lua/opencode/ui/question_window.lua | 187 +++------ lua/opencode/ui/renderer.lua | 25 +- .../data/multiple-question-ask.expected.json | 2 +- tests/data/permission-ask-new.expected.json | 2 +- tests/data/permission-prompt.expected.json | 2 +- tests/data/question-ask.expected.json | 2 +- .../shifting-and-multiple-perms.expected.json | 2 +- 13 files changed, 549 insertions(+), 372 deletions(-) create mode 100644 lua/opencode/ui/dialog.lua diff --git a/README.md b/README.md index 743e8931..0c80d419 100644 --- a/README.md +++ b/README.md @@ -142,15 +142,12 @@ require('opencode').setup({ ['orr'] = { 'diff_restore_snapshot_file' }, -- Restore a file to a restore point ['orR'] = { 'diff_restore_snapshot_all' }, -- Restore all files to a restore point ['ox'] = { 'swap_position' }, -- Swap Opencode pane left/right - ['opa'] = { 'permission_accept' }, -- Accept permission request once - ['opA'] = { 'permission_accept_all' }, -- Accept all (for current tool) - ['opd'] = { 'permission_deny' }, -- Deny permission request once ['ott'] = { 'toggle_tool_output' }, -- Toggle tools output (diffs, cmd output, etc.) ['otr'] = { 'toggle_reasoning_output' }, -- Toggle reasoning output (thinking steps) ['o/'] = { 'quick_chat', mode = { 'n', 'x' } }, -- Open quick chat input with selection context in visual mode or current line context in normal mode }, input_window = { - [''] = { 'submit_input_prompt', mode = { 'n', 'i' } }, -- Submit prompt (normal mode and insert mode) + [''] = { 'submit_input_prompt', mode = { 'n', 'i' } }, -- Submit prompt (normal mode and insert mode) [''] = { 'close' }, -- Close UI windows [''] = { 'cancel' }, -- Cancel opencode request while it is running ['~'] = { 'mention_file', mode = 'i' }, -- Pick a file and add to context. See File Mentions section @@ -178,15 +175,10 @@ require('opencode').setup({ ['oO'] = { 'debug_output' }, -- Open raw output in new buffer for debugging ['ods'] = { 'debug_session' }, -- Open raw session in new buffer for debugging }, - permission = { - accept = 'a', -- Accept permission request once (only available when there is a pending permission request) - accept_all = 'A', -- Accept all (for current tool) permission request once (only available when there is a pending permission request) - deny = 'd', -- Deny permission request once (only available when there is a pending permission request) - }, session_picker = { rename_session = { '' }, -- Rename selected session in the session picker delete_session = { '' }, -- Delete selected session in the session picker - new_session = { '' }, -- Create and switch to a new session in the session picker + new_session = { '' }, -- Create and switch to a new session in the session picker }, timeline_picker = { undo = { '', mode = { 'i', 'n' } }, -- Undo to selected message in timeline picker diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index 5e2e1d49..b1b71767 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -41,9 +41,6 @@ M.defaults = { ['orr'] = { 'diff_restore_snapshot_file', desc = 'Restore file snapshot' }, ['orR'] = { 'diff_restore_snapshot_all', desc = 'Restore all snapshots' }, ['ox'] = { 'swap_position', desc = 'Swap window position' }, - ['oPa'] = { 'permission_accept', desc = 'Accept permission' }, - ['oPA'] = { 'permission_accept_all', desc = 'Accept all permissions' }, - ['oPd'] = { 'permission_deny', desc = 'Deny permission' }, ['otr'] = { 'toggle_reasoning_output', desc = 'Toggle reasoning output' }, ['ott'] = { 'toggle_tool_output', desc = 'Toggle tool output' }, ['o/'] = { 'quick_chat', desc = 'Quick chat with current context', mode = { 'n', 'x' } }, @@ -85,11 +82,6 @@ M.defaults = { ['oO'] = { 'debug_output' }, ['ods'] = { 'debug_session' }, }, - permission = { - accept = 'a', - accept_all = 'A', - deny = 'd', - }, session_picker = { rename_session = { '' }, delete_session = { '' }, diff --git a/lua/opencode/types.lua b/lua/opencode/types.lua index ab65a792..7689a4c6 100644 --- a/lua/opencode/types.lua +++ b/lua/opencode/types.lua @@ -73,16 +73,10 @@ ---@class OpencodeKeymapInputWindow : table ---@class OpencodeKeymapOutputWindow : table ----@class OpencodeKeymapPermission ----@field accept string ----@field accept_all string ----@field deny string - ---@class OpencodeKeymap ---@field editor OpencodeKeymapEditor ---@field input_window OpencodeKeymapInputWindow ---@field output_window OpencodeKeymapOutputWindow ----@field permission OpencodeKeymapPermission ---@field session_picker OpencodeSessionPickerKeymap ---@field timeline_picker OpencodeTimelinePickerKeymap ---@field history_picker OpencodeHistoryPickerKeymap diff --git a/lua/opencode/ui/dialog.lua b/lua/opencode/ui/dialog.lua new file mode 100644 index 00000000..c1f0da57 --- /dev/null +++ b/lua/opencode/ui/dialog.lua @@ -0,0 +1,366 @@ +---@class DialogConfig +---@field buffer integer Buffer ID where keymaps should be set +---@field on_select function(index: integer) Called when an option is selected +---@field on_dismiss? function() Called when dialog is dismissed +---@field on_navigate? function() Called when selection changes +---@field get_option_count function(): integer Returns the total number of options +---@field check_focused? function(): boolean Returns whether dialog should be active +---@field keymaps? DialogKeymaps Custom keymap configuration +---@field namespace_prefix? string Prefix for vim.on_key namespace (default: 'opencode_dialog') +---@field hide_input? boolean Whether to hide the input window when dialog is active (default: true) + +---@class DialogKeymaps +---@field up? string[] Keys for navigating up (default: {'k', ''}) +---@field down? string[] Keys for navigating down (default: {'j', ''}) +---@field select? string Key for selecting current option (default: '') +---@field dismiss? string Key for dismissing dialog (default: '') +---@field number_shortcuts? boolean Enable 1-9 number shortcuts (default: true) + +---@class Dialog +---@field private _config DialogConfig +---@field private _keymaps integer[] List of keymap IDs for cleanup +---@field private _key_capture_ns integer? Namespace for vim.on_key +---@field private _selected_index integer Currently selected option index +---@field private _active boolean Whether dialog is currently active +local Dialog = {} +Dialog.__index = Dialog + +---Create a new dialog instance +---@param config DialogConfig Dialog configuration +---@return Dialog +function Dialog.new(config) + local self = setmetatable({}, Dialog) + + -- Set up default keymaps if not provided + local default_keymaps = { + up = { 'k', '' }, + down = { 'j', '' }, + select = '', + dismiss = '', + number_shortcuts = true, + } + + self._config = vim.tbl_deep_extend('force', { + keymaps = default_keymaps, + namespace_prefix = 'opencode_dialog', + check_focused = function() + return true + end, + hide_input = true, + } --[[@as DialogConfig]], config) + + self._keymaps = {} + self._key_capture_ns = nil + self._selected_index = 1 + self._active = false + + return self +end + +---Get the currently selected option index +---@return integer +function Dialog:get_selection() + return self._selected_index +end + +---Set the selected option index +---@param index integer Option index to select +function Dialog:set_selection(index) + local option_count = self._config.get_option_count() + if index >= 1 and index <= option_count then + self._selected_index = index + end +end + +---Navigate selection by delta (positive for down, negative for up) +---@param delta integer Amount to move selection +function Dialog:navigate(delta) + if not self._active or not self._config.check_focused() then + return + end + + local option_count = self._config.get_option_count() + if option_count == 0 then + return + end + + self._selected_index = self._selected_index + delta + + -- Wrap around selection + if self._selected_index < 1 then + self._selected_index = option_count + elseif self._selected_index > option_count then + self._selected_index = 1 + end + + if self._config.on_navigate then + self._config.on_navigate() + end +end + +---Select the current option +function Dialog:select() + if not self._active or not self._config.check_focused() then + return + end + + local option_count = self._config.get_option_count() + if option_count == 0 then + return + end + + self._config.on_select(self._selected_index) +end + +---Dismiss the dialog +function Dialog:dismiss() + if not self._active or not self._config.check_focused() then + return + end + + if self._config.on_dismiss then + self._config.on_dismiss() + end +end + +---Set up keymaps and activate the dialog +function Dialog:setup() + if self._active then + self:teardown() + end + + self._active = true + + -- Hide input window if configured + if self._config.hide_input then + local input_window = require('opencode.ui.input_window') + input_window._hide() + end + + self:_setup_keymaps() +end + +---Clean up keymaps and deactivate the dialog +function Dialog:teardown() + self._active = false + self:_clear_keymaps() + + -- Show input window if it was hidden + if self._config.hide_input then + local input_window = require('opencode.ui.input_window') + input_window._show() + end +end + +---Check if dialog is currently active +---@return boolean +function Dialog:is_active() + return self._active +end + +---Format the legend/instructions for this dialog +---@param output Output Output object to write to +---@param options? table Options for legend formatting +function Dialog:format_legend(output, options) + options = options or {} + local ui = require('opencode.ui.ui') + + if not self._active then + return + end + + local option_count = self._config.get_option_count() + if option_count == 0 then + return + end + + if ui.is_opencode_focused() then + local legend_parts = {} + local keymaps = self._config.keymaps + if not keymaps then + return + end + + if keymaps.up and #keymaps.up > 0 and keymaps.down and #keymaps.down > 0 then + table.insert(legend_parts, string.format('Navigate: `%s`/`%s` or `↑`/`↓`', keymaps.down[1], keymaps.up[1])) + end + + if keymaps.select and keymaps.select ~= '' then + local select_text = string.format('Select: `%s`', keymaps.select) + if keymaps.number_shortcuts and option_count > 0 then + local max_shortcut = math.min(option_count, 9) + select_text = select_text .. string.format(' or `1-%d`', max_shortcut) + end + table.insert(legend_parts, select_text) + end + + if keymaps.dismiss and keymaps.dismiss ~= '' then + table.insert(legend_parts, string.format('Dismiss: `%s`', keymaps.dismiss)) + end + + if #legend_parts > 0 then + output:add_line(table.concat(legend_parts, ' ')) + end + else + local message = options.unfocused_message or 'Focus Opencode window to interact' + output:add_line(message) + end +end + +---Format a complete dialog with title, options, legend, and border +---@param output Output Output object to write to +---@param config table Configuration for dialog rendering +--- - title: string - Dialog title +--- - title_hl: string - Highlight group for title +--- - border_hl: string - Highlight group for border +--- - options: table[] - Array of option objects with {label: string, description?: string} +--- - unfocused_message: string - Message to show when not focused +--- - progress?: string - Progress indicator (e.g., "(1/3)") +--- - content?: string[] - Array of lines to render before options +--- - render_content?: function(output: Output) - Custom function to render content before options +function Dialog:format_dialog(output, config) + if not self._active then + return + end + + local formatter = require('opencode.ui.formatter') + local icons = require('opencode.ui.icons') + + local start_line = output:get_line_count() + + local title = config.title or 'Dialog' + if config.progress then + title = title .. config.progress + end + + output:add_line(title) + if config.title_hl then + output:add_extmark(start_line, { line_hl_group = config.title_hl } --[[@as OutputExtmark]]) + end + output:add_line('') + + if config.render_content then + config.render_content(output) + output:add_line('') + elseif config.content then + for _, line in ipairs(config.content) do + output:add_line(line) + end + output:add_line('') + end + + self:format_options(output, config.options or {}) + + output:add_line('') + + self:format_legend(output, { unfocused_message = config.unfocused_message }) + + 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) + end + + output:add_line('') +end + +---Format options list with selection indicator +---@param output Output Output object to write to +---@param options table[] Array of option objects with {label: string, description?: string} +function Dialog:format_options(output, options) + for i, option in ipairs(options) do + local label = option.label + if option.description and option.description ~= '' then + 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) + + if is_selected then + output:add_extmark(line_idx, { line_hl_group = 'OpencodeDialogOptionHover' } --[[@as OutputExtmark]]) + output:add_extmark(line_idx, { + start_col = 2, + virt_text = { { '› ', 'OpencodeDialogOptionHover' } }, + virt_text_pos = 'overlay', + } --[[@as OutputExtmark]]) + end + end +end + +---Set up buffer-scoped keymaps +function Dialog:_setup_keymaps() + self:_clear_keymaps() + + local buf = self._config.buffer + if not buf or not vim.api.nvim_buf_is_valid(buf) then + return + end + + local keymaps = self._config.keymaps + local keymap_opts = { buffer = buf, silent = true } + + if keymaps.up then + for _, key in ipairs(keymaps.up) do + if key and key ~= '' then + local id = vim.keymap.set('n', key, function() + self:navigate(-1) + end, keymap_opts) + table.insert(self._keymaps, id) + end + end + end + + if keymaps.down then + for _, key in ipairs(keymaps.down) do + if key and key ~= '' then + local id = vim.keymap.set('n', key, function() + self:navigate(1) + end, keymap_opts) + table.insert(self._keymaps, id) + end + end + end + + if keymaps.select and keymaps.select ~= '' then + local id = vim.keymap.set('n', keymaps.select, function() + self:select() + end, keymap_opts) + table.insert(self._keymaps, id) + end + + if keymaps.dismiss and keymaps.dismiss ~= '' then + local id = vim.keymap.set('n', keymaps.dismiss, function() + self:dismiss() + end, keymap_opts) + table.insert(self._keymaps, id) + end + + if keymaps.number_shortcuts then + local option_count = self._config.get_option_count() + local number_keymap_opts = vim.tbl_extend('force', keymap_opts, { nowait = true }) + for i = 1, math.min(option_count, 9) do + local id = vim.keymap.set('n', tostring(i), function() + if not self._active or not self._config.check_focused() then + return + end + self._selected_index = i + self._config.on_select(i) + end, number_keymap_opts) + table.insert(self._keymaps, id) + end + end +end + +---Clear all buffer-scoped keymaps +function Dialog:_clear_keymaps() + for _, keymap_id in ipairs(self._keymaps) do + pcall(vim.keymap.del, 'n', keymap_id, { buffer = self._config.buffer }) + end + self._keymaps = {} +end + +return Dialog diff --git a/lua/opencode/ui/highlight.lua b/lua/opencode/ui/highlight.lua index 50a4d3de..dbcc471b 100644 --- a/lua/opencode/ui/highlight.lua +++ b/lua/opencode/ui/highlight.lua @@ -40,8 +40,8 @@ function M.setup() vim.api.nvim_set_hl(0, 'OpencodeReference', { fg = '#1976D2', default = true }) vim.api.nvim_set_hl(0, 'OpencodeReasoningText', { link = 'Comment', default = true }) vim.api.nvim_set_hl(0, 'OpencodePermissionTitle', { fg = '#FF9E3B', default = true }) + vim.api.nvim_set_hl(0, 'OpencodeDialogOptionHover', { bg = '#E3F2FD', fg = '#1976D2', default = true }) vim.api.nvim_set_hl(0, 'OpencodeQuestionOption', { link = 'Normal', default = true }) - vim.api.nvim_set_hl(0, 'OpencodeQuestionOptionHover', { bg = '#E3F2FD', fg = '#1976D2', 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 }) else @@ -80,8 +80,8 @@ function M.setup() vim.api.nvim_set_hl(0, 'OpencodeReference', { fg = '#7AA2F7', default = true }) vim.api.nvim_set_hl(0, 'OpencodeReasoningText', { link = 'Comment', default = true }) vim.api.nvim_set_hl(0, 'OpencodePermissionTitle', { fg = '#FF9E3B', default = true }) + vim.api.nvim_set_hl(0, 'OpencodeDialogOptionHover', { bg = '#2B3A5A', fg = '#61AFEF', default = true }) vim.api.nvim_set_hl(0, 'OpencodeQuestionOption', { link = 'Normal', default = true }) - vim.api.nvim_set_hl(0, 'OpencodeQuestionOptionHover', { bg = '#2B3A5A', fg = '#61AFEF', 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 }) end diff --git a/lua/opencode/ui/permission_window.lua b/lua/opencode/ui/permission_window.lua index 30dca93a..5677a677 100644 --- a/lua/opencode/ui/permission_window.lua +++ b/lua/opencode/ui/permission_window.lua @@ -1,31 +1,12 @@ local state = require('opencode.state') -local config = require('opencode.config') +local Dialog = require('opencode.ui.dialog') local M = {} -- Simple state M._permission_queue = {} -M._selected_index = 1 - ----Get focus-aware permission keys ----@return table|nil keys table with accept, accept_all, deny keys -local function get_permission_keys() - local is_opencode_focused = require('opencode.ui.ui').is_opencode_focused() - - if is_opencode_focused then - return { - accept = config.keymap.permission.accept, - accept_all = config.keymap.permission.accept_all, - deny = config.keymap.permission.deny, - } - else - return { - accept = config.get_key_for_function('editor', 'permission_accept'), - accept_all = config.get_key_for_function('editor', 'permission_accept_all'), - deny = config.get_key_for_function('editor', 'permission_deny'), - } - end -end +M._dialog = nil +M._processing = false ---Add permission to queue ---@param permission OpencodePermission @@ -38,11 +19,13 @@ function M.add_permission(permission) for i, existing in ipairs(M._permission_queue) do if existing.id == permission.id then M._permission_queue[i] = permission + M._setup_dialog() -- Refresh dialog when permission is updated return end end table.insert(M._permission_queue, permission) + M._setup_dialog() -- Setup dialog when first permission is added end ---Remove permission from queue @@ -51,226 +34,152 @@ function M.remove_permission(permission_id) for i, permission in ipairs(M._permission_queue) do if permission.id == permission_id then table.remove(M._permission_queue, i) - if M._selected_index > #M._permission_queue then - M._selected_index = math.max(1, #M._permission_queue) - end - return + break end end - if #M._permission_queue == 0 then - M.clear_keymaps() - end -end - ----Select next permission -function M.select_next() - if #M._permission_queue > 1 then - M._selected_index = M._selected_index % #M._permission_queue + 1 - end -end ----Select previous permission -function M.select_prev() - if #M._permission_queue > 1 then - M._selected_index = M._selected_index == 1 and #M._permission_queue or M._selected_index - 1 + if #M._permission_queue == 0 then + M._clear_dialog() + else + M._setup_dialog() -- Setup dialog for next permission end end ----Get currently selected permission +---Get currently selected permission (always the first one now) ---@return OpencodePermission|nil function M.get_current_permission() - if M._selected_index > 0 and M._selected_index <= #M._permission_queue then - return M._permission_queue[M._selected_index] - end - return nil + return M._permission_queue[1] end ---Get permission display lines to append to output ---@param output Output function M.format_display(output) - local formatter = require('opencode.ui.formatter') - local icons = require('opencode.ui.icons') - if #M._permission_queue == 0 then - return {} + if #M._permission_queue == 0 or not M._dialog or M._processing then + return end - local keys = get_permission_keys() + local permission = M._permission_queue[1] + if not permission then + return + end - M.setup_keymaps() + local icons = require('opencode.ui.icons') + local formatter = require('opencode.ui.formatter') - for i, permission in ipairs(M._permission_queue) do - local start_line = output:get_line_count() + local progress = '' + if #M._permission_queue > 1 then + progress = string.format(' (%d/%d)', 1, #M._permission_queue) + end - output:add_line(icons.get('warning') .. ' Permission Required') - output:add_extmark(start_line, { - line_hl_group = 'OpencodePermissionTitle', - } --[[@as OutputExtmark]]) + local title = permission.title or table.concat(permission.patterns or {}, ', ') or 'Unknown Permission' + local perm_type = permission.permission or permission.type or 'unknown' - output:add_line('') + local content = { (icons.get(perm_type) or '') .. ' *' .. (perm_type or '') .. '*' .. ' `' .. title .. '`' } - local title = permission.title or table.concat(permission.patterns or {}, ', ') or 'Unknown Permission' - local perm_type = permission.permission or permission.type or 'unknown' + local options = { + { label = 'Allow once' }, + { label = 'Reject' }, + { label = 'Allow always' }, + } - output:add_line((icons.get(perm_type) or '') .. ' *' .. (perm_type or '') .. '*' .. ' `' .. title .. '`') - output:add_line('') + local render_content = nil + if perm_type == 'edit' and permission.metadata and permission.metadata.diff then + render_content = function(out) + out:add_line(content[1]) + out:add_line('') - if perm_type == 'edit' and permission.metadata.diff then local file_type = permission.metadata.filepath and vim.fn.fnamemodify(permission.metadata.filepath, ':e') or '' - formatter.format_diff(output, permission.metadata.diff, file_type) - end - - output:add_line('') - - if keys then - local actions = {} - local action_order = { 'accept', 'deny', 'accept_all' } - - for _, action in ipairs(action_order) do - local key = keys[action] - if key then - local action_label = action == 'accept' and '*Accept*' - or action == 'accept_all' and '*Always*' - or action == 'deny' and '*Deny*' - or action - - if #M._permission_queue > 1 then - table.insert(actions, string.format('%s `%s%d`', action_label, key, i)) - else - table.insert(actions, string.format('%s `%s`', action_label, key)) - end - end - end - if #actions > 0 then - output:add_line(table.concat(actions, ' ')) - end - end - - if i < #M._permission_queue then - output:add_line('') + formatter.format_diff(out, permission.metadata.diff, file_type) end - local end_line = output:get_line_count() - formatter.add_vertical_border(output, start_line + 1, end_line, 'OpencodePermissionBorder', -2) - output:add_line('') end - output:add_line('') + M._dialog:format_dialog(output, { + title = icons.get('warning') .. ' Permission Required' .. progress, + title_hl = 'OpencodePermissionTitle', + border_hl = 'OpencodePermissionBorder', + content = render_content and nil or content, + render_content = render_content, + options = options, + unfocused_message = 'Focus Opencode window to respond to permission', + }) end -function M.clear_keymaps() +function M._setup_dialog() if #M._permission_queue == 0 then + M._clear_dialog() return end - local buffers = { state.windows and state.windows.input_buf, state.windows and state.windows.output_buf } - local opencode_keys = { - accept = config.keymap.permission.accept, - accept_all = config.keymap.permission.accept_all, - deny = config.keymap.permission.deny, - } - - local editor_keys = { - accept = config.get_key_for_function('editor', 'permission_accept'), - accept_all = config.get_key_for_function('editor', 'permission_accept_all'), - deny = config.get_key_for_function('editor', 'permission_deny'), - } + M._clear_dialog() - local action_order = { 'accept', 'deny', 'accept_all' } - - local function safe_del(keymap, opts) - pcall(vim.keymap.del, 'n', keymap, opts) - end - - for i, permission in ipairs(M._permission_queue) do - for _, action in ipairs(action_order) do - local opencode_key = opencode_keys[action] - if opencode_key then - for _, buf in ipairs(buffers) do - if buf then - safe_del(opencode_key .. tostring(i), { buffer = buf }) - safe_del(opencode_key, { buffer = buf }) - end - end - end - - local editor_key = editor_keys[action] - if editor_key then - safe_del(editor_key .. tostring(i)) - safe_del(editor_key) - end - end + if not state.windows or not state.windows.output_buf then + return end -end ----Setup keymaps for all permission actions -function M.setup_keymaps() - M.clear_keymaps() + local buf = state.windows.output_buf - if #M._permission_queue == 0 then - return + local function check_focused() + local ui = require('opencode.ui.ui') + return ui.is_opencode_focused() and #M._permission_queue > 0 end - local buffers = { state.windows and state.windows.input_buf, state.windows and state.windows.output_buf } - local api = require('opencode.api') + local function on_select(index) + if not check_focused() then + return + end - local opencode_keys = { - accept = config.keymap.permission.accept, - accept_all = config.keymap.permission.accept_all, - deny = config.keymap.permission.deny, - } + local permission = M.get_current_permission() + if not permission then + return + end - local editor_keys = { - accept = config.get_key_for_function('editor', 'permission_accept'), - accept_all = config.get_key_for_function('editor', 'permission_accept_all'), - deny = config.get_key_for_function('editor', 'permission_deny'), - } + M._processing = true + require('opencode.ui.renderer').render_permissions_display() + M._clear_dialog() - local action_order = { 'accept', 'deny', 'accept_all' } + local api = require('opencode.api') + local actions = { 'accept', 'deny', 'accept_all' } + local action = actions[index] - for i, permission in ipairs(M._permission_queue) do - for _, action in ipairs(action_order) do - local api_func = api['permission_' .. action] - if api_func then - local function execute_action() - M._selected_index = i + vim.defer_fn(function() + if action then + local api_func = api['permission_' .. action] + if api_func then api_func(permission) - M.remove_permission(permission.id) end + end + M.remove_permission(permission.id) + M._processing = false + end, 50) + end - local opencode_key = opencode_keys[action] - if opencode_key then - local keymap_opts = { - silent = true, - desc = string.format('Permission %s: %s', #M._permission_queue > 1 and i or '', action), - } - - for _, buf in ipairs(buffers) do - if buf then - local buffer_opts = vim.tbl_extend('force', keymap_opts, { buffer = buf }) - - if #M._permission_queue > 1 then - vim.keymap.set('n', opencode_key .. tostring(i), execute_action, buffer_opts) - else - vim.keymap.set('n', opencode_key, execute_action, buffer_opts) - end - end - end - end + local function on_navigate() + require('opencode.ui.renderer').render_permissions_display() + end - local editor_key = editor_keys[action] - if editor_key then - local global_opts = { - silent = true, - desc = string.format('Permission %s: %s (global)', #M._permission_queue > 1 and i or '', action), - } - - if #M._permission_queue > 1 then - vim.keymap.set('n', editor_key .. tostring(i), execute_action, global_opts) - else - vim.keymap.set('n', editor_key, execute_action, global_opts) - end - end - end - end + local function get_option_count() + return #M._permission_queue > 0 and 3 or 0 -- accept, deny, accept_all + end + + M._dialog = Dialog.new({ + buffer = buf, + on_select = on_select, + on_navigate = on_navigate, + get_option_count = get_option_count, + check_focused = check_focused, + namespace_prefix = 'opencode_permission', + keymaps = { + dismiss = '', -- Disable dismiss keymap and legend + }, + }) + + M._dialog:setup() +end + +function M._clear_dialog() + if M._dialog then + M._dialog:teardown() + M._dialog = nil end end @@ -282,8 +191,8 @@ end ---Clear all permissions function M.clear_all() + M._clear_dialog() M._permission_queue = {} - M._selected_index = 1 end ---Get all permissions diff --git a/lua/opencode/ui/question_window.lua b/lua/opencode/ui/question_window.lua index ee749dff..bc4c188b 100644 --- a/lua/opencode/ui/question_window.lua +++ b/lua/opencode/ui/question_window.lua @@ -1,20 +1,14 @@ local state = require('opencode.state') local icons = require('opencode.ui.icons') +local Dialog = require('opencode.ui.dialog') local M = {} M._current_question = nil M._current_question_index = 1 M._collected_answers = {} -M._key_capture_ns = nil -M._hovered_option = 1 -M._option_line_map = {} M._answering = false -M._question_keymaps = {} - -local function toggle_input_window(show) - require('opencode.ui.input_window')[show and '_show' or '_hide']() -end +M._dialog = nil local function render_question() require('opencode.ui.renderer').render_question_display() @@ -33,22 +27,17 @@ function M.show_question(question_request) M._current_question = question_request M._current_question_index = 1 M._collected_answers = {} - M._hovered_option = 1 - M._setup_key_capture() + M._setup_dialog() render_question() - toggle_input_window(false) end function M.clear_question() - M._clear_key_capture() + M._clear_dialog() M._current_question = nil M._current_question_index = 1 M._collected_answers = {} - M._hovered_option = 1 - M._option_line_map = {} M._answering = false render_question() - toggle_input_window(true) end ---@return OpencodeQuestionInfo|nil @@ -80,11 +69,16 @@ local function answer_current_question(answer_value) M._send_reply(request.id, M._collected_answers) M.clear_question() else - M._clear_key_capture() - M._hovered_option = 1 - M._setup_key_capture() + M._answering = false + M._clear_dialog() + M._setup_dialog() end render_question() + + -- Use schedule to ensure UI updates happen after all state changes + vim.schedule(function() + require('opencode.ui.renderer').scroll_to_bottom(true) + end) end local function find_other_option(options) @@ -149,78 +143,49 @@ end ---@param output Output function M.format_display(output) - if not M.has_question() then + if not M.has_question() or M._answering then return end local question_info = M.get_current_question_info() - if not question_info then + if not question_info or not M._dialog then return end - local formatter = require('opencode.ui.formatter') - local start_line = output:get_line_count() + local icons = require('opencode.ui.icons') local progress = '' if M._current_question and #M._current_question.questions > 1 then progress = string.format(' (%d/%d)', M._current_question_index, #M._current_question.questions) end - output:add_line(icons.get('question') .. ' Question' .. progress) - output:add_extmark(start_line, { line_hl_group = 'OpencodeQuestionTitle' } --[[@as OutputExtmark]]) - output:add_line('') - output:add_line(question_info.question) - output:add_line('') - + -- Prepare options local options_to_display = add_other_if_missing(question_info.options) - local num_options = #options_to_display - - M._option_line_map = {} + local options = {} for i, option in ipairs(options_to_display) do - local label = option.label - if option.description and option.description ~= '' then - label = label .. ' - ' .. option.description - end - - local line_idx = output:get_line_count() - local line_text = M._hovered_option == i and string.format(' %d. %s ', i, label) - or string.format(' %d. %s', i, label) - - output:add_line(line_text) - M._option_line_map[i] = line_idx - - if M._hovered_option == i then - output:add_extmark(line_idx, { line_hl_group = 'OpencodeQuestionOptionHover' } --[[@as OutputExtmark]]) - output:add_extmark(line_idx, { - start_col = 2, - virt_text = { { '› ', 'OpencodeQuestionOptionHover' } }, - virt_text_pos = 'overlay', - } --[[@as OutputExtmark]]) - end - end - - output:add_line('') - - if M.has_question() and not M._answering then - local ui = require('opencode.ui.ui') - if ui.is_opencode_focused() then - output:add_line('Navigate: `j`/`k` or `↑`/`↓` Select: `` or `1-' .. num_options .. '` Dismiss: ``') - else - output:add_line('Focus Opencode window to answer question') - end + table.insert(options, { + label = option.label, + description = option.description, + }) end - local end_line = output:get_line_count() - formatter.add_vertical_border(output, start_line + 1, end_line, 'OpencodeQuestionBorder', -2) - output:add_line('') + -- Use dialog's format_dialog method + M._dialog:format_dialog(output, { + title = icons.get('question') .. ' Question' .. progress, + title_hl = 'OpencodeQuestionTitle', + border_hl = 'OpencodeQuestionBorder', + content = { question_info.question }, + options = options, + unfocused_message = 'Focus Opencode window to answer question', + }) end -function M._setup_key_capture() +function M._setup_dialog() if not M.has_question() then return end - M._clear_key_capture() + M._clear_dialog() local question_info = M.get_current_question_info() if not question_info or not state.windows or not state.windows.output_buf then @@ -228,42 +193,25 @@ function M._setup_key_capture() end local buf = state.windows.output_buf - local total_options = get_total_options(question_info) local function check_focused() local ui = require('opencode.ui.ui') return ui.is_opencode_focused() and M.has_question() end - local function navigate(delta) - return function() - if not check_focused() then - return - end - M._hovered_option = M._hovered_option + delta - if M._hovered_option < 1 then - M._hovered_option = total_options - elseif M._hovered_option > total_options then - M._hovered_option = 1 - end - render_question() - end - end - - local function select_option() + local function on_select(index) if not check_focused() then return end M._answering = true render_question() - M._clear_key_capture() + M._clear_dialog() vim.defer_fn(function() - M._answer_with_option(M._hovered_option) - M._answering = false + M._answer_with_option(index) end, 100) end - local function dismiss_question() + local function on_dismiss() if not check_focused() then return end @@ -272,51 +220,32 @@ function M._setup_key_capture() render_question() end - M._question_keymaps = { - vim.keymap.set('n', 'k', navigate(-1), { buffer = buf, silent = true }), - vim.keymap.set('n', 'j', navigate(1), { buffer = buf, silent = true }), - vim.keymap.set('n', '', navigate(-1), { buffer = buf, silent = true }), - vim.keymap.set('n', '', navigate(1), { buffer = buf, silent = true }), - vim.keymap.set('n', '', select_option, { buffer = buf, silent = true }), - vim.keymap.set('n', '', dismiss_question, { buffer = buf, silent = true }), - } - - M._key_capture_ns = vim.api.nvim_create_namespace('opencode_question_keys') - - vim.on_key(function(key, typed) - if not check_focused() or typed == '' then - return - end + local function on_navigate() + render_question() + end - if not M.has_question() then - M._clear_key_capture() - end + local function get_option_count() + local question_info = M.get_current_question_info() + return question_info and get_total_options(question_info) or 0 + end - local num = tonumber(key) - if num and num >= 1 and num <= 9 and num <= total_options then - M._answering = true - M._hovered_option = num - render_question() - M._clear_key_capture() - vim.defer_fn(function() - M._answer_with_option(num) - M._answering = false - end, 100) - end - end, M._key_capture_ns) + M._dialog = Dialog.new({ + buffer = buf, + on_select = on_select, + on_dismiss = on_dismiss, + on_navigate = on_navigate, + get_option_count = get_option_count, + check_focused = check_focused, + namespace_prefix = 'opencode_question', + }) + + M._dialog:setup() end -function M._clear_key_capture() - if M._question_keymaps then - for _, keymap_id in ipairs(M._question_keymaps) do - pcall(vim.keymap.del, 'n', keymap_id, { buffer = state.windows.output_buf }) - end - M._question_keymaps = {} - end - - if M._key_capture_ns then - vim.on_key(nil, M._key_capture_ns) - M._key_capture_ns = nil +function M._clear_dialog() + if M._dialog then + M._dialog:teardown() + M._dialog = nil end end diff --git a/lua/opencode/ui/renderer.lua b/lua/opencode/ui/renderer.lua index 35237259..6e16e010 100644 --- a/lua/opencode/ui/renderer.lua +++ b/lua/opencode/ui/renderer.lua @@ -164,7 +164,7 @@ function M._render_full_session_data(session_data) if set_mode_from_messages then M._set_model_and_mode_from_messages() end - M.scroll_to_bottom() + M.scroll_to_bottom(true) if config.hooks and config.hooks.on_session_loaded then pcall(config.hooks.on_session_loaded, state.active_session) @@ -173,6 +173,12 @@ end ---Append permissions display as a fake part at the end function M.render_permissions_display() + local permissions = permission_window.get_all_permissions() + if not permissions or #permissions == 0 then + M._remove_part_from_buffer('permission-display-part') + M._remove_message_from_buffer('permission-display-message') + return + end local fake_message = { info = { id = 'permission-display-message', @@ -190,10 +196,8 @@ function M.render_permissions_display() type = 'permissions-display', } - local permissions = permission_window.get_all_permissions() - if #permissions > 0 then - M.on_part_updated({ part = fake_part }) - end + M.on_part_updated({ part = fake_part }) + M.scroll_to_bottom(true) end function M.clear_question_display() @@ -236,18 +240,9 @@ function M.render_question_display() } M.on_part_updated({ part = fake_part }) - M._redraw() M.scroll_to_bottom(true) end -function M._redraw() - if state.windows and state.windows.output_win and vim.api.nvim_win_is_valid(state.windows.output_win) then - vim.api.nvim_win_call(state.windows.output_win, function() - vim.cmd('redraw') - end) - end -end - ---Render lines as the entire output buffer ---@param lines any function M.render_lines(lines) @@ -904,7 +899,7 @@ function M.on_permission_updated(permission) M.render_permissions_display() M._rerender_part('permission-display-part') - M.scroll_to_bottom() + M.scroll_to_bottom(true) end ---Event handler for permission.replied events diff --git a/tests/data/multiple-question-ask.expected.json b/tests/data/multiple-question-ask.expected.json index 7163e444..ca81dd9d 100644 --- a/tests/data/multiple-question-ask.expected.json +++ b/tests/data/multiple-question-ask.expected.json @@ -1 +1 @@ -{"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: ``","",""],"extmarks":[[1,1,0,{"virt_text_hide":false,"virt_text_win_col":-3,"priority":10,"virt_text_repeat_linebreak":false,"right_gravity":true,"ns_id":3,"virt_text":[["▌󰭻 ","OpencodeMessageRoleUser"],[" "],["USER","OpencodeMessageRoleUser"],["","OpencodeHint"],[" (2026-01-26 14:30:46)","OpencodeHint"],[" [msg_bfab6dbf3001ZOVHTFKR1CMUE5]","OpencodeHint"]],"virt_text_pos":"win_col"}],[2,2,0,{"virt_text_hide":false,"virt_text_win_col":-3,"priority":4096,"virt_text_repeat_linebreak":true,"right_gravity":true,"ns_id":3,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_pos":"win_col"}],[3,3,0,{"virt_text_hide":false,"virt_text_win_col":-3,"priority":4096,"virt_text_repeat_linebreak":true,"right_gravity":true,"ns_id":3,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_pos":"win_col"}],[4,4,0,{"virt_text_hide":false,"virt_text_win_col":-3,"priority":4096,"virt_text_repeat_linebreak":true,"right_gravity":true,"ns_id":3,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_pos":"win_col"}],[5,5,0,{"virt_text_hide":false,"virt_text_win_col":-3,"priority":4096,"virt_text_repeat_linebreak":true,"right_gravity":true,"ns_id":3,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_pos":"win_col"}],[6,6,0,{"virt_text_hide":false,"virt_text_win_col":-3,"priority":4096,"virt_text_repeat_linebreak":true,"right_gravity":true,"ns_id":3,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_pos":"win_col"}],[7,8,0,{"virt_text_hide":false,"virt_text_win_col":-3,"priority":10,"virt_text_repeat_linebreak":false,"right_gravity":true,"ns_id":3,"virt_text":[[" ","OpencodeMessageRoleAssistant"],[" "],["BUILD","OpencodeMessageRoleAssistant"],[" gpt-4.1","OpencodeHint"],[" (2026-01-26 14:30:46)","OpencodeHint"],[" [msg_bfab6dc80001FueCN7E2691J2R]","OpencodeHint"]],"virt_text_pos":"win_col"}],[8,13,0,{"virt_text_hide":false,"virt_text_win_col":-3,"priority":10,"virt_text_repeat_linebreak":false,"right_gravity":true,"ns_id":3,"virt_text":[[" ","OpencodeMessageRoleSystem"],[" "],["SYSTEM","OpencodeMessageRoleSystem"],["","OpencodeHint"],["","OpencodeHint"],[" [question-display-message]","OpencodeHint"]],"virt_text_pos":"win_col"}],[9,15,0,{"right_gravity":true,"ns_id":3,"priority":4096,"line_hl_group":"OpencodeQuestionTitle"}],[10,15,0,{"virt_text_hide":false,"virt_text_win_col":-2,"priority":4096,"virt_text_repeat_linebreak":true,"right_gravity":true,"ns_id":3,"virt_text":[["▌","OpencodeQuestionBorder"]],"virt_text_pos":"win_col"}],[11,16,0,{"virt_text_hide":false,"virt_text_win_col":-2,"priority":4096,"virt_text_repeat_linebreak":true,"right_gravity":true,"ns_id":3,"virt_text":[["▌","OpencodeQuestionBorder"]],"virt_text_pos":"win_col"}],[12,17,0,{"virt_text_hide":false,"virt_text_win_col":-2,"priority":4096,"virt_text_repeat_linebreak":true,"right_gravity":true,"ns_id":3,"virt_text":[["▌","OpencodeQuestionBorder"]],"virt_text_pos":"win_col"}],[13,18,0,{"virt_text_hide":false,"virt_text_win_col":-2,"priority":4096,"virt_text_repeat_linebreak":true,"right_gravity":true,"ns_id":3,"virt_text":[["▌","OpencodeQuestionBorder"]],"virt_text_pos":"win_col"}],[14,19,0,{"right_gravity":true,"ns_id":3,"priority":4096,"line_hl_group":"OpencodeQuestionOptionHover"}],[15,19,0,{"virt_text_hide":false,"virt_text_win_col":-2,"priority":4096,"virt_text_repeat_linebreak":true,"right_gravity":true,"ns_id":3,"virt_text":[["▌","OpencodeQuestionBorder"]],"virt_text_pos":"win_col"}],[16,19,2,{"virt_text_hide":false,"priority":4096,"virt_text_repeat_linebreak":false,"right_gravity":true,"ns_id":3,"virt_text":[["› ","OpencodeQuestionOptionHover"]],"virt_text_pos":"overlay"}],[17,20,0,{"virt_text_hide":false,"virt_text_win_col":-2,"priority":4096,"virt_text_repeat_linebreak":true,"right_gravity":true,"ns_id":3,"virt_text":[["▌","OpencodeQuestionBorder"]],"virt_text_pos":"win_col"}],[18,21,0,{"virt_text_hide":false,"virt_text_win_col":-2,"priority":4096,"virt_text_repeat_linebreak":true,"right_gravity":true,"ns_id":3,"virt_text":[["▌","OpencodeQuestionBorder"]],"virt_text_pos":"win_col"}],[19,22,0,{"virt_text_hide":false,"virt_text_win_col":-2,"priority":4096,"virt_text_repeat_linebreak":true,"right_gravity":true,"ns_id":3,"virt_text":[["▌","OpencodeQuestionBorder"]],"virt_text_pos":"win_col"}],[20,23,0,{"virt_text_hide":false,"virt_text_win_col":-2,"priority":4096,"virt_text_repeat_linebreak":true,"right_gravity":true,"ns_id":3,"virt_text":[["▌","OpencodeQuestionBorder"]],"virt_text_pos":"win_col"}]],"actions":[],"timestamp":1769438058} \ No newline at end of file +{"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: ``","",""],"actions":[],"extmarks":[[1,1,0,{"virt_text_win_col":-3,"priority":10,"virt_text_hide":false,"right_gravity":true,"virt_text_repeat_linebreak":false,"virt_text_pos":"win_col","virt_text":[["▌󰭻 ","OpencodeMessageRoleUser"],[" "],["USER","OpencodeMessageRoleUser"],["","OpencodeHint"],[" (2026-01-26 14:30:46)","OpencodeHint"],[" [msg_bfab6dbf3001ZOVHTFKR1CMUE5]","OpencodeHint"]],"ns_id":3}],[2,2,0,{"virt_text_win_col":-3,"priority":4096,"virt_text_hide":false,"right_gravity":true,"virt_text_repeat_linebreak":true,"virt_text_pos":"win_col","virt_text":[["▌","OpencodeMessageRoleUser"]],"ns_id":3}],[3,3,0,{"virt_text_win_col":-3,"priority":4096,"virt_text_hide":false,"right_gravity":true,"virt_text_repeat_linebreak":true,"virt_text_pos":"win_col","virt_text":[["▌","OpencodeMessageRoleUser"]],"ns_id":3}],[4,4,0,{"virt_text_win_col":-3,"priority":4096,"virt_text_hide":false,"right_gravity":true,"virt_text_repeat_linebreak":true,"virt_text_pos":"win_col","virt_text":[["▌","OpencodeMessageRoleUser"]],"ns_id":3}],[5,5,0,{"virt_text_win_col":-3,"priority":4096,"virt_text_hide":false,"right_gravity":true,"virt_text_repeat_linebreak":true,"virt_text_pos":"win_col","virt_text":[["▌","OpencodeMessageRoleUser"]],"ns_id":3}],[6,6,0,{"virt_text_win_col":-3,"priority":4096,"virt_text_hide":false,"right_gravity":true,"virt_text_repeat_linebreak":true,"virt_text_pos":"win_col","virt_text":[["▌","OpencodeMessageRoleUser"]],"ns_id":3}],[7,8,0,{"virt_text_win_col":-3,"priority":10,"virt_text_hide":false,"right_gravity":true,"virt_text_repeat_linebreak":false,"virt_text_pos":"win_col","virt_text":[[" ","OpencodeMessageRoleAssistant"],[" "],["BUILD","OpencodeMessageRoleAssistant"],[" gpt-4.1","OpencodeHint"],[" (2026-01-26 14:30:46)","OpencodeHint"],[" [msg_bfab6dc80001FueCN7E2691J2R]","OpencodeHint"]],"ns_id":3}],[8,13,0,{"virt_text_win_col":-3,"priority":10,"virt_text_hide":false,"right_gravity":true,"virt_text_repeat_linebreak":false,"virt_text_pos":"win_col","virt_text":[[" ","OpencodeMessageRoleSystem"],[" "],["SYSTEM","OpencodeMessageRoleSystem"],["","OpencodeHint"],["","OpencodeHint"],[" [question-display-message]","OpencodeHint"]],"ns_id":3}],[9,15,0,{"line_hl_group":"OpencodeQuestionTitle","priority":4096,"right_gravity":true,"ns_id":3}],[10,15,0,{"virt_text_win_col":-2,"priority":4096,"virt_text_hide":false,"right_gravity":true,"virt_text_repeat_linebreak":true,"virt_text_pos":"win_col","virt_text":[["▌","OpencodeQuestionBorder"]],"ns_id":3}],[11,16,0,{"virt_text_win_col":-2,"priority":4096,"virt_text_hide":false,"right_gravity":true,"virt_text_repeat_linebreak":true,"virt_text_pos":"win_col","virt_text":[["▌","OpencodeQuestionBorder"]],"ns_id":3}],[12,17,0,{"virt_text_win_col":-2,"priority":4096,"virt_text_hide":false,"right_gravity":true,"virt_text_repeat_linebreak":true,"virt_text_pos":"win_col","virt_text":[["▌","OpencodeQuestionBorder"]],"ns_id":3}],[13,18,0,{"virt_text_win_col":-2,"priority":4096,"virt_text_hide":false,"right_gravity":true,"virt_text_repeat_linebreak":true,"virt_text_pos":"win_col","virt_text":[["▌","OpencodeQuestionBorder"]],"ns_id":3}],[14,19,0,{"line_hl_group":"OpencodeDialogOptionHover","priority":4096,"right_gravity":true,"ns_id":3}],[15,19,0,{"virt_text_win_col":-2,"priority":4096,"virt_text_hide":false,"right_gravity":true,"virt_text_repeat_linebreak":true,"virt_text_pos":"win_col","virt_text":[["▌","OpencodeQuestionBorder"]],"ns_id":3}],[16,19,2,{"priority":4096,"virt_text_hide":false,"right_gravity":true,"virt_text_repeat_linebreak":false,"virt_text_pos":"overlay","virt_text":[["› ","OpencodeDialogOptionHover"]],"ns_id":3}],[17,20,0,{"virt_text_win_col":-2,"priority":4096,"virt_text_hide":false,"right_gravity":true,"virt_text_repeat_linebreak":true,"virt_text_pos":"win_col","virt_text":[["▌","OpencodeQuestionBorder"]],"ns_id":3}],[18,21,0,{"virt_text_win_col":-2,"priority":4096,"virt_text_hide":false,"right_gravity":true,"virt_text_repeat_linebreak":true,"virt_text_pos":"win_col","virt_text":[["▌","OpencodeQuestionBorder"]],"ns_id":3}],[19,22,0,{"virt_text_win_col":-2,"priority":4096,"virt_text_hide":false,"right_gravity":true,"virt_text_repeat_linebreak":true,"virt_text_pos":"win_col","virt_text":[["▌","OpencodeQuestionBorder"]],"ns_id":3}],[20,23,0,{"virt_text_win_col":-2,"priority":4096,"virt_text_hide":false,"right_gravity":true,"virt_text_repeat_linebreak":true,"virt_text_pos":"win_col","virt_text":[["▌","OpencodeQuestionBorder"]],"ns_id":3}]],"timestamp":1769441953} \ No newline at end of file diff --git a/tests/data/permission-ask-new.expected.json b/tests/data/permission-ask-new.expected.json index a33cf72b..06d1b932 100644 --- a/tests/data/permission-ask-new.expected.json +++ b/tests/data/permission-ask-new.expected.json @@ -1 +1 @@ -{"timestamp":1768833733,"lines":["----","","","@no-trust run a git status and tell me what files are changed","","[lua/opencode/ui/formatter.lua](lua/opencode/ui/formatter.lua)","","**Diagnostics:**  (11)","","----","","","** run** `Shows working tree status`","","`````bash","> git status","","`````","","----","",""," Permission Required",""," *bash* `git status`","","","*Accept* `a` *Deny* `d` *Always* `A`","","",""],"actions":[],"extmarks":[[1,1,0,{"priority":10,"virt_text_repeat_linebreak":false,"virt_text":[["▌󰭻 ","OpencodeMessageRoleUser"],[" "],["USER","OpencodeMessageRoleUser"],["","OpencodeHint"],[" (2026-01-05 14:07:54)","OpencodeHint"],[" [msg_b8e7c60a2001Kisjwk2mVB4dye]","OpencodeHint"]],"right_gravity":true,"ns_id":3,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_win_col":-3}],[2,2,0,{"priority":4096,"virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodeMessageRoleUser"]],"right_gravity":true,"ns_id":3,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_win_col":-3}],[3,3,0,{"priority":4096,"virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodeMessageRoleUser"]],"right_gravity":true,"ns_id":3,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_win_col":-3}],[4,4,0,{"priority":4096,"virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodeMessageRoleUser"]],"right_gravity":true,"ns_id":3,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_win_col":-3}],[5,5,0,{"priority":4096,"virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodeMessageRoleUser"]],"right_gravity":true,"ns_id":3,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_win_col":-3}],[6,6,0,{"priority":4096,"virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodeMessageRoleUser"]],"right_gravity":true,"ns_id":3,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_win_col":-3}],[7,7,0,{"priority":4096,"virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodeMessageRoleUser"]],"right_gravity":true,"ns_id":3,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_win_col":-3}],[8,8,0,{"priority":4096,"virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodeMessageRoleUser"]],"right_gravity":true,"ns_id":3,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_win_col":-3}],[9,10,0,{"priority":10,"virt_text_repeat_linebreak":false,"virt_text":[[" ","OpencodeMessageRoleAssistant"],[" "],["BUILD","OpencodeMessageRoleAssistant"],[" gpt-4.1","OpencodeHint"],[" (2026-01-05 14:07:54)","OpencodeHint"],[" [msg_b8e7c60f1001aEWYlAaDRXQ4aJ]","OpencodeHint"]],"right_gravity":true,"ns_id":3,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_win_col":-3}],[10,12,0,{"priority":4096,"virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodeToolBorder"]],"right_gravity":true,"ns_id":3,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_win_col":-1}],[11,13,0,{"priority":4096,"virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodeToolBorder"]],"right_gravity":true,"ns_id":3,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_win_col":-1}],[12,14,0,{"priority":4096,"virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodeToolBorder"]],"right_gravity":true,"ns_id":3,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_win_col":-1}],[13,15,0,{"priority":4096,"virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodeToolBorder"]],"right_gravity":true,"ns_id":3,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_win_col":-1}],[14,16,0,{"priority":4096,"virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodeToolBorder"]],"right_gravity":true,"ns_id":3,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_win_col":-1}],[15,17,0,{"priority":4096,"virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodeToolBorder"]],"right_gravity":true,"ns_id":3,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_win_col":-1}],[16,20,0,{"priority":10,"virt_text_repeat_linebreak":false,"virt_text":[[" ","OpencodeMessageRoleSystem"],[" "],["SYSTEM","OpencodeMessageRoleSystem"],["","OpencodeHint"],["","OpencodeHint"],[" [permission-display-message]","OpencodeHint"]],"right_gravity":true,"ns_id":3,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_win_col":-3}],[17,22,0,{"ns_id":3,"right_gravity":true,"priority":4096,"line_hl_group":"OpencodePermissionTitle"}],[18,22,0,{"priority":4096,"virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"right_gravity":true,"ns_id":3,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_win_col":-2}],[19,23,0,{"priority":4096,"virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"right_gravity":true,"ns_id":3,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_win_col":-2}],[20,24,0,{"priority":4096,"virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"right_gravity":true,"ns_id":3,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_win_col":-2}],[21,25,0,{"priority":4096,"virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"right_gravity":true,"ns_id":3,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_win_col":-2}],[22,26,0,{"priority":4096,"virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"right_gravity":true,"ns_id":3,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_win_col":-2}],[23,27,0,{"priority":4096,"virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"right_gravity":true,"ns_id":3,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_win_col":-2}]]} \ No newline at end of file +{"extmarks":[[1,1,0,{"virt_text_repeat_linebreak":false,"right_gravity":true,"virt_text":[["▌󰭻 ","OpencodeMessageRoleUser"],[" "],["USER","OpencodeMessageRoleUser"],["","OpencodeHint"],[" (2026-01-05 14:07:54)","OpencodeHint"],[" [msg_b8e7c60a2001Kisjwk2mVB4dye]","OpencodeHint"]],"ns_id":3,"virt_text_win_col":-3,"priority":10,"virt_text_hide":false,"virt_text_pos":"win_col"}],[2,2,0,{"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text":[["▌","OpencodeMessageRoleUser"]],"ns_id":3,"virt_text_win_col":-3,"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col"}],[3,3,0,{"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text":[["▌","OpencodeMessageRoleUser"]],"ns_id":3,"virt_text_win_col":-3,"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col"}],[4,4,0,{"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text":[["▌","OpencodeMessageRoleUser"]],"ns_id":3,"virt_text_win_col":-3,"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col"}],[5,5,0,{"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text":[["▌","OpencodeMessageRoleUser"]],"ns_id":3,"virt_text_win_col":-3,"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col"}],[6,6,0,{"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text":[["▌","OpencodeMessageRoleUser"]],"ns_id":3,"virt_text_win_col":-3,"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col"}],[7,7,0,{"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text":[["▌","OpencodeMessageRoleUser"]],"ns_id":3,"virt_text_win_col":-3,"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col"}],[8,8,0,{"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text":[["▌","OpencodeMessageRoleUser"]],"ns_id":3,"virt_text_win_col":-3,"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col"}],[9,10,0,{"virt_text_repeat_linebreak":false,"right_gravity":true,"virt_text":[[" ","OpencodeMessageRoleAssistant"],[" "],["BUILD","OpencodeMessageRoleAssistant"],[" gpt-4.1","OpencodeHint"],[" (2026-01-05 14:07:54)","OpencodeHint"],[" [msg_b8e7c60f1001aEWYlAaDRXQ4aJ]","OpencodeHint"]],"ns_id":3,"virt_text_win_col":-3,"priority":10,"virt_text_hide":false,"virt_text_pos":"win_col"}],[10,12,0,{"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text":[["▌","OpencodeToolBorder"]],"ns_id":3,"virt_text_win_col":-1,"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col"}],[11,13,0,{"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text":[["▌","OpencodeToolBorder"]],"ns_id":3,"virt_text_win_col":-1,"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col"}],[12,14,0,{"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text":[["▌","OpencodeToolBorder"]],"ns_id":3,"virt_text_win_col":-1,"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col"}],[13,15,0,{"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text":[["▌","OpencodeToolBorder"]],"ns_id":3,"virt_text_win_col":-1,"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col"}],[14,16,0,{"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text":[["▌","OpencodeToolBorder"]],"ns_id":3,"virt_text_win_col":-1,"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col"}],[15,17,0,{"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text":[["▌","OpencodeToolBorder"]],"ns_id":3,"virt_text_win_col":-1,"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col"}],[16,20,0,{"virt_text_repeat_linebreak":false,"right_gravity":true,"virt_text":[[" ","OpencodeMessageRoleSystem"],[" "],["SYSTEM","OpencodeMessageRoleSystem"],["","OpencodeHint"],["","OpencodeHint"],[" [permission-display-message]","OpencodeHint"]],"ns_id":3,"virt_text_win_col":-3,"priority":10,"virt_text_hide":false,"virt_text_pos":"win_col"}],[17,22,0,{"line_hl_group":"OpencodePermissionTitle","right_gravity":true,"priority":4096,"ns_id":3}],[18,22,0,{"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col"}],[19,23,0,{"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col"}],[20,24,0,{"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col"}],[21,25,0,{"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col"}],[22,26,0,{"line_hl_group":"OpencodeDialogOptionHover","right_gravity":true,"priority":4096,"ns_id":3}],[23,26,0,{"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col"}],[24,26,2,{"virt_text_repeat_linebreak":false,"right_gravity":true,"virt_text":[["› ","OpencodeDialogOptionHover"]],"ns_id":3,"priority":4096,"virt_text_hide":false,"virt_text_pos":"overlay"}],[25,27,0,{"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col"}],[26,28,0,{"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col"}],[27,29,0,{"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col"}],[28,30,0,{"virt_text_repeat_linebreak":true,"right_gravity":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col"}]],"actions":[],"lines":["----","","","@no-trust run a git status and tell me what files are changed","","[lua/opencode/ui/formatter.lua](lua/opencode/ui/formatter.lua)","","**Diagnostics:**  (11)","","----","","","** run** `Shows working tree status`","","`````bash","> git status","","`````","","----","",""," Permission Required",""," *bash* `git status`",""," 1. Allow once "," 2. Reject"," 3. Allow always","","Navigate: `j`/`k` or `↑`/`↓` Select: `` or `1-3`","",""],"timestamp":1769449848} \ No newline at end of file diff --git a/tests/data/permission-prompt.expected.json b/tests/data/permission-prompt.expected.json index 01fb8578..c4a91d6b 100644 --- a/tests/data/permission-prompt.expected.json +++ b/tests/data/permission-prompt.expected.json @@ -1 +1 @@ -{"timestamp":1768833669,"actions":[],"extmarks":[[1,1,0,{"right_gravity":true,"virt_text_pos":"win_col","virt_text_repeat_linebreak":false,"ns_id":3,"virt_text":[[" ","OpencodeMessageRoleAssistant"],[" "],["PLAN","OpencodeMessageRoleAssistant"],[" claude-sonnet-4.5","OpencodeHint"],[" (2025-10-16 04:27:36)","OpencodeHint"],[" [msg_9eb45fbe60020xE560OGH3Vdoo]","OpencodeHint"]],"virt_text_hide":false,"virt_text_win_col":-3,"priority":10}],[2,5,0,{"right_gravity":true,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"ns_id":3,"virt_text":[["▌","OpencodeToolBorder"]],"virt_text_hide":false,"virt_text_win_col":-1,"priority":4096}],[3,6,0,{"right_gravity":true,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"ns_id":3,"virt_text":[["▌","OpencodeToolBorder"]],"virt_text_hide":false,"virt_text_win_col":-1,"priority":4096}],[4,7,0,{"right_gravity":true,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"ns_id":3,"virt_text":[["▌","OpencodeToolBorder"]],"virt_text_hide":false,"virt_text_win_col":-1,"priority":4096}],[5,8,0,{"right_gravity":true,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"ns_id":3,"virt_text":[["▌","OpencodeToolBorder"]],"virt_text_hide":false,"virt_text_win_col":-1,"priority":4096}],[6,9,0,{"right_gravity":true,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"ns_id":3,"virt_text":[["▌","OpencodeToolBorder"]],"virt_text_hide":false,"virt_text_win_col":-1,"priority":4096}],[7,10,0,{"right_gravity":true,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"ns_id":3,"virt_text":[["▌","OpencodeToolBorder"]],"virt_text_hide":false,"virt_text_win_col":-1,"priority":4096}],[8,13,0,{"right_gravity":true,"virt_text_pos":"win_col","virt_text_repeat_linebreak":false,"ns_id":3,"virt_text":[[" ","OpencodeMessageRoleSystem"],[" "],["SYSTEM","OpencodeMessageRoleSystem"],["","OpencodeHint"],["","OpencodeHint"],[" [permission-display-message]","OpencodeHint"]],"virt_text_hide":false,"virt_text_win_col":-3,"priority":10}],[9,15,0,{"right_gravity":true,"ns_id":3,"line_hl_group":"OpencodePermissionTitle","priority":4096}],[10,15,0,{"right_gravity":true,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"ns_id":3,"virt_text":[["▌","OpencodePermissionBorder"]],"virt_text_hide":false,"virt_text_win_col":-2,"priority":4096}],[11,16,0,{"right_gravity":true,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"ns_id":3,"virt_text":[["▌","OpencodePermissionBorder"]],"virt_text_hide":false,"virt_text_win_col":-2,"priority":4096}],[12,17,0,{"right_gravity":true,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"ns_id":3,"virt_text":[["▌","OpencodePermissionBorder"]],"virt_text_hide":false,"virt_text_win_col":-2,"priority":4096}],[13,18,0,{"right_gravity":true,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"ns_id":3,"virt_text":[["▌","OpencodePermissionBorder"]],"virt_text_hide":false,"virt_text_win_col":-2,"priority":4096}],[14,19,0,{"right_gravity":true,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"ns_id":3,"virt_text":[["▌","OpencodePermissionBorder"]],"virt_text_hide":false,"virt_text_win_col":-2,"priority":4096}],[15,20,0,{"right_gravity":true,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"ns_id":3,"virt_text":[["▌","OpencodePermissionBorder"]],"virt_text_hide":false,"virt_text_win_col":-2,"priority":4096}]],"lines":["----","","","Perfect! Now I understand how it works. The message headers have extmarks with `virt_text` where the first element contains the icon (either `header_user` or `header_assistant`). Let me check the output_window module to understand the extmark namespace:","","** run** `Find extmark namespace usage`","","`````bash","> rg \"nvim_buf_get_extmarks|ns_id\" /Users/cam/Dev/neovim-dev/opencode.nvim/lua/opencode/ui/output_window.lua -B 2 -A 2","","`````","","----","",""," Permission Required",""," *bash* `rg \"nvim_buf_get_extmarks|ns_id\" /Users/cam/Dev/neovim-dev/opencode.nvim/lua/opencode/ui/output_window.lua -B 2 -A 2`","","","*Accept* `a` *Deny* `d` *Always* `A`","","",""]} \ No newline at end of file +{"lines":["----","","","Perfect! Now I understand how it works. The message headers have extmarks with `virt_text` where the first element contains the icon (either `header_user` or `header_assistant`). Let me check the output_window module to understand the extmark namespace:","","** run** `Find extmark namespace usage`","","`````bash","> rg \"nvim_buf_get_extmarks|ns_id\" /Users/cam/Dev/neovim-dev/opencode.nvim/lua/opencode/ui/output_window.lua -B 2 -A 2","","`````","","----","",""," Permission Required",""," *bash* `rg \"nvim_buf_get_extmarks|ns_id\" /Users/cam/Dev/neovim-dev/opencode.nvim/lua/opencode/ui/output_window.lua -B 2 -A 2`",""," 1. Allow once "," 2. Reject"," 3. Allow always","","Navigate: `j`/`k` or `↑`/`↓` Select: `` or `1-3`","",""],"extmarks":[[1,1,0,{"virt_text":[[" ","OpencodeMessageRoleAssistant"],[" "],["PLAN","OpencodeMessageRoleAssistant"],[" claude-sonnet-4.5","OpencodeHint"],[" (2025-10-16 04:27:36)","OpencodeHint"],[" [msg_9eb45fbe60020xE560OGH3Vdoo]","OpencodeHint"]],"virt_text_pos":"win_col","ns_id":3,"right_gravity":true,"virt_text_hide":false,"priority":10,"virt_text_repeat_linebreak":false,"virt_text_win_col":-3}],[2,5,0,{"virt_text":[["▌","OpencodeToolBorder"]],"virt_text_pos":"win_col","ns_id":3,"right_gravity":true,"virt_text_hide":false,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_win_col":-1}],[3,6,0,{"virt_text":[["▌","OpencodeToolBorder"]],"virt_text_pos":"win_col","ns_id":3,"right_gravity":true,"virt_text_hide":false,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_win_col":-1}],[4,7,0,{"virt_text":[["▌","OpencodeToolBorder"]],"virt_text_pos":"win_col","ns_id":3,"right_gravity":true,"virt_text_hide":false,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_win_col":-1}],[5,8,0,{"virt_text":[["▌","OpencodeToolBorder"]],"virt_text_pos":"win_col","ns_id":3,"right_gravity":true,"virt_text_hide":false,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_win_col":-1}],[6,9,0,{"virt_text":[["▌","OpencodeToolBorder"]],"virt_text_pos":"win_col","ns_id":3,"right_gravity":true,"virt_text_hide":false,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_win_col":-1}],[7,10,0,{"virt_text":[["▌","OpencodeToolBorder"]],"virt_text_pos":"win_col","ns_id":3,"right_gravity":true,"virt_text_hide":false,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_win_col":-1}],[8,13,0,{"virt_text":[[" ","OpencodeMessageRoleSystem"],[" "],["SYSTEM","OpencodeMessageRoleSystem"],["","OpencodeHint"],["","OpencodeHint"],[" [permission-display-message]","OpencodeHint"]],"virt_text_pos":"win_col","ns_id":3,"right_gravity":true,"virt_text_hide":false,"priority":10,"virt_text_repeat_linebreak":false,"virt_text_win_col":-3}],[9,15,0,{"priority":4096,"line_hl_group":"OpencodePermissionTitle","ns_id":3,"right_gravity":true}],[10,15,0,{"virt_text":[["▌","OpencodePermissionBorder"]],"virt_text_pos":"win_col","ns_id":3,"right_gravity":true,"virt_text_hide":false,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_win_col":-2}],[11,16,0,{"virt_text":[["▌","OpencodePermissionBorder"]],"virt_text_pos":"win_col","ns_id":3,"right_gravity":true,"virt_text_hide":false,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_win_col":-2}],[12,17,0,{"virt_text":[["▌","OpencodePermissionBorder"]],"virt_text_pos":"win_col","ns_id":3,"right_gravity":true,"virt_text_hide":false,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_win_col":-2}],[13,18,0,{"virt_text":[["▌","OpencodePermissionBorder"]],"virt_text_pos":"win_col","ns_id":3,"right_gravity":true,"virt_text_hide":false,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_win_col":-2}],[14,19,0,{"priority":4096,"line_hl_group":"OpencodeDialogOptionHover","ns_id":3,"right_gravity":true}],[15,19,0,{"virt_text":[["▌","OpencodePermissionBorder"]],"virt_text_pos":"win_col","ns_id":3,"right_gravity":true,"virt_text_hide":false,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_win_col":-2}],[16,19,2,{"virt_text":[["› ","OpencodeDialogOptionHover"]],"virt_text_pos":"overlay","ns_id":3,"right_gravity":true,"virt_text_hide":false,"virt_text_repeat_linebreak":false,"priority":4096}],[17,20,0,{"virt_text":[["▌","OpencodePermissionBorder"]],"virt_text_pos":"win_col","ns_id":3,"right_gravity":true,"virt_text_hide":false,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_win_col":-2}],[18,21,0,{"virt_text":[["▌","OpencodePermissionBorder"]],"virt_text_pos":"win_col","ns_id":3,"right_gravity":true,"virt_text_hide":false,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_win_col":-2}],[19,22,0,{"virt_text":[["▌","OpencodePermissionBorder"]],"virt_text_pos":"win_col","ns_id":3,"right_gravity":true,"virt_text_hide":false,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_win_col":-2}],[20,23,0,{"virt_text":[["▌","OpencodePermissionBorder"]],"virt_text_pos":"win_col","ns_id":3,"right_gravity":true,"virt_text_hide":false,"priority":4096,"virt_text_repeat_linebreak":true,"virt_text_win_col":-2}]],"timestamp":1769449859,"actions":[]} \ No newline at end of file diff --git a/tests/data/question-ask.expected.json b/tests/data/question-ask.expected.json index ee078621..47cf19be 100644 --- a/tests/data/question-ask.expected.json +++ b/tests/data/question-ask.expected.json @@ -1 +1 @@ -{"timestamp":1769437004,"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: ``","",""],"extmarks":[[1,1,0,{"right_gravity":true,"virt_text_hide":false,"virt_text_repeat_linebreak":false,"priority":10,"ns_id":3,"virt_text_win_col":-3,"virt_text":[["▌󰭻 ","OpencodeMessageRoleUser"],[" "],["USER","OpencodeMessageRoleUser"],["","OpencodeHint"],[" (2026-01-26 14:06:19)","OpencodeHint"],[" [msg_bfaa078a2001RUYhlKZhSlyWeF]","OpencodeHint"]],"virt_text_pos":"win_col"}],[2,2,0,{"right_gravity":true,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"priority":4096,"ns_id":3,"virt_text_win_col":-3,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_pos":"win_col"}],[3,3,0,{"right_gravity":true,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"priority":4096,"ns_id":3,"virt_text_win_col":-3,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_pos":"win_col"}],[4,4,0,{"right_gravity":true,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"priority":4096,"ns_id":3,"virt_text_win_col":-3,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_pos":"win_col"}],[5,5,0,{"right_gravity":true,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"priority":4096,"ns_id":3,"virt_text_win_col":-3,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_pos":"win_col"}],[6,6,0,{"right_gravity":true,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"priority":4096,"ns_id":3,"virt_text_win_col":-3,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_pos":"win_col"}],[7,8,0,{"right_gravity":true,"virt_text_hide":false,"virt_text_repeat_linebreak":false,"priority":10,"ns_id":3,"virt_text_win_col":-3,"virt_text":[[" ","OpencodeMessageRoleAssistant"],[" "],["BUILD","OpencodeMessageRoleAssistant"],[" gpt-4.1","OpencodeHint"],[" (2026-01-26 14:06:19)","OpencodeHint"],[" [msg_bfaa078e7001qc33BqBlqzHv8X]","OpencodeHint"]],"virt_text_pos":"win_col"}],[8,13,0,{"right_gravity":true,"virt_text_hide":false,"virt_text_repeat_linebreak":false,"priority":10,"ns_id":3,"virt_text_win_col":-3,"virt_text":[[" ","OpencodeMessageRoleSystem"],[" "],["SYSTEM","OpencodeMessageRoleSystem"],["","OpencodeHint"],["","OpencodeHint"],[" [question-display-message]","OpencodeHint"]],"virt_text_pos":"win_col"}],[9,15,0,{"ns_id":3,"priority":4096,"right_gravity":true,"line_hl_group":"OpencodeQuestionTitle"}],[10,15,0,{"right_gravity":true,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"priority":4096,"ns_id":3,"virt_text_win_col":-2,"virt_text":[["▌","OpencodeQuestionBorder"]],"virt_text_pos":"win_col"}],[11,16,0,{"right_gravity":true,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"priority":4096,"ns_id":3,"virt_text_win_col":-2,"virt_text":[["▌","OpencodeQuestionBorder"]],"virt_text_pos":"win_col"}],[12,17,0,{"right_gravity":true,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"priority":4096,"ns_id":3,"virt_text_win_col":-2,"virt_text":[["▌","OpencodeQuestionBorder"]],"virt_text_pos":"win_col"}],[13,18,0,{"right_gravity":true,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"priority":4096,"ns_id":3,"virt_text_win_col":-2,"virt_text":[["▌","OpencodeQuestionBorder"]],"virt_text_pos":"win_col"}],[14,19,0,{"ns_id":3,"priority":4096,"right_gravity":true,"line_hl_group":"OpencodeQuestionOptionHover"}],[15,19,0,{"right_gravity":true,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"priority":4096,"ns_id":3,"virt_text_win_col":-2,"virt_text":[["▌","OpencodeQuestionBorder"]],"virt_text_pos":"win_col"}],[16,19,2,{"virt_text_hide":false,"virt_text_repeat_linebreak":false,"right_gravity":true,"ns_id":3,"priority":4096,"virt_text":[["› ","OpencodeQuestionOptionHover"]],"virt_text_pos":"overlay"}],[17,20,0,{"right_gravity":true,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"priority":4096,"ns_id":3,"virt_text_win_col":-2,"virt_text":[["▌","OpencodeQuestionBorder"]],"virt_text_pos":"win_col"}],[18,21,0,{"right_gravity":true,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"priority":4096,"ns_id":3,"virt_text_win_col":-2,"virt_text":[["▌","OpencodeQuestionBorder"]],"virt_text_pos":"win_col"}],[19,22,0,{"right_gravity":true,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"priority":4096,"ns_id":3,"virt_text_win_col":-2,"virt_text":[["▌","OpencodeQuestionBorder"]],"virt_text_pos":"win_col"}],[20,23,0,{"right_gravity":true,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"priority":4096,"ns_id":3,"virt_text_win_col":-2,"virt_text":[["▌","OpencodeQuestionBorder"]],"virt_text_pos":"win_col"}]],"actions":[]} \ No newline at end of file +{"actions":[],"extmarks":[[1,1,0,{"right_gravity":true,"virt_text_win_col":-3,"virt_text":[["▌󰭻 ","OpencodeMessageRoleUser"],[" "],["USER","OpencodeMessageRoleUser"],["","OpencodeHint"],[" (2026-01-26 14:06:19)","OpencodeHint"],[" [msg_bfaa078a2001RUYhlKZhSlyWeF]","OpencodeHint"]],"ns_id":3,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":false,"priority":10}],[2,2,0,{"right_gravity":true,"virt_text_win_col":-3,"virt_text":[["▌","OpencodeMessageRoleUser"]],"ns_id":3,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"priority":4096}],[3,3,0,{"right_gravity":true,"virt_text_win_col":-3,"virt_text":[["▌","OpencodeMessageRoleUser"]],"ns_id":3,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"priority":4096}],[4,4,0,{"right_gravity":true,"virt_text_win_col":-3,"virt_text":[["▌","OpencodeMessageRoleUser"]],"ns_id":3,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"priority":4096}],[5,5,0,{"right_gravity":true,"virt_text_win_col":-3,"virt_text":[["▌","OpencodeMessageRoleUser"]],"ns_id":3,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"priority":4096}],[6,6,0,{"right_gravity":true,"virt_text_win_col":-3,"virt_text":[["▌","OpencodeMessageRoleUser"]],"ns_id":3,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"priority":4096}],[7,8,0,{"right_gravity":true,"virt_text_win_col":-3,"virt_text":[[" ","OpencodeMessageRoleAssistant"],[" "],["BUILD","OpencodeMessageRoleAssistant"],[" gpt-4.1","OpencodeHint"],[" (2026-01-26 14:06:19)","OpencodeHint"],[" [msg_bfaa078e7001qc33BqBlqzHv8X]","OpencodeHint"]],"ns_id":3,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":false,"priority":10}],[8,13,0,{"right_gravity":true,"virt_text_win_col":-3,"virt_text":[[" ","OpencodeMessageRoleSystem"],[" "],["SYSTEM","OpencodeMessageRoleSystem"],["","OpencodeHint"],["","OpencodeHint"],[" [question-display-message]","OpencodeHint"]],"ns_id":3,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":false,"priority":10}],[9,15,0,{"right_gravity":true,"priority":4096,"line_hl_group":"OpencodeQuestionTitle","ns_id":3}],[10,15,0,{"right_gravity":true,"virt_text_win_col":-2,"virt_text":[["▌","OpencodeQuestionBorder"]],"ns_id":3,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"priority":4096}],[11,16,0,{"right_gravity":true,"virt_text_win_col":-2,"virt_text":[["▌","OpencodeQuestionBorder"]],"ns_id":3,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"priority":4096}],[12,17,0,{"right_gravity":true,"virt_text_win_col":-2,"virt_text":[["▌","OpencodeQuestionBorder"]],"ns_id":3,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"priority":4096}],[13,18,0,{"right_gravity":true,"virt_text_win_col":-2,"virt_text":[["▌","OpencodeQuestionBorder"]],"ns_id":3,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"priority":4096}],[14,19,0,{"right_gravity":true,"priority":4096,"line_hl_group":"OpencodeDialogOptionHover","ns_id":3}],[15,19,0,{"right_gravity":true,"virt_text_win_col":-2,"virt_text":[["▌","OpencodeQuestionBorder"]],"ns_id":3,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"priority":4096}],[16,19,2,{"virt_text":[["› ","OpencodeDialogOptionHover"]],"virt_text_hide":false,"ns_id":3,"right_gravity":true,"virt_text_pos":"overlay","virt_text_repeat_linebreak":false,"priority":4096}],[17,20,0,{"right_gravity":true,"virt_text_win_col":-2,"virt_text":[["▌","OpencodeQuestionBorder"]],"ns_id":3,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"priority":4096}],[18,21,0,{"right_gravity":true,"virt_text_win_col":-2,"virt_text":[["▌","OpencodeQuestionBorder"]],"ns_id":3,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"priority":4096}],[19,22,0,{"right_gravity":true,"virt_text_win_col":-2,"virt_text":[["▌","OpencodeQuestionBorder"]],"ns_id":3,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"priority":4096}],[20,23,0,{"right_gravity":true,"virt_text_win_col":-2,"virt_text":[["▌","OpencodeQuestionBorder"]],"ns_id":3,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"priority":4096}]],"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":1769441972} \ No newline at end of file diff --git a/tests/data/shifting-and-multiple-perms.expected.json b/tests/data/shifting-and-multiple-perms.expected.json index 2c8b2bf1..2caeec3b 100644 --- a/tests/data/shifting-and-multiple-perms.expected.json +++ b/tests/data/shifting-and-multiple-perms.expected.json @@ -1 +1 @@ -{"extmarks":[[1,1,0,{"priority":10,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":false,"virt_text":[["▌󰭻 ","OpencodeMessageRoleUser"],[" "],["USER","OpencodeMessageRoleUser"],["","OpencodeHint"],[" (2025-10-17 01:05:49)","OpencodeHint"],[" [msg_9efb39d68001J2h30a50B2774b]","OpencodeHint"]],"ns_id":3,"virt_text_win_col":-3,"right_gravity":true}],[2,2,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodeMessageRoleUser"]],"ns_id":3,"virt_text_win_col":-3,"right_gravity":true}],[3,3,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodeMessageRoleUser"]],"ns_id":3,"virt_text_win_col":-3,"right_gravity":true}],[4,4,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodeMessageRoleUser"]],"ns_id":3,"virt_text_win_col":-3,"right_gravity":true}],[5,5,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodeMessageRoleUser"]],"ns_id":3,"virt_text_win_col":-3,"right_gravity":true}],[6,8,0,{"priority":10,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":false,"virt_text":[[" ","OpencodeMessageRoleAssistant"],[" "],["PLAN","OpencodeMessageRoleAssistant"],[" claude-sonnet-4.5","OpencodeHint"],[" (2025-10-17 01:05:50)","OpencodeHint"],[" [msg_9efb39dc3002f81rMRqF2WO1UU]","OpencodeHint"]],"ns_id":3,"virt_text_win_col":-3,"right_gravity":true}],[7,83,0,{"priority":10,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":false,"virt_text":[["▌󰭻 ","OpencodeMessageRoleUser"],[" "],["USER","OpencodeMessageRoleUser"],["","OpencodeHint"],[" (2025-10-17 01:07:23)","OpencodeHint"],[" [msg_9efb50a0b001WFK7AMDV45cF8Z]","OpencodeHint"]],"ns_id":3,"virt_text_win_col":-3,"right_gravity":true}],[8,84,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodeMessageRoleUser"]],"ns_id":3,"virt_text_win_col":-3,"right_gravity":true}],[9,85,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodeMessageRoleUser"]],"ns_id":3,"virt_text_win_col":-3,"right_gravity":true}],[10,88,0,{"priority":10,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":false,"virt_text":[[" ","OpencodeMessageRoleAssistant"],[" "],["PLAN","OpencodeMessageRoleAssistant"],[" claude-sonnet-4.5","OpencodeHint"],[" (2025-10-17 01:07:23)","OpencodeHint"],[" [msg_9efb50a2a002dzMgbQnasd86o1]","OpencodeHint"]],"ns_id":3,"virt_text_win_col":-3,"right_gravity":true}],[11,111,0,{"priority":10,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":false,"virt_text":[["▌󰭻 ","OpencodeMessageRoleUser"],[" "],["USER","OpencodeMessageRoleUser"],["","OpencodeHint"],[" (2025-10-17 01:08:01)","OpencodeHint"],[" [msg_9efb59d93001LSm9y0DS9p8cP6]","OpencodeHint"]],"ns_id":3,"virt_text_win_col":-3,"right_gravity":true}],[12,112,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodeMessageRoleUser"]],"ns_id":3,"virt_text_win_col":-3,"right_gravity":true}],[13,113,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodeMessageRoleUser"]],"ns_id":3,"virt_text_win_col":-3,"right_gravity":true}],[14,116,0,{"priority":10,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":false,"virt_text":[[" ","OpencodeMessageRoleAssistant"],[" "],["PLAN","OpencodeMessageRoleAssistant"],[" claude-sonnet-4.5","OpencodeHint"],[" (2025-10-17 01:08:01)","OpencodeHint"],[" [msg_9efb59db4002uWmyFRTjRIhIaQ]","OpencodeHint"]],"ns_id":3,"virt_text_win_col":-3,"right_gravity":true}],[15,125,0,{"priority":10,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":false,"virt_text":[[" ","OpencodeMessageRoleSystem"],[" "],["SYSTEM","OpencodeMessageRoleSystem"],["","OpencodeHint"],["","OpencodeHint"],[" [permission-display-message]","OpencodeHint"]],"ns_id":3,"virt_text_win_col":-3,"right_gravity":true}],[16,127,0,{"priority":4096,"ns_id":3,"line_hl_group":"OpencodePermissionTitle","right_gravity":true}],[17,127,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[18,128,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[19,129,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[20,130,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[21,131,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[22,132,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[23,133,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[24,134,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[25,135,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[26,136,0,{"end_right_gravity":false,"virt_text_hide":false,"virt_text_repeat_linebreak":false,"end_col":0,"virt_text_pos":"overlay","priority":5000,"hl_eol":true,"ns_id":3,"virt_text":[["+","OpencodeDiffAdd"]],"hl_group":"OpencodeDiffAdd","end_row":137,"right_gravity":true}],[27,136,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[28,137,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[29,138,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[30,139,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[31,140,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[32,141,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[33,142,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[34,143,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[35,144,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[36,145,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[37,147,0,{"priority":4096,"ns_id":3,"line_hl_group":"OpencodePermissionTitle","right_gravity":true}],[38,147,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[39,148,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[40,149,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[41,150,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[42,151,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[43,152,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[44,153,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[45,154,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[46,155,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[47,156,0,{"end_right_gravity":false,"virt_text_hide":false,"virt_text_repeat_linebreak":false,"end_col":0,"virt_text_pos":"overlay","priority":5000,"hl_eol":true,"ns_id":3,"virt_text":[["+","OpencodeDiffAdd"]],"hl_group":"OpencodeDiffAdd","end_row":157,"right_gravity":true}],[48,156,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[49,157,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[50,158,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[51,159,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[52,160,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[53,161,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[54,162,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[55,163,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[56,164,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[57,165,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[58,167,0,{"priority":4096,"ns_id":3,"line_hl_group":"OpencodePermissionTitle","right_gravity":true}],[59,167,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[60,168,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[61,169,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[62,170,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[63,171,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[64,172,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[65,173,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[66,174,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[67,175,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[68,176,0,{"end_right_gravity":false,"virt_text_hide":false,"virt_text_repeat_linebreak":false,"end_col":0,"virt_text_pos":"overlay","priority":5000,"hl_eol":true,"ns_id":3,"virt_text":[["+","OpencodeDiffAdd"]],"hl_group":"OpencodeDiffAdd","end_row":177,"right_gravity":true}],[69,176,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[70,177,0,{"end_right_gravity":false,"virt_text_hide":false,"virt_text_repeat_linebreak":false,"end_col":0,"virt_text_pos":"overlay","priority":5000,"hl_eol":true,"ns_id":3,"virt_text":[["+","OpencodeDiffAdd"]],"hl_group":"OpencodeDiffAdd","end_row":178,"right_gravity":true}],[71,177,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[72,178,0,{"end_right_gravity":false,"virt_text_hide":false,"virt_text_repeat_linebreak":false,"end_col":0,"virt_text_pos":"overlay","priority":5000,"hl_eol":true,"ns_id":3,"virt_text":[["+","OpencodeDiffAdd"]],"hl_group":"OpencodeDiffAdd","end_row":179,"right_gravity":true}],[73,178,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[74,179,0,{"end_right_gravity":false,"virt_text_hide":false,"virt_text_repeat_linebreak":false,"end_col":0,"virt_text_pos":"overlay","priority":5000,"hl_eol":true,"ns_id":3,"virt_text":[["+","OpencodeDiffAdd"]],"hl_group":"OpencodeDiffAdd","end_row":180,"right_gravity":true}],[75,179,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[76,180,0,{"end_right_gravity":false,"virt_text_hide":false,"virt_text_repeat_linebreak":false,"end_col":0,"virt_text_pos":"overlay","priority":5000,"hl_eol":true,"ns_id":3,"virt_text":[["+","OpencodeDiffAdd"]],"hl_group":"OpencodeDiffAdd","end_row":181,"right_gravity":true}],[77,180,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[78,181,0,{"end_right_gravity":false,"virt_text_hide":false,"virt_text_repeat_linebreak":false,"end_col":0,"virt_text_pos":"overlay","priority":5000,"hl_eol":true,"ns_id":3,"virt_text":[["+","OpencodeDiffAdd"]],"hl_group":"OpencodeDiffAdd","end_row":182,"right_gravity":true}],[79,181,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[80,182,0,{"end_right_gravity":false,"virt_text_hide":false,"virt_text_repeat_linebreak":false,"end_col":0,"virt_text_pos":"overlay","priority":5000,"hl_eol":true,"ns_id":3,"virt_text":[["+","OpencodeDiffAdd"]],"hl_group":"OpencodeDiffAdd","end_row":183,"right_gravity":true}],[81,182,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[82,183,0,{"end_right_gravity":false,"virt_text_hide":false,"virt_text_repeat_linebreak":false,"end_col":0,"virt_text_pos":"overlay","priority":5000,"hl_eol":true,"ns_id":3,"virt_text":[["+","OpencodeDiffAdd"]],"hl_group":"OpencodeDiffAdd","end_row":184,"right_gravity":true}],[83,183,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[84,184,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[85,185,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[86,186,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[87,187,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[88,188,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[89,189,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[90,190,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[91,191,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[92,192,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[93,193,0,{"end_right_gravity":false,"virt_text_hide":false,"virt_text_repeat_linebreak":false,"end_col":0,"virt_text_pos":"overlay","priority":5000,"hl_eol":true,"ns_id":3,"virt_text":[["+","OpencodeDiffAdd"]],"hl_group":"OpencodeDiffAdd","end_row":194,"right_gravity":true}],[94,193,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[95,194,0,{"end_right_gravity":false,"virt_text_hide":false,"virt_text_repeat_linebreak":false,"end_col":0,"virt_text_pos":"overlay","priority":5000,"hl_eol":true,"ns_id":3,"virt_text":[["+","OpencodeDiffAdd"]],"hl_group":"OpencodeDiffAdd","end_row":195,"right_gravity":true}],[96,194,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[97,195,0,{"end_right_gravity":false,"virt_text_hide":false,"virt_text_repeat_linebreak":false,"end_col":0,"virt_text_pos":"overlay","priority":5000,"hl_eol":true,"ns_id":3,"virt_text":[["+","OpencodeDiffAdd"]],"hl_group":"OpencodeDiffAdd","end_row":196,"right_gravity":true}],[98,195,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[99,196,0,{"end_right_gravity":false,"virt_text_hide":false,"virt_text_repeat_linebreak":false,"end_col":0,"virt_text_pos":"overlay","priority":5000,"hl_eol":true,"ns_id":3,"virt_text":[["+","OpencodeDiffAdd"]],"hl_group":"OpencodeDiffAdd","end_row":197,"right_gravity":true}],[100,196,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[101,197,0,{"end_right_gravity":false,"virt_text_hide":false,"virt_text_repeat_linebreak":false,"end_col":0,"virt_text_pos":"overlay","priority":5000,"hl_eol":true,"ns_id":3,"virt_text":[["+","OpencodeDiffAdd"]],"hl_group":"OpencodeDiffAdd","end_row":198,"right_gravity":true}],[102,197,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[103,198,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[104,199,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[105,200,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[106,201,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[107,202,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[108,203,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[109,204,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"virt_text_win_col":-2,"right_gravity":true}],[110,205,0,{"priority":4096,"virt_text_hide":false,"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"virt_text":[["▌","OpencodePermissionBorder"]],"ns_id":3,"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",""," *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 = {}","","`````","","*Accept* `a1` *Deny* `d1` *Always* `A1`","",""," Permission Required",""," *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:reset()"," M._actions = {}"," M._has_trailing_line = false"," "," output_window.clear()"," "," state.messages = {}","","`````","","*Accept* `a2` *Deny* `d2` *Always* `A2`","",""," Permission Required",""," *edit* `Edit this file: /Users/cam/Dev/neovim-dev/opencode.nvim/lua/opencode/ui/renderer.lua`","","`````"," ---@return {line_start: integer, line_end: integer}? Range where data was written"," 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","@@ -268,8 +276,13 @@"," "," 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,"," }","","`````","","*Accept* `a3` *Deny* `d3` *Always* `A3`","","",""],"timestamp":1769172297,"actions":[]} \ No newline at end of file +{"actions":[],"extmarks":[[1,1,0,{"right_gravity":true,"virt_text_win_col":-3,"ns_id":3,"virt_text_hide":false,"virt_text":[["▌󰭻 ","OpencodeMessageRoleUser"],[" "],["USER","OpencodeMessageRoleUser"],["","OpencodeHint"],[" (2025-10-17 01:05:49)","OpencodeHint"],[" [msg_9efb39d68001J2h30a50B2774b]","OpencodeHint"]],"virt_text_pos":"win_col","virt_text_repeat_linebreak":false,"priority":10}],[2,2,0,{"right_gravity":true,"virt_text_win_col":-3,"ns_id":3,"virt_text_hide":false,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"priority":4096}],[3,3,0,{"right_gravity":true,"virt_text_win_col":-3,"ns_id":3,"virt_text_hide":false,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"priority":4096}],[4,4,0,{"right_gravity":true,"virt_text_win_col":-3,"ns_id":3,"virt_text_hide":false,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"priority":4096}],[5,5,0,{"right_gravity":true,"virt_text_win_col":-3,"ns_id":3,"virt_text_hide":false,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"priority":4096}],[6,8,0,{"right_gravity":true,"virt_text_win_col":-3,"ns_id":3,"virt_text_hide":false,"virt_text":[[" ","OpencodeMessageRoleAssistant"],[" "],["PLAN","OpencodeMessageRoleAssistant"],[" claude-sonnet-4.5","OpencodeHint"],[" (2025-10-17 01:05:50)","OpencodeHint"],[" [msg_9efb39dc3002f81rMRqF2WO1UU]","OpencodeHint"]],"virt_text_pos":"win_col","virt_text_repeat_linebreak":false,"priority":10}],[7,83,0,{"right_gravity":true,"virt_text_win_col":-3,"ns_id":3,"virt_text_hide":false,"virt_text":[["▌󰭻 ","OpencodeMessageRoleUser"],[" "],["USER","OpencodeMessageRoleUser"],["","OpencodeHint"],[" (2025-10-17 01:07:23)","OpencodeHint"],[" [msg_9efb50a0b001WFK7AMDV45cF8Z]","OpencodeHint"]],"virt_text_pos":"win_col","virt_text_repeat_linebreak":false,"priority":10}],[8,84,0,{"right_gravity":true,"virt_text_win_col":-3,"ns_id":3,"virt_text_hide":false,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"priority":4096}],[9,85,0,{"right_gravity":true,"virt_text_win_col":-3,"ns_id":3,"virt_text_hide":false,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"priority":4096}],[10,88,0,{"right_gravity":true,"virt_text_win_col":-3,"ns_id":3,"virt_text_hide":false,"virt_text":[[" ","OpencodeMessageRoleAssistant"],[" "],["PLAN","OpencodeMessageRoleAssistant"],[" claude-sonnet-4.5","OpencodeHint"],[" (2025-10-17 01:07:23)","OpencodeHint"],[" [msg_9efb50a2a002dzMgbQnasd86o1]","OpencodeHint"]],"virt_text_pos":"win_col","virt_text_repeat_linebreak":false,"priority":10}],[11,111,0,{"right_gravity":true,"virt_text_win_col":-3,"ns_id":3,"virt_text_hide":false,"virt_text":[["▌󰭻 ","OpencodeMessageRoleUser"],[" "],["USER","OpencodeMessageRoleUser"],["","OpencodeHint"],[" (2025-10-17 01:08:01)","OpencodeHint"],[" [msg_9efb59d93001LSm9y0DS9p8cP6]","OpencodeHint"]],"virt_text_pos":"win_col","virt_text_repeat_linebreak":false,"priority":10}],[12,112,0,{"right_gravity":true,"virt_text_win_col":-3,"ns_id":3,"virt_text_hide":false,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"priority":4096}],[13,113,0,{"right_gravity":true,"virt_text_win_col":-3,"ns_id":3,"virt_text_hide":false,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"priority":4096}],[14,116,0,{"right_gravity":true,"virt_text_win_col":-3,"ns_id":3,"virt_text_hide":false,"virt_text":[[" ","OpencodeMessageRoleAssistant"],[" "],["PLAN","OpencodeMessageRoleAssistant"],[" claude-sonnet-4.5","OpencodeHint"],[" (2025-10-17 01:08:01)","OpencodeHint"],[" [msg_9efb59db4002uWmyFRTjRIhIaQ]","OpencodeHint"]],"virt_text_pos":"win_col","virt_text_repeat_linebreak":false,"priority":10}],[15,125,0,{"right_gravity":true,"virt_text_win_col":-3,"ns_id":3,"virt_text_hide":false,"virt_text":[[" ","OpencodeMessageRoleSystem"],[" "],["SYSTEM","OpencodeMessageRoleSystem"],["","OpencodeHint"],["","OpencodeHint"],[" [permission-display-message]","OpencodeHint"]],"virt_text_pos":"win_col","virt_text_repeat_linebreak":false,"priority":10}],[16,127,0,{"right_gravity":true,"ns_id":3,"line_hl_group":"OpencodePermissionTitle","priority":4096}],[17,127,0,{"right_gravity":true,"virt_text_win_col":-2,"ns_id":3,"virt_text_hide":false,"virt_text":[["▌","OpencodePermissionBorder"]],"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"priority":4096}],[18,128,0,{"right_gravity":true,"virt_text_win_col":-2,"ns_id":3,"virt_text_hide":false,"virt_text":[["▌","OpencodePermissionBorder"]],"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"priority":4096}],[19,129,0,{"right_gravity":true,"virt_text_win_col":-2,"ns_id":3,"virt_text_hide":false,"virt_text":[["▌","OpencodePermissionBorder"]],"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"priority":4096}],[20,130,0,{"right_gravity":true,"virt_text_win_col":-2,"ns_id":3,"virt_text_hide":false,"virt_text":[["▌","OpencodePermissionBorder"]],"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"priority":4096}],[21,131,0,{"right_gravity":true,"virt_text_win_col":-2,"ns_id":3,"virt_text_hide":false,"virt_text":[["▌","OpencodePermissionBorder"]],"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"priority":4096}],[22,132,0,{"right_gravity":true,"virt_text_win_col":-2,"ns_id":3,"virt_text_hide":false,"virt_text":[["▌","OpencodePermissionBorder"]],"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"priority":4096}],[23,133,0,{"right_gravity":true,"virt_text_win_col":-2,"ns_id":3,"virt_text_hide":false,"virt_text":[["▌","OpencodePermissionBorder"]],"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"priority":4096}],[24,134,0,{"right_gravity":true,"virt_text_win_col":-2,"ns_id":3,"virt_text_hide":false,"virt_text":[["▌","OpencodePermissionBorder"]],"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"priority":4096}],[25,135,0,{"right_gravity":true,"virt_text_win_col":-2,"ns_id":3,"virt_text_hide":false,"virt_text":[["▌","OpencodePermissionBorder"]],"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"priority":4096}],[26,136,0,{"end_col":0,"hl_group":"OpencodeDiffAdd","right_gravity":true,"ns_id":3,"virt_text_hide":false,"end_row":137,"hl_eol":true,"end_right_gravity":false,"virt_text":[["+","OpencodeDiffAdd"]],"virt_text_pos":"overlay","virt_text_repeat_linebreak":false,"priority":5000}],[27,136,0,{"right_gravity":true,"virt_text_win_col":-2,"ns_id":3,"virt_text_hide":false,"virt_text":[["▌","OpencodePermissionBorder"]],"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"priority":4096}],[28,137,0,{"right_gravity":true,"virt_text_win_col":-2,"ns_id":3,"virt_text_hide":false,"virt_text":[["▌","OpencodePermissionBorder"]],"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"priority":4096}],[29,138,0,{"right_gravity":true,"virt_text_win_col":-2,"ns_id":3,"virt_text_hide":false,"virt_text":[["▌","OpencodePermissionBorder"]],"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"priority":4096}],[30,139,0,{"right_gravity":true,"virt_text_win_col":-2,"ns_id":3,"virt_text_hide":false,"virt_text":[["▌","OpencodePermissionBorder"]],"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"priority":4096}],[31,140,0,{"right_gravity":true,"virt_text_win_col":-2,"ns_id":3,"virt_text_hide":false,"virt_text":[["▌","OpencodePermissionBorder"]],"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"priority":4096}],[32,141,0,{"right_gravity":true,"virt_text_win_col":-2,"ns_id":3,"virt_text_hide":false,"virt_text":[["▌","OpencodePermissionBorder"]],"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"priority":4096}],[33,142,0,{"right_gravity":true,"virt_text_win_col":-2,"ns_id":3,"virt_text_hide":false,"virt_text":[["▌","OpencodePermissionBorder"]],"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"priority":4096}],[34,143,0,{"right_gravity":true,"virt_text_win_col":-2,"ns_id":3,"virt_text_hide":false,"virt_text":[["▌","OpencodePermissionBorder"]],"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"priority":4096}],[35,144,0,{"right_gravity":true,"ns_id":3,"line_hl_group":"OpencodeDialogOptionHover","priority":4096}],[36,144,0,{"right_gravity":true,"virt_text_win_col":-2,"ns_id":3,"virt_text_hide":false,"virt_text":[["▌","OpencodePermissionBorder"]],"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"priority":4096}],[37,144,2,{"right_gravity":true,"virt_text_hide":false,"ns_id":3,"virt_text":[["› ","OpencodeDialogOptionHover"]],"virt_text_pos":"overlay","virt_text_repeat_linebreak":false,"priority":4096}],[38,145,0,{"right_gravity":true,"virt_text_win_col":-2,"ns_id":3,"virt_text_hide":false,"virt_text":[["▌","OpencodePermissionBorder"]],"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"priority":4096}],[39,146,0,{"right_gravity":true,"virt_text_win_col":-2,"ns_id":3,"virt_text_hide":false,"virt_text":[["▌","OpencodePermissionBorder"]],"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"priority":4096}],[40,147,0,{"right_gravity":true,"virt_text_win_col":-2,"ns_id":3,"virt_text_hide":false,"virt_text":[["▌","OpencodePermissionBorder"]],"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"priority":4096}],[41,148,0,{"right_gravity":true,"virt_text_win_col":-2,"ns_id":3,"virt_text_hide":false,"virt_text":[["▌","OpencodePermissionBorder"]],"virt_text_pos":"win_col","virt_text_repeat_linebreak":true,"priority":4096}]],"timestamp":1769449853,"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`","",""]} \ No newline at end of file