diff --git a/doc/codecompanion.txt b/doc/codecompanion.txt index 49ca37a0b..c01324f70 100644 --- a/doc/codecompanion.txt +++ b/doc/codecompanion.txt @@ -3951,6 +3951,27 @@ Also, multiple files can be selected and added to the chat buffer: Please note that these mappings may be different depending on your provider. +/DOCUMENT ~ + + + [!NOTE] Currently only available with the Anthropic adapter +The `document` slash command allows you to add document files (PDF, DOCX, RTF, +CSV, XLSX) to the chat buffer for analysis and discussion with the LLM. The +command provides three different sources for documents: + +- **File**: Browse and select document files from your file system using native, `Telescope`, `mini.pick`, `fzf.lua` or `snacks.nvim` providers +- **URL**: Provide a URL to a publicly accessible document (the URL must end with a supported file extension) +- **Files API**: Reference a document previously uploaded via Anthropic’s Files API by providing the `file_id` + +Documents are automatically validated for: - Size limit (32MB maximum per +Anthropic API requirements) - Supported file types (pdf, rtf, docx, csv, xlsx) + +In the config for the slash command, you can specify directories (`opts.dirs`) +and filetypes (`opts.filetypes`) to customize the document picker’s search +scope. By default, it searches the current working directory for all supported +document types. + + /HELP ~ The `help` slash command allows you to add content from a vim help file diff --git a/doc/usage/chat-buffer/slash-commands.md b/doc/usage/chat-buffer/slash-commands.md index bb4d5aadc..972c14b2d 100644 --- a/doc/usage/chat-buffer/slash-commands.md +++ b/doc/usage/chat-buffer/slash-commands.md @@ -47,6 +47,23 @@ The _file_ slash command allows you to add the contents of a file in the current Please note that these mappings may be different depending on your provider. +## /document + +> [!NOTE] +> Currently only available with the Anthropic adapter + +The _document_ slash command allows you to add document files (PDF, DOCX, RTF, CSV, XLSX) to the chat buffer for analysis and discussion with the LLM. The command provides different sources for documents depending on adapter capabilities: + +- **File**: Browse and select document files from your file system using native, _Telescope_, _mini.pick_, _fzf.lua_ or _snacks.nvim_ providers +- **URL**: Provide a URL to a publicly accessible document (the URL must end with a supported file extension) +- **Files API**: Reference a document previously uploaded via Anthropic's Files API by providing the `file_id` (only available if the adapter supports the Files API) + +Documents are automatically validated for: +- Size limit (32MB maximum per Anthropic API requirements) +- Supported file types (pdf, rtf, docx, csv, xlsx) + +In the config for the slash command, you can specify directories (`opts.dirs`) and filetypes (`opts.filetypes`) to customize the document picker's search scope. By default, it searches the current working directory for all supported document types. + ## /help The _help_ slash command allows you to add content from a vim help file (`:h helpfile`), to the chat buffer, by searching for help tags. Currently this is only available for _Telescope_, _mini.pick_, _fzf_lua_ and _snacks.nvim_ providers. By default, the slash command will prompt you to trim a help file that is over 1,000 lines in length. diff --git a/lua/codecompanion/adapters/acp/auggie_cli.lua b/lua/codecompanion/adapters/acp/auggie_cli.lua index 79eab0d5b..7678703b2 100644 --- a/lua/codecompanion/adapters/acp/auggie_cli.lua +++ b/lua/codecompanion/adapters/acp/auggie_cli.lua @@ -41,7 +41,7 @@ return { ---@param self CodeCompanion.ACPAdapter ---@param messages table - ---@param capabilities table + ---@param capabilities ACP.agentCapabilities ---@return table form_messages = function(self, messages, capabilities) return helpers.form_messages(self, messages, capabilities) diff --git a/lua/codecompanion/adapters/acp/cagent.lua b/lua/codecompanion/adapters/acp/cagent.lua index 783ed53d9..8100cd857 100644 --- a/lua/codecompanion/adapters/acp/cagent.lua +++ b/lua/codecompanion/adapters/acp/cagent.lua @@ -42,7 +42,7 @@ return { ---@param self CodeCompanion.ACPAdapter ---@param messages table - ---@param capabilities table + ---@param capabilities ACP.agentCapabilities ---@return table form_messages = function(self, messages, capabilities) return helpers.form_messages(self, messages, capabilities) diff --git a/lua/codecompanion/adapters/acp/claude_code.lua b/lua/codecompanion/adapters/acp/claude_code.lua index 194234191..3a2554ac7 100644 --- a/lua/codecompanion/adapters/acp/claude_code.lua +++ b/lua/codecompanion/adapters/acp/claude_code.lua @@ -55,7 +55,7 @@ return { ---@param self CodeCompanion.ACPAdapter ---@param messages table - ---@param capabilities table + ---@param capabilities ACP.agentCapabilities ---@return table form_messages = function(self, messages, capabilities) return helpers.form_messages(self, messages, capabilities) diff --git a/lua/codecompanion/adapters/acp/codex.lua b/lua/codecompanion/adapters/acp/codex.lua index 6faa6afcf..721a87468 100644 --- a/lua/codecompanion/adapters/acp/codex.lua +++ b/lua/codecompanion/adapters/acp/codex.lua @@ -44,7 +44,7 @@ return { ---@param self CodeCompanion.ACPAdapter ---@param messages table - ---@param capabilities table + ---@param capabilities ACP.agentCapabilities ---@return table form_messages = function(self, messages, capabilities) return helpers.form_messages(self, messages, capabilities) diff --git a/lua/codecompanion/adapters/acp/gemini_cli.lua b/lua/codecompanion/adapters/acp/gemini_cli.lua index 2bf341fe5..b1312c572 100644 --- a/lua/codecompanion/adapters/acp/gemini_cli.lua +++ b/lua/codecompanion/adapters/acp/gemini_cli.lua @@ -11,6 +11,7 @@ return { }, opts = { vision = true, + doc_upload = true, }, commands = { default = { @@ -50,7 +51,7 @@ return { ---@param self CodeCompanion.ACPAdapter ---@param messages table - ---@param capabilities table + ---@param capabilities ACP.agentCapabilities ---@return table form_messages = function(self, messages, capabilities) return helpers.form_messages(self, messages, capabilities) diff --git a/lua/codecompanion/adapters/acp/goose.lua b/lua/codecompanion/adapters/acp/goose.lua index 8ae4c6eb6..d31826042 100644 --- a/lua/codecompanion/adapters/acp/goose.lua +++ b/lua/codecompanion/adapters/acp/goose.lua @@ -41,7 +41,7 @@ return { ---@param self CodeCompanion.ACPAdapter ---@param messages table - ---@param capabilities table + ---@param capabilities ACP.agentCapabilities ---@return table form_messages = function(self, messages, capabilities) return helpers.form_messages(self, messages, capabilities) diff --git a/lua/codecompanion/adapters/acp/helpers.lua b/lua/codecompanion/adapters/acp/helpers.lua index 6e1f319c4..813d59f63 100644 --- a/lua/codecompanion/adapters/acp/helpers.lua +++ b/lua/codecompanion/adapters/acp/helpers.lua @@ -28,6 +28,43 @@ M.form_messages = function(self, messages, capabilities) } end end + + if msg._meta and msg._meta.tag == "document" and msg.context and msg.context.mimetype then + if has.embeddedContext then + if msg.context.source == "url" then + -- URL-based document + return { + type = "resource_link", + resource = { + uri = "file://" .. msg.context.url, + name = "", + mimeType = msg.context.mimetype, + }, + } + elseif msg.context.source == "file" then + -- Files API reference - requires file_api capability + log:warn("The %s agent does not support Files API", self.formatted_name) + -- Remove the message if file_api is not supported + return nil + else + -- Base64-encoded document + return { + type = "resource", + resource = { + uri = "file://" .. msg.context.path, + name = vim.fn.fnamemodify(msg.context.path, ":t"), + mimeType = msg.context.mimetype, + blob = msg.content, + }, + } + end + else + log:warn("The %s agent does not support receiving documents", self.formatted_name) + -- Remove the message if document upload support is not enabled + return nil + end + end + if msg.content and msg.content ~= "" then if msg._meta and msg._meta.tag == "file" then -- If we can't send the file as a resource, send as text diff --git a/lua/codecompanion/adapters/acp/kimi_cli.lua b/lua/codecompanion/adapters/acp/kimi_cli.lua index 4db955819..d0fc9ecfb 100644 --- a/lua/codecompanion/adapters/acp/kimi_cli.lua +++ b/lua/codecompanion/adapters/acp/kimi_cli.lua @@ -41,7 +41,7 @@ return { ---@param self CodeCompanion.ACPAdapter ---@param messages table - ---@param capabilities table + ---@param capabilities ACP.agentCapabilities ---@return table form_messages = function(self, messages, capabilities) return helpers.form_messages(self, messages, capabilities) diff --git a/lua/codecompanion/adapters/acp/opencode.lua b/lua/codecompanion/adapters/acp/opencode.lua index 6d43c17b4..51be5e674 100644 --- a/lua/codecompanion/adapters/acp/opencode.lua +++ b/lua/codecompanion/adapters/acp/opencode.lua @@ -50,7 +50,7 @@ return { ---@param self CodeCompanion.ACPAdapter ---@param messages table - ---@param capabilities table + ---@param capabilities ACP.agentCapabilities ---@return table form_messages = function(self, messages, capabilities) return helpers.form_messages(self, messages, capabilities) diff --git a/lua/codecompanion/adapters/http/anthropic.lua b/lua/codecompanion/adapters/http/anthropic.lua index 0aa5c4836..50513bb6f 100644 --- a/lua/codecompanion/adapters/http/anthropic.lua +++ b/lua/codecompanion/adapters/http/anthropic.lua @@ -21,6 +21,8 @@ return { stream = true, tools = true, vision = true, + doc_upload = true, + file_api = true, }, url = "https://api.anthropic.com/v1/messages", env = { @@ -111,6 +113,12 @@ return { if not model_opts.opts.has_vision then self.opts.vision = false end + if not model_opts.opts.has_doc_upload then + self.opts.doc_upload = false + end + if not model_opts.opts.has_file_api then + self.opts.file_api = false + end end -- Add the extended output header if enabled @@ -198,6 +206,56 @@ return { end end + -- 3a. Account for any documents (PDFs) + if m._meta and m._meta.tag == "document" and m.context then + if self.opts and self.opts.doc_upload then + if m.context.source == "url" then + -- URL-based document + m.content = { + { + type = "document", + source = { + type = "url", + url = m.context.url, + }, + }, + } + elseif m.context.source == "file" then + -- Files API reference - requires file_api capability + if self.opts.file_api then + m.content = { + { + type = "document", + source = { + type = "file", + file_id = m.context.file_id, + }, + }, + } + else + -- Remove the message if file_api is not supported + return nil + end + else + -- Base64-encoded document + local content_data = m.content + m.content = { + { + type = "document", + source = { + type = "base64", + media_type = m.context.mimetype or "application/pdf", + data = content_data, + }, + }, + } + end + else + -- Remove the message if document upload support is not enabled + return nil + end + end + -- 4. Remove disallowed keys m = adapter_utils.filter_out_messages({ message = m, @@ -379,7 +437,7 @@ return { ---@return table|nil form_tools = function(self, tools) if not self.opts.tools or not tools then - return + return nil end local transformed = {} @@ -611,35 +669,41 @@ return { choices = { ["claude-haiku-4-5"] = { formatted_name = "Claude Haiku 4.5", - opts = { can_reason = true, has_vision = true }, + opts = { can_reason = true, has_vision = true, has_doc_upload = true, has_file_api = true }, }, ["claude-opus-4-5"] = { formatted_name = "Claude Opus 4.5", - opts = { can_reason = true, has_vision = true }, + opts = { can_reason = true, has_vision = true, has_doc_upload = true, has_file_api = true }, }, ["claude-sonnet-4-5"] = { formatted_name = "Claude Sonnet 4.5", - opts = { can_reason = true, has_vision = true }, + opts = { can_reason = true, has_vision = true, has_doc_upload = true, has_file_api = true }, }, ["claude-opus-4-1"] = { formatted_name = "Claude Opus 4.1", - opts = { can_reason = true, has_vision = true }, + opts = { can_reason = true, has_vision = true, has_doc_upload = true, has_file_api = true }, }, ["claude-opus-4-0"] = { formatted_name = "Claude Opus 4", - opts = { can_reason = true, has_vision = true }, + opts = { can_reason = true, has_vision = true, has_doc_upload = true, has_file_api = true }, }, ["claude-sonnet-4-0"] = { formatted_name = "Claude Sonnet 4", - opts = { can_reason = true, has_vision = true }, + opts = { can_reason = true, has_vision = true, has_doc_upload = true, has_file_api = true }, }, ["claude-3-7-sonnet-latest"] = { formatted_name = "Claude Sonnet 3.7", - opts = { can_reason = true, has_vision = true, has_token_efficient_tools = true }, + opts = { + can_reason = true, + has_vision = true, + has_token_efficient_tools = true, + has_doc_upload = true, + has_file_api = true, + }, }, ["claude-3-5-haiku-latest"] = { formatted_name = "Claude Haiku 3.5", - opts = { has_vision = true }, + opts = { has_vision = true, has_doc_upload = true, has_file_api = true }, }, }, }, diff --git a/lua/codecompanion/config.lua b/lua/codecompanion/config.lua index 54bd4d0f8..18b996505 100644 --- a/lua/codecompanion/config.lua +++ b/lua/codecompanion/config.lua @@ -416,6 +416,23 @@ If you are providing code changes, use the insert_edit_into_file tool (if availa provider = providers.images, -- telescope|snacks|default }, }, + ["document"] = { + callback = "interactions.chat.slash_commands.builtin.document", + description = "Insert a document (PDF)", + ---@param opts { adapter: CodeCompanion.HTTPAdapter|CodeCompanion.ACPAdapter } + ---@return boolean + enabled = function(opts) + if opts.adapter and opts.adapter.opts then + return opts.adapter.opts.doc_upload == true + end + return false + end, + opts = { + dirs = {}, -- Directories to search for documents + filetypes = { "pdf", "rtf", "docx", "csv", "xslx" }, -- Filetypes to search for + provider = providers.pickers, -- telescope|fzf_lua|mini_pick|snacks|default + }, + }, ["rules"] = { callback = "interactions.chat.slash_commands.builtin.rules", description = "Insert rules into the chat buffer", diff --git a/lua/codecompanion/interactions/chat/init.lua b/lua/codecompanion/interactions/chat/init.lua index f76bb8b72..cf8da3755 100644 --- a/lua/codecompanion/interactions/chat/init.lua +++ b/lua/codecompanion/interactions/chat/init.lua @@ -979,6 +979,43 @@ function Chat:add_image_message(image, opts) }) end +---Add a document to the chat buffer +---@param document CodeCompanion.Document The document object containing the path and other metadata +---@param opts? {role?: "user"|string, source?: string, bufnr?: integer} Options for adding the document +---@return nil +function Chat:add_document_message(document, opts) + opts = vim.tbl_deep_extend("force", { + role = config.constants.USER_ROLE, + source = "codecompanion.interactions.chat.slash_commands.document", + bufnr = document.bufnr, + }, opts or {}) + + local id = "" .. (document.id or document.path) .. "" + + self:add_message({ + role = opts.role, + content = document.base64 or "", + }, { + context = { + id = id, + mimetype = document.mimetype, + path = document.path or document.id, + source = document.source, + url = document.url, + file_id = document.file_id, + }, + _meta = { tag = "document" }, + visible = false, + }) + + self.context:add({ + bufnr = opts.bufnr, + id = id, + path = document.path or document.url or document.file_id, + source = opts.source, + }) +end + ---Apply any tools or variables that a user has tagged in their message ---@param message table ---@return nil diff --git a/lua/codecompanion/interactions/chat/slash_commands/builtin/document.lua b/lua/codecompanion/interactions/chat/slash_commands/builtin/document.lua new file mode 100644 index 000000000..224ed920e --- /dev/null +++ b/lua/codecompanion/interactions/chat/slash_commands/builtin/document.lua @@ -0,0 +1,300 @@ +local config = require("codecompanion.config") +local document_utils = require("codecompanion.utils.documents") +local log = require("codecompanion.utils.log") + +local CONSTANTS = { + NAME = "Document", + PROMPT = "Documents", + DOCUMENT_DIRS = config.interactions.chat.slash_commands.document + and config.interactions.chat.slash_commands.document.opts + and config.interactions.chat.slash_commands.document.opts.dirs + or {}, + DOCUMENT_TYPES = config.interactions.chat.slash_commands.document + and config.interactions.chat.slash_commands.document.opts + and config.interactions.chat.slash_commands.document.opts.filetypes + or { "pdf", "rtf", "docx", "csv", "xslx" }, +} + +---Prepares document search directories and filetypes +---@return table, table|nil Returns search_dirs and filetypes +local function prepare_document_search_options() + local current_search_dirs = { vim.fn.getcwd() } + + if CONSTANTS.DOCUMENT_DIRS and vim.tbl_count(CONSTANTS.DOCUMENT_DIRS) > 0 then + vim.list_extend(current_search_dirs, CONSTANTS.DOCUMENT_DIRS) + end + + local ft = nil + if CONSTANTS.DOCUMENT_TYPES and vim.tbl_count(CONSTANTS.DOCUMENT_TYPES) > 0 then + ft = CONSTANTS.DOCUMENT_TYPES + end + + return current_search_dirs, ft +end + +local providers = { + ---The default provider + ---@param SlashCommand CodeCompanion.SlashCommand + ---@return nil + default = function(SlashCommand) + local dirs, ft = prepare_document_search_options() + + local default = require("codecompanion.providers.slash_commands.default") + default = default + .new({ + output = function(selection) + local _res = document_utils.from_path(selection.path, { chat_bufnr = SlashCommand.Chat.bufnr }) + if type(_res) == "string" then + return log:error(_res) + end + return SlashCommand:output(_res) + end, + SlashCommand = SlashCommand, + title = CONSTANTS.PROMPT, + }) + :documents(dirs, ft) + :display() + end, + + ---The Snacks.nvim provider + ---@param SlashCommand CodeCompanion.SlashCommand + ---@return nil + snacks = function(SlashCommand) + local snacks = require("codecompanion.providers.slash_commands.snacks") + snacks = snacks.new({ + output = function(selection) + local _res = document_utils.from_path(selection.file, { chat_bufnr = SlashCommand.Chat.bufnr }) + if type(_res) == "string" then + return log:error(_res) + end + return SlashCommand:output(_res) + end, + }) + + local dirs, ft = prepare_document_search_options() + + snacks.provider.picker.pick("files", { + confirm = snacks:display(), + dirs = dirs, + ft = ft, + main = { file = false, float = true }, + }) + end, + + ---The Telescope provider + ---@param SlashCommand CodeCompanion.SlashCommand + ---@return nil + telescope = function(SlashCommand) + local telescope = require("codecompanion.providers.slash_commands.telescope") + telescope = telescope.new({ + title = CONSTANTS.PROMPT, + output = function(selection) + local _res = document_utils.from_path(selection[1], { chat_bufnr = SlashCommand.Chat.bufnr }) + if type(_res) == "string" then + return log:error(_res) + end + return SlashCommand:output(_res) + end, + }) + + local dirs, doc_fts = prepare_document_search_options() + local find_command = { "fd", "--type", "f", "--follow", "--hidden" } + if doc_fts then + for _, ext in ipairs(doc_fts) do + table.insert(find_command, "--extension") + table.insert(find_command, ext) + end + end + + telescope.provider.find_files({ + find_command = find_command, + prompt_title = telescope.title, + attach_mappings = telescope:display(), + search_dirs = dirs, + }) + end, + + ---The Mini.Pick provider + ---@param SlashCommand CodeCompanion.SlashCommand + ---@return nil + mini_pick = function(SlashCommand) + local mini_pick = require("codecompanion.providers.slash_commands.mini_pick") + mini_pick = mini_pick.new({ + title = CONSTANTS.PROMPT, + output = function(selected) + local _res = document_utils.from_path(selected.path or selected, { chat_bufnr = SlashCommand.Chat.bufnr }) + if type(_res) == "string" then + return log:error(_res) + end + return SlashCommand:output(_res) + end, + }) + + local dirs, doc_fts = prepare_document_search_options() + -- Build glob pattern for mini.pick + local glob_pattern = "**/*" + if doc_fts then + glob_pattern = "**/*.{" .. table.concat(doc_fts, ",") .. "}" + end + + mini_pick.provider.builtin.files( + { cwd = dirs[1], glob_pattern = glob_pattern }, + mini_pick:display(function(selected) + return { + path = selected, + } + end) + ) + end, + + ---The fzf-lua provider + ---@param SlashCommand CodeCompanion.SlashCommand + ---@return nil + fzf_lua = function(SlashCommand) + local fzf = require("codecompanion.providers.slash_commands.fzf_lua") + fzf = fzf.new({ + title = CONSTANTS.PROMPT, + output = function(selected) + local file_path = type(selected) == "table" and (selected.path or selected[1]) or selected + local _res = document_utils.from_path(file_path, { chat_bufnr = SlashCommand.Chat.bufnr }) + if type(_res) == "string" then + return log:error(_res) + end + return SlashCommand:output(_res) + end, + }) + + local dirs, doc_fts = prepare_document_search_options() + -- Build file extension filter for fzf + local ext_pattern = "*" + if doc_fts then + ext_pattern = "*.{" .. table.concat(doc_fts, ",") .. "}" + end + + fzf.provider.files( + fzf:display(function(selected, opts) + local file = fzf.provider.path.entry_to_file(selected, opts) + return { + relative_path = file.stripped, + path = file.path, + } + end), + { + cwd = dirs[1], + file_icons = true, + find_opts = ext_pattern, + } + ) + end, +} + +local choice = { + ---Load the file picker + ---@param SlashCommand CodeCompanion.SlashCommand.Document + ---@param SlashCommands CodeCompanion.SlashCommands + ---@return nil + File = function(SlashCommand, SlashCommands) + return SlashCommands:set_provider(SlashCommand, providers) + end, + + ---Share the URL of a document + ---@param SlashCommand CodeCompanion.SlashCommand.Document + ---@return nil + URL = function(SlashCommand, _) + return vim.ui.input({ prompt = "Enter the document URL: " }, function(url) + if #vim.trim(url or "") == 0 then + return + end + + document_utils.from_url(url, { chat_bufnr = SlashCommand.Chat.bufnr }, function(_res) + if type(_res) == "string" then + return log:error(_res) + end + SlashCommand:output(_res) + end) + end) + end, + + ---Use Files API reference + ---@param SlashCommand CodeCompanion.SlashCommand.Document + ---@return nil + ["Files API"] = function(SlashCommand, _) + return vim.ui.input({ prompt = "Enter the file_id: " }, function(file_id) + if #vim.trim(file_id or "") == 0 then + return + end + + SlashCommand:output({ + source = "file", + file_id = file_id, + id = file_id, + path = "", -- Required field, empty for Files API references + }) + end) + end, +} + +---@class CodeCompanion.SlashCommand.Document: CodeCompanion.SlashCommand +local SlashCommand = {} + +---@param args CodeCompanion.SlashCommandArgs +function SlashCommand.new(args) + local self = setmetatable({ + Chat = args.Chat, + config = args.config, + context = args.context, + }, { __index = SlashCommand }) + + return self +end + +---Execute the slash command +---@param SlashCommands CodeCompanion.SlashCommands +---@return nil +function SlashCommand:execute(SlashCommands) + -- Build options based on adapter capabilities + local options = { "URL", "File" } + + -- Only show Files API option if adapter supports it + if self.Chat.adapter.opts and self.Chat.adapter.opts.file_api then + table.insert(options, "Files API") + end + + vim.ui.select(options, { + prompt = "Select a document source", + }, function(selected) + if not selected then + return + end + return choice[selected](self, SlashCommands) + end) +end + +---Add document to chat buffer +---@param selected CodeCompanion.Document +---@param opts? table +---@return nil +function SlashCommand:output(selected, opts) + if selected.source == "file" then + -- Files API reference - no encoding needed + return self.Chat:add_document_message(selected) + end + + local encoded_document = document_utils.encode_document(selected) + if type(encoded_document) == "string" then + return log:error("Could not encode document: %s", encoded_document) + end + return self.Chat:add_document_message(encoded_document) +end + +---Is the slash command enabled? +---@param chat CodeCompanion.Chat +---@return boolean, string +function SlashCommand.enabled(chat) + -- Check if adapter supports document upload + local supports_docs = chat.adapter.opts and chat.adapter.opts.doc_upload or false + + return supports_docs, "The document Slash Command is not enabled for this adapter" +end + +return SlashCommand diff --git a/lua/codecompanion/providers/slash_commands/default.lua b/lua/codecompanion/providers/slash_commands/default.lua index d4348ec28..48b883516 100644 --- a/lua/codecompanion/providers/slash_commands/default.lua +++ b/lua/codecompanion/providers/slash_commands/default.lua @@ -116,6 +116,38 @@ function Default:images(paths, filetypes) return self end +---Find documents in a set of paths +---@param paths table +---@param filetypes table +function Default:documents(paths, filetypes) + local files = {} + for _, path in ipairs(paths) do + local p = Path:new(path) + + local file = scan.scan_dir(p:absolute(), { + hidden = false, + depth = 5, + add_dirs = false, + search_pattern = filetypes, + }) + + vim.list_extend(files, file) + end + + self.to_display = vim + .iter(files) + :map(function(f) + return { relative_path = f, path = f } + end) + :totable() + + self.to_format = function(item) + return item.relative_path + end + + return self +end + ---The function to display the provider ---@return function function Default:display() diff --git a/lua/codecompanion/utils/documents.lua b/lua/codecompanion/utils/documents.lua new file mode 100644 index 000000000..e01fa7618 --- /dev/null +++ b/lua/codecompanion/utils/documents.lua @@ -0,0 +1,206 @@ +local files_utils = require("codecompanion.utils.files") + +local M = {} + +local CONSTANTS = { + MAX_SIZE_MB = 32, + SUPPORTED_TYPES = { + pdf = "application/pdf", + rtf = "text/rtf", + xslx = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + csv = "text/csv", + docx = "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + }, +} + +---@class (private) CodeCompanion.Document +---@field id string +---@field path string +---@field bufnr? integer +---@field base64? string +---@field mimetype? string +---@field source? string "base64"|"url"|"file" +---@field url? string +---@field file_id? string + +---Validate document file +---@param path string +---@return boolean success, string? error_message +local function validate_document(path) + local stat = vim.loop.fs_stat(path) + if not stat then + return false, "File does not exist" + end + + -- Check file size (32MB limit per Anthropic API) + local max_size = CONSTANTS.MAX_SIZE_MB * 1024 * 1024 + if stat.size > max_size then + return false, string.format("File too large: %.2fMB (max %dMB)", stat.size / 1024 / 1024, CONSTANTS.MAX_SIZE_MB) + end + + -- Check file extension + local ext = path:match("%.([^%.]+)$") + if ext then + ext = ext:lower() + end + if not ext or not CONSTANTS.SUPPORTED_TYPES[ext] then + return false, + string.format( + "Unsupported file type: .%s (supported: %s)", + ext or "unknown", + table.concat(vim.tbl_keys(CONSTANTS.SUPPORTED_TYPES), ", ") + ) + end + + return true, nil +end + +---Base64 encode the given document and generate the corresponding mimetype +---@param document CodeCompanion.Document +---@return CodeCompanion.Document|string The encoded document or error message +function M.encode_document(document) + if document.source == "url" then + return document -- URLs don't need encoding + end + + if document.source == "file" then + return document -- Files API references don't need encoding + end + + if document.base64 then + return document -- Already encoded + end + + local path = document.path + local ok, err = validate_document(path) + if not ok then + return assert(err, "validate_document must return error message when ok is false") + end + + -- Read and encode file + local b64_content, b64_err = files_utils.base64_encode_file(path) + if b64_err then + return b64_err + end + + document.base64 = assert(b64_content, "base64_encode_file must return content when no error") + + if not document.mimetype then + local ext = path:match("%.([^%.]+)$") + if ext then + ext = ext:lower() + document.mimetype = CONSTANTS.SUPPORTED_TYPES[ext] or "application/octet-stream" + end + end + + document.source = "base64" + return document +end + +---@class (private) CodeCompanion.Document.Preprocessor.Context +---@field chat_bufnr integer? + +---@alias CodeCompanion.Document.Preprocessor +--- | fun(source: string, ctx: CodeCompanion.Document.Preprocessor.Context?, cb: fun(result: string|CodeCompanion.Document)):nil +--- | fun(source: string, ctx: CodeCompanion.Document.Preprocessor.Context?, cb: nil): string|CodeCompanion.Document + +---Load document from file path +---@type CodeCompanion.Document.Preprocessor +function M.from_path(path, _, cb) + -- Validate the document + local ok, err = validate_document(path) + if not ok then + local error_msg = assert(err, "validate_document must return error message when ok is false") + if type(cb) == "function" then + return vim.schedule(function() + cb(error_msg) + end) + end + return error_msg + end + + -- Expand to full path + local full_path = vim.fn.expand(path) + + -- Extract extension and set mimetype + local ext = full_path:match("%.([^%.]+)$") + local mimetype = "application/octet-stream" -- default fallback + if ext then + ext = ext:lower() + mimetype = CONSTANTS.SUPPORTED_TYPES[ext] or mimetype + end + + -- Create document object + ---@type CodeCompanion.Document + local document = { + path = full_path, + id = full_path, + mimetype = mimetype, + source = "base64", + } + + if type(cb) == "function" then + return vim.schedule(function() + cb(document) + end) + end + return document +end + +---Load document from URL +---@type CodeCompanion.Document.Preprocessor +function M.from_url(url, ctx, cb) + ctx = ctx or {} + + -- Validate URL points to a supported document type + local has_supported_ext = false + for ext, _ in pairs(CONSTANTS.SUPPORTED_TYPES) do + if url:match("%." .. ext .. "$") or url:match("%." .. ext .. "%?") then + has_supported_ext = true + break + end + end + + if not has_supported_ext then + local supported = table.concat(vim.tbl_keys(CONSTANTS.SUPPORTED_TYPES), ", ") + local err_msg = string.format("URL must point to a supported document type (%s)", supported) + if type(cb) == "function" then + return vim.schedule(function() + cb(err_msg) + end) + end + return err_msg + end + + -- For URLs, we can pass directly to the API without downloading + ---@type CodeCompanion.Document + local document = { + source = "url", + url = url, + id = url, + path = "", -- Required by CodeCompanion.Document class + } + + if type(cb) == "function" then + return vim.schedule(function() + cb(document) + end) + end + return document +end + +---Get document info for display +---@param document CodeCompanion.Document +---@return string +function M.get_document_info(document) + if document.source == "url" then + return string.format("Document: %s", document.url) + elseif document.source == "file" then + return string.format("Document: file_id=%s", document.file_id) + else + local filename = vim.fn.fnamemodify(document.path, ":t") + return string.format("Document: %s", filename) + end +end + +return M diff --git a/lua/codecompanion/utils/files.lua b/lua/codecompanion/utils/files.lua index 3c119a92b..14a17544a 100644 --- a/lua/codecompanion/utils/files.lua +++ b/lua/codecompanion/utils/files.lua @@ -221,6 +221,10 @@ function M.get_mimetype(path) png = "image/png", webp = "image/webp", pdf = "application/pdf", + rtf = "text/rtf", + xslx = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + csv = "text/csv", + docx = "application/vnd.openxmlformats-officedocument.wordprocessingml.document", } local extension = vim.fn.fnamemodify(path, ":e") diff --git a/stylua.toml b/stylua.toml index 91384aea3..141fdde66 100644 --- a/stylua.toml +++ b/stylua.toml @@ -4,6 +4,7 @@ indent_width = 2 line_endings = "Unix" quote_style = "AutoPreferDouble" no_call_parentheses = false +syntax = "Lua52" [sort_requires] enabled = true