Skip to content
Merged
115 changes: 72 additions & 43 deletions lua/opencode/ui/formatter.lua
Original file line number Diff line number Diff line change
Expand Up @@ -19,27 +19,21 @@ M.separator = {
---@param part OpencodeMessagePart
function M._format_reasoning(output, part)
local text = vim.trim(part.text or '')
if text == '' then
return
end

local start_line = output:get_line_count() + 1

local title = 'Reasoning'
local time = part.time
if time and type(time) == 'table' and time.start then
local start_text = util.format_time(time.start) or ''
local end_text = (time['end'] and util.format_time(time['end'])) or nil
if end_text and end_text ~= '' then
title = string.format('%s (%s - %s)', title, start_text, end_text)
elseif start_text ~= '' then
title = string.format('%s (%s)', title, start_text)
local duration_text = util.format_duration_seconds(time.start, time['end'])
if duration_text then
title = string.format('%s %s', title, duration_text)
end
end

M.format_action(output, icons.get('reasoning') .. ' ' .. title, '')

if config.ui.output.tools.show_reasoning_output then
if config.ui.output.tools.show_reasoning_output and text ~= '' then
output:add_empty_line()
output:add_lines(vim.split(text, '\n'))
output:add_empty_line()
Expand Down Expand Up @@ -219,7 +213,6 @@ function M.format_message_header(message)
local icon = message.info.role == 'user' and icons.get('header_user') or icons.get('header_assistant')

local time = message.info.time and message.info.time.created or nil
local time_text = (time and ' (' .. util.format_time(time) .. ')' or '')
local role_hl = 'OpencodeMessageRole' .. role:sub(1, 1):upper() .. role:sub(2)
local model_text = message.info.modelID and ' ' .. message.info.modelID or ''

Expand Down Expand Up @@ -250,13 +243,20 @@ function M.format_message_header(message)
{ ' ' },
{ display_name, role_hl },
{ model_text, 'OpencodeHint' },
{ time_text, 'OpencodeHint' },
{ debug_text, 'OpencodeHint' },
},
virt_text_win_col = -3,
priority = 10,
} --[[@as OutputExtmark]])

if time then
output:add_extmark(output:get_line_count() - 1, {
virt_text = { { ' ' .. util.format_time(time), 'OpencodeHint' } },
virt_text_pos = 'right_align',
priority = 9,
} --[[@as OutputExtmark]])
end

-- Only want to show the error if we have no parts. If we have parts, they'll
-- handle rendering the error
if
Expand Down Expand Up @@ -468,20 +468,24 @@ end
---@param output Output Output object to write to
---@param tool_type string Tool type (e.g., 'run', 'read', 'edit', etc.)
---@param value string Value associated with the action (e.g., filename, command)
function M.format_action(output, tool_type, value)
---@param duration_text? string
function M.format_action(output, tool_type, value, duration_text)
if not tool_type or not value then
return
end
local line = string.format('**%s** %s', tool_type, value and #value > 0 and ('`' .. value .. '`') or '')
local detail = value and #value > 0 and ('`' .. value .. '`') or ''
local duration_suffix = duration_text and (' ' .. duration_text) or ''
local line = string.format('**%s** %s%s', tool_type, detail, duration_suffix)

output:add_line(line)
end

---@param output Output Output object to write to
---@param input BashToolInput data for the tool
---@param metadata BashToolMetadata Metadata for the tool use
function M._format_bash_tool(output, input, metadata)
M.format_action(output, icons.get('run') .. ' run', input and input.description)
---@param duration_text? string
function M._format_bash_tool(output, input, metadata, duration_text)
M.format_action(output, icons.get('run') .. ' run', input and input.description, duration_text)

if not config.ui.output.tools.show_output then
return
Expand All @@ -498,7 +502,8 @@ end
---@param tool_type string Tool type (e.g., 'read', 'edit', 'write')
---@param input FileToolInput data for the tool
---@param metadata FileToolMetadata Metadata for the tool use
function M._format_file_tool(output, tool_type, input, metadata)
---@param duration_text? string
function M._format_file_tool(output, tool_type, input, metadata, duration_text)
local file_name = ''
if input and input.filePath then
local cwd = vim.fn.getcwd()
Expand All @@ -514,7 +519,7 @@ function M._format_file_tool(output, tool_type, input, metadata)
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)
M.format_action(output, tool_action_icons[tool_type] .. ' ' .. tool_type, file_name, duration_text)

