From a627f8a42440c2b4f6a8145c046feadda419101f Mon Sep 17 00:00:00 2001 From: joaopluigi Date: Sat, 11 Oct 2025 14:18:24 -0300 Subject: [PATCH 01/25] fix selection context payload --- lua/eca/api.lua | 8 ++++---- lua/eca/sidebar.lua | 4 ++++ 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/lua/eca/api.lua b/lua/eca/api.lua index 851a102..589f58d 100644 --- a/lua/eca/api.lua +++ b/lua/eca/api.lua @@ -153,11 +153,11 @@ function M.add_selection_context() -- Create context object local context = { type = "file", - path = context_path, - lines_range = { + path = current_file, + linesRange = { start = start_line, - End = end_line - } + ["end"] = end_line, -- end is a reserved word in Lua + }, } -- Get current sidebar and add context diff --git a/lua/eca/sidebar.lua b/lua/eca/sidebar.lua index 1fb5b43..7570254 100644 --- a/lua/eca/sidebar.lua +++ b/lua/eca/sidebar.lua @@ -1132,6 +1132,10 @@ function M:_update_contexts_display() 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 + if context.linesRange and context.linesRange.start and context.linesRange["end"] then + name = string.format("%s:%d-%d", name, context.linesRange.start, context.linesRange["end"]) + end + -- Create context reference with @ prefix like eca-emacs local ref = "@" .. name table.insert(context_refs, ref) From b8d08429862e798dfe45ad84725aafaac8883afe Mon Sep 17 00:00:00 2001 From: joaopluigi Date: Sat, 11 Oct 2025 14:18:45 -0300 Subject: [PATCH 02/25] wrap server error message in vim.inspect --- lua/eca/sidebar.lua | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/lua/eca/sidebar.lua b/lua/eca/sidebar.lua index 7570254..a36a49f 100644 --- a/lua/eca/sidebar.lua +++ b/lua/eca/sidebar.lua @@ -1287,9 +1287,8 @@ 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 From 859014441aae186ba4d9b59bbb13c988507aa3d4 Mon Sep 17 00:00:00 2001 From: joaopluigi Date: Tue, 14 Oct 2025 08:46:29 -0300 Subject: [PATCH 03/25] move contexts to state --- lua/eca/api.lua | 137 ++++++++++++++++++++----------------------- lua/eca/commands.lua | 4 +- lua/eca/mediator.lua | 127 +++++++++++++++++++++++++++++++++++++++ lua/eca/sidebar.lua | 2 +- lua/eca/state.lua | 57 ++++++++++++++++++ lua/eca/types.lua | 18 ++++++ 6 files changed, 270 insertions(+), 75 deletions(-) diff --git a/lua/eca/api.lua b/lua/eca/api.lua index 589f58d..0006c79 100644 --- a/lua/eca/api.lua +++ b/lua/eca/api.lua @@ -81,23 +81,20 @@ 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() - end + local chat = eca.get() - if sidebar then - sidebar:add_context(context) - else - Logger.notify("Failed to create ECA sidebar", vim.log.levels.ERROR) + 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("File context added: " .. vim.inspect(context)) end ---@param directory_path string @@ -113,12 +110,20 @@ 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 function M.add_current_file_context() @@ -131,6 +136,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 +153,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", + + 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) + + -- Create context object + local context = { + type = "file", + data = { path = current_file, - linesRange = { - start = start_line, - ["end"] = end_line, -- end is a reserved word in Lua + lines_range = { + line_start = start_line, + 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 sidebar then - sidebar:add_context(context) - - -- 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,35 +201,34 @@ 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) + local chat = eca.get() + + if not chat or not chat.mediator then + Logger.notify("No active ECA Chat", vim.log.levels.WARN) return end - sidebar:clear_contexts() + chat.mediator:clear_contexts() + Logger.info("Cleared all contexts") end 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) + local chat = eca.get() + + if not chat or not chat.mediator then + Logger.notify("No active ECA Chat", vim.log.levels.WARN) return end - sidebar:remove_context(path) + chat.mediator:remove_context(path) + Logger.info("Context removed: " .. path) end function M.add_repo_map_context() diff --git a/lua/eca/commands.lua b/lua/eca/commands.lua index 24d26cc..589e584 100644 --- a/lua/eca/commands.lua +++ b/lua/eca/commands.lua @@ -351,7 +351,7 @@ function M.setup() return end - local chat = eca.current.sidebar + local chat = eca.get() local models = chat.mediator:models() vim.ui.select(models, { @@ -373,7 +373,7 @@ function M.setup() return end - local chat = eca.current.sidebar + local chat = eca.get() local behaviors = chat.mediator:behaviors() vim.ui.select(behaviors, { diff --git a/lua/eca/mediator.lua b/lua/eca/mediator.lua index e70fd1d..a7a1c13 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) @@ -23,6 +110,18 @@ function mediator:send(method, params, callback) end require("eca.logger").notify("Server is not rnning, please start the server", vim.log.levels.WARN) end + + 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 + self.server:send_request(method, params, callback) end @@ -90,4 +189,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(name) + if not self.state then + return + end + + self.state:remove_context(name) +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 a36a49f..4edde64 100644 --- a/lua/eca/sidebar.lua +++ b/lua/eca/sidebar.lua @@ -1277,7 +1277,7 @@ function M:_send_message(message) -- Add user message to chat self:_add_message("user", message) - local contexts = self:get_contexts() + local contexts = self.mediator:contexts() self.mediator:send("chat/prompt", { chatId = self.mediator:id(), requestId = tostring(os.time()), diff --git a/lua/eca/state.lua b/lua/eca/state.lua index 9fa05f3..29b1df1 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,59 @@ 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() + 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 From 1e4cc318fc818e1838e79502584643aa3c9f5dcb Mon Sep 17 00:00:00 2001 From: joaopluigi Date: Tue, 14 Oct 2025 08:50:12 -0300 Subject: [PATCH 04/25] rm selected_code, todos and contexts containers and make contexts display together with input --- lua/eca/sidebar.lua | 591 ++++++++------------------------------------ 1 file changed, 105 insertions(+), 486 deletions(-) diff --git a/lua/eca/sidebar.lua b/lua/eca/sidebar.lua index 4edde64..72cfd8f 100644 --- a/lua/eca/sidebar.lua +++ b/lua/eca/sidebar.lua @@ -18,9 +18,6 @@ local Split = require("nui.split") ---@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 @@ -54,9 +51,6 @@ function M.new(id, mediator) 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 @@ -190,9 +184,6 @@ function M:reset() 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 end @@ -226,20 +217,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 +261,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 +282,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 +301,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 +322,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 +345,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 +359,8 @@ 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_keymaps(container) - elseif name == "status" then - -- No special keymaps for status container (read-only) end end @@ -463,33 +373,6 @@ function M:_handle_container_closed(name) end 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) - 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) - end - end - end, { noremap = true, silent = true }) -end - ---@private ---@param container NuiSplit function M:_setup_input_keymaps(container) @@ -511,11 +394,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 +410,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 +421,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 +442,7 @@ end function M:_refresh_container_content() -- Refresh content without full setup - if self.containers.contexts then - self:_update_contexts_display() - end - + -- Update chat header with mediator contexts if self.containers.chat then self:_set_welcome_content() end @@ -726,16 +451,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 +461,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 @@ -772,7 +493,7 @@ function M:_setup_chat_container() -- Disable treesitter initially to prevent highlighting errors during setup vim.api.nvim_set_option_value("syntax", "off", { buf = chat.bufnr }) - -- Set initial content first + -- Set initial content after the header self:_set_welcome_content() -- Set filetype to markdown for syntax highlighting @@ -784,41 +505,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 +522,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 +560,90 @@ 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() 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 = vim.fn.fnamemodify(path, ":t") + 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 + + local placeholder_line = "@" + for _ = 1, #contexts_name do + placeholder_line = placeholder_line .. "@" + end + + -- Get existing lines to preserve user input (lines after the header) + local existing_lines = vim.api.nvim_buf_get_lines(input.bufnr, 1, -1, false) + + vim.api.nvim_buf_set_lines(input.bufnr, 0, -1, false, { 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 + + 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 + + if #existing_lines > 0 then + vim.api.nvim_buf_set_lines(input.bufnr, 1, 1 + #existing_lines, false, existing_lines) + end + + self.extmarks.prefix._id = 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 }) + ) + + -- Set cursor to end of prefix line if vim.api.nvim_win_is_valid(input.winid) then - vim.api.nvim_win_set_cursor(input.winid, { 1, #prefix }) + vim.api.nvim_win_set_cursor(input.winid, { 2, #prefix }) end end) end @@ -928,14 +662,16 @@ 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() + -- Ensure there is at least a header and a prefix line + if #lines < 2 then + self:_update_input_display() + lines = vim.api.nvim_buf_get_lines(input.bufnr, 0, -1, false) end + -- Place cursor on the prefix line (line 2), after the prefix + local cursor_col = #prefix + vim.api.nvim_win_set_cursor(input.winid, { 2, cursor_col }) + -- Enter insert mode local mode = vim.api.nvim_get_mode().mode if mode == "n" then @@ -952,17 +688,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 +712,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() 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 +745,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,42 +771,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 - - if context.linesRange and context.linesRange.start and context.linesRange["end"] then - name = string.format("%s:%d-%d", name, context.linesRange.start, context.linesRange["end"]) - end - - -- 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 @@ -1292,7 +911,7 @@ function M:_send_message(message) 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) @@ -1323,7 +942,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() From 854a4a852b165a02bea4d6cfd612b098178ff6e0 Mon Sep 17 00:00:00 2001 From: joaopluigi Date: Tue, 14 Oct 2025 08:55:53 -0300 Subject: [PATCH 05/25] revert some unwanted changes --- lua/eca/sidebar.lua | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lua/eca/sidebar.lua b/lua/eca/sidebar.lua index 72cfd8f..c2ddbad 100644 --- a/lua/eca/sidebar.lua +++ b/lua/eca/sidebar.lua @@ -442,7 +442,6 @@ end function M:_refresh_container_content() -- Refresh content without full setup - -- Update chat header with mediator contexts if self.containers.chat then self:_set_welcome_content() end @@ -493,7 +492,7 @@ function M:_setup_chat_container() -- Disable treesitter initially to prevent highlighting errors during setup vim.api.nvim_set_option_value("syntax", "off", { buf = chat.bufnr }) - -- Set initial content after the header + -- Set initial content first self:_set_welcome_content() -- Set filetype to markdown for syntax highlighting From 3868642dae1256dc4b60a8fe360844555a68dfd4 Mon Sep 17 00:00:00 2001 From: joaopluigi Date: Sat, 18 Oct 2025 09:11:19 -0300 Subject: [PATCH 06/25] initial implementation of context area handling --- lua/eca/mediator.lua | 4 +- lua/eca/sidebar.lua | 93 ++++++++++++++++++++++++++++++++++++++------ lua/eca/state.lua | 1 + 3 files changed, 84 insertions(+), 14 deletions(-) diff --git a/lua/eca/mediator.lua b/lua/eca/mediator.lua index a7a1c13..18c22be 100644 --- a/lua/eca/mediator.lua +++ b/lua/eca/mediator.lua @@ -201,12 +201,12 @@ function mediator:add_context(context) self.state:add_context(context) end -function mediator:remove_context(name) +function mediator:remove_context(context) if not self.state then return end - self.state:remove_context(name) + self.state:remove_context(context) end function mediator:clear_contexts() diff --git a/lua/eca/sidebar.lua b/lua/eca/sidebar.lua index c2ddbad..5dd17ab 100644 --- a/lua/eca/sidebar.lua +++ b/lua/eca/sidebar.lua @@ -23,6 +23,8 @@ local Split = require("nui.split") ---@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 @@ -60,6 +62,7 @@ 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 = "" require("eca.observer").subscribe("sidebar-" .. id, function(message) instance:handle_chat_content(message) @@ -186,6 +189,7 @@ function M:reset() self._force_welcome = false self._current_status = "" self._welcome_message_applied = false + self._contexts_placeholder_line = "" end function M:new_chat() @@ -360,6 +364,7 @@ end function M:_setup_container_events(container, name) -- Setup container-specific keymaps if name == "input" then + self:_setup_input_events(container) self:_setup_input_keymaps(container) end end @@ -373,6 +378,58 @@ function M:_handle_container_closed(name) end end +---@private +---@param container NuiSplit +function M:_setup_input_events(container) + -- prevent contexts line or input prefix from being deleted + vim.api.nvim_buf_attach(container.bufnr, false, { + on_lines = function(_, buf, _changedtick, first, _last, _new_last, _bytecount) + if first ~= 0 and first ~= 1 then + return + end + + local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) + + -- restore input line if deleted + if first == 1 and #lines < 2 then + self:_update_input_display() + return + end + + -- first line (contexts) was changed + if first == 0 then + local contexts_line = lines[1] + + -- if contexts line is deleted, clear all contexts + if not contexts_line or contexts_line == "" then + self.mediator:clear_contexts() + return + end + + -- contexts line was modified + if contexts_line ~= self._contexts_placeholder_line then + + -- if contexts line is shorter than placeholder, 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 + + self:_update_input_display() + return + end + end + end + }) +end + ---@private ---@param container NuiSplit function M:_setup_input_keymaps(container) @@ -561,7 +618,7 @@ function M:_set_welcome_content() self:_update_welcome_content() end -function M:_update_input_display() +function M:_update_input_display(opts) return vim.schedule(function() local input = self.containers.input if not input then @@ -590,15 +647,22 @@ function M:_update_input_display() end end - local placeholder_line = "@" + local old_contexts_placeholder_line = self._contexts_placeholder_line + + self._contexts_placeholder_line = "@" for _ = 1, #contexts_name do - placeholder_line = placeholder_line .. "@" + self._contexts_placeholder_line = self._contexts_placeholder_line .. "@" end -- Get existing lines to preserve user input (lines after the header) - local existing_lines = vim.api.nvim_buf_get_lines(input.bufnr, 1, -1, false) + local existing_lines = vim.api.nvim_buf_get_lines(input.bufnr, 0, -1, false) + + -- If first line is the contexts placeholder, remove it from existing lines + if existing_lines and #existing_lines > 1 and (existing_lines[1] == "" or existing_lines[1] == old_contexts_placeholder_line or existing_lines[1] == self._contexts_placeholder_line) then + table.remove(existing_lines, 1) + end - vim.api.nvim_buf_set_lines(input.bufnr, 0, -1, false, { placeholder_line, "" }) + vim.api.nvim_buf_set_lines(input.bufnr, 0, -1, false, { self._contexts_placeholder_line, "" }) if not self.extmarks.contexts then self.extmarks.contexts = { @@ -628,7 +692,9 @@ function M:_update_input_display() } end - if #existing_lines > 0 then + 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 @@ -640,9 +706,12 @@ function M:_update_input_display() vim.tbl_extend("force", { virt_text = { { prefix, "Normal" } }, virt_text_pos = "inline", right_gravity = false }, { id = self.extmarks.prefix._id }) ) - -- Set cursor to end of prefix line + -- 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, { 2, #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 @@ -715,7 +784,7 @@ function M:_handle_input() self:_send_message(message) -- Add new input line and focus - self:_update_input_display() + self:_update_input_display({ clear = true }) self:_focus_input() end @@ -832,7 +901,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 @@ -858,11 +927,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 diff --git a/lua/eca/state.lua b/lua/eca/state.lua index 29b1df1..73b9949 100644 --- a/lua/eca/state.lua +++ b/lua/eca/state.lua @@ -276,6 +276,7 @@ function State:remove_context(context) 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 From f5f38bd4f8735a92c0e393ff712ad4f7ffea5cce Mon Sep 17 00:00:00 2001 From: joaopluigi Date: Sat, 18 Oct 2025 12:26:38 -0300 Subject: [PATCH 07/25] make contexts area handle logic use extmarks --- lua/eca/sidebar.lua | 101 ++++++++++++++++++++++++++++++-------------- 1 file changed, 70 insertions(+), 31 deletions(-) diff --git a/lua/eca/sidebar.lua b/lua/eca/sidebar.lua index 5dd17ab..a6a8c5f 100644 --- a/lua/eca/sidebar.lua +++ b/lua/eca/sidebar.lua @@ -388,28 +388,50 @@ function M:_setup_input_events(container) return end - local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) + vim.schedule(function() + local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) - -- restore input line if deleted - if first == 1 and #lines < 2 then - self:_update_input_display() - return - end + -- 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 - -- first line (contexts) was changed - if first == 0 then - local contexts_line = lines[1] + if not prefix_extmark or not contexts_extmark then + return + end - -- if contexts line is deleted, clear all contexts - if not contexts_line or contexts_line == "" then + 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_row = unpack(vim.api.nvim_buf_get_extmark_by_id(buf, prefix_ns, prefix_id, {})) + local contexts_row = 0 + + -- If both are in the same row, contexts_row was deleted + if prefix_row == contexts_row then self.mediator:clear_contexts() return end - -- contexts line was modified - if contexts_line ~= self._contexts_placeholder_line then + 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 contexts line is shorter than placeholder, a context was removed + -- prefix line missing, restore + if not prefix_line and contexts_line == contexts_placeholder_line then + self:_update_input_display() + return + end + + if contexts_line ~= contexts_placeholder_line then + -- a context was removed if #contexts_line < #self._contexts_placeholder_line then local contexts = self.mediator:contexts() @@ -425,7 +447,7 @@ function M:_setup_input_events(container) self:_update_input_display() return end - end + end) end }) end @@ -654,14 +676,20 @@ function M:_update_input_display(opts) self._contexts_placeholder_line = self._contexts_placeholder_line .. "@" end - -- Get existing lines to preserve user input (lines after the header) - local existing_lines = vim.api.nvim_buf_get_lines(input.bufnr, 0, -1, false) - -- If first line is the contexts placeholder, remove it from existing lines - if existing_lines and #existing_lines > 1 and (existing_lines[1] == "" or existing_lines[1] == old_contexts_placeholder_line or existing_lines[1] == self._contexts_placeholder_line) then - table.remove(existing_lines, 1) + 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 @@ -698,14 +726,22 @@ function M:_update_input_display(opts) vim.api.nvim_buf_set_lines(input.bufnr, 1, 1 + #existing_lines, false, existing_lines) end - self.extmarks.prefix._id = vim.api.nvim_buf_set_extmark( + 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 }) + vim.tbl_extend("force", { virt_text = { { prefix, "Normal" } }, virt_text_pos = "inline", right_gravity = false }, { id = self.extmarks.prefix._id[1] }) ) + -- local prefix_mark = vim.api.nvim_buf_get_extmark_by_id(input.bufnr, self.extmarks.prefix._ns, self.extmarks.prefix._id[1], { details = true }) + -- local prefix_mark = opts and opts.prefix_mark or {} + -- vim.notify(vim.inspect(prefix_mark), vim.log.levels.DEBUG) + -- Set cursor to end of input line if vim.api.nvim_win_is_valid(input.winid) then local row = 1 + (not clear and existing_lines and #existing_lines > 0 and #existing_lines or 1) @@ -730,23 +766,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 "> " + local row = 2 + local col = #prefix + -- Ensure there is at least a header and a prefix line if #lines < 2 then - self:_update_input_display() - lines = vim.api.nvim_buf_get_lines(input.bufnr, 0, -1, false) + row = 1 + col = 0 end - -- Place cursor on the prefix line (line 2), after the prefix - local cursor_col = #prefix - vim.api.nvim_win_set_cursor(input.winid, { 2, cursor_col }) + 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() From 3220b6a5a0829139f81bb9b4aca215da9d792218 Mon Sep 17 00:00:00 2001 From: joaopluigi Date: Mon, 20 Oct 2025 09:04:46 -0300 Subject: [PATCH 08/25] add tests for contexts area --- lua/eca/sidebar.lua | 35 ++-- tests/test_contexts_area.lua | 386 +++++++++++++++++++++++++++++++++++ 2 files changed, 408 insertions(+), 13 deletions(-) create mode 100644 tests/test_contexts_area.lua diff --git a/lua/eca/sidebar.lua b/lua/eca/sidebar.lua index a6a8c5f..f6ac4ad 100644 --- a/lua/eca/sidebar.lua +++ b/lua/eca/sidebar.lua @@ -8,6 +8,7 @@ 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 @@ -411,19 +412,32 @@ function M:_setup_input_events(container) return end - local prefix_row = unpack(vim.api.nvim_buf_get_extmark_by_id(buf, prefix_ns, prefix_id, {})) + local prefix_mark = vim.api.nvim_buf_get_extmark_by_id(buf, prefix_ns, prefix_id, {}) + local prefix_row = unpack(prefix_mark) local contexts_row = 0 - -- If both are in the same row, contexts_row was deleted + 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 "" + + -- Logger.test(lines) + -- Logger.test("first line changed: " .. first) + -- Logger.test(prefix_mark) + if prefix_row == contexts_row then + -- prefix line missing, restore + if contexts_line == contexts_placeholder_line then + -- Logger.test("prefix line missing, restore") + self:_update_input_display() + return + end + + -- we can consider that contexts were deleted + -- Logger.test("contexts cleared") self.mediator:clear_contexts() return end - 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 "" - -- prefix line missing, restore if not prefix_line and contexts_line == contexts_placeholder_line then self:_update_input_display() @@ -669,14 +683,11 @@ function M:_update_input_display(opts) end end - local old_contexts_placeholder_line = self._contexts_placeholder_line - 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 @@ -702,6 +713,8 @@ function M:_update_input_display(opts) 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, @@ -738,10 +751,6 @@ function M:_update_input_display(opts) vim.tbl_extend("force", { virt_text = { { prefix, "Normal" } }, virt_text_pos = "inline", right_gravity = false }, { id = self.extmarks.prefix._id[1] }) ) - -- local prefix_mark = vim.api.nvim_buf_get_extmark_by_id(input.bufnr, self.extmarks.prefix._ns, self.extmarks.prefix._id[1], { details = true }) - -- local prefix_mark = opts and opts.prefix_mark or {} - -- vim.notify(vim.inspect(prefix_mark), vim.log.levels.DEBUG) - -- Set cursor to end of input line if vim.api.nvim_win_is_valid(input.winid) then local row = 1 + (not clear and existing_lines and #existing_lines > 0 and #existing_lines or 1) diff --git a/tests/test_contexts_area.lua b/tests/test_contexts_area.lua new file mode 100644 index 0000000..e8f790f --- /dev/null +++ b/tests/test_contexts_area.lua @@ -0,0 +1,386 @@ +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.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["contexts area"] = MiniTest.new_set() + +T["contexts 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["contexts 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["contexts 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["contexts 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["contexts 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["contexts 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["contexts 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["contexts 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 + +return T From 9881c850e323f876af3a35ecce4109ceb25e6dd5 Mon Sep 17 00:00:00 2001 From: joaopluigi Date: Sat, 25 Oct 2025 17:17:54 -0300 Subject: [PATCH 09/25] start implementing add context in contexts area --- lua/eca/sidebar.lua | 54 +++++++++++++++------------------------------ 1 file changed, 18 insertions(+), 36 deletions(-) diff --git a/lua/eca/sidebar.lua b/lua/eca/sidebar.lua index f6ac4ad..6bef7df 100644 --- a/lua/eca/sidebar.lua +++ b/lua/eca/sidebar.lua @@ -13,7 +13,6 @@ local Split = require("nui.split") ---@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 @@ -48,7 +47,6 @@ 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 @@ -182,7 +180,6 @@ 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 @@ -458,6 +455,20 @@ function M:_setup_input_events(container) end end + -- contexts line modified, restore + if #contexts_line > #self._contexts_placeholder_line then + local placeholders = vim.split(contexts_line, "@", { plain = true, trimempty = false }) + + vim.notify("placeholders: " .. vim.inspect(placeholders), vim.log.levels.DEBUG) + + if #placeholders[#placeholders] < 1 then + self:_update_input_display() + return + end + + return + end + self:_update_input_display() return end @@ -1146,7 +1157,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 {} @@ -1176,8 +1186,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 @@ -1201,7 +1211,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 @@ -1209,7 +1219,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 @@ -1312,7 +1322,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() @@ -1322,7 +1331,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 @@ -1363,32 +1371,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) From 027a1bfa198a75cd17ada70568adfb211e117b2e Mon Sep 17 00:00:00 2001 From: joaopluigi Date: Sun, 16 Nov 2025 11:32:18 -0300 Subject: [PATCH 10/25] wip completion in context area --- lua/eca/completion/context.lua | 4 ++++ lua/eca/sidebar.lua | 31 +++++++++++++++++++++---------- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/lua/eca/completion/context.lua b/lua/eca/completion/context.lua index 401e569..b5c4e25 100644 --- a/lua/eca/completion/context.lua +++ b/lua/eca/completion/context.lua @@ -78,6 +78,10 @@ function M.resolve_completion_item(completion_item, callback) if context_item.type == "file" then completion_item.documentation = documentation(context_item, 20) end + vim.api.nvim_exec_autocmds("User", { + pattern = { "EcaChatContextUpdated" }, + data = completion_item.data.context_item, + }) callback(completion_item) end end diff --git a/lua/eca/sidebar.lua b/lua/eca/sidebar.lua index 6bef7df..06ca3ec 100644 --- a/lua/eca/sidebar.lua +++ b/lua/eca/sidebar.lua @@ -62,6 +62,7 @@ function M.new(id, mediator) } instance._welcome_message_applied = false instance._contexts_placeholder_line = "" + instance._contexts_to_resolve = {} require("eca.observer").subscribe("sidebar-" .. id, function(message) instance:handle_chat_content(message) @@ -188,6 +189,7 @@ function M:reset() self._current_status = "" self._welcome_message_applied = false self._contexts_placeholder_line = "" + self._contexts_to_resolve = {} end function M:new_chat() @@ -379,14 +381,26 @@ end ---@private ---@param container NuiSplit function M:_setup_input_events(container) + vim.api.nvim_create_autocmd("User", { + pattern = { "EcaChatContextUpdated" }, + callback = function(event) + local context_to_resolve = { + path = event.data.path, + name = vim.fn.fnamemodify(event.data.path, ":.") + } + + table.insert(self._contexts_to_resolve, context_to_resolve) + end, + }) + -- prevent contexts line or input prefix from being deleted vim.api.nvim_buf_attach(container.bufnr, false, { on_lines = function(_, buf, _changedtick, first, _last, _new_last, _bytecount) - if first ~= 0 and first ~= 1 then - return - end - vim.schedule(function() + if first ~= 0 and first ~= 1 then + return + end + local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) -- handle empty buffer @@ -417,10 +431,6 @@ function M:_setup_input_events(container) local contexts_line = lines[contexts_row + 1] or nil local contexts_placeholder_line = self._contexts_placeholder_line or "" - -- Logger.test(lines) - -- Logger.test("first line changed: " .. first) - -- Logger.test(prefix_mark) - if prefix_row == contexts_row then -- prefix line missing, restore if contexts_line == contexts_placeholder_line then @@ -459,13 +469,14 @@ function M:_setup_input_events(container) if #contexts_line > #self._contexts_placeholder_line then local placeholders = vim.split(contexts_line, "@", { plain = true, trimempty = false }) - vim.notify("placeholders: " .. vim.inspect(placeholders), vim.log.levels.DEBUG) - if #placeholders[#placeholders] < 1 then self:_update_input_display() return end + vim.notify("Contexts line: " .. contexts_line, vim.log.levels.DEBUG) + vim.notify("Contexts to resolve: " .. vim.inspect(self._contexts_to_resolve), vim.log.levels.DEBUG) + return end From 0c3328602b81a2876e36a74cb020b21410c71af4 Mon Sep 17 00:00:00 2001 From: joaopluigi Date: Mon, 17 Nov 2025 07:10:52 -0300 Subject: [PATCH 11/25] add to contexts when completion on contexts area --- lua/eca/completion/blink/context.lua | 7 ++++ lua/eca/completion/cmp/context.lua | 7 ++++ lua/eca/completion/context.lua | 12 ++++++- lua/eca/sidebar.lua | 51 +++++++++++++++++----------- 4 files changed, 56 insertions(+), 21 deletions(-) diff --git a/lua/eca/completion/blink/context.lua b/lua/eca/completion/blink/context.lua index ba1222e..e9bafce 100644 --- a/lua/eca/completion/blink/context.lua +++ b/lua/eca/completion/blink/context.lua @@ -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..3cf6a75 100644 --- a/lua/eca/completion/cmp/context.lua +++ b/lua/eca/completion/cmp/context.lua @@ -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/context.lua b/lua/eca/completion/context.lua index b5c4e25..0366e0a 100644 --- a/lua/eca/completion/context.lua +++ b/lua/eca/completion/context.lua @@ -78,11 +78,21 @@ function M.resolve_completion_item(completion_item, callback) if context_item.type == "file" then completion_item.documentation = documentation(context_item, 20) end + 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 = { "EcaChatContextUpdated" }, + pattern = { "CompletionItemSelected" }, data = completion_item.data.context_item, }) callback(completion_item) end end + return M diff --git a/lua/eca/sidebar.lua b/lua/eca/sidebar.lua index 06ca3ec..e0b6e2f 100644 --- a/lua/eca/sidebar.lua +++ b/lua/eca/sidebar.lua @@ -62,7 +62,7 @@ function M.new(id, mediator) } instance._welcome_message_applied = false instance._contexts_placeholder_line = "" - instance._contexts_to_resolve = {} + instance._contexts = {} require("eca.observer").subscribe("sidebar-" .. id, function(message) instance:handle_chat_content(message) @@ -189,7 +189,7 @@ function M:reset() self._current_status = "" self._welcome_message_applied = false self._contexts_placeholder_line = "" - self._contexts_to_resolve = {} + self._contexts = {} end function M:new_chat() @@ -382,18 +382,25 @@ end ---@param container NuiSplit function M:_setup_input_events(container) vim.api.nvim_create_autocmd("User", { - pattern = { "EcaChatContextUpdated" }, + pattern = { "CompletionItemSelected" }, callback = function(event) - local context_to_resolve = { - path = event.data.path, - name = vim.fn.fnamemodify(event.data.path, ":.") - } + if not event.data or not event.data.path or not event.data.type then + return + end - table.insert(self._contexts_to_resolve, context_to_resolve) + if self._contexts then + self._contexts.to_add = { + name = vim.fn.fnamemodify(event.data.path, ":."), + type = event.data.type, + data = { + path = event.data.path + } + } + end end, }) - -- prevent contexts line or input prefix from being deleted + -- 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() @@ -434,13 +441,11 @@ function M:_setup_input_events(container) if prefix_row == contexts_row then -- prefix line missing, restore if contexts_line == contexts_placeholder_line then - -- Logger.test("prefix line missing, restore") self:_update_input_display() return end -- we can consider that contexts were deleted - -- Logger.test("contexts cleared") self.mediator:clear_contexts() return end @@ -451,6 +456,14 @@ function M:_setup_input_events(container) 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 @@ -465,18 +478,16 @@ function M:_setup_input_events(container) end end - -- contexts line modified, restore - if #contexts_line > #self._contexts_placeholder_line then - local placeholders = vim.split(contexts_line, "@", { plain = true, trimempty = false }) + -- contexts line modified + if #contexts_line > #self._contexts_placeholder_line then + local placeholders = vim.split(contexts_line, "@", { plain = true, trimempty = true }) - if #placeholders[#placeholders] < 1 then - self:_update_input_display() - return + 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) + end end - vim.notify("Contexts line: " .. contexts_line, vim.log.levels.DEBUG) - vim.notify("Contexts to resolve: " .. vim.inspect(self._contexts_to_resolve), vim.log.levels.DEBUG) - return end From 753881666b2ba68f0668894c8846dd5d77ccf41a Mon Sep 17 00:00:00 2001 From: joaopluigi Date: Mon, 17 Nov 2025 09:20:13 -0300 Subject: [PATCH 12/25] add context path expansion at the input with both @ and # --- lua/eca/completion/blink/context.lua | 6 ++-- lua/eca/completion/cmp/context.lua | 6 ++-- lua/eca/completion/commands.lua | 37 +++++++++++++++-------- lua/eca/completion/context.lua | 45 ++++++++++++++++++---------- lua/eca/mediator.lua | 26 ++++++++++++---- lua/eca/sidebar.lua | 21 ++++++------- 6 files changed, 91 insertions(+), 50 deletions(-) diff --git a/lua/eca/completion/blink/context.lua b/lua/eca/completion/blink/context.lua index e9bafce..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 diff --git a/lua/eca/completion/cmp/context.lua b/lua/eca/completion/cmp/context.lua index 3cf6a75..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() 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 0366e0a..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 @@ -89,7 +101,10 @@ function M.execute(completion_item, callback) if completion_item.data then vim.api.nvim_exec_autocmds("User", { pattern = { "CompletionItemSelected" }, - data = completion_item.data.context_item, + data = { + context_item = completion_item.data.context_item, + label = completion_item.label, + } }) callback(completion_item) end diff --git a/lua/eca/mediator.lua b/lua/eca/mediator.lua index 18c22be..8cd60da 100644 --- a/lua/eca/mediator.lua +++ b/lua/eca/mediator.lua @@ -111,16 +111,30 @@ function mediator:send(method, params, callback) require("eca.logger").notify("Server is not rnning, please start the server", vim.log.levels.WARN) end - local contexts = {} + 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) + 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 - params.contexts = contexts + if params.message and type(params.message) == "string" then + local message = params.message:gsub("([@#])([%w%._%-%/]+)", function(prefix, path) + -- expand ~ + if path:sub(1,1) == "~" then + path = vim.fn.expand(path) + end + return prefix .. vim.fn.fnamemodify(path, ":p") + end) + + params.message = message + end self.server:send_request(method, params, callback) end diff --git a/lua/eca/sidebar.lua b/lua/eca/sidebar.lua index e0b6e2f..296f806 100644 --- a/lua/eca/sidebar.lua +++ b/lua/eca/sidebar.lua @@ -384,16 +384,16 @@ 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.path or not event.data.type then + if not event.data or not event.data.context_item or not event.data.label then return end if self._contexts then self._contexts.to_add = { - name = vim.fn.fnamemodify(event.data.path, ":."), - type = event.data.type, + name = event.data.label, + type = event.data.context_item.type, data = { - path = event.data.path + path = event.data.context_item.path } } end @@ -404,10 +404,6 @@ function M:_setup_input_events(container) vim.api.nvim_buf_attach(container.bufnr, false, { on_lines = function(_, buf, _changedtick, first, _last, _new_last, _bytecount) vim.schedule(function() - if first ~= 0 and first ~= 1 then - return - end - local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) -- handle empty buffer @@ -431,7 +427,10 @@ function M:_setup_input_events(container) end local prefix_mark = vim.api.nvim_buf_get_extmark_by_id(buf, prefix_ns, prefix_id, {}) - local prefix_row = unpack(prefix_mark) + 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 @@ -485,6 +484,7 @@ function M:_setup_input_events(container) 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 @@ -494,6 +494,7 @@ function M:_setup_input_events(container) self:_update_input_display() return end + end) end }) @@ -750,7 +751,7 @@ function M:_update_input_display(opts) for i, context_name in ipairs(contexts_name) do self.extmarks.contexts._id[i] = vim.api.nvim_buf_set_extmark( - input.bufnr, + input.bufnr, self.extmarks.contexts._ns, 0, i, From 4c4248ec52099dafa765ff173557cc148dab6613 Mon Sep 17 00:00:00 2001 From: joaopluigi Date: Mon, 17 Nov 2025 18:15:05 -0300 Subject: [PATCH 13/25] make path replace at sidebar to avoid printing the full path on chat --- lua/eca/mediator.lua | 12 ------------ lua/eca/sidebar.lua | 22 ++++++++++++++++++---- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/lua/eca/mediator.lua b/lua/eca/mediator.lua index 8cd60da..63ebd3e 100644 --- a/lua/eca/mediator.lua +++ b/lua/eca/mediator.lua @@ -124,18 +124,6 @@ function mediator:send(method, params, callback) params.contexts = contexts end - if params.message and type(params.message) == "string" then - local message = params.message:gsub("([@#])([%w%._%-%/]+)", function(prefix, path) - -- expand ~ - if path:sub(1,1) == "~" then - path = vim.fn.expand(path) - end - return prefix .. vim.fn.fnamemodify(path, ":p") - end) - - params.message = message - end - self.server:send_request(method, params, callback) end diff --git a/lua/eca/sidebar.lua b/lua/eca/sidebar.lua index 296f806..9bdec96 100644 --- a/lua/eca/sidebar.lua +++ b/lua/eca/sidebar.lua @@ -1038,14 +1038,27 @@ 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 not type(message) == "string" then + Logger.error("Cannot send empty message") + return + end -- Add user message to chat self:_add_message("user", message) + local replaced = message:gsub("([@#])([%w%._%-%/]+)", function(prefix, path) + -- expand ~ + if path:sub(1,1) == "~" 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(), @@ -1158,6 +1171,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 From 97e0d75d12677027519a0b42ae2c6ad740e9e5c0 Mon Sep 17 00:00:00 2001 From: joaopluigi Date: Mon, 17 Nov 2025 18:57:20 -0300 Subject: [PATCH 14/25] fix tests --- lua/eca/commands.lua | 12 ++++++------ tests/test_contexts_area.lua | 1 + tests/test_select_commands.lua | 1 + 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/lua/eca/commands.lua b/lua/eca/commands.lua index 589e584..fe467e2 100644 --- a/lua/eca/commands.lua +++ b/lua/eca/commands.lua @@ -345,13 +345,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.get() local models = chat.mediator:models() vim.ui.select(models, { @@ -367,13 +367,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.get() local behaviors = chat.mediator:behaviors() vim.ui.select(behaviors, { diff --git a/tests/test_contexts_area.lua b/tests/test_contexts_area.lua index e8f790f..136303b 100644 --- a/tests/test_contexts_area.lua +++ b/tests/test_contexts_area.lua @@ -16,6 +16,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 } _G.get_state = function() 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 From 4435552a17743fe9905e9f89ae9aa338f93cbe89 Mon Sep 17 00:00:00 2001 From: joaopluigi Date: Mon, 1 Dec 2025 11:40:06 -0300 Subject: [PATCH 15/25] refact context commands --- lua/eca/api.lua | 173 ++++++++----------------------------------- lua/eca/commands.lua | 128 ++++++++++++++++---------------- 2 files changed, 93 insertions(+), 208 deletions(-) diff --git a/lua/eca/api.lua b/lua/eca/api.lua index 0006c79..d24b0f0 100644 --- a/lua/eca/api.lua +++ b/lua/eca/api.lua @@ -97,6 +97,36 @@ function M.add_file_context(file_path) 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("No current file to remove as context", vim.log.levels.WARN) + end +end + ---@param directory_path string function M.add_directory_context(directory_path) Logger.info("Adding directory context: " .. directory_path) @@ -218,50 +248,6 @@ function M.clear_contexts() Logger.info("Cleared all contexts") end -function M.remove_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 - - chat.mediator:remove_context(path) - Logger.info("Context removed: " .. 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 -end - ---@return boolean function M.is_server_running() local eca = require("eca") @@ -300,105 +286,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 fe467e2..0bf792b 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,98 @@ 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 - end, { - desc = "Add current selection as context to ECA", - range = true, - }) - - 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("EcaClearContexts", function() - require("eca.api").clear_contexts() + 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 = "Clear all contexts from ECA", + desc = "Add file as context to 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) + Logger.notify("EcaRemoveContext is deprecated. Use EcaChatRemoveFile instead.", vim.log.levels.WARN) + + 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("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 + require("eca.api").remove_current_file_context() + end end, { - desc = "Add repository map context to ECA", + desc = "Remove specific file context from ECA", + nargs = "?", + complete = "file", }) - -- ===== Selected Code Commands ===== + vim.api.nvim_create_user_command("EcaAddSelection", function() + Logger.notify("EcaAddSelection is deprecated. Use EcaChatAddSelection instead.", vim.log.levels.WARN) - vim.api.nvim_create_user_command("EcaShowSelection", function() - require("eca.api").show_selected_code() + -- 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("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 = "Clear selected code from ECA", + desc = "Add current selection as context to ECA", + range = true, }) - -- ===== TODOs Commands ===== + vim.api.nvim_create_user_command("EcaListContexts", function() + Logger.notify("EcaListContexts is deprecated. Use EcaChatListContexts instead.") - 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.") + + 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, { From d06e18af03a24f5aabf0a8eefbfd9f83c7395118 Mon Sep 17 00:00:00 2001 From: joaopluigi Date: Mon, 1 Dec 2025 13:23:46 -0300 Subject: [PATCH 16/25] add tests for context commands --- lua/eca/commands.lua | 4 +- tests/test_context_commands.lua | 345 ++++++++++++++++++++++++++++++++ 2 files changed, 347 insertions(+), 2 deletions(-) create mode 100644 tests/test_context_commands.lua diff --git a/lua/eca/commands.lua b/lua/eca/commands.lua index 0bf792b..df5a81a 100644 --- a/lua/eca/commands.lua +++ b/lua/eca/commands.lua @@ -108,7 +108,7 @@ function M.setup() }) vim.api.nvim_create_user_command("EcaListContexts", function() - Logger.notify("EcaListContexts is deprecated. Use EcaChatListContexts instead.") + Logger.notify("EcaListContexts is deprecated. Use EcaChatListContexts instead.", vim.log.levels.WARN) require("eca.api").list_contexts() end, { @@ -122,7 +122,7 @@ function M.setup() }) vim.api.nvim_create_user_command("EcaClearContexts", function() - Logger.notify("EcaClearContexts is deprecated. Use EcaChatClearContexts instead.") + Logger.notify("EcaClearContexts is deprecated. Use EcaChatClearContexts instead.", vim.log.levels.WARN) require("eca.api").clear_contexts() end, { diff --git a/tests/test_context_commands.lua b/tests/test_context_commands.lua new file mode 100644 index 0000000..9d2f5bc --- /dev/null +++ b/tests/test_context_commands.lua @@ -0,0 +1,345 @@ +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 + ]]) +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["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 From e6e8d14e8ae59138c65e6beb34f528be389a9864 Mon Sep 17 00:00:00 2001 From: joaopluigi Date: Mon, 1 Dec 2025 14:06:25 -0300 Subject: [PATCH 17/25] rename context area test and add one more use case --- ...ontexts_area.lua => test_context_area.lua} | 71 ++++++++++++++++--- 1 file changed, 62 insertions(+), 9 deletions(-) rename tests/{test_contexts_area.lua => test_context_area.lua} (83%) diff --git a/tests/test_contexts_area.lua b/tests/test_context_area.lua similarity index 83% rename from tests/test_contexts_area.lua rename to tests/test_context_area.lua index 136303b..c48fee0 100644 --- a/tests/test_contexts_area.lua +++ b/tests/test_context_area.lua @@ -95,9 +95,9 @@ local function flush(ms) child.api.nvim_eval("1") end -T["contexts area"] = MiniTest.new_set() +T["context area"] = MiniTest.new_set() -T["contexts area"]["deletes all lines"] = function() +T["context area"]["deletes all lines"] = function() flush() local initial = child.lua_get("_G.get_state()") @@ -118,7 +118,7 @@ T["contexts area"]["deletes all lines"] = function() eq(result.contexts, {}) end -T["contexts area"]["deletes the contexts line"] = function() +T["context area"]["deletes the contexts line"] = function() flush() local initial = child.lua_get("_G.get_state()") @@ -139,7 +139,7 @@ T["contexts area"]["deletes the contexts line"] = function() eq(result.contexts, {}) end -T["contexts area"]["deletes the input line"] = function() +T["context area"]["deletes the input line"] = function() flush() local initial = child.lua_get("_G.get_state()") @@ -160,7 +160,7 @@ T["contexts area"]["deletes the input line"] = function() eq(result.contexts, {}) end -T["contexts area"]["keep input text when deleting contexts line"] = function() +T["context area"]["keep input text when deleting contexts line"] = function() flush() local input_text = "text*in<>the > input#preFIX 123456 lIne" @@ -188,7 +188,7 @@ T["contexts area"]["keep input text when deleting contexts line"] = function() eq(result.contexts, {}) end -T["contexts area"]["keep multiple lines text input when removing the first context"] = function() +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" @@ -227,7 +227,7 @@ T["contexts area"]["keep multiple lines text input when removing the first conte eq(result.contexts, {}) end -T["contexts area"]["remove all contexts when deleting the contexts line"] = function() +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" @@ -263,7 +263,7 @@ T["contexts area"]["remove all contexts when deleting the contexts line"] = func eq(result.contexts, {}) end -T["contexts area"]["remove one specific context when multiple contexts are present"] = function() +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" @@ -303,7 +303,7 @@ T["contexts area"]["remove one specific context when multiple contexts are prese eq(result.contexts, { "sidebar.lua ", "server.lua " }) end -T["contexts area"]["remove contexts one by one in an arbitrary order while preserving input"] = function() +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" @@ -384,4 +384,57 @@ T["contexts area"]["remove contexts one by one in an arbitrary order while prese 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 From 2b6a0ec1669db9405e763bde38e0845764e67f9f Mon Sep 17 00:00:00 2001 From: joaopluigi Date: Mon, 1 Dec 2025 14:25:05 -0300 Subject: [PATCH 18/25] add command to add web context --- lua/eca/api.lua | 28 ++++++++++++++++++++++++++++ lua/eca/commands.lua | 17 +++++++++++++++++ lua/eca/config.lua | 1 + lua/eca/sidebar.lua | 12 +++++++++++- 4 files changed, 57 insertions(+), 1 deletion(-) diff --git a/lua/eca/api.lua b/lua/eca/api.lua index d24b0f0..d061a67 100644 --- a/lua/eca/api.lua +++ b/lua/eca/api.lua @@ -156,6 +156,34 @@ function M.add_directory_context(directory_path) 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() local current_file = vim.api.nvim_buf_get_name(0) if current_file and current_file ~= "" then diff --git a/lua/eca/commands.lua b/lua/eca/commands.lua index df5a81a..481511f 100644 --- a/lua/eca/commands.lua +++ b/lua/eca/commands.lua @@ -107,6 +107,23 @@ function M.setup() range = true, }) + 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 = "Add URL as web context to ECA", + }) + vim.api.nvim_create_user_command("EcaListContexts", function() Logger.notify("EcaListContexts is deprecated. Use EcaChatListContexts instead.", vim.log.levels.WARN) 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/sidebar.lua b/lua/eca/sidebar.lua index 9bdec96..8ec2409 100644 --- a/lua/eca/sidebar.lua +++ b/lua/eca/sidebar.lua @@ -706,7 +706,17 @@ function M:_update_input_display(opts) break end - local name = vim.fn.fnamemodify(path, ":t") + 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 From eb177de099829a823a009258283bc08db000a977 Mon Sep 17 00:00:00 2001 From: joaopluigi Date: Mon, 1 Dec 2025 14:33:19 -0300 Subject: [PATCH 19/25] add tests for EcaChatAddUrl command --- tests/test_context_commands.lua | 70 +++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/tests/test_context_commands.lua b/tests/test_context_commands.lua index 9d2f5bc..4ccb8e7 100644 --- a/tests/test_context_commands.lua +++ b/tests/test_context_commands.lua @@ -65,6 +65,15 @@ local function setup_test_environment() _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 @@ -256,6 +265,67 @@ T["EcaAddSelection"]["shows deprecation notice when called"] = function() 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() From 0caa6ef156d3edcba0bc6e64ba4d9d6251328b67 Mon Sep 17 00:00:00 2001 From: joaopluigi Date: Mon, 1 Dec 2025 16:23:26 -0300 Subject: [PATCH 20/25] update usage docs regarding contexts --- docs/usage.md | 154 ++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 132 insertions(+), 22 deletions(-) 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) ``` From 95661474dad1d33f11632d39d12b8c10885ca448 Mon Sep 17 00:00:00 2001 From: joaopluigi Date: Mon, 1 Dec 2025 16:29:23 -0300 Subject: [PATCH 21/25] update plugin-spec --- plugin-spec.lua | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) 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", }, } From 838219216e239e658fad929284c25ab16ea354ec Mon Sep 17 00:00:00 2001 From: joaopluigi Date: Mon, 1 Dec 2025 17:22:44 -0300 Subject: [PATCH 22/25] fix server not running typo at mediator --- lua/eca/mediator.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/eca/mediator.lua b/lua/eca/mediator.lua index 63ebd3e..5816e48 100644 --- a/lua/eca/mediator.lua +++ b/lua/eca/mediator.lua @@ -108,7 +108,7 @@ 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 From f28d19188d42667792099fb7d38e8c85d575ff67 Mon Sep 17 00:00:00 2001 From: joaopluigi Date: Mon, 1 Dec 2025 17:23:06 -0300 Subject: [PATCH 23/25] add context trigger regex as constant to utils --- lua/eca/utils.lua | 1 + 1 file changed, 1 insertion(+) diff --git a/lua/eca/utils.lua b/lua/eca/utils.lua index 7a46ac8..5870b38 100644 --- a/lua/eca/utils.lua +++ b/lua/eca/utils.lua @@ -7,6 +7,7 @@ local M = {} local CONSTANTS = { SIDEBAR_FILETYPE = "Eca", SIDEBAR_BUFFER_NAME = "__ECA__", + CONTEXT_TRIGGER_REGEX = "[@#]([%w%./_\\%-~]*)", } ---@param bufnr integer From 837c7745292d48979283b767da4d0331e160b968 Mon Sep 17 00:00:00 2001 From: joaopluigi Date: Mon, 1 Dec 2025 17:39:11 -0300 Subject: [PATCH 24/25] revert utils --- lua/eca/utils.lua | 1 - 1 file changed, 1 deletion(-) diff --git a/lua/eca/utils.lua b/lua/eca/utils.lua index 5870b38..7a46ac8 100644 --- a/lua/eca/utils.lua +++ b/lua/eca/utils.lua @@ -7,7 +7,6 @@ local M = {} local CONSTANTS = { SIDEBAR_FILETYPE = "Eca", SIDEBAR_BUFFER_NAME = "__ECA__", - CONTEXT_TRIGGER_REGEX = "[@#]([%w%./_\\%-~]*)", } ---@param bufnr integer From a8aa9262965029a38a6ca4098f668262a6649f3d Mon Sep 17 00:00:00 2001 From: joaopluigi Date: Mon, 1 Dec 2025 17:40:00 -0300 Subject: [PATCH 25/25] pr review changes --- lua/eca/sidebar.lua | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lua/eca/sidebar.lua b/lua/eca/sidebar.lua index 8ec2409..d803a78 100644 --- a/lua/eca/sidebar.lua +++ b/lua/eca/sidebar.lua @@ -797,8 +797,8 @@ function M:_update_input_display(opts) -- Set cursor to end of input line if vim.api.nvim_win_is_valid(input.winid) then - 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) + 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 @@ -1048,7 +1048,7 @@ end ---@param message string function M:_send_message(message) - if not message or not type(message) == "string" then + if not message or type(message) ~= "string" then Logger.error("Cannot send empty message") return end @@ -1056,9 +1056,9 @@ function M:_send_message(message) -- Add user message to chat self:_add_message("user", message) - local replaced = message:gsub("([@#])([%w%._%-%/]+)", function(prefix, path) + local replaced = message:gsub("([@#])([%w%._%-%/\\]+)", function(prefix, path) -- expand ~ - if path:sub(1,1) == "~" then + if vim.startswith(path, "~") then path = vim.fn.expand(path) end return prefix .. vim.fn.fnamemodify(path, ":p")