Skip to content

Commit cf1c5fd

Browse files
Danilo Verde RibeiroDanilo Verde Ribeiro
authored andcommitted
feat(ui): stabilize assistant_mode labeling and improve session formatting robustness\n\n- Persist assistant_mode onto latest assistant message after run\n- Backfill assistant_mode for historical assistant messages for stable display names\n- Use assistant_mode (uppercase) in headers instead of generic ASSISTANT\n- Improve revert stats typing and snapshot action anchoring with display_line + range\n- Harden get_message_at_line nil checks to avoid indexing errors\n- Extend MessagePart.type to include patch and step-start; allow OutputExtmark function form\n- Add assistant_mode field to Message type\n- Fix synthetic user message check (part.synthetic ~= true)\n- Improve callout width handling when window invalid; fallback to config width or 80\n- Add type annotations for tool formatting inputs/metadata/output\n- Correct diff virt_text structure and highlight group names\n- General annotation cleanup and stability improvements
1 parent 3eb419b commit cf1c5fd

3 files changed

Lines changed: 87 additions & 21 deletions

File tree

lua/opencode/core.lua

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,16 @@ function M.after_run(prompt)
142142
require('opencode.history').write(prompt)
143143

144144
if state.windows then
145+
-- Persist assistant_mode on the latest assistant message if available
146+
local msgs = state.messages
147+
if msgs and #msgs > 0 then
148+
local last = msgs[#msgs]
149+
if last and last.role == 'assistant' then
150+
if not last.assistant_mode or last.assistant_mode == '' then
151+
last.assistant_mode = state.current_mode
152+
end
153+
end
154+
end
145155
ui.render_output()
146156
end
147157
end

lua/opencode/types.lua

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,7 @@
230230
---@field msg_idx number|nil Message index in session
231231
---@field part_idx number|nil Part index in message
232232
---@field role 'user'|'assistant'|'system'|nil Message role
233-
---@field type 'text'|'tool'|'header'|nil Message part type
233+
---@field type 'text'|'tool'|'header'|'patch'|'step-start'|nil Message part type
234234
---@field snapshot? string|nil snapshot commit hash
235235

236236
---@class OutputAction
@@ -241,7 +241,7 @@
241241
---@field display_line number Line number to display the action
242242
---@field range? { from: number, to: number } Optional range for the action
243243

244-
---@alias OutputExtmark vim.api.keyset.set_extmark
244+
---@alias OutputExtmark vim.api.keyset.set_extmark|fun():vim.api.keyset.set_extmark
245245

246246
---@class Message
247247
---@field id string Unique message identifier
@@ -256,6 +256,7 @@
256256
---@field providerID string Provider identifier
257257
---@field role 'user'|'assistant'|'system' Role of the message sender
258258
---@field system_role string|nil Role defined in system messages
259+
---@field assistant_mode string|nil Assistant mode active when message was created
259260
---@field error table
260261

261262
---@class RestorePoint

lua/opencode/ui/session_formatter.lua

Lines changed: 74 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,19 @@ function M.format_session(session)
3232
M.output:add_line('')
3333
M.output:add_line('')
3434

35+
-- Backfill assistant_mode for all assistant messages once so names remain stable
36+
local last_seen_mode = state.current_mode
37+
for _, amsg in ipairs(state.messages) do
38+
if amsg.role == 'assistant' then
39+
if amsg.assistant_mode and amsg.assistant_mode ~= '' then
40+
last_seen_mode = amsg.assistant_mode
41+
else
42+
amsg.assistant_mode = last_seen_mode or state.current_mode or 'assistant'
43+
last_seen_mode = amsg.assistant_mode
44+
end
45+
end
46+
end
47+
3548
for i, msg in ipairs(state.messages) do
3649
M.output:add_lines(M.separator)
3750
state.current_message = msg
@@ -49,6 +62,7 @@ function M.format_session(session)
4962
end
5063

5164
if session.revert and session.revert.messageID == msg.id then
65+
---@type {messages: number, tool_calls: number, files: table<string, {additions: number, deletions: number}>}
5266
local revert_stats = M._calculate_revert_stats(state.messages, i, session.revert)
5367
M._format_revert_message(revert_stats)
5468
break
@@ -61,7 +75,7 @@ function M.format_session(session)
6175
M.output:add_metadata(M._current)
6276

6377
if part.type == 'text' and part.text then
64-
if msg.role == 'user' and not part.synthetic == true then
78+
if msg.role == 'user' and part.synthetic ~= true then
6579
state.last_user_message = msg
6680
M._format_user_message(vim.trim(part.text), msg)
6781
elseif msg.role == 'assistant' then
@@ -85,12 +99,18 @@ function M.format_session(session)
8599
end
86100

87101
---@param line number Buffer line number
88-
---@return {message: Message, part: MessagePart, type: string, msg_idx: number, part_idx: number}|nil
102+
---@return {message: Message, part: MessagePart, msg_idx: number, part_idx: number}|nil
89103
function M.get_message_at_line(line)
90104
local metadata = M.output:get_nearest_metadata(line)
91105
if metadata and metadata.msg_idx and metadata.part_idx then
92-
local msg = state.messages[metadata.msg_idx]
106+
local msg = state.messages and state.messages[metadata.msg_idx]
107+
if not msg or not msg.parts then
108+
return nil
109+
end
93110
local part = msg.parts[metadata.part_idx]
111+
if not part then
112+
return nil
113+
end
94114
return {
95115
message = msg,
96116
part = part,
@@ -109,7 +129,7 @@ end
109129
---@param messages Message[] All messages in the session
110130
---@param revert_index number Index of the message where revert occurred
111131
---@param revert_info SessionRevertInfo Revert information
112-
---@return {messages: number, tool_calls: number, files: {additions: number, deletions: number}[]}
132+
---@return {messages: number, tool_calls: number, files: table<string, {additions: number, deletions: number}>}
113133
function M._calculate_revert_stats(messages, revert_index, revert_info)
114134
local stats = {
115135
messages = 0,
@@ -185,10 +205,10 @@ function M._format_revert_message(stats)
185205
end
186206
if #file_diff > 0 then
187207
local line_str = string.format(icons.get('file') .. '%s: %s', file, table.concat(file_diff, ' '))
188-
local line_idx = M.output:add_line(line_str)
189-
local col = #(' ' .. file .. ': ')
208+
local line_idx = M.output:add_line(line_str) ---@type number
209+
local col = #(' ' .. file .. ': ') ---@type number
190210
for _, diff in ipairs(file_diff) do
191-
local hl_group = diff:sub(1, 1) == '+' and 'OpencodeDiffAddText' or 'OpencodeDiffDeleteText'
211+
local hl_group = diff:sub(1, 1) == '+' and 'OpencodeDiffAddText' or 'OpencodeDiffDeleteText' ---@type string
192212
M.output:add_extmark(line_idx, {
193213
virt_text = { { diff, hl_group } },
194214
virt_text_pos = 'inline',
@@ -206,23 +226,32 @@ function M._format_patch(part)
206226
local restore_points = snapshot.get_restore_points_by_parent(part.hash)
207227
M.output:add_empty_line()
208228
M._format_action(icons.get('snapshot') .. ' **Created Snapshot**', vim.trim(part.hash:sub(1, 8)))
229+
local snapshot_header_line = M.output:get_line_count()
230+
231+
-- Anchor all snapshot-level actions to the snapshot header line
209232
M.output:add_action({
210233
text = '[R]evert file',
211234
type = 'diff_revert_selected_file',
212235
args = { part.hash },
213236
key = 'R',
237+
display_line = snapshot_header_line,
238+
range = { from = snapshot_header_line, to = snapshot_header_line },
214239
})
215240
M.output:add_action({
216241
text = 'Revert [A]ll',
217242
type = 'diff_revert_all',
218243
args = { part.hash },
219244
key = 'A',
245+
display_line = snapshot_header_line,
246+
range = { from = snapshot_header_line, to = snapshot_header_line },
220247
})
221248
M.output:add_action({
222249
text = '[D]iff',
223250
type = 'diff_open',
224251
args = { part.hash },
225252
key = 'D',
253+
display_line = snapshot_header_line,
254+
range = { from = snapshot_header_line, to = snapshot_header_line },
226255
})
227256

228257
if #restore_points > 0 then
@@ -235,17 +264,22 @@ function M._format_patch(part)
235264
util.time_ago(restore_point.created_at)
236265
)
237266
)
267+
local restore_line = M.output:get_line_count() ---@type number
238268
M.output:add_action({
239269
text = 'Restore [A]ll',
240270
type = 'diff_restore_snapshot_all',
241271
args = { part.hash },
242272
key = 'A',
273+
display_line = restore_line,
274+
range = { from = restore_line, to = restore_line },
243275
})
244276
M.output:add_action({
245277
text = '[R]estore file',
246278
type = 'diff_restore_snapshot_file',
247279
args = { part.hash },
248280
key = 'R',
281+
display_line = restore_line,
282+
range = { from = restore_line, to = restore_line },
249283
})
250284
end
251285
end
@@ -258,6 +292,7 @@ function M._format_error(message)
258292
end
259293

260294
---@param message Message
295+
---@param msg_idx number Message index in the session
261296
function M._format_message_header(message, msg_idx)
262297
local role = message.role or 'unknown'
263298
local icon = message.role == 'user' and icons.get('header_user') or icons.get('header_assistant')
@@ -270,14 +305,28 @@ function M._format_message_header(message, msg_idx)
270305

271306
M.output:add_empty_line()
272307
M.output:add_metadata({ msg_idx = msg_idx, part_idx = 1, role = role, type = 'header' })
308+
309+
-- Use the assistant_mode stored on the message only (stable label)
310+
local display_name
311+
if role == 'assistant' then
312+
local mode = message.assistant_mode
313+
if mode and mode ~= '' then
314+
display_name = mode:upper()
315+
else
316+
display_name = 'ASSISTANT'
317+
end
318+
else
319+
display_name = role:upper()
320+
end
321+
273322
M.output:add_extmark(M.output:get_line_count(), {
274323
virt_text = {
275324
{ icon, role_hl },
276325
{ ' ' },
277-
{ role:upper(), role_hl },
326+
{ display_name, role_hl },
278327
{ model_text, 'OpencodeHint' },
279-
{ time_text, 'OpenCodeHint' },
280-
{ debug_text, 'OpenCodeHint' },
328+
{ time_text, 'OpencodeHint' },
329+
{ debug_text, 'OpencodeHint' },
281330
},
282331
virt_text_win_col = -3,
283332
priority = 10,
@@ -287,9 +336,11 @@ function M._format_message_header(message, msg_idx)
287336
end
288337

289338
---@param callout string Callout type (e.g., 'ERROR', 'TODO')
339+
---@param text string Callout text content
340+
---@param title? string Optional title for the callout
290341
function M._format_callout(callout, text, title)
291342
title = title and title .. ' ' or ''
292-
local win_width = vim.api.nvim_win_get_width(state.windows.output_win)
343+
local win_width = (state.windows and state.windows.output_win and vim.api.nvim_win_is_valid(state.windows.output_win)) and vim.api.nvim_win_get_width(state.windows.output_win) or config.ui.window_width or 80
293344
if #text > win_width - 4 then
294345
local ok, substituted = pcall(vim.fn.substitute, text, '\v(.{' .. (win_width - 8) .. '})', '\1\n', 'g')
295346
text = ok and substituted or text
@@ -315,7 +366,7 @@ function M._format_user_message(text, message)
315366
context = context_module.extract_from_opencode_message(message)
316367
end
317368

318-
local start_line = M.output:get_line_count() - 1
369+
local start_line = M.output:get_line_count() - 1 ---@type number
319370

320371
M.output:add_empty_line()
321372
M.output:add_lines(vim.split(context.prompt, '\n'))
@@ -333,7 +384,7 @@ function M._format_user_message(text, message)
333384
M.output:add_line(string.format('[%s](%s)', path, context.current_file))
334385
end
335386

336-
local end_line = M.output:get_line_count()
387+
local end_line = M.output:get_line_count() ---@type number
337388

338389
M._add_vertical_border(start_line, end_line, 'OpencodeMessageRoleUser', -3)
339390
end
@@ -345,6 +396,7 @@ function M._format_assistant_message(text)
345396
end
346397

347398
---@param type string Tool type (e.g., 'run', 'read', 'edit', etc.)
399+
---@param value string Value associated with the action (e.g., filename, command)
348400
function M._format_action(type, value)
349401
if not type or not value then
350402
return
@@ -473,9 +525,12 @@ function M._format_tool(part)
473525
end
474526

475527
local start_line = M.output:get_line_count() + 1
476-
local input = part.state and part.state.input or {}
477-
local metadata = part.state.metadata or {}
478-
local output = part.state and part.state.output or ''
528+
---@type TaskToolInput|BashToolInput|FileToolInput|TodoToolInput|GlobToolInput|GrepToolInput|WebFetchToolInput|ListToolInput
529+
local input = (part.state and part.state.input) or {}
530+
---@type ToolMetadataBase|TaskToolMetadata|WebFetchToolMetadata|BashToolMetadata|FileToolMetadata|GlobToolMetadata|GrepToolMetadata|ListToolMetadata
531+
local metadata = (part.state and part.state.metadata) or {}
532+
---@type string
533+
local output = (part.state and part.state.output) or ''
479534

480535
if tool == 'bash' then
481536
M._format_bash_tool(input --[[@as BashToolInput]], metadata --[[@as BashToolMetadata]])
@@ -503,7 +558,7 @@ function M._format_tool(part)
503558

504559
M.output:add_empty_line()
505560

506-
local end_line = M.output:get_line_count()
561+
local end_line = M.output:get_line_count() ---@type number
507562
if end_line - start_line > 1 then
508563
M._add_vertical_border(start_line, end_line - 1, 'OpencodeToolBorder', -1)
509564
end
@@ -532,7 +587,7 @@ function M._format_task_tool(input, metadata, output)
532587
end
533588
end
534589

535-
local end_line = M.output:get_line_count()
590+
local end_line = M.output:get_line_count() ---@type number
536591
M.output:add_action({
537592
text = '[S]elect Child Session',
538593
type = 'select_child_session',
@@ -569,7 +624,7 @@ function M._format_diff(code, file_type)
569624
return {
570625
end_col = 0,
571626
end_row = line_idx,
572-
virt_text = { { first_char, { hl_group } } },
627+
virt_text = { { first_char, hl_group } },
573628
hl_group = hl_group,
574629
hl_eol = true,
575630
priority = 5000,

0 commit comments

Comments
 (0)