Skip to content

Commit c07f293

Browse files
cameronrsudo-tee
authored andcommitted
fix(renderer): render errors after last part
1 parent bebe01c commit c07f293

File tree

5 files changed

+420
-26
lines changed

5 files changed

+420
-26
lines changed

lua/opencode/ui/formatter.lua

Lines changed: 25 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ function M._format_revert_message(session_data, start_idx)
145145
virt_text_pos = 'inline',
146146
virt_text_win_col = col,
147147
priority = 1000,
148-
})
148+
} --[[@as OutputExtmark]])
149149
col = col + #diff + 1
150150
end
151151
end
@@ -270,9 +270,16 @@ function M.format_message_header(message)
270270
},
271271
virt_text_win_col = -3,
272272
priority = 10,
273-
})
273+
} --[[@as OutputExtmark]])
274274

275-
if role == 'assistant' and message.info.error and message.info.error ~= '' then
275+
-- Only want to show the error if we have no parts. If we have parts, they'll
276+
-- handle rendering the error
277+
if
278+
role == 'assistant'
279+
and message.info.error
280+
and message.info.error ~= ''
281+
and (not message.parts or #message.parts == 0)
282+
then
276283
local error = message.info.error
277284
local error_messgage = error.data and error.data.message or vim.inspect(error)
278285

@@ -676,19 +683,24 @@ function M._add_vertical_border(output, start_line, end_line, hl_group, win_col)
676683
virt_text_pos = 'overlay',
677684
virt_text_win_col = win_col,
678685
virt_text_repeat_linebreak = true,
679-
})
686+
} --[[@as OutputExtmark]])
680687
end
681688
end
682689

683690
---Formats a single message part and returns the resulting output object
684691
---@param part OpencodeMessagePart The part to format
685692
---@param message? OpencodeMessage Optional message object to extract role and mentions from
693+
---@param is_last_part? boolean Whether this is the last part in the message, used to show an error if there is one
686694
---@return Output
687-
function M.format_part(part, message)
695+
function M.format_part(part, message, is_last_part)
688696
local output = Output.new()
689697

698+
if not message or not message.info or not message.info.role then
699+
return output
700+
end
701+
690702
local content_added = false
691-
local role = message and message.info and message.info.role
703+
local role = message.info.role
692704

693705
if role == 'user' then
694706
if part.type == 'text' and part.text then
@@ -722,18 +734,14 @@ function M.format_part(part, message)
722734
output:add_empty_line()
723735
end
724736

725-
return output
726-
end
727-
728-
---@param error_text string
729-
---@return Output
730-
function M.format_error_callout(error_text)
731-
local temp_output = Output.new()
732-
733-
temp_output:add_empty_line()
734-
M._format_callout(temp_output, 'ERROR', error_text)
737+
if is_last_part and role == 'assistant' and message.info.error and message.info.error ~= '' then
738+
local error = message.info.error
739+
local error_messgage = error.data and error.data.message or vim.inspect(error)
740+
M._format_callout(output, 'ERROR', error_messgage)
741+
output:add_empty_line()
742+
end
735743

736-
return temp_output
744+
return output
737745
end
738746

739747
return M

lua/opencode/ui/renderer.lua

Lines changed: 58 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -450,15 +450,23 @@ function M.on_message_updated(message, revert_index)
450450
end
451451

452452
if found_msg then
453-
-- see if an error was added (or removed). have to check before we set
454-
-- found_msg.info = message.info below
455-
local rerender_message = not vim.deep_equal(found_msg.info.error, msg.info.error)
453+
local error_changed = not vim.deep_equal(found_msg.info.error, msg.info.error)
456454

457455
found_msg.info = msg.info
458456

459-
if rerender_message and not revert_index then
460-
local header_data = formatter.format_message_header(found_msg)
461-
M._replace_message_in_buffer(msg.info.id, header_data)
457+
--- NOTE: error handling is a bit messy because errors come in on messages
458+
--- but we want to display the error at the end. In this case, we an error
459+
--- was added to this message. We find the last part and re-render it to
460+
--- display the message. If there are no parts, we'll re-render the message
461+
462+
if error_changed and not revert_index then
463+
local last_part_id = M._get_last_part_for_message(found_msg)
464+
if last_part_id then
465+
M._rerender_part(last_part_id)
466+
else
467+
local header_data = formatter.format_message_header(found_msg)
468+
M._replace_message_in_buffer(msg.info.id, header_data)
469+
end
462470
end
463471
else
464472
table.insert(state.messages, msg)
@@ -507,6 +515,9 @@ function M.on_part_updated(properties, revert_index)
507515
local part_data = M._render_state:get_part(part.id)
508516
local is_new_part = not part_data
509517

