Skip to content

Commit dfe5577

Browse files
committed
feat(ui/output): add max_messages option to limit rendered messages
Add support for ui.output.max_messages to cap how many messages are kept rendered in the output buffer. When the configured limit is exceeded the oldest messages for the active session are evicted and a hidden-messages notice is inserted/updated to indicate how many older messages are not displayed. This should fix #320
1 parent a52636b commit dfe5577

8 files changed

Lines changed: 538 additions & 16 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,7 @@ require('opencode').setup({
243243
markdown_debounce_ms = 250, -- Debounce time for markdown rendering on new data (default: 250ms)
244244
on_data_rendered = nil, -- Called when new data is rendered; set to false to disable default RenderMarkdown/Markview behavior
245245
},
246+
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)
246247
},
247248
input = {
248249
min_height = 0.10, -- min height of prompt input as percentage of window height

lua/opencode/config.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@ M.defaults = {
155155
show_output = true,
156156
show_reasoning_output = true,
157157
},
158+
max_messages = nil,
158159
always_scroll_to_bottom = false,
159160
},
160161
questions = {

lua/opencode/types.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,7 @@
179179
---@class OpencodeUIOutputConfig
180180
---@field tools { show_output: boolean, show_reasoning_output: boolean }
181181
---@field rendering OpencodeUIOutputRenderingConfig
182+
---@field max_messages integer|nil
182183
---@field always_scroll_to_bottom boolean
183184
---@field filetype string
184185
---@field compact_assistant_headers boolean

lua/opencode/ui/formatter.lua

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,18 @@ function M._format_revert_message(session_data, start_idx)
9999
return output
100100
end
101101

102+
---@param hidden_count integer
103+
---@return Output
104+
function M._format_hidden_messages_notice(hidden_count)
105+
local output = Output.new()
106+
local message_text = hidden_count == 1 and 'message is' or 'messages are'
107+
108+
output:add_line(string.format('> %d older %s not displayed.', hidden_count, message_text))
109+
output:add_empty_line()
110+
111+
return output
112+
end
113+
102114
---@param output Output
103115
---@param text string
104116
---@param action_type string
@@ -167,6 +179,10 @@ function M.format_message_header(message, previous_message)
167179
return output
168180
end
169181

182+
if message.info and message.info.id == '__opencode_hidden_messages_notice__' then
183+
return output
184+
end
185+
170186
local role = message.info.role or 'unknown'
171187
local icon = message.info.role == 'user' and icons.get('header_user') or icons.get('header_assistant')
172188

@@ -664,6 +680,12 @@ function M.format_part(part, message, is_last_part, get_child_parts)
664680
output = M._format_revert_message(state.messages or {}, revert_index)
665681
content_added = output:get_line_count() > 0
666682
end
683+
elseif part.type == 'hidden-messages-display' then
684+
local hidden_count = part.state and part.state.hidden_count
685+
if type(hidden_count) == 'number' and hidden_count > 0 then
686+
output = M._format_hidden_messages_notice(hidden_count)
687+
content_added = output:get_line_count() > 0
688+
end
667689
end
668690
end
669691

lua/opencode/ui/renderer.lua

Lines changed: 239 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,222 @@ local flush = require('opencode.ui.renderer.flush')
99
local scroll = require('opencode.ui.renderer.scroll')
1010

1111
local M = {}
12+
local HIDDEN_MESSAGES_NOTICE_MESSAGE_ID = '__opencode_hidden_messages_notice__'
13+
local HIDDEN_MESSAGES_NOTICE_PART_ID = '__opencode_hidden_messages_notice_part__'
14+
15+
---@return integer|nil
16+
local function get_max_rendered_messages()
17+
local limit = config.ui and config.ui.output and config.ui.output.max_messages
18+
if type(limit) ~= 'number' or limit <= 0 then
19+
return nil
20+
end
21+
return math.floor(limit)
22+
end
23+
24+
---@param message OpencodeMessage|nil
25+
---@return boolean
26+
local function is_renderer_synthetic_message(message)
27+
local message_id = message and message.info and message.info.id
28+
return message_id == '__opencode_revert_message__' or message_id == HIDDEN_MESSAGES_NOTICE_MESSAGE_ID
29+
end
30+
31+
---@param message OpencodeMessage|nil
32+
---@return boolean
33+
local function is_active_session_message(message)
34+
local session_id = message and message.info and message.info.sessionID
35+
return session_id ~= nil and state.active_session and state.active_session.id == session_id
36+
end
37+
38+
---@param messages OpencodeMessage[]|nil
39+
---@return OpencodeMessage[]
40+
local function get_real_session_messages(messages)
41+
return vim.tbl_filter(function(message)
42+
return is_active_session_message(message) and not is_renderer_synthetic_message(message)
43+
end, messages or {})
44+
end
45+
46+
---@param messages OpencodeMessage[]|nil
47+
---@return integer|nil
48+
local function get_revert_index(messages)
49+
local revert = state.active_session and state.active_session.revert
50+
local revert_message_id = revert and revert.messageID
51+
if not revert_message_id then
52+
return nil
53+
end
54+
55+
local real_messages = get_real_session_messages(messages)
56+
for i, message in ipairs(real_messages) do
57+
if message.info and message.info.id == revert_message_id then
58+
return i
59+
end
60+
end
61+
62+
return nil
63+
end
64+
65+
---@param messages OpencodeMessage[]|nil
66+
---@return OpencodeMessage[] visible_messages
67+
---@return integer hidden_count
68+
local function get_visible_session_messages(messages)
69+
local real_messages = get_real_session_messages(messages)
70+
local revert_index = get_revert_index(messages)
71+
if revert_index then
72+
real_messages = vim.list_slice(real_messages, 1, revert_index - 1)
73+
end
74+
75+
local limit = get_max_rendered_messages()
76+
if not limit or #real_messages <= limit then
77+
return real_messages, 0
78+
end
79+
80+
local start_index = #real_messages - limit + 1
81+
return vim.list_slice(real_messages, start_index, #real_messages), start_index - 1
82+
end
83+
84+
---@param hidden_count integer
85+
---@return OpencodeMessage
86+
local function build_hidden_messages_notice(hidden_count)
87+
local session_id = state.active_session and state.active_session.id or ''
88+
return {
89+
info = {
90+
id = HIDDEN_MESSAGES_NOTICE_MESSAGE_ID,
91+
sessionID = session_id,
92+
role = 'system',
93+
},
94+
parts = {
95+
{
96+
id = HIDDEN_MESSAGES_NOTICE_PART_ID,
97+
messageID = HIDDEN_MESSAGES_NOTICE_MESSAGE_ID,
98+
sessionID = session_id,
99+
type = 'hidden-messages-display',
100+
state = {
101+
hidden_count = hidden_count,
102+
},
103+
},
104+
},
105+
}
106+
end
107+
108+
---@param message_id string
109+
---@return OpencodeMessage|nil
110+
local function find_message_in_state(message_id)
111+
for _, message in ipairs(state.messages or {}) do
112+
if message.info and message.info.id == message_id then
113+
return message
114+
end
115+
end
116+
return nil
117+
end
118+
119+
---@param message OpencodeMessage
120+
local function ensure_message_rendered(message)
121+
local message_id = message.info and message.info.id
122+
if not message_id or ctx.render_state:get_message(message_id) then
123+
return
124+
end
125+
126+
ctx.render_state:set_message(message)
127+
flush.mark_message_dirty(message_id)
128+
129+
for _, part in ipairs(message.parts or {}) do
130+
if part.id and part.type ~= 'step-start' and part.type ~= 'step-finish' then
131+
ctx.render_state:set_part(part)
132+
flush.mark_part_dirty(part.id, message_id)
133+
end
134+
end
135+
end
136+
137+
---@param hidden_count integer
138+
local function upsert_hidden_messages_notice(hidden_count)
139+
local existing_message = ctx.render_state:get_message(HIDDEN_MESSAGES_NOTICE_MESSAGE_ID)
140+
local notice_message = build_hidden_messages_notice(hidden_count)
141+
142+
if not existing_message then
143+
ensure_message_rendered(notice_message)
144+
return
145+
end
146+
147+
local existing_part = ctx.render_state:get_part(HIDDEN_MESSAGES_NOTICE_PART_ID)
148+
if not existing_part or not existing_part.part then
149+
hide_rendered_message(HIDDEN_MESSAGES_NOTICE_MESSAGE_ID)
150+
ensure_message_rendered(notice_message)
151+
return
152+
end
153+
154+
ctx.render_state:set_message(notice_message, existing_message.line_start, existing_message.line_end)
155+
ctx.render_state:set_part(notice_message.parts[1], existing_part.line_start, existing_part.line_end)
156+
flush.mark_part_dirty(HIDDEN_MESSAGES_NOTICE_PART_ID, HIDDEN_MESSAGES_NOTICE_MESSAGE_ID)
157+
end
158+
159+
---@param message_id string
160+
local function hide_rendered_message(message_id)
161+
local rendered_message = ctx.render_state:get_message(message_id)
162+
local message = rendered_message and rendered_message.message or find_message_in_state(message_id)
163+
if not message then
164+
return
165+
end
166+
167+
ctx.render_state:clear_orphan_parts(message_id)
168+
for _, part in ipairs(message.parts or {}) do
169+
if part.id then
170+
flush.queue_part_removal(part.id)
171+
end
172+
end
173+
flush.queue_message_removal(message_id)
174+
end
175+
176+
local function reconcile_rendered_message_limit()
177+
if not state.active_session or not state.messages then
178+
return
179+
end
180+
181+
local limit = get_max_rendered_messages()
182+
if not limit then
183+
if ctx.render_state:get_message(HIDDEN_MESSAGES_NOTICE_MESSAGE_ID) then
184+
hide_rendered_message(HIDDEN_MESSAGES_NOTICE_MESSAGE_ID)
185+
end
186+
return
187+
end
188+
189+
local visible_messages, hidden_count = get_visible_session_messages(state.messages)
190+
local visible_ids = {}
191+
for _, message in ipairs(visible_messages) do
192+
local message_id = message.info and message.info.id
193+
if message_id then
194+
visible_ids[message_id] = true
195+
ensure_message_rendered(message)
196+
end
197+
end
198+
199+
for _, message in ipairs(get_real_session_messages(state.messages)) do
200+
local message_id = message.info and message.info.id
201+
if message_id and not visible_ids[message_id] and ctx.render_state:get_message(message_id) then
202+
hide_rendered_message(message_id)
203+
end
204+
end
205+
206+
if hidden_count > 0 then
207+
upsert_hidden_messages_notice(hidden_count)
208+
elseif ctx.render_state:get_message(HIDDEN_MESSAGES_NOTICE_MESSAGE_ID) then
209+
hide_rendered_message(HIDDEN_MESSAGES_NOTICE_MESSAGE_ID)
210+
end
211+
end
212+
213+
---@param message_id string|nil
214+
---@return boolean
215+
local function is_message_visible(message_id)
216+
if not message_id then
217+
return false
218+
end
219+
220+
for _, message in ipairs(select(1, get_visible_session_messages(state.messages))) do
221+
if message.info and message.info.id == message_id then
222+
return true
223+
end
224+
end
225+
226+
return false
227+
end
12228

13229
-- Expose event handlers on M so tests can call them directly and subscriptions
14230
-- can be stubbed cleanly (e.g. stub(renderer, '_render_full_session_data'))
@@ -100,22 +316,35 @@ end
100316
function M._render_full_session_data(session_data, opts)
101317
opts = opts or {}
102318
M.reset()
319+
state.renderer.set_messages(vim.deepcopy(session_data or {}))
103320

104321
if not state.active_session or not state.messages then
105322
return
106323
end
107324

108-
local revert_index = nil
325+
local visible_messages, hidden_count = get_visible_session_messages(state.messages)
326+
local revert_index = get_revert_index(state.messages)
109327

110328
flush.begin_bulk_mode()
111329

112-
for i, msg in ipairs(session_data) do
113-
if state.active_session.revert and state.active_session.revert.messageID == msg.info.id then
114-
revert_index = i
115-
end
116-
events.on_message_updated({ info = msg.info }, revert_index)
330+
if hidden_count > 0 then
331+
local hidden_notice = build_hidden_messages_notice(hidden_count)
332+
events.on_message_updated(hidden_notice)
333+
events.on_part_updated({ part = hidden_notice.parts[1] })
334+
end
335+
336+
for _, msg in ipairs(visible_messages) do
337+
events.on_message_updated({ info = msg.info })
117338
for _, part in ipairs(msg.parts or {}) do
118-
events.on_part_updated({ part = part }, revert_index)
339+
events.on_part_updated({ part = part })
340+
end
341+
end
342+
343+
for _, msg in ipairs(state.messages) do
344+
if msg.info and msg.info.sessionID ~= state.active_session.id then
345+
for _, part in ipairs(msg.parts or {}) do
346+
events.on_part_updated({ part = part })
347+
end
119348
end
120349
end
121350

@@ -234,6 +463,9 @@ function M.on_session_changed(_, new, old)
234463
end
235464
end
236465

466+
M.reconcile_rendered_message_limit = reconcile_rendered_message_limit
467+
M.is_message_visible = is_message_visible
468+
237469
---Scroll to bottom after all queued events have been processed
238470
function M.on_emit_events_finished()
239471
M.scroll_to_bottom()

lua/opencode/ui/renderer/buffer.lua

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,22 @@ local pinned_bottom_message_ids = {
99
['question-display-message'] = true,
1010
}
1111

12+
local pinned_top_message_ids = {
13+
['__opencode_hidden_messages_notice__'] = true,
14+
}
15+
1216
---@param message_id string|nil
1317
---@return boolean
1418
local function is_pinned_bottom_message(message_id)
1519
return message_id ~= nil and pinned_bottom_message_ids[message_id] == true
1620
end
1721

22+
---@param message_id string|nil
23+
---@return boolean
24+
local function is_pinned_top_message(message_id)
25+
return message_id ~= nil and pinned_top_message_ids[message_id] == true
26+
end
27+
1828
---@param extmarks table<number, OutputExtmark[]|fun(): OutputExtmark>[]|table<number, OutputExtmark[]>|nil
1929
---@return boolean
2030
local function has_extmarks(extmarks)
@@ -247,6 +257,10 @@ local function get_message_insert_line(message_id)
247257
return rendered_message.line_start
248258
end
249259

260+
if is_pinned_top_message(message_id) then
261+
return 0
262+
end
263+
250264
local line_count = output_window.get_buf_line_count()
251265
local append_at = math.max(line_count - 1, 0)
252266
if line_count == 1 then

0 commit comments

Comments
 (0)