Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 25 additions & 17 deletions lua/opencode/ui/formatter.lua
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ function M._format_revert_message(session_data, start_idx)
virt_text_pos = 'inline',
virt_text_win_col = col,
priority = 1000,
})
} --[[@as OutputExtmark]])
col = col + #diff + 1
end
end
Expand Down Expand Up @@ -270,9 +270,16 @@ function M.format_message_header(message)
},
virt_text_win_col = -3,
priority = 10,
})
} --[[@as OutputExtmark]])

if role == 'assistant' and message.info.error and message.info.error ~= '' then
-- Only want to show the error if we have no parts. If we have parts, they'll
-- handle rendering the error
if
role == 'assistant'
and message.info.error
and message.info.error ~= ''
and (not message.parts or #message.parts == 0)
then
local error = message.info.error
local error_messgage = error.data and error.data.message or vim.inspect(error)

Expand Down Expand Up @@ -676,19 +683,24 @@ function M._add_vertical_border(output, start_line, end_line, hl_group, win_col)
virt_text_pos = 'overlay',
virt_text_win_col = win_col,
virt_text_repeat_linebreak = true,
})
} --[[@as OutputExtmark]])
end
end

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

if not message or not message.info or not message.info.role then
return output
end

local content_added = false
local role = message and message.info and message.info.role
local role = message.info.role

if role == 'user' then
if part.type == 'text' and part.text then
Expand Down Expand Up @@ -722,18 +734,14 @@ function M.format_part(part, message)
output:add_empty_line()
end

return output
end

---@param error_text string
---@return Output
function M.format_error_callout(error_text)
local temp_output = Output.new()

temp_output:add_empty_line()
M._format_callout(temp_output, 'ERROR', error_text)
if is_last_part and role == 'assistant' and message.info.error and message.info.error ~= '' then
local error = message.info.error
local error_messgage = error.data and error.data.message or vim.inspect(error)
M._format_callout(output, 'ERROR', error_messgage)
output:add_empty_line()
end

return temp_output
return output
end

return M
66 changes: 58 additions & 8 deletions lua/opencode/ui/renderer.lua
Original file line number Diff line number Diff line change
Expand Up @@ -450,15 +450,23 @@ function M.on_message_updated(message, revert_index)
end

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

found_msg.info = msg.info

if rerender_message and not revert_index then
local header_data = formatter.format_message_header(found_msg)
M._replace_message_in_buffer(msg.info.id, header_data)
--- NOTE: error handling is a bit messy because errors come in on messages
--- but we want to display the error at the end. In this case, we an error
--- was added to this message. We find the last part and re-render it to
--- display the message. If there are no parts, we'll re-render the message

if error_changed and not revert_index then
local last_part_id = M._get_last_part_for_message(found_msg)
if last_part_id then
M._rerender_part(last_part_id)
else
local header_data = formatter.format_message_header(found_msg)
M._replace_message_in_buffer(msg.info.id, header_data)
end
end
else
table.insert(state.messages, msg)
Expand Down Expand Up @@ -507,6 +515,9 @@ function M.on_part_updated(properties, revert_index)
local part_data = M._render_state:get_part(part.id)
local is_new_part = not part_data

local prev_last_part_id = M._get_last_part_for_message(message)
local is_last_part = is_new_part or (prev_last_part_id == part.id)

if is_new_part then
table.insert(message.parts, part)
else
Expand All @@ -528,14 +539,33 @@ function M.on_part_updated(properties, revert_index)
M._render_state:update_part_data(part)
end

local formatted = formatter.format_part(part, message)
local formatted = formatter.format_part(part, message, is_last_part)

if revert_index and is_new_part then
return
end

if is_new_part then
M._insert_part_to_buffer(part.id, formatted)

if message.info.error then
--- NOTE: More error display code. As mentioned above, errors come in on messages
--- but we want to display them after parts so we tack the error onto the last
--- part. When a part is added and there's an error, we need to rerender
--- previous last part so it doesn't also display the message. If there was no previous
--- part, then we need to rerender the header so it doesn't display the error

vim.notify('new part and error: ' .. part.id)

if not prev_last_part_id then
-- no previous part, we're the first part, re-render the message header
-- so it doesn't also display the error
local header_data = formatter.format_message_header(message)
M._replace_message_in_buffer(part.messageID, header_data)
elseif prev_last_part_id ~= part.id then
M._rerender_part(prev_last_part_id)
end
end
else
M._replace_part_in_buffer(part.id, formatted)
end
Expand Down Expand Up @@ -737,6 +767,24 @@ function M._find_text_part_for_message(message)
return nil
end

---Find the last part in a message
---@param message OpencodeMessage The message containing the parts
---@return string? last_part_id The ID of the last part
function M._get_last_part_for_message(message)
if not message or not message.parts or #message.parts == 0 then
return nil
end

for i = #message.parts, 1, -1 do
local part = message.parts[i]
if part.type ~= 'step-start' and part.type ~= 'step-finish' and part.id then
return part.id
end
end

return nil
end

---Re-render existing part with current state
---Used for permission updates and other dynamic changes
---@param part_id string Part ID to re-render
Expand All @@ -753,7 +801,9 @@ function M._rerender_part(part_id)
end

local message = rendered_message.message
local formatted = formatter.format_part(part, message)
local last_part_id = M._get_last_part_for_message(message)
local is_last_part = (last_part_id == part_id)
local formatted = formatter.format_part(part, message, is_last_part)

M._replace_part_in_buffer(part_id, formatted)
end
Expand Down
1 change: 1 addition & 0 deletions tests/data/api-abort.expected.json
Original file line number Diff line number Diff line change
@@ -0,0 +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":[]}
Loading