Skip to content
Closed
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
4 changes: 2 additions & 2 deletions lua/opencode/context.lua
Original file line number Diff line number Diff line change
Expand Up @@ -269,14 +269,14 @@ end

---@param selection OpencodeContextSelection
local function format_selection_part(selection)
local lang = selection.file and selection.file.extension or ''
local lang = util.get_markdown_filetype(selection.file and selection.file.name or '') or ''

return {
type = 'text',
text = vim.json.encode({
context_type = 'selection',
file = selection.file,
content = string.format('```%s\n%s\n```', lang, selection.content),
content = string.format('`````%s\n%s\n`````', lang, selection.content),
lines = selection.lines,
}),
synthetic = true,
Expand Down
15 changes: 9 additions & 6 deletions lua/opencode/ui/formatter.lua
Original file line number Diff line number Diff line change
Expand Up @@ -428,7 +428,7 @@ end
---@param metadata FileToolMetadata Metadata for the tool use
function M._format_file_tool(output, tool_type, input, metadata)
local file_name = input and vim.fn.fnamemodify(input.filePath, ':t') or ''
local file_type = input and vim.fn.fnamemodify(input.filePath, ':e') or ''
local file_type = input and util.get_markdown_filetype(input.filePath) or ''
local tool_action_icons = { read = icons.get('read'), edit = icons.get('edit'), write = icons.get('write') }

M._format_action(output, tool_action_icons[tool_type] .. ' ' .. tool_type, file_name)
Expand Down Expand Up @@ -627,17 +627,20 @@ end
---@param language string
function M._format_code(output, lines, language)
output:add_empty_line()
output:add_line('```' .. (language or ''))
output:add_lines(util.strip_ansi_lines(lines))
output:add_line('```')
--- NOTE: use longer code fence because lines could contain ```
output:add_line('`````' .. (language or ''))
output:add_lines(util.sanitize_lines(lines))
output:add_line('`````')
end

---@param output Output Output object to write to
---@param code string
---@param file_type string
function M._format_diff(output, code, file_type)
output:add_empty_line()
output:add_line('```' .. file_type)

--- NOTE: use longer code fence because code could contain ```
output:add_line('`````' .. file_type)
local lines = vim.split(code, '\n')
if #lines > 5 then
lines = vim.list_slice(lines, 6)
Expand Down Expand Up @@ -668,7 +671,7 @@ function M._format_diff(output, code, file_type)
output:add_line(line)
end
end
output:add_line('```')
output:add_line('`````')
end

---@param output Output Output object to write to
Expand Down
8 changes: 5 additions & 3 deletions lua/opencode/ui/render_state.lua
Original file line number Diff line number Diff line change
Expand Up @@ -243,16 +243,18 @@ end

---Update part data reference
---@param part_ref OpencodeMessagePart New part reference (must include id)
---@return RenderedPart? part The rendered part
function RenderState:update_part_data(part_ref)
if not part_ref or not part_ref.id then
return
end
local part_data = self._parts[part_ref.id]
if not part_data then
local rendered_part = self._parts[part_ref.id]
if not rendered_part then
return
end

part_data.part = part_ref
rendered_part.part = part_ref
return rendered_part
end

---Helper to update action line numbers
Expand Down
11 changes: 8 additions & 3 deletions lua/opencode/ui/renderer.lua
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,7 @@ end
function M._replace_part_in_buffer(part_id, formatted_data)
local cached = M._render_state:get_part(part_id)
if not cached or not cached.line_start or not cached.line_end then
-- return M._insert_part_to_buffer(part_id, formatted_data)
return false
end

Expand Down Expand Up @@ -536,7 +537,13 @@ function M.on_part_updated(properties, revert_index)
if is_new_part then
M._render_state:set_part(part)
else
M._render_state:update_part_data(part)
local rendered_part = M._render_state:update_part_data(part)
-- NOTE: This isn't the first time we've seen the part but we haven't rendered it previously
-- so try and render it this time by setting is_new_part = true (otherwise we'd call
-- _replace_message_in_buffer and it wouldn't do anything because the part hasn't been rendered)
if not rendered_part or (not rendered_part.line_start and not rendered_part.line_end) then
is_new_part = true
end
end

local formatted = formatter.format_part(part, message, is_last_part)
Expand All @@ -555,8 +562,6 @@ function M.on_part_updated(properties, revert_index)
--- 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
Expand Down
27 changes: 26 additions & 1 deletion lua/opencode/util.lua
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ end
---Strip ANSI escape sequences from all lines
---@param lines table
---@return table stripped_lines
function M.strip_ansi_lines(lines)
function M.sanitize_lines(lines)
local stripped_lines = {}
for _, line in pairs(lines) do
table.insert(stripped_lines, M.strip_ansi(line))
Expand Down Expand Up @@ -360,6 +360,31 @@ function M.parse_dot_args(args_str)
return result
end

--- Get the markdown type to use based on the filename. First gets the neovim type
--- for the file. Then apply any specific overrides. Falls back to using the file
--- extension if nothing else matches
--- @param filename string filename, possibly including path
--- @return string markdown_filetype
function M.get_markdown_filetype(filename)
local file_type_overrides = {
javascriptreact = 'jsx',
typescriptreact = 'tsx',
sh = 'bash',
yaml = 'yml',
text = 'txt', -- nvim 0.12-nightly returns text as the type which breaks our unit tests
}

local file_type = vim.filetype.match({ filename = filename }) or ''

if file_type_overrides[file_type] then
return file_type_overrides[file_type]
end

if file_type and file_type ~= '' then
return file_type
end

return vim.fn.fnamemodify(filename, ':e')
--- Check if prompt is allowed via guard callback
--- @param guard_callback? function
--- @param mentioned_files? string[] List of mentioned files in the context
Expand Down
2 changes: 1 addition & 1 deletion tests/data/ansi-codes.expected.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion tests/data/api-abort.expected.json
Original file line number Diff line number Diff line change
@@ -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":[]}
{"extmarks":[[1,2,0,{"priority":10,"virt_text_hide":false,"right_gravity":true,"virt_text_repeat_linebreak":false,"ns_id":3,"virt_text":[["▌󰭻 ","OpencodeMessageRoleUser"],[" "],["USER","OpencodeMessageRoleUser"],["","OpencodeHint"],[" (2025-10-27 22:44:29)","OpencodeHint"],[" [msg_a27d8299d001nchmBunYlZcPyL]","OpencodeHint"]],"virt_text_pos":"win_col","virt_text_win_col":-3}],[2,3,0,{"priority":4096,"virt_text_hide":false,"right_gravity":true,"virt_text_repeat_linebreak":true,"ns_id":3,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_pos":"win_col","virt_text_win_col":-3}],[3,4,0,{"priority":4096,"virt_text_hide":false,"right_gravity":true,"virt_text_repeat_linebreak":true,"ns_id":3,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_pos":"win_col","virt_text_win_col":-3}],[4,5,0,{"priority":4096,"virt_text_hide":false,"right_gravity":true,"virt_text_repeat_linebreak":true,"ns_id":3,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_pos":"win_col","virt_text_win_col":-3}],[5,6,0,{"priority":4096,"virt_text_hide":false,"right_gravity":true,"virt_text_repeat_linebreak":true,"ns_id":3,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_pos":"win_col","virt_text_win_col":-3}],[6,9,0,{"priority":10,"virt_text_hide":false,"right_gravity":true,"virt_text_repeat_linebreak":false,"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","virt_text_win_col":-3}]],"timestamp":1761708190,"actions":[],"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.",""]}
2 changes: 1 addition & 1 deletion tests/data/api-error.expected.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"extmarks":[[1,2,0,{"virt_text_pos":"win_col","virt_text_hide":false,"virt_text":[["▌󰭻 ","OpencodeMessageRoleUser"],[" "],["USER","OpencodeMessageRoleUser"],["","OpencodeHint"],[" (2025-10-20 04:44:37)","OpencodeHint"],[" [msg_9ffef0129001CoCrBKemk7DqcU]","OpencodeHint"]],"right_gravity":true,"virt_text_win_col":-3,"priority":10,"ns_id":3,"virt_text_repeat_linebreak":false}],[2,3,0,{"virt_text_pos":"win_col","virt_text_hide":false,"virt_text":[["▌","OpencodeMessageRoleUser"]],"right_gravity":true,"virt_text_win_col":-3,"priority":4096,"ns_id":3,"virt_text_repeat_linebreak":true}],[3,4,0,{"virt_text_pos":"win_col","virt_text_hide":false,"virt_text":[["▌","OpencodeMessageRoleUser"]],"right_gravity":true,"virt_text_win_col":-3,"priority":4096,"ns_id":3,"virt_text_repeat_linebreak":true}],[4,5,0,{"virt_text_pos":"win_col","virt_text_hide":false,"virt_text":[["▌","OpencodeMessageRoleUser"]],"right_gravity":true,"virt_text_win_col":-3,"priority":4096,"ns_id":3,"virt_text_repeat_linebreak":true}],[5,6,0,{"virt_text_pos":"win_col","virt_text_hide":false,"virt_text":[["▌","OpencodeMessageRoleUser"]],"right_gravity":true,"virt_text_win_col":-3,"priority":4096,"ns_id":3,"virt_text_repeat_linebreak":true}],[6,9,0,{"virt_text_pos":"win_col","virt_text_hide":false,"virt_text":[[" ","OpencodeMessageRoleAssistant"],[" "],["PLAN","OpencodeMessageRoleAssistant"],[" claude-sonnet-4-5-20250929","OpencodeHint"],[" (2025-10-20 04:44:37)","OpencodeHint"],[" [msg_9ffef0160001eArLyAssT]","OpencodeHint"]],"right_gravity":true,"virt_text_win_col":-3,"priority":10,"ns_id":3,"virt_text_repeat_linebreak":false}],[7,16,0,{"virt_text_pos":"win_col","virt_text_hide":false,"virt_text":[[" ","OpencodeMessageRoleAssistant"],[" "],["PLAN","OpencodeMessageRoleAssistant"],[" claude-sonnet-4-5-20250929","OpencodeHint"],[" (2025-10-20 04:44:37)","OpencodeHint"],[" [msg_9ffef0170001s2OM00h2cDa94A]","OpencodeHint"]],"right_gravity":true,"virt_text_win_col":-3,"priority":10,"ns_id":3,"virt_text_repeat_linebreak":false}]],"timestamp":1761608289,"lines":["","----","","","test 3","","[diff-test.txt](diff-test.txt)","","----","","","This is some sample text","","> [!ERROR] Simulated: tool/file read failed for earlier assistant message","","----","","","> [!ERROR] AI_APICallError: Your credit balance is too low to access the Anthropic API. Please go to Plans & Billing to upgrade or purchase credits.",""],"actions":[]}
{"timestamp":1761708190,"extmarks":[[1,2,0,{"virt_text_win_col":-3,"virt_text_hide":false,"right_gravity":true,"virt_text_repeat_linebreak":false,"ns_id":3,"priority":10,"virt_text":[["▌󰭻 ","OpencodeMessageRoleUser"],[" "],["USER","OpencodeMessageRoleUser"],["","OpencodeHint"],[" (2025-10-20 04:44:37)","OpencodeHint"],[" [msg_9ffef0129001CoCrBKemk7DqcU]","OpencodeHint"]],"virt_text_pos":"win_col"}],[2,3,0,{"virt_text_win_col":-3,"virt_text_hide":false,"right_gravity":true,"virt_text_repeat_linebreak":true,"ns_id":3,"priority":4096,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_pos":"win_col"}],[3,4,0,{"virt_text_win_col":-3,"virt_text_hide":false,"right_gravity":true,"virt_text_repeat_linebreak":true,"ns_id":3,"priority":4096,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_pos":"win_col"}],[4,5,0,{"virt_text_win_col":-3,"virt_text_hide":false,"right_gravity":true,"virt_text_repeat_linebreak":true,"ns_id":3,"priority":4096,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_pos":"win_col"}],[5,6,0,{"virt_text_win_col":-3,"virt_text_hide":false,"right_gravity":true,"virt_text_repeat_linebreak":true,"ns_id":3,"priority":4096,"virt_text":[["▌","OpencodeMessageRoleUser"]],"virt_text_pos":"win_col"}],[6,9,0,{"virt_text_win_col":-3,"virt_text_hide":false,"right_gravity":true,"virt_text_repeat_linebreak":false,"ns_id":3,"priority":10,"virt_text":[[" ","OpencodeMessageRoleAssistant"],[" "],["PLAN","OpencodeMessageRoleAssistant"],[" claude-sonnet-4-5-20250929","OpencodeHint"],[" (2025-10-20 04:44:37)","OpencodeHint"],[" [msg_9ffef0160001eArLyAssT]","OpencodeHint"]],"virt_text_pos":"win_col"}],[7,16,0,{"virt_text_win_col":-3,"virt_text_hide":false,"right_gravity":true,"virt_text_repeat_linebreak":false,"ns_id":3,"priority":10,"virt_text":[[" ","OpencodeMessageRoleAssistant"],[" "],["PLAN","OpencodeMessageRoleAssistant"],[" claude-sonnet-4-5-20250929","OpencodeHint"],[" (2025-10-20 04:44:37)","OpencodeHint"],[" [msg_9ffef0170001s2OM00h2cDa94A]","OpencodeHint"]],"virt_text_pos":"win_col"}]],"lines":["","----","","","test 3","","[diff-test.txt](diff-test.txt)","","----","","","This is some sample text","","> [!ERROR] Simulated: tool/file read failed for earlier assistant message","","----","","","> [!ERROR] AI_APICallError: Your credit balance is too low to access the Anthropic API. Please go to Plans & Billing to upgrade or purchase credits.",""],"actions":[]}
Loading
Loading