Skip to content

Commit f20f99e

Browse files
committed
refactor(formatter): split tool formatters into individual modules
1 parent 3617941 commit f20f99e

16 files changed

Lines changed: 537 additions & 456 deletions

lua/opencode/ui/formatter.lua

Lines changed: 7 additions & 283 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,10 @@ local snapshot = require('opencode.snapshot')
88
local mention = require('opencode.ui.mention')
99
local permission_window = require('opencode.ui.permission_window')
1010
local tool_formatters = require('opencode.ui.formatter.tools')
11-
local tool_helpers = require('opencode.ui.formatter.tools.helpers')
11+
local format_utils = require('opencode.ui.formatter.utils')
1212

1313
local M = {}
1414

15-
---@note child-session parts are requested from the renderer at format time
16-
1715
M.separator = {
1816
'----',
1917
'',
@@ -35,7 +33,7 @@ function M._format_reasoning(output, part)
3533
end
3634
end
3735

38-
M.format_action(output, 'reasoning', title, '')
36+
format_utils.format_action(output, icons.get('reasoning'), title, '')
3937

4038
if config.ui.output.tools.show_reasoning_output and text ~= '' then
4139
output:add_empty_line()
@@ -53,69 +51,13 @@ function M._format_reasoning(output, part)
5351
end
5452
end
5553

56-
---Calculate statistics for reverted messages and tool calls
57-
---@param messages {info: MessageInfo, parts: OpencodeMessagePart[]}[] All messages in the session
58-
---@param revert_index number Index of the message where revert occurred
59-
---@param revert_info SessionRevertInfo Revert information
60-
---@return {messages: number, tool_calls: number, files: table<string, {additions: number, deletions: number}>}
61-
function M._calculate_revert_stats(messages, revert_index, revert_info)
62-
local stats = {
63-
messages = 0,
64-
tool_calls = 0,
65-
files = {}, -- { [filename] = { additions = n, deletions = m } }
66-
}
67-
68-
for i = revert_index, #messages do
69-
local msg = messages[i]
70-
if msg.info.role == 'user' then
71-
stats.messages = stats.messages + 1
72-
end
73-
if msg.parts then
74-
for _, part in ipairs(msg.parts) do
75-
if part.type == 'tool' then
76-
stats.tool_calls = stats.tool_calls + 1
77-
end
78-
end
79-
end
80-
end
81-
82-
if revert_info.diff then
83-
local current_file = nil
84-
for line in revert_info.diff:gmatch('[^\r\n]+') do
85-
local file_a = line:match('^%-%-%- ([ab]/.+)')
86-
local file_b = line:match('^%+%+%+ ([ab]/.+)')
87-
if file_b then
88-
current_file = file_b:gsub('^[ab]/', '')
89-
if not stats.files[current_file] then
90-
stats.files[current_file] = { additions = 0, deletions = 0 }
91-
end
92-
elseif file_a then
93-
current_file = file_a:gsub('^[ab]/', '')
94-
if not stats.files[current_file] then
95-
stats.files[current_file] = { additions = 0, deletions = 0 }
96-
end
97-
elseif line:sub(1, 1) == '+' and not line:match('^%+%+%+') then
98-
if current_file then
99-
stats.files[current_file].additions = stats.files[current_file].additions + 1
100-
end
101-
elseif line:sub(1, 1) == '-' and not line:match('^%-%-%-') then
102-
if current_file then
103-
stats.files[current_file].deletions = stats.files[current_file].deletions + 1
104-
end
105-
end
106-
end
107-
end
108-
109-
return stats
110-
end
111-
11254
---Format the revert callout with statistics
11355
---@param session_data OpencodeMessage[] All messages in the session
11456
---@param start_idx number Index of the message where revert occurred
11557
---@return Output output object representing the lines, extmarks, and actions
11658
function M._format_revert_message(session_data, start_idx)
11759
local output = Output.new()
118-
local stats = M._calculate_revert_stats(session_data, start_idx, state.active_session.revert)
60+
local stats = format_utils.calculate_revert_stats(session_data, start_idx, state.active_session.revert)
11961
local message_text = stats.messages == 1 and 'message' or 'messages'
12062
local tool_text = stats.tool_calls == 1 and 'tool call' or 'tool calls'
12163

@@ -177,7 +119,7 @@ function M._format_patch(output, part)
177119
end
178120

179121
local restore_points = snapshot.get_restore_points_by_parent(part.hash) or {}
180-
M.format_action(output, 'snapshot', 'Created Snapshot', vim.trim(part.hash:sub(1, 8)))
122+
format_utils.format_action(output, icons.get('snapshot'), 'Created Snapshot', vim.trim(part.hash:sub(1, 8)))
181123

182124
-- Anchor all snapshot-level actions to the snapshot header line
183125
add_action(output, '[R]evert file', 'diff_revert_selected_file', { part.hash }, 'R')
@@ -469,88 +411,19 @@ function M._format_assistant_message(output, text)
469411
output:add_lines(vim.split(result, '\n'))
470412
end
471413

472-
---Build the formatted action line string without writing to output
473-
---@param icon_name string Name of the icon to fetch with `icons.get`
474-
---@param tool_type string Tool type (e.g., 'run', 'read', 'edit', etc.)
475-
---@param value string Value associated with the action (e.g., filename, command)
476-
---@param duration_text? string
477-
---@return string
478-
function M._build_action_line(icon_name, tool_type, value, duration_text)
479-
local icon = icons.get(icon_name)
480-
local detail = value and #value > 0 and ('`' .. value .. '`') or ''
481-
local duration_suffix = duration_text and (' ' .. duration_text) or ''
482-
return string.format('**%s %s** %s%s', icon, tool_type, detail, duration_suffix)
483-
end
484-
485-
---@param output Output Output object to write to
486-
---@param tool_type string Tool type (e.g., 'run', 'read', 'edit', etc.)
487-
---@param value string Value associated with the action (e.g., filename, command)
488-
---@param duration_text? string
489-
function M.format_action(output, icon_name, tool_type, value, duration_text)
490-
if not icon_name or not tool_type then
491-
return
492-
end
493-
output:add_line(M._build_action_line(icon_name, tool_type, value, duration_text))
494-
end
495-
496-
function M._resolve_file_name(file_path)
497-
return tool_helpers.resolve_file_name(file_path)
498-
end
499-
500-
---@param file_path string
501-
---@param tool_output? string
502-
---@return boolean
503-
function M._is_directory_path(file_path, tool_output)
504-
return tool_helpers.is_directory_path(file_path, tool_output)
505-
end
506-
507-
---@param file_path string
508-
---@param tool_output? string
509-
---@return string
510-
function M._resolve_display_file_name(file_path, tool_output)
511-
return tool_helpers.resolve_display_file_name(file_path, tool_output)
512-
end
513-
514-
function M._resolve_grep_string(input)
515-
return tool_helpers.resolve_grep_string(input)
516-
end
517-
518414
---@param output Output Output object to write to
519415
---@param part OpencodeMessagePart
520416
---@param get_child_parts? fun(session_id: string): OpencodeMessagePart[]?
521-
function M._format_tool(output, part, get_child_parts)
417+
function M.format_tool(output, part, get_child_parts)
522418
local tool = part.tool
523419
if not tool or not part.state then
524420
return
525421
end
526422

527423
local start_line = output:get_line_count() + 1
528-
local input = part.state.input or {}
529-
local metadata = part.state.metadata or {}
530-
local tool_output = part.state.output or ''
531-
local tool_time = part.state.time or {}
532-
local tool_status = part.state.status
533-
local should_show_duration = tool ~= 'question' and tool_status ~= 'pending'
534-
local duration_text = should_show_duration and util.format_duration_seconds(tool_time.start, tool_time['end']) or nil
535424

536425
local formatter = tool_formatters[tool] or tool_formatters.tool
537-
formatter.format({
538-
output = output,
539-
tool_type = tool,
540-
input = input,
541-
metadata = metadata,
542-
tool_output = tool_output,
543-
title = part.state.title,
544-
status = part.state.status,
545-
duration_text = duration_text,
546-
config = config,
547-
format_action = M.format_action,
548-
format_diff = M.format_diff,
549-
format_code = M._format_code,
550-
build_action_line = M._build_action_line,
551-
get_child_parts = get_child_parts,
552-
tool_summary_handlers = M._tool_summary_handlers,
553-
})
426+
formatter.format(output, part, get_child_parts)
554427

555428
if part.state.status == 'error' and part.state.error then
556429
output:add_line('')
@@ -569,155 +442,6 @@ function M._format_tool(output, part, get_child_parts)
569442
end
570443
end
571444

572-
M._tool_summary_handlers = {
573-
bash = function(part, input, metadata)
574-
return tool_formatters.bash.summary(part, input, metadata)
575-
end,
576-
read = function(part, input, metadata)
577-
return tool_formatters.read.summary(part, input, metadata)
578-
end,
579-
edit = function(part, input, metadata)
580-
return tool_formatters.edit.summary(part, input, metadata)
581-
end,
582-
write = function(part, input, metadata)
583-
return tool_formatters.write.summary(part, input, metadata)
584-
end,
585-
apply_patch = function(part, input, metadata)
586-
return tool_formatters.apply_patch.summary(part, input, metadata)
587-
end,
588-
todowrite = function(part, input, metadata)
589-
return tool_formatters.todowrite.summary(part, input, metadata)
590-
end,
591-
glob = function(part, input, metadata)
592-
return tool_formatters.glob.summary(part, input, metadata)
593-
end,
594-
webfetch = function(part, input, metadata)
595-
return tool_formatters.webfetch.summary(part, input, metadata)
596-
end,
597-
list = function(part, input, metadata)
598-
return tool_formatters.list.summary(part, input, metadata)
599-
end,
600-
task = function(part, input, metadata)
601-
return tool_formatters.task.summary(part, input, metadata)
602-
end,
603-
grep = function(part, input, metadata)
604-
return tool_formatters.grep.summary(part, input, metadata)
605-
end,
606-
tool = function(part, input, metadata)
607-
return tool_formatters.tool.summary(part, input, metadata)
608-
end,
609-
}
610-
611-
---@param output Output Output object to write to
612-
---@param lines string[]
613-
---@param language string
614-
function M._format_code(output, lines, language)
615-
output:add_empty_line()
616-
--- NOTE: use longer code fence because lines could contain ```
617-
output:add_line('`````' .. (language or ''))
618-
output:add_lines(util.sanitize_lines(lines))
619-
output:add_line('`````')
620-
end
621-
622-
---@param lines string[]
623-
local function parse_diff_line_numbers(lines)
624-
local numbered_lines = {}
625-
local old_line
626-
local new_line
627-
local max_line_number = 0
628-
629-
for idx, line in ipairs(lines) do
630-
local old_start, new_start = line:match('^@@ %-(%d+),?%d* %+(%d+),?%d* @@')
631-
632-
if old_start and new_start then
633-
old_line = tonumber(old_start)
634-
new_line = tonumber(new_start)
635-
elseif old_line and new_line then
636-
local first_char = line:sub(1, 1)
637-
638-
if first_char == ' ' then
639-
numbered_lines[idx] = { old = old_line, new = new_line }
640-
max_line_number = math.max(max_line_number, old_line, new_line)
641-
old_line = old_line + 1
642-
new_line = new_line + 1
643-
elseif first_char == '+' and not line:match('^%+%+%+%s') then
644-
numbered_lines[idx] = { old = nil, new = new_line }
645-
max_line_number = math.max(max_line_number, new_line)
646-
new_line = new_line + 1
647-
elseif first_char == '-' and not line:match('^%-%-%-%s') then
648-
numbered_lines[idx] = { old = old_line, new = nil }
649-
max_line_number = math.max(max_line_number, old_line)
650-
old_line = old_line + 1
651-
end
652-
end
653-
end
654-
655-
return numbered_lines, math.max(#tostring(max_line_number), 4)
656-
end
657-
658-
local function build_diff_gutter(line_numbers, width)
659-
local line_number = line_numbers.new or line_numbers.old
660-
return string.format('%-' .. width .. 's', line_number and tostring(line_number) or '')
661-
end
662-
663-
local function add_diff_line(output, line, line_numbers, width)
664-
local first_char = line:sub(1, 1)
665-
local line_hl = first_char == '+' and 'OpencodeDiffAdd' or first_char == '-' and 'OpencodeDiffDelete' or nil
666-
local gutter_hl = first_char == '+' and 'OpencodeDiffAddGutter'
667-
or first_char == '-' and 'OpencodeDiffDeleteGutter'
668-
or 'OpencodeDiffGutter'
669-
local sign_hl = gutter_hl
670-
local gutter = build_diff_gutter(line_numbers, width)
671-
local gutter_width = #gutter + 2
672-
673-
output:add_line(string.rep(' ', gutter_width) .. line:sub(2))
674-
675-
local line_idx = output:get_line_count()
676-
local extmark = {
677-
end_col = 0,
678-
end_row = line_idx,
679-
virt_text = {
680-
{ gutter, gutter_hl },
681-
{ first_char, sign_hl },
682-
{ ' ', gutter_hl },
683-
},
684-
priority = 5000,
685-
right_gravity = true,
686-
end_right_gravity = false,
687-
virt_text_hide = false,
688-
virt_text_pos = 'overlay',
689-
virt_text_repeat_linebreak = false,
690-
}
691-
692-
if line_hl then
693-
extmark.hl_group = line_hl
694-
extmark.hl_eol = true
695-
end
696-
697-
output:add_extmark(line_idx - 1, extmark --[[@as OutputExtmark]])
698-
end
699-
700-
function M.format_diff(output, code, file_type)
701-
output:add_empty_line()
702-
703-
--- NOTE: use longer code fence because code could contain ```
704-
output:add_line('`````' .. file_type)
705-
local full_lines = vim.split(code, '\n')
706-
local numbered_lines, line_number_width = parse_diff_line_numbers(full_lines)
707-
local first_visible_line = #full_lines > 5 and 6 or 1
708-
local lines = first_visible_line > 1 and vim.list_slice(full_lines, first_visible_line) or full_lines
709-
710-
for idx, line in ipairs(lines) do
711-
local source_idx = first_visible_line + idx - 1
712-
if numbered_lines[source_idx] then
713-
add_diff_line(output, line, numbered_lines[source_idx], line_number_width)
714-
else
715-
output:add_line(line)
716-
end
717-
end
718-
output:add_line('`````')
719-
end
720-
721445
---@param output Output Output object to write to
722446
---@param start_line number
723447
---@param end_line number
@@ -783,7 +507,7 @@ function M.format_part(part, message, is_last_part, get_child_parts)
783507
M._format_reasoning(output, part)
784508
content_added = true
785509
elseif part.type == 'tool' then
786-
M._format_tool(output, part, get_child_parts)
510+
M.format_tool(output, part, get_child_parts)
787511
content_added = true
788512
elseif part.type == 'patch' and part.hash then
789513
M._format_patch(output, part)

0 commit comments

Comments
 (0)