if not config.ui.output.tools.show_output then
return
Expand All @@ -530,8 +535,9 @@ end
---@param output Output Output object to write to
---@param title string
---@param input TodoToolInput
function M._format_todo_tool(output, title, input)
M.format_action(output, icons.get('plan') .. ' plan', (title or ''))
---@param duration_text? string
function M._format_todo_tool(output, title, input, duration_text)
M.format_action(output, icons.get('plan') .. ' plan', (title or ''), duration_text)
if not config.ui.output.tools.show_output then
return
end
Expand All @@ -547,8 +553,9 @@ end
---@param output Output Output object to write to
---@param input GlobToolInput data for the tool
---@param metadata GlobToolMetadata Metadata for the tool use
function M._format_glob_tool(output, input, metadata)
M.format_action(output, icons.get('search') .. ' glob', input and input.pattern)
---@param duration_text? string
function M._format_glob_tool(output, input, metadata, duration_text)
M.format_action(output, icons.get('search') .. ' glob', input and input.pattern, duration_text)
if not config.ui.output.tools.show_output then
return
end
Expand All @@ -559,15 +566,16 @@ end
---@param output Output Output object to write to
---@param input GrepToolInput data for the tool
---@param metadata GrepToolMetadata Metadata for the tool use
function M._format_grep_tool(output, input, metadata)
---@param duration_text? string
function M._format_grep_tool(output, input, metadata, duration_text)
local grep_str = table.concat(
vim.tbl_filter(function(part)
return part ~= nil
end, { input.path or input.include, input.pattern }),
'` `'
)

M.format_action(output, icons.get('search') .. ' grep', grep_str)
M.format_action(output, icons.get('search') .. ' grep', grep_str, duration_text)
if not config.ui.output.tools.show_output then
return
end
Expand All @@ -579,16 +587,18 @@ end

---@param output Output Output object to write to
---@param input WebFetchToolInput data for the tool
function M._format_webfetch_tool(output, input)
M.format_action(output, icons.get('web') .. ' fetch', input and input.url)
---@param duration_text? string
function M._format_webfetch_tool(output, input, duration_text)
M.format_action(output, icons.get('web') .. ' fetch', input and input.url, duration_text)
end

---@param output Output Output object to write to
---@param input ListToolInput
---@param metadata ListToolMetadata
---@param tool_output string
function M._format_list_tool(output, input, metadata, tool_output)
M.format_action(output, icons.get('list') .. ' list', input and input.path or '')
---@param duration_text? string
function M._format_list_tool(output, input, metadata, tool_output, duration_text)
M.format_action(output, icons.get('list') .. ' list', input and input.path or '', duration_text)
if not config.ui.output.tools.show_output then
return
end
Expand All @@ -615,8 +625,9 @@ end
---@param input QuestionToolInput Question tool input data
---@param metadata QuestionToolMetadata Question tool metadata
---@param status string Status of the tool execution
function M._format_question_tool(output, input, metadata, status)
M.format_action(output, icons.get('question') .. ' question', '')
---@param duration_text? string
function M._format_question_tool(output, input, metadata, status, duration_text)
M.format_action(output, icons.get('question') .. ' question', '', duration_text)
output:add_empty_line()
if not config.ui.output.tools.show_output or status ~= 'completed' then
return
Expand Down Expand Up @@ -655,32 +666,49 @@ function M._format_tool(output, part)
local input = part.state.input or {}
local metadata = part.state.metadata or {}
local tool_output = part.state.output or ''
local tool_time = part.state.time or {}
local tool_status = part.state.status
local should_show_duration = tool ~= 'question' and tool_status ~= 'pending'
local duration_text = should_show_duration and util.format_duration_seconds(tool_time.start, tool_time['end']) or nil

