diff --git a/README.md b/README.md index 41cce54d..02f33861 100644 --- a/README.md +++ b/README.md @@ -243,6 +243,7 @@ require('opencode').setup({ markdown_debounce_ms = 250, -- Debounce time for markdown rendering on new data (default: 250ms) on_data_rendered = nil, -- Called when new data is rendered; set to false to disable default RenderMarkdown/Markview behavior }, + max_messages = nil, -- Max number of messages to keep in the output buffer; older messages will be removed as new ones arrive (default: nil, which means no limit) }, input = { min_height = 0.10, -- min height of prompt input as percentage of window height diff --git a/lua/opencode/api.lua b/lua/opencode/api.lua index c4b63f96..52031a24 100644 --- a/lua/opencode/api.lua +++ b/lua/opencode/api.lua @@ -89,6 +89,7 @@ local action_groups = { debug_session = workflow.debug_session, toggle_tool_output = workflow.toggle_tool_output, toggle_reasoning_output = workflow.toggle_reasoning_output, + toggle_max_messages = workflow.toggle_max_messages, submit_input_prompt = workflow.submit_input_prompt, run = workflow.run, run_new_session = workflow.run_new_session, diff --git a/lua/opencode/commands/handlers/workflow.lua b/lua/opencode/commands/handlers/workflow.lua index b0e252ac..834a4eeb 100644 --- a/lua/opencode/commands/handlers/workflow.lua +++ b/lua/opencode/commands/handlers/workflow.lua @@ -283,6 +283,23 @@ function M.actions.toggle_reasoning_output() ui.render_output() end +local original_max_messages = config.ui.output.max_messages +function M.actions.toggle_max_messages() + local current = config.ui.output.max_messages + local next_val + if type(current) == 'number' and current > 0 then + next_val = nil + else + next_val = original_max_messages or 20 + end + + local action_text = next_val == nil and 'Disabling' or 'Enabling' + local val_text = next_val == nil and 'none' or tostring(next_val) + vim.notify(action_text .. ' message limit to ' .. val_text, vim.log.levels.INFO) + config.values.ui.output.max_messages = next_val + ui.render_output() +end + M.actions.review = Promise.async(function(args) local new_session = core.create_new_session('Code review checklist for diffs and PRs'):await() if not new_session then @@ -453,6 +470,10 @@ M.command_defs = { desc = 'Toggle reasoning output visibility in the output window', execute = M.actions.toggle_reasoning_output, }, + toggle_max_messages = { + desc = 'Toggle maximum number of rendered messages', + execute = M.actions.toggle_max_messages, + }, paste_image = { desc = 'Paste image from clipboard and add to context', execute = M.actions.paste_image, diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index c38bb1e5..9193bb53 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -56,7 +56,9 @@ M.defaults = { ['ox'] = { 'swap_position', desc = 'Swap window position' }, ['otr'] = { 'toggle_reasoning_output', desc = 'Toggle reasoning output' }, ['ott'] = { 'toggle_tool_output', desc = 'Toggle tool output' }, + ['otm'] = { 'toggle_max_messages', desc = 'Toggle max messages' }, ['o/'] = { 'quick_chat', mode = { 'n', 'x' }, desc = 'Quick chat with current context' }, + }, output_window = { [''] = { 'close', desc = 'Close Opencode windows' }, @@ -155,6 +157,7 @@ M.defaults = { show_output = true, show_reasoning_output = true, }, + max_messages = nil, always_scroll_to_bottom = false, }, questions = { diff --git a/lua/opencode/types.lua b/lua/opencode/types.lua index 510853cd..c3064dd1 100644 --- a/lua/opencode/types.lua +++ b/lua/opencode/types.lua @@ -244,6 +244,7 @@ ---@class OpencodeUIOutputConfig ---@field tools { show_output: boolean, show_reasoning_output: boolean } ---@field rendering OpencodeUIOutputRenderingConfig +---@field max_messages integer|nil ---@field always_scroll_to_bottom boolean ---@field filetype string ---@field compact_assistant_headers boolean @@ -498,7 +499,7 @@ ---@class OutputAction ---@field text string Action text ----@field type 'diff_revert_all'|'diff_revert_selected_file'|'diff_open'|'diff_restore_snapshot_file'|'diff_restore_snapshot_all'|'select_child_session' Type of action +---@field type 'diff_revert_all'|'diff_revert_selected_file'|'diff_open'|'diff_restore_snapshot_file'|'diff_restore_snapshot_all'|'select_child_session'|'toggle_max_messages' ---@field args? string[] Optional arguments for the command ---@field key string keybinding for the action ---@field display_line number Line number to display the action diff --git a/lua/opencode/ui/formatter.lua b/lua/opencode/ui/formatter.lua index ca8f2426..0d5b505e 100644 --- a/lua/opencode/ui/formatter.lua +++ b/lua/opencode/ui/formatter.lua @@ -99,6 +99,26 @@ function M._format_revert_message(session_data, start_idx) return output end +---@param hidden_count integer +---@return Output +function M._format_hidden_messages_notice(hidden_count) + local output = Output.new() + local message_text = hidden_count == 1 and 'message is' or 'messages are' + + output:add_line(string.format('> %d older %s not displayed.', hidden_count, message_text)) + output:add_action({ + text = 'Show [A]ll messages', + type = 'toggle_max_messages', + args = {}, + key = 'A', + display_line = output:get_line_count() - 1, + range = { from = output:get_line_count() - 1, to = output:get_line_count() - 1 }, + }) + output:add_empty_line() + + return output +end + ---@param output Output ---@param text string ---@param action_type string @@ -167,6 +187,10 @@ function M.format_message_header(message, previous_message) return output end + if message.info and message.info.id == '__opencode_hidden_messages_notice__' then + return output + end + local role = message.info.role or 'unknown' local icon = message.info.role == 'user' and icons.get('header_user') or icons.get('header_assistant') @@ -664,6 +688,12 @@ function M.format_part(part, message, is_last_part, get_child_parts) output = M._format_revert_message(state.messages or {}, revert_index) content_added = output:get_line_count() > 0 end + elseif part.type == 'hidden-messages-display' then + local hidden_count = part.state and part.state.hidden_count + if type(hidden_count) == 'number' and hidden_count > 0 then + output = M._format_hidden_messages_notice(hidden_count) + content_added = output:get_line_count() > 0 + end end end diff --git a/lua/opencode/ui/renderer.lua b/lua/opencode/ui/renderer.lua index cd0590d8..62bdb8d6 100644 --- a/lua/opencode/ui/renderer.lua +++ b/lua/opencode/ui/renderer.lua @@ -9,6 +9,234 @@ local flush = require('opencode.ui.renderer.flush') local scroll = require('opencode.ui.renderer.scroll') local M = {} +local HIDDEN_MESSAGES_NOTICE_MESSAGE_ID = '__opencode_hidden_messages_notice__' +local HIDDEN_MESSAGES_NOTICE_PART_ID = '__opencode_hidden_messages_notice_part__' + +---@return integer|nil +local function get_max_rendered_messages() + local limit = config.ui and config.ui.output and config.ui.output.max_messages + if type(limit) ~= 'number' or limit <= 0 then + return nil + end + return math.floor(limit) +end + +---@param message OpencodeMessage|nil +---@return boolean +local function is_renderer_synthetic_message(message) + local message_id = message and message.info and message.info.id + return message_id == '__opencode_revert_message__' or message_id == HIDDEN_MESSAGES_NOTICE_MESSAGE_ID +end + +---@param message OpencodeMessage|nil +---@return boolean +local function is_active_session_message(message) + local session_id = message and message.info and message.info.sessionID + return session_id ~= nil and state.active_session and state.active_session.id == session_id +end + +---@param messages OpencodeMessage[]|nil +---@return OpencodeMessage[] +local function get_real_session_messages(messages) + return vim.tbl_filter(function(message) + return is_active_session_message(message) and not is_renderer_synthetic_message(message) + end, messages or {}) +end + +---@param messages OpencodeMessage[]|nil +---@return integer|nil +local function get_revert_index(messages) + local revert = state.active_session and state.active_session.revert + local revert_message_id = revert and revert.messageID + if not revert_message_id then + return nil + end + + local real_messages = get_real_session_messages(messages) + for i, message in ipairs(real_messages) do + if message.info and message.info.id == revert_message_id then + return i + end + end + + return nil +end + +---@param messages OpencodeMessage[]|nil +---@return OpencodeMessage[] visible_messages +---@return integer hidden_count +local function get_visible_session_messages(messages) + local real_messages = get_real_session_messages(messages) + local revert_index = get_revert_index(messages) + if revert_index then + real_messages = vim.list_slice(real_messages, 1, revert_index - 1) + end + + local limit = get_max_rendered_messages() + if not limit or #real_messages <= limit then + return real_messages, 0 + end + + local start_index = #real_messages - limit + 1 + return vim.list_slice(real_messages, start_index, #real_messages), start_index - 1 +end + +---@param hidden_count integer +---@return OpencodeMessage +local function build_hidden_messages_notice(hidden_count) + local session_id = state.active_session and state.active_session.id or '' + return { + info = { + id = HIDDEN_MESSAGES_NOTICE_MESSAGE_ID, + sessionID = session_id, + role = 'system', + }, + parts = { + { + id = HIDDEN_MESSAGES_NOTICE_PART_ID, + messageID = HIDDEN_MESSAGES_NOTICE_MESSAGE_ID, + sessionID = session_id, + type = 'hidden-messages-display', + state = { + hidden_count = hidden_count, + }, + }, + }, + } +end + +---@param message_id string +---@return OpencodeMessage|nil +local function find_message_in_state(message_id) + for _, message in ipairs(state.messages or {}) do + if message.info and message.info.id == message_id then + return message + end + end + return nil +end + +---@param message OpencodeMessage +local function ensure_message_rendered(message) + local message_id = message.info and message.info.id + if not message_id or ctx.render_state:get_message(message_id) then + return + end + + ctx.render_state:set_message(message) + flush.mark_message_dirty(message_id) + + for _, part in ipairs(message.parts or {}) do + if part.id and part.type ~= 'step-start' and part.type ~= 'step-finish' then + ctx.render_state:set_part(part) + flush.mark_part_dirty(part.id, message_id) + end + end +end + +---@param hidden_count integer +local function upsert_hidden_messages_notice(hidden_count) + local existing_message = ctx.render_state:get_message(HIDDEN_MESSAGES_NOTICE_MESSAGE_ID) + local notice_message = build_hidden_messages_notice(hidden_count) + + if not existing_message then + ensure_message_rendered(notice_message) + else + local existing_part = ctx.render_state:get_part(HIDDEN_MESSAGES_NOTICE_PART_ID) + if not existing_part or not existing_part.part then + hide_rendered_message(HIDDEN_MESSAGES_NOTICE_MESSAGE_ID) + ensure_message_rendered(notice_message) + else + ctx.render_state:set_message(notice_message, existing_message.line_start, existing_message.line_end) + ctx.render_state:set_part(notice_message.parts[1], existing_part.line_start, existing_part.line_end) + end + end + + local part_data = ctx.render_state:get_part(HIDDEN_MESSAGES_NOTICE_PART_ID) + if part_data then + ctx.render_state:add_actions(HIDDEN_MESSAGES_NOTICE_PART_ID, { + { + text = 'Toggle Max Messages', + type = 'toggle_max_messages', + args = {}, + key = 'm', + range = { from = part_data.line_start, to = part_data.line_end }, + display_line = part_data.line_start, + }, + }) + flush.mark_part_dirty(HIDDEN_MESSAGES_NOTICE_PART_ID, HIDDEN_MESSAGES_NOTICE_MESSAGE_ID) + end +end + +---@param message_id string +local function hide_rendered_message(message_id) + local rendered_message = ctx.render_state:get_message(message_id) + local message = rendered_message and rendered_message.message or find_message_in_state(message_id) + if not message then + return + end + + ctx.render_state:clear_orphan_parts(message_id) + for _, part in ipairs(message.parts or {}) do + if part.id then + flush.queue_part_removal(part.id) + end + end + flush.queue_message_removal(message_id) +end + +local function reconcile_rendered_message_limit() + if not state.active_session or not state.messages then + return + end + + local limit = get_max_rendered_messages() + if not limit then + if ctx.render_state:get_message(HIDDEN_MESSAGES_NOTICE_MESSAGE_ID) then + hide_rendered_message(HIDDEN_MESSAGES_NOTICE_MESSAGE_ID) + end + return + end + + local visible_messages, hidden_count = get_visible_session_messages(state.messages) + local visible_ids = {} + for _, message in ipairs(visible_messages) do + local message_id = message.info and message.info.id + if message_id then + visible_ids[message_id] = true + ensure_message_rendered(message) + end + end + + for _, message in ipairs(get_real_session_messages(state.messages)) do + local message_id = message.info and message.info.id + if message_id and not visible_ids[message_id] and ctx.render_state:get_message(message_id) then + hide_rendered_message(message_id) + end + end + + if hidden_count > 0 then + upsert_hidden_messages_notice(hidden_count) + elseif ctx.render_state:get_message(HIDDEN_MESSAGES_NOTICE_MESSAGE_ID) then + hide_rendered_message(HIDDEN_MESSAGES_NOTICE_MESSAGE_ID) + end +end + +---@param message_id string|nil +---@return boolean +local function is_message_visible(message_id) + if not message_id then + return false + end + + for _, message in ipairs(select(1, get_visible_session_messages(state.messages))) do + if message.info and message.info.id == message_id then + return true + end + end + + return false +end -- Expose event handlers on M so tests can call them directly and subscriptions -- can be stubbed cleanly (e.g. stub(renderer, '_render_full_session_data')) @@ -100,22 +328,35 @@ end function M._render_full_session_data(session_data, opts) opts = opts or {} M.reset() + state.renderer.set_messages(vim.deepcopy(session_data or {})) if not state.active_session or not state.messages then return end - local revert_index = nil + local visible_messages, hidden_count = get_visible_session_messages(state.messages) + local revert_index = get_revert_index(state.messages) flush.begin_bulk_mode() - for i, msg in ipairs(session_data) do - if state.active_session.revert and state.active_session.revert.messageID == msg.info.id then - revert_index = i - end - events.on_message_updated({ info = msg.info }, revert_index) + if hidden_count > 0 then + local hidden_notice = build_hidden_messages_notice(hidden_count) + events.on_message_updated(hidden_notice) + events.on_part_updated({ part = hidden_notice.parts[1] }) + end + + for _, msg in ipairs(visible_messages) do + events.on_message_updated({ info = msg.info }) for _, part in ipairs(msg.parts or {}) do - events.on_part_updated({ part = part }, revert_index) + events.on_part_updated({ part = part }) + end + end + + for _, msg in ipairs(state.messages) do + if msg.info and msg.info.sessionID ~= state.active_session.id then + for _, part in ipairs(msg.parts or {}) do + events.on_part_updated({ part = part }) + end end end @@ -234,6 +475,9 @@ function M.on_session_changed(_, new, old) end end +M.reconcile_rendered_message_limit = reconcile_rendered_message_limit +M.is_message_visible = is_message_visible + ---Scroll to bottom after all queued events have been processed function M.on_emit_events_finished() M.scroll_to_bottom() diff --git a/lua/opencode/ui/renderer/buffer.lua b/lua/opencode/ui/renderer/buffer.lua index 3287dda1..770630d5 100644 --- a/lua/opencode/ui/renderer/buffer.lua +++ b/lua/opencode/ui/renderer/buffer.lua @@ -9,12 +9,22 @@ local pinned_bottom_message_ids = { ['question-display-message'] = true, } +local pinned_top_message_ids = { + ['__opencode_hidden_messages_notice__'] = true, +} + ---@param message_id string|nil ---@return boolean local function is_pinned_bottom_message(message_id) return message_id ~= nil and pinned_bottom_message_ids[message_id] == true end +---@param message_id string|nil +---@return boolean +local function is_pinned_top_message(message_id) + return message_id ~= nil and pinned_top_message_ids[message_id] == true +end + ---@param extmarks table[]|table|nil ---@return boolean local function has_extmarks(extmarks) @@ -247,6 +257,10 @@ local function get_message_insert_line(message_id) return rendered_message.line_start end + if is_pinned_top_message(message_id) then + return 0 + end + local line_count = output_window.get_buf_line_count() local append_at = math.max(line_count - 1, 0) if line_count == 1 then diff --git a/lua/opencode/ui/renderer/events.lua b/lua/opencode/ui/renderer/events.lua index 96a2dc2d..28be67e1 100644 --- a/lua/opencode/ui/renderer/events.lua +++ b/lua/opencode/ui/renderer/events.lua @@ -33,6 +33,22 @@ local function find_text_part_for_message(message) return nil end +---@param message_id string|nil +---@return OpencodeMessage|nil +local function find_message_in_state(message_id) + if not message_id then + return nil + end + + for _, message in ipairs(state.messages or {}) do + if message.info and message.info.id == message_id then + return message + end + end + + return nil +end + -- Lazy require to avoid circular dependency: renderer.lua <-> events.lua ---@param force? boolean local function scroll(force) @@ -171,18 +187,23 @@ function M.on_message_updated(message, revert_index) end local rendered_message = ctx.render_state:get_message(msg.info.id) - local found_msg = rendered_message and rendered_message.message + local found_msg = rendered_message and rendered_message.message or find_message_in_state(msg.info.id) if revert_index then if not found_msg then table.insert(state.messages, msg) + found_msg = msg end - ctx.render_state:set_message(msg, 0, 0) + ctx.render_state:set_message(found_msg, 0, 0) replay_orphan_parts(msg.info.id, revert_index) return end if found_msg then + if not rendered_message then + ctx.render_state:set_message(found_msg) + flush.mark_message_dirty(msg.info.id) + end local error_changed = not vim.deep_equal(found_msg.info.error, msg.info.error) found_msg.info = msg.info @@ -210,6 +231,10 @@ function M.on_message_updated(message, revert_index) end update_stats(msg) + + if not revert_index and not ctx.bulk_mode and msg.info.id ~= '__opencode_hidden_messages_notice__' then + require('opencode.ui.renderer').reconcile_rendered_message_limit() + end end ---Handle message.removed — remove the message and all its parts from the buffer @@ -225,12 +250,13 @@ function M.on_message_removed(properties) end local rendered_message = ctx.render_state:get_message(message_id) + local message = rendered_message and rendered_message.message or find_message_in_state(message_id) ctx.render_state:clear_orphan_parts(message_id) - if not rendered_message or not rendered_message.message then + if not message then return end - for _, part in ipairs(rendered_message.message.parts or {}) do + for _, part in ipairs(message.parts or {}) do if part.id then flush.queue_part_removal(part.id) end @@ -244,6 +270,10 @@ function M.on_message_removed(properties) break end end + + if not ctx.bulk_mode and message_id ~= '__opencode_hidden_messages_notice__' then + require('opencode.ui.renderer').reconcile_rendered_message_limit() + end end ---Handle message.part.updated — insert or replace a part in the buffer @@ -272,6 +302,13 @@ function M.on_part_updated(properties, revert_index) end local rendered_message = ctx.render_state:get_message(part.messageID) + if not rendered_message then + local existing_message = find_message_in_state(part.messageID) + if existing_message then + ctx.render_state:set_message(existing_message) + rendered_message = ctx.render_state:get_message(part.messageID) + end + end if not rendered_message or not rendered_message.message then ctx.render_state:upsert_orphan_part(part.messageID, part) return @@ -284,15 +321,30 @@ function M.on_part_updated(properties, revert_index) local is_new_part = not part_data local prev_last_part_id = get_last_part_for_message(message) + local existing_part_index = nil + for i = #message.parts, 1, -1 do + if message.parts[i].id == part.id then + existing_part_index = i + break + end + end -- Update the part reference in the message if is_new_part then - table.insert(message.parts, part) + if existing_part_index then + message.parts[existing_part_index] = part + else + table.insert(message.parts, part) + end else - for i = #message.parts, 1, -1 do - if message.parts[i].id == part.id then - message.parts[i] = part - break + if existing_part_index then + message.parts[existing_part_index] = part + else + for i = #message.parts, 1, -1 do + if message.parts[i].id == part.id then + message.parts[i] = part + break + end end end end diff --git a/lua/opencode/ui/ui.lua b/lua/opencode/ui/ui.lua index fe1684bc..d8178dbb 100644 --- a/lua/opencode/ui/ui.lua +++ b/lua/opencode/ui/ui.lua @@ -473,12 +473,14 @@ end ---from opencode ---@param synchronous? boolean If true, waits until session is fully rendered ---@param opts? {force_scroll?: boolean} +---@return Promise | OpencodeMessage[] | nil function M.render_output(synchronous, opts) local ret = renderer.render_full_session(opts) if ret and synchronous then ret:wait() end + return ret end ---@param lines string[] diff --git a/tests/replay/renderer_spec.lua b/tests/replay/renderer_spec.lua index 042f5863..afd2e6d2 100644 --- a/tests/replay/renderer_spec.lua +++ b/tests/replay/renderer_spec.lua @@ -225,6 +225,205 @@ describe('renderer unit tests', function() assert.are.equal(1, #revert_messages) end) + it('limits rendered messages and inserts a hidden-messages notice', function() + local renderer = require('opencode.ui.renderer') + + helpers.replay_setup() + config.ui.output.max_messages = 2 + + state.session.set_active({ + id = 'ses_123', + title = 'Session', + time = { created = 1, updated = 1 }, + }) + + renderer._render_full_session_data({ + { + info = { id = 'msg_1', role = 'user', sessionID = 'ses_123', time = { created = 1 } }, + parts = { + { id = 'part_1', messageID = 'msg_1', sessionID = 'ses_123', type = 'text', text = 'first' }, + }, + }, + { + info = { id = 'msg_2', role = 'assistant', sessionID = 'ses_123', time = { created = 2 } }, + parts = { + { id = 'part_2', messageID = 'msg_2', sessionID = 'ses_123', type = 'text', text = 'second' }, + }, + }, + { + info = { id = 'msg_3', role = 'assistant', sessionID = 'ses_123', time = { created = 3 } }, + parts = { + { id = 'part_3', messageID = 'msg_3', sessionID = 'ses_123', type = 'text', text = 'third' }, + }, + }, + }) + + assert.is_not_nil(renderer.get_rendered_message('__opencode_hidden_messages_notice__')) + assert.is_nil(renderer.get_rendered_message('msg_1')) + assert.is_not_nil(renderer.get_rendered_message('msg_2')) + assert.is_not_nil(renderer.get_rendered_message('msg_3')) + + local lines = vim.api.nvim_buf_get_lines(state.windows.output_buf, 0, -1, false) + assert.are.equal('> 1 older message is not displayed.', lines[1]) + + config.ui.output.max_messages = nil + end) + + it('evicts the oldest rendered message during streaming updates', function() + local renderer = require('opencode.ui.renderer') + local events = require('opencode.ui.renderer.events') + local flush = require('opencode.ui.renderer.flush') + + helpers.replay_setup() + config.ui.output.max_messages = 2 + + state.session.set_active({ + id = 'ses_123', + title = 'Session', + time = { created = 1, updated = 1 }, + }) + state.renderer.set_messages({ + { + info = { id = 'msg_1', role = 'user', sessionID = 'ses_123', time = { created = 1 } }, + parts = { + { id = 'part_1', messageID = 'msg_1', sessionID = 'ses_123', type = 'text', text = 'first' }, + }, + }, + { + info = { id = 'msg_2', role = 'assistant', sessionID = 'ses_123', time = { created = 2 } }, + parts = { + { id = 'part_2', messageID = 'msg_2', sessionID = 'ses_123', type = 'text', text = 'second' }, + }, + }, + }) + + renderer._render_full_session_data(state.messages) + + events.on_message_updated({ + info = { id = 'msg_3', role = 'assistant', sessionID = 'ses_123', time = { created = 3 } }, + parts = {}, + }) + events.on_part_updated({ + part = { id = 'part_3', messageID = 'msg_3', sessionID = 'ses_123', type = 'text', text = 'third' }, + }) + flush.flush() + + assert.is_nil(renderer.get_rendered_message('msg_1')) + assert.is_not_nil(renderer.get_rendered_message('msg_2')) + assert.is_not_nil(renderer.get_rendered_message('msg_3')) + + local lines = vim.api.nvim_buf_get_lines(state.windows.output_buf, 0, -1, false) + assert.are.equal('> 1 older message is not displayed.', lines[1]) + + config.ui.output.max_messages = nil + end) + + it('updates the hidden-messages notice when an older hidden message is removed', function() + local renderer = require('opencode.ui.renderer') + local events = require('opencode.ui.renderer.events') + local flush = require('opencode.ui.renderer.flush') + + helpers.replay_setup() + config.ui.output.max_messages = 2 + + state.session.set_active({ + id = 'ses_123', + title = 'Session', + time = { created = 1, updated = 1 }, + }) + + renderer._render_full_session_data({ + { + info = { id = 'msg_1', role = 'user', sessionID = 'ses_123', time = { created = 1 } }, + parts = { + { id = 'part_1', messageID = 'msg_1', sessionID = 'ses_123', type = 'text', text = 'first' }, + }, + }, + { + info = { id = 'msg_2', role = 'assistant', sessionID = 'ses_123', time = { created = 2 } }, + parts = { + { id = 'part_2', messageID = 'msg_2', sessionID = 'ses_123', type = 'text', text = 'second' }, + }, + }, + { + info = { id = 'msg_3', role = 'assistant', sessionID = 'ses_123', time = { created = 3 } }, + parts = { + { id = 'part_3', messageID = 'msg_3', sessionID = 'ses_123', type = 'text', text = 'third' }, + }, + }, + { + info = { id = 'msg_4', role = 'assistant', sessionID = 'ses_123', time = { created = 4 } }, + parts = { + { id = 'part_4', messageID = 'msg_4', sessionID = 'ses_123', type = 'text', text = 'fourth' }, + }, + }, + }) + + events.on_message_removed({ sessionID = 'ses_123', messageID = 'msg_1' }) + flush.flush() + + local lines = vim.api.nvim_buf_get_lines(state.windows.output_buf, 0, -1, false) + assert.are.equal('> 1 older message is not displayed.', lines[1]) + + config.ui.output.max_messages = nil + end) + + it('updates the hidden-messages notice count after multiple hidden removals', function() + local renderer = require('opencode.ui.renderer') + local events = require('opencode.ui.renderer.events') + local flush = require('opencode.ui.renderer.flush') + + helpers.replay_setup() + config.ui.output.max_messages = 2 + + state.session.set_active({ + id = 'ses_123', + title = 'Session', + time = { created = 1, updated = 1 }, + }) + + renderer._render_full_session_data({ + { + info = { id = 'msg_1', role = 'user', sessionID = 'ses_123', time = { created = 1 } }, + parts = { + { id = 'part_1', messageID = 'msg_1', sessionID = 'ses_123', type = 'text', text = 'first' }, + }, + }, + { + info = { id = 'msg_2', role = 'assistant', sessionID = 'ses_123', time = { created = 2 } }, + parts = { + { id = 'part_2', messageID = 'msg_2', sessionID = 'ses_123', type = 'text', text = 'second' }, + }, + }, + { + info = { id = 'msg_3', role = 'assistant', sessionID = 'ses_123', time = { created = 3 } }, + parts = { + { id = 'part_3', messageID = 'msg_3', sessionID = 'ses_123', type = 'text', text = 'third' }, + }, + }, + { + info = { id = 'msg_4', role = 'assistant', sessionID = 'ses_123', time = { created = 4 } }, + parts = { + { id = 'part_4', messageID = 'msg_4', sessionID = 'ses_123', type = 'text', text = 'fourth' }, + }, + }, + }) + + events.on_message_removed({ sessionID = 'ses_123', messageID = 'msg_1' }) + flush.flush() + + local lines = vim.api.nvim_buf_get_lines(state.windows.output_buf, 0, -1, false) + assert.are.equal('> 1 older message is not displayed.', lines[1]) + + events.on_message_removed({ sessionID = 'ses_123', messageID = 'msg_2' }) + flush.flush() + + lines = vim.api.nvim_buf_get_lines(state.windows.output_buf, 0, -1, false) + assert.are.equal('----', lines[1]) + + config.ui.output.max_messages = nil + end) + it('ignores session.updated for non-active session IDs', function() local renderer = require('opencode.ui.renderer')