518+
local prev_last_part_id = M._get_last_part_for_message(message)
519+
local is_last_part = is_new_part or (prev_last_part_id == part.id)
520+
510521
if is_new_part then
511522
table.insert(message.parts, part)
512523
else
@@ -528,14 +539,33 @@ function M.on_part_updated(properties, revert_index)
528539
M._render_state:update_part_data(part)
529540
end
530541

531-
local formatted = formatter.format_part(part, message)
542+
local formatted = formatter.format_part(part, message, is_last_part)
532543

533544
if revert_index and is_new_part then
534545
return
535546
end
536547

537548
if is_new_part then
538549
M._insert_part_to_buffer(part.id, formatted)
550+
551+
if message.info.error then
552+
--- NOTE: More error display code. As mentioned above, errors come in on messages
553+
--- but we want to display them after parts so we tack the error onto the last
554+
--- part. When a part is added and there's an error, we need to rerender
555+
--- previous last part so it doesn't also display the message. If there was no previous
556+
--- part, then we need to rerender the header so it doesn't display the error
557+
558+
vim.notify('new part and error: ' .. part.id)
559+
560+
if not prev_last_part_id then
561+
-- no previous part, we're the first part, re-render the message header
562+
-- so it doesn't also display the error
563+
local header_data = formatter.format_message_header(message)
564+
M._replace_message_in_buffer(part.messageID, header_data)
565+
elseif prev_last_part_id ~= part.id then
566+
M._rerender_part(prev_last_part_id)
567+
end
568+
end
539569
else
540570
M._replace_part_in_buffer(part.id, formatted)
541571
end
@@ -737,6 +767,24 @@ function M._find_text_part_for_message(message)
737767
return nil
738768
end
739769

770+
---Find the last part in a message
771+
---@param message OpencodeMessage The message containing the parts
772+
---@return string? last_part_id The ID of the last part
773+
function M._get_last_part_for_message(message)
774+
if not message or not message.parts or #message.parts == 0 then
775+
return nil
776+
end
777+
778+
for i = #message.parts, 1, -1 do
779+
local part = message.parts[i]
780+
if part.type ~= 'step-start' and part.type ~= 'step-finish' and part.id then
781+
return part.id
782+
end
783+
end
784+
785+
return nil
786+
end
787+
740788
---Re-render existing part with current state
741789
---Used for permission updates and other dynamic changes
742790
---@param part_id string Part ID to re-render
@@ -753,7 +801,9 @@ function M._rerender_part(part_id)
753801
end
754802

755803
local message = rendered_message.message
756-
local formatted = formatter.format_part(part, message)
804+
local last_part_id = M._get_last_part_for_message(message)
805+
local is_last_part = (last_part_id == part_id)
806+
local formatted = formatter.format_part(part, message, is_last_part)
757807

758808
M._replace_part_in_buffer(part_id, formatted)
759809
end

tests/data/api-abort.expected.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"timestamp":1761606463,"extmarks":[[1,2,0,{"virt_text_win_col":-3,"virt_text_hide":false,"virt_text_repeat_linebreak":false,"right_gravity":true,"priority":10,"ns_id":3,"virt_text":[["▌󰭻 ","OpencodeMessageRoleUser"],[" "],["USER","OpencodeMessageRoleUser"],["","OpencodeHint"],[" (2025-10-27 22:44:29)","OpencodeHint"],[" [msg_a27d8299d001nchmBunYlZcPyL]","OpencodeHint"]],"virt_text_pos":"win_col"}],[2,3,0,{"virt_text_win_col":-3,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true,"priority":4096,"ns_id":3,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_pos":"win_col"}],[3,4,0,{"virt_text_win_col":-3,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true,"priority":4096,"ns_id":3,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_pos":"win_col"}],[4,5,0,{"virt_text_win_col":-3,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true,"priority":4096,"ns_id":3,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_pos":"win_col"}],[5,6,0,{"virt_text_win_col":-3,"virt_text_hide":false,"virt_text_repeat_linebreak":true,"right_gravity":true,"priority":4096,"ns_id":3,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_pos":"win_col"}],[6,9,0,{"virt_text_win_col":-3,"virt_text_hide":false,"virt_text_repeat_linebreak":false,"right_gravity":true,"priority":10,"ns_id":3,"virt_text":[[" ","OpencodeMessageRoleAssistant"],[" "],["PLAN","OpencodeMessageRoleAssistant"],[" gpt-4.1","OpencodeHint"],[" (2025-10-27 22:44:29)","OpencodeHint"],[" [msg_a27d829f9002sfUFPslHq5P2b4]","OpencodeHint"]],"virt_text_pos":"win_col"}]],"lines":["","----","","","can generate 10 numbers?","","[a-empty.txt](a-empty.txt)","","----","","","You asked if I can generate 10 numbers, and you referenced reading an empty file (`a-empty.txt`). However, I'm currently in \"plan mode,\" which means I cannot write or modify any files—I'm only allowed to read, observe,","","> [!ERROR] The operation was aborted.",""],"actions":[]}

0 commit comments

Comments
 (0)