diff --git a/docs/usage.md b/docs/usage.md index c69c219..6abd51a 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -17,16 +17,22 @@ Everything you need to get productive with ECA inside Neovim. | Command | Description | Example | |--------|-------------|---------| -| `:EcaChat` | Opens ECA chat | `:EcaChat` | -| `:EcaToggle` | Toggles sidebar visibility | `:EcaToggle` | -| `:EcaFocus` | Focus on ECA sidebar | `:EcaFocus` | -| `:EcaClose` | Closes ECA sidebar | `:EcaClose` | -| `:EcaAddFile [file]` | Adds file as context | `:EcaAddFile src/main.lua` | -| `:EcaAddSelection` | Adds current selection as context | `:EcaAddSelection` | -| `:EcaServerStart` | Starts ECA server manually | `:EcaServerStart` | -| `:EcaServerStop` | Stops ECA server | `:EcaServerStop` | -| `:EcaServerRestart` | Restarts ECA server | `:EcaServerRestart` | -| `:EcaSend ` | Sends message directly | `:EcaSend Explain this function` | +| `:EcaChat` | Open ECA chat sidebar | `:EcaChat` | +| `:EcaToggle` | Toggle sidebar visibility | `:EcaToggle` | +| `:EcaFocus` | Focus ECA sidebar | `:EcaFocus` | +| `:EcaClose` | Close ECA sidebar | `:EcaClose` | +| `:EcaChatAddFile [file]` | Add a file as context for the current chat | `:EcaChatAddFile lua/eca/sidebar.lua` | +| `:EcaChatRemoveFile [file]` | Remove a file context from the current chat | `:EcaChatRemoveFile lua/eca/sidebar.lua` | +| `:EcaChatAddSelection` | Add current visual selection as a file-range context | `:EcaChatAddSelection` | +| `:EcaChatAddUrl` | Add a URL as "web" context | `:EcaChatAddUrl` | +| `:EcaChatListContexts` | List active contexts for the current chat | `:EcaChatListContexts` | +| `:EcaChatClearContexts` | Clear all contexts for the current chat | `:EcaChatClearContexts` | +| `:EcaServerStart` | Start ECA server manually | `:EcaServerStart` | +| `:EcaServerStop` | Stop ECA server | `:EcaServerStop` | +| `:EcaServerRestart` | Restart ECA server | `:EcaServerRestart` | +| `:EcaSend ` | Send message directly (without opening chat) | `:EcaSend Explain this function` | + +Deprecated aliases (still available but log a warning): `:EcaAddFile`, `:EcaAddSelection`, `:EcaRemoveContext`, `:EcaListContexts`, `:EcaClearContexts`. Prefer the `:EcaChat*` variants above. --- @@ -81,30 +87,134 @@ Consider readability and maintainability. ### Current file ```vim -:EcaAddFile +:EcaChatAddFile ``` +Adds the current buffer as a file context for the active chat. + ### Specific file ```vim -:EcaAddFile src/main.lua -:EcaAddFile /full/path/to/file.js +:EcaChatAddFile src/main.lua +:EcaChatAddFile /full/path/to/file.js ``` +Pass a path to add that file as context. Relative paths are resolved to absolute paths. + ### Code selection 1. Select code in visual mode (`v`, `V`, or `Ctrl+v`) -2. Run `:EcaAddSelection` -3. Selected code will be added as context +2. Run `:EcaChatAddSelection` +3. The selected lines will be added as a file-range context (file + line range) + +### Web URLs + +```vim +:EcaChatAddUrl +``` + +Prompts for a URL and adds it as a `web` context. The URL label in the input is truncated for display, but the full URL is sent to the server. + +### Listing and clearing contexts + +```vim +:EcaChatListContexts " show all active contexts +:EcaChatClearContexts " remove all contexts from the current chat +:EcaChatRemoveFile " remove the current file from contexts +``` ### Multiple files ```vim -:EcaAddFile src/utils.lua -:EcaAddFile src/config.lua -:EcaAddFile tests/test_utils.lua +:EcaChatAddFile +:EcaChatAddFile src/utils.lua +:EcaChatAddFile src/config.lua +:EcaChatAddFile tests/test_utils.lua +``` + +### Context area in the input + +When the sidebar is open, the chat input buffer has **two parts**: + +1. **First line – context area**: shows one label per active context (e.g. `sidebar.lua `, `sidebar.lua:25-50 ` or a truncated URL). +2. **Below that – message input**: your prompt, prefixed by `> ` (configurable via `windows.input.prefix`). + +You normally do not need to edit the first line manually, but you can: + +- **Remove a single context**: move the cursor to the corresponding label on the first line and delete it; the context is removed from the current chat while your message text is preserved. +- **Clear all contexts**: delete the whole first line; ECA restores an empty context line and clears all contexts. + +#### Examples + +**No contexts yet** + +```text +@ +> Explain this code +``` + +**Single file context** + +```text +@sidebar.lua @ +> Explain this code ``` +**Two contexts (file + line range)** + +```text +@sidebar.lua @sidebar.lua:25-50 @ +> Explain this selection +``` + +If you now delete just the `sidebar.lua:25-50 ` label on the first line, only that context is removed: + +```text +@sidebar.lua @ +> Explain this selection +``` + +If instead you delete the **entire first line**, all contexts are cleared. ECA recreates an empty context line internally and keeps your input text: + +```text +@ +> Explain this selection +``` + +When typing paths directly with `@` to trigger completion, the input might briefly look like: + +```text +@lua/eca/sidebar.lua +> Input text +``` + +After confirming a completion item, that `@...` reference is turned into a context entry and shown as a short label (for example `sidebar.lua `) in the context area. + +### Context completion and `@` / `#` path shortcuts + +Inside the input (filetype `eca-input`): + +- Typing `@` or `#` followed by part of a path triggers context completion (via the provided `cmp`/`blink` sources). +- Selecting a completion item in the **context area line** automatically adds that item as a context for the current chat and shows it as a label on the first line. + +Semantics of the two prefixes: + +- **`@` prefix** – *inline content*: + - `@path/to/file.lua` means: "resolve this to the file contents and send those contents to the model". + - The server expands the `@` reference to the actual file content before forming the prompt. +- **`#` prefix** – *path reference*: + - `#path/to/file.lua` means: "send the full absolute path; the model will fetch and read the file itself". + - The server keeps it as a path reference in the prompt so the model can look up the file by path. + +In both cases, when you send a message any occurrences like: + +```text +@relative/path/to/file.lua +#another/path +``` + +are first expanded to absolute paths on the Neovim side (including `~` expansion). The difference is how the server then interprets `@` (inline file contents) versus `#` (path-only reference that the model resolves). + --- ## Common Use Cases @@ -145,7 +255,7 @@ Consider readability and maintainability. ## Recommended Workflow 1. Open the file you want to analyze -2. Add as context: `:EcaAddFile` +2. Add as context: `:EcaChatAddFile` 3. Open chat: `ec` 4. Ask your question: ```markdown @@ -190,7 +300,7 @@ Consider readability and maintainability. ## Tips and Tricks ### Productivity -1. Use `:EcaAddFile` before asking about specific code +1. Use `:EcaChatAddFile` before asking about specific code 2. Combine contexts: add multiple related files 3. Be specific: detailed questions generate better responses 4. Use Markdown: ECA understands Markdown formatting @@ -230,10 +340,10 @@ Consider readability and maintainability. -- More convenient shortcuts vim.keymap.set("n", "", ":EcaChat") vim.keymap.set("n", "", ":EcaToggle") -vim.keymap.set("v", "ea", ":EcaAddSelection") +vim.keymap.set("v", "ea", ":EcaChatAddSelection") -- Shortcut to add current file vim.keymap.set("n", "ef", function() - vim.cmd("EcaAddFile " .. vim.fn.expand("%")) + vim.cmd("EcaChatAddFile " .. vim.fn.expand("%")) end) ``` diff --git a/lua/eca/api.lua b/lua/eca/api.lua index 851a102..d061a67 100644 --- a/lua/eca/api.lua +++ b/lua/eca/api.lua @@ -81,22 +81,49 @@ function M.add_file_context(file_path) -- Create context object local context = { type = "file", - path = file_path, - content = content, + data = { + path = file_path, + } } - -- Get current sidebar and add context - local sidebar = eca.get() - if not sidebar then - Logger.info("Opening ECA sidebar to add context...") - M.chat() - sidebar = eca.get() + local chat = eca.get() + + if not chat or not chat.mediator then + Logger.notify("No active ECA Chat to add context", vim.log.levels.WARN) + return end - if sidebar then - sidebar:add_context(context) + chat.mediator:add_context(context) + Logger.info("File context added: " .. vim.inspect(context)) +end + +function M.remove_file_context(path) + local eca = require("eca") + local chat = eca.get() + + if not chat or not chat.mediator then + Logger.notify("No active ECA Chat", vim.log.levels.WARN) + return + end + + -- Create context object + local context = { + type = "file", + data = { + path = path, + } + } + + Logger.info("Removing context: " .. vim.inspect(context)) + chat.mediator:remove_context(context) +end + +function M.remove_current_file_context() + local current_file = vim.api.nvim_buf_get_name(0) + if current_file and current_file ~= "" then + M.remove_file_context(current_file) else - Logger.notify("Failed to create ECA sidebar", vim.log.levels.ERROR) + Logger.notify("No current file to remove as context", vim.log.levels.WARN) end end @@ -113,12 +140,48 @@ function M.add_directory_context(directory_path) -- Create context object for directory local context = { type = "directory", - path = directory_path, + data = { + path = directory_path, + }, } - -- For now, store it for next message - -- TODO: Implement context management - Logger.debug("Directory context added: " .. directory_path) + local chat = eca.get() + + if not chat or not chat.mediator then + Logger.notify("No active ECA Chat to add context", vim.log.levels.WARN) + return + end + + chat.mediator:add_context(context) + Logger.info("Directory context added: " .. vim.inspect(context)) +end + +---@param url string +function M.add_web_context(url) + Logger.info("Adding web context: " .. url) + local eca = require("eca") + + if not eca.server or not eca.server:is_running() then + Logger.notify("ECA server is not running", vim.log.levels.ERROR) + return + end + + local chat = eca.get() + + if not chat or not chat.mediator then + Logger.notify("No active ECA Chat to add context", vim.log.levels.WARN) + return + end + + local context = { + type = "web", + data = { + path = url, + }, + } + + chat.mediator:add_context(context) + Logger.info("Web context added: " .. vim.inspect(context)) end function M.add_current_file_context() @@ -131,6 +194,9 @@ function M.add_current_file_context() end function M.add_selection_context() + Logger.info("Adding selection context ...") + local eca = require("eca") + -- Get visual selection marks (should be set by the command before calling this) local start_pos = vim.fn.getpos("'<") local end_pos = vim.fn.getpos("'>") @@ -145,61 +211,47 @@ function M.add_selection_context() local end_line = math.max(start_pos[2], end_pos[2]) local lines = vim.api.nvim_buf_get_lines(0, start_line - 1, end_line, false) - if #lines > 0 then - local selection_text = table.concat(lines, "\n") - local current_file = vim.api.nvim_buf_get_name(0) - local context_path = current_file .. ":" .. start_line .. "-" .. end_line - - -- Create context object - local context = { - type = "file", - path = context_path, - lines_range = { - start = start_line, - End = end_line - } - } - -- Get current sidebar and add context - local eca = require("eca") - local sidebar = eca.get() - if not sidebar then - Logger.info("Opening ECA sidebar to add context...") - M.chat() - sidebar = eca.get() - end + if #lines <= 0 then + Logger.notify("No lines found in the selection", vim.log.levels.WARN) + return + end + + local current_file = vim.api.nvim_buf_get_name(0) - if sidebar then - sidebar:add_context(context) + -- Create context object + local context = { + type = "file", + data = { + path = current_file, + lines_range = { + line_start = start_line, + line_end = end_line, + }, + } + } - -- Also set as selected code for visual display - local selected_code = { - filepath = current_file, - content = selection_text, - start_line = start_line, - end_line = end_line, - filetype = vim.api.nvim_get_option_value("filetype", { buf = 0 }), - } - sidebar:set_selected_code(selected_code) + local chat = eca.get() - Logger.info("Added selection context (" .. #lines .. " lines from lines " .. start_line .. "-" .. end_line .. ")") - else - Logger.notify("Failed to create ECA sidebar", vim.log.levels.ERROR) - end - else - Logger.notify("No lines found in selection", vim.log.levels.WARN) + if not chat or not chat.mediator then + Logger.notify("No active ECA Chat to add context", vim.log.levels.WARN) + return end + + chat.mediator:add_context(context) + Logger.info("Added selection context: " .. vim.inspect(context)) end function M.list_contexts() local eca = require("eca") - local sidebar = eca.get() - if not sidebar then + local chat = eca.get() + + if not chat or not chat.mediator then Logger.notify("No active ECA sidebar", vim.log.levels.WARN) return end - local contexts = sidebar:get_contexts() + local contexts = chat.mediator:contexts() if #contexts == 0 then Logger.notify("No active contexts", vim.log.levels.INFO) return @@ -207,66 +259,21 @@ function M.list_contexts() Logger.info("Active contexts (" .. #contexts .. "):") for i, context in ipairs(contexts) do - local size_info = "" - if context.content then - local lines = vim.split(context.content, "\n") - size_info = " (" .. #lines .. " lines)" - end - Logger.info(i .. ". " .. context.type .. ": " .. context.path .. size_info) + Logger.info(i .. ". " .. context.type .. ": " .. vim.inspect(context.data)) end end function M.clear_contexts() local eca = require("eca") - local sidebar = eca.get() - if not sidebar then - Logger.notify("No active ECA sidebar", vim.log.levels.WARN) - return - end - - sidebar:clear_contexts() -end + local chat = eca.get() -function M.remove_context(path) - local eca = require("eca") - local sidebar = eca.get() - if not sidebar then - Logger.notify("No active ECA sidebar", vim.log.levels.WARN) + if not chat or not chat.mediator then + Logger.notify("No active ECA Chat", vim.log.levels.WARN) return end - sidebar:remove_context(path) -end - -function M.add_repo_map_context() - local eca = require("eca") - local sidebar = eca.get() - if not sidebar then - Logger.info("Opening ECA sidebar to add repoMap context...") - M.chat() - sidebar = eca.get() - end - - if sidebar then - -- Check if repoMap already exists - local contexts = sidebar:get_contexts() - for _, context in ipairs(contexts) do - if context.type == "repoMap" then - Logger.notify("RepoMap context already added", vim.log.levels.INFO) - return - end - end - - -- Add repoMap context - sidebar:add_context({ - type = "repoMap", - path = "repoMap", - content = "Repository structure and code mapping for better project understanding", - }) - Logger.info("Added repoMap context") - else - Logger.notify("Failed to create ECA sidebar", vim.log.levels.ERROR) - end + chat.mediator:clear_contexts() + Logger.info("Cleared all contexts") end ---@return boolean @@ -307,105 +314,6 @@ function M.server_status() end end --- ===== Selected Code Management ===== - -function M.show_selected_code() - local eca = require("eca") - local sidebar = eca.get() - if sidebar then - local selected_code = sidebar._selected_code - if selected_code then - Logger.notify( - "Selected code: " - .. selected_code.filepath - .. " (lines " - .. (selected_code.start_line or "?") - .. "-" - .. (selected_code.end_line or "?") - .. ")", - vim.log.levels.INFO - ) - else - Logger.notify("No code currently selected", vim.log.levels.INFO) - end - else - Logger.notify("ECA sidebar not available", vim.log.levels.WARN) - end -end - -function M.clear_selected_code() - local eca = require("eca") - local sidebar = eca.get() - if sidebar then - sidebar:clear_selected_code() - else - Logger.notify("ECA sidebar not available", vim.log.levels.WARN) - end -end - --- ===== TODOs Management ===== - -function M.add_todo(content) - local eca = require("eca") - local sidebar = eca.get() - if not sidebar then - Logger.info("Opening ECA sidebar to add TODO...") - M.chat() - sidebar = eca.get() - end - - if sidebar then - local todo = { - content = content, - status = "pending", - } - sidebar:add_todo(todo) - else - Logger.notify("Failed to create ECA sidebar", vim.log.levels.ERROR) - end -end - -function M.list_todos() - local eca = require("eca") - local sidebar = eca.get() - if sidebar then - local todos = sidebar:get_todos() - if #todos == 0 then - Logger.notify("No active TODOs", vim.log.levels.INFO) - return - end - - Logger.notify("Active TODOs:", vim.log.levels.INFO) - for i, todo in ipairs(todos) do - local status_icon = todo.status == "completed" and "✓" or "○" - Logger.notify(string.format("%d. %s %s", i, status_icon, todo.content), vim.log.levels.INFO) - end - else - Logger.notify("ECA sidebar not available", vim.log.levels.WARN) - end -end - -function M.toggle_todo(index) - local eca = require("eca") - local sidebar = eca.get() - if sidebar then - return sidebar:toggle_todo(index) - else - Logger.notify("ECA sidebar not available", vim.log.levels.WARN) - return false - end -end - -function M.clear_todos() - local eca = require("eca") - local sidebar = eca.get() - if sidebar then - sidebar:clear_todos() - else - Logger.notify("ECA sidebar not available", vim.log.levels.WARN) - end -end - -- Keep reference to logs popup globally to reuse it local logs_popup = nil diff --git a/lua/eca/commands.lua b/lua/eca/commands.lua index 24d26cc..481511f 100644 --- a/lua/eca/commands.lua +++ b/lua/eca/commands.lua @@ -32,8 +32,10 @@ function M.setup() }) vim.api.nvim_create_user_command("EcaAddFile", function(opts) - if opts.args and opts.args ~= "" then - require("eca.api").add_file_context(opts.args) + Logger.notify("EcaAddFile is deprecated. Use EcaChatAddFile instead.", vim.log.levels.WARN) + + if opts.args and opts.args ~= "" and type(opts.args) == "string" then + require("eca.api").add_file_context(vim.fn.fnamemodify(opts.args, ":p")) else require("eca.api").add_current_file_context() end @@ -43,102 +45,115 @@ function M.setup() complete = "file", }) - vim.api.nvim_create_user_command("EcaAddSelection", function() - -- Force exit visual mode and set marks - vim.cmd("normal! \\") - vim.defer_fn(function() - require("eca.api").add_selection_context() - end, 50) -- Small delay to ensure marks are set + vim.api.nvim_create_user_command("EcaChatAddFile", function(opts) + if opts.args and opts.args ~= "" and type(opts.args) == "string" then + require("eca.api").add_file_context(vim.fn.fnamemodify(opts.args, ":p")) + else + require("eca.api").add_current_file_context() + end end, { - desc = "Add current selection as context to ECA", - range = true, + desc = "Add file as context to ECA", + nargs = "?", + complete = "file", }) - vim.api.nvim_create_user_command("EcaListContexts", function() - require("eca.api").list_contexts() - end, { - desc = "List active contexts in ECA", - }) + vim.api.nvim_create_user_command("EcaRemoveContext", function(opts) + Logger.notify("EcaRemoveContext is deprecated. Use EcaChatRemoveFile instead.", vim.log.levels.WARN) - vim.api.nvim_create_user_command("EcaClearContexts", function() - require("eca.api").clear_contexts() + if opts.args and opts.args ~= "" and type(opts.args) == "string" then + require("eca.api").remove_file_context(vim.fn.fnamemodify(opts.args, ":p")) + else + require("eca.api").remove_current_file_context() + end end, { - desc = "Clear all contexts from ECA", + desc = "Remove specific file context from ECA", + nargs = "?", + complete = "file", }) - vim.api.nvim_create_user_command("EcaRemoveContext", function(opts) - if opts.args and opts.args ~= "" then - require("eca.api").remove_context(opts.args) + vim.api.nvim_create_user_command("EcaChatRemoveFile", function(opts) + if opts.args and opts.args ~= "" and type(opts.args) == "string" then + require("eca.api").remove_file_context(vim.fn.fnamemodify(opts.args, ":p")) else - Logger.notify("Please provide a file path to remove", vim.log.levels.WARN) + require("eca.api").remove_current_file_context() end end, { - desc = "Remove specific context from ECA", - nargs = "+", + desc = "Remove specific file context from ECA", + nargs = "?", complete = "file", }) - vim.api.nvim_create_user_command("EcaAddRepoMap", function() - require("eca.api").add_repo_map_context() + vim.api.nvim_create_user_command("EcaAddSelection", function() + Logger.notify("EcaAddSelection is deprecated. Use EcaChatAddSelection instead.", vim.log.levels.WARN) + + -- Force exit visual mode and set marks + vim.cmd("normal! \\") + vim.defer_fn(function() + require("eca.api").add_selection_context() + end, 50) -- Small delay to ensure marks are set end, { - desc = "Add repository map context to ECA", + desc = "Add current selection as context to ECA", + range = true, }) - -- ===== Selected Code Commands ===== - - vim.api.nvim_create_user_command("EcaShowSelection", function() - require("eca.api").show_selected_code() + vim.api.nvim_create_user_command("EcaChatAddSelection", function() + -- Force exit visual mode and set marks + vim.cmd("normal! \\") + vim.defer_fn(function() + require("eca.api").add_selection_context() + end, 50) -- Small delay to ensure marks are set end, { - desc = "Show currently selected code in ECA", + desc = "Add current selection as context to ECA", + range = true, }) - vim.api.nvim_create_user_command("EcaClearSelection", function() - require("eca.api").clear_selected_code() + vim.api.nvim_create_user_command("EcaChatAddUrl", function() + vim.ui.input({ prompt = "Enter URL to add as context: " }, function(input) + if not input or input == "" then + return + end + + local url = vim.fn.trim(input) + if url == "" then + return + end + + require("eca.api").add_web_context(url) + end) end, { - desc = "Clear selected code from ECA", + desc = "Add URL as web context to ECA", }) - -- ===== TODOs Commands ===== + vim.api.nvim_create_user_command("EcaListContexts", function() + Logger.notify("EcaListContexts is deprecated. Use EcaChatListContexts instead.", vim.log.levels.WARN) - vim.api.nvim_create_user_command("EcaAddTodo", function(opts) - if opts.args and opts.args ~= "" then - require("eca.api").add_todo(opts.args) - else - Logger.notify("Please provide TODO content", vim.log.levels.WARN) - end + require("eca.api").list_contexts() end, { - desc = "Add a new TODO to ECA", - nargs = "+", + desc = "List active contexts in ECA", }) - vim.api.nvim_create_user_command("EcaListTodos", function() - require("eca.api").list_todos() + vim.api.nvim_create_user_command("EcaChatListContexts", function() + require("eca.api").list_contexts() end, { - desc = "List active TODOs in ECA", + desc = "List active contexts in ECA", }) - vim.api.nvim_create_user_command("EcaToggleTodo", function(opts) - if opts.args and opts.args ~= "" then - local index = tonumber(opts.args) - if index then - require("eca.api").toggle_todo(index) - else - Logger.notify("Please provide a valid TODO index", vim.log.levels.WARN) - end - else - Logger.notify("Please provide TODO index to toggle", vim.log.levels.WARN) - end + vim.api.nvim_create_user_command("EcaClearContexts", function() + Logger.notify("EcaClearContexts is deprecated. Use EcaChatClearContexts instead.", vim.log.levels.WARN) + + require("eca.api").clear_contexts() end, { - desc = "Toggle TODO completion status", - nargs = 1, + desc = "Clear all contexts from ECA", }) - vim.api.nvim_create_user_command("EcaClearTodos", function() - require("eca.api").clear_todos() + vim.api.nvim_create_user_command("EcaChatClearContexts", function() + require("eca.api").clear_contexts() end, { - desc = "Clear all TODOs from ECA", + desc = "Clear all contexts from ECA", }) + -- ===== Server Commands ===== + vim.api.nvim_create_user_command("EcaServerStart", function() require("eca.api").start_server() end, { @@ -345,13 +360,13 @@ function M.setup() vim.api.nvim_create_user_command("EcaChatSelectModel", function() local eca = require("eca") + local chat = eca.get() - if not eca or not eca.current or not eca.current.sidebar then - Logger.notify("No active ECA sidebar found", vim.log.levels.WARN) + if not chat or not chat.mediator then + Logger.notify("No active ECA chat found", vim.log.levels.WARN) return end - local chat = eca.current.sidebar local models = chat.mediator:models() vim.ui.select(models, { @@ -367,13 +382,13 @@ function M.setup() vim.api.nvim_create_user_command("EcaChatSelectBehavior", function() local eca = require("eca") + local chat = eca.get() - if not eca or not eca.current or not eca.current.sidebar then - Logger.notify("No active ECA sidebar found", vim.log.levels.WARN) + if not chat or not chat.mediator then + Logger.notify("No active ECA chat found", vim.log.levels.WARN) return end - local chat = eca.current.sidebar local behaviors = chat.mediator:behaviors() vim.ui.select(behaviors, { diff --git a/lua/eca/completion/blink/context.lua b/lua/eca/completion/blink/context.lua index ba1222e..0b25809 100644 --- a/lua/eca/completion/blink/context.lua +++ b/lua/eca/completion/blink/context.lua @@ -20,7 +20,7 @@ end -- (Optional) Non-alphanumeric characters that trigger the source function source:get_trigger_characters() - return { "@" } + return { "@", "#" } end ---@param context eca.ChatContext @@ -31,13 +31,13 @@ local function as_completion_item(context) ---@diagnostic disable-next-line: missing-fields local item = {} if context.type == "file" then - item.label = string.format("@%s", vim.fn.fnamemodify(context.path, ":.")) + item.label = vim.fn.fnamemodify(context.path, ":.") item.kind = kinds.File item.data = { context_item = context, } elseif context.type == "directory" then - item.label = string.format("@%s", vim.fn.fnamemodify(context.path, ":.")) + item.label = vim.fn.fnamemodify(context.path, ":.") item.kind = kinds.Folder elseif context.type == "web" then item.label = context.url @@ -73,4 +73,11 @@ function source:resolve(item, callback) require("eca.completion.context").resolve_completion_item(item, callback) end +---Executed after the item was selected +---@param item lsp.CompletionItem +---@param callback fun(any) +function source:execute(item, callback) + require("eca.completion.context").execute(item, callback) +end + return source diff --git a/lua/eca/completion/cmp/context.lua b/lua/eca/completion/cmp/context.lua index 777eb65..c27349e 100644 --- a/lua/eca/completion/cmp/context.lua +++ b/lua/eca/completion/cmp/context.lua @@ -7,13 +7,13 @@ local function as_completion_item(context) ---@diagnostic disable-next-line: missing-fields local item = {} if context.type == "file" then - item.label = string.format("@%s", vim.fn.fnamemodify(context.path, ":.")) + item.label = vim.fn.fnamemodify(context.path, ":.") item.kind = cmp.lsp.CompletionItemKind.File item.data = { context_item = context, } elseif context.type == "directory" then - item.label = string.format("@%s", vim.fn.fnamemodify(context.path, ":.")) + item.label = vim.fn.fnamemodify(context.path, ":.") item.kind = cmp.lsp.CompletionItemKind.Folder elseif context.type == "web" then item.label = context.url @@ -40,7 +40,7 @@ function source.new() end function source:get_trigger_characters() - return { "@" } + return { "@", "#" } end function source:get_keyword_pattern() @@ -66,4 +66,11 @@ function source:resolve(completion_item, callback) require("eca.completion.context").resolve_completion_item(completion_item, callback) end +---Executed after the item was selected. +---@param completion_item lsp.CompletionItem +---@param callback fun(completion_item: lsp.CompletionItem|nil) +function source:execute(completion_item, callback) + require("eca.completion.context").execute(completion_item, callback) +end + return source diff --git a/lua/eca/completion/commands.lua b/lua/eca/completion/commands.lua index 20370d6..1e8c864 100644 --- a/lua/eca/completion/commands.lua +++ b/lua/eca/completion/commands.lua @@ -10,20 +10,31 @@ end ---@param as_completion_item fun(eca.ChatCommand): lsp.CompletionItem ---@param callback fun(resp: {items: lsp.CompletionItem[], isIncomplete?: boolean, is_incomplete_forward?: boolean, is_incomplete_backward?: boolean}) function M.get_completion_candidates(query, as_completion_item, callback) - local server = require("eca").server - server:send_request("chat/queryCommands", { query = query }, function(err, result) - if err then - ---@diagnostic disable-next-line: missing-fields - callback({ items = {} }) - end + local eca = require("eca") + local chat = eca.get() - if result and result.commands then - local items = vim.iter(result.commands):map(as_completion_item):totable() - callback({ items = items }) - else - callback({ items = {} }) - end - end) + if not chat or not chat.mediator then + Logger.notify("No active ECA sidebar", vim.log.levels.WARN) + return + end + + chat.mediator:send("chat/queryCommands", { + chatId = chat.mediator:id(), + query = query, + }, + function(err, result) + if err then + ---@diagnostic disable-next-line: missing-fields + callback({ items = {} }) + end + + if result and result.commands then + local items = vim.iter(result.commands):map(as_completion_item):totable() + callback({ items = items }) + else + callback({ items = {} }) + end + end) end return M diff --git a/lua/eca/completion/context.lua b/lua/eca/completion/context.lua index 401e569..538a0e3 100644 --- a/lua/eca/completion/context.lua +++ b/lua/eca/completion/context.lua @@ -7,7 +7,7 @@ function M.get_query(cursor_line, cursor_position) local before_cursor = cursor_line:sub(1, cursor_position.col) ---@type string[] local matches = {} - local it = before_cursor:gmatch("@([%w%./_\\%-~]*)") + local it = before_cursor:gmatch("[@#]([%w%./_\\%-~]*)") for match in it do table.insert(matches, match) end @@ -18,20 +18,32 @@ end ---@param as_completion_item fun(eca.ChatContext): lsp.CompletionItem ---@param callback fun(resp: {items: lsp.CompletionItem[], isIncomplete?: boolean, is_incomplete_forward?: boolean, is_incomplete_backward?: boolean}) function M.get_completion_candidates(query, as_completion_item, callback) - local server = require("eca").server - server:send_request("chat/queryContext", { query = query }, function(err, result) - if err then - callback({ items = {} }) - return - end + local eca = require("eca") + local chat = eca.get() - if result and result.contexts then - local items = vim.iter(result.contexts):map(as_completion_item):totable() - callback({ items = items }) - else - callback({ items = {} }) - end - end) + if not chat or not chat.mediator then + Logger.notify("No active ECA sidebar", vim.log.levels.WARN) + return + end + + chat.mediator:send("chat/queryContext", { + chatId = chat.mediator:id(), + query = query, + contexts = chat.mediator:contexts() or {}, + }, + function(err, result) + if err then + callback({ items = {} }) + return + end + + if result and result.contexts then + local items = vim.iter(result.contexts):map(as_completion_item):totable() + callback({ items = items }) + else + callback({ items = {} }) + end + end) end --- Taken from https://github.com/hrsh7th/cmp-path/blob/9a16c8e5d0be845f1d1b64a0331b155a9fe6db4d/lua/cmp_path/init.lua @@ -81,4 +93,21 @@ function M.resolve_completion_item(completion_item, callback) callback(completion_item) end end + +---Executed after the item was selected +---@param completion_item lsp.CompletionItem +---@param callback fun(any) +function M.execute(completion_item, callback) + if completion_item.data then + vim.api.nvim_exec_autocmds("User", { + pattern = { "CompletionItemSelected" }, + data = { + context_item = completion_item.data.context_item, + label = completion_item.label, + } + }) + callback(completion_item) + end +end + return M diff --git a/lua/eca/config.lua b/lua/eca/config.lua index a7f8604..be7e300 100644 --- a/lua/eca/config.lua +++ b/lua/eca/config.lua @@ -66,6 +66,7 @@ M._defaults = { input = { prefix = "> ", height = 8, -- Height of the input window + web_context_max_len = 20, -- Maximum length for web context names in input }, edit = { border = "rounded", diff --git a/lua/eca/mediator.lua b/lua/eca/mediator.lua index e70fd1d..5816e48 100644 --- a/lua/eca/mediator.lua +++ b/lua/eca/mediator.lua @@ -13,6 +13,93 @@ function mediator.new(server, state) }, { __index = mediator }) end +local function context_adapter(context) + if not context or not context.type or not context.data then + return nil + end + + local function is_pos(p) + return type(p) == "table" and type(p.line) == "number" and type(p.character) == "number" + end + + local adapters = { + cursor = function(ctx) + local d = ctx.data + + if type(d.path) ~= "string" or type(d.position) ~= "table" then + return nil + end + + local start_src = d.position.position_start + local end_src = d.position.position_end + + if not (is_pos(start_src) and is_pos(end_src)) then + return nil + end + + return { + type = ctx.type, + path = d.path, + position = { + start = { + line = start_src.line, + character = start_src.character, + }, + ["end"] = { + line = end_src.line, + character = end_src.character, + }, + }, + } + end, + directory = function(ctx) + if type(ctx.data.path) ~= "string" then + return nil + end + return { + type = ctx.type, + path = ctx.data.path, + } + end, + file = function(ctx) + local d = ctx.data + if type(d.path) ~= "string" then + return nil + end + local linesRange = nil + if type(d.lines_range) == "table" and type(d.lines_range.line_start) == "number" and type(d.lines_range.line_end) == "number" then + linesRange = { + start = d.lines_range.line_start, + ["end"] = d.lines_range.line_end, + } + end + + return { + type = ctx.type, + path = d.path, + linesRange = linesRange, + } + end, + web = function(ctx) + if type(ctx.data.path) ~= "string" then + return nil + end + return { + type = ctx.type, + url = ctx.data.path, + } + end, + } + + local adapter = adapters[context.type] + + if not adapter then + return nil + end + + return adapter(context) +end + ---@param method string ---@param params eca.MessageParams ---@param callback? fun(err?: string, result?: table) @@ -21,8 +108,22 @@ function mediator:send(method, params, callback) if callback then callback("Server is not running, please start the server", nil) end - require("eca.logger").notify("Server is not rnning, please start the server", vim.log.levels.WARN) + require("eca.logger").notify("Server is not running, please start the server", vim.log.levels.WARN) end + + if params.contexts then + local contexts = {} + + for _, context in pairs(params.contexts) do + local adapted = context_adapter(context) + if adapted then + table.insert(contexts, adapted) + end + end + + params.contexts = contexts + end + self.server:send_request(method, params, callback) end @@ -90,4 +191,32 @@ function mediator:id() return self.state and self.state.id end +function mediator:contexts() + return self.state and self.state.contexts +end + +function mediator:add_context(context) + if not self.state then + return + end + + self.state:add_context(context) +end + +function mediator:remove_context(context) + if not self.state then + return + end + + self.state:remove_context(context) +end + +function mediator:clear_contexts() + if not self.state then + return + end + + self.state:clear_contexts() +end + return mediator diff --git a/lua/eca/sidebar.lua b/lua/eca/sidebar.lua index 1fb5b43..d803a78 100644 --- a/lua/eca/sidebar.lua +++ b/lua/eca/sidebar.lua @@ -8,24 +8,23 @@ local Split = require("nui.split") ---@class eca.Sidebar ---@field public id integer The tab ID ---@field public containers table The nui containers +---@field public extmarks table The extmarks for various UI elements ---@field mediator eca.Mediator mediator to send server requests to ---@field private _initialized boolean Whether the sidebar has been initialized ---@field private _current_response_buffer string Buffer for accumulating streaming response ---@field private _is_streaming boolean Whether we're currently receiving a streaming response ----@field private _last_assistant_line integer Line number of the last assistant message ---@field private _usage_info string Current usage information ---@field private _last_user_message string Last user message to avoid duplicates ---@field private _current_tool_call table Current tool call being accumulated ---@field private _is_tool_call_streaming boolean Whether we're currently receiving a streaming tool call ---@field private _force_welcome boolean Whether to force show welcome content on next open ----@field private _contexts table Active contexts for this chat session ----@field private _selected_code table Current selected code for display ----@field private _todos table List of active todos ---@field private _current_status string Current processing status message ---@field private _augroup integer Autocmd group ID ---@field private _response_start_time number Timestamp when streaming started ---@field private _max_response_length number Maximum allowed response length ---@field private _headers table Table of headers for the chat +---@field private _welcome_message_applied boolean Whether the welcome message has been applied +---@field private _contexts_placeholder_line string Placeholder line for contexts in input local M = {} M.__index = M @@ -48,15 +47,11 @@ function M.new(id, mediator) instance._initialized = false instance._current_response_buffer = "" instance._is_streaming = false - instance._last_assistant_line = 0 instance._usage_info = "" instance._last_user_message = "" instance._current_tool_call = nil instance._is_tool_call_streaming = false instance._force_welcome = false - instance._contexts = {} - instance._selected_code = nil - instance._todos = {} instance._current_status = "" instance._augroup = vim.api.nvim_create_augroup("eca_sidebar_" .. id, { clear = true }) instance._response_start_time = 0 @@ -66,6 +61,8 @@ function M.new(id, mediator) assistant = (Config.chat and Config.chat.headers and Config.chat.headers.assistant) or "", } instance._welcome_message_applied = false + instance._contexts_placeholder_line = "" + instance._contexts = {} require("eca.observer").subscribe("sidebar-" .. id, function(message) instance:handle_chat_content(message) @@ -184,17 +181,15 @@ function M:reset() self._initialized = false self._is_streaming = false self._current_response_buffer = "" - self._last_assistant_line = 0 self._usage_info = "" self._last_user_message = "" self._current_tool_call = nil self._is_tool_call_streaming = false self._force_welcome = false - self._contexts = {} - self._selected_code = nil - self._todos = {} self._current_status = "" self._welcome_message_applied = false + self._contexts_placeholder_line = "" + self._contexts = {} end function M:new_chat() @@ -226,20 +221,12 @@ function M:_create_containers() -- Calculate dynamic heights using existing methods local input_height = Config.windows.input.height local usage_height = 1 - local status_height = 1 - local contexts_height = self:get_contexts_height() - local selected_code_height = self:get_selected_code_height() - local todos_height = self:get_todos_height() local original_chat_height = self:get_chat_height() local chat_height = original_chat_height local config_height = 1 -- Validate total height to prevent "Not enough room" error local total_height = chat_height - + selected_code_height - + todos_height - + status_height - + contexts_height + input_height + usage_height + config_height @@ -278,7 +265,7 @@ function M:_create_containers() winfixwidth = false, } - -- 1. Create and mount main chat container first + -- Create and mount main chat container first self.containers.chat = Split({ relative = "editor", position = "right", @@ -299,7 +286,7 @@ function M:_create_containers() local current_winid = self.containers.chat.winid Logger.debug("Mounted container: chat (winid: " .. current_winid .. ")") - --2. Create config container in top of chat + -- Create config container in top of chat self.containers.config = Split({ relative = { type = "win", @@ -318,72 +305,7 @@ function M:_create_containers() self:_setup_container_events(self.containers.config, "config") Logger.debug("Mounted container: config (winid: " .. self.containers.config.winid .. ")") - -- 3. Create selected_code container (conditional) - if selected_code_height > 0 then - self.containers.selected_code = Split({ - relative = { - type = "win", - winid = current_winid, - }, - position = "bottom", - size = { height = selected_code_height }, - buf_options = vim.tbl_deep_extend("force", base_buf_options, { - modifiable = false, - filetype = self._selected_code and self._selected_code.filetype or "text", - }), - win_options = vim.tbl_deep_extend("force", base_win_options, { - winhighlight = "Normal:Visual", - }), - }) - self.containers.selected_code:mount() - self:_setup_container_events(self.containers.selected_code, "selected_code") - current_winid = self.containers.selected_code.winid - Logger.debug("Mounted container: selected_code (winid: " .. current_winid .. ")") - end - - -- 4. Create todos container (conditional) - if todos_height > 0 then - self.containers.todos = Split({ - relative = { - type = "win", - winid = current_winid, - }, - position = "bottom", - size = { height = todos_height }, - buf_options = vim.tbl_deep_extend("force", base_buf_options, { - modifiable = false, - }), - win_options = vim.tbl_deep_extend("force", base_win_options, { - winhighlight = "Normal:DiffAdd", - }), - }) - self.containers.todos:mount() - self:_setup_container_events(self.containers.todos, "todos") - current_winid = self.containers.todos.winid - Logger.debug("Mounted container: todos (winid: " .. current_winid .. ")") - end - - -- 5. Create contexts container between chat and input - self.containers.contexts = Split({ - relative = { - type = "win", - winid = current_winid, - }, - position = "bottom", - size = { height = contexts_height }, - buf_options = vim.tbl_deep_extend("force", base_buf_options, { - modifiable = false, - }), - win_options = vim.tbl_deep_extend("force", base_win_options, { - winhighlight = "Normal:Comment", - }), - }) - self.containers.contexts:mount() - self:_setup_container_events(self.containers.contexts, "contexts") - current_winid = self.containers.contexts.winid - Logger.debug("Mounted container: contexts (winid: " .. current_winid .. ")") - - --6. Create input container (always present) + -- Create input container (always present) self.containers.input = Split({ relative = { type = "win", @@ -404,7 +326,7 @@ function M:_create_containers() current_winid = self.containers.input.winid Logger.debug("Mounted container: input (winid: " .. current_winid .. ")") - -- 7. Create usage container (always present) - moved to bottom + -- Create usage container (always present) - moved to bottom self.containers.usage = Split({ relative = { type = "win", @@ -427,12 +349,8 @@ function M:_create_containers() Logger.debug( string.format( - "Created containers: contexts=%d, chat=%d, selected_code=%s, todos=%s, status=%d, input=%d, usage=%d, config=%d", - contexts_height, + "Created containers: chat=%d, input=%d, usage=%d, config=%d", chat_height, - selected_code_height > 0 and tostring(selected_code_height) or "hidden", - todos_height > 0 and tostring(todos_height) or "hidden", - status_height, input_height, usage_height, config_height @@ -445,12 +363,9 @@ end ---@param name string function M:_setup_container_events(container, name) -- Setup container-specific keymaps - if name == "todos" then - self:_setup_todos_keymaps(container) - elseif name == "input" then + if name == "input" then + self:_setup_input_events(container) self:_setup_input_keymaps(container) - elseif name == "status" then - -- No special keymaps for status container (read-only) end end @@ -465,29 +380,124 @@ end ---@private ---@param container NuiSplit -function M:_setup_todos_keymaps(container) - -- Setup keymaps for todos container - container:map("n", "", function() - local line = vim.api.nvim_win_get_cursor(container.winid)[1] - if line > 0 then - local header_offset = Config.windows.sidebar_header.enabled and 1 or 0 - local todo_index = line - header_offset - if todo_index > 0 and todo_index <= #self._todos then - self:toggle_todo(todo_index) +function M:_setup_input_events(container) + vim.api.nvim_create_autocmd("User", { + pattern = { "CompletionItemSelected" }, + callback = function(event) + if not event.data or not event.data.context_item or not event.data.label then + return end - end - end, { noremap = true, silent = true }) - container:map("n", "", function() - local line = vim.api.nvim_win_get_cursor(container.winid)[1] - if line > 0 then - local header_offset = Config.windows.sidebar_header.enabled and 1 or 0 - local todo_index = line - header_offset - if todo_index > 0 and todo_index <= #self._todos then - self:toggle_todo(todo_index) + if self._contexts then + self._contexts.to_add = { + name = event.data.label, + type = event.data.context_item.type, + data = { + path = event.data.context_item.path + } + } end + end, + }) + + -- contexts area and input handler + vim.api.nvim_buf_attach(container.bufnr, false, { + on_lines = function(_, buf, _changedtick, first, _last, _new_last, _bytecount) + vim.schedule(function() + local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) + + -- handle empty buffer + if not lines or #lines < 1 then + self:_update_input_display() + return + end + + local prefix_extmark = self.extmarks.prefix or nil + local contexts_extmark = self.extmarks.contexts or nil + + if not prefix_extmark or not contexts_extmark then + return + end + + local prefix_ns = prefix_extmark._ns or nil + local prefix_id = prefix_extmark._id and prefix_extmark._id[1] or nil + + if not prefix_ns or not prefix_id then + return + end + + local prefix_mark = vim.api.nvim_buf_get_extmark_by_id(buf, prefix_ns, prefix_id, {}) + local prefix_row = 1 + if prefix_mark and type(prefix_mark) == "table" and prefix_mark[1] ~= nil then + prefix_row = tonumber(prefix_mark[1]) or 1 + end + local contexts_row = 0 + + local prefix_line = lines[prefix_row + 1] or nil + local contexts_line = lines[contexts_row + 1] or nil + local contexts_placeholder_line = self._contexts_placeholder_line or "" + + if prefix_row == contexts_row then + -- prefix line missing, restore + if contexts_line == contexts_placeholder_line then + self:_update_input_display() + return + end + + -- we can consider that contexts were deleted + self.mediator:clear_contexts() + return + end + + -- prefix line missing, restore + if not prefix_line and contexts_line == contexts_placeholder_line then + self:_update_input_display() + return + end + + -- something wrong, restore + if prefix_row - contexts_row ~= 1 then + self:_update_input_display() + return + end + + local context_to_add = self._contexts.to_add or {} + + if contexts_line ~= contexts_placeholder_line then + -- a context was removed + if #contexts_line < #self._contexts_placeholder_line then + local contexts = self.mediator:contexts() + + local row, col = unpack(vim.api.nvim_win_get_cursor(container.winid)) + local context = contexts[col+1] + + if row == 1 and context then + self.mediator:remove_context(context) + return + end + end + + -- contexts line modified + if #contexts_line > #self._contexts_placeholder_line then + local placeholders = vim.split(contexts_line, "@", { plain = true, trimempty = true }) + + for i = 1, #placeholders do + if context_to_add.name and context_to_add.name == placeholders[i] then + self.mediator:add_context(context_to_add) + self._contexts.to_add = {} + end + end + + return + end + + self:_update_input_display() + return + end + + end) end - end, { noremap = true, silent = true }) + }) end ---@private @@ -511,11 +521,7 @@ function M:_update_container_sizes() -- Recalculate heights local new_heights = { - contexts = self:get_contexts_height(), chat = self:get_chat_height(), - selected_code = self:get_selected_code_height(), - todos = self:get_todos_height(), - status = 1, input = Config.windows.input.height, usage = 1, } @@ -531,49 +537,10 @@ function M:_update_container_sizes() end end --- Include all the height calculation methods from the original sidebar -function M:get_selected_code_height() - if not self._selected_code or not Config.selected_code.enabled then - return 0 - end - - local lines = vim.split(self._selected_code.content or "", "\n") - local content_lines = #lines - local header_lines = Config.windows.sidebar_header.enabled and 1 or 0 - - return math.min(Config.selected_code.max_height, content_lines + header_lines + 1) -end - -function M:get_todos_height() - if #self._todos == 0 or not Config.todos.enabled then - return 0 - end - - local header_lines = Config.windows.sidebar_header.enabled and 1 or 0 - local todo_lines = math.min(#self._todos, Config.todos.max_height - header_lines) - - return math.min(Config.todos.max_height, todo_lines + header_lines) -end - -function M:get_contexts_height() - if #self._contexts == 0 then - return 1 -- Always show at least "No contexts" - end - - local header_lines = 1 -- "📂 Contexts (N):" - local bubble_lines = 1 -- All bubbles on same line as requested - - return header_lines + bubble_lines -end - function M:get_chat_height() local total_height = vim.o.lines - vim.o.cmdheight - 1 local input_height = Config.windows.input.height local usage_height = 1 - local status_height = 1 - local contexts_height = self:get_contexts_height() - local selected_code_height = self:get_selected_code_height() - local todos_height = self:get_todos_height() local config_height = 1 return math.max( @@ -581,130 +548,18 @@ function M:get_chat_height() total_height - input_height - usage_height - - status_height - - contexts_height - - selected_code_height - - todos_height - WINDOW_MARGIN - config_height ) end --- Include all the context/todo/selected_code management methods from original sidebar --- (These will be copied from the original implementation) - ----@param context table Context object with type, path, content -function M:add_context(context) - -- Check if context already exists (by path) - for i, existing in ipairs(self._contexts) do - if existing.path == context.path then - -- Update existing context - self._contexts[i] = context - self:_update_contexts_display() - Logger.info("Updated context: " .. context.path) - return - end - end - - -- Add new context - table.insert(self._contexts, context) - self:_update_contexts_display() - Logger.info("Added context: " .. context.path .. " (" .. #self._contexts .. " total)") -end - ----@param path string Path to remove from contexts -function M:remove_context(path) - for i, context in ipairs(self._contexts) do - if context.path == path then - table.remove(self._contexts, i) - self:_update_contexts_display() - Logger.info("Removed context: " .. path) - return true - end - end - Logger.warn("Context not found: " .. path) - return false -end - ----@return table List of active contexts -function M:get_contexts() - return vim.deepcopy(self._contexts) -end - ----@return integer Number of active contexts -function M:get_context_count() - return #self._contexts -end - -function M:clear_contexts() - local count = #self._contexts - self._contexts = {} - self:_update_contexts_display() - Logger.info("Cleared " .. count .. " contexts") -end - ----@param code table Selected code object with filepath, content, start_line, end_line -function M:set_selected_code(code) - self._selected_code = code - self:_update_selected_code_display() - Logger.info("Selected code updated: " .. (code and code.filepath or "none")) -end - -function M:clear_selected_code() - self._selected_code = nil - self:_update_selected_code_display() - Logger.info("Selected code cleared") -end - ----@param todo table Todo object with content, status ("pending", "completed") -function M:add_todo(todo) - table.insert(self._todos, todo) - self:_update_todos_display() - Logger.info("Added TODO: " .. todo.content) -end - ----@param index integer Index of todo to toggle -function M:toggle_todo(index) - if index <= 0 or index > #self._todos then - Logger.warn("Invalid TODO index: " .. index) - return false - end - - local todo = self._todos[index] - todo.status = todo.status == "completed" and "pending" or "completed" - self:_update_todos_display() - Logger.info("Toggled TODO " .. index .. ": " .. todo.content) - return true -end - -function M:clear_todos() - local count = #self._todos - self._todos = {} - self:_update_todos_display() - Logger.info("Cleared " .. count .. " TODOs") -end - ----@return table List of active todos -function M:get_todos() - return vim.deepcopy(self._todos) -end - -- Placeholder methods for the display and setup functions -- These will use the same logic as the original sidebar but with nui containers function M:_setup_containers() -- Setup each container's content and behavior - self:_setup_contexts_container() self:_setup_chat_container() - if self.containers.selected_code then - self:_setup_selected_code_container() - end - - if self.containers.todos then - self:_setup_todos_container() - end - self:_update_config_display() self:_setup_input_container() self:_setup_usage_container() @@ -714,10 +569,6 @@ end function M:_refresh_container_content() -- Refresh content without full setup - if self.containers.contexts then - self:_update_contexts_display() - end - if self.containers.chat then self:_set_welcome_content() end @@ -726,16 +577,8 @@ function M:_refresh_container_content() self:_update_config_display() end - if self.containers.selected_code then - self:_update_selected_code_display() - end - - if self.containers.todos then - self:_update_todos_display() - end - if self.containers.input then - self:_add_input_line() + self:_update_input_display() end if self.containers.usage then @@ -744,6 +587,10 @@ function M:_refresh_container_content() end function M:_handle_state_updated(state) + if state.contexts then + self:_update_input_display() + end + if state.usage or state.status then self:_update_usage_info() end @@ -784,41 +631,6 @@ function M:_setup_chat_container() end, 200) end -function M:_setup_selected_code_container() - local container = self.containers.selected_code - if not container then - return - end - - -- Set initial content - self:_update_selected_code_display() - - -- Set filetype based on the selected code's language - if self._selected_code and self._selected_code.filetype then - vim.api.nvim_set_option_value("filetype", self._selected_code.filetype, { buf = container.bufnr }) - end -end - -function M:_setup_todos_container() - local container = self.containers.todos - if not container then - return - end - - -- Set initial content - self:_update_todos_display() -end - -function M:_setup_contexts_container() - local contexts = self.containers.contexts - if not contexts then - return - end - - -- Set initial contexts display - self:_update_contexts_display() -end - function M:_setup_usage_container() local usage = self.containers.usage if not usage then @@ -836,7 +648,7 @@ function M:_setup_input_container() end -- Set initial input prompt - self:_add_input_line() + self:_update_input_display() end -- Placeholder methods that need to be implemented @@ -874,42 +686,121 @@ function M:_set_welcome_content() end self:_update_welcome_content() - - -- Auto-add repoMap context if enabled and not already present - if Config.options.context.auto_repo_map then - -- Check if repoMap already exists - local has_repo_map = false - for _, context in ipairs(self._contexts) do - if context.type == "repoMap" then - has_repo_map = true - break - end - end - - if not has_repo_map then - self:add_context({ - type = "repoMap", - path = "repoMap", - content = "Repository structure and code mapping for better project understanding", - }) - Logger.debug("Auto-added repoMap context on welcome") - end - end end -function M:_add_input_line() +function M:_update_input_display(opts) return vim.schedule(function() local input = self.containers.input if not input then return end + local contexts = (self.mediator and self.mediator:contexts()) or {} + local contexts_name = {} + + if #contexts > 0 then + for _, context in ipairs(contexts) do + local path = context.data.path + + if not path or path == "" then + break + end + + local name + if context.type == "web" then + name = path + local max_len = (Config.windows and Config.windows.input and Config.windows.input.web_context_max_len) or 20 + if #name > max_len then + name = string.sub(name, 1, max_len - 3) .. "..." + end + else + name = vim.fn.fnamemodify(path, ":t") + end + + local lines_range = context.data.lines_range + + if lines_range and lines_range.line_start and lines_range.line_end then + name = string.format("%s:%d-%d", name, lines_range.line_start, lines_range.line_end) + end + + table.insert(contexts_name, name .. " ") + end + end + + self._contexts_placeholder_line = "@" + for _ = 1, #contexts_name do + self._contexts_placeholder_line = self._contexts_placeholder_line .. "@" + end + + local prefix_extmark = self.extmarks.prefix or nil + local prefix_ns = prefix_extmark and prefix_extmark._ns or nil + local prefix_id = prefix_extmark and prefix_extmark._id and prefix_extmark._id[1] or nil + local prefix_row = 1 + + if prefix_ns and prefix_id then + local prefix_mark = vim.api.nvim_buf_get_extmark_by_id(input.bufnr, prefix_ns, prefix_id, {}) + prefix_row = prefix_mark and #prefix_mark > 0 and prefix_mark[1] or 1 + end + + -- Get existing lines to preserve user input (lines after the header) + local existing_lines = vim.api.nvim_buf_get_lines(input.bufnr, prefix_row, -1, false) + + vim.api.nvim_buf_set_lines(input.bufnr, 0, -1, false, { self._contexts_placeholder_line, "" }) + + if not self.extmarks.contexts then + self.extmarks.contexts = { + _ns = vim.api.nvim_create_namespace('extmarks_contexts'), + } + end + + if not self.extmarks.contexts._id then + self.extmarks.contexts._id = {} + end + + vim.api.nvim_buf_clear_namespace(input.bufnr, self.extmarks.contexts._ns, 0, -1) + + for i, context_name in ipairs(contexts_name) do + self.extmarks.contexts._id[i] = vim.api.nvim_buf_set_extmark( + input.bufnr, + self.extmarks.contexts._ns, + 0, + i, + vim.tbl_extend("force", { virt_text = { { context_name, "Comment" } }, virt_text_pos = "inline", hl_mode = "replace" }, { id = self.extmarks.contexts._id[i] }) + ) + end + local prefix = Config.windows.input.prefix or "> " - vim.api.nvim_buf_set_lines(input.bufnr, 0, -1, false, { prefix }) - -- Set cursor to end of line + if not self.extmarks.prefix then + self.extmarks.prefix = { + _ns = vim.api.nvim_create_namespace('extmarks_prefix'), + } + end + + local clear = opts and opts.clear + + if #existing_lines > 0 and not clear then + vim.api.nvim_buf_set_lines(input.bufnr, 1, 1 + #existing_lines, false, existing_lines) + end + + if not self.extmarks.prefix._id then + self.extmarks.prefix._id = {} + end + + self.extmarks.prefix._id[1] = vim.api.nvim_buf_set_extmark( + input.bufnr, + self.extmarks.prefix._ns, + 1, + 0, + vim.tbl_extend("force", { virt_text = { { prefix, "Normal" } }, virt_text_pos = "inline", right_gravity = false }, { id = self.extmarks.prefix._id[1] }) + ) + + -- Set cursor to end of input line if vim.api.nvim_win_is_valid(input.winid) then - vim.api.nvim_win_set_cursor(input.winid, { 1, #prefix }) + local row = 1 + ((not clear and existing_lines and #existing_lines > 0) and #existing_lines or 1) + local col = #prefix + ((not clear and existing_lines and #existing_lines > 0) and #existing_lines[#existing_lines] or 0) + + vim.api.nvim_win_set_cursor(input.winid, { row, col }) end end) end @@ -928,21 +819,26 @@ function M:_focus_input() local lines = vim.api.nvim_buf_get_lines(input.bufnr, 0, -1, false) local prefix = Config.windows.input.prefix or "> " - if #lines > 0 then - local first_line = lines[1] or "" - local cursor_col = math.max(#prefix, #first_line) - vim.api.nvim_win_set_cursor(input.winid, { 1, cursor_col }) - else - self:_add_input_line() + local row = 2 + local col = #prefix + + -- Ensure there is at least a header and a prefix line + if #lines < 2 then + row = 1 + col = 0 end + vim.api.nvim_win_set_cursor(input.winid, { row, col }) + -- Enter insert mode - local mode = vim.api.nvim_get_mode().mode - if mode == "n" then - vim.cmd("startinsert!") + if Config.windows and Config.windows.edit and Config.windows.edit.start_insert then + local mode = vim.api.nvim_get_mode().mode + if mode == "n" then + vim.cmd("startinsert!") + end end end - end, 50) + end, 100) end function M:_handle_input() @@ -952,17 +848,18 @@ function M:_handle_input() end local lines = vim.api.nvim_buf_get_lines(input.bufnr, 0, -1, false) - if #lines == 0 then + if #lines < 2 then return end - -- Process input (remove prefix, concatenate lines) + -- Process input: ignore first line (contexts header) and use second line onwards as input local message_lines = {} local prefix = Config.windows.input.prefix or "> " - for _, line in ipairs(lines) do + for i = 2, #lines do + local line = lines[i] local content = line - if vim.startswith(line, prefix) then + if i == 2 and vim.startswith(line, prefix) then content = line:sub(#prefix + 1) end if content ~= "" then @@ -975,94 +872,14 @@ function M:_handle_input() return end - -- Clear input - vim.api.nvim_buf_set_lines(input.bufnr, 0, -1, false, {}) - -- Send message self:_send_message(message) -- Add new input line and focus - self:_add_input_line() + self:_update_input_display({ clear = true }) self:_focus_input() end --- Placeholder for the other display update methods -function M:_update_selected_code_display() - -- Implementation would be similar to original but use nui container - local container = self.containers.selected_code - if not container or not vim.api.nvim_buf_is_valid(container.bufnr) then - return - end - - local lines = {} - - if not self._selected_code then - lines = { "📝 No code selected" } - else - -- Add header if enabled - local filename = vim.fn.fnamemodify(self._selected_code.filepath or "unknown", ":t") - local line_info = "" - if self._selected_code.start_line and self._selected_code.end_line then - line_info = string.format(" (lines %d-%d)", self._selected_code.start_line, self._selected_code.end_line) - end - local header_text = "📝 " .. filename .. line_info - local header_lines = self:_render_header("selected_code", header_text) - for _, line in ipairs(header_lines) do - table.insert(lines, line) - end - - -- Add code content - local code_lines = vim.split(self._selected_code.content or "", "\n") - for _, line in ipairs(code_lines) do - table.insert(lines, line) - end - end - - -- Update the buffer - vim.api.nvim_set_option_value("modifiable", true, { buf = container.bufnr }) - vim.api.nvim_buf_set_lines(container.bufnr, 0, -1, false, lines) - vim.api.nvim_set_option_value("modifiable", false, { buf = container.bufnr }) -end - -function M:_update_todos_display() - -- Similar implementation for todos... - local container = self.containers.todos - if not container or not vim.api.nvim_buf_is_valid(container.bufnr) then - return - end - - local lines = {} - - if #self._todos == 0 then - lines = { "✅ No active TODOs" } - else - -- Add header if enabled - local completed_count = 0 - for _, todo in ipairs(self._todos) do - if todo.status == "completed" then - completed_count = completed_count + 1 - end - end - local header_text = string.format("✅ Tasks (%d/%d completed)", completed_count, #self._todos) - local header_lines = self:_render_header("todos", header_text) - for _, line in ipairs(header_lines) do - table.insert(lines, line) - end - - -- Add todos - for i, todo in ipairs(self._todos) do - local checkbox = todo.status == "completed" and "[x]" or "[ ]" - local line = string.format("%d. %s %s", i, checkbox, todo.content) - table.insert(lines, line) - end - end - - -- Update the buffer - vim.api.nvim_set_option_value("modifiable", true, { buf = container.bufnr }) - vim.api.nvim_buf_set_lines(container.bufnr, 0, -1, false, lines) - vim.api.nvim_set_option_value("modifiable", false, { buf = container.bufnr }) -end - function M:_update_config_display() local config = self.containers.config if not config or not config.bufnr or not vim.api.nvim_buf_is_valid(config.bufnr) then @@ -1088,14 +905,12 @@ function M:_update_config_display() end local texts = { - { "model:", "Comment" }, { model, "Normal" }, { "\t" }, + { "model:", "Comment" }, { model, "Normal" }, { " " }, { "behavior:", "Comment" }, { behavior, "Normal" }, { " " }, { "mcps:", "Comment" }, { tostring(vim.tbl_count(mcps)), mcps_hl }, } - local virt_opts = { virt_text = texts, hl_mode = "combine" } - - self.extmarks = self.extmarks or {} + local virt_opts = { virt_text = texts, virt_text_pos = "overlay", hl_mode = "combine" } if not self.extmarks.config then self.extmarks.config = { @@ -1116,38 +931,6 @@ function M:_update_config_display() ) end -function M:_update_contexts_display() - -- Similar implementation for contexts... - local contexts = self.containers.contexts - if not contexts or not vim.api.nvim_buf_is_valid(contexts.bufnr) then - return - end - - local lines = {} - - if #self._contexts > 0 then - -- Create context references with @ prefix (eca-emacs style) - local context_refs = {} - - for i, context in ipairs(self._contexts) do - local name = context.type == "repoMap" and "repoMap" or vim.fn.fnamemodify(context.path, ":t") -- Get filename only - - -- Create context reference with @ prefix like eca-emacs - local ref = "@" .. name - table.insert(context_refs, ref) - end - - -- Add all context references to a single line - local contexts_line = table.concat(context_refs, " ") - table.insert(lines, contexts_line) - end - - -- Update the buffer - vim.api.nvim_set_option_value("modifiable", true, { buf = contexts.bufnr }) - vim.api.nvim_buf_set_lines(contexts.bufnr, 0, -1, false, lines) - vim.api.nvim_set_option_value("modifiable", false, { buf = contexts.bufnr }) -end - function M:_update_usage_info() local usage = self.containers.usage if not usage or not usage.bufnr or not vim.api.nvim_buf_is_valid(usage.bufnr) then @@ -1210,7 +993,7 @@ function M:_update_usage_info() end function M:_update_welcome_content() - if self._welcome_applied then + if self._welcome_message_applied then return end @@ -1236,11 +1019,11 @@ function M:_update_welcome_content() end end - self._welcome_applied = true + self._welcome_message_applied = true end table.insert(lines, "") - Logger.debug("Setting welcome content for chat (welcome applied: " .. tostring(self._welcome_applied) .. ")") + Logger.debug("Setting welcome content for chat (welcome applied: " .. tostring(self._welcome_message_applied) .. ")") vim.api.nvim_buf_set_lines(chat.bufnr, 0, -1, false, lines) end @@ -1265,15 +1048,28 @@ end ---@param message string function M:_send_message(message) - Logger.debug("Sending message: " .. message) - - -- Store the last user message to avoid duplication - self._last_user_message = message + if not message or type(message) ~= "string" then + Logger.error("Cannot send empty message") + return + end -- Add user message to chat self:_add_message("user", message) - local contexts = self:get_contexts() + local replaced = message:gsub("([@#])([%w%._%-%/\\]+)", function(prefix, path) + -- expand ~ + if vim.startswith(path, "~") then + path = vim.fn.expand(path) + end + return prefix .. vim.fn.fnamemodify(path, ":p") + end) + + message = replaced + + -- Store the last user message to avoid duplication + self._last_user_message = message + + local contexts = self.mediator:contexts() self.mediator:send("chat/prompt", { chatId = self.mediator:id(), requestId = tostring(os.time()), @@ -1283,13 +1079,12 @@ function M:_send_message(message) behavior = self.mediator:selected_behavior(), }, function(err, result) if err then - print("err is " .. err) - Logger.error("Failed to send message to ECA server: " .. err) - self:_add_message("assistant", "❌ **Error**: Failed to send message to ECA server: " .. err) + Logger.error("Failed to send message to ECA server: " .. vim.inspect(err)) + self:_add_message("assistant", "❌ **Error**: Failed to send message to ECA server: " .. vim.inspect(err)) return end -- Response will come through server notification handler - self:_add_input_line() + self:_update_input_display() self:handle_chat_content_received(result.params) end) @@ -1320,7 +1115,7 @@ function M:handle_chat_content_received(params) elseif content.type == "progress" then if content.state == "finished" then self:_finalize_streaming_response() - self:_add_input_line() + self:_update_input_display() end elseif content.type == "toolCallPrepare" then self:_finalize_streaming_response() @@ -1386,6 +1181,7 @@ function M:_handle_streaming_text(text) Logger.debug("Ignoring empty text response") return end + Logger.debug("Received text chunk: '" .. text:sub(1, 50) .. (text:len() > 50 and "..." or "") .. "'") if vim.trim(text) == vim.trim(self._last_user_message) then @@ -1408,7 +1204,6 @@ function M:_handle_streaming_text(text) -- Add assistant placeholder and track its start line self:_add_message("assistant", "") - self._last_assistant_line = start_line -- Track placeholder with an extmark independent of header content self.extmarks = self.extmarks or {} @@ -1438,8 +1233,8 @@ end ---@param content string function M:_update_streaming_message(content) local chat = self.containers.chat - if not chat or self._last_assistant_line == 0 then - Logger.notify("Cannot update - no chat or no assistant line", vim.log.levels.ERROR) + if not chat then + Logger.debug("DEBUG: Cannot update - no chat") return end @@ -1463,7 +1258,7 @@ function M:_update_streaming_message(content) local content_lines = Utils.split_lines(content) -- Resolve assistant start line using extmark if available - local start_line = self._last_assistant_line + local start_line = 0 if self.extmarks and self.extmarks.assistant and self.extmarks.assistant._id then local pos = vim.api.nvim_buf_get_extmark_by_id(chat.bufnr, self.extmarks.assistant._ns, self.extmarks.assistant._id, {}) if pos and pos[1] then @@ -1471,7 +1266,7 @@ function M:_update_streaming_message(content) end end - Logger.debug("DEBUG: Assistant line: " .. tostring(self._last_assistant_line) .. ", start_line: " .. tostring(start_line)) + Logger.debug("DEBUG: Start Line: " .. tostring(start_line)) Logger.debug("DEBUG: Content lines: " .. #content_lines) -- Replace assistant content directly @@ -1574,7 +1369,6 @@ function M:_add_message(role, content) -- Auto-scroll to bottom after adding new message self:_scroll_to_bottom() end) - self._last_assistant_line = self:_get_last_message_line() end function M:_finalize_streaming_response() @@ -1584,7 +1378,6 @@ function M:_finalize_streaming_response() self._is_streaming = false self._current_response_buffer = "" - self._last_assistant_line = 0 self._response_start_time = 0 -- Clear assistant placeholder tracking extmark @@ -1625,32 +1418,6 @@ function M:_scroll_to_bottom() end, 10) -- Reduced delay for faster streaming response end -function M:_get_last_message_line() - local chat = self.containers.chat - if not chat then - return 0 - end - - local lines = vim.api.nvim_buf_get_lines(chat.bufnr, 0, -1, false) - local assistant_header_lines = Utils.split_lines(self._headers.assistant) - local assistant_header = "" - - for i = #assistant_header_lines, 1, -1 do - if assistant_header_lines[i] and assistant_header_lines[i] ~= "" then - assistant_header = assistant_header_lines[i] - break - end - end - - for i = #lines, 1, -1 do - local line = lines[i] - if line and line:sub(1, #assistant_header) == assistant_header then - return i - end - end - return 0 -end - ---@param bufnr integer ---@param callback function function M:_safe_buffer_update(bufnr, callback) diff --git a/lua/eca/state.lua b/lua/eca/state.lua index 9fa05f3..73b9949 100644 --- a/lua/eca/state.lua +++ b/lua/eca/state.lua @@ -22,6 +22,7 @@ ---@field config eca.StateConfig ---@field usage eca.StateUsage ---@field tools table +---@field contexts eca.Context[] local State = {} ---@return eca.State @@ -56,6 +57,7 @@ function State._new() }, }, tools = {}, + contexts = {}, }, { __index = State }) local handlers = { @@ -228,4 +230,60 @@ function State:update_selected_behavior(behavior) end) end +function State:_update_contexts() + vim.schedule(function() + require("eca.observer").notify({ type = "state/updated", content = { contexts = vim.deepcopy(self.contexts) } }) + end) +end + +function State:add_context(context) + if not context or type(context) ~= "table" then + return + end + + if not context.type or not context.data then + return + end + + -- avoid duplicates + for _, ctx in ipairs(self.contexts) do + if ctx.type == context.type and vim.deep_equal(ctx.data, context.data) then + return + end + end + + -- if is 'cursor' type and exists 'cursor' in contexts, replace + if context.type == "cursor" then + for i, ctx in ipairs(self.contexts) do + if ctx.type == "cursor" then + self.contexts[i] = context + self:_update_contexts() + return + end + end + end + + table.insert(self.contexts, context) + self:_update_contexts() +end + +function State:remove_context(context) + if not context or type(context) ~= "table" then + return + end + + for i, ctx in ipairs(self.contexts) do + if ctx.type == context.type and vim.deep_equal(ctx.data, context.data) then + table.remove(self.contexts, i) + self:_update_contexts() + break + end + end +end + +function State:clear_contexts() + self.contexts = {} + self:_update_contexts() +end + return State diff --git a/lua/eca/types.lua b/lua/eca/types.lua index 5ff312b..c7967e7 100644 --- a/lua/eca/types.lua +++ b/lua/eca/types.lua @@ -84,3 +84,21 @@ ---@field sessionTokens number ---@field messageCost? string ---@field sessionCost? string + +---@class eca.ContextCursor +---@field path string +---@field position { position_start: { line: number , character: number }, position_end: { line: number , character: number } } + +---@class eca.ContextDirectory +---@field path string + +---@class eca.ContextFile +---@field path string +---@field lines_range { line_start: number, line_end: number } + +---@class eca.ContextWeb +---@field path string + +---@class eca.Context +---@field type 'cursor'|'directory'|'file'|'web' +---@field data eca.ContextCursor|eca.ContextDirectory|eca.ContextFile|eca.ContextWeb diff --git a/plugin-spec.lua b/plugin-spec.lua index 68a4b71..a48fb76 100644 --- a/plugin-spec.lua +++ b/plugin-spec.lua @@ -11,6 +11,7 @@ return { -- Default configuration server_path = "", server_args = "", + usage_string_format = "{messageCost} / {sessionCost}", log = { display = "split", -- "split" or "popup" level = vim.log.levels.INFO, @@ -20,6 +21,9 @@ return { behavior = { auto_set_keymaps = true, auto_focus_sidebar = true, + auto_start_server = false, + auto_download = true, + show_status_updates = true, }, mappings = { chat = "ec", @@ -35,16 +39,31 @@ return { }, cmd = { "EcaChat", - "EcaToggle", + "EcaToggle", "EcaFocus", "EcaClose", "EcaAddFile", + "EcaChatAddFile", + "EcaRemoveContext", + "EcaChatRemoveFile", "EcaAddSelection", + "EcaChatAddSelection", + "EcaChatAddUrl", + "EcaListContexts", + "EcaChatListContexts", + "EcaClearContexts", + "EcaChatClearContexts", "EcaServerStart", "EcaServerStop", "EcaServerRestart", - "EcaServerStatus", + "EcaServerMessages", "EcaSend", "EcaLogs", + "EcaDebugWidth", + "EcaRedownload", + "EcaStopResponse", + "EcaFixTreesitter", + "EcaChatSelectModel", + "EcaChatSelectBehavior", }, } diff --git a/tests/test_context_area.lua b/tests/test_context_area.lua new file mode 100644 index 0000000..c48fee0 --- /dev/null +++ b/tests/test_context_area.lua @@ -0,0 +1,440 @@ +local MiniTest = require("mini.test") +local eq = MiniTest.expect.equality +local child = MiniTest.new_child_neovim() + +local function setup_test_environment() + -- makes easy to debug test + _G.log = {} + Logger = require("eca.logger") + Logger.test = function(message) + table.insert(_G.log, message) + end + + -- Setup a minimal environment: Server, State, Mediator, Sidebar + _G.Server = require('eca.server').new() + _G.State = require('eca.state').new() + _G.Mediator = require('eca.mediator').new(_G.Server, _G.State) + _G.Sidebar = require('eca.sidebar').new(1, _G.Mediator) + _G.Eca = require('eca') + _G.Eca.sidebars[1] = _G.Sidebar + _G.Eca.current = { sidebar = _G.Sidebar } + + _G.get_state = function() + local buf = _G.Sidebar.containers.input.bufnr + local win = _G.Sidebar.containers.input.winid + local contexts_ns = _G.Sidebar.extmarks.contexts._ns + local contexts_ids = _G.Sidebar.extmarks.contexts._id + local contexts = {} + for _, id in ipairs(contexts_ids) do + local mark = vim.api.nvim_buf_get_extmark_by_id(buf, contexts_ns, id, { details = true }) + local _, _, details = unpack(mark) + local context_name = details and details.virt_text[1][1] or nil + if context_name then + table.insert(contexts, context_name) + end + end + return { + lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false), + cursor = vim.api.nvim_win_get_cursor(win), + prefix = require('eca.config').windows.input.prefix, + contexts = contexts, + } + end + + _G.set_lines = function(opts) + local buf = _G.Sidebar.containers.input.bufnr + local line_start = opts and opts.line_start or 0 + local line_end = opts and opts.line_end or -1 + local lines = opts and opts.lines or {} + vim.api.nvim_buf_set_lines(buf, line_start, line_end, true, lines) + end + + _G.set_text = function(opts) + local buf = _G.Sidebar.containers.input.bufnr + local start_row = opts and opts.start_row or 0 + local start_col = opts and opts.start_col or 0 + local end_row = opts and opts.end_row or 0 + local end_col = opts and opts.end_col or 1 + local lines = opts and opts.lines or {} + vim.api.nvim_buf_set_text(buf, start_row, start_col, end_row, end_col, lines) + end + + _G.set_cursor = function(row, col) + local win = _G.Sidebar.containers.input.winid + vim.api.nvim_set_current_win(win) + vim.api.nvim_win_set_cursor(win, { row, col }) + end + + _G.add_contexts = function(ctxs) + for _, ctx in ipairs(ctxs) do + _G.Mediator:add_context(ctx) + end + end + + -- Open the sidebar so containers are created + _G.Sidebar:open() +end + +local T = MiniTest.new_set({ + hooks = { + pre_case = function() + child.restart({ "-u", "scripts/minimal_init.lua" }) + child.lua_func(setup_test_environment) + end, + post_case = function() + -- Ensure sidebar windows cleaned + child.lua([[ if _G.Sidebar then _G.Sidebar:close() end ]]) + end, + post_once = child.stop, + }, +}) + +-- Helper to flush scheduled operations (vim.schedule / vim.defer_fn) +local function flush(ms) + vim.uv.sleep(ms or 100) + child.api.nvim_eval("1") +end + +T["context area"] = MiniTest.new_set() + +T["context area"]["deletes all lines"] = function() + flush() + + local initial = child.lua_get("_G.get_state()") + + eq(initial.lines, { "@", "" }) + eq(initial.cursor, { 2, 0 }) + eq(initial.contexts, {}) + + -- Delete all lines in input buffer + child.lua("_G.set_lines({ lines = {} })") + + flush() + + local result = child.lua_get("_G.get_state()") + + eq(result.lines, { "@", "" }) + eq(result.cursor, { 2, 0 }) + eq(result.contexts, {}) +end + +T["context area"]["deletes the contexts line"] = function() + flush() + + local initial = child.lua_get("_G.get_state()") + + eq(initial.lines, { "@", "" }) + eq(initial.cursor, { 2, 0 }) + eq(initial.contexts, {}) + + -- Delete only first line + child.lua("_G.set_lines({ line_start = 0, line_end = 1, lines = {} })") + + flush() + + local result = child.lua_get("_G.get_state()") + + eq(result.lines, { "@", "" }) + eq(result.cursor, { 2, 0 }) + eq(result.contexts, {}) +end + +T["context area"]["deletes the input line"] = function() + flush() + + local initial = child.lua_get("_G.get_state()") + + eq(initial.lines, { "@", "" }) + eq(initial.cursor, { 2, 0 }) + eq(initial.contexts, {}) + + -- Delete the input line + child.lua("_G.set_lines({ line_start = 1, line_end = -1, lines = {} })") + + flush() + + local result = child.lua_get("_G.get_state()") + + eq(result.lines, { "@", "" }) + eq(result.cursor, { 2, 0 }) + eq(result.contexts, {}) +end + +T["context area"]["keep input text when deleting contexts line"] = function() + flush() + + local input_text = "text*in<>the > input#preFIX 123456 lIne" + + -- Set input text + child.lua(string.format("_G.set_lines({ line_start = 1, line_end = -1, lines = { '%s' } })", input_text)) + + flush() + + local initial = child.lua_get("_G.get_state()") + + eq(initial.lines, { "@", input_text }) + eq(initial.cursor, { 2, 0 }) + eq(initial.contexts, {}) + + -- Delete the contexts line + child.lua("_G.set_lines({ line_start = 0, line_end = 1, lines = {} })") + + flush() + + local result = child.lua_get("_G.get_state()") + + eq(result.lines, { "@", input_text }) + eq(result.cursor, { 2, #input_text }) + eq(result.contexts, {}) +end + +T["context area"]["keep multiple lines text input when removing the first context"] = function() + flush() + + local input_text_first_line = "text*in<>the > input#preFIX 123456 lIne" + local input_text_second_line = "text in the 2nd line after input prefix line" + + -- Set input text + child.lua(string.format("_G.set_lines({ line_start = 1, line_end = -1, lines = { '%s', '%s' } })", input_text_first_line, input_text_second_line)) + + -- Add context + child.lua([[_G.add_contexts({ + { type = 'file', data = { path = '/tmp/sidebar.lua' } } + })]]) + + flush() + + local initial = child.lua_get("_G.get_state()") + + eq(initial.lines, { "@@", input_text_first_line, input_text_second_line }) + eq(initial.cursor, { 3, #input_text_second_line }) + eq(initial.contexts, { "sidebar.lua " }) + + -- Set cursor to first context placeholder + child.lua("_G.set_cursor(1, 0)") + + -- Delete the context placeholder in contexts line + child.lua("_G.set_text({ start_row = 0, start_col = 0, end_row = 0, end_col = 1, lines = {} })") + + flush() + + -- eq(child.lua_get("_G.log"), {}) + + local result = child.lua_get("_G.get_state()") + + eq(result.lines, { "@", input_text_first_line, input_text_second_line }) + eq(result.cursor, { 3, #input_text_second_line }) + eq(result.contexts, {}) +end + +T["context area"]["remove all contexts when deleting the contexts line"] = function() + flush() + + local input_text_first_line = "text*in<>the > input#preFIX 123456 lIne" + local input_text_second_line = "text in the 2nd line after input prefix line" + local input_text_fourth_line = "another text in the 4 line (note that line 3 is only with a newline)" + + -- Set input text + child.lua(string.format("_G.set_lines({ line_start = 1, line_end = -1, lines = { '%s', '%s', '', '%s' } })", input_text_first_line, input_text_second_line, input_text_fourth_line)) + + -- Add context + child.lua([[_G.add_contexts({ + { type = 'file', data = { path = '/tmp/sidebar.lua' } }, + { type = 'file', data = { path = '/tmp/sidebar.lua', lines_range = {line_start = 25, line_end = 50 } } } + })]]) + + flush() + + local initial = child.lua_get("_G.get_state()") + + eq(initial.lines, { "@@@", input_text_first_line, input_text_second_line, "", input_text_fourth_line }) + eq(initial.cursor, { 5, #input_text_fourth_line }) + eq(initial.contexts, { "sidebar.lua ", "sidebar.lua:25-50 " }) + + -- Delete the contexts line + child.lua("_G.set_lines({ line_start = 0, line_end = 1, lines = {} })") + + flush() + + local result = child.lua_get("_G.get_state()") + + eq(result.lines, { "@", input_text_first_line, input_text_second_line, "", input_text_fourth_line }) + eq(result.cursor, { 5, #input_text_fourth_line }) + eq(result.contexts, {}) +end + +T["context area"]["remove one specific context when multiple contexts are present"] = function() + flush() + + local input_text_first_line = "text*in<>the > input#preFIX 123456 lIne" + local input_text_second_line = "text in the 2nd line after input prefix line" + local input_text_fourth_line = "another text in the 4 line (note that line 3 is only with a newline)" + + -- Set input text + child.lua(string.format("_G.set_lines({ line_start = 1, line_end = -1, lines = { '%s', '%s', '', '%s' } })", input_text_first_line, input_text_second_line, input_text_fourth_line)) + + -- Add context + child.lua([[_G.add_contexts({ + { type = 'file', data = { path = '/tmp/sidebar.lua' } }, + { type = 'file', data = { path = '/tmp/sidebar.lua', lines_range = { line_start = 25, line_end = 50 } } }, + { type = 'file', data = { path = '/tmp/server.lua' } } + })]]) + + flush() + + local initial = child.lua_get("_G.get_state()") + + eq(initial.lines, { "@@@@", input_text_first_line, input_text_second_line, "", input_text_fourth_line }) + eq(initial.cursor, { 5, #input_text_fourth_line }) + eq(initial.contexts, { "sidebar.lua ", "sidebar.lua:25-50 ", "server.lua " }) + + -- Set cursor to the second context placeholder + child.lua("_G.set_cursor(1, 1)") + + -- Delete the context placeholder in contexts line + child.lua("_G.set_text({ start_row = 0, start_col = 1, end_row = 0, end_col = 2, lines = {} })") + + flush() + + local result = child.lua_get("_G.get_state()") + + eq(result.lines, { "@@@", input_text_first_line, input_text_second_line, "", input_text_fourth_line }) + eq(result.cursor, { 5, #input_text_fourth_line }) + eq(result.contexts, { "sidebar.lua ", "server.lua " }) +end + +T["context area"]["remove contexts one by one in an arbitrary order while preserving input"] = function() + flush() + + local input_text_first_line = "text*in<>the > input#preFIX 123456 lIne" + local input_text_second_line = "text in the 2nd line after input prefix line" + local input_text_fourth_line = "another text in the 4 line (note that line 3 is only with a newline)" + + -- Set input text + child.lua(string.format("_G.set_lines({ line_start = 1, line_end = -1, lines = { '%s', '%s', '', '%s' } })", input_text_first_line, input_text_second_line, input_text_fourth_line)) + + -- Add context + child.lua([[_G.add_contexts({ + { type = 'file', data = { path = '/dev/chat.lua' } }, + { type = 'file', data = { path = '/tmp/sidebar.lua' } }, + { type = 'file', data = { path = '/tmp/sidebar.lua', lines_range = { line_start = 25, line_end = 50 } } }, + { type = 'file', data = { path = '/dev/server.lua', lines_range = { line_start = 999, line_end = 1200 } } }, + { type = 'file', data = { path = '/dev/server.lua' } }, + })]]) + + flush() + + local initial = child.lua_get("_G.get_state()") + + eq(initial.lines, { "@@@@@@", input_text_first_line, input_text_second_line, "", input_text_fourth_line }) + eq(initial.cursor, { 5, #input_text_fourth_line }) + eq(initial.contexts, { "chat.lua ", "sidebar.lua ", "sidebar.lua:25-50 ", "server.lua:999-1200 ", "server.lua " }) + + -- Set cursor to the second context placeholder + child.lua("_G.set_cursor(1, 1)") + + -- Delete the context placeholder in contexts line + child.lua("_G.set_text({ start_row = 0, start_col = 1, end_row = 0, end_col = 2, lines = {} })") + + flush() + + local result = child.lua_get("_G.get_state()") + + eq(result.lines, { "@@@@@", input_text_first_line, input_text_second_line, "", input_text_fourth_line }) + eq(result.cursor, { 5, #input_text_fourth_line }) + eq(result.contexts, { "chat.lua ", "sidebar.lua:25-50 ", "server.lua:999-1200 ", "server.lua " }) + + -- Set cursor to the second context placeholder + child.lua("_G.set_cursor(1, 3)") + + -- Delete the context placeholder in contexts line + child.lua("_G.set_text({ start_row = 0, start_col = 3, end_row = 0, end_col = 4, lines = {} })") + + flush() + + local result_2 = child.lua_get("_G.get_state()") + + eq(result_2.lines, { "@@@@", input_text_first_line, input_text_second_line, "", input_text_fourth_line }) + eq(result_2.cursor, { 5, #input_text_fourth_line }) + eq(result_2.contexts, { "chat.lua ", "sidebar.lua:25-50 ", "server.lua:999-1200 " }) + + -- Set cursor to the second context placeholder + child.lua("_G.set_cursor(1, 0)") + + -- Delete the context placeholder in contexts line + child.lua("_G.set_text({ start_row = 0, start_col = 0, end_row = 0, end_col = 1, lines = {} })") + + flush() + + local result_3 = child.lua_get("_G.get_state()") + + eq(result_3.lines, { "@@@", input_text_first_line, input_text_second_line, "", input_text_fourth_line }) + eq(result_3.cursor, { 5, #input_text_fourth_line }) + eq(result_3.contexts, { "sidebar.lua:25-50 ", "server.lua:999-1200 " }) + + -- Delete the contexts line + child.lua("_G.set_lines({ line_start = 0, line_end = 1, lines = {} })") + + flush() + + local result_4 = child.lua_get("_G.get_state()") + + eq(result_4.lines, { "@", input_text_first_line, input_text_second_line, "", input_text_fourth_line }) + eq(result_4.cursor, { 5, #input_text_fourth_line }) + eq(result_4.contexts, {}) +end + +T["context area"]["displays filename in context area and expands path in sent message"] = function() + flush() + + local rel_path = "lua/eca/sidebar.lua" + local abs_path = child.lua_get("vim.fn.fnamemodify(..., ':p')", { rel_path }) + local tail = child.lua_get("vim.fn.fnamemodify(..., ':t')", { rel_path }) .. " " + + -- Add a context with relative path; context area should show only the + -- filename (tail), not the full path. + child.lua([[_G.add_contexts({ + { type = 'file', data = { path = 'lua/eca/sidebar.lua' } }, + })]]) + + flush() + + local state = child.lua_get("_G.get_state()") + + -- Contexts in the area should use the tail of the path + eq(state.contexts, { tail }) + + -- Mock server on mediator so we don't start a real process. Capture + -- the last request instead of sending anything. + child.lua([[ + _G.last_request = nil + _G.Mediator.server = { + is_running = function() + return true + end, + send_request = function(_, method, params, callback) + _G.last_request = { method = method, params = params } + if callback then + callback(nil, {}) + end + end, + } + ]]) + + -- Send a message that references the same relative path using the + -- @path shorthand. Sidebar should expand it to an absolute path + -- before sending to the (mocked) server. + child.lua("_G.Sidebar:_send_message('please check @' .. '" .. rel_path .. "')") + + local req = child.lua_get("_G.last_request") + eq(req.method, "chat/prompt") + + local msg = req.params.message + local expected = "please check @" .. abs_path + + -- Message sent to the server must contain the absolute path and no + -- longer contain the original relative path. + eq(msg, expected) +end + +return T diff --git a/tests/test_context_commands.lua b/tests/test_context_commands.lua new file mode 100644 index 0000000..4ccb8e7 --- /dev/null +++ b/tests/test_context_commands.lua @@ -0,0 +1,415 @@ +local MiniTest = require("mini.test") +local eq = MiniTest.expect.equality +local child = MiniTest.new_child_neovim() + +local function flush(ms) + vim.uv.sleep(ms or 100) + child.api.nvim_eval("1") +end + +local function setup_test_environment() + child.lua([[ + local Eca = require('eca') + + -- Setup plugin with no auto server or keymaps so tests are + -- deterministic and don't spawn external processes. + Eca.setup({ + behavior = { + auto_start_server = false, + auto_set_keymaps = false, + }, + }) + + -- Ensure we have a sidebar/mediator for the current tab + local tab = vim.api.nvim_get_current_tabpage() + Eca._init(tab) + Eca.open_sidebar({}) + + -- Fake server so eca.api thinks it is running but we never + -- actually start the external binary. + if Eca.server then + Eca.server.is_running = function() + return true + end + else + Eca.server = { + is_running = function() + return true + end, + } + end + + -- Clear any existing contexts before each test + if Eca.mediator then + Eca.mediator:clear_contexts() + end + + -- Capture Logger.notify calls (used by deprecated commands and + -- some API helpers) so we can assert on deprecation messages. + local Logger = require('eca.logger') + _G.Logger = Logger + _G.original_logger_notify = Logger.notify + _G.captured_notifications = {} + + Logger.notify = function(msg, level, opts) + level = level or vim.log.levels.INFO + opts = opts or {} + + table.insert(_G.captured_notifications, { + message = msg, + level = level, + opts = opts, + }) + + if _G.original_logger_notify then + _G.original_logger_notify(msg, level, opts) + end + end + + -- Stub vim.ui.input so we can simulate user input in tests + vim.ui = vim.ui or {} + _G.__test_next_input = nil + vim.ui.input = function(opts, on_confirm) + if on_confirm then + on_confirm(_G.__test_next_input) + end + end + ]]) +end + +local T = MiniTest.new_set({ + hooks = { + pre_case = function() + child.restart({ "-u", "scripts/minimal_init.lua" }) + setup_test_environment() + end, + post_once = child.stop, + }, +}) + +local function contexts_count() + return child.lua_get("#require('eca').mediator:contexts()") +end + +local function get_contexts() + return child.lua_get("require('eca').mediator:contexts()") +end + +-- EcaChatAddFile ----------------------------------------------------------- + +T["EcaChatAddFile"] = MiniTest.new_set() + +T["EcaChatAddFile"]["command is registered"] = function() + local commands = child.lua_get("vim.api.nvim_get_commands({})") + eq(type(commands.EcaChatAddFile), "table") + eq(commands.EcaChatAddFile.name, "EcaChatAddFile") +end + +T["EcaChatAddFile"]["adds current file as context when no args"] = function() + child.cmd("edit README.md") + local abs = child.lua_get("vim.fn.fnamemodify('README.md', ':p')") + + eq(contexts_count(), 0) + + child.cmd("EcaChatAddFile") + flush() + + local contexts = get_contexts() + eq(#contexts, 1) + eq(contexts[1].type, "file") + eq(contexts[1].data.path, abs) +end + +T["EcaChatAddFile"]["adds provided path as context when args are given"] = function() + local filename = "README.md" + local expected_abs = child.lua_get("vim.fn.fnamemodify(..., ':p')", { filename }) + + eq(contexts_count(), 0) + + child.cmd("EcaChatAddFile " .. filename) + flush() + + local contexts = get_contexts() + eq(#contexts, 1) + eq(contexts[1].type, "file") + eq(contexts[1].data.path, expected_abs) +end + +-- Deprecated EcaAddFile ---------------------------------------------------- + +T["EcaAddFile"] = MiniTest.new_set() + +T["EcaAddFile"]["command is registered"] = function() + local commands = child.lua_get("vim.api.nvim_get_commands({})") + eq(type(commands.EcaAddFile), "table") + eq(commands.EcaAddFile.name, "EcaAddFile") +end + +T["EcaAddFile"]["shows deprecation notice when called"] = function() + child.cmd("EcaAddFile") + flush() + + local notifications = child.lua_get("_G.captured_notifications") + eq(#notifications > 0, true) + eq(notifications[1].message, "EcaAddFile is deprecated. Use EcaChatAddFile instead.") + eq(notifications[1].level, child.lua_get("vim.log.levels.WARN")) +end + +-- EcaChatRemoveFile -------------------------------------------------------- + +T["EcaChatRemoveFile"] = MiniTest.new_set() + +T["EcaChatRemoveFile"]["command is registered"] = function() + local commands = child.lua_get("vim.api.nvim_get_commands({})") + eq(type(commands.EcaChatRemoveFile), "table") + eq(commands.EcaChatRemoveFile.name, "EcaChatRemoveFile") +end + +T["EcaChatRemoveFile"]["removes current file context when no args"] = function() + child.cmd("edit README.md") + + child.cmd("EcaChatAddFile") + flush() + eq(contexts_count(), 1) + + child.cmd("EcaChatRemoveFile") + flush() + + eq(contexts_count(), 0) +end + +T["EcaChatRemoveFile"]["removes context for provided path when args are given"] = function() + local filename = "README.md" + + child.cmd("edit README.md") + child.cmd("EcaChatAddFile") + flush() + eq(contexts_count(), 1) + + child.cmd("EcaChatRemoveFile " .. filename) + flush() + + eq(contexts_count(), 0) +end + +-- Deprecated EcaRemoveContext --------------------------------------------- + +T["EcaRemoveContext"] = MiniTest.new_set() + +T["EcaRemoveContext"]["command is registered"] = function() + local commands = child.lua_get("vim.api.nvim_get_commands({})") + eq(type(commands.EcaRemoveContext), "table") + eq(commands.EcaRemoveContext.name, "EcaRemoveContext") +end + +T["EcaRemoveContext"]["shows deprecation notice when called"] = function() + child.cmd("EcaRemoveContext") + flush() + + local notifications = child.lua_get("_G.captured_notifications") + eq(#notifications > 0, true) + eq(notifications[1].message, "EcaRemoveContext is deprecated. Use EcaChatRemoveFile instead.") + eq(notifications[1].level, child.lua_get("vim.log.levels.WARN")) +end + +T["EcaChatAddSelection"] = MiniTest.new_set() + +T["EcaChatAddSelection"]["command is registered"] = function() + local commands = child.lua_get("vim.api.nvim_get_commands({})") + eq(type(commands.EcaChatAddSelection), "table") + eq(commands.EcaChatAddSelection.name, "EcaChatAddSelection") +end + +T["EcaChatAddSelection"]["adds a ranged file context based on visual selection"] = function() + child.cmd("edit README.md") + local abs = child.lua_get("vim.fn.fnamemodify('README.md', ':p')") + + -- Manually set visual selection marks for lines 1-2 to avoid headless + -- visual-mode quirks + child.lua([[ + local bufnr = vim.api.nvim_get_current_buf() + vim.fn.setpos("'<", {bufnr, 1, 1, 0}) + vim.fn.setpos("'>", {bufnr, 2, 1, 0}) + ]]) + + eq(contexts_count(), 0) + + child.cmd("EcaChatAddSelection") + flush(200) + + local contexts = get_contexts() + eq(#contexts, 1) + eq(contexts[1].type, "file") + eq(contexts[1].data.path, abs) + eq(contexts[1].data.lines_range.line_start, 1) + eq(contexts[1].data.lines_range.line_end, 2) +end + +-- Deprecated EcaAddSelection ----------------------------------------------- + +T["EcaAddSelection"] = MiniTest.new_set() + +T["EcaAddSelection"]["command is registered"] = function() + local commands = child.lua_get("vim.api.nvim_get_commands({})") + eq(type(commands.EcaAddSelection), "table") + eq(commands.EcaAddSelection.name, "EcaAddSelection") +end + +T["EcaAddSelection"]["shows deprecation notice when called"] = function() + child.cmd("EcaAddSelection") + flush(200) + + local notifications = child.lua_get("_G.captured_notifications") + eq(#notifications > 0, true) + eq(notifications[1].message, "EcaAddSelection is deprecated. Use EcaChatAddSelection instead.") + eq(notifications[1].level, child.lua_get("vim.log.levels.WARN")) +end + +T["EcaChatAddUrl"] = MiniTest.new_set() + +T["EcaChatAddUrl"]["command is registered"] = function() + local commands = child.lua_get("vim.api.nvim_get_commands({})") + eq(type(commands.EcaChatAddUrl), "table") + eq(commands.EcaChatAddUrl.name, "EcaChatAddUrl") +end + +T["EcaChatAddUrl"]["adds web context and truncates name based on config"] = function() + -- Use a small max length to make truncation easy to assert on + child.lua([[require('eca.config').override({ + windows = { + input = { + web_context_max_len = 10, + }, + }, + })]]) + + local long_url = "https://example.com/some/really/long/path" + + -- Provide the URL for the stubbed vim.ui.input + child.lua(string.format("_G.__test_next_input = %q", long_url)) + + eq(contexts_count(), 0) + + child.cmd("EcaChatAddUrl") + flush(200) + + local contexts = get_contexts() + eq(#contexts, 1) + eq(contexts[1].type, "web") + eq(contexts[1].data.path, long_url) + + -- Ensure the context name shown in the input buffer is truncated + child.lua([[ + local eca = require('eca') + local sidebar = eca.current.sidebar + if not sidebar or not sidebar.containers or not sidebar.containers.input then + _G.__test_displayed_context = nil + return + end + local input = sidebar.containers.input + local ns = vim.api.nvim_create_namespace('extmarks_contexts') + local marks = vim.api.nvim_buf_get_extmarks(input.bufnr, ns, 0, -1, { details = true }) + if not marks or #marks == 0 then + _G.__test_displayed_context = nil + return + end + local details = marks[1][4] + if not details or not details.virt_text or #details.virt_text == 0 then + _G.__test_displayed_context = nil + return + end + _G.__test_displayed_context = details.virt_text[1][1] + ]]) + + local displayed = child.lua_get("_G.__test_displayed_context") + local expected = long_url:sub(1, 7) .. "... " + eq(displayed, expected) +end + +T["EcaChatListContexts"] = MiniTest.new_set() + +T["EcaChatListContexts"]["command is registered"] = function() + local commands = child.lua_get("vim.api.nvim_get_commands({})") + eq(type(commands.EcaChatListContexts), "table") + eq(commands.EcaChatListContexts.name, "EcaChatListContexts") +end + +T["EcaChatListContexts"]["runs without modifying contexts"] = function() + child.cmd("edit README.md") + child.cmd("EcaChatAddFile") + flush() + + local before = contexts_count() + child.cmd("EcaChatListContexts") + flush() + local after = contexts_count() + + eq(after, before) +end + +-- Deprecated EcaListContexts ----------------------------------------------- + +T["EcaListContexts"] = MiniTest.new_set() + +T["EcaListContexts"]["command is registered"] = function() + local commands = child.lua_get("vim.api.nvim_get_commands({})") + eq(type(commands.EcaListContexts), "table") + eq(commands.EcaListContexts.name, "EcaListContexts") +end + +T["EcaListContexts"]["shows deprecation notice when called"] = function() + child.cmd("EcaListContexts") + flush() + + local notifications = child.lua_get("_G.captured_notifications") + eq(#notifications > 0, true) + eq(notifications[1].message, "EcaListContexts is deprecated. Use EcaChatListContexts instead.") + eq(notifications[1].level, child.lua_get("vim.log.levels.WARN")) + -- No explicit level is passed in the command for this deprecation, + -- so we only assert on the message. +end + +T["EcaChatClearContexts"] = MiniTest.new_set() + +T["EcaChatClearContexts"]["command is registered"] = function() + local commands = child.lua_get("vim.api.nvim_get_commands({})") + eq(type(commands.EcaChatClearContexts), "table") + eq(commands.EcaChatClearContexts.name, "EcaChatClearContexts") +end + +T["EcaChatClearContexts"]["clears all contexts"] = function() + child.cmd("edit README.md") + child.cmd("EcaChatAddFile") + child.cmd("EcaChatAddFile") + flush() + eq(contexts_count() > 0, true) + + child.cmd("EcaChatClearContexts") + flush() + + eq(contexts_count(), 0) +end + +-- Deprecated EcaClearContexts ---------------------------------------------- + +T["EcaClearContexts"] = MiniTest.new_set() + +T["EcaClearContexts"]["command is registered"] = function() + local commands = child.lua_get("vim.api.nvim_get_commands({})") + eq(type(commands.EcaClearContexts), "table") + eq(commands.EcaClearContexts.name, "EcaClearContexts") +end + +T["EcaClearContexts"]["shows deprecation notice when called"] = function() + child.cmd("EcaClearContexts") + flush() + + local notifications = child.lua_get("_G.captured_notifications") + eq(#notifications > 0, true) + eq(notifications[1].message, "EcaClearContexts is deprecated. Use EcaChatClearContexts instead.") + eq(notifications[1].level, child.lua_get("vim.log.levels.WARN")) + -- No explicit level is passed in the command for this deprecation, + -- so we only assert on the message. +end + +return T diff --git a/tests/test_select_commands.lua b/tests/test_select_commands.lua index 23c028d..a117223 100644 --- a/tests/test_select_commands.lua +++ b/tests/test_select_commands.lua @@ -12,6 +12,7 @@ local function setup_test_environment() _G.Mediator = require('eca.mediator').new(_G.Server, _G.State) _G.Sidebar = require('eca.sidebar').new(1, _G.Mediator) _G.Eca = require('eca') + _G.Eca.sidebars[1] = _G.Sidebar _G.Eca.current = { sidebar = _G.Sidebar } -- Mock vim.ui.select for testing