Skip to content

Commit b11f82f

Browse files
committed
feat(renderer): add tool calls summary to subagents
1 parent 57ffbfd commit b11f82f

6 files changed

Lines changed: 5709 additions & 65 deletions

File tree

lua/opencode/ui/formatter.lua

Lines changed: 137 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ local permission_window = require('opencode.ui.permission_window')
1010

1111
local M = {}
1212

13+
---@note child-session parts are requested from the renderer at format time
14+
1315
M.separator = {
1416
'----',
1517
'',
@@ -31,7 +33,7 @@ function M._format_reasoning(output, part)
3133
end
3234
end
3335

34-
M.format_action(output, icons.get('reasoning') .. ' ' .. title, '')
36+
M.format_action(output, 'reasoning', title, '')
3537

3638
if config.ui.output.tools.show_reasoning_output and text ~= '' then
3739
output:add_empty_line()
@@ -173,7 +175,7 @@ function M._format_patch(output, part)
173175
end
174176

175177
local restore_points = snapshot.get_restore_points_by_parent(part.hash) or {}
176-
M.format_action(output, icons.get('snapshot') .. ' Created Snapshot', vim.trim(part.hash:sub(1, 8)))
178+
M.format_action(output, 'snapshot', 'Created Snapshot', vim.trim(part.hash:sub(1, 8)))
177179

178180
-- Anchor all snapshot-level actions to the snapshot header line
179181
add_action(output, '[R]evert file', 'diff_revert_selected_file', { part.hash }, 'R')
@@ -465,27 +467,36 @@ function M._format_assistant_message(output, text)
465467
output:add_lines(vim.split(result, '\n'))
466468
end
467469

468-
---@param output Output Output object to write to
470+
---Build the formatted action line string without writing to output
471+
---@param icon_name string Name of the icon to fetch with `icons.get`
469472
---@param tool_type string Tool type (e.g., 'run', 'read', 'edit', etc.)
470473
---@param value string Value associated with the action (e.g., filename, command)
471474
---@param duration_text? string
472-
function M.format_action(output, tool_type, value, duration_text)
473-
if not tool_type or not value then
474-
return
475-
end
475+
---@return string
476+
function M._build_action_line(icon_name, tool_type, value, duration_text)
477+
local icon = icons.get(icon_name)
476478
local detail = value and #value > 0 and ('`' .. value .. '`') or ''
477479
local duration_suffix = duration_text and (' ' .. duration_text) or ''
478-
local line = string.format('**%s** %s%s', tool_type, detail, duration_suffix)
480+
return string.format('**%s %s** %s%s', icon, tool_type, detail, duration_suffix)
481+
end
479482

480-
output:add_line(line)
483+
---@param output Output Output object to write to
484+
---@param tool_type string Tool type (e.g., 'run', 'read', 'edit', etc.)
485+
---@param value string Value associated with the action (e.g., filename, command)
486+
---@param duration_text? string
487+
function M.format_action(output, icon_name, tool_type, value, duration_text)
488+
if not icon_name or not tool_type then
489+
return
490+
end
491+
output:add_line(M._build_action_line(icon_name, tool_type, value, duration_text))
481492
end
482493

483494
---@param output Output Output object to write to
484495
---@param input BashToolInput data for the tool
485496
---@param metadata BashToolMetadata Metadata for the tool use
486497
---@param duration_text? string
487498
function M._format_bash_tool(output, input, metadata, duration_text)
488-
M.format_action(output, icons.get('run') .. ' run', input and input.description, duration_text)
499+
M.format_action(output, 'run', 'run', input and input.description, duration_text)
489500

490501
if not config.ui.output.tools.show_output then
491502
return
@@ -504,22 +515,11 @@ end
504515
---@param metadata FileToolMetadata Metadata for the tool use
505516
---@param duration_text? string
506517
function M._format_file_tool(output, tool_type, input, metadata, duration_text)
507-
local file_name = ''
508-
if input and input.filePath then
509-
local cwd = vim.fn.getcwd()
510-
local absolute = vim.fn.fnamemodify(input.filePath, ':p')
511-
512-
if vim.startswith(absolute, cwd .. '/') then
513-
file_name = absolute:sub(#cwd + 2)
514-
else
515-
file_name = absolute
516-
end
517-
end
518+
local file_name = M._resolve_file_name(input and input.filePath or '')
518519

519520
local file_type = input and util.get_markdown_filetype(input.filePath) or ''
520-
local tool_action_icons = { read = icons.get('read'), edit = icons.get('edit'), write = icons.get('write') }
521521

522-
M.format_action(output, tool_action_icons[tool_type] .. ' ' .. tool_type, file_name, duration_text)
522+
M.format_action(output, tool_type, tool_type, file_name, duration_text)
523523

524524
if not config.ui.output.tools.show_output then
525525
return
@@ -537,7 +537,7 @@ end
537537
---@param duration_text? string
538538
function M._format_apply_patch_tool(output, metadata, duration_text)
539539
for _, file in ipairs(metadata.files or {}) do
540-
M.format_action(output, icons.get('edit') .. ' apply patch', file.relativePath or file.filePath, duration_text)
540+
M.format_action(output, 'edit', 'apply patch', file.relativePath or file.filePath, duration_text)
541541
if config.ui.output.tools.show_output and file.diff then
542542
local file_type = file and util.get_markdown_filetype(file.filePath) or ''
543543
M.format_diff(output, file.diff, file_type)
@@ -550,7 +550,7 @@ end
550550
---@param input TodoToolInput
551551
---@param duration_text? string
552552
function M._format_todo_tool(output, title, input, duration_text)
553-
M.format_action(output, icons.get('plan') .. ' plan', (title or ''), duration_text)
553+
M.format_action(output, 'plan', 'plan', (title or ''), duration_text)
554554
if not config.ui.output.tools.show_output then
555555
return
556556
end
@@ -568,7 +568,7 @@ end
568568
---@param metadata GlobToolMetadata Metadata for the tool use
569569
---@param duration_text? string
570570
function M._format_glob_tool(output, input, metadata, duration_text)
571-
M.format_action(output, icons.get('search') .. ' glob', input and input.pattern, duration_text)
571+
M.format_action(output, 'search', 'glob', input and input.pattern, duration_text)
572572
if not config.ui.output.tools.show_output then
573573
return
574574
end
@@ -581,14 +581,8 @@ end
581581
---@param metadata GrepToolMetadata Metadata for the tool use
582582
---@param duration_text? string
583583
function M._format_grep_tool(output, input, metadata, duration_text)
584-
local grep_str = table.concat(
585-
vim.tbl_filter(function(part)
586-
return part ~= nil
587-
end, { input.path or input.include, input.pattern }),
588-
'` `'
589-
)
590-
591-
M.format_action(output, icons.get('search') .. ' grep', grep_str, duration_text)
584+
local grep_str = M._resolve_grep_string(input)
585+
M.format_action(output, 'search', 'grep', grep_str, duration_text)
592586
if not config.ui.output.tools.show_output then
593587
return
594588
end
@@ -602,7 +596,7 @@ end
602596
---@param input WebFetchToolInput data for the tool
603597
---@param duration_text? string
604598
function M._format_webfetch_tool(output, input, duration_text)
605-
M.format_action(output, icons.get('web') .. ' fetch', input and input.url, duration_text)
599+
M.format_action(output, 'web', 'fetch', input and input.url, duration_text)
606600
end
607601

608602
---@param output Output Output object to write to
@@ -611,7 +605,7 @@ end
611605
---@param tool_output string
612606
---@param duration_text? string
613607
function M._format_list_tool(output, input, metadata, tool_output, duration_text)
614-
M.format_action(output, icons.get('list') .. ' list', input and input.path or '', duration_text)
608+
M.format_action(output, 'list', 'list', input and input.path or '', duration_text)
615609
if not config.ui.output.tools.show_output then
616610
return
617611
end
@@ -640,7 +634,7 @@ end
640634
---@param status string Status of the tool execution
641635
---@param duration_text? string
642636
function M._format_question_tool(output, input, metadata, status, duration_text)
643-
M.format_action(output, icons.get('question') .. ' question', '', duration_text)
637+
M.format_action(output, 'question', 'question', '', duration_text)
644638
output:add_empty_line()
645639
if not config.ui.output.tools.show_output or status ~= 'completed' then
646640
return
@@ -673,9 +667,36 @@ function M._format_question_tool(output, input, metadata, status, duration_text)
673667
end
674668
end
675669

670+
function M._resolve_file_name(file_path)
671+
if not file_path then
672+
return ''
673+
end
674+
local cwd = vim.fn.getcwd()
675+
local absolute = vim.fn.fnamemodify(file_path, ':p')
676+
if vim.startswith(absolute, cwd .. '/') then
677+
return absolute:sub(#cwd + 2)
678+
end
679+
return absolute
680+
end
681+
682+
function M._resolve_grep_string(input)
683+
if not input then
684+
return ''
685+
end
686+
local path_part = input.path or input.include or ''
687+
local pattern_part = input.pattern or ''
688+
return table.concat(
689+
vim.tbl_filter(function(p)
690+
return p ~= nil and p ~= ''
691+
end, { path_part, pattern_part }),
692+
' '
693+
)
694+
end
695+
676696
---@param output Output Output object to write to
677697
---@param part OpencodeMessagePart
678-
function M._format_tool(output, part)
698+
---@param get_child_parts? fun(session_id: string): OpencodeMessagePart[]?
699+
function M._format_tool(output, part, get_child_parts)
679700
local tool = part.tool
680701
if not tool or not part.state then
681702
return
@@ -718,7 +739,8 @@ function M._format_tool(output, part)
718739
input --[[@as TaskToolInput]],
719740
metadata --[[@as TaskToolMetadata]],
720741
tool_output,
721-
duration_text
742+
duration_text,
743+
get_child_parts
722744
)
723745
elseif tool == 'question' then
724746
M._format_question_tool(
@@ -729,7 +751,7 @@ function M._format_tool(output, part)
729751
duration_text
730752
)
731753
else
732-
M.format_action(output, icons.get('tool') .. ' tool', tool, duration_text)
754+
M.format_action(output, 'tool', 'tool', tool, duration_text)
733755
end
734756

735757
if part.state.status == 'error' and part.state.error then
@@ -749,12 +771,73 @@ function M._format_tool(output, part)
749771
end
750772
end
751773

774+
local tool_summary_handlers = {
775+
bash = function(_, input)
776+
return 'run', input.description or ''
777+
end,
778+
read = function(_, input)
779+
return 'read', M._resolve_file_name(input.filePath)
780+
end,
781+
edit = function(_, input)
782+
return 'edit', M._resolve_file_name(input.filePath)
783+
end,
784+
write = function(_, input)
785+
return 'write', M._resolve_file_name(input.filePath)
786+
end,
787+
apply_patch = function(_, metadata)
788+
local file = metadata.files and metadata.files[1]
789+
local others_count = metadata.files and #metadata.files - 1 or 0
790+
local suffix = others_count > 0 and string.format(' (+%d more)', others_count) or ''
791+
792+
return 'write', file and M._resolve_file_name(file.filePath) .. suffix or ''
793+
end,
794+
todowrite = function(part, _)
795+
return 'plan', part.state and part.state.title or ''
796+
end,
797+
glob = function(_, input)
798+
return 'search', input.pattern or ''
799+
end,
800+
webfetch = function(_, input)
801+
return 'web', input.url or ''
802+
end,
803+
list = function(_, input)
804+
return 'list', input.path or ''
805+
end,
806+
task = function(_, input)
807+
return 'task', input.description or ''
808+
end,
809+
grep = function(_, input)
810+
return 'search', M._resolve_grep_string(input)
811+
end,
812+
tool = function(_, input)
813+
return 'tool', input.description or ''
814+
end,
815+
}
816+
817+
---Build the action line string for a part (icon + meaningful value, no duration)
818+
---Used to show per-tool icon+label in child session activity lists.
819+
---@param part OpencodeMessagePart
820+
---@param status string Optional icon name to use for the status (e.g., 'running', 'completed', 'error'). If not provided, will use the default icon for the tool.
821+
---@return string
822+
function M._tool_action_line(part, status)
823+
local tool = part.tool
824+
local input = part.state and part.state.input or {}
825+
local handler = tool_summary_handlers[tool] or tool_summary_handlers['tool']
826+
local icon_name, tool_value = handler(part, input)
827+
if status ~= 'completed' then
828+
icon_name = status
829+
end
830+
831+
return M._build_action_line(icon_name, tool or 'tool', tool_value)
832+
end
833+
752834
---@param output Output Output object to write to
753835
---@param input TaskToolInput data for the tool
754836
---@param metadata TaskToolMetadata Metadata for the tool use
755837
---@param tool_output string
756838
---@param duration_text? string
757-
function M._format_task_tool(output, input, metadata, tool_output, duration_text)
839+
---@param get_child_parts? fun(session_id: string): OpencodeMessagePart[]?
840+
function M._format_task_tool(output, input, metadata, tool_output, duration_text, get_child_parts)
758841
local start_line = output:get_line_count() + 1
759842

760843
-- Show agent type if available
@@ -764,28 +847,20 @@ function M._format_task_tool(output, input, metadata, tool_output, duration_text
764847
description = string.format('%s (@%s)', description, agent_type)
765848
end
766849

767-
M.format_action(output, icons.get('task') .. ' task', description, duration_text)
850+
M.format_action(output, 'task', 'task', description, duration_text)
768851

769852
if config.ui.output.tools.show_output then
770-
-- Show task summary from metadata
771-
-- The summary contains items with structure: {id, tool, state: {status, title}}
772-
if metadata and metadata.summary and type(metadata.summary) == 'table' and #metadata.summary > 0 then
773-
output:add_empty_line()
853+
-- Show live tool activity from the child session
854+
local child_session_id = metadata and metadata.sessionId
855+
local child_parts = child_session_id and get_child_parts and get_child_parts(child_session_id)
774856

775-
local status_icons = {
776-
completed = icons.get('status_on') or '+',
777-
running = icons.get('run') or '>',
778-
pending = icons.get('status_off') or '-',
779-
error = icons.get('error') or 'x',
780-
}
857+
if child_parts and #child_parts > 0 then
858+
output:add_empty_line()
781859

782-
for _, item in ipairs(metadata.summary) do
860+
for _, item in ipairs(child_parts) do
783861
if item.tool then
784862
local status = item.state and item.state.status or 'pending'
785-
local title = item.state and item.state.title or item.tool
786-
local icon = status_icons[status] or status_icons.pending
787-
788-
output:add_line(string.format(' %s %s', icon, title))
863+
output:add_line(' ' .. M._tool_action_line(item, status))
789864
end
790865
end
791866

@@ -794,8 +869,8 @@ function M._format_task_tool(output, input, metadata, tool_output, duration_text
794869

795870
-- Show tool output text (usually the final summary from the subagent)
796871
if tool_output and tool_output ~= '' then
797-
-- Strip task_metadata tags from output for cleaner display
798-
local clean_output = tool_output:gsub('<task_metadata>.-</task_metadata>', ''):gsub('%s+$', '')
872+
-- remove the task_result tag, only get the inner content, since the tool output is already visually separated and the tag doesn't add much value in that case
873+
local clean_output = tool_output:gsub('<task_result>', ''):gsub('</task_result>', '')
799874
if clean_output ~= '' then
800875
output:add_empty_line()
801876
output:add_lines(vim.split(clean_output, '\n'))
@@ -895,8 +970,9 @@ end
895970
---@param part OpencodeMessagePart The part to format
896971
---@param message? OpencodeMessage Optional message object to extract role and mentions from
897972
---@param is_last_part? boolean Whether this is the last part in the message, used to show an error if there is one
973+
---@param get_child_parts? fun(session_id: string): OpencodeMessagePart[]?
898974
---@return Output
899-
function M.format_part(part, message, is_last_part)
975+
function M.format_part(part, message, is_last_part, get_child_parts)
900976
local output = Output.new()
901977

902978
if not message or not message.info or not message.info.role then
@@ -931,7 +1007,7 @@ function M.format_part(part, message, is_last_part)
9311007
M._format_reasoning(output, part)
9321008
content_added = true
9331009
elseif part.type == 'tool' then
934-
M._format_tool(output, part)
1010+
M._format_tool(output, part, get_child_parts)
9351011
content_added = true
9361012
elseif part.type == 'patch' and part.hash then
9371013
M._format_patch(output, part)

lua/opencode/ui/icons.lua

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ local presets = {
4545
bash = '',
4646
preferred = '',
4747
last_used = '󰃰 ',
48+
completed = '󰄳 ',
49+
pending = '󰅐 ',
50+
running = '',
4851
},
4952
text = {
5053
-- headers
@@ -86,6 +89,9 @@ local presets = {
8689
bash = '$ ',
8790
preferred = '* ',
8891
last_used = '~ ',
92+
completed = 'X ',
93+
pending = '- ',
94+
running = '> ',
8995
},
9096
}
9197

0 commit comments

Comments
 (0)