if tool == 'bash' then
M._format_bash_tool(output, input --[[@as BashToolInput]], metadata --[[@as BashToolMetadata]])
M._format_bash_tool(output, input --[[@as BashToolInput]], metadata --[[@as BashToolMetadata]], duration_text)
elseif tool == 'read' or tool == 'edit' or tool == 'write' then
M._format_file_tool(output, tool, input --[[@as FileToolInput]], metadata --[[@as FileToolMetadata]])
M._format_file_tool(output, tool, input --[[@as FileToolInput]], metadata --[[@as FileToolMetadata]], duration_text)
elseif tool == 'todowrite' then
M._format_todo_tool(output, part.state.title, input --[[@as TodoToolInput]])
M._format_todo_tool(output, part.state.title, input --[[@as TodoToolInput]], duration_text)
elseif tool == 'glob' then
M._format_glob_tool(output, input --[[@as GlobToolInput]], metadata --[[@as GlobToolMetadata]])
M._format_glob_tool(output, input --[[@as GlobToolInput]], metadata --[[@as GlobToolMetadata]], duration_text)
elseif tool == 'list' then
M._format_list_tool(output, input --[[@as ListToolInput]], metadata --[[@as ListToolMetadata]], tool_output)
M._format_list_tool(
output,
input --[[@as ListToolInput]],
metadata --[[@as ListToolMetadata]],
tool_output,
duration_text
)
elseif tool == 'grep' then
M._format_grep_tool(output, input --[[@as GrepToolInput]], metadata --[[@as GrepToolMetadata]])
M._format_grep_tool(output, input --[[@as GrepToolInput]], metadata --[[@as GrepToolMetadata]], duration_text)
elseif tool == 'webfetch' then
M._format_webfetch_tool(output, input --[[@as WebFetchToolInput]])
M._format_webfetch_tool(output, input --[[@as WebFetchToolInput]], duration_text)
elseif tool == 'task' then
M._format_task_tool(output, input --[[@as TaskToolInput]], metadata --[[@as TaskToolMetadata]], tool_output)
M._format_task_tool(
output,
input --[[@as TaskToolInput]],
metadata --[[@as TaskToolMetadata]],
tool_output,
duration_text
)
elseif tool == 'question' then
M._format_question_tool(
output,
input --[[@as QuestionToolInput]],
metadata --[[@as QuestionToolMetadata]],
part.state.status
part.state.status,
duration_text
)
else
M.format_action(output, icons.get('tool') .. ' tool', tool)
M.format_action(output, icons.get('tool') .. ' tool', tool, duration_text)
end

if part.state.status == 'error' and part.state.error then
Expand All @@ -704,7 +732,8 @@ end
---@param input TaskToolInput data for the tool
---@param metadata TaskToolMetadata Metadata for the tool use
---@param tool_output string
function M._format_task_tool(output, input, metadata, tool_output)
---@param duration_text? string
function M._format_task_tool(output, input, metadata, tool_output, duration_text)
local start_line = output:get_line_count() + 1

-- Show agent type if available
Expand All @@ -714,7 +743,7 @@ function M._format_task_tool(output, input, metadata, tool_output)
description = string.format('%s (@%s)', description, agent_type)
end

M.format_action(output, icons.get('task') .. ' task', description)
M.format_action(output, icons.get('task') .. ' task', description, duration_text)

if config.ui.output.tools.show_output then
-- Show task summary from metadata
Expand Down Expand Up @@ -877,7 +906,7 @@ function M.format_part(part, message, is_last_part)
if part.type == 'text' and part.text then
M._format_assistant_message(output, vim.trim(part.text))
content_added = true
elseif part.type == 'reasoning' and part.text then
elseif part.type == 'reasoning' then
M._format_reasoning(output, part)
content_added = true
elseif part.type == 'tool' then
Expand Down
55 changes: 49 additions & 6 deletions lua/opencode/util.lua
Original file line number Diff line number Diff line change
Expand Up @@ -88,18 +88,61 @@ end
--- @param timestamp number
--- @return string: Formatted time string
function M.format_time(timestamp)
local formats = { day = '%I:%M %p', year = '%d %b %I:%M %p', full = '%d %b %Y %I:%M %p' }

if timestamp > 1e12 then
timestamp = math.floor(timestamp / 1000)
timestamp = M.normalize_timestamp(timestamp)
if not timestamp then
return ''
end

local same_day = os.date('%Y-%m-%d') == os.date('%Y-%m-%d', timestamp)
local same_year = os.date('%Y') == os.date('%Y', timestamp)
local locale_time = vim.trim(os.date('%X', timestamp) or '')

-- Keep output close to previous formatting by dropping seconds when present.
locale_time = locale_time:gsub('^(%d?%d:%d%d):%d%d(.*)$', '%1%2')
if locale_time == '' then
locale_time = vim.trim(os.date('%H:%M', timestamp) or '')
end

if same_day then
return locale_time
end

local format_str = same_day and formats.day or (same_year and formats.year or formats.full)
if same_year then
return string.format('%s %s', os.date('%d %b', timestamp), locale_time)
end

return string.format('%s %s', os.date('%d %b %Y', timestamp), locale_time)
end

---@param timestamp number
---@return number
function M.normalize_timestamp(timestamp)
if not timestamp then
return nil
end

if timestamp > 1e12 then
return math.floor(timestamp / 1000)
end

return timestamp
end

---@param start_time number
---@param end_time number|nil
---@return string|nil
function M.format_duration_seconds(start_time, end_time)
if not start_time or not end_time then
return nil
end

local elapsed_seconds = math.max(0, M.normalize_timestamp(end_time) - M.normalize_timestamp(start_time))

if elapsed_seconds < 1 then
return nil
end

return os.date(format_str, timestamp) --[[@as string]]
return string.format('%ds', elapsed_seconds)
end

function M.index_of(tbl, value)
Expand Down
Loading