local Helpers = require("avante.history.helpers") local Line = require("avante.ui.line") local Utils = require("avante.utils") local Highlights = require("avante.highlights") local M = {} ---@diagnostic disable-next-line: deprecated local islist = vim.islist or vim.tbl_islist ---Converts text into format suitable for UI ---@param text string ---@param decoration string | nil ---@param filter? fun(text_line: string, idx: integer, len: integer): boolean ---@return avante.ui.Line[] local function text_to_lines(text, decoration, filter) local text_lines = vim.split(text, "\n") local lines = {} for idx, text_line in ipairs(text_lines) do if filter and not filter(text_line, idx, #text_lines) then goto continue end if decoration then table.insert(lines, Line:new({ { decoration }, { text_line } })) else table.insert(lines, Line:new({ { text_line } })) end ::continue:: end return lines end ---Converts text into format suitable for UI ---@param text string ---@param decoration string | nil ---@param truncate boolean | nil ---@return avante.ui.Line[] local function text_to_truncated_lines(text, decoration, truncate) local text_lines = vim.split(text, "\n") local lines = {} for _, text_line in ipairs(text_lines) do if truncate and #lines > 3 then table.insert( lines, Line:new({ { decoration }, { string.format("... (Result truncated, remaining %d lines not shown)", #text_lines - #lines + 1), Highlights.AVANTE_COMMENT_FG, }, }) ) break end table.insert(lines, Line:new({ { decoration }, { text_line } })) end return lines end ---@param lines avante.ui.Line[] ---@param decoration string | nil ---@param truncate boolean | nil ---@return avante.ui.Line[] local function lines_to_truncated_lines(lines, decoration, truncate) local truncated_lines = {} for idx, line in ipairs(lines) do if truncate and #truncated_lines > 3 then table.insert( truncated_lines, Line:new({ { decoration }, { string.format("... (Result truncated, remaining %d lines not shown)", #lines - idx + 1), Highlights.AVANTE_COMMENT_FG, }, }) ) break end table.insert(truncated_lines, line) end return truncated_lines end ---Converts "thinking" item into format suitable for UI ---@param item AvanteLLMMessageContentItem ---@return avante.ui.Line[] local function thinking_to_lines(item) local text = item.thinking or item.data or "" local text_lines = vim.split(text, "\n") --- trim prefix empty lines while #text_lines > 0 and text_lines[1] == "" do table.remove(text_lines, 1) end --- trim suffix empty lines while #text_lines > 0 and text_lines[#text_lines] == "" do table.remove(text_lines, #text_lines) end local ui_lines = {} table.insert(ui_lines, Line:new({ { Utils.icon("🤔 ") .. "Thought content:" } })) table.insert(ui_lines, Line:new({ { "" } })) for _, text_line in ipairs(text_lines) do table.insert(ui_lines, Line:new({ { "> " .. text_line } })) end return ui_lines end ---Converts logs generated by a tool during execution into format suitable for UI ---@param tool_name string ---@param logs string[] ---@return avante.ui.Line[] function M.tool_logs_to_lines(tool_name, logs) local ui_lines = {} local num_logs = #logs for log_idx = 1, num_logs do local log_lines = vim.split(logs[log_idx]:gsub("^%[" .. tool_name .. "%]: ", "", 1), "\n") local num_lines = #log_lines for line_idx = 1, num_lines do local decoration = "│ " table.insert(ui_lines, Line:new({ { decoration }, { " " .. log_lines[line_idx] } })) end end return ui_lines end local STATE_TO_HL = { generating = "AvanteStateSpinnerToolCalling", failed = "AvanteStateSpinnerFailed", succeeded = "AvanteStateSpinnerSucceeded", } function M.get_diff_lines(old_str, new_str, decoration, truncate) local lines = {} local line_count = 0 local old_lines = vim.split(old_str, "\n") local new_lines = vim.split(new_str, "\n") ---@diagnostic disable-next-line: assign-type-mismatch, missing-fields local patch = vim.diff(old_str, new_str, { ---@type integer[][] algorithm = "histogram", result_type = "indices", ctxlen = vim.o.scrolloff, }) local prev_start_a = 0 local truncated_lines = 0 for _, hunk in ipairs(patch) do local start_a, count_a, start_b, count_b = unpack(hunk) local no_change_lines = vim.list_slice(old_lines, prev_start_a, start_a - 1) if truncate then local last_three_no_change_lines = vim.list_slice(no_change_lines, #no_change_lines - 3) truncated_lines = truncated_lines + #no_change_lines - #last_three_no_change_lines if #no_change_lines > 4 then table.insert(lines, Line:new({ { decoration }, { "...", Highlights.AVANTE_COMMENT_FG } })) end no_change_lines = last_three_no_change_lines end for idx, line in ipairs(no_change_lines) do if truncate and line_count > 10 then truncated_lines = truncated_lines + #no_change_lines - idx break end line_count = line_count + 1 table.insert(lines, Line:new({ { decoration }, { line } })) end prev_start_a = start_a + count_a if count_a > 0 then local delete_lines = vim.list_slice(old_lines, start_a, start_a + count_a - 1) for idx, line in ipairs(delete_lines) do if truncate and line_count > 10 then truncated_lines = truncated_lines + #delete_lines - idx break end line_count = line_count + 1 table.insert(lines, Line:new({ { decoration }, { line, Highlights.TO_BE_DELETED_WITHOUT_STRIKETHROUGH } })) end end if count_b > 0 then local create_lines = vim.list_slice(new_lines, start_b, start_b + count_b - 1) for idx, line in ipairs(create_lines) do if truncate and line_count > 10 then truncated_lines = truncated_lines + #create_lines - idx break end line_count = line_count + 1 table.insert(lines, Line:new({ { decoration }, { line, Highlights.INCOMING } })) end end end if prev_start_a < #old_lines then -- Append remaining old_lines local no_change_lines = vim.list_slice(old_lines, prev_start_a, #old_lines) local first_three_no_change_lines = vim.list_slice(no_change_lines, 1, 3) for idx, line in ipairs(first_three_no_change_lines) do if truncate and line_count > 10 then truncated_lines = truncated_lines + #first_three_no_change_lines - idx break end line_count = line_count + 1 table.insert(lines, Line:new({ { decoration }, { line } })) end end if truncate and truncated_lines > 0 then table.insert( lines, Line:new({ { decoration }, { string.format("... (Result truncated, remaining %d lines not shown)", truncated_lines), Highlights.AVANTE_COMMENT_FG, }, }) ) end return lines end ---@param content any ---@param decoration string | nil ---@param truncate boolean | nil function M.get_content_lines(content, decoration, truncate) local lines = {} local content_obj = content if type(content) == "string" then local ok, content_obj_ = pcall(vim.json.decode, content) if ok then content_obj = content_obj_ end end if type(content_obj) == "table" then if islist(content_obj) then local all_lines = {} for _, content_item in ipairs(content_obj) do if type(content_item) == "string" then local lines_ = text_to_lines(content_item, decoration) all_lines = vim.list_extend(all_lines, lines_) end end local lines_ = lines_to_truncated_lines(all_lines, decoration, truncate) lines = vim.list_extend(lines, lines_) end if type(content_obj.content) == "string" then local lines_ = text_to_truncated_lines(content_obj.content, decoration, truncate) lines = vim.list_extend(lines, lines_) end if islist(content_obj.content) then local all_lines = {} for _, content_item in ipairs(content_obj.content) do if type(content_item) == "string" then local lines_ = text_to_lines(content_item, decoration) all_lines = vim.list_extend(all_lines, lines_) end end local lines_ = lines_to_truncated_lines(all_lines, decoration, truncate) lines = vim.list_extend(lines, lines_) end if islist(content_obj.matches) then local all_lines = {} for _, content_item in ipairs(content_obj.matches) do if type(content_item) == "string" then local lines_ = text_to_lines(content_item, decoration) all_lines = vim.list_extend(all_lines, lines_) end end local lines_ = lines_to_truncated_lines(all_lines, decoration, truncate) lines = vim.list_extend(lines, lines_) end end if type(content_obj) == "string" then local lines_ = text_to_lines(content_obj, decoration) local line_count = 0 for idx, line in ipairs(lines_) do if truncate and line_count > 3 then table.insert( lines, Line:new({ { decoration }, { string.format("... (Result truncated, remaining %d lines not shown)", #lines_ - idx + 1), Highlights.AVANTE_COMMENT_FG, }, }) ) break end line_count = line_count + 1 table.insert(lines, line) end end if type(content_obj) == "number" then table.insert(lines, Line:new({ { decoration }, { tostring(content_obj) } })) end if islist(content) then for _, content_item in ipairs(content) do local line_count = 0 if content_item.type == "content" then if content_item.content.type == "text" then local lines_ = text_to_lines(content_item.content.text, decoration, function(text_line, idx, len) if idx == 1 and text_line:match("^%s*```%s*$") then return false end if idx == len and text_line:match("^%s*```%s*$") then return false end return true end) for idx, line in ipairs(lines_) do if truncate and line_count > 3 then table.insert( lines, Line:new({ { decoration }, { string.format("... (Result truncated, remaining %d lines not shown)", #lines_ - idx + 1), Highlights.AVANTE_COMMENT_FG, }, }) ) break end line_count = line_count + 1 table.insert(lines, line) end end elseif content_item.type == "diff" and content_item.oldText ~= nil and content_item.newText ~= nil and content_item.oldText ~= vim.NIL and content_item.newText ~= vim.NIL then local relative_path = Utils.relative_path(content_item.path) table.insert(lines, Line:new({ { decoration }, { "Path: " .. relative_path } })) local lines_ = M.get_diff_lines(content_item.oldText, content_item.newText, decoration, truncate) lines = vim.list_extend(lines, lines_) end end end return lines end ---@param message avante.HistoryMessage ---@return string tool_name ---@return string | nil error function M.get_tool_display_name(message) local content = message.message.content if type(content) ~= "table" then return "", "expected message content to be a table" end ---@cast content AvanteLLMMessageContentItem[] if not islist(content) then return "", "expected message content to be a list" end local item = message.message.content[1] local native_tool_name = item.name if native_tool_name == "other" and message.acp_tool_call then native_tool_name = message.acp_tool_call.title or "Other" end if message.acp_tool_call and message.acp_tool_call.title then native_tool_name = message.acp_tool_call.title end local tool_name = native_tool_name if message.displayed_tool_name then tool_name = message.displayed_tool_name else local param if item.input and type(item.input) == "table" then local path if type(item.input.path) == "string" then path = item.input.path end if type(item.input.rel_path) == "string" then path = item.input.rel_path end if type(item.input.filepath) == "string" then path = item.input.filepath end if type(item.input.file_path) == "string" then path = item.input.file_path end if type(item.input.query) == "string" then param = item.input.query end if type(item.input.pattern) == "string" then param = item.input.pattern end if type(item.input.command) == "string" then param = item.input.command local pieces = vim.split(param, "\n") if #pieces > 1 then param = pieces[1] .. "..." end end if native_tool_name == "execute" and not param then if message.acp_tool_call and message.acp_tool_call.title then param = message.acp_tool_call.title end end if not param and path then local relative_path = Utils.relative_path(path) param = relative_path end end if not param and message.acp_tool_call then if message.acp_tool_call.locations then for _, location in ipairs(message.acp_tool_call.locations) do if location.path then local relative_path = Utils.relative_path(location.path) param = relative_path break end end end end if not param and message.acp_tool_call and message.acp_tool_call.rawInput and message.acp_tool_call.rawInput.command then param = message.acp_tool_call.rawInput.command pcall(function() local project_root = Utils.root.get() param = param:gsub(project_root .. "/?", "") end) end if param then tool_name = native_tool_name .. "(" .. vim.inspect(param) .. ")" end end ---@cast tool_name string return tool_name, nil end ---Converts a tool invocation into format suitable for UI ---@param item AvanteLLMMessageContentItem ---@param message avante.HistoryMessage ---@param messages avante.HistoryMessage[] ---@param expanded boolean | nil ---@return avante.ui.Line[] local function tool_to_lines(item, message, messages, expanded) -- local logs = message.tool_use_logs local lines = {} local tool_name, error = M.get_tool_display_name(message) if error then table.insert(lines, Line:new({ { "❌ " }, { error } })) return lines end local rest_input_text_lines = {} local result = Helpers.get_tool_result(item.id, messages) local state if not result then state = "generating" elseif result.is_error then state = "failed" else state = "succeeded" end table.insert( lines, Line:new({ { "╭─ " }, { " " .. tool_name .. " ", STATE_TO_HL[state] }, { " " .. state }, }) ) -- if logs then vim.list_extend(lines, tool_logs_to_lines(item.name, logs)) end local decoration = "│ " if rest_input_text_lines and #rest_input_text_lines > 0 then local lines_ = text_to_lines(table.concat(rest_input_text_lines, "\n"), decoration) local line_count = 0 for idx, line in ipairs(lines_) do if not expanded and line_count > 3 then table.insert( lines, Line:new({ { decoration }, { string.format("... (Input truncated, remaining %d lines not shown)", #lines_ - idx + 1), Highlights.AVANTE_COMMENT_FG, }, }) ) break end line_count = line_count + 1 table.insert(lines, line) end table.insert(lines, Line:new({ { decoration }, { "" } })) end local add_diff_lines = false if item.input and type(item.input) == "table" then if type(item.input.old_str) == "string" and type(item.input.new_str) == "string" then local diff_lines = M.get_diff_lines(item.input.old_str, item.input.new_str, decoration, not expanded) add_diff_lines = true vim.list_extend(lines, diff_lines) end end if not add_diff_lines and message.acp_tool_call and message.acp_tool_call.rawInput and message.acp_tool_call.rawInput.oldString then local diff_lines = M.get_diff_lines( message.acp_tool_call.rawInput.oldString, message.acp_tool_call.rawInput.newString, decoration, not expanded ) vim.list_extend(lines, diff_lines) end if message.acp_tool_call and message.acp_tool_call.rawOutput and message.acp_tool_call.rawOutput.metadata and message.acp_tool_call.rawOutput.metadata.preview then local preview = message.acp_tool_call.rawOutput.metadata.preview if preview then local content_lines = M.get_content_lines(preview, decoration, not expanded) vim.list_extend(lines, content_lines) end else if message.acp_tool_call and message.acp_tool_call.content then local content = message.acp_tool_call.content if content then local content_lines = M.get_content_lines(content, decoration, not expanded) vim.list_extend(lines, content_lines) end else if result and result.content then local result_content = result.content if result_content then local content_lines = M.get_content_lines(result_content, decoration, not expanded) vim.list_extend(lines, content_lines) end end end end if #lines <= 1 then if state == "generating" then table.insert(lines, Line:new({ { decoration }, { "...", Highlights.AVANTE_COMMENT_FG } })) else table.insert(lines, Line:new({ { decoration }, { "completed" } })) end end --- remove last empty lines while #lines > 0 and lines[#lines].sections[2] and lines[#lines].sections[2][1] == "" do table.remove(lines, #lines) end local last_line = lines[#lines] last_line.sections[1][1] = "╰─ " return lines end ---Converts a message item into representation suitable for UI ---@param item AvanteLLMMessageContentItem ---@param message avante.HistoryMessage ---@param messages avante.HistoryMessage[] ---@param expanded boolean | nil ---@return avante.ui.Line[] local function message_content_item_to_lines(item, message, messages, expanded) if type(item) == "string" then return text_to_lines(item) elseif type(item) == "table" then if item.type == "thinking" or item.type == "redacted_thinking" then return thinking_to_lines(item) elseif item.type == "text" then return text_to_lines(item.text) elseif item.type == "image" then return { Line:new({ { "![image](" .. item.source.media_type .. ": " .. item.source.data .. ")" } }) } elseif item.type == "tool_use" and item.name then local ok, llm_tool = pcall(require, "avante.llm_tools." .. item.name) if ok then local tool_result_message = Helpers.get_tool_result_message(item.id, messages) ---@cast llm_tool AvanteLLMTool if llm_tool.on_render then return llm_tool.on_render(item.input, { logs = message.tool_use_logs, state = message.state, store = message.tool_use_store, result_message = tool_result_message, }) end end local lines = tool_to_lines(item, message, messages, expanded) if message.tool_use_log_lines then lines = vim.list_extend(lines, message.tool_use_log_lines) end return lines end end return {} end ---Converts a message into representation suitable for UI ---@param message avante.HistoryMessage ---@param messages avante.HistoryMessage[] ---@param expanded boolean | nil ---@return avante.ui.Line[] function M.message_to_lines(message, messages, expanded) if message.displayed_content then return text_to_lines(message.displayed_content) end local content = message.message.content if type(content) == "string" then return text_to_lines(content) end if islist(content) then local lines = {} for _, item in ipairs(content) do local item_lines = message_content_item_to_lines(item, message, messages, expanded) lines = vim.list_extend(lines, item_lines) end return lines end return {} end ---Converts a message item into text representation ---@param item AvanteLLMMessageContentItem ---@param message avante.HistoryMessage ---@param messages avante.HistoryMessage[] ---@return string local function message_content_item_to_text(item, message, messages) local lines = message_content_item_to_lines(item, message, messages) return vim.iter(lines):map(function(line) return tostring(line) end):join("\n") end ---Converts a message into text representation ---@param message avante.HistoryMessage ---@param messages avante.HistoryMessage[] ---@return string function M.message_to_text(message, messages) local content = message.message.content if type(content) == "string" then return content end if islist(content) then return vim .iter(content) :map(function(item) return message_content_item_to_text(item, message, messages) end) :filter(function(text) return text ~= "" end) :join("\n") end return "" end return M