Skip to content

Commit 1d10bf9

Browse files
committed
feat(ui/output): add compact assistant headers
Add config.ui.output.compact_assistant_headers (boolean) to collapse consecutive assistant headers in the same mode into a minimal header showing only a right-aligned timestamp. Update formatter to accept a previous message and render minimal headers when appropriate. Add RenderState:get_previous_message, renderer navigation helpers, and pass the previous rendered message in renderer.flush. Update types, defaults, README example, and tests.
1 parent 2f73a21 commit 1d10bf9

8 files changed

Lines changed: 159 additions & 15 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,7 @@ require('opencode').setup({
234234
},
235235
output = {
236236
filetype = 'opencode_output', -- Filetype assigned to the output buffer (default: 'opencode_output')
237+
compact_assistant_headers = false, -- Collapse consecutive assistant headers in the same mode to a right-aligned timestamp only
237238
tools = {
238239
show_output = true, -- Show tools output [diffs, cmd output, etc.] (default: true)
239240
show_reasoning_output = true, -- Show reasoning/thinking steps output (default: true)

lua/opencode/config.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ M.defaults = {
139139
},
140140
output = {
141141
filetype = 'opencode_output',
142+
compact_assistant_headers = false,
142143
rendering = {
143144
markdown_debounce_ms = 250,
144145
on_data_rendered = nil,

lua/opencode/types.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,7 @@
181181
---@field rendering OpencodeUIOutputRenderingConfig
182182
---@field always_scroll_to_bottom boolean
183183
---@field filetype string
184+
---@field compact_assistant_headers boolean
184185

185186
---@class OpencodeUIPickerConfig
186187
---@field snacks_layout? snacks.picker.layout.Config

lua/opencode/ui/formatter.lua

Lines changed: 35 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -157,16 +157,16 @@ function M._format_error(output, message)
157157
end
158158

159159
---@param message OpencodeMessage
160+
---@param previous_message? OpencodeMessage
160161
---@return Output
161-
function M.format_message_header(message)
162+
function M.format_message_header(message, previous_message)
162163
local output = Output.new()
163164

164165
if message.info and message.info.id == '__opencode_revert_message__' then
165166
output:add_lines(M.separator)
166167
return output
167168
end
168169

169-
output:add_lines(M.separator)
170170
local role = message.info.role or 'unknown'
171171
local icon = message.info.role == 'user' and icons.get('header_user') or icons.get('header_assistant')
172172

@@ -190,21 +190,42 @@ function M.format_message_header(message)
190190
display_name = role:upper()
191191
end
192192

193-
output:add_extmark(output:get_line_count() - 1, {
194-
virt_text = {
195-
{ icon, role_hl },
196-
{ ' ' },
197-
{ display_name, role_hl },
198-
{ model_text, 'OpencodeHint' },
199-
{ debug_text, 'OpencodeHint' },
200-
},
201-
virt_text_win_col = -3,
202-
priority = 10,
203-
} --[[@as OutputExtmark]])
193+
local same_mode_as_previous = false
194+
if config.ui.output.compact_assistant_headers and role == 'assistant' and previous_message then
195+
local previous_role = previous_message.info and previous_message.info.role or nil
196+
local previous_mode = previous_message.info and previous_message.info.mode or state.current_mode
197+
local current_mode = message.info.mode or state.current_mode
198+
same_mode_as_previous = previous_role == 'assistant'
199+
and current_mode
200+
and previous_mode
201+
and current_mode ~= ''
202+
and previous_mode ~= ''
203+
and current_mode == previous_mode
204+
end
205+
206+
if not same_mode_as_previous then
207+
output:add_lines(M.separator)
208+
else
209+
output:add_line('')
210+
end
211+
212+
if not same_mode_as_previous then
213+
output:add_extmark(output:get_line_count() - 1, {
214+
virt_text = {
215+
{ icon, role_hl },
216+
{ ' ' },
217+
{ display_name, role_hl },
218+
{ model_text, 'OpencodeHint' },
219+
{ debug_text, 'OpencodeHint' },
220+
},
221+
virt_text_win_col = -3,
222+
priority = 10,
223+
} --[[@as OutputExtmark]])
224+
end
204225

205226
if time then
206227
output:add_extmark(output:get_line_count() - 1, {
207-
virt_text = { { ' ' .. util.format_time(time), 'OpencodeHint' } },
228+
virt_text = { { (same_mode_as_previous and '' or ' ') .. util.format_time(time), 'OpencodeHint' } },
208229
virt_text_pos = 'right_align',
209230
priority = 9,
210231
} --[[@as OutputExtmark]])

lua/opencode/ui/render_state.lua

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,23 @@ function RenderState:get_message(message_id)
207207
return self._messages[message_id]
208208
end
209209

210+
---@param messages OpencodeMessage[]
211+
---@param message_id string
212+
---@return RenderedMessage?
213+
function RenderState:get_previous_message(messages, message_id)
214+
for i = #messages, 1, -1 do
215+
local message = messages[i]
216+
if message and message.info and message.info.id == message_id then
217+
if i <= 1 then
218+
return nil
219+
end
220+
local previous_message = messages[i - 1]
221+
return previous_message and previous_message.info and self._messages[previous_message.info.id] or nil
222+
end
223+
end
224+
return nil
225+
end
226+
210227
---@param message_id string
211228
---@param part OpencodeMessagePart
212229
function RenderState:upsert_orphan_part(message_id, part)

lua/opencode/ui/renderer.lua

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,4 +249,34 @@ function M.get_rendered_message(message_id)
249249
return ctx.render_state:get_message(message_id) or nil
250250
end
251251

252+
---@param current_line integer
253+
---@return RenderedMessage|nil
254+
function M.get_next_rendered_message(current_line)
255+
local next_message = nil
256+
257+
for _, message in ipairs(state.messages or {}) do
258+
local rendered = message.info and message.info.id and ctx.render_state:get_message(message.info.id) or nil
259+
if rendered and rendered.line_start and rendered.line_start + 1 > current_line then
260+
next_message = rendered
261+
break
262+
end
263+
end
264+
265+
return next_message
266+
end
267+
268+
---@param current_line integer
269+
---@return RenderedMessage|nil
270+
function M.get_prev_rendered_message(current_line)
271+
for i = #(state.messages or {}), 1, -1 do
272+
local message = state.messages[i]
273+
local rendered = message and message.info and message.info.id and ctx.render_state:get_message(message.info.id) or nil
274+
if rendered and rendered.line_start and rendered.line_start + 1 < current_line then
275+
return rendered
276+
end
277+
end
278+
279+
return nil
280+
end
281+
252282
return M

lua/opencode/ui/renderer/flush.lua

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -291,7 +291,8 @@ local function format_message(message_id)
291291
end
292292

293293
local prev = ctx.formatted_messages[message_id]
294-
local formatted = formatter.format_message_header(message)
294+
local previous_rendered = ctx.render_state:get_previous_message(state.messages or {}, message_id)
295+
local formatted = formatter.format_message_header(message, previous_rendered and previous_rendered.message or nil)
295296

296297
if prev and lines_equal(prev.lines, formatted.lines) and extmarks_equal(prev.extmarks, formatted.extmarks) then
297298
-- no visible change

tests/unit/formatter_spec.lua

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@ local config = require('opencode.config')
33
local formatter = require('opencode.ui.formatter')
44
local Output = require('opencode.ui.output')
55
local state = require('opencode.state')
6+
local util = require('opencode.util')
67

78
describe('formatter', function()
89
before_each(function()
910
config.setup({
1011
ui = {
1112
output = {
13+
compact_assistant_headers = false,
1214
tools = {
1315
show_output = true,
1416
},
@@ -296,6 +298,76 @@ describe('formatter', function()
296298
assert.are.equal('BUILD', output.extmarks[1][1].virt_text[3][1])
297299
end)
298300

301+
it('renders minimal same-mode assistant headers with only right-aligned time', function()
302+
config.setup({
303+
ui = {
304+
output = {
305+
compact_assistant_headers = true,
306+
},
307+
},
308+
})
309+
310+
local output = formatter.format_message_header({
311+
info = {
312+
id = 'msg_current',
313+
role = 'assistant',
314+
sessionID = 'ses_1',
315+
mode = 'build',
316+
time = {
317+
created = 1,
318+
},
319+
},
320+
parts = {},
321+
}, {
322+
info = {
323+
id = 'msg_prev',
324+
role = 'assistant',
325+
sessionID = 'ses_1',
326+
mode = 'build',
327+
},
328+
parts = {},
329+
})
330+
331+
assert.are.same({ '', '' }, output.lines)
332+
assert.is_truthy(output.extmarks[0])
333+
assert.are.equal(util.format_time(1), output.extmarks[0][1].virt_text[1][1])
334+
assert.are.equal('right_align', output.extmarks[0][1].virt_text_pos)
335+
end)
336+
337+
it('keeps full assistant headers when the mode changes', function()
338+
config.setup({
339+
ui = {
340+
output = {
341+
compact_assistant_headers = true,
342+
},
343+
},
344+
})
345+
346+
local output = formatter.format_message_header({
347+
info = {
348+
id = 'msg_current',
349+
role = 'assistant',
350+
sessionID = 'ses_1',
351+
mode = 'build',
352+
time = {
353+
created = 1,
354+
},
355+
},
356+
parts = {},
357+
}, {
358+
info = {
359+
id = 'msg_prev',
360+
role = 'assistant',
361+
sessionID = 'ses_1',
362+
mode = 'plan',
363+
},
364+
parts = {},
365+
})
366+
367+
assert.are.same({ '----', '', '' }, output.lines)
368+
assert.are.equal('BUILD', output.extmarks[1][1].virt_text[3][1])
369+
end)
370+
299371
it('anchors task child-session action to the rendered task block', function()
300372
local message = {
301373
info = {

0 commit comments

Comments
 (0)