From 2388fdfdfb1f585357e13675adf4447c9f14e3eb Mon Sep 17 00:00:00 2001 From: joaopluigi Date: Thu, 11 Dec 2025 13:51:08 -0300 Subject: [PATCH 01/31] fix search on EcaServerMessages --- lua/eca/commands.lua | 40 +++++++++++++++++++++++++++++++--------- 1 file changed, 31 insertions(+), 9 deletions(-) diff --git a/lua/eca/commands.lua b/lua/eca/commands.lua index 481511f..76c1474 100644 --- a/lua/eca/commands.lua +++ b/lua/eca/commands.lua @@ -193,15 +193,37 @@ function M.setup() end for msg in vim.iter(eca.server.messages) do - local decoded = vim.json.decode(msg.content) - table.insert(items, { - text = decoded.method, - idx = decoded.id, - preview = { - text = vim.inspect(decoded), - ft = "lua", - }, - }) + local ok, decoded = pcall(vim.json.decode, msg.content) + if ok and type(decoded) == "table" then + local parts = {} + + if msg.direction then + table.insert(parts, string.format("[%s]", tostring(msg.direction))) + end + + if decoded.method then + table.insert(parts, tostring(decoded.method)) + end + + if decoded.id ~= nil then + table.insert(parts, string.format("#%s", tostring(decoded.id))) + end + + local text = table.concat(parts, " ") + if text == "" then + -- Fallback to a compact JSON representation so matcher always has a string + text = vim.json.encode(decoded) + end + + table.insert(items, { + text = text, + idx = decoded.id or msg.id or (#items + 1), + preview = { + text = vim.inspect(decoded), + ft = "lua", + }, + }) + end end return items end, From 249b5135c9b06125a1abf4c2d250afd8bb75723b Mon Sep 17 00:00:00 2001 From: joaopluigi Date: Thu, 11 Dec 2025 13:51:29 -0300 Subject: [PATCH 02/31] add EcaServerTools command --- lua/eca/commands.lua | 59 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/lua/eca/commands.lua b/lua/eca/commands.lua index 76c1474..21daf30 100644 --- a/lua/eca/commands.lua +++ b/lua/eca/commands.lua @@ -424,6 +424,65 @@ function M.setup() desc = "Select current ECA Chat behavior", }) + vim.api.nvim_create_user_command("EcaServerTools", function() + local has_snacks, snacks = pcall(require, "snacks") + if not has_snacks then + Logger.notify("snacks.nvim is not available", vim.log.levels.ERROR) + return + end + + snacks.picker( + ---@type snacks.picker.Config + { + source = "eca tools", + finder = function(_, _) + ---@type snacks.picker.finder.Item[] + local items = {} + local eca = require("eca") + if not eca or not eca.state then + Logger.notify("ECA state is not available", vim.log.levels.ERROR) + return items + end + + local tools = eca.state.tools or {} + if not tools or vim.tbl_isempty(tools) then + Logger.notify("No tools registered in server state", vim.log.levels.INFO) + return items + end + + -- Collect and sort tool names for stable ordering + local names = vim.tbl_keys(tools) + table.sort(names) + + for _, name in ipairs(names) do + local tool = tools[name] or {} + local type_ = tool.type or "unknown" + local status = tool.status or "unknown" + + table.insert(items, { + text = name, + idx = name, + preview = { + text = vim.inspect(tool), + ft = "lua", + }, + }) + end + + return items + end, + preview = "preview", + format = "text", + confirm = function(self, item, _) + vim.fn.setreg("", item.preview.text) + self:close() + end, + } + ) + end, { + desc = "Display ECA server tools (yank preview on confirm)", + }) + Logger.debug("ECA commands registered") end From 50f20cecd64dd37892a4790a6662bc8265e0971f Mon Sep 17 00:00:00 2001 From: joaopluigi Date: Thu, 11 Dec 2025 13:52:02 -0300 Subject: [PATCH 03/31] fix mcp display info --- lua/eca/sidebar.lua | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/lua/eca/sidebar.lua b/lua/eca/sidebar.lua index d803a78..cac6f21 100644 --- a/lua/eca/sidebar.lua +++ b/lua/eca/sidebar.lua @@ -890,24 +890,42 @@ function M:_update_config_display() local behavior = self.mediator:selected_behavior() or "unknown" local mcps = self.mediator:mcps() - local mcps_hl = "Normal" + local registered_count = vim.tbl_count(mcps) + local starting_count = 0 + local running_count = 0 + local has_failed = false for _, mcp in pairs(mcps) do if mcp.status == "starting" then - mcps_hl = "Comment" - break + starting_count = starting_count + 1 + elseif mcp.status == "running" then + running_count = running_count + 1 end if mcp.status == "failed" then - mcps_hl = "Exception" - break + has_failed = true end end + -- Active MCPs include both starting and running + local active_count = starting_count + running_count + + -- While any MCP is still starting, dim the active count + local active_hl = "Normal" + if starting_count > 0 then + active_hl = "Comment" + end + + local registered_hl = "Normal" + if has_failed then + registered_hl = "Exception" -- highlight registered count in red when any MCP failed + end + local texts = { { "model:", "Comment" }, { model, "Normal" }, { " " }, { "behavior:", "Comment" }, { behavior, "Normal" }, { " " }, - { "mcps:", "Comment" }, { tostring(vim.tbl_count(mcps)), mcps_hl }, + { "mcps:", "Comment" }, { tostring(active_count), active_hl }, { "/", "Comment" }, + { tostring(registered_count), registered_hl }, } local virt_opts = { virt_text = texts, virt_text_pos = "overlay", hl_mode = "combine" } From c4ee96ae62f293731f6f8781a50f5220bdc1d546 Mon Sep 17 00:00:00 2001 From: joaopluigi Date: Thu, 11 Dec 2025 13:52:21 -0300 Subject: [PATCH 04/31] fix tool call name when calling MCP tools --- lua/eca/sidebar.lua | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/lua/eca/sidebar.lua b/lua/eca/sidebar.lua index cac6f21..a977088 100644 --- a/lua/eca/sidebar.lua +++ b/lua/eca/sidebar.lua @@ -1146,7 +1146,7 @@ function M:handle_chat_content_received(params) -- Show the accumulated tool call self:_display_tool_call(content) elseif content.type == "toolCalled" then - local tool_text = (content.summary or "Tool call") + local tool_text = self:_tool_call_text(content) -- Add diff to current tool call if present in toolCalled content if self._current_tool_call and content.details then @@ -1528,14 +1528,36 @@ function M:_handle_tool_call_prepare(content) end end +function M:_tool_call_text(content) + if content.summary and content.summary ~= "" then + return content.summary + end + + if self._current_tool_call.summary and self._current_tool_call.summary ~= "" then + return self._current_tool_call.summary + end + + if content.name and content.name ~= "" then + return content.name + end + + if self._current_tool_call.name and self._current_tool_call.name ~= "" then + return self._current_tool_call.name + end + + return "Tool call" +end + function M:_display_tool_call(content) if not self._is_tool_call_streaming or not self._current_tool_call then return nil end + local tool_name = self:_tool_call_text(content) + local diff = "" - local tool_text = "🔧 " .. (content.summary or self._current_tool_call.summary or "Tool call") - local tool_log = string.format("**Tool Call**: %s", self._current_tool_call.name or "unknown") + local tool_text = "🔧 " .. tool_name + local tool_log = string.format("**Tool Call**: %s", tool_name or "unknown") if self._current_tool_call.arguments and self._current_tool_call.arguments ~= "" then tool_log = tool_log .. "\n```json\n" .. self._current_tool_call.arguments .. "\n```" From 9fae90963bad2c1260f93d4ab2354acfe87e7113 Mon Sep 17 00:00:00 2001 From: joaopluigi Date: Fri, 12 Dec 2025 09:36:22 -0300 Subject: [PATCH 05/31] add expandable tool call --- lua/eca/config.lua | 9 ++ lua/eca/sidebar.lua | 339 +++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 332 insertions(+), 16 deletions(-) diff --git a/lua/eca/config.lua b/lua/eca/config.lua index be7e300..012a1b3 100644 --- a/lua/eca/config.lua +++ b/lua/eca/config.lua @@ -73,6 +73,15 @@ M._defaults = { start_insert = true, -- Start insert mode when opening the edit window }, }, + icons = { + tool_call = { + success = "✅", -- Shown when a tool call succeeds + error = "❌", -- Shown when a tool call fails + running = "⏳", -- Shown while a tool call is running / has no final status yet + expanded = "▲", -- Arrow when the tool call details are expanded + collapsed = "▶", -- Arrow when the tool call details are collapsed + }, + }, } ---@type eca.Config diff --git a/lua/eca/sidebar.lua b/lua/eca/sidebar.lua index a977088..48b3ef4 100644 --- a/lua/eca/sidebar.lua +++ b/lua/eca/sidebar.lua @@ -35,6 +35,18 @@ local WINDOW_MARGIN = 3 -- Additional margin for window borders and spacing local UI_ELEMENTS_HEIGHT = 2 -- Reserve space for statusline and tabline local SAFETY_MARGIN = 2 -- Extra margin to prevent "Not enough room" errors +-- Tool call icons (can be overridden via Config.icons.tool_call) +local function get_tool_call_icons() + local icons_cfg = (Config.icons and Config.icons.tool_call) or {} + return { + success = icons_cfg.success or "✅", + error = icons_cfg.error or "❌", + running = icons_cfg.running or "⏳", + expanded = icons_cfg.expanded or "▲", + collapsed = icons_cfg.collapsed or "▶", + } +end + ---@param id integer Tab ID ---@param mediator eca.Mediator ---@return eca.Sidebar @@ -63,6 +75,7 @@ function M.new(id, mediator) instance._welcome_message_applied = false instance._contexts_placeholder_line = "" instance._contexts = {} + instance._tool_calls = {} require("eca.observer").subscribe("sidebar-" .. id, function(message) instance:handle_chat_content(message) @@ -190,6 +203,7 @@ function M:reset() self._welcome_message_applied = false self._contexts_placeholder_line = "" self._contexts = {} + self._tool_calls = {} end function M:new_chat() @@ -366,6 +380,8 @@ function M:_setup_container_events(container, name) if name == "input" then self:_setup_input_events(container) self:_setup_input_keymaps(container) + elseif name == "chat" then + self:_setup_chat_keymaps(container) end end @@ -513,6 +529,15 @@ function M:_setup_input_keymaps(container) end, { noremap = true, silent = true }) end +---@private +---@param container NuiSplit +function M:_setup_chat_keymaps(container) + -- Toggle tool call details when pressing on a tool call line + container:map("n", "", function() + self:_toggle_tool_call_at_cursor() + end, { noremap = true, silent = true }) +end + ---@private function M:_update_container_sizes() if not self:is_open() then @@ -1153,7 +1178,7 @@ function M:handle_chat_content_received(params) self._current_tool_call.details = content.details end - -- Show the tool result + -- Show the tool result in logs only local tool_log = string.format("**Tool Result**: %s", content.name or "unknown") if content.outputs and #content.outputs > 0 then for _, output in ipairs(content.outputs) do @@ -1164,17 +1189,58 @@ function M:handle_chat_content_received(params) end Logger.debug(tool_log) - local tool_text_completed = "✅ " - + -- Determine completion status icon (configurable) + local icons = get_tool_call_icons() + local status_icon = icons.success if content.error then - tool_text_completed = "❌ " + status_icon = icons.error end - local tool_text_running = "🔧 " .. tool_text - tool_text_completed = tool_text_completed .. tool_text + -- Ensure tool calls table exists + self._tool_calls = self._tool_calls or {} - if tool_text == nil or not self:_replace_text(tool_text_running, tool_text_completed) then - self:_add_message("assistant", tool_text_completed) + -- Try to find an existing tool call entry for this id + local call = self:_find_tool_call_by_id(content.id) + + if call then + -- Update details and status for an existing call + if content.details then + call.details = content.details + call.details_lines = self:_build_tool_call_details_lines(call.arguments, call.details) + call.details_line_count = call.details_lines and #call.details_lines or 0 + end + + call.status = status_icon + call.title = tool_text or call.title + + -- Update the header line to move the checkmark/error icon to the end + self:_update_tool_call_header_line(call) + else + -- Create a new entry for tool calls that didn't have a running phase + local details = content.details or {} + local arguments = self._current_tool_call and self._current_tool_call.arguments or "" + local details_lines = self:_build_tool_call_details_lines(arguments, details) + + call = { + id = content.id, + title = tool_text or (content.name or "Tool call"), + header_line = nil, + expanded = false, + status = status_icon, + arguments = arguments, + details = details, + details_lines = details_lines, + details_line_count = details_lines and #details_lines or 0, + } + + local chat = self.containers.chat + if chat and vim.api.nvim_buf_is_valid(chat.bufnr) then + local before_line_count = vim.api.nvim_buf_line_count(chat.bufnr) + local header_text = self:_build_tool_call_header_text(call) + self:_add_message("assistant", header_text) + call.header_line = before_line_count + 1 + table.insert(self._tool_calls, call) + end end -- Clean up tool call state @@ -1503,6 +1569,7 @@ function M:_handle_tool_call_prepare(content) if not self._is_tool_call_streaming then self._is_tool_call_streaming = true self._current_tool_call = { + id = content.id, name = "", summary = "", arguments = "", @@ -1511,6 +1578,10 @@ function M:_handle_tool_call_prepare(content) end -- Accumulate tool call data + if content.id then + self._current_tool_call.id = content.id + end + if content.name then self._current_tool_call.name = content.name end @@ -1533,7 +1604,7 @@ function M:_tool_call_text(content) return content.summary end - if self._current_tool_call.summary and self._current_tool_call.summary ~= "" then + if self._current_tool_call and self._current_tool_call.summary and self._current_tool_call.summary ~= "" then return self._current_tool_call.summary end @@ -1541,7 +1612,7 @@ function M:_tool_call_text(content) return content.name end - if self._current_tool_call.name and self._current_tool_call.name ~= "" then + if self._current_tool_call and self._current_tool_call.name and self._current_tool_call.name ~= "" then return self._current_tool_call.name end @@ -1553,10 +1624,12 @@ function M:_display_tool_call(content) return nil end - local tool_name = self:_tool_call_text(content) + local chat = self.containers.chat + if not chat or not vim.api.nvim_buf_is_valid(chat.bufnr) then + return nil + end - local diff = "" - local tool_text = "🔧 " .. tool_name + local tool_name = self:_tool_call_text(content) local tool_log = string.format("**Tool Call**: %s", tool_name or "unknown") if self._current_tool_call.arguments and self._current_tool_call.arguments ~= "" then @@ -1564,11 +1637,52 @@ function M:_display_tool_call(content) end if self._current_tool_call.details and self._current_tool_call.details.diff then - diff = "\n\n**Diff**:\n```diff\n" .. self._current_tool_call.details.diff .. "\n```" + tool_log = tool_log .. "\n\n**Diff**:\n```diff\n" .. self._current_tool_call.details.diff .. "\n```" end - Logger.debug(tool_log .. diff) - self:_add_message("assistant", tool_text .. diff) + Logger.debug(tool_log) + + -- Ensure tool calls table exists + self._tool_calls = self._tool_calls or {} + + -- Try to find an existing entry for this tool call + local existing_call = nil + if self._current_tool_call.id then + existing_call = self:_find_tool_call_by_id(self._current_tool_call.id) + end + + -- Build details lines from current state + local details_lines = self:_build_tool_call_details_lines(self._current_tool_call.arguments, self._current_tool_call.details) + + if existing_call then + -- Update details for existing call (do not add another header) + existing_call.arguments = self._current_tool_call.arguments or existing_call.arguments + existing_call.details = self._current_tool_call.details or existing_call.details + existing_call.details_lines = details_lines + existing_call.details_line_count = #details_lines + return + end + + -- Create a new tool call entry and header (collapsed by default) + local header_title = tool_name or "Tool call" + local call = { + id = self._current_tool_call.id or content.id, + title = header_title, + header_line = nil, + expanded = false, + status = nil, + arguments = self._current_tool_call.arguments or "", + details = self._current_tool_call.details or {}, + details_lines = details_lines, + details_line_count = #details_lines, + } + + local before_line_count = vim.api.nvim_buf_line_count(chat.bufnr) + local header_text = self:_build_tool_call_header_text(call) + self:_add_message("assistant", header_text) + call.header_line = before_line_count + 1 + + table.insert(self._tool_calls, call) end function M:_finalize_tool_call() @@ -1576,6 +1690,199 @@ function M:_finalize_tool_call() self._is_tool_call_streaming = false end +-- Find existing tool call entry by id +function M:_find_tool_call_by_id(id) + if not self._tool_calls or not id then + return nil + end + + for _, call in ipairs(self._tool_calls) do + if call.id == id then + return call + end + end + + return nil +end + +-- Find the tool call that owns a given buffer line +function M:_find_tool_call_for_line(line) + if not self._tool_calls then + return nil + end + + for _, call in ipairs(self._tool_calls) do + if call.header_line then + local start_line = call.header_line + local details_count = call.details_lines and #call.details_lines or 0 + local end_line = start_line + (call.expanded and details_count or 0) + if line >= start_line and line <= end_line then + return call + end + end + end + + return nil +end + +-- Build the header text for a tool call like: "▶ summary ⏳" (or "▶ summary ✅" / "▶ summary ❌") +function M:_build_tool_call_header_text(call) + local title = call.title or "Tool call" + local icons = get_tool_call_icons() + local arrow = call.expanded and icons.expanded or icons.collapsed + local status = call.status or icons.running + local parts = { arrow, title, status } + + return table.concat(parts, " ") +end + +-- Build the detail lines (arguments + diff) for a tool call +function M:_build_tool_call_details_lines(arguments, details) + local lines = {} + + if arguments and arguments ~= "" then + table.insert(lines, "```json") + for _, line in ipairs(Utils.split_lines(arguments)) do + table.insert(lines, line) + end + table.insert(lines, "```") + table.insert(lines, "") + end + + local diff = details and details.diff or nil + if diff and diff ~= "" then + table.insert(lines, "Diff:") + table.insert(lines, "```diff") + for _, line in ipairs(Utils.split_lines(diff)) do + table.insert(lines, line) + end + table.insert(lines, "```") + table.insert(lines, "") + end + + return lines +end + +-- Update the visual header line for a tool call +function M:_update_tool_call_header_line(call) + local chat = self.containers.chat + if not chat or not vim.api.nvim_buf_is_valid(chat.bufnr) or not call.header_line then + return + end + + local header_text = self:_build_tool_call_header_text(call) + + self:_safe_buffer_update(chat.bufnr, function() + vim.api.nvim_buf_set_lines(chat.bufnr, call.header_line - 1, call.header_line, false, { header_text }) + end) +end + +-- Adjust header_line for tool calls that come after the given one +function M:_adjust_tool_call_lines(changed_call, delta) + if not self._tool_calls or delta == 0 then + return + end + + for _, call in ipairs(self._tool_calls) do + if call ~= changed_call and call.header_line and changed_call.header_line and call.header_line > changed_call.header_line then + call.header_line = call.header_line + delta + end + end +end + +-- Expand a tool call, inserting its details below the header +function M:_expand_tool_call(call) + local chat = self.containers.chat + if not chat or not vim.api.nvim_buf_is_valid(chat.bufnr) then + return + end + + if call.expanded then + return + end + + call.details_lines = call.details_lines or self:_build_tool_call_details_lines(call.arguments, call.details) + local count = call.details_lines and #call.details_lines or 0 + if count == 0 then + return + end + + self:_safe_buffer_update(chat.bufnr, function() + -- Insert details immediately after the header (before the following blank line) + vim.api.nvim_buf_set_lines(chat.bufnr, call.header_line, call.header_line, false, call.details_lines) + end) + + call.expanded = true + + -- Shift subsequent tool call header lines down + self:_adjust_tool_call_lines(call, count) + + -- Update header arrow + self:_update_tool_call_header_line(call) + + -- Move cursor to show the full details block + if chat.winid and vim.api.nvim_win_is_valid(chat.winid) then + local last_line = call.header_line + count + vim.api.nvim_win_set_cursor(chat.winid, { last_line, 0 }) + vim.api.nvim_win_call(chat.winid, function() + vim.cmd("normal! zb") + end) + end +end + +-- Collapse a tool call, removing its details from the buffer +function M:_collapse_tool_call(call) + local chat = self.containers.chat + if not chat or not vim.api.nvim_buf_is_valid(chat.bufnr) then + return + end + + if not call.expanded then + return + end + + local count = call.details_lines and #call.details_lines or 0 + if count == 0 then + call.expanded = false + self:_update_tool_call_header_line(call) + return + end + + self:_safe_buffer_update(chat.bufnr, function() + vim.api.nvim_buf_set_lines(chat.bufnr, call.header_line, call.header_line + count, false, {}) + end) + + call.expanded = false + + -- Shift subsequent tool call header lines back up + self:_adjust_tool_call_lines(call, -count) + + -- Update header arrow + self:_update_tool_call_header_line(call) +end + +-- Toggle tool call details at the current cursor position in the chat window +function M:_toggle_tool_call_at_cursor() + local chat = self.containers.chat + if not chat or not vim.api.nvim_win_is_valid(chat.winid) then + return + end + + local cursor = vim.api.nvim_win_get_cursor(chat.winid) + local line = cursor[1] + + local call = self:_find_tool_call_for_line(line) + if not call then + return + end + + if call.expanded then + self:_collapse_tool_call(call) + else + self:_expand_tool_call(call) + end +end + ---@param target string ---@param replacement string ---@param opts? table|nil Optional search options: { max_search_lines = number, start_line = number } From 1303a8271080db76aa6aa244d6c5c8c360510164 Mon Sep 17 00:00:00 2001 From: joaopluigi Date: Fri, 12 Dec 2025 09:36:34 -0300 Subject: [PATCH 06/31] fix MCP display --- lua/eca/sidebar.lua | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lua/eca/sidebar.lua b/lua/eca/sidebar.lua index 48b3ef4..e043df2 100644 --- a/lua/eca/sidebar.lua +++ b/lua/eca/sidebar.lua @@ -944,6 +944,9 @@ function M:_update_config_display() local registered_hl = "Normal" if has_failed then registered_hl = "Exception" -- highlight registered count in red when any MCP failed + elseif active_hl == "Comment" then + -- While MCPs are still starting, dim the total count as well + registered_hl = "Comment" end local texts = { From 765269be3c32393ade401b19d63bb7045abfb6af Mon Sep 17 00:00:00 2001 From: joaopluigi Date: Fri, 12 Dec 2025 10:01:35 -0300 Subject: [PATCH 07/31] add view diff feature --- lua/eca/config.lua | 4 + lua/eca/sidebar.lua | 308 +++++++++++++++++++++++++++++++++++++++----- 2 files changed, 283 insertions(+), 29 deletions(-) diff --git a/lua/eca/config.lua b/lua/eca/config.lua index 012a1b3..6233e3f 100644 --- a/lua/eca/config.lua +++ b/lua/eca/config.lua @@ -82,6 +82,10 @@ M._defaults = { collapsed = "▶", -- Arrow when the tool call details are collapsed }, }, + tool_call = { + ---@type string + diff_label = "view diff", -- Label used for tool call diffs + }, } ---@type eca.Config diff --git a/lua/eca/sidebar.lua b/lua/eca/sidebar.lua index e043df2..719a06e 100644 --- a/lua/eca/sidebar.lua +++ b/lua/eca/sidebar.lua @@ -47,6 +47,12 @@ local function get_tool_call_icons() } end +-- Label used for tool call diffs ([+ label] / [- label]) +local function get_tool_call_diff_label() + local cfg = Config.tool_call or {} + return cfg.diff_label or "view diff" +end + ---@param id integer Tab ID ---@param mediator eca.Mediator ---@return eca.Sidebar @@ -1209,8 +1215,16 @@ function M:handle_chat_content_received(params) -- Update details and status for an existing call if content.details then call.details = content.details - call.details_lines = self:_build_tool_call_details_lines(call.arguments, call.details) - call.details_line_count = call.details_lines and #call.details_lines or 0 + call.has_diff = self:_has_details_diff(call.details) + call.arguments_lines = self:_build_tool_call_arguments_lines(call.arguments) + call.diff_lines = self:_build_tool_call_diff_lines(call.details) + call.details_lines = nil + call.details_line_count = 0 + + -- If this call now has a diff and doesn't yet have a label line, add it + if call.has_diff and not call.label_line and not call.expanded then + self:_insert_tool_call_diff_label_line(call) + end end call.status = status_icon @@ -1222,18 +1236,26 @@ function M:handle_chat_content_received(params) -- Create a new entry for tool calls that didn't have a running phase local details = content.details or {} local arguments = self._current_tool_call and self._current_tool_call.arguments or "" - local details_lines = self:_build_tool_call_details_lines(arguments, details) + local arguments_lines = self:_build_tool_call_arguments_lines(arguments) + local diff_lines = self:_build_tool_call_diff_lines(details) + local has_diff = self:_has_details_diff(details) call = { id = content.id, title = tool_text or (content.name or "Tool call"), header_line = nil, - expanded = false, + expanded = false, -- controls argument visibility + diff_expanded = false, -- controls diff visibility status = status_icon, arguments = arguments, details = details, - details_lines = details_lines, - details_line_count = details_lines and #details_lines or 0, + has_diff = has_diff, + label_line = nil, + -- Separate storage for arguments vs diff so each can be toggled independently + arguments_lines = arguments_lines, + diff_lines = diff_lines, + details_lines = nil, + details_line_count = 0, } local chat = self.containers.chat @@ -1242,6 +1264,11 @@ function M:handle_chat_content_received(params) local header_text = self:_build_tool_call_header_text(call) self:_add_message("assistant", header_text) call.header_line = before_line_count + 1 + + if call.has_diff then + self:_insert_tool_call_diff_label_line(call) + end + table.insert(self._tool_calls, call) end end @@ -1654,15 +1681,31 @@ function M:_display_tool_call(content) existing_call = self:_find_tool_call_by_id(self._current_tool_call.id) end - -- Build details lines from current state - local details_lines = self:_build_tool_call_details_lines(self._current_tool_call.arguments, self._current_tool_call.details) + -- Build detail lines from current state (arguments and diff are controlled separately) + local arguments_lines = self:_build_tool_call_arguments_lines(self._current_tool_call.arguments) + local diff_lines = self:_build_tool_call_diff_lines(self._current_tool_call.details) + local has_diff = self:_has_details_diff(self._current_tool_call.details) if existing_call then -- Update details for existing call (do not add another header) existing_call.arguments = self._current_tool_call.arguments or existing_call.arguments existing_call.details = self._current_tool_call.details or existing_call.details - existing_call.details_lines = details_lines - existing_call.details_line_count = #details_lines + existing_call.has_diff = has_diff + existing_call.arguments_lines = arguments_lines + existing_call.diff_lines = diff_lines + existing_call.details_lines = nil + existing_call.details_line_count = 0 + + -- Reset diff visibility when we get new diff content + if not has_diff then + existing_call.diff_expanded = false + end + + -- If this call now has a diff and doesn't yet have a label line, add it + if has_diff and not existing_call.label_line and not existing_call.expanded then + self:_insert_tool_call_diff_label_line(existing_call) + end + return end @@ -1672,12 +1715,18 @@ function M:_display_tool_call(content) id = self._current_tool_call.id or content.id, title = header_title, header_line = nil, - expanded = false, + expanded = false, -- controls argument visibility + diff_expanded = false, -- controls diff visibility status = nil, arguments = self._current_tool_call.arguments or "", details = self._current_tool_call.details or {}, - details_lines = details_lines, - details_line_count = #details_lines, + has_diff = has_diff, + label_line = nil, + -- Store arguments and diff lines separately so they can be toggled independently + arguments_lines = arguments_lines, + diff_lines = diff_lines, + details_lines = nil, + details_line_count = 0, } local before_line_count = vim.api.nvim_buf_line_count(chat.bufnr) @@ -1685,6 +1734,10 @@ function M:_display_tool_call(content) self:_add_message("assistant", header_text) call.header_line = before_line_count + 1 + if call.has_diff then + self:_insert_tool_call_diff_label_line(call) + end + table.insert(self._tool_calls, call) end @@ -1717,8 +1770,26 @@ function M:_find_tool_call_for_line(line) for _, call in ipairs(self._tool_calls) do if call.header_line then local start_line = call.header_line - local details_count = call.details_lines and #call.details_lines or 0 - local end_line = start_line + (call.expanded and details_count or 0) + local end_line = start_line + + -- Include argument block when expanded + local arg_count = call.arguments_lines and #call.arguments_lines or 0 + if call.expanded and arg_count > 0 then + end_line = end_line + arg_count + end + + -- Include label line and optional diff block when present + if call.has_diff and call.label_line then + local label_end = call.label_line + local diff_count = call.diff_lines and #call.diff_lines or 0 + if call.diff_expanded and diff_count > 0 then + label_end = label_end + diff_count + end + if label_end > end_line then + end_line = label_end + end + end + if line >= start_line and line <= end_line then return call end @@ -1739,8 +1810,8 @@ function M:_build_tool_call_header_text(call) return table.concat(parts, " ") end --- Build the detail lines (arguments + diff) for a tool call -function M:_build_tool_call_details_lines(arguments, details) +-- Build the argument detail lines (tool call arguments only) +function M:_build_tool_call_arguments_lines(arguments) local lines = {} if arguments and arguments ~= "" then @@ -1752,9 +1823,15 @@ function M:_build_tool_call_details_lines(arguments, details) table.insert(lines, "") end + return lines +end + +-- Build the diff detail lines (tool call diff only) +function M:_build_tool_call_diff_lines(details) + local lines = {} + local diff = details and details.diff or nil if diff and diff ~= "" then - table.insert(lines, "Diff:") table.insert(lines, "```diff") for _, line in ipairs(Utils.split_lines(diff)) do table.insert(lines, line) @@ -1766,6 +1843,85 @@ function M:_build_tool_call_details_lines(arguments, details) return lines end +-- Optional helper to build combined detail lines (arguments + diff) +-- NOTE: callers that need independent control over arguments vs diff +-- should prefer _build_tool_call_arguments_lines/_build_tool_call_diff_lines. +function M:_build_tool_call_details_lines(arguments, details) + local lines = {} + + local arg_lines = self:_build_tool_call_arguments_lines(arguments) + for _, line in ipairs(arg_lines) do + table.insert(lines, line) + end + + local diff_lines = self:_build_tool_call_diff_lines(details) + for _, line in ipairs(diff_lines) do + table.insert(lines, line) + end + + return lines +end + +-- Check if tool call details contain a diff +function M:_has_details_diff(details) + return details and type(details) == "table" and details.diff and details.diff ~= "" +end + +-- Build the label text shown below the tool call summary when a diff is available +function M:_build_tool_call_diff_label_text(call) + local label = get_tool_call_diff_label() + + -- Use different indicators depending on diff expanded/collapsed state + if call and call.diff_expanded then + return "[- " .. label .. "]" + end + + return "[+ " .. label .. "]" +end + +-- Update the existing "view diff" label line to reflect the current expanded/collapsed state +function M:_update_tool_call_diff_label_line(call) + local chat = self.containers.chat + if not chat or not vim.api.nvim_buf_is_valid(chat.bufnr) then + return + end + + if not call or not call.label_line then + return + end + + local label_text = self:_build_tool_call_diff_label_text(call) + + self:_safe_buffer_update(chat.bufnr, function() + vim.api.nvim_buf_set_lines(chat.bufnr, call.label_line - 1, call.label_line, false, { label_text }) + end) +end + +-- Insert the "view diff" label line directly below the tool call summary +function M:_insert_tool_call_diff_label_line(call) + local chat = self.containers.chat + if not chat or not vim.api.nvim_buf_is_valid(chat.bufnr) then + return + end + + if not call or not call.header_line or call.label_line or not call.has_diff then + return + end + + local label_text = self:_build_tool_call_diff_label_text(call) + + self:_safe_buffer_update(chat.bufnr, function() + -- Insert label immediately after the header line + vim.api.nvim_buf_set_lines(chat.bufnr, call.header_line, call.header_line, false, { label_text }) + end) + + -- Track the label line (1-based) + call.label_line = call.header_line + 1 + + -- Shift subsequent tool call headers/labels down by one line + self:_adjust_tool_call_lines(call, 1) +end + -- Update the visual header line for a tool call function M:_update_tool_call_header_line(call) local chat = self.containers.chat @@ -1780,7 +1936,7 @@ function M:_update_tool_call_header_line(call) end) end --- Adjust header_line for tool calls that come after the given one +-- Adjust header_line (and optional label_line) for tool calls that come after the given one function M:_adjust_tool_call_lines(changed_call, delta) if not self._tool_calls or delta == 0 then return @@ -1790,10 +1946,14 @@ function M:_adjust_tool_call_lines(changed_call, delta) if call ~= changed_call and call.header_line and changed_call.header_line and call.header_line > changed_call.header_line then call.header_line = call.header_line + delta end + + if call ~= changed_call and call.label_line and changed_call.header_line and call.label_line > changed_call.header_line then + call.label_line = call.label_line + delta + end end end --- Expand a tool call, inserting its details below the header +-- Expand a tool call's arguments, inserting them below the header function M:_expand_tool_call(call) local chat = self.containers.chat if not chat or not vim.api.nvim_buf_is_valid(chat.bufnr) then @@ -1804,26 +1964,31 @@ function M:_expand_tool_call(call) return end - call.details_lines = call.details_lines or self:_build_tool_call_details_lines(call.arguments, call.details) - local count = call.details_lines and #call.details_lines or 0 + call.arguments_lines = call.arguments_lines or self:_build_tool_call_arguments_lines(call.arguments) + local count = call.arguments_lines and #call.arguments_lines or 0 if count == 0 then return end self:_safe_buffer_update(chat.bufnr, function() - -- Insert details immediately after the header (before the following blank line) - vim.api.nvim_buf_set_lines(chat.bufnr, call.header_line, call.header_line, false, call.details_lines) + -- Insert arguments immediately after the header line + vim.api.nvim_buf_set_lines(chat.bufnr, call.header_line, call.header_line, false, call.arguments_lines) end) + -- If there is a diff label, it must move down by the number of inserted lines + if call.has_diff and call.label_line then + call.label_line = call.label_line + count + end + call.expanded = true - -- Shift subsequent tool call header lines down + -- Shift subsequent tool call header/label lines down self:_adjust_tool_call_lines(call, count) -- Update header arrow self:_update_tool_call_header_line(call) - -- Move cursor to show the full details block + -- Move cursor to show the full arguments block if chat.winid and vim.api.nvim_win_is_valid(chat.winid) then local last_line = call.header_line + count vim.api.nvim_win_set_cursor(chat.winid, { last_line, 0 }) @@ -1833,7 +1998,7 @@ function M:_expand_tool_call(call) end end --- Collapse a tool call, removing its details from the buffer +-- Collapse a tool call's arguments, removing them from the buffer function M:_collapse_tool_call(call) local chat = self.containers.chat if not chat or not vim.api.nvim_buf_is_valid(chat.bufnr) then @@ -1844,7 +2009,7 @@ function M:_collapse_tool_call(call) return end - local count = call.details_lines and #call.details_lines or 0 + local count = call.arguments_lines and #call.arguments_lines or 0 if count == 0 then call.expanded = false self:_update_tool_call_header_line(call) @@ -1852,19 +2017,92 @@ function M:_collapse_tool_call(call) end self:_safe_buffer_update(chat.bufnr, function() + -- Remove the arguments block directly below the header vim.api.nvim_buf_set_lines(chat.bufnr, call.header_line, call.header_line + count, false, {}) end) + -- If there is a diff label, move it back up + if call.has_diff and call.label_line then + call.label_line = call.label_line - count + end + call.expanded = false - -- Shift subsequent tool call header lines back up + -- Shift subsequent tool call header/label lines back up self:_adjust_tool_call_lines(call, -count) -- Update header arrow self:_update_tool_call_header_line(call) end +-- Expand a tool call's diff, inserting it below the diff label +function M:_expand_tool_call_diff(call) + local chat = self.containers.chat + if not chat or not vim.api.nvim_buf_is_valid(chat.bufnr) then + return + end + + if not call.has_diff or not call.label_line or call.diff_expanded then + return + end + + call.diff_lines = call.diff_lines or self:_build_tool_call_diff_lines(call.details) + local count = call.diff_lines and #call.diff_lines or 0 + if count == 0 then + return + end + + self:_safe_buffer_update(chat.bufnr, function() + -- Insert diff lines immediately after the diff label line + vim.api.nvim_buf_set_lines(chat.bufnr, call.label_line, call.label_line, false, call.diff_lines) + end) + + call.diff_expanded = true + + -- Shift subsequent tool call header/label lines down + self:_adjust_tool_call_lines(call, count) + + -- Update the diff label to show the collapse indicator + self:_update_tool_call_diff_label_line(call) +end + +-- Collapse a tool call's diff, removing it from the buffer +function M:_collapse_tool_call_diff(call) + local chat = self.containers.chat + if not chat or not vim.api.nvim_buf_is_valid(chat.bufnr) then + return + end + + if not call.diff_expanded then + return + end + + local count = call.diff_lines and #call.diff_lines or 0 + if count == 0 then + call.diff_expanded = false + self:_update_tool_call_diff_label_line(call) + return + end + + self:_safe_buffer_update(chat.bufnr, function() + -- Remove the diff block that starts immediately after the label line + vim.api.nvim_buf_set_lines(chat.bufnr, call.label_line, call.label_line + count, false, {}) + end) + + call.diff_expanded = false + + -- Shift subsequent tool call header/label lines back up + self:_adjust_tool_call_lines(call, -count) + + -- Update the diff label to show the expand indicator again + self:_update_tool_call_diff_label_line(call) +end + -- Toggle tool call details at the current cursor position in the chat window +-- +-- When a tool call has a diff available, the header toggle (arrow) controls +-- visibility of the tool arguments, while the "view diff" label controls +-- visibility of the diff only. function M:_toggle_tool_call_at_cursor() local chat = self.containers.chat if not chat or not vim.api.nvim_win_is_valid(chat.winid) then @@ -1879,6 +2117,18 @@ function M:_toggle_tool_call_at_cursor() return end + -- If we are on or below the diff label for a call that has a diff, + -- toggle only the diff block. + if call.has_diff and call.label_line and line >= call.label_line then + if call.diff_expanded then + self:_collapse_tool_call_diff(call) + else + self:_expand_tool_call_diff(call) + end + return + end + + -- Otherwise toggle the arguments block via the header arrow if call.expanded then self:_collapse_tool_call(call) else From 9e9e87a04034dd44941c77c520ec4e4af58d539a Mon Sep 17 00:00:00 2001 From: joaopluigi Date: Fri, 12 Dec 2025 10:01:44 -0300 Subject: [PATCH 08/31] update README.md --- README.md | 38 ++++++++++++-------------------------- 1 file changed, 12 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 2a0449d..c1c7c7d 100644 --- a/README.md +++ b/README.md @@ -1,54 +1,40 @@ -# 🤖 ECA Neovim Plugin +# ECA Neovim Plugin demo -A modern Neovim plugin that integrates [ECA (Editor Code Assistant)](https://eca.dev/) directly into the editor for an intuitive, streaming AI experience. +A lightweight Neovim plugin that embeds [ECA (Editor Code Assistant)](https://eca.dev/) directly into your editor. It is designed to be very simple, while remaining highly customizable. -## ✨ Features -- 🤖 Integrated AI chat in Neovim -- 📁 Add files, directories and selections as context -- 🚀 Automatic ECA server download and start -- 🎨 Clean sidebar UI with Markdown rendering -- ⌨️ Intuitive defaults (Ctrl+S to send, Enter for newline) -- 🔧 Highly configurable windows, keymaps and behavior -- 📊 Usage and status feedback +## Quick Start + +> Requires Neovim >= 0.8.0, curl and unzip. -## ⚡ Quick Start 1. Install via your plugin manager (see Installation below) -2. Restart Neovim -3. Run `:EcaChat` or press `ec` -4. Type your message and press `Ctrl+S` -5. Add context with `:EcaAddFile` or `:EcaAddSelection` +2. Run `:EcaChat` or press `ec` +3. Type your message and press `Ctrl+S` -> Requires Neovim >= 0.8.0, curl and unzip. -## 📚 Documentation +## Documentation - [Installation and system requirements](./docs/installation.md) - [Usage guide (commands, keymaps, tips)](./docs/usage.md) - [Configuration reference and presets](./docs/configuration.md) - [Troubleshooting common issues](./docs/troubleshooting.md) - [Development & contributing](./docs/development.md) -## 🔗 Useful Links +## Useful Links - [Official ECA Website](https://eca.dev/) - [ECA Documentation](https://docs.eca.dev/) - [VS Code Plugin](https://marketplace.visualstudio.com/items?itemName=editor-code-assistant.eca-vscode) - [ECA GitHub](https://github.com/editor-code-assistant) -## 📄 License +## License Apache License 2.0 — see [LICENSE](LICENSE) for details. -## 🙏 Acknowledgments -Inspired by: -- [avante.nvim](https://github.com/yetone/avante.nvim) — base structure and UI concepts -- [eca-vscode](https://github.com/editor-code-assistant/eca-vscode) — ECA server integration - ---
-✨ Made with ❤️ for the Neovim community ✨ +Made for the Neovim community -[⭐ Give a star if this plugin was useful!](https://github.com/editor-code-assistant/eca-nvim) +[Give a star if this plugin was useful](https://github.com/editor-code-assistant/eca-nvim)
From c20c6bcef7ea54df470944563c134f040fe7a800 Mon Sep 17 00:00:00 2001 From: joaopluigi Date: Fri, 12 Dec 2025 11:30:33 -0300 Subject: [PATCH 09/31] make server messages be searchable in EcaServeMessages command --- lua/eca/commands.lua | 110 ++++++++++++++++++++++++++++++------------- 1 file changed, 78 insertions(+), 32 deletions(-) diff --git a/lua/eca/commands.lua b/lua/eca/commands.lua index 21daf30..11e3223 100644 --- a/lua/eca/commands.lua +++ b/lua/eca/commands.lua @@ -1,5 +1,6 @@ local Utils = require("eca.utils") local Logger = require("eca.logger") +local Picker = require("eca.ui.picker") local M = {} @@ -173,18 +174,10 @@ function M.setup() }) vim.api.nvim_create_user_command("EcaServerMessages", function() - local has_snacks, snacks = pcall(require, "snacks") - if not has_snacks then - Logger.notify("snacks.nvim is not available", vim.log.levels.ERROR) - return - end - - snacks.picker( - ---@type snacks.picker.Config + Picker.pick( { source = "eca messages", - finder = function(opts, ctx) - ---@type snacks.picker.finder.Item[] + finder = function(opts, _) local items = {} local eca = require("eca") if not eca or not eca.server then @@ -192,6 +185,10 @@ function M.setup() return items end + -- First pass: collect messages so we can render them with + -- a fixed header width and keep the separator in a constant column. + local entries = {} + for msg in vim.iter(eca.server.messages) do local ok, decoded = pcall(vim.json.decode, msg.content) if ok and type(decoded) == "table" then @@ -209,22 +206,81 @@ function M.setup() table.insert(parts, string.format("#%s", tostring(decoded.id))) end - local text = table.concat(parts, " ") - if text == "" then - -- Fallback to a compact JSON representation so matcher always has a string - text = vim.json.encode(decoded) + local header = table.concat(parts, " ") + local preview_text = vim.inspect(decoded) or "" + + -- Flatten whitespace so searching works on a single line + local flat_preview = "" + if preview_text ~= "" then + flat_preview = preview_text:gsub("%s+", " ") + end + + local json_text = "" + local ok_json, encoded = pcall(vim.json.encode, decoded) + if ok_json and type(encoded) == "string" and encoded ~= "" then + json_text = encoded end - table.insert(items, { - text = text, - idx = decoded.id or msg.id or (#items + 1), - preview = { - text = vim.inspect(decoded), - ft = "lua", - }, + table.insert(entries, { + header = header, + flat_preview = flat_preview, + preview_text = preview_text, + json_text = json_text, + id = decoded.id or msg.id, }) end end + + if #entries == 0 then + return items + end + + -- Second pass: build display items with a fixed header width so that + -- the separator and body always start in the same column. + local separator = " | " + local header_width = 40 -- column where the header area ends + + for idx, entry in ipairs(entries) do + local header = entry.header or "" + local preview_text = entry.preview_text or "" + local flat_preview = entry.flat_preview or "" + + -- Truncate overly long headers so the separator stays fixed. + local display_header = header + if #display_header > header_width then + display_header = display_header:sub(1, header_width - 1) + end + + -- Pad headers (or empty ones) up to header_width so the + -- first character of the separator is always in the same column. + local padding = math.max(0, header_width - #display_header) + local padded_header = display_header .. string.rep(" ", padding) + + local text = padded_header + if flat_preview ~= "" then + text = padded_header .. separator .. flat_preview + end + + if text == "" then + if preview_text ~= "" then + text = preview_text:gsub("%s+", " ") + elseif entry.json_text and entry.json_text ~= "" then + text = entry.json_text + else + text = "" + end + end + + table.insert(items, { + text = text, + idx = entry.id or idx, + preview = { + text = preview_text, + ft = "lua", + }, + }) + end + return items end, preview = "preview", @@ -425,18 +481,10 @@ function M.setup() }) vim.api.nvim_create_user_command("EcaServerTools", function() - local has_snacks, snacks = pcall(require, "snacks") - if not has_snacks then - Logger.notify("snacks.nvim is not available", vim.log.levels.ERROR) - return - end - - snacks.picker( - ---@type snacks.picker.Config + Picker.pick( { source = "eca tools", finder = function(_, _) - ---@type snacks.picker.finder.Item[] local items = {} local eca = require("eca") if not eca or not eca.state then @@ -456,8 +504,6 @@ function M.setup() for _, name in ipairs(names) do local tool = tools[name] or {} - local type_ = tool.type or "unknown" - local status = tool.status or "unknown" table.insert(items, { text = name, From b7999b9ddfdd96e876b802dae8e8a58d82d7f300 Mon Sep 17 00:00:00 2001 From: joaopluigi Date: Fri, 12 Dec 2025 11:30:48 -0300 Subject: [PATCH 10/31] abstract picker --- lua/eca/ui/picker.lua | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 lua/eca/ui/picker.lua diff --git a/lua/eca/ui/picker.lua b/lua/eca/ui/picker.lua new file mode 100644 index 0000000..1f82388 --- /dev/null +++ b/lua/eca/ui/picker.lua @@ -0,0 +1,18 @@ +local Logger = require("eca.logger") + +local M = {} + +--- Wrapper around snacks.picker to provide a common entrypoint +--- for ECA pickers and handle the snacks dependency consistently. +---@param config snacks.picker.Config +function M.pick(config) + local has_snacks, snacks = pcall(require, "snacks") + if not has_snacks then + Logger.notify("snacks.nvim is not available", vim.log.levels.ERROR) + return + end + + return snacks.picker(config) +end + +return M From bb89c05c3764b552fbafb01022886d4e6f6d1584 Mon Sep 17 00:00:00 2001 From: joaopluigi Date: Fri, 12 Dec 2025 11:31:01 -0300 Subject: [PATCH 11/31] add thinking behavior --- lua/eca/config.lua | 26 +-- lua/eca/highlights.lua | 3 + lua/eca/sidebar.lua | 419 +++++++++++++++++++++++++++++++++++++++-- 3 files changed, 422 insertions(+), 26 deletions(-) diff --git a/lua/eca/config.lua b/lua/eca/config.lua index 6233e3f..ca7b78e 100644 --- a/lua/eca/config.lua +++ b/lua/eca/config.lua @@ -54,6 +54,19 @@ M._defaults = { "Type your message and use CTRL+s to send", -- Tips appended under the welcome (set empty list {} to disable) }, }, + tool_call = { + icons = { + success = "✅", -- Shown when a tool call succeeds + error = "❌", -- Shown when a tool call fails + running = "⏳", -- Shown while a tool call is running / has no final status yet + expanded = "▲", -- Arrow when the tool call details are expanded + collapsed = "▶", -- Arrow when the tool call details are collapsed + }, + diff_label = { + collapsed = "+ view diff", -- Label when the diff is collapsed + expanded = "- view diff", -- Label when the diff is expanded + }, + }, }, windows = { wrap = true, @@ -73,19 +86,6 @@ M._defaults = { start_insert = true, -- Start insert mode when opening the edit window }, }, - icons = { - tool_call = { - success = "✅", -- Shown when a tool call succeeds - error = "❌", -- Shown when a tool call fails - running = "⏳", -- Shown while a tool call is running / has no final status yet - expanded = "▲", -- Arrow when the tool call details are expanded - collapsed = "▶", -- Arrow when the tool call details are collapsed - }, - }, - tool_call = { - ---@type string - diff_label = "view diff", -- Label used for tool call diffs - }, } ---@type eca.Config diff --git a/lua/eca/highlights.lua b/lua/eca/highlights.lua index b48c9ac..37c6143 100644 --- a/lua/eca/highlights.lua +++ b/lua/eca/highlights.lua @@ -12,6 +12,9 @@ function M.setup() vim.api.nvim_set_hl(0, "EcaSuccess", { fg = "#9ece6a", bg = "#2b3b2e" }) vim.api.nvim_set_hl(0, "EcaWarning", { fg = "#e0af68", bg = "#3d3a2b" }) vim.api.nvim_set_hl(0, "EcaInfo", { fg = "#7dcfff", bg = "#2b3a3d" }) + vim.api.nvim_set_hl(0, "EcaToolCall", { link = "Title" }) + vim.api.nvim_set_hl(0, "EcaHyperlink", { link = "Underlined", underline = true }) + vim.api.nvim_set_hl(0, "EcaUsage", { link = "Comment" }) end return M diff --git a/lua/eca/sidebar.lua b/lua/eca/sidebar.lua index 719a06e..e3e0f7c 100644 --- a/lua/eca/sidebar.lua +++ b/lua/eca/sidebar.lua @@ -1,7 +1,7 @@ local Utils = require("eca.utils") local Logger = require("eca.logger") local Config = require("eca.config") - +ca -- Load nui.nvim components (required dependency) local Split = require("nui.split") @@ -25,6 +25,7 @@ local Split = require("nui.split") ---@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 +---@field private _reasons table Map of in-flight reasoning entries keyed by id local M = {} M.__index = M @@ -35,9 +36,9 @@ local WINDOW_MARGIN = 3 -- Additional margin for window borders and spacing local UI_ELEMENTS_HEIGHT = 2 -- Reserve space for statusline and tabline local SAFETY_MARGIN = 2 -- Extra margin to prevent "Not enough room" errors --- Tool call icons (can be overridden via Config.icons.tool_call) +-- Tool call icons (can be overridden via Config.chat.tool_call.icons) local function get_tool_call_icons() - local icons_cfg = (Config.icons and Config.icons.tool_call) or {} + local icons_cfg = (Config.chat and Config.chat.tool_call and Config.chat.tool_call.icons) or {} return { success = icons_cfg.success or "✅", error = icons_cfg.error or "❌", @@ -47,10 +48,26 @@ local function get_tool_call_icons() } end --- Label used for tool call diffs ([+ label] / [- label]) -local function get_tool_call_diff_label() - local cfg = Config.tool_call or {} - return cfg.diff_label or "view diff" +-- Label texts used for tool call diffs. +-- +-- Configuration (under `Config.chat.tool_call`): +-- - `diff_label.collapsed`: text when diff is collapsed (default: "+ view diff") +-- - `diff_label.expanded`: text when diff is expanded (default: "- view diff") +-- +-- Backwards compatibility: +-- - `diff_label_collapsed` / `diff_label_expanded` (flat keys) are still +-- honored if the nested table is not provided. +local function get_tool_call_diff_labels() + local cfg = (Config.chat and Config.chat.tool_call) or {} + local labels_cfg = cfg.diff_label or {} + + local collapsed = labels_cfg.collapsed or "+ view diff" + local expanded = labels_cfg.expanded or "- view diff" + + return { + collapsed = collapsed, + expanded = expanded, + } end ---@param id integer Tab ID @@ -82,6 +99,7 @@ function M.new(id, mediator) instance._contexts_placeholder_line = "" instance._contexts = {} instance._tool_calls = {} + instance._reasons = {} require("eca.observer").subscribe("sidebar-" .. id, function(message) instance:handle_chat_content(message) @@ -210,6 +228,7 @@ function M:reset() self._contexts_placeholder_line = "" self._contexts = {} self._tool_calls = {} + self._reasons = {} end function M:new_chat() @@ -359,7 +378,7 @@ function M:_create_containers() modifiable = false, }), win_options = vim.tbl_deep_extend("force", base_win_options, { - winhighlight = "Normal:Comment", + winhighlight = "Normal:EcaUsage", statusline = " ", }), }) @@ -1275,6 +1294,12 @@ function M:handle_chat_content_received(params) -- Clean up tool call state self:_finalize_tool_call() + elseif content.type == "reasonStarted" then + self:_handle_reason_started(content) + elseif content.type == "reasonText" then + self:_handle_reason_text(content) + elseif content.type == "reasonFinished" then + self:_handle_reason_finished(content) end end @@ -1421,6 +1446,9 @@ function M:_update_streaming_message(content) if not success then Logger.notify("Error updating buffer: " .. tostring(err), vim.log.levels.ERROR) else + -- Reapply highlights for existing tool calls and reasoning blocks, + -- since full-buffer updates can drop extmark-based styling. + self:_reapply_tool_call_highlights() -- Auto-scroll to bottom during streaming to follow the text self:_scroll_to_bottom() end @@ -1483,6 +1511,10 @@ function M:_add_message(role, content) -- Auto-scroll to bottom after adding new message self:_scroll_to_bottom() end) + + -- After appending a new message, previously highlighted tool calls and + -- reasoning blocks may lose their extmark-based styling, so reapply it. + self:_reapply_tool_call_highlights() end function M:_finalize_streaming_response() @@ -1734,6 +1766,9 @@ function M:_display_tool_call(content) self:_add_message("assistant", header_text) call.header_line = before_line_count + 1 + -- Apply header highlight (tool call vs reasoning) + self:_highlight_tool_call_header(call) + if call.has_diff then self:_insert_tool_call_diff_label_line(call) end @@ -1746,6 +1781,174 @@ function M:_finalize_tool_call() self._is_tool_call_streaming = false end +-- ===== Reasoning ("Thinking") handling ===== + +-- Create a new reasoning entry that behaves like a tool call +function M:_handle_reason_started(content) + local id = content.id + if not id then + return + end + + local chat = self.containers.chat + if not chat or not vim.api.nvim_buf_is_valid(chat.bufnr) then + return + end + + self._reasons = self._reasons or {} + self._tool_calls = self._tool_calls or {} + + -- If a new reasoning starts while another one is still "running", + -- mark the previous one as finished so only one active "Thinking" + -- block is shown at a time. + for existing_id, existing_call in pairs(self._reasons) do + if existing_id ~= id + and existing_call + and existing_call.status == nil + -- Only auto-convert entries that are *currently* showing + -- the running label, so we don't clobber completed + -- entries like "Thought 1.23 s" when a new reasoning + -- block starts later. + and existing_call.title == "Thinking..." then + -- For reasoning entries we don't show status icons; instead we just + -- update the label from "Thinking..." to "Thought". + existing_call.title = "Thought" + existing_call.status = nil + self:_update_tool_call_header_line(existing_call) + end + end + + -- Avoid creating duplicates for the same reasoning id + if self._reasons[id] then + return + end + + local call = { + id = id, + title = "Thinking...", -- fixed summary label while reasoning is running + header_line = nil, + expanded = false, -- controls visibility of reasoning text + diff_expanded = false, -- unused for reasoning + status = nil, -- unused for reasoning headers; no status icons + arguments = "", -- we reuse arguments as the accumulated reasoning text + details = {}, + has_diff = false, + label_line = nil, + arguments_lines = {}, + diff_lines = {}, + details_lines = nil, + details_line_count = 0, + is_reason = true, + } + + local before_line_count = vim.api.nvim_buf_line_count(chat.bufnr) + local header_text = self:_build_tool_call_header_text(call) + self:_add_message("assistant", header_text) + call.header_line = before_line_count + 1 + + -- Apply header highlight (reasoning entries use Comment) + self:_highlight_tool_call_header(call) + + -- Track this reasoning both in a dedicated map and in the generic tool_calls + self._reasons[id] = call + table.insert(self._tool_calls, call) +end + +-- Append streamed reasoning text and update the expanded block (if open) +function M:_handle_reason_text(content) + local id = content.id + if not id or not content.text then + return + end + + self._reasons = self._reasons or {} + local call = self._reasons[id] + if not call then + -- If we somehow receive text without a start, create the entry lazily + self:_handle_reason_started({ id = id }) + call = self._reasons[id] + if not call then + return + end + end + + local chat = self.containers.chat + if not chat or not vim.api.nvim_buf_is_valid(chat.bufnr) then + return + end + + -- Accumulate raw reasoning text + call.arguments = (call.arguments or "") .. content.text + + -- Build markdown quote block for the reasoning body + call.arguments_lines = {} + + local lines = Utils.split_lines(call.arguments or "") + for index, line in ipairs(lines) do + line = line ~= "" and line or " " + if index == 1 then + -- First line uses markdown blockquote prefix + table.insert(call.arguments_lines, "> " .. line) + else + -- Subsequent lines are indented to align with the first line's content + table.insert(call.arguments_lines, " " .. line) + end + end + + -- Trailing blank line for spacing + table.insert(call.arguments_lines, "") + + -- If the reasoning block is expanded, update its region in the buffer in-place + if call.expanded then + local prev_count = call._last_arguments_count or 0 + local new_count = #call.arguments_lines + + self:_safe_buffer_update(chat.bufnr, function() + if prev_count == 0 and new_count > 0 then + -- Insert for the first time immediately after the header + vim.api.nvim_buf_set_lines(chat.bufnr, call.header_line, call.header_line, false, call.arguments_lines) + -- Shift subsequent tool calls down by the inserted line count + self:_adjust_tool_call_lines(call, new_count) + else + -- Replace existing block; adjust subsequent calls only if size changed + vim.api.nvim_buf_set_lines(chat.bufnr, call.header_line, call.header_line + prev_count, false, call.arguments_lines) + if new_count ~= prev_count then + self:_adjust_tool_call_lines(call, new_count - prev_count) + end + end + end) + + call._last_arguments_count = new_count + end +end + +-- Mark reasoning as finished and update the header icon +function M:_handle_reason_finished(content) + local id = content.id + if not id then + return + end + + self._reasons = self._reasons or {} + local call = self._reasons[id] + if not call then + return + end + + -- When reasoning is finished, update the label from "Thinking..." to + -- "Thought s" when totalTimeMs is available. + local total_ms = tonumber(content.totalTimeMs) + if total_ms and total_ms > 0 then + local seconds = total_ms / 1000 + call.title = string.format("Thought %.2f s", seconds) + else + call.title = "Thought" + end + + call.status = nil + self:_update_tool_call_header_line(call) +end + -- Find existing tool call entry by id function M:_find_tool_call_by_id(id) if not self._tool_calls or not id then @@ -1801,9 +2004,17 @@ end -- Build the header text for a tool call like: "▶ summary ⏳" (or "▶ summary ✅" / "▶ summary ❌") function M:_build_tool_call_header_text(call) - local title = call.title or "Tool call" local icons = get_tool_call_icons() local arrow = call.expanded and icons.expanded or icons.collapsed + + -- Reasoning ("Thinking") entries do not show status icons; they only + -- display the toggle arrow and a text label ("Thinking..." / "Thought"). + if call.is_reason then + local title = call.title or "Thinking..." + return table.concat({ arrow, title }, " ") + end + + local title = call.title or "Tool call" local status = call.status or icons.running local parts = { arrow, title, status } @@ -1869,14 +2080,141 @@ end -- Build the label text shown below the tool call summary when a diff is available function M:_build_tool_call_diff_label_text(call) - local label = get_tool_call_diff_label() + local labels = get_tool_call_diff_labels() - -- Use different indicators depending on diff expanded/collapsed state + -- Use different texts depending on diff expanded/collapsed state if call and call.diff_expanded then - return "[- " .. label .. "]" + return labels.expanded + end + + return labels.collapsed +end + +-- Helper to choose sidebar highlight groups in a theme-aware way +local function _eca_sidebar_hl(kind) + if kind == "tool_header" then + return "EcaToolCall" + elseif kind == "reason_header" then + return "EcaUsage" + elseif kind == "diff_label" then + return "EcaHyperlink" + end + + return "Normal" +end + +-- Highlight a tool call header line (summary / reasoning label) +-- +-- * Regular tool calls use a dedicated highlight group (EcaToolCall by default) +-- * Reasoning entries ("Thinking..." / "Thought ...") are highlighted differently +-- and adapt to light/dark themes via _eca_sidebar_hl() +function M:_highlight_tool_call_header(call) + local chat = self.containers.chat + if not chat or not vim.api.nvim_buf_is_valid(chat.bufnr) then + return + end + + if not call or not call.header_line then + return + end + + self.extmarks = self.extmarks or {} + if not self.extmarks.tool_header then + self.extmarks.tool_header = { + _ns = vim.api.nvim_create_namespace("extmarks_tool_header"), + _id = {}, + } + end + + local ns = self.extmarks.tool_header._ns + local key = call.id or tostring(call.header_line) + + -- Clear any previous highlight for this call + if self.extmarks.tool_header._id[key] then + pcall(vim.api.nvim_buf_del_extmark, chat.bufnr, ns, self.extmarks.tool_header._id[key]) + end + + local line = vim.api.nvim_buf_get_lines(chat.bufnr, call.header_line - 1, call.header_line, false)[1] or "" + local end_col = #line + + local hl_group + if call.is_reason then + hl_group = _eca_sidebar_hl("reason_header") + else + hl_group = _eca_sidebar_hl("tool_header") + end + + -- Add an extmark that highlights the entire header line + self.extmarks.tool_header._id[key] = vim.api.nvim_buf_set_extmark(chat.bufnr, ns, call.header_line - 1, 0, { + hl_group = hl_group, + end_col = end_col, + priority = 180, + }) +end + +-- Highlight the "view diff" label as a hyperlink-style element +function M:_highlight_tool_call_diff_label_line(call) + local chat = self.containers.chat + if not chat or not vim.api.nvim_buf_is_valid(chat.bufnr) then + return + end + + if not call or not call.label_line then + return + end + + self.extmarks = self.extmarks or {} + if not self.extmarks.diff_label then + self.extmarks.diff_label = { + _ns = vim.api.nvim_create_namespace("extmarks_diff_label"), + _id = {}, + } + end + + local ns = self.extmarks.diff_label._ns + local key = call.id or tostring(call.header_line) + + -- Clear any previous highlight for this call + if self.extmarks.diff_label._id[key] then + pcall(vim.api.nvim_buf_del_extmark, chat.bufnr, ns, self.extmarks.diff_label._id[key]) + end + + -- Determine how much of the line to highlight (the whole label text) + local line = vim.api.nvim_buf_get_lines(chat.bufnr, call.label_line - 1, call.label_line, false)[1] or "" + local end_col = #line + + -- Add an extmark that highlights the label text using a theme-aware group + -- Use a high priority so it wins over Treesitter/markdown highlights. + self.extmarks.diff_label._id[key] = vim.api.nvim_buf_set_extmark(chat.bufnr, ns, call.label_line - 1, 0, { + hl_group = _eca_sidebar_hl("diff_label"), + end_col = end_col, + priority = 200, + }) +end + +-- Reapply header and diff label highlights after full-buffer updates +-- (e.g. when streaming assistant responses or appending new messages). +-- This ensures previously expanded tool calls and reasoning blocks +-- keep their visual styling instead of "resetting". +function M:_reapply_tool_call_highlights() + if not self._tool_calls or vim.tbl_isempty(self._tool_calls) then + return end - return "[+ " .. label .. "]" + local chat = self.containers.chat + if not chat or not vim.api.nvim_buf_is_valid(chat.bufnr) then + return + end + + for _, call in ipairs(self._tool_calls) do + if call.header_line then + self:_highlight_tool_call_header(call) + end + + if call.has_diff and call.label_line then + self:_highlight_tool_call_diff_label_line(call) + end + end end -- Update the existing "view diff" label line to reflect the current expanded/collapsed state @@ -1895,6 +2233,9 @@ function M:_update_tool_call_diff_label_line(call) self:_safe_buffer_update(chat.bufnr, function() vim.api.nvim_buf_set_lines(chat.bufnr, call.label_line - 1, call.label_line, false, { label_text }) end) + + -- Ensure the label is highlighted after updating its text + self:_highlight_tool_call_diff_label_line(call) end -- Insert the "view diff" label line directly below the tool call summary @@ -1918,6 +2259,9 @@ function M:_insert_tool_call_diff_label_line(call) -- Track the label line (1-based) call.label_line = call.header_line + 1 + -- Ensure the label is highlighted when first inserted + self:_highlight_tool_call_diff_label_line(call) + -- Shift subsequent tool call headers/labels down by one line self:_adjust_tool_call_lines(call, 1) end @@ -1934,6 +2278,9 @@ function M:_update_tool_call_header_line(call) self:_safe_buffer_update(chat.bufnr, function() vim.api.nvim_buf_set_lines(chat.bufnr, call.header_line - 1, call.header_line, false, { header_text }) end) + + -- Re-apply header highlight after updating its text/arrow/status + self:_highlight_tool_call_header(call) end -- Adjust header_line (and optional label_line) for tool calls that come after the given one @@ -1964,6 +2311,52 @@ function M:_expand_tool_call(call) return end + -- Reasoning ("Thinking") entries behave slightly differently: we never + -- wrap them in code fences and the streaming handler is responsible for + -- keeping the body up to date. Here we just insert the current body once. + if call.is_reason then + -- Build markdown fenced block if we don't have it yet + if not call.arguments_lines or #call.arguments_lines == 0 then + call.arguments_lines = {} + + table.insert(call.arguments_lines, "```text") + for _, line in ipairs(Utils.split_lines(call.arguments or "")) do + table.insert(call.arguments_lines, line) + end + table.insert(call.arguments_lines, "```") + table.insert(call.arguments_lines, "") + end + + local count = call.arguments_lines and #call.arguments_lines or 0 + if count > 0 then + self:_safe_buffer_update(chat.bufnr, function() + -- Insert reasoning lines immediately after the header line + vim.api.nvim_buf_set_lines(chat.bufnr, call.header_line, call.header_line, false, call.arguments_lines) + end) + + -- Track how many lines we inserted so that subsequent streaming + -- updates (_handle_reason_text) can correctly replace this block. + call._last_arguments_count = count + + -- Shift subsequent tool call header/label lines down + self:_adjust_tool_call_lines(call, count) + end + + call.expanded = true + self:_update_tool_call_header_line(call) + + if chat.winid and vim.api.nvim_win_is_valid(chat.winid) then + local last_line = call.header_line + (call._last_arguments_count or 0) + vim.api.nvim_win_set_cursor(chat.winid, { last_line, 0 }) + vim.api.nvim_win_call(chat.winid, function() + vim.cmd("normal! zb") + end) + end + + return + end + + -- Regular tool calls: show JSON arguments block call.arguments_lines = call.arguments_lines or self:_build_tool_call_arguments_lines(call.arguments) local count = call.arguments_lines and #call.arguments_lines or 0 if count == 0 then From 733ecb7548a37612fc2559b1400277467351c48b Mon Sep 17 00:00:00 2001 From: joaopluigi Date: Fri, 12 Dec 2025 11:31:43 -0300 Subject: [PATCH 12/31] fix typo --- lua/eca/sidebar.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/eca/sidebar.lua b/lua/eca/sidebar.lua index e3e0f7c..3918a84 100644 --- a/lua/eca/sidebar.lua +++ b/lua/eca/sidebar.lua @@ -1,7 +1,7 @@ local Utils = require("eca.utils") local Logger = require("eca.logger") local Config = require("eca.config") -ca + -- Load nui.nvim components (required dependency) local Split = require("nui.split") From cf9546e4ffc2acd25ef2e6d738e617a076d9cbbb Mon Sep 17 00:00:00 2001 From: joaopluigi Date: Fri, 12 Dec 2025 12:35:31 -0300 Subject: [PATCH 13/31] update README.md --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index c1c7c7d..a5bfe52 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ A lightweight Neovim plugin that embeds [ECA (Editor Code Assistant)](https://eca.dev/) directly into your editor. It is designed to be very simple, while remaining highly customizable. +> Status: **Work in Progress** — we’re actively developing this plugin and would love feedback, bug reports, and contributions. If you’d like to help, check out [Development & contributing](./docs/development.md) or open an issue/PR. + ## Quick Start > Requires Neovim >= 0.8.0, curl and unzip. From c48e4e1efdb4a49ef61b8139b69a6149bcb4ea69 Mon Sep 17 00:00:00 2001 From: joaopluigi Date: Fri, 12 Dec 2025 12:35:52 -0300 Subject: [PATCH 14/31] add expanded config for diff and thinking --- docs/configuration.md | 20 +++++ lua/eca/config.lua | 12 ++- lua/eca/sidebar.lua | 197 ++++++++++++++++++++++++++++++++++-------- 3 files changed, 192 insertions(+), 37 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 95be3b9..b056f9e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -59,6 +59,26 @@ require("eca").setup({ "Type your message and use CTRL+s to send", }, }, + tool_call = { + icons = { + success = "✅", -- Shown when a tool call succeeds + error = "❌", -- Shown when a tool call fails + running = "⏳", -- Shown while a tool call is running / has no final status yet + expanded = "▲", -- Arrow when the tool call details are expanded + collapsed = "▶", -- Arrow when the tool call details are collapsed + }, + diff_label = { + collapsed = "+ view diff", -- Label when the diff is collapsed + expanded = "- view diff", -- Label when the diff is expanded + }, + }, + reasoning = { + -- When true, "Thinking" blocks are expanded by default + expanded = false, + -- Customize the labels used for the reasoning block + running_label = "Thinking...", + finished_label = "Thought", + }, }, -- === WINDOW SETTINGS === diff --git a/lua/eca/config.lua b/lua/eca/config.lua index ca7b78e..8ca8aec 100644 --- a/lua/eca/config.lua +++ b/lua/eca/config.lua @@ -62,11 +62,17 @@ M._defaults = { expanded = "▲", -- Arrow when the tool call details are expanded collapsed = "▶", -- Arrow when the tool call details are collapsed }, - diff_label = { - collapsed = "+ view diff", -- Label when the diff is collapsed - expanded = "- view diff", -- Label when the diff is expanded + diff = { + collapsed_label = "+ view diff", -- Label when the diff is collapsed + expanded_label = "- view diff", -- Label when the diff is expanded + expanded = false, -- When true, tool diffs start expanded }, }, + reasoning = { + expanded = false, -- When true, "Thinking" blocks start expanded + running_label = "Thinking...", -- Label while reasoning is running + finished_label = "Thought", -- Base label when reasoning is finished + }, }, windows = { wrap = true, diff --git a/lua/eca/sidebar.lua b/lua/eca/sidebar.lua index 3918a84..6241f35 100644 --- a/lua/eca/sidebar.lua +++ b/lua/eca/sidebar.lua @@ -50,19 +50,34 @@ end -- Label texts used for tool call diffs. -- --- Configuration (under `Config.chat.tool_call`): --- - `diff_label.collapsed`: text when diff is collapsed (default: "+ view diff") --- - `diff_label.expanded`: text when diff is expanded (default: "- view diff") +-- Preferred configuration (under `Config.chat.tool_call`): +-- tool_call = { +-- diff = { +-- collapsed_label = "+ view diff", -- Label when the diff is collapsed +-- expanded_label = "- view diff", -- Label when the diff is expanded +-- expanded = false, -- When true, tool diffs start expanded +-- }, +-- } -- -- Backwards compatibility: --- - `diff_label_collapsed` / `diff_label_expanded` (flat keys) are still --- honored if the nested table is not provided. +-- - `diff_label = { collapsed, expanded }` (older table format) +-- - `diff_label_collapsed` / `diff_label_expanded` (flat keys) +-- - `diff_start_expanded` (boolean flag) to control initial expansion local function get_tool_call_diff_labels() local cfg = (Config.chat and Config.chat.tool_call) or {} + + local diff_cfg = cfg.diff or {} local labels_cfg = cfg.diff_label or {} - local collapsed = labels_cfg.collapsed or "+ view diff" - local expanded = labels_cfg.expanded or "- view diff" + local collapsed = diff_cfg.collapsed_label + or labels_cfg.collapsed + or cfg.diff_label_collapsed + or "+ view diff" + + local expanded = diff_cfg.expanded_label + or labels_cfg.expanded + or cfg.diff_label_expanded + or "- view diff" return { collapsed = collapsed, @@ -70,6 +85,32 @@ local function get_tool_call_diff_labels() } end +local function should_start_diff_expanded() + local cfg = (Config.chat and Config.chat.tool_call) or {} + local diff_cfg = cfg.diff or {} + + if diff_cfg.expanded ~= nil then + return diff_cfg.expanded == true + end + + if cfg.diff_start_expanded ~= nil then + return cfg.diff_start_expanded == true + end + + return false +end + +local function get_reasoning_labels() + local cfg = (Config.chat and Config.chat.reasoning) or {} + local running = cfg.running_label or "Thinking..." + local finished = cfg.finished_label or "Thought" + + return { + running = running, + finished = finished, + } +end + ---@param id integer Tab ID ---@param mediator eca.Mediator ---@return eca.Sidebar @@ -100,6 +141,10 @@ function M.new(id, mediator) instance._contexts = {} instance._tool_calls = {} instance._reasons = {} + -- Internal queue for smooth, per-character streaming in the chat + instance._stream_queue = "" + instance._stream_visible_buffer = "" + instance._stream_tick_scheduled = false require("eca.observer").subscribe("sidebar-" .. id, function(message) instance:handle_chat_content(message) @@ -229,6 +274,9 @@ function M:reset() self._contexts = {} self._tool_calls = {} self._reasons = {} + self._stream_queue = "" + self._stream_visible_buffer = "" + self._stream_tick_scheduled = false end function M:new_chat() @@ -1243,6 +1291,9 @@ function M:handle_chat_content_received(params) -- If this call now has a diff and doesn't yet have a label line, add it if call.has_diff and not call.label_line and not call.expanded then self:_insert_tool_call_diff_label_line(call) + if should_start_diff_expanded() then + self:_expand_tool_call_diff(call) + end end end @@ -1286,6 +1337,9 @@ function M:handle_chat_content_received(params) if call.has_diff then self:_insert_tool_call_diff_label_line(call) + if should_start_diff_expanded() then + self:_expand_tool_call_diff(call) + end end table.insert(self._tool_calls, call) @@ -1330,9 +1384,13 @@ function M:_handle_streaming_text(text) if not self._is_streaming then Logger.debug("Starting streaming response") - -- Start streaming - simple and direct + -- Start streaming with an internal character queue so that text + -- is rendered smoothly, one (or a few) characters at a time. self._is_streaming = true self._current_response_buffer = "" + self._stream_queue = "" + self._stream_visible_buffer = "" + self._stream_tick_scheduled = false -- Determine insertion point before adding placeholder (works even with empty header) local chat = self.containers.chat @@ -1360,13 +1418,68 @@ function M:_handle_streaming_text(text) end end - -- Simple accumulation - no complex checks + -- Accumulate the full response for finalization and history self._current_response_buffer = (self._current_response_buffer or "") .. text + -- Enqueue new characters to be rendered gradually + self._stream_queue = (self._stream_queue or "") .. text + + Logger.debug("DEBUG: Buffer now has " .. #self._current_response_buffer .. " chars (queued: " .. #self._stream_queue .. ")") + + -- Ensure the per-character render loop is running + self:_start_streaming_tick() +end + +-- Internal helper: run a tick of the per-character streaming loop. +-- This pulls a small number of characters from `_stream_queue` and +-- appends them to the visible buffer, then reschedules itself while +-- streaming is active. The goal is to make updates feel smooth and +-- natural instead of "chunky". +function M:_start_streaming_tick() + if self._stream_tick_scheduled then + return + end + + self._stream_tick_scheduled = true + + local function step() + -- This runs on the main loop via vim.defer_fn + self._stream_tick_scheduled = false + + -- If streaming finished in the meantime, stop here. + if not self._is_streaming then + return + end + + local queue = self._stream_queue or "" + if queue == "" then + -- Nothing to render yet; poll again shortly while the server + -- continues to stream more content. + self._stream_tick_scheduled = true + vim.defer_fn(step, 10) + return + end + + -- Render a small batch of characters per tick so long messages + -- don't take forever to appear but still feel "typewriter-like". + local CHARS_PER_TICK = 2 + local count = math.min(CHARS_PER_TICK, #queue) + local chunk = queue:sub(1, count) + self._stream_queue = queue:sub(count + 1) + + self._stream_visible_buffer = (self._stream_visible_buffer or "") .. chunk + self:_update_streaming_message(self._stream_visible_buffer) - Logger.debug("DEBUG: Buffer now has " .. #self._current_response_buffer .. " chars") + -- Keep the loop going while we still have content or the server is + -- likely to send more. + if self._is_streaming or (self._stream_queue and #self._stream_queue > 0) then + self._stream_tick_scheduled = true + vim.defer_fn(step, 10) + end + end - -- Update the assistant's message in place - self:_update_streaming_message(self._current_response_buffer) + -- Start immediately (or after a tiny delay) so the first characters + -- appear right away. + vim.defer_fn(step, 1) end ---@param content string @@ -1522,9 +1635,18 @@ function M:_finalize_streaming_response() Logger.debug("DEBUG: Finalizing streaming response") Logger.debug("DEBUG: Final buffer had " .. #(self._current_response_buffer or "") .. " chars") + -- On finalize, ensure the full response is rendered, regardless of + -- how many characters are still sitting in the internal queue. + if self._current_response_buffer and self._current_response_buffer ~= "" then + self:_update_streaming_message(self._current_response_buffer) + end + self._is_streaming = false self._current_response_buffer = "" self._response_start_time = 0 + self._stream_queue = "" + self._stream_visible_buffer = "" + self._stream_tick_scheduled = false -- Clear assistant placeholder tracking extmark local chat = self.containers.chat @@ -1771,6 +1893,9 @@ function M:_display_tool_call(content) if call.has_diff then self:_insert_tool_call_diff_label_line(call) + if should_start_diff_expanded() then + self:_expand_tool_call_diff(call) + end end table.insert(self._tool_calls, call) @@ -1801,6 +1926,10 @@ function M:_handle_reason_started(content) -- If a new reasoning starts while another one is still "running", -- mark the previous one as finished so only one active "Thinking" -- block is shown at a time. + local labels = get_reasoning_labels() + local running_label = labels.running + local finished_label = labels.finished + for existing_id, existing_call in pairs(self._reasons) do if existing_id ~= id and existing_call @@ -1809,10 +1938,10 @@ function M:_handle_reason_started(content) -- the running label, so we don't clobber completed -- entries like "Thought 1.23 s" when a new reasoning -- block starts later. - and existing_call.title == "Thinking..." then + and existing_call.title == running_label then -- For reasoning entries we don't show status icons; instead we just - -- update the label from "Thinking..." to "Thought". - existing_call.title = "Thought" + -- update the label from the running label to the finished label. + existing_call.title = finished_label existing_call.status = nil self:_update_tool_call_header_line(existing_call) end @@ -1823,14 +1952,18 @@ function M:_handle_reason_started(content) return end + -- Whether "Thinking" blocks should start expanded by default + local reasoning_cfg = (Config.chat and Config.chat.reasoning) or {} + local expand = reasoning_cfg.expanded == true + local call = { id = id, - title = "Thinking...", -- fixed summary label while reasoning is running + title = running_label, -- summary label while reasoning is running header_line = nil, - expanded = false, -- controls visibility of reasoning text - diff_expanded = false, -- unused for reasoning - status = nil, -- unused for reasoning headers; no status icons - arguments = "", -- we reuse arguments as the accumulated reasoning text + expanded = expand, -- controls visibility of reasoning text + diff_expanded = false, -- unused for reasoning + status = nil, -- unused for reasoning headers; no status icons + arguments = "", -- we reuse arguments as the accumulated reasoning text details = {}, has_diff = false, label_line = nil, @@ -1884,15 +2017,10 @@ function M:_handle_reason_text(content) call.arguments_lines = {} local lines = Utils.split_lines(call.arguments or "") - for index, line in ipairs(lines) do + for _, line in ipairs(lines) do line = line ~= "" and line or " " - if index == 1 then - -- First line uses markdown blockquote prefix - table.insert(call.arguments_lines, "> " .. line) - else - -- Subsequent lines are indented to align with the first line's content - table.insert(call.arguments_lines, " " .. line) - end + -- Prefix each line with markdown blockquote + table.insert(call.arguments_lines, "> " .. line) end -- Trailing blank line for spacing @@ -1935,14 +2063,17 @@ function M:_handle_reason_finished(content) return end - -- When reasoning is finished, update the label from "Thinking..." to - -- "Thought s" when totalTimeMs is available. + local labels = get_reasoning_labels() + local finished_label = labels.finished + + -- When reasoning is finished, update the label from running to a + -- finished label, appending the total time in seconds when available. local total_ms = tonumber(content.totalTimeMs) if total_ms and total_ms > 0 then local seconds = total_ms / 1000 - call.title = string.format("Thought %.2f s", seconds) + call.title = string.format("%s %.2f s", finished_label, seconds) else - call.title = "Thought" + call.title = finished_label end call.status = nil @@ -2319,11 +2450,9 @@ function M:_expand_tool_call(call) if not call.arguments_lines or #call.arguments_lines == 0 then call.arguments_lines = {} - table.insert(call.arguments_lines, "```text") for _, line in ipairs(Utils.split_lines(call.arguments or "")) do table.insert(call.arguments_lines, line) end - table.insert(call.arguments_lines, "```") table.insert(call.arguments_lines, "") end From 3fbe87f300ffc8a07b90fe7ba611c1e9abaca55a Mon Sep 17 00:00:00 2001 From: joaopluigi Date: Fri, 12 Dec 2025 13:22:17 -0300 Subject: [PATCH 15/31] small adjustments --- lua/eca/config.lua | 2 +- lua/eca/sidebar.lua | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/lua/eca/config.lua b/lua/eca/config.lua index 8ca8aec..6cc354a 100644 --- a/lua/eca/config.lua +++ b/lua/eca/config.lua @@ -59,7 +59,7 @@ M._defaults = { success = "✅", -- Shown when a tool call succeeds error = "❌", -- Shown when a tool call fails running = "⏳", -- Shown while a tool call is running / has no final status yet - expanded = "▲", -- Arrow when the tool call details are expanded + expanded = "▼", -- Arrow when the tool call details are expanded collapsed = "▶", -- Arrow when the tool call details are collapsed }, diff = { diff --git a/lua/eca/sidebar.lua b/lua/eca/sidebar.lua index 6241f35..72f28f2 100644 --- a/lua/eca/sidebar.lua +++ b/lua/eca/sidebar.lua @@ -2013,14 +2013,13 @@ function M:_handle_reason_text(content) -- Accumulate raw reasoning text call.arguments = (call.arguments or "") .. content.text - -- Build markdown quote block for the reasoning body + -- Build plain text block for the reasoning body (no markdown quote prefix) call.arguments_lines = {} local lines = Utils.split_lines(call.arguments or "") for _, line in ipairs(lines) do line = line ~= "" and line or " " - -- Prefix each line with markdown blockquote - table.insert(call.arguments_lines, "> " .. line) + table.insert(call.arguments_lines, line) end -- Trailing blank line for spacing From 71f37f8518b5492a70905df3b92dc6dd6067fa22 Mon Sep 17 00:00:00 2001 From: joaopluigi Date: Sat, 13 Dec 2025 11:16:55 -0300 Subject: [PATCH 16/31] fix some bugs --- lua/eca/sidebar.lua | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/lua/eca/sidebar.lua b/lua/eca/sidebar.lua index 72f28f2..064145d 100644 --- a/lua/eca/sidebar.lua +++ b/lua/eca/sidebar.lua @@ -2137,15 +2137,32 @@ function M:_build_tool_call_header_text(call) local icons = get_tool_call_icons() local arrow = call.expanded and icons.expanded or icons.collapsed + -- Normalize all pieces to strings to avoid issues when configuration or + -- status fields accidentally contain non-string values (e.g. userdata). + if type(arrow) ~= "string" then + arrow = tostring(arrow) + end + -- Reasoning ("Thinking") entries do not show status icons; they only -- display the toggle arrow and a text label ("Thinking..." / "Thought"). if call.is_reason then local title = call.title or "Thinking..." + if type(title) ~= "string" then + title = tostring(title) + end return table.concat({ arrow, title }, " ") end local title = call.title or "Tool call" local status = call.status or icons.running + + if type(title) ~= "string" then + title = tostring(title) + end + if type(status) ~= "string" then + status = tostring(status) + end + local parts = { arrow, title, status } return table.concat(parts, " ") @@ -2248,6 +2265,13 @@ function M:_highlight_tool_call_header(call) return end + -- Guard against stale header_line values that point past the end of the + -- buffer (for example after streaming updates that rewrote the chat). + local line_count = vim.api.nvim_buf_line_count(chat.bufnr) + if call.header_line < 1 or call.header_line > line_count then + return + end + self.extmarks = self.extmarks or {} if not self.extmarks.tool_header then self.extmarks.tool_header = { @@ -2293,6 +2317,13 @@ function M:_highlight_tool_call_diff_label_line(call) return end + -- Guard against stale label_line values that point past the end of the + -- buffer (for example after streaming updates that rewrote the chat). + local line_count = vim.api.nvim_buf_line_count(chat.bufnr) + if call.label_line < 1 or call.label_line > line_count then + return + end + self.extmarks = self.extmarks or {} if not self.extmarks.diff_label then self.extmarks.diff_label = { From 92862d8160abb1ca92cfb4b8238a373f75de4d2d Mon Sep 17 00:00:00 2001 From: joaopluigi Date: Tue, 16 Dec 2025 10:01:51 -0300 Subject: [PATCH 17/31] change EcaUsage to EcaLabel and some configs update --- lua/eca/sidebar.lua | 83 ++++++++++++++++++++++++++++++++++----------- 1 file changed, 63 insertions(+), 20 deletions(-) diff --git a/lua/eca/sidebar.lua b/lua/eca/sidebar.lua index 064145d..1958b56 100644 --- a/lua/eca/sidebar.lua +++ b/lua/eca/sidebar.lua @@ -36,9 +36,45 @@ local WINDOW_MARGIN = 3 -- Additional margin for window borders and spacing local UI_ELEMENTS_HEIGHT = 2 -- Reserve space for statusline and tabline local SAFETY_MARGIN = 2 -- Extra margin to prevent "Not enough room" errors --- Tool call icons (can be overridden via Config.chat.tool_call.icons) +local function _shorten_tokens(n) + n = tonumber(n) or 0 + if n >= 1000 then + local rounded = math.floor(n / 1000 + 0.5) + return string.format("%dk", rounded) + end + return tostring(n) +end + +local function _format_usage(tokens, limit, costs) + local usage_cfg = (Config.windows and Config.windows.usage) or {} + local fmt = usage_cfg.format + or Config.usage_string_format -- backwards compatibility + or "{session_tokens_short} / {limit_tokens_short} (${session_cost})" + + local placeholders = { + session_tokens = tostring(tokens or 0), + limit_tokens = tostring(limit or 0), + session_tokens_short = _shorten_tokens(tokens), + limit_tokens_short = _shorten_tokens(limit), + session_cost = tostring(costs or "0.00"), + } + + local result = fmt:gsub("{(.-)}", function(key) + return placeholders[key] or "" + end) + + return result +end + +-- Tool call icons (can be overridden via Config.windows.chat.tool_call.icons) +local function _get_chat_config() + -- Prefer `windows.chat`, but fall back to top-level `chat` for backwards compatibility + return (Config.windows and Config.windows.chat) or Config.chat or {} +end + local function get_tool_call_icons() - local icons_cfg = (Config.chat and Config.chat.tool_call and Config.chat.tool_call.icons) or {} + local chat_cfg = _get_chat_config() + local icons_cfg = (chat_cfg.tool_call and chat_cfg.tool_call.icons) or {} return { success = icons_cfg.success or "✅", error = icons_cfg.error or "❌", @@ -50,7 +86,7 @@ end -- Label texts used for tool call diffs. -- --- Preferred configuration (under `Config.chat.tool_call`): +-- Preferred configuration (under `windows.chat.tool_call`): -- tool_call = { -- diff = { -- collapsed_label = "+ view diff", -- Label when the diff is collapsed @@ -64,7 +100,8 @@ end -- - `diff_label_collapsed` / `diff_label_expanded` (flat keys) -- - `diff_start_expanded` (boolean flag) to control initial expansion local function get_tool_call_diff_labels() - local cfg = (Config.chat and Config.chat.tool_call) or {} + local chat_cfg = _get_chat_config() + local cfg = chat_cfg.tool_call or {} local diff_cfg = cfg.diff or {} local labels_cfg = cfg.diff_label or {} @@ -86,7 +123,8 @@ local function get_tool_call_diff_labels() end local function should_start_diff_expanded() - local cfg = (Config.chat and Config.chat.tool_call) or {} + local chat_cfg = _get_chat_config() + local cfg = chat_cfg.tool_call or {} local diff_cfg = cfg.diff or {} if diff_cfg.expanded ~= nil then @@ -101,7 +139,8 @@ local function should_start_diff_expanded() end local function get_reasoning_labels() - local cfg = (Config.chat and Config.chat.reasoning) or {} + local chat_cfg = _get_chat_config() + local cfg = chat_cfg.reasoning or {} local running = cfg.running_label or "Thinking..." local finished = cfg.finished_label or "Thought" @@ -132,9 +171,10 @@ function M.new(id, mediator) instance._augroup = vim.api.nvim_create_augroup("eca_sidebar_" .. id, { clear = true }) instance._response_start_time = 0 instance._max_response_length = 50000 -- 50KB max response + local chat_cfg = _get_chat_config() instance._headers = { - user = (Config.chat and Config.chat.headers and Config.chat.headers.user) or "> ", - assistant = (Config.chat and Config.chat.headers and Config.chat.headers.assistant) or "", + user = (chat_cfg.headers and chat_cfg.headers.user) or "> ", + assistant = (chat_cfg.headers and chat_cfg.headers.assistant) or "", } instance._welcome_message_applied = false instance._contexts_placeholder_line = "" @@ -426,7 +466,7 @@ function M:_create_containers() modifiable = false, }), win_options = vim.tbl_deep_extend("force", base_win_options, { - winhighlight = "Normal:EcaUsage", + winhighlight = "Normal:EcaLabel", statusline = " ", }), }) @@ -863,7 +903,7 @@ function M:_update_input_display(opts) 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] }) + vim.tbl_extend("force", { virt_text = { { context_name, "EcaLabel" } }, virt_text_pos = "inline", hl_mode = "replace" }, { id = self.extmarks.contexts._id[i] }) ) end @@ -1011,21 +1051,21 @@ function M:_update_config_display() -- While any MCP is still starting, dim the active count local active_hl = "Normal" if starting_count > 0 then - active_hl = "Comment" + active_hl = "EcaLabel" end local registered_hl = "Normal" if has_failed then registered_hl = "Exception" -- highlight registered count in red when any MCP failed - elseif active_hl == "Comment" then + elseif active_hl == "EcaLabel" then -- While MCPs are still starting, dim the total count as well - registered_hl = "Comment" + registered_hl = "EcaLabel" end local texts = { - { "model:", "Comment" }, { model, "Normal" }, { " " }, - { "behavior:", "Comment" }, { behavior, "Normal" }, { " " }, - { "mcps:", "Comment" }, { tostring(active_count), active_hl }, { "/", "Comment" }, + { "model:", "EcaLabel" }, { model, "Normal" }, { " " }, + { "behavior:", "EcaLabel" }, { behavior, "Normal" }, { " " }, + { "mcps:", "EcaLabel" }, { tostring(active_count), active_hl }, { "/", "EcaLabel" }, { tostring(registered_count), registered_hl }, } @@ -1068,7 +1108,7 @@ function M:_update_usage_info() local costs = self.mediator:costs_session() or "0.00" self._current_status = string.format("%s", status_text) - self._usage_info = string.format("%d / %d (%s)", tokens, limit, costs) + self._usage_info = _format_usage(tokens, limit, costs) self.extmarks = self.extmarks or {} @@ -1121,7 +1161,8 @@ function M:_update_welcome_content() return end - local cfg = (Config.chat and Config.chat.welcome) or {} + local chat_cfg = _get_chat_config() + local cfg = chat_cfg.welcome or {} local cfg_msg = (cfg.message and cfg.message ~= "" and cfg.message) or nil local welcome_message = cfg_msg or (self.mediator and self.mediator:welcome_message() or nil) @@ -1497,7 +1538,9 @@ function M:_update_streaming_message(content) return end - -- Simple and direct buffer update + -- Simple and direct buffer update that only rewrites the assistant's + -- own streaming region. This avoids clobbering content that may have + -- been appended after it (e.g. tool calls or reasoning blocks). local success, err = pcall(function() -- Make buffer modifiable vim.api.nvim_set_option_value("modifiable", true, { buf = chat.bufnr }) @@ -2242,7 +2285,7 @@ local function _eca_sidebar_hl(kind) if kind == "tool_header" then return "EcaToolCall" elseif kind == "reason_header" then - return "EcaUsage" + return "EcaLabel" elseif kind == "diff_label" then return "EcaHyperlink" end From afdb9f1ba82335637232e0ea9dd2cbfebd844fd0 Mon Sep 17 00:00:00 2001 From: joaopluigi Date: Tue, 16 Dec 2025 10:02:03 -0300 Subject: [PATCH 18/31] change EcaUsage to EcaLabel at highlights --- lua/eca/highlights.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/eca/highlights.lua b/lua/eca/highlights.lua index 37c6143..7377be7 100644 --- a/lua/eca/highlights.lua +++ b/lua/eca/highlights.lua @@ -14,7 +14,7 @@ function M.setup() vim.api.nvim_set_hl(0, "EcaInfo", { fg = "#7dcfff", bg = "#2b3a3d" }) vim.api.nvim_set_hl(0, "EcaToolCall", { link = "Title" }) vim.api.nvim_set_hl(0, "EcaHyperlink", { link = "Underlined", underline = true }) - vim.api.nvim_set_hl(0, "EcaUsage", { link = "Comment" }) + vim.api.nvim_set_hl(0, "EcaLabel", { link = "Comment" }) end return M From b77b6aaf595c35117cb0cd4844b18f22c423fc4f Mon Sep 17 00:00:00 2001 From: joaopluigi Date: Tue, 16 Dec 2025 10:02:32 -0300 Subject: [PATCH 19/31] some config updates --- docs/configuration.md | 249 ++++++++++++++++++++++++------------------ lua/eca/config.lua | 81 +++++++------- plugin-spec.lua | 6 +- 3 files changed, 188 insertions(+), 148 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index b056f9e..934aa3a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -6,122 +6,162 @@ ECA is highly configurable. This page lists all available options and provides c ```lua require("eca").setup({ - -- === BASIC SETTINGS === + -- === SERVER === - -- Enable debug mode (shows detailed logs) - debug = false, - - -- Path to ECA binary (empty = automatic download) + -- Path to the ECA binary + -- - Empty string: automatically download & manage the binary + -- - Custom path: use your own binary server_path = "", - -- Extra arguments for ECA server - server_args = "--log-level info", + -- Extra arguments passed to the ECA server (eca start ...) + server_args = "", + + -- === LOGGING === + log = { + -- Where to display logs inside Neovim + -- "split" - use a split window + -- "float" - use a floating window + -- "none" - disable the log window + display = "split", - -- Usage string format (tokens/cost) - usage_string_format = "{messageCost} / {sessionCost}", + -- Minimum log level to record + -- vim.log.levels.TRACE | DEBUG | INFO | WARN | ERROR + level = vim.log.levels.INFO, + + -- Optional file path for persistent logs (empty = disabled) + file = "", + + -- Maximum log file size before ECA warns you (in MB) + max_file_size_mb = 10, + }, -- === BEHAVIOR === behavior = { - -- Set keymaps automatically + -- Set default keymaps automatically auto_set_keymaps = true, - -- Focus sidebar automatically when opening + -- Focus the ECA sidebar when opening it auto_focus_sidebar = true, - -- Start server automatically + -- Automatically start the server on plugin setup auto_start_server = false, - -- Download server automatically if not found + -- Automatically download the server if not found auto_download = true, - -- Show status updates in notifications + -- Show status updates (startup, downloads, errors) as notifications show_status_updates = true, }, - -- === KEY MAPPINGS === - mappings = { - chat = "ec", -- Open chat - focus = "ef", -- Focus sidebar - toggle = "et", -- Toggle sidebar + -- === CONTEXT === + context = { + -- Automatically attach repo context (repoMap) when starting new chats + auto_repo_map = true, }, - -- === CHAT === - chat = { - headers = { - user = "> ", - assistant = "", - }, - welcome = { - -- If non-empty, overrides server-provided welcome message - message = "", - -- Tips appended under the welcome (set {} to disable) - tips = { - "Type your message and use CTRL+s to send", - }, - }, - tool_call = { - icons = { - success = "✅", -- Shown when a tool call succeeds - error = "❌", -- Shown when a tool call fails - running = "⏳", -- Shown while a tool call is running / has no final status yet - expanded = "▲", -- Arrow when the tool call details are expanded - collapsed = "▶", -- Arrow when the tool call details are collapsed - }, - diff_label = { - collapsed = "+ view diff", -- Label when the diff is collapsed - expanded = "- view diff", -- Label when the diff is expanded - }, - }, - reasoning = { - -- When true, "Thinking" blocks are expanded by default - expanded = false, - -- Customize the labels used for the reasoning block - running_label = "Thinking...", - finished_label = "Thought", - }, + -- === TODOS PANEL === + todos = { + enabled = true, -- Enable or disable todos integration + max_height = 5, -- Maximum height for the todos container + }, + + -- === SELECTED CODE PANEL === + selected_code = { + enabled = true, -- Show currently selected code in the UI + max_height = 8, -- Maximum height for the selected code container + }, + + -- === KEY MAPPINGS === + mappings = { + chat = "ec", -- Open chat + focus = "ef", -- Focus sidebar + toggle = "et",-- Toggle sidebar }, - -- === WINDOW SETTINGS === + -- === WINDOWS & UI === windows = { - -- Automatic line wrapping + -- Automatic line wrapping in ECA buffers wrap = true, - -- Width as percentage of screen (1-100) + -- Width as percentage of Neovim columns (1–100) width = 40, -- Sidebar header configuration sidebar_header = { enabled = true, - align = "center", -- "left", "center", "right" + align = "center", -- "left", "center", "right" rounded = true, }, -- Input area configuration input = { - prefix = "> ", -- Input line prefix - height = 8, -- Input window height + prefix = "> ", -- Input line prefix + height = 8, -- Input window height (lines) + + -- Maximum length for web context names in the input area + web_context_max_len = 20, }, -- Edit window configuration edit = { - border = "rounded", -- "none", "single", "double", "rounded" - start_insert = true, -- Start in insert mode + border = "rounded", -- "none", "single", "double", "rounded" + start_insert = true, -- Start in insert mode }, - -- Ask window configuration - ask = { - floating = false, -- Use floating window - start_insert = true, -- Start in insert mode - border = "rounded", - focus_on_apply = "ours", -- "ours" or "theirs" + -- Usage line configuration (token / cost display) + usage = { + -- Supported placeholders: + -- {session_tokens} - raw session token count (e.g. "30376") + -- {limit_tokens} - raw token limit (e.g. "400000") + -- {session_tokens_short} - shortened session tokens (e.g. "30k") + -- {limit_tokens_short} - shortened token limit (e.g. "400k") + -- {session_cost} - session cost (e.g. "0.09") + -- Default: "30k / 400k ($0.09)" -> + -- "{session_tokens_short} / {limit_tokens_short} (${session_cost})" + format = "{session_tokens_short} / {limit_tokens_short} (${session_cost})", }, - }, - -- === HIGHLIGHTS AND COLORS === - highlights = { - diff = { - current = "DiffText", -- Highlight for current diff - incoming = "DiffAdd", -- Highlight for incoming diff + -- Chat window & behavior + chat = { + -- Prefixes for each speaker + headers = { + user = "> ", + assistant = "", + }, + + -- Welcome message configuration + welcome = { + -- If non-empty, overrides the server-provided welcome message + message = "", + + -- Tips appended under the welcome (set {} to disable) + tips = { + "Type your message and use CTRL+s to send", + }, + }, + + -- Tool call display settings + tool_call = { + icons = { + success = "✅", -- Shown when a tool call succeeds + error = "❌", -- Shown when a tool call fails + running = "⏳", -- Shown while a tool call is running + expanded = "▼", -- Arrow when the tool call details are expanded + collapsed = "▶", -- Arrow when the tool call details are collapsed + }, + diff = { + collapsed_label = "+ view diff", -- Label when the diff is collapsed + expanded_label = "- view diff", -- Label when the diff is expanded + expanded = false, -- When true, tool diffs start expanded + }, + }, + + -- Reasoning ("Thinking") block behavior + reasoning = { + expanded = false, -- When true, "Thinking" blocks start expanded + running_label = "Thinking...", -- Label while reasoning is running + finished_label = "Thought", -- Base label when reasoning is finished + }, }, }, }) @@ -131,21 +171,27 @@ require("eca").setup({ ## Presets +These examples show how to override just a subset of the configuration. + ### Minimalist ```lua require("eca").setup({ - behavior = { show_status_updates = false }, - windows = { width = 30 }, - chat = { - headers = { - user = "> ", - assistant = "", + behavior = { + show_status_updates = false, + }, + windows = { + width = 30, + chat = { + headers = { + user = "> ", + assistant = "", + }, }, }, }) ``` -### Visual/UX focused +### Visual / UX focused ```lua require("eca").setup({ behavior = { auto_focus_sidebar = true }, @@ -154,11 +200,14 @@ require("eca").setup({ wrap = true, sidebar_header = { enabled = true, rounded = true }, input = { prefix = "💬 ", height = 10 }, - }, - chat = { - headers = { - user = "## 👤 You\n\n", - assistant = "## 🤖 ECA\n\n", + chat = { + headers = { + user = "## 👤 You\n\n", + assistant = "## 🤖 ECA\n\n", + }, + reasoning = { + expanded = true, + }, }, }, }) @@ -167,28 +216,11 @@ require("eca").setup({ ### Development ```lua require("eca").setup({ - debug = true, server_args = "--log-level debug", - behavior = { - auto_start_server = true, - show_status_updates = true, - }, - mappings = { - chat = "", - toggle = "", - focus = "", - }, -}) -``` - -### Performance-oriented -```lua -require("eca").setup({ - behavior = { - auto_focus_sidebar = false, - show_status_updates = false, + log = { + level = vim.log.levels.DEBUG, + display = "split", }, - windows = { width = 25 }, }) ``` @@ -196,6 +228,9 @@ require("eca").setup({ ## Notes - Set `server_path` if you prefer using a local ECA binary. -- For noisy environments, disable `show_status_updates`. +- Use the `log` block to control verbosity and where logs are written. +- `context.auto_repo_map` controls whether repo context is attached automatically. +- `todos` and `selected_code` can be disabled entirely if you prefer a simpler UI. - Adjust `windows.width` to fit your layout. -- Keymaps can be set manually by turning off `auto_set_keymaps`. +- Keymaps can be set manually by turning off `behavior.auto_set_keymaps` and defining your own mappings. +- The `windows.usage.format` string controls how token and cost usage are displayed. diff --git a/lua/eca/config.lua b/lua/eca/config.lua index 6cc354a..9815009 100644 --- a/lua/eca/config.lua +++ b/lua/eca/config.lua @@ -3,17 +3,8 @@ local M = {} ---@class eca.Config M._defaults = { - ---@type string server_path = "", -- Path to the ECA binary, will download automatically if empty - ---@type string server_args = "", -- Extra args for the eca start command - ---@type string - usage_string_format = "{messageCost} / {sessionCost}", - ---@class eca.LogConfig - ---@field display 'popup'|'split' - ---@field level integer - ---@field file string - ---@field max_file_size_mb number log = { display = "split", level = vim.log.levels.INFO, @@ -43,37 +34,6 @@ M._defaults = { focus = "ef", toggle = "et", }, - chat = { - headers = { - user = "> ", - assistant = "", - }, - welcome = { - message = "", -- If non-empty, overrides server-provided welcome message - tips = { - "Type your message and use CTRL+s to send", -- Tips appended under the welcome (set empty list {} to disable) - }, - }, - tool_call = { - icons = { - success = "✅", -- Shown when a tool call succeeds - error = "❌", -- Shown when a tool call fails - running = "⏳", -- Shown while a tool call is running / has no final status yet - expanded = "▼", -- Arrow when the tool call details are expanded - collapsed = "▶", -- Arrow when the tool call details are collapsed - }, - diff = { - collapsed_label = "+ view diff", -- Label when the diff is collapsed - expanded_label = "- view diff", -- Label when the diff is expanded - expanded = false, -- When true, tool diffs start expanded - }, - }, - reasoning = { - expanded = false, -- When true, "Thinking" blocks start expanded - running_label = "Thinking...", -- Label while reasoning is running - finished_label = "Thought", -- Base label when reasoning is finished - }, - }, windows = { wrap = true, width = 40, -- Window width as percentage (40 = 40% of screen width) @@ -91,6 +51,47 @@ M._defaults = { border = "rounded", start_insert = true, -- Start insert mode when opening the edit window }, + usage = { + --- Supported placeholders: + --- {session_tokens} - raw session token count (e.g. "30376") + --- {limit_tokens} - raw token limit (e.g. "400000") + --- {session_tokens_short} - shortened session tokens (e.g. "30k") + --- {limit_tokens_short} - shortened token limit (e.g. "400k") + --- {session_cost} - session cost (e.g. "0.09") + --- Default: "30k / 400k ($0.09)" -> "{session_tokens_short} / {limit_tokens_short} (${session_cost})" + format = "{session_tokens_short} / {limit_tokens_short} (${session_cost})", + }, + chat = { + headers = { + user = "> ", + assistant = "", + }, + welcome = { + message = "", -- If non-empty, overrides server-provided welcome message + tips = { + "Type your message and use CTRL+s to send", -- Tips appended under the welcome (set empty list {} to disable) + }, + }, + tool_call = { + icons = { + success = "✅", -- Shown when a tool call succeeds + error = "❌", -- Shown when a tool call fails + running = "⏳", -- Shown while a tool call is running / has no final status yet + expanded = "▼", -- Arrow when the tool call details are expanded + collapsed = "▶", -- Arrow when the tool call details are collapsed + }, + diff = { + collapsed_label = "+ view diff", -- Label when the diff is collapsed + expanded_label = "- view diff", -- Label when the diff is expanded + expanded = false, -- When true, tool diffs start expanded + }, + }, + reasoning = { + expanded = false, -- When true, "Thinking" blocks start expanded + running_label = "Thinking...", -- Label while reasoning is running + finished_label = "Thought", -- Base label when reasoning is finished + }, + }, }, } diff --git a/plugin-spec.lua b/plugin-spec.lua index a48fb76..e908522 100644 --- a/plugin-spec.lua +++ b/plugin-spec.lua @@ -11,7 +11,11 @@ return { -- Default configuration server_path = "", server_args = "", - usage_string_format = "{messageCost} / {sessionCost}", + windows = { + usage = { + format = "{session_tokens_short} / {limit_tokens_short} (${session_cost})", + }, + }, log = { display = "split", -- "split" or "popup" level = vim.log.levels.INFO, From a8bb8ec84776afe9f08b48ffdb88b1cd8500b5dd Mon Sep 17 00:00:00 2001 From: joaopluigi Date: Wed, 17 Dec 2025 09:36:49 -0300 Subject: [PATCH 20/31] fix replace_text bug --- lua/eca/sidebar.lua | 36 ++++++++++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/lua/eca/sidebar.lua b/lua/eca/sidebar.lua index 1958b56..d5c141f 100644 --- a/lua/eca/sidebar.lua +++ b/lua/eca/sidebar.lua @@ -2782,11 +2782,39 @@ function M:_replace_text(target, replacement, opts) local line = range_lines[idx] or "" local s_idx, e_idx = line:find(target, 1, true) if s_idx then - local new_line = (line:sub(1, s_idx - 1)) .. replacement .. (line:sub(e_idx + 1)) local absolute_line = end_line + idx - 1 -- convert to absolute 1-based line - vim.api.nvim_buf_set_lines(chat.bufnr, absolute_line - 1, absolute_line, false, { new_line }) - changed = true - break + + -- If replacement contains newlines, split it into proper buffer lines + if type(replacement) == "string" and replacement:find("\n") then + local parts = Utils.split_lines(replacement) + local prefix = line:sub(1, s_idx - 1) + local suffix = line:sub(e_idx + 1) + + local new_lines = {} + if #parts > 0 then + -- First line: prefix + first part + table.insert(new_lines, prefix .. parts[1]) + -- Middle parts (if any) + for i = 2, #parts do + table.insert(new_lines, parts[i]) + end + -- Append suffix to the last inserted line + new_lines[#new_lines] = new_lines[#new_lines] .. suffix + else + -- Fallback: no parts (shouldn't happen), just replace inline + table.insert(new_lines, prefix .. suffix) + end + + vim.api.nvim_buf_set_lines(chat.bufnr, absolute_line - 1, absolute_line, false, new_lines) + changed = true + break + else + -- Simple single-line replacement + local new_line = (line:sub(1, s_idx - 1)) .. replacement .. (line:sub(e_idx + 1)) + vim.api.nvim_buf_set_lines(chat.bufnr, absolute_line - 1, absolute_line, false, { new_line }) + changed = true + break + end end end end) From 6727eaeaa91561936f58b378d2004505d073ef7f Mon Sep 17 00:00:00 2001 From: joaopluigi Date: Tue, 6 Jan 2026 09:54:42 -0300 Subject: [PATCH 21/31] add tests --- docs/configuration.md | 12 - lua/eca/commands.lua | 18 +- lua/eca/sidebar.lua | 19 +- tests/test_eca.lua | 8 + tests/test_highlights.lua | 27 ++ tests/test_picker.lua | 77 ++++ tests/test_server_picker_commands.lua | 182 +++++++++ tests/test_sidebar_usage_and_tools.lua | 501 +++++++++++++++++++++++++ 8 files changed, 828 insertions(+), 16 deletions(-) create mode 100644 tests/test_highlights.lua create mode 100644 tests/test_picker.lua create mode 100644 tests/test_server_picker_commands.lua create mode 100644 tests/test_sidebar_usage_and_tools.lua diff --git a/docs/configuration.md b/docs/configuration.md index 934aa3a..b8372b2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -59,18 +59,6 @@ require("eca").setup({ auto_repo_map = true, }, - -- === TODOS PANEL === - todos = { - enabled = true, -- Enable or disable todos integration - max_height = 5, -- Maximum height for the todos container - }, - - -- === SELECTED CODE PANEL === - selected_code = { - enabled = true, -- Show currently selected code in the UI - max_height = 8, -- Maximum height for the selected code container - }, - -- === KEY MAPPINGS === mappings = { chat = "ec", -- Open chat diff --git a/lua/eca/commands.lua b/lua/eca/commands.lua index 11e3223..54d62c7 100644 --- a/lua/eca/commands.lua +++ b/lua/eca/commands.lua @@ -505,11 +505,27 @@ function M.setup() for _, name in ipairs(names) do local tool = tools[name] or {} + -- Build a human-readable preview string that always includes the + -- tool name and its primary "kind" field (when available), + -- followed by a full vim.inspect dump for debugging. + local preview_text + if next(tool) ~= nil then + local kind_value = tool.kind ~= nil and tostring(tool.kind) or "(unknown)" + preview_text = string.format("name: %s\nkind: %s", name, kind_value) + + local inspected = vim.inspect(tool) + if inspected and inspected ~= "" then + preview_text = preview_text .. "\n" .. inspected + end + else + preview_text = string.format("name: %s\n", name) + end + table.insert(items, { text = name, idx = name, preview = { - text = vim.inspect(tool), + text = preview_text, ft = "lua", }, }) diff --git a/lua/eca/sidebar.lua b/lua/eca/sidebar.lua index d5c141f..8275504 100644 --- a/lua/eca/sidebar.lua +++ b/lua/eca/sidebar.lua @@ -68,8 +68,18 @@ end -- Tool call icons (can be overridden via Config.windows.chat.tool_call.icons) local function _get_chat_config() - -- Prefer `windows.chat`, but fall back to top-level `chat` for backwards compatibility - return (Config.windows and Config.windows.chat) or Config.chat or {} + -- Merge top-level `chat` (backwards compatible) with `windows.chat`. + -- `windows.chat` provides modern defaults, while a user-provided + -- `chat.tool_call` block (legacy style) can still override fields + -- like `diff_label` and `diff_start_expanded`. + local win_chat = (Config.windows and Config.windows.chat) or {} + local top_chat = Config.chat or {} + + if next(top_chat) == nil then + return win_chat + end + + return vim.tbl_deep_extend("force", win_chat, top_chat) end local function get_tool_call_icons() @@ -1996,7 +2006,10 @@ function M:_handle_reason_started(content) end -- Whether "Thinking" blocks should start expanded by default - local reasoning_cfg = (Config.chat and Config.chat.reasoning) or {} + -- Use the merged chat config so both legacy `chat.reasoning` and + -- modern `windows.chat.reasoning` can control this behavior. + local chat_cfg = _get_chat_config() + local reasoning_cfg = chat_cfg.reasoning or {} local expand = reasoning_cfg.expanded == true local call = { diff --git a/tests/test_eca.lua b/tests/test_eca.lua index d177bc3..faab82a 100644 --- a/tests/test_eca.lua +++ b/tests/test_eca.lua @@ -16,6 +16,14 @@ T["config"]["has default values"] = function() MiniTest.expect.equality(type(config), "table") end +T["config"]["has usage window defaults"] = function() + local config = require("eca.config") + MiniTest.expect.equality( + config.options.windows.usage.format, + "{session_tokens_short} / {limit_tokens_short} (${session_cost})" + ) +end + -- Test utilities T["utils"] = MiniTest.new_set() diff --git a/tests/test_highlights.lua b/tests/test_highlights.lua new file mode 100644 index 0000000..9ddfd3b --- /dev/null +++ b/tests/test_highlights.lua @@ -0,0 +1,27 @@ +local MiniTest = require("mini.test") +local eq = MiniTest.expect.equality +local child = MiniTest.new_child_neovim() + +local T = MiniTest.new_set({ + hooks = { + pre_case = function() + child.restart({ "-u", "scripts/minimal_init.lua" }) + child.lua([[require('eca.highlights').setup()]]) + end, + post_once = child.stop, + }, +}) + +T["highlights"] = MiniTest.new_set() + +T["highlights"]["defines ECA highlight groups used in sidebar"] = function() + local ok_label = child.lua_get("pcall(vim.api.nvim_get_hl, 0, { name = 'EcaLabel' })") + local ok_tool = child.lua_get("pcall(vim.api.nvim_get_hl, 0, { name = 'EcaToolCall' })") + local ok_link = child.lua_get("pcall(vim.api.nvim_get_hl, 0, { name = 'EcaHyperlink' })") + + eq(ok_label, true) + eq(ok_tool, true) + eq(ok_link, true) +end + +return T diff --git a/tests/test_picker.lua b/tests/test_picker.lua new file mode 100644 index 0000000..2a28a18 --- /dev/null +++ b/tests/test_picker.lua @@ -0,0 +1,77 @@ +local MiniTest = require("mini.test") +local eq = MiniTest.expect.equality +local child = MiniTest.new_child_neovim() + +local T = MiniTest.new_set({ + hooks = { + pre_case = function() + child.restart({ "-u", "scripts/minimal_init.lua" }) + child.lua([[ + _G.captured_notifications = {} + local Logger = require('eca.logger') + _G._original_notify = Logger.notify + Logger.notify = function(msg, level, opts) + table.insert(_G.captured_notifications, { + message = msg, + level = level, + opts = opts or {}, + }) + end + ]]) + end, + post_case = function() + child.lua([[ + local Logger = require('eca.logger') + if _G._original_notify then + Logger.notify = _G._original_notify + end + _G.captured_notifications = nil + ]]) + end, + post_once = child.stop, + }, +}) + +T["picker wrapper"] = MiniTest.new_set() + +T["picker wrapper"]["logs error when snacks is missing"] = function() + child.lua([[ + package.loaded['snacks'] = nil + local Picker = require('eca.ui.picker') + _G.result = Picker.pick({ source = 'test-source' }) + ]]) + + local result = child.lua_get("_G.result") + eq(result, vim.NIL) + + local notifications = child.lua_get("_G.captured_notifications") + eq(#notifications, 1) + eq(notifications[1].message, "snacks.nvim is not available") + eq(notifications[1].level, child.lua_get("vim.log.levels.ERROR")) +end + +T["picker wrapper"]["delegates to snacks.picker when available"] = function() + child.lua([[ + local calls = {} + package.loaded['snacks'] = { + picker = function(config) + table.insert(calls, config) + return 'OK' + end, + } + _G.snacks_calls = calls + + local Picker = require('eca.ui.picker') + _G.result = Picker.pick({ source = 'test-source', extra = true }) + ]]) + + local result = child.lua_get("_G.result") + eq(result, "OK") + + local calls = child.lua_get("_G.snacks_calls") + eq(#calls, 1) + eq(calls[1].source, "test-source") + eq(calls[1].extra, true) +end + +return T diff --git a/tests/test_server_picker_commands.lua b/tests/test_server_picker_commands.lua new file mode 100644 index 0000000..dd08581 --- /dev/null +++ b/tests/test_server_picker_commands.lua @@ -0,0 +1,182 @@ +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 50) + child.api.nvim_eval("1") +end + +local function setup_env() + require('eca.commands').setup() + + -- Stub Picker.pick so commands can run without snacks.nvim + local Picker = require('eca.ui.picker') + _G.picker_calls = {} + Picker.pick = function(config) + table.insert(_G.picker_calls, config) + end +end + +local T = MiniTest.new_set({ + hooks = { + pre_case = function() + child.restart({ "-u", "scripts/minimal_init.lua" }) + child.lua_func(setup_env) + end, + post_case = function() + child.lua("_G.picker_calls = nil") + end, + post_once = child.stop, + }, +}) + +T["EcaServerMessages"] = MiniTest.new_set() + +T["EcaServerMessages"]["uses picker and filters invalid JSON messages"] = function() + child.lua([[ + local eca = require('eca') + eca.server = eca.server or {} + eca.server.messages = { + { id = 1, direction = 'send', content = vim.json.encode({ jsonrpc = '2.0', method = 'test/method', id = 1 }) }, + { id = 2, direction = 'recv', content = 'not-json' }, + } + + vim.cmd('EcaServerMessages') + ]]) + + flush() + + child.lua([[ + local calls = _G.picker_calls or {} + local cfg = calls[1] + _G.picker_info = { + count = #calls, + source = cfg and cfg.source or nil, + } + ]]) + + local picker_info = child.lua_get("_G.picker_info") + + eq(picker_info.count, 1) + eq(picker_info.source, "eca messages") + + -- Run finder and inspect produced items + child.lua([[ + local cfg = _G.picker_calls[1] + local items = cfg.finder({}, {}) + _G.result_messages = { + count = #items, + first = items[1], + } + ]]) + + local result = child.lua_get("_G.result_messages") + + -- Only the valid JSON message should be included + eq(result.count, 1) + eq(type(result.first), "table") + eq(result.first.idx, 1) + eq(result.first.preview.ft, "lua") + + -- Preview text should contain the method name + local has_method = child.lua_get("string.find(..., 'test/method', 1, true) ~= nil", { result.first.preview.text }) + eq(has_method, true) + + -- Confirm callback yanks preview text and closes picker + child.lua([[ + local cfg = _G.picker_calls[1] + local item = cfg.finder({}, {})[1] + local picker = { closed = false } + function picker:close() self.closed = true end + + cfg.confirm(picker, item, nil) + + _G.confirm_messages = { + reg = vim.fn.getreg(''), + closed = picker.closed, + } + ]]) + + local confirm_ok = child.lua_get("_G.confirm_messages") + + eq(confirm_ok.closed, true) + eq(type(confirm_ok.reg), "string") + local has_method_in_reg = child.lua_get("string.find(..., 'test/method', 1, true) ~= nil", { confirm_ok.reg }) + eq(has_method_in_reg, true) +end + +T["EcaServerTools"] = MiniTest.new_set() + +T["EcaServerTools"]["lists tools from state in sorted order"] = function() + child.lua([[ + local eca = require('eca') + eca.state = eca.state or {} + eca.state.tools = { + zebra = { kind = 'z' }, + alpha = { kind = 'a' }, + middle = { kind = 'm' }, + } + + vim.cmd('EcaServerTools') + ]]) + + flush() + + child.lua([[ + local calls = _G.picker_calls or {} + _G.picker_info = { + count = #calls, + } + ]]) + + local picker_info = child.lua_get("_G.picker_info") + + eq(picker_info.count, 1) + + child.lua([[ + local cfg = _G.picker_calls[1] + local items = cfg.finder({}, {}) + _G.result_tools = { + count = #items, + names = { items[1].text, items[2].text, items[3].text }, + first = items[1], + } + ]]) + + local result = child.lua_get("_G.result_tools") + + eq(result.count, 3) + -- Names must be sorted alphabetically + eq(result.names[1], "alpha") + eq(result.names[2], "middle") + eq(result.names[3], "zebra") + + -- Preview contains vim.inspect output of the tool + local has_kind = child.lua_get("string.find(..., 'kind%p a', 1) ~= nil", { result.first.preview.text }) + eq(has_kind, true) + + -- Confirm yanks preview text and closes picker + child.lua([[ + local cfg = _G.picker_calls[1] + local items = cfg.finder({}, {}) + local picker = { closed = false } + function picker:close() self.closed = true end + + cfg.confirm(picker, items[2], nil) + + _G.confirm_tools = { + reg = vim.fn.getreg(''), + closed = picker.closed, + } + ]]) + + local confirm_ok = child.lua_get("_G.confirm_tools") + + eq(confirm_ok.closed, true) + eq(type(confirm_ok.reg), "string") + local has_middle = child.lua_get("string.find(..., 'middle', 1, true) ~= nil", { confirm_ok.reg }) + eq(has_middle, true) +end + +return T diff --git a/tests/test_sidebar_usage_and_tools.lua b/tests/test_sidebar_usage_and_tools.lua new file mode 100644 index 0000000..87c5220 --- /dev/null +++ b/tests/test_sidebar_usage_and_tools.lua @@ -0,0 +1,501 @@ +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 80) + child.api.nvim_eval("1") +end + +local function setup_env() + -- Minimal environment with 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) + + -- Open 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_env) + end, + post_case = function() + child.lua([[ if _G.Sidebar then _G.Sidebar:close() end ]]) + end, + post_once = child.stop, + }, +}) + +T["usage formatting"] = MiniTest.new_set() + +T["usage formatting"]["uses default short token format"] = function() + child.lua([[ + local Sidebar = _G.Sidebar + local Mediator = _G.Mediator + + -- Stub mediator usage values + function Mediator:status_state() return 'responding' end + function Mediator:status_text() return 'Responding' end + function Mediator:tokens_session() return 1499 end + function Mediator:tokens_limit() return 1501 end + function Mediator:costs_session() return '0.00' end + + Sidebar:_update_usage_info() + ]]) + + local info = child.lua_get("_G.Sidebar._usage_info") + -- 1499 -> 1k, 1501 -> 2k with rounding + eq(info, "1k / 2k ($0.00)") +end + +T["usage formatting"]["respects custom windows.usage.format"] = function() + child.lua([[ + local Config = require('eca.config') + Config.override({ + windows = { + usage = { + format = '{session_tokens} of {limit_tokens} tokens (${session_cost})', + }, + }, + }) + + local Sidebar = _G.Sidebar + local Mediator = _G.Mediator + + function Mediator:status_state() return 'responding' end + function Mediator:status_text() return 'Responding' end + function Mediator:tokens_session() return 42 end + function Mediator:tokens_limit() return 1000 end + function Mediator:costs_session() return '1.23' end + + Sidebar:_update_usage_info() + ]]) + + local info = child.lua_get("_G.Sidebar._usage_info") + eq(info, "42 of 1000 tokens ($1.23)") +end + +T["tool call diffs"] = MiniTest.new_set() + +T["tool call diffs"]["shows diff label and toggles diff block with "] = function() + child.lua([[ + local Sidebar = _G.Sidebar + local chat = Sidebar.containers.chat + + -- Simulate a tool call lifecycle with diff + Sidebar:handle_chat_content_received({ + chatId = 'chat-1', + content = { + type = 'toolCallPrepare', + id = 'tool-1', + name = 'test_tool', + summary = 'Test Tool', + argumentsText = '{"value": 1}', + details = {}, + }, + }) + + Sidebar:handle_chat_content_received({ + chatId = 'chat-1', + content = { + type = 'toolCallRunning', + id = 'tool-1', + }, + }) + + Sidebar:handle_chat_content_received({ + chatId = 'chat-1', + content = { + type = 'toolCalled', + id = 'tool-1', + name = 'test_tool', + summary = 'Test Tool', + details = { diff = '+added\n-removed' }, + }, + }) + ]]) + + flush(100) + + child.lua([[ + local Sidebar = _G.Sidebar + local call = Sidebar._tool_calls[1] + local buf = Sidebar.containers.chat.bufnr + _G.call_info = { + has_diff = call and call.has_diff or false, + header_line = call and call.header_line or 0, + label_line = call and call.label_line or 0, + header_text = call and vim.api.nvim_buf_get_lines(buf, call.header_line - 1, call.header_line, false)[1] or '', + label_text = call and vim.api.nvim_buf_get_lines(buf, call.label_line - 1, call.label_line, false)[1] or '', + } + ]]) + + local call_info = child.lua_get("_G.call_info") + + eq(call_info.has_diff, true) + eq(call_info.label_line > 0, true) + + -- Default collapsed label + eq(call_info.label_text, "+ view diff") + + -- Header should mention the summary + local has_summary = child.lua_get("string.find(..., 'Test Tool', 1, true) ~= nil", { call_info.header_text }) + eq(has_summary, true) + + -- Toggle on the diff label line should expand/collapse only the diff block + child.lua([[ + local Sidebar = _G.Sidebar + local call = Sidebar._tool_calls[1] + local chat = Sidebar.containers.chat + + vim.api.nvim_win_set_cursor(chat.winid, { call.label_line, 0 }) + Sidebar:_toggle_tool_call_at_cursor() -- expand diff + ]]) + + flush(50) + + child.lua([[ + local Sidebar = _G.Sidebar + local call = Sidebar._tool_calls[1] + local buf = Sidebar.containers.chat.bufnr + local first_diff = vim.api.nvim_buf_get_lines(buf, call.label_line, call.label_line + 1, false)[1] or '' + _G.expanded_info = { + diff_expanded = call.diff_expanded, + first_diff = first_diff, + } + ]]) + + local expanded = child.lua_get("_G.expanded_info") + + eq(expanded.diff_expanded, true) + eq(expanded.first_diff, "```diff") + + -- Collapse again + child.lua([[ + local Sidebar = _G.Sidebar + local call = Sidebar._tool_calls[1] + local chat = Sidebar.containers.chat + + vim.api.nvim_win_set_cursor(chat.winid, { call.label_line, 0 }) + Sidebar:_toggle_tool_call_at_cursor() -- collapse diff + ]]) + + flush(50) + + local collapsed = child.lua_get("_G.Sidebar._tool_calls[1].diff_expanded") + eq(collapsed, false) +end + +T["reasoning blocks"] = MiniTest.new_set() + +T["reasoning blocks"]["stream reasoning and toggle body"] = function() + child.lua([[ + local Sidebar = _G.Sidebar + + Sidebar:handle_chat_content_received({ + chatId = 'chat-r', + content = { type = 'reasonStarted', id = 'r1' }, + }) + + Sidebar:handle_chat_content_received({ + chatId = 'chat-r', + content = { type = 'reasonText', id = 'r1', text = 'First line.\nSecond line.' }, + }) + + Sidebar:handle_chat_content_received({ + chatId = 'chat-r', + content = { type = 'reasonFinished', id = 'r1', totalTimeMs = 1234 }, + }) + ]]) + + flush(100) + + child.lua([[ + local Sidebar = _G.Sidebar + local call = Sidebar._reasons['r1'] + local buf = Sidebar.containers.chat.bufnr + local header = call and vim.api.nvim_buf_get_lines(buf, call.header_line - 1, call.header_line, false)[1] or '' + _G.reason_info = { + has_reason = call ~= nil, + header_line = call and call.header_line or 0, + header = header, + } + ]]) + + local info = child.lua_get("_G.reason_info") + + eq(info.has_reason, true) + + -- Header should contain the finished label and elapsed time + local has_thought = child.lua_get("string.find(..., 'Thought', 1, true) ~= nil", { info.header }) + eq(has_thought, true) + + local has_secs = child.lua_get("string.find(..., 's', 1, true) ~= nil", { info.header }) + eq(has_secs, true) + + -- Toggle body via header line + child.lua([[ + local Sidebar = _G.Sidebar + local call = Sidebar._reasons['r1'] + local chat = Sidebar.containers.chat + vim.api.nvim_win_set_cursor(chat.winid, { call.header_line, 0 }) + Sidebar:_toggle_tool_call_at_cursor() + ]]) + + flush(80) + + child.lua([[ + local Sidebar = _G.Sidebar + local call = Sidebar._reasons['r1'] + local buf = Sidebar.containers.chat.bufnr + local first = vim.api.nvim_buf_get_lines(buf, call.header_line, call.header_line + 1, false)[1] or '' + _G.reason_body = { + expanded = call.expanded, + first = first, + } + ]]) + + local body = child.lua_get("_G.reason_body") + + eq(body.expanded, true) + local has_first_line = child.lua_get("string.find(..., 'First line.', 1, true) ~= nil", { body.first }) + eq(has_first_line, true) +end + +T["replace_text"] = MiniTest.new_set() + +T["replace_text"]["supports multi-line replacement"] = function() + child.lua([[ + local Sidebar = _G.Sidebar + local chat = Sidebar.containers.chat + + vim.api.nvim_buf_set_lines(chat.bufnr, 0, -1, false, { + 'prefix TARGET suffix', + 'other line', + }) + + _G.changed = Sidebar:_replace_text('TARGET', 'foo\nbar') + _G.lines = vim.api.nvim_buf_get_lines(chat.bufnr, 0, -1, false) + ]]) + + local changed = child.lua_get("_G.changed") + eq(changed, true) + + local lines = child.lua_get("_G.lines") + eq(lines[1], "prefix foo") + eq(lines[2], "bar suffix") + eq(lines[3], "other line") +end + +T["tool call config"] = MiniTest.new_set() + +T["tool call config"]["respects windows.chat.tool_call.diff labels and expanded flag"] = function() + child.lua([[ + local Config = require('eca.config') + Config.override({ + windows = { + chat = { + tool_call = { + diff = { + collapsed_label = '[open diff]', + expanded_label = '[close diff]', + expanded = true, + }, + }, + }, + }, + }) + + local Sidebar = _G.Sidebar + + Sidebar:handle_chat_content_received({ + chatId = 'chat-cfg', + content = { + type = 'toolCallPrepare', + id = 'cfg-tool', + name = 'cfg_tool', + summary = 'Config Tool', + argumentsText = '{"foo": 1}', + details = {}, + }, + }) + + Sidebar:handle_chat_content_received({ + chatId = 'chat-cfg', + content = { + type = 'toolCallRunning', + id = 'cfg-tool', + }, + }) + + Sidebar:handle_chat_content_received({ + chatId = 'chat-cfg', + content = { + type = 'toolCalled', + id = 'cfg-tool', + name = 'cfg_tool', + summary = 'Config Tool', + details = { diff = '+x\n-y' }, + }, + }) + ]]) + + flush(100) + + child.lua([[ + local Sidebar = _G.Sidebar + local call = Sidebar._tool_calls[1] + local buf = Sidebar.containers.chat.bufnr + _G.cfg_call = { + label_line = call and call.label_line or 0, + label_text = call and vim.api.nvim_buf_get_lines(buf, call.label_line - 1, call.label_line, false)[1] or '', + diff_expanded = call and call.diff_expanded or false, + first_diff = call and vim.api.nvim_buf_get_lines(buf, call.label_line, call.label_line + 1, false)[1] or '', + } + ]]) + + local cfg = child.lua_get("_G.cfg_call") + + eq(cfg.label_text, "[close diff]") + eq(cfg.diff_expanded, true) + eq(cfg.first_diff, "```diff") +end + +T["reasoning blocks"]["use configured labels"] = function() + child.lua([[ + local Config = require('eca.config') + Config.override({ + windows = { + chat = { + reasoning = { + running_label = 'Working...', + finished_label = 'Plan', + }, + }, + }, + }) + + local Sidebar = _G.Sidebar + + Sidebar:handle_chat_content_received({ + chatId = 'chat-r2', + content = { type = 'reasonStarted', id = 'r2' }, + }) + + Sidebar:handle_chat_content_received({ + chatId = 'chat-r2', + content = { type = 'reasonFinished', id = 'r2', totalTimeMs = 500 }, + }) + ]]) + + flush(80) + + child.lua([[ + local Sidebar = _G.Sidebar + local call = Sidebar._reasons['r2'] + local buf = Sidebar.containers.chat.bufnr + _G.reason_cfg = { + header = call and vim.api.nvim_buf_get_lines(buf, call.header_line - 1, call.header_line, false)[1] or '', + } + ]]) + + local rcfg = child.lua_get("_G.reason_cfg") + + local has_plan = child.lua_get("string.find(..., 'Plan', 1, true) ~= nil", { rcfg.header }) + eq(has_plan, true) +end + +T["reasoning blocks"]["start expanded when configured"] = function() + child.lua([[ + local Config = require('eca.config') + Config.override({ + windows = { + chat = { + reasoning = { + expanded = true, + }, + }, + }, + }) + + local Sidebar = _G.Sidebar + + Sidebar:handle_chat_content_received({ + chatId = 'chat-r3', + content = { type = 'reasonStarted', id = 'r3' }, + }) + + Sidebar:handle_chat_content_received({ + chatId = 'chat-r3', + content = { type = 'reasonText', id = 'r3', text = 'First line.' }, + }) + ]]) + + flush(80) + + child.lua([[ + local Sidebar = _G.Sidebar + local call = Sidebar._reasons['r3'] + local buf = Sidebar.containers.chat.bufnr + _G.reason_expanded = { + expanded = call and call.expanded or false, + first = call and vim.api.nvim_buf_get_lines(buf, call.header_line, call.header_line + 1, false)[1] or '', + } + ]]) + + local r = child.lua_get("_G.reason_expanded") + + eq(r.expanded, true) + local has_first = child.lua_get("string.find(..., 'First line.', 1, true) ~= nil", { r.first }) + eq(has_first, true) +end + +T["mcps display"] = MiniTest.new_set() + +T["mcps display"]["shows active and registered counts with highlights"] = function() + child.lua([[ + local Sidebar = _G.Sidebar + local Mediator = _G.Mediator + + function Mediator:selected_model() return 'gpt' end + function Mediator:selected_behavior() return 'default' end + + function Mediator:mcps() + return { + a = { status = 'starting' }, + b = { status = 'running' }, + c = { status = 'failed' }, + } + end + + Sidebar:_update_config_display() + ]]) + + child.lua([[ + local Sidebar = _G.Sidebar + local buf = Sidebar.containers.config.bufnr + local ns = Sidebar.extmarks.config._ns + local marks = vim.api.nvim_buf_get_extmarks(buf, ns, 0, -1, { details = true }) + local virt = marks[1][4].virt_text + _G.mcps_info = { + active_text = virt[8][1], + active_hl = virt[8][2], + registered_text = virt[10][1], + registered_hl = virt[10][2], + } + ]]) + + local info = child.lua_get("_G.mcps_info") + + eq(info.active_text, "2") -- starting + running + eq(info.active_hl, "EcaLabel") + eq(info.registered_text, "3") + eq(info.registered_hl, "Exception") +end + +return T From d09ba81de8588a5a0601f595045133268b0837ac Mon Sep 17 00:00:00 2001 From: joaopluigi Date: Tue, 6 Jan 2026 10:29:47 -0300 Subject: [PATCH 22/31] tool call improvements --- lua/eca/sidebar.lua | 188 +++++++++++++++++++++---- tests/test_sidebar_usage_and_tools.lua | 171 ++++++++++++++++++++++ 2 files changed, 334 insertions(+), 25 deletions(-) diff --git a/lua/eca/sidebar.lua b/lua/eca/sidebar.lua index 8275504..4936f08 100644 --- a/lua/eca/sidebar.lua +++ b/lua/eca/sidebar.lua @@ -1300,6 +1300,27 @@ function M:handle_chat_content_received(params) elseif content.type == "toolCalled" then local tool_text = self:_tool_call_text(content) + -- If this tool call reports a file change, append the basename of the + -- path to the summary shown in the chat so users can immediately see + -- which file was touched. + local details = content.details + if details and type(details) == "table" and details.type == "fileChange" then + local path = details.path + if path and path ~= "" then + local filename = vim.fn.fnamemodify(path, ":t") + if filename and filename ~= "" then + -- Avoid duplicating the filename if it is already present + if tool_text and tool_text ~= "" then + if not string.find(tool_text, filename, 1, true) then + tool_text = string.format("%s %s", tool_text, filename) + end + else + tool_text = filename + end + end + end + end + -- Add diff to current tool call if present in toolCalled content if self._current_tool_call and content.details then self._current_tool_call.details = content.details @@ -1307,12 +1328,28 @@ function M:handle_chat_content_received(params) -- Show the tool result in logs only local tool_log = string.format("**Tool Result**: %s", content.name or "unknown") + local outputs_text = nil + local outputs_type = nil if content.outputs and #content.outputs > 0 then + local pieces = {} for _, output in ipairs(content.outputs) do - if output.type == "text" and output.content then - tool_log = tool_log .. "\n" .. output.content + if output.type == "text" then + local txt = output.text or output.content + if txt and txt ~= "" then + table.insert(pieces, txt) + tool_log = tool_log .. "\n" .. txt + outputs_type = outputs_type or output.type + end + else + -- Even if we don't render non-text payloads directly, remember + -- their reported type so that any displayed block can still + -- use an appropriate fence language. + outputs_type = outputs_type or output.type end end + if #pieces > 0 then + outputs_text = table.concat(pieces, "\n") + end end Logger.debug(tool_log) @@ -1334,7 +1371,6 @@ function M:handle_chat_content_received(params) if content.details then call.details = content.details call.has_diff = self:_has_details_diff(call.details) - call.arguments_lines = self:_build_tool_call_arguments_lines(call.arguments) call.diff_lines = self:_build_tool_call_diff_lines(call.details) call.details_lines = nil call.details_line_count = 0 @@ -1348,6 +1384,13 @@ function M:handle_chat_content_received(params) end end + -- Always refresh stored outputs/argument lines when we get a final toolCalled event + if outputs_text and outputs_text ~= "" then + call.outputs = outputs_text + call.outputs_type = outputs_type or call.outputs_type + end + call.arguments_lines = self:_build_tool_call_arguments_lines(call.arguments, call.outputs, call.outputs_type) + call.status = status_icon call.title = tool_text or call.title @@ -1357,7 +1400,9 @@ function M:handle_chat_content_received(params) -- Create a new entry for tool calls that didn't have a running phase local details = content.details or {} local arguments = self._current_tool_call and self._current_tool_call.arguments or "" - local arguments_lines = self:_build_tool_call_arguments_lines(arguments) + local outputs = outputs_text or "" + local outputs_type_value = outputs_type + local arguments_lines = self:_build_tool_call_arguments_lines(arguments, outputs, outputs_type_value) local diff_lines = self:_build_tool_call_diff_lines(details) local has_diff = self:_has_details_diff(details) @@ -1370,6 +1415,8 @@ function M:handle_chat_content_received(params) status = status_icon, arguments = arguments, details = details, + outputs = outputs, + outputs_type = outputs_type_value, has_diff = has_diff, label_line = nil, -- Separate storage for arguments vs diff so each can be toggled independently @@ -1811,6 +1858,7 @@ function M:_handle_tool_call_prepare(content) summary = "", arguments = "", details = {}, + outputs = "", } end @@ -1889,7 +1937,11 @@ function M:_display_tool_call(content) end -- Build detail lines from current state (arguments and diff are controlled separately) - local arguments_lines = self:_build_tool_call_arguments_lines(self._current_tool_call.arguments) + local arguments_lines = self:_build_tool_call_arguments_lines( + self._current_tool_call.arguments, + self._current_tool_call.outputs, + self._current_tool_call.outputs_type + ) local diff_lines = self:_build_tool_call_diff_lines(self._current_tool_call.details) local has_diff = self:_has_details_diff(self._current_tool_call.details) @@ -1927,6 +1979,7 @@ function M:_display_tool_call(content) status = nil, arguments = self._current_tool_call.arguments or "", details = self._current_tool_call.details or {}, + outputs = self._current_tool_call.outputs or "", has_diff = has_diff, label_line = nil, -- Store arguments and diff lines separately so they can be toggled independently @@ -2070,6 +2123,9 @@ function M:_handle_reason_text(content) call.arguments = (call.arguments or "") .. content.text -- Build plain text block for the reasoning body (no markdown quote prefix) + -- We intentionally do not insert a leading blank spacer line here so + -- that the first line immediately below the header contains the first + -- piece of reasoning text (this matches the expectations in tests). call.arguments_lines = {} local lines = Utils.split_lines(call.arguments or "") @@ -2078,9 +2134,6 @@ function M:_handle_reason_text(content) table.insert(call.arguments_lines, line) end - -- Trailing blank line for spacing - table.insert(call.arguments_lines, "") - -- If the reasoning block is expanded, update its region in the buffer in-place if call.expanded then local prev_count = call._last_arguments_count or 0 @@ -2103,6 +2156,11 @@ function M:_handle_reason_text(content) call._last_arguments_count = new_count end + + -- Ensure the header arrow reflects whether there is body content to show. + -- This will add the expand/collapse icon once we have streamed some + -- reasoning text, and it will be omitted while the body is still empty. + self:_update_tool_call_header_line(call) end -- Mark reasoning as finished and update the header icon @@ -2191,24 +2249,49 @@ end -- Build the header text for a tool call like: "▶ summary ⏳" (or "▶ summary ✅" / "▶ summary ❌") function M:_build_tool_call_header_text(call) local icons = get_tool_call_icons() - local arrow = call.expanded and icons.expanded or icons.collapsed - - -- Normalize all pieces to strings to avoid issues when configuration or - -- status fields accidentally contain non-string values (e.g. userdata). - if type(arrow) ~= "string" then - arrow = tostring(arrow) - end -- Reasoning ("Thinking") entries do not show status icons; they only - -- display the toggle arrow and a text label ("Thinking..." / "Thought"). + -- display a toggle arrow (when there is body content to show) and a + -- text label ("Thinking..." / "Thought"). if call.is_reason then local title = call.title or "Thinking..." if type(title) ~= "string" then title = tostring(title) end + + -- Only show the expand/collapse arrow once we actually have some + -- reasoning text to display. This avoids showing a useless toggle + -- while the model is still preparing its thoughts. + local has_body = false + if call.arguments and type(call.arguments) == "string" and call.arguments:match("%S") then + has_body = true + elseif call.arguments_lines and #call.arguments_lines > 0 then + has_body = true + end + + if not has_body then + return title + end + + local arrow = call.expanded and icons.expanded or icons.collapsed + if type(arrow) ~= "string" then + arrow = tostring(arrow) + end + return table.concat({ arrow, title }, " ") end + -- Regular tool calls always show an expand/collapse arrow. The arrow + -- controls the visibility of the arguments block; any diff is toggled + -- separately via the "view diff" label. + local arrow = call.expanded and icons.expanded or icons.collapsed + + -- Normalize all pieces to strings to avoid issues when configuration or + -- status fields accidentally contain non-string values (e.g. userdata). + if type(arrow) ~= "string" then + arrow = tostring(arrow) + end + local title = call.title or "Tool call" local status = call.status or icons.running @@ -2224,11 +2307,20 @@ function M:_build_tool_call_header_text(call) return table.concat(parts, " ") end --- Build the argument detail lines (tool call arguments only) -function M:_build_tool_call_arguments_lines(arguments) + -- Build the argument and output detail lines for a tool call. +-- Arguments and outputs are shown in separate labeled sections. +function M:_build_tool_call_arguments_lines(arguments, outputs, outputs_type) local lines = {} + local has_content = false if arguments and arguments ~= "" then + if not has_content then + -- Spacer between the header and the first line of the block + table.insert(lines, "") + has_content = true + end + + table.insert(lines, "Arguments:") table.insert(lines, "```json") for _, line in ipairs(Utils.split_lines(arguments)) do table.insert(lines, line) @@ -2237,6 +2329,42 @@ function M:_build_tool_call_arguments_lines(arguments) table.insert(lines, "") end + if outputs and outputs ~= "" then + if not has_content then + -- Spacer between the header and the first line of the block + table.insert(lines, "") + has_content = true + end + + table.insert(lines, "Output:") + + -- Choose fence language based on reported output type. When the tool + -- says the output type is "text", render it as a plain text fence + -- instead of JSON. For unknown types we keep using "json" for + -- backwards compatibility. + local lang = "json" + if type(outputs_type) == "string" and outputs_type ~= "" then + if outputs_type == "text" then + lang = "text" + else + lang = outputs_type + end + end + + table.insert(lines, "```" .. lang) + for _, line in ipairs(Utils.split_lines(outputs)) do + table.insert(lines, line) + end + table.insert(lines, "```") + table.insert(lines, "") + end + + -- Remove any trailing blank lines; we keep internal spacing (for example + -- between the Arguments and Output sections) intact. + while #lines > 0 and lines[#lines]:match("^%s*$") do + table.remove(lines) + end + return lines end @@ -2246,12 +2374,19 @@ function M:_build_tool_call_diff_lines(details) local diff = details and details.diff or nil if diff and diff ~= "" then + -- Start the diff block directly with the fenced header so that + -- the first inserted line is the ```diff fence. Tests expect the + -- first diff line immediately below the label to be this fence. table.insert(lines, "```diff") for _, line in ipairs(Utils.split_lines(diff)) do table.insert(lines, line) end table.insert(lines, "```") - table.insert(lines, "") + end + + -- Remove any trailing blank lines just in case + while #lines > 0 and lines[#lines]:match("^%s*$") do + table.remove(lines) end return lines @@ -2260,10 +2395,10 @@ end -- Optional helper to build combined detail lines (arguments + diff) -- NOTE: callers that need independent control over arguments vs diff -- should prefer _build_tool_call_arguments_lines/_build_tool_call_diff_lines. -function M:_build_tool_call_details_lines(arguments, details) +function M:_build_tool_call_details_lines(arguments, details, outputs, outputs_type) local lines = {} - local arg_lines = self:_build_tool_call_arguments_lines(arguments) + local arg_lines = self:_build_tool_call_arguments_lines(arguments, outputs, outputs_type) for _, line in ipairs(arg_lines) do table.insert(lines, line) end @@ -2532,14 +2667,17 @@ function M:_expand_tool_call(call) -- wrap them in code fences and the streaming handler is responsible for -- keeping the body up to date. Here we just insert the current body once. if call.is_reason then - -- Build markdown fenced block if we don't have it yet + -- Build a plain text block if we don't have it yet. We keep the + -- first inserted line as the first line of reasoning text rather + -- than a blank spacer so that navigation from the header lands on + -- the actual content. if not call.arguments_lines or #call.arguments_lines == 0 then call.arguments_lines = {} for _, line in ipairs(Utils.split_lines(call.arguments or "")) do + line = line ~= "" and line or " " table.insert(call.arguments_lines, line) end - table.insert(call.arguments_lines, "") end local count = call.arguments_lines and #call.arguments_lines or 0 @@ -2571,8 +2709,8 @@ function M:_expand_tool_call(call) return end - -- Regular tool calls: show JSON arguments block - call.arguments_lines = call.arguments_lines or self:_build_tool_call_arguments_lines(call.arguments) + -- Regular tool calls: show JSON arguments and output blocks + call.arguments_lines = call.arguments_lines or self:_build_tool_call_arguments_lines(call.arguments, call.outputs) local count = call.arguments_lines and #call.arguments_lines or 0 if count == 0 then return diff --git a/tests/test_sidebar_usage_and_tools.lua b/tests/test_sidebar_usage_and_tools.lua index 87c5220..20873b7 100644 --- a/tests/test_sidebar_usage_and_tools.lua +++ b/tests/test_sidebar_usage_and_tools.lua @@ -82,6 +82,93 @@ end T["tool call diffs"] = MiniTest.new_set() +T["tool call diffs"]["shows arguments and outputs sections when expanded"] = function() + child.lua([[ + local Sidebar = _G.Sidebar + + -- Simulate a tool call lifecycle with arguments and outputs (no diff) + Sidebar:handle_chat_content_received({ + chatId = 'chat-out', + content = { + type = 'toolCallPrepare', + id = 'tool-out', + name = 'test_tool', + summary = 'Test Tool', + argumentsText = '{"value": 1}', + details = {}, + }, + }) + + Sidebar:handle_chat_content_received({ + chatId = 'chat-out', + content = { + type = 'toolCallRunning', + id = 'tool-out', + }, + }) + + Sidebar:handle_chat_content_received({ + chatId = 'chat-out', + content = { + type = 'toolCalled', + id = 'tool-out', + name = 'test_tool', + summary = 'Test Tool', + outputs = { + { type = 'text', text = 'tool-output-123' }, + }, + }, + }) + ]]) + + flush(100) + + child.lua([[ + local Sidebar = _G.Sidebar + local call = Sidebar._tool_calls[1] + local chat = Sidebar.containers.chat + + if not call or not chat or not chat.bufnr then + _G.tool_details = { expanded = false, has_arguments = false, has_output = false, has_output_text = false } + return + end + + -- Expand the arguments/output block via the header + vim.api.nvim_win_set_cursor(chat.winid, { call.header_line, 0 }) + Sidebar:_toggle_tool_call_at_cursor() + + local buf = chat.bufnr + local lines = vim.api.nvim_buf_get_lines(buf, call.header_line, call.header_line + 20, false) + + local details = { + expanded = call.expanded, + has_arguments = false, + has_output = false, + has_output_text = false, + } + + for _, line in ipairs(lines) do + if line == 'Arguments:' then + details.has_arguments = true + elseif line == 'Output:' then + details.has_output = true + end + if string.find(line, 'tool-output-123', 1, true) then + details.has_output_text = true + end + end + + _G.tool_details = details + ]]) + + local info = child.lua_get("_G.tool_details") + + eq(info.expanded, true) + eq(info.has_arguments, true) + eq(info.has_output, true) + eq(info.has_output_text, true) +end + T["tool call diffs"]["shows diff label and toggles diff block with "] = function() child.lua([[ local Sidebar = _G.Sidebar @@ -455,6 +542,33 @@ T["reasoning blocks"]["start expanded when configured"] = function() eq(has_first, true) end +T["reasoning blocks"]["hide arrow until there is body text"] = function() + child.lua([[ + local Sidebar = _G.Sidebar + + Sidebar:handle_chat_content_received({ + chatId = 'chat-r0', + content = { type = 'reasonStarted', id = 'r0' }, + }) + ]]) + + flush(50) + + child.lua([[ + local Sidebar = _G.Sidebar + local call = Sidebar._reasons['r0'] + local buf = Sidebar.containers.chat.bufnr + _G.reason_header = call and vim.api.nvim_buf_get_lines(buf, call.header_line - 1, call.header_line, false)[1] or '' + ]]) + + local header = child.lua_get("_G.reason_header") + + -- With no streamed reasoning text yet, the header should not show + -- the expand/collapse arrow icon. + local has_arrow = child.lua_get("string.find(..., '▶', 1, true) ~= nil", { header }) + eq(has_arrow, false) +end + T["mcps display"] = MiniTest.new_set() T["mcps display"]["shows active and registered counts with highlights"] = function() @@ -498,4 +612,61 @@ T["mcps display"]["shows active and registered counts with highlights"] = functi eq(info.registered_hl, "Exception") end +T["tool call summaries"] = MiniTest.new_set() + +T["tool call summaries"]["appends filename for fileChange details"] = function() + child.lua([[ + local Sidebar = _G.Sidebar + + -- Simulate a tool call lifecycle that reports a file change + Sidebar:handle_chat_content_received({ + chatId = 'chat-file', + content = { + type = 'toolCallPrepare', + id = 'tool-file', + name = 'write_file', + summary = 'Apply edit', + argumentsText = '{"value": 1}', + details = {}, + }, + }) + + Sidebar:handle_chat_content_received({ + chatId = 'chat-file', + content = { + type = 'toolCallRunning', + id = 'tool-file', + }, + }) + + Sidebar:handle_chat_content_received({ + chatId = 'chat-file', + content = { + type = 'toolCalled', + id = 'tool-file', + name = 'write_file', + summary = 'Apply edit', + details = { type = 'fileChange', path = '/tmp/example/foo.lua', diff = '+added' }, + }, + }) + ]]) + + flush(100) + + child.lua([[ + local Sidebar = _G.Sidebar + local call = Sidebar._tool_calls[1] + local buf = Sidebar.containers.chat.bufnr + _G.file_summary = { + header = call and vim.api.nvim_buf_get_lines(buf, call.header_line - 1, call.header_line, false)[1] or '', + } + ]]) + + local info = child.lua_get("_G.file_summary") + + -- Header should mention the basename of the changed file + local has_filename = child.lua_get("string.find(..., 'foo.lua', 1, true) ~= nil", { info.header }) + eq(has_filename, true) +end + return T From 824e793d61a2a76226f25bea395ad47cf1066698 Mon Sep 17 00:00:00 2001 From: joaopluigi Date: Tue, 6 Jan 2026 15:15:54 -0300 Subject: [PATCH 23/31] make stream tick be a queue instead --- lua/eca/config.lua | 5 + lua/eca/sidebar.lua | 268 ++++------------ lua/eca/stream_queue.lua | 113 +++++++ lua/eca/utils.lua | 89 ++++++ tests/test_stream_queue.lua | 619 ++++++++++++++++++++++++++++++++++++ tests/test_utils.lua | 188 +++++++++++ 6 files changed, 1075 insertions(+), 207 deletions(-) create mode 100644 lua/eca/stream_queue.lua create mode 100644 tests/test_stream_queue.lua create mode 100644 tests/test_utils.lua diff --git a/lua/eca/config.lua b/lua/eca/config.lua index 9815009..1dff2ff 100644 --- a/lua/eca/config.lua +++ b/lua/eca/config.lua @@ -72,6 +72,11 @@ M._defaults = { "Type your message and use CTRL+s to send", -- Tips appended under the welcome (set empty list {} to disable) }, }, + typing = { + enabled = true, -- Enable typewriter effect for streaming responses + chars_per_tick = 1, -- Number of characters to display per tick (1 = realistic typing) + tick_delay = 10, -- Delay in milliseconds between ticks (lower = faster) + }, tool_call = { icons = { success = "✅", -- Shown when a tool call succeeds diff --git a/lua/eca/sidebar.lua b/lua/eca/sidebar.lua index 4936f08..4658e30 100644 --- a/lua/eca/sidebar.lua +++ b/lua/eca/sidebar.lua @@ -1,6 +1,7 @@ local Utils = require("eca.utils") local Logger = require("eca.logger") local Config = require("eca.config") +local StreamQueue = require("eca.stream_queue") -- Load nui.nvim components (required dependency) local Split = require("nui.split") @@ -26,6 +27,8 @@ local Split = require("nui.split") ---@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 ---@field private _reasons table Map of in-flight reasoning entries keyed by id +---@field private _stream_queue eca.StreamQueue Queue for streaming text display +---@field private _stream_visible_buffer string Accumulated visible text during streaming local M = {} M.__index = M @@ -36,15 +39,6 @@ local WINDOW_MARGIN = 3 -- Additional margin for window borders and spacing local UI_ELEMENTS_HEIGHT = 2 -- Reserve space for statusline and tabline local SAFETY_MARGIN = 2 -- Extra margin to prevent "Not enough room" errors -local function _shorten_tokens(n) - n = tonumber(n) or 0 - if n >= 1000 then - local rounded = math.floor(n / 1000 + 0.5) - return string.format("%dk", rounded) - end - return tostring(n) -end - local function _format_usage(tokens, limit, costs) local usage_cfg = (Config.windows and Config.windows.usage) or {} local fmt = usage_cfg.format @@ -54,8 +48,8 @@ local function _format_usage(tokens, limit, costs) local placeholders = { session_tokens = tostring(tokens or 0), limit_tokens = tostring(limit or 0), - session_tokens_short = _shorten_tokens(tokens), - limit_tokens_short = _shorten_tokens(limit), + session_tokens_short = Utils.shorten_tokens(tokens), + limit_tokens_short = Utils.shorten_tokens(limit), session_cost = tostring(costs or "0.00"), } @@ -66,104 +60,11 @@ local function _format_usage(tokens, limit, costs) return result end --- Tool call icons (can be overridden via Config.windows.chat.tool_call.icons) -local function _get_chat_config() - -- Merge top-level `chat` (backwards compatible) with `windows.chat`. - -- `windows.chat` provides modern defaults, while a user-provided - -- `chat.tool_call` block (legacy style) can still override fields - -- like `diff_label` and `diff_start_expanded`. - local win_chat = (Config.windows and Config.windows.chat) or {} - local top_chat = Config.chat or {} - - if next(top_chat) == nil then - return win_chat - end - - return vim.tbl_deep_extend("force", win_chat, top_chat) -end - -local function get_tool_call_icons() - local chat_cfg = _get_chat_config() - local icons_cfg = (chat_cfg.tool_call and chat_cfg.tool_call.icons) or {} - return { - success = icons_cfg.success or "✅", - error = icons_cfg.error or "❌", - running = icons_cfg.running or "⏳", - expanded = icons_cfg.expanded or "▲", - collapsed = icons_cfg.collapsed or "▶", - } -end - --- Label texts used for tool call diffs. --- --- Preferred configuration (under `windows.chat.tool_call`): --- tool_call = { --- diff = { --- collapsed_label = "+ view diff", -- Label when the diff is collapsed --- expanded_label = "- view diff", -- Label when the diff is expanded --- expanded = false, -- When true, tool diffs start expanded --- }, --- } --- --- Backwards compatibility: --- - `diff_label = { collapsed, expanded }` (older table format) --- - `diff_label_collapsed` / `diff_label_expanded` (flat keys) --- - `diff_start_expanded` (boolean flag) to control initial expansion -local function get_tool_call_diff_labels() - local chat_cfg = _get_chat_config() - local cfg = chat_cfg.tool_call or {} - - local diff_cfg = cfg.diff or {} - local labels_cfg = cfg.diff_label or {} - - local collapsed = diff_cfg.collapsed_label - or labels_cfg.collapsed - or cfg.diff_label_collapsed - or "+ view diff" - - local expanded = diff_cfg.expanded_label - or labels_cfg.expanded - or cfg.diff_label_expanded - or "- view diff" - - return { - collapsed = collapsed, - expanded = expanded, - } -end - -local function should_start_diff_expanded() - local chat_cfg = _get_chat_config() - local cfg = chat_cfg.tool_call or {} - local diff_cfg = cfg.diff or {} - - if diff_cfg.expanded ~= nil then - return diff_cfg.expanded == true - end - - if cfg.diff_start_expanded ~= nil then - return cfg.diff_start_expanded == true - end - - return false -end - -local function get_reasoning_labels() - local chat_cfg = _get_chat_config() - local cfg = chat_cfg.reasoning or {} - local running = cfg.running_label or "Thinking..." - local finished = cfg.finished_label or "Thought" - - return { - running = running, - finished = finished, - } -end - ---@param id integer Tab ID ---@param mediator eca.Mediator ---@return eca.Sidebar function M.new(id, mediator) + local chat_cfg = Utils.get_chat_config() local instance = setmetatable({}, M) instance.id = id instance.mediator = mediator @@ -181,7 +82,6 @@ function M.new(id, mediator) instance._augroup = vim.api.nvim_create_augroup("eca_sidebar_" .. id, { clear = true }) instance._response_start_time = 0 instance._max_response_length = 50000 -- 50KB max response - local chat_cfg = _get_chat_config() instance._headers = { user = (chat_cfg.headers and chat_cfg.headers.user) or "> ", assistant = (chat_cfg.headers and chat_cfg.headers.assistant) or "", @@ -191,10 +91,25 @@ function M.new(id, mediator) instance._contexts = {} instance._tool_calls = {} instance._reasons = {} - -- Internal queue for smooth, per-character streaming in the chat - instance._stream_queue = "" instance._stream_visible_buffer = "" - instance._stream_tick_scheduled = false + + -- Get typing configuration + local typing_cfg = chat_cfg.typing or {} + local typing_enabled = typing_cfg.enabled ~= false -- Default to true + local chars_per_tick = typing_enabled and (typing_cfg.chars_per_tick or 1) or 1000 -- Large number = instant + local tick_delay = typing_enabled and (typing_cfg.tick_delay or 10) or 0 + + -- Initialize stream queue with callback to update display + instance._stream_queue = StreamQueue.new(function(chunk, is_complete) + instance._stream_visible_buffer = (instance._stream_visible_buffer or "") .. chunk + instance:_update_streaming_message(instance._stream_visible_buffer) + end, { + chars_per_tick = chars_per_tick, + tick_delay = tick_delay, + should_continue = function() + return instance._is_streaming + end, + }) require("eca.observer").subscribe("sidebar-" .. id, function(message) instance:handle_chat_content(message) @@ -324,9 +239,10 @@ function M:reset() self._contexts = {} self._tool_calls = {} self._reasons = {} - self._stream_queue = "" self._stream_visible_buffer = "" - self._stream_tick_scheduled = false + if self._stream_queue then + self._stream_queue:clear() + end end function M:new_chat() @@ -1171,7 +1087,7 @@ function M:_update_welcome_content() return end - local chat_cfg = _get_chat_config() + local chat_cfg = Utils.get_chat_config() local cfg = chat_cfg.welcome or {} local cfg_msg = (cfg.message and cfg.message ~= "" and cfg.message) or nil local welcome_message = cfg_msg or (self.mediator and self.mediator:welcome_message() or nil) @@ -1354,7 +1270,7 @@ function M:handle_chat_content_received(params) Logger.debug(tool_log) -- Determine completion status icon (configurable) - local icons = get_tool_call_icons() + local icons = Utils.get_tool_call_icons() local status_icon = icons.success if content.error then status_icon = icons.error @@ -1378,7 +1294,7 @@ function M:handle_chat_content_received(params) -- If this call now has a diff and doesn't yet have a label line, add it if call.has_diff and not call.label_line and not call.expanded then self:_insert_tool_call_diff_label_line(call) - if should_start_diff_expanded() then + if Utils.should_start_diff_expanded() then self:_expand_tool_call_diff(call) end end @@ -1435,7 +1351,7 @@ function M:handle_chat_content_received(params) if call.has_diff then self:_insert_tool_call_diff_label_line(call) - if should_start_diff_expanded() then + if Utils.should_start_diff_expanded() then self:_expand_tool_call_diff(call) end end @@ -1482,13 +1398,13 @@ function M:_handle_streaming_text(text) if not self._is_streaming then Logger.debug("Starting streaming response") - -- Start streaming with an internal character queue so that text - -- is rendered smoothly, one (or a few) characters at a time. + -- Start streaming with the stream queue self._is_streaming = true self._current_response_buffer = "" - self._stream_queue = "" self._stream_visible_buffer = "" - self._stream_tick_scheduled = false + if self._stream_queue then + self._stream_queue:clear() + end -- Determine insertion point before adding placeholder (works even with empty header) local chat = self.containers.chat @@ -1518,66 +1434,12 @@ function M:_handle_streaming_text(text) -- Accumulate the full response for finalization and history self._current_response_buffer = (self._current_response_buffer or "") .. text - -- Enqueue new characters to be rendered gradually - self._stream_queue = (self._stream_queue or "") .. text - - Logger.debug("DEBUG: Buffer now has " .. #self._current_response_buffer .. " chars (queued: " .. #self._stream_queue .. ")") - - -- Ensure the per-character render loop is running - self:_start_streaming_tick() -end - --- Internal helper: run a tick of the per-character streaming loop. --- This pulls a small number of characters from `_stream_queue` and --- appends them to the visible buffer, then reschedules itself while --- streaming is active. The goal is to make updates feel smooth and --- natural instead of "chunky". -function M:_start_streaming_tick() - if self._stream_tick_scheduled then - return + -- Enqueue new text to be rendered gradually + if self._stream_queue then + self._stream_queue:enqueue(text) end - self._stream_tick_scheduled = true - - local function step() - -- This runs on the main loop via vim.defer_fn - self._stream_tick_scheduled = false - - -- If streaming finished in the meantime, stop here. - if not self._is_streaming then - return - end - - local queue = self._stream_queue or "" - if queue == "" then - -- Nothing to render yet; poll again shortly while the server - -- continues to stream more content. - self._stream_tick_scheduled = true - vim.defer_fn(step, 10) - return - end - - -- Render a small batch of characters per tick so long messages - -- don't take forever to appear but still feel "typewriter-like". - local CHARS_PER_TICK = 2 - local count = math.min(CHARS_PER_TICK, #queue) - local chunk = queue:sub(1, count) - self._stream_queue = queue:sub(count + 1) - - self._stream_visible_buffer = (self._stream_visible_buffer or "") .. chunk - self:_update_streaming_message(self._stream_visible_buffer) - - -- Keep the loop going while we still have content or the server is - -- likely to send more. - if self._is_streaming or (self._stream_queue and #self._stream_queue > 0) then - self._stream_tick_scheduled = true - vim.defer_fn(step, 10) - end - end - - -- Start immediately (or after a tiny delay) so the first characters - -- appear right away. - vim.defer_fn(step, 1) + Logger.debug("DEBUG: Buffer now has " .. #self._current_response_buffer .. " chars (queue size: " .. (self._stream_queue and self._stream_queue:size() or 0) .. ")") end ---@param content string @@ -1744,9 +1606,10 @@ function M:_finalize_streaming_response() self._is_streaming = false self._current_response_buffer = "" self._response_start_time = 0 - self._stream_queue = "" self._stream_visible_buffer = "" - self._stream_tick_scheduled = false + if self._stream_queue then + self._stream_queue:clear() + end -- Clear assistant placeholder tracking extmark local chat = self.containers.chat @@ -1997,14 +1860,14 @@ function M:_display_tool_call(content) -- Apply header highlight (tool call vs reasoning) self:_highlight_tool_call_header(call) - if call.has_diff then - self:_insert_tool_call_diff_label_line(call) - if should_start_diff_expanded() then - self:_expand_tool_call_diff(call) - end - end - - table.insert(self._tool_calls, call) + if call.has_diff then + self:_insert_tool_call_diff_label_line(call) + if Utils.should_start_diff_expanded() then + self:_expand_tool_call_diff(call) + end + end + + table.insert(self._tool_calls, call) end function M:_finalize_tool_call() @@ -2032,7 +1895,7 @@ function M:_handle_reason_started(content) -- If a new reasoning starts while another one is still "running", -- mark the previous one as finished so only one active "Thinking" -- block is shown at a time. - local labels = get_reasoning_labels() + local labels = Utils.get_reasoning_labels() local running_label = labels.running local finished_label = labels.finished @@ -2061,7 +1924,7 @@ function M:_handle_reason_started(content) -- Whether "Thinking" blocks should start expanded by default -- Use the merged chat config so both legacy `chat.reasoning` and -- modern `windows.chat.reasoning` can control this behavior. - local chat_cfg = _get_chat_config() + local chat_cfg = Utils.get_chat_config() local reasoning_cfg = chat_cfg.reasoning or {} local expand = reasoning_cfg.expanded == true @@ -2122,10 +1985,9 @@ function M:_handle_reason_text(content) -- Accumulate raw reasoning text call.arguments = (call.arguments or "") .. content.text - -- Build plain text block for the reasoning body (no markdown quote prefix) - -- We intentionally do not insert a leading blank spacer line here so - -- that the first line immediately below the header contains the first - -- piece of reasoning text (this matches the expectations in tests). + -- Build plain text block for the reasoning body (no markdown quote prefix). + -- We keep the first inserted line as the first line of reasoning text rather + -- than a blank spacer so that navigation from the header lands on the actual content. call.arguments_lines = {} local lines = Utils.split_lines(call.arguments or "") @@ -2176,7 +2038,7 @@ function M:_handle_reason_finished(content) return end - local labels = get_reasoning_labels() + local labels = Utils.get_reasoning_labels() local finished_label = labels.finished -- When reasoning is finished, update the label from running to a @@ -2248,7 +2110,7 @@ end -- Build the header text for a tool call like: "▶ summary ⏳" (or "▶ summary ✅" / "▶ summary ❌") function M:_build_tool_call_header_text(call) - local icons = get_tool_call_icons() + local icons = Utils.get_tool_call_icons() -- Reasoning ("Thinking") entries do not show status icons; they only -- display a toggle arrow (when there is body content to show) and a @@ -2314,12 +2176,6 @@ function M:_build_tool_call_arguments_lines(arguments, outputs, outputs_type) local has_content = false if arguments and arguments ~= "" then - if not has_content then - -- Spacer between the header and the first line of the block - table.insert(lines, "") - has_content = true - end - table.insert(lines, "Arguments:") table.insert(lines, "```json") for _, line in ipairs(Utils.split_lines(arguments)) do @@ -2327,13 +2183,13 @@ function M:_build_tool_call_arguments_lines(arguments, outputs, outputs_type) end table.insert(lines, "```") table.insert(lines, "") + has_content = true end if outputs and outputs ~= "" then if not has_content then - -- Spacer between the header and the first line of the block + -- Add spacer only if this is the first section table.insert(lines, "") - has_content = true end table.insert(lines, "Output:") @@ -2374,9 +2230,7 @@ function M:_build_tool_call_diff_lines(details) local diff = details and details.diff or nil if diff and diff ~= "" then - -- Start the diff block directly with the fenced header so that - -- the first inserted line is the ```diff fence. Tests expect the - -- first diff line immediately below the label to be this fence. + -- Start the diff block with the fenced header (no leading newline) table.insert(lines, "```diff") for _, line in ipairs(Utils.split_lines(diff)) do table.insert(lines, line) @@ -2418,7 +2272,7 @@ end -- Build the label text shown below the tool call summary when a diff is available function M:_build_tool_call_diff_label_text(call) - local labels = get_tool_call_diff_labels() + local labels = Utils.get_tool_call_diff_labels() -- Use different texts depending on diff expanded/collapsed state if call and call.diff_expanded then diff --git a/lua/eca/stream_queue.lua b/lua/eca/stream_queue.lua new file mode 100644 index 0000000..0ac305c --- /dev/null +++ b/lua/eca/stream_queue.lua @@ -0,0 +1,113 @@ +---@class eca.StreamQueue +---@field private queue table Array of items to process +---@field private running boolean Whether the queue is currently processing +---@field private on_process function Callback to process each item +---@field private should_continue function Optional callback to check if processing should continue +---@field private chars_per_tick number Number of characters to display per tick +---@field private tick_delay number Delay between ticks in milliseconds + +local M = {} +M.__index = M + +---Create a new stream queue +---@param on_process function Function to call for each character batch: fn(text, is_complete) +---@param opts? table Optional configuration { chars_per_tick: number, tick_delay: number, should_continue: function } +---@return eca.StreamQueue +function M.new(on_process, opts) + opts = opts or {} + local instance = setmetatable({}, M) + instance.queue = {} + instance.running = false + instance.on_process = on_process + instance.should_continue = opts.should_continue + instance.chars_per_tick = opts.chars_per_tick or 1 + instance.tick_delay = opts.tick_delay or 10 + return instance +end + +---Add text to the queue for processing +---@param text string Text to add to the queue +function M:enqueue(text) + if not text or text == "" then + return + end + table.insert(self.queue, text) + self:process() +end + +---Process the queue +function M:process() + -- If already processing or queue is empty, return early + if self.running or #self.queue == 0 then + return + end + + self.running = true + + -- Get the next text chunk from the queue + local text = table.remove(self.queue, 1) + + -- Create a local queue of characters from this text chunk + local char_queue = {} + for i = 1, #text do + table.insert(char_queue, text:sub(i, i)) + end + + local function done() + self.running = false + -- Process next item in queue if available + self:process() + end + + local function step() + -- Check if we should continue processing (e.g., streaming is still active) + if self.should_continue and not self.should_continue() then + done() + return + end + + -- If no more characters in this chunk, mark as done + if #char_queue == 0 then + done() + return + end + + -- Render a small batch of characters per tick + local count = math.min(self.chars_per_tick, #char_queue) + local chunk = "" + for i = 1, count do + chunk = chunk .. table.remove(char_queue, 1) + end + + -- Call the process callback with the chunk + -- Pass true if this is the last chunk + local is_complete = #char_queue == 0 and #self.queue == 0 + self.on_process(chunk, is_complete) + + -- Continue processing this chunk + vim.defer_fn(step, self.tick_delay) + end + + -- Start processing this chunk + vim.defer_fn(step, 1) +end + +---Clear the queue and stop processing +function M:clear() + self.queue = {} + self.running = false +end + +---Check if the queue is empty +---@return boolean +function M:is_empty() + return #self.queue == 0 and not self.running +end + +---Get the current queue size +---@return number +function M:size() + return #self.queue +end + +return M diff --git a/lua/eca/utils.lua b/lua/eca/utils.lua index 7a46ac8..7fc1b15 100644 --- a/lua/eca/utils.lua +++ b/lua/eca/utils.lua @@ -1,6 +1,7 @@ local uv = vim.uv or vim.loop local Logger = require("eca.logger") +local Config = require("eca.config") local M = {} @@ -114,6 +115,94 @@ function M.write_file(path, content) return true end +---@param n number|string +---@return string +function M.shorten_tokens(n) + n = tonumber(n) or 0 + if n >= 1000 then + local rounded = math.floor(n / 1000 + 0.5) + return string.format("%dk", rounded) + end + return tostring(n) +end + +---Get chat configuration by merging top-level and windows.chat config +---@return table +function M.get_chat_config() + -- Merge top-level `chat` (backwards compatible) with `windows.chat`. + -- `windows.chat` provides modern defaults, while a user-provided + -- `chat.tool_call` block (legacy style) can still override fields + -- like `diff_label` and `diff_start_expanded`. + local win_chat = (Config.windows and Config.windows.chat) or {} + local top_chat = Config.chat or {} + + if next(top_chat) == nil then + return win_chat + end + + return vim.tbl_deep_extend("force", win_chat, top_chat) +end + +---Get tool call icons configuration +---@return table +function M.get_tool_call_icons() + local chat_cfg = M.get_chat_config() + local icons_cfg = (chat_cfg.tool_call and chat_cfg.tool_call.icons) or {} + return { + success = icons_cfg.success or "✅", + error = icons_cfg.error or "❌", + running = icons_cfg.running or "⏳", + expanded = icons_cfg.expanded or "▲", + collapsed = icons_cfg.collapsed or "▶", + } +end + +---Get tool call diff labels configuration +--- +---Configuration (under `windows.chat.tool_call`): +--- tool_call = { +--- diff = { +--- collapsed_label = "+ view diff", -- Label when the diff is collapsed +--- expanded_label = "- view diff", -- Label when the diff is expanded +--- expanded = false, -- When true, tool diffs start expanded +--- }, +--- } +---@return table +function M.get_tool_call_diff_labels() + local chat_cfg = M.get_chat_config() + local cfg = chat_cfg.tool_call or {} + local diff_cfg = cfg.diff or {} + + return { + collapsed = diff_cfg.collapsed_label or "+ view diff", + expanded = diff_cfg.expanded_label or "- view diff", + } +end + +---Check if tool call diffs should start expanded +---@return boolean +function M.should_start_diff_expanded() + local chat_cfg = M.get_chat_config() + local cfg = chat_cfg.tool_call or {} + local diff_cfg = cfg.diff or {} + + return diff_cfg.expanded == true +end + +---Get reasoning labels configuration +---@return table +function M.get_reasoning_labels() + local chat_cfg = M.get_chat_config() + local cfg = chat_cfg.reasoning or {} + local running = cfg.running_label or "Thinking..." + local finished = cfg.finished_label or "Thought" + + return { + running = running, + finished = finished, + } +end + function M.constants() return CONSTANTS end diff --git a/tests/test_stream_queue.lua b/tests/test_stream_queue.lua new file mode 100644 index 0000000..8cb293a --- /dev/null +++ b/tests/test_stream_queue.lua @@ -0,0 +1,619 @@ +local MiniTest = require("mini.test") +local eq = MiniTest.expect.equality +local child = MiniTest.new_child_neovim() + +local T = MiniTest.new_set({ + hooks = { + pre_case = function() + child.restart({ "-u", "scripts/minimal_init.lua" }) + child.lua([[ + _G.StreamQueue = require('eca.stream_queue') + _G.output = "" + _G.chunks_received = {} + ]]) + end, + post_once = child.stop, + }, +}) + +-- Ensure scheduled callbacks run (vim.schedule and vim.defer_fn) +local function flush(ms) + vim.uv.sleep(ms or 50) + -- Force at least one main loop iteration + child.api.nvim_eval("1") +end + +T["basic queue operations"] = MiniTest.new_set() + +T["basic queue operations"]["creates a new queue instance"] = function() + child.lua([[ + _G.queue = _G.StreamQueue.new(function(chunk, is_complete) + -- noop + end) + ]]) + + eq(child.lua_get("type(_G.queue)"), "table") + eq(child.lua_get("_G.queue:is_empty()"), true) + eq(child.lua_get("_G.queue:size()"), 0) +end + +T["basic queue operations"]["enqueue triggers processing"] = function() + child.lua([[ + _G.queue = _G.StreamQueue.new(function(chunk, is_complete) + -- noop + end) + _G.queue:enqueue("Hello") + _G.queue:enqueue("World") + ]]) + + -- When items are enqueued, the first starts processing immediately + -- So size will be 1 (second item waiting) and not empty (still processing) + local size = child.lua_get("_G.queue:size()") + local is_empty = child.lua_get("_G.queue:is_empty()") + + -- Either 1 item in queue (first being processed) or 2 items (depending on timing) + eq(size >= 0 and size <= 2, true) + eq(is_empty, false) +end + +T["basic queue operations"]["clear removes all items"] = function() + child.lua([[ + _G.queue = _G.StreamQueue.new(function(chunk, is_complete) + -- noop + end) + _G.queue:enqueue("Hello") + _G.queue:enqueue("World") + _G.queue:clear() + ]]) + + eq(child.lua_get("_G.queue:size()"), 0) + eq(child.lua_get("_G.queue:is_empty()"), true) +end + +T["queue processing"] = MiniTest.new_set() + +T["queue processing"]["processes single text chunk"] = function() + child.lua([[ + _G.output = "" + _G.queue = _G.StreamQueue.new(function(chunk, is_complete) + _G.output = _G.output .. chunk + end, { + chars_per_tick = 2, + tick_delay = 10, + }) + _G.queue:enqueue("Hi") + ]]) + + -- Wait for processing to complete + flush(100) + + eq(child.lua_get("_G.output"), "Hi") + eq(child.lua_get("_G.queue:is_empty()"), true) +end + +T["queue processing"]["processes multiple text chunks in order"] = function() + child.lua([[ + _G.output = "" + _G.queue = _G.StreamQueue.new(function(chunk, is_complete) + _G.output = _G.output .. chunk + end, { + chars_per_tick = 2, + tick_delay = 5, + }) + _G.queue:enqueue("Hello") + _G.queue:enqueue(" ") + _G.queue:enqueue("World") + _G.queue:enqueue("!") + ]]) + + -- Wait for all processing to complete + flush(300) + + eq(child.lua_get("_G.output"), "Hello World!") + eq(child.lua_get("_G.queue:is_empty()"), true) +end + +T["queue processing"]["respects chars_per_tick setting"] = function() + child.lua([[ + _G.chunks_received = {} + _G.queue = _G.StreamQueue.new(function(chunk, is_complete) + table.insert(_G.chunks_received, chunk) + end, { + chars_per_tick = 1, + tick_delay = 5, + }) + _G.queue:enqueue("ABC") + ]]) + + -- Wait for processing to complete + flush(100) + + -- With chars_per_tick = 1, "ABC" should be split into 3 chunks + local chunks = child.lua_get("_G.chunks_received") + eq(#chunks, 3) + eq(chunks[1], "A") + eq(chunks[2], "B") + eq(chunks[3], "C") +end + +T["queue processing"]["calls callback with is_complete flag"] = function() + child.lua([[ + _G.completion_flags = {} + _G.queue = _G.StreamQueue.new(function(chunk, is_complete) + table.insert(_G.completion_flags, is_complete) + end, { + chars_per_tick = 2, + tick_delay = 5, + }) + _G.queue:enqueue("AB") + _G.queue:enqueue("CD") + ]]) + + -- Wait for all processing to complete + flush(150) + + local flags = child.lua_get("_G.completion_flags") + -- The last chunk should have is_complete = true + eq(flags[#flags], true) +end + +T["queue processing"]["respects should_continue callback"] = function() + child.lua([[ + _G.output = "" + _G.should_continue = true + _G.queue = _G.StreamQueue.new(function(chunk, is_complete) + _G.output = _G.output .. chunk + end, { + chars_per_tick = 1, + tick_delay = 10, + should_continue = function() + return _G.should_continue + end, + }) + _G.queue:enqueue("ABCDEF") + ]]) + + -- Let it process a bit + flush(30) + + -- Stop processing + child.lua([[_G.should_continue = false]]) + + -- Wait to ensure it stops + flush(50) + + local output = child.lua_get("_G.output") + -- Should have processed some but not all characters + eq(#output < 6, true) + eq(#output > 0, true) +end + +T["edge cases"] = MiniTest.new_set() + +T["edge cases"]["handles empty text gracefully"] = function() + child.lua([[ + _G.callback_called = false + _G.queue = _G.StreamQueue.new(function(chunk, is_complete) + _G.callback_called = true + end) + _G.queue:enqueue("") + ]]) + + flush(50) + + -- Empty text should not trigger processing + eq(child.lua_get("_G.callback_called"), false) +end + +T["edge cases"]["handles nil text gracefully"] = function() + child.lua([[ + _G.callback_called = false + _G.queue = _G.StreamQueue.new(function(chunk, is_complete) + _G.callback_called = true + end) + _G.queue:enqueue(nil) + ]]) + + flush(50) + + -- Nil text should not trigger processing + eq(child.lua_get("_G.callback_called"), false) +end + +T["edge cases"]["processes queue even with rapid enqueues"] = function() + child.lua([[ + _G.output = "" + _G.queue = _G.StreamQueue.new(function(chunk, is_complete) + _G.output = _G.output .. chunk + end, { + chars_per_tick = 2, + tick_delay = 5, + }) + -- Rapidly enqueue multiple items + for i = 1, 10 do + _G.queue:enqueue(tostring(i)) + end + ]]) + + -- Wait for all processing to complete + flush(500) + + eq(child.lua_get("_G.output"), "12345678910") + eq(child.lua_get("_G.queue:is_empty()"), true) +end + +T["typing speed configuration"] = MiniTest.new_set() + +T["typing speed configuration"]["default speed processes at expected rate"] = function() + child.lua([[ + _G.start_time = vim.loop.hrtime() + _G.output = "" + _G.queue = _G.StreamQueue.new(function(chunk, is_complete) + _G.output = _G.output .. chunk + if is_complete then + _G.end_time = vim.loop.hrtime() + end + end, { + chars_per_tick = 1, -- Default: 1 char at a time + tick_delay = 10, -- Default: 10ms delay + }) + _G.queue:enqueue("ABCDE") -- 5 characters + ]]) + + -- With 1 char per tick and 10ms delay, 5 chars should take at least 40ms + flush(100) + + eq(child.lua_get("_G.output"), "ABCDE") + local duration_ns = child.lua_get("_G.end_time - _G.start_time") + local duration_ms = duration_ns / 1000000 + -- Should take at least 40ms (5 chars * 10ms - overhead for first char) + eq(duration_ms >= 30, true) +end + +T["typing speed configuration"]["fast speed processes quickly"] = function() + child.lua([[ + _G.output = "" + _G.chunks_count = 0 + _G.queue = _G.StreamQueue.new(function(chunk, is_complete) + _G.output = _G.output .. chunk + _G.chunks_count = _G.chunks_count + 1 + end, { + chars_per_tick = 3, -- Fast: 3 chars at a time + tick_delay = 2, -- Fast: 2ms delay + }) + _G.queue:enqueue("ABCDEFGHI") -- 9 characters + ]]) + + flush(50) + + eq(child.lua_get("_G.output"), "ABCDEFGHI") + -- With 3 chars per tick, 9 chars should take 3 chunks + eq(child.lua_get("_G.chunks_count"), 3) +end + +T["typing speed configuration"]["slow speed processes slowly"] = function() + child.lua([[ + _G.start_time = vim.loop.hrtime() + _G.output = "" + _G.queue = _G.StreamQueue.new(function(chunk, is_complete) + _G.output = _G.output .. chunk + if is_complete then + _G.end_time = vim.loop.hrtime() + end + end, { + chars_per_tick = 1, -- Slow: 1 char at a time + tick_delay = 30, -- Slow: 30ms delay + }) + _G.queue:enqueue("ABC") -- 3 characters + ]]) + + flush(150) + + eq(child.lua_get("_G.output"), "ABC") + local duration_ns = child.lua_get("_G.end_time - _G.start_time") + local duration_ms = duration_ns / 1000000 + -- Should take at least 60ms (3 chars * 30ms - overhead for first char) + eq(duration_ms >= 50, true) +end + +T["typing speed configuration"]["instant display with large chars_per_tick"] = function() + child.lua([[ + _G.output = "" + _G.chunks_count = 0 + _G.queue = _G.StreamQueue.new(function(chunk, is_complete) + _G.output = _G.output .. chunk + _G.chunks_count = _G.chunks_count + 1 + end, { + chars_per_tick = 1000, -- Instant: large batch + tick_delay = 0, -- Instant: no delay + }) + _G.queue:enqueue("Hello World!") -- 12 characters + ]]) + + flush(50) + + eq(child.lua_get("_G.output"), "Hello World!") + -- Should process in 1 chunk since chars_per_tick is larger than text + eq(child.lua_get("_G.chunks_count"), 1) +end + +T["typing speed configuration"]["different speeds for different queues"] = function() + child.lua([[ + _G.output_fast = "" + _G.output_slow = "" + + _G.queue_fast = _G.StreamQueue.new(function(chunk, is_complete) + _G.output_fast = _G.output_fast .. chunk + end, { + chars_per_tick = 5, + tick_delay = 1, + }) + + _G.queue_slow = _G.StreamQueue.new(function(chunk, is_complete) + _G.output_slow = _G.output_slow .. chunk + end, { + chars_per_tick = 1, + tick_delay = 20, + }) + + _G.queue_fast:enqueue("FAST") + _G.queue_slow:enqueue("SLOW") + ]]) + + -- Fast should complete quickly + flush(50) + eq(child.lua_get("_G.output_fast"), "FAST") + + -- Slow may still be processing + flush(150) + eq(child.lua_get("_G.output_slow"), "SLOW") +end + +-- Integration tests with Sidebar +T["sidebar integration"] = MiniTest.new_set({ + hooks = { + pre_case = function() + child.restart({ "-u", "scripts/minimal_init.lua" }) + child.lua([[ + -- Setup complete environment with 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.Sidebar:open() + ]]) + end, + post_case = function() + child.lua([[ if _G.Sidebar then _G.Sidebar:close() end ]]) + end, + }, +}) + +T["sidebar integration"]["initializes stream queue with default config"] = function() + child.lua([[ + local Sidebar = _G.Sidebar + _G.queue_info = { + exists = Sidebar._stream_queue ~= nil, + is_empty = Sidebar._stream_queue and Sidebar._stream_queue:is_empty() or false, + } + ]]) + + local info = child.lua_get("_G.queue_info") + eq(info.exists, true) + eq(info.is_empty, true) +end + +T["sidebar integration"]["streams text with typing effect when enabled"] = function() + child.lua([[ + local Config = require('eca.config') + Config.override({ + windows = { + chat = { + typing = { + enabled = true, + chars_per_tick = 2, + tick_delay = 5, + }, + }, + }, + }) + + -- Recreate sidebar with new config + _G.Sidebar:close() + _G.Sidebar = require('eca.sidebar').new(1, _G.Mediator) + _G.Sidebar:open() + + local Sidebar = _G.Sidebar + + -- Simulate streaming text + Sidebar:handle_chat_content_received({ + chatId = 'chat-typing', + content = { + type = 'text', + text = 'Hello', + }, + }) + + -- Add another chunk (simulates multiple streaming updates) + Sidebar:handle_chat_content_received({ + chatId = 'chat-typing', + content = { + type = 'text', + text = ' World', + }, + }) + ]]) + + -- Wait for typing to complete with faster settings + flush(200) + + child.lua([[ + local Sidebar = _G.Sidebar + local total = Sidebar._current_response_buffer or "" + _G.typing_info = { + total = total, + total_len = #total, + queue_empty = Sidebar._stream_queue:is_empty(), + } + ]]) + + local info = child.lua_get("_G.typing_info") + eq(info.total_len, 11) -- "Hello World" + eq(info.queue_empty, true) +end + +T["sidebar integration"]["displays instantly when typing disabled"] = function() + child.lua([[ + local Config = require('eca.config') + Config.override({ + windows = { + chat = { + typing = { + enabled = false, + }, + }, + }, + }) + + -- Recreate sidebar with new config + _G.Sidebar:close() + _G.Sidebar = require('eca.sidebar').new(1, _G.Mediator) + _G.Sidebar:open() + + local Sidebar = _G.Sidebar + + -- Simulate streaming text + Sidebar:handle_chat_content_received({ + chatId = 'chat-instant', + content = { + type = 'text', + text = 'Instant Display', + }, + }) + ]]) + + -- With typing disabled, text should appear immediately + flush(50) + + child.lua([[ + local Sidebar = _G.Sidebar + _G.instant_info = { + visible = Sidebar._stream_visible_buffer or "", + total = Sidebar._current_response_buffer or "", + } + ]]) + + local info = child.lua_get("_G.instant_info") + eq(info.visible, "Instant Display") + eq(info.total, "Instant Display") +end + +T["sidebar integration"]["respects custom typing speed"] = function() + child.lua([[ + local Config = require('eca.config') + Config.override({ + windows = { + chat = { + typing = { + enabled = true, + chars_per_tick = 3, + tick_delay = 5, + }, + }, + }, + }) + + -- Recreate sidebar with new config + _G.Sidebar:close() + _G.Sidebar = require('eca.sidebar').new(1, _G.Mediator) + _G.Sidebar:open() + + local Sidebar = _G.Sidebar + + -- Simulate streaming text + Sidebar:handle_chat_content_received({ + chatId = 'chat-fast', + content = { + type = 'text', + text = 'ABCDEFGHI', + }, + }) + ]]) + + -- With chars_per_tick=3, should type faster + flush(80) + + local final = child.lua_get("_G.Sidebar._stream_visible_buffer") + eq(final, "ABCDEFGHI") +end + +T["sidebar integration"]["clears queue on new chat"] = function() + child.lua([[ + local Sidebar = _G.Sidebar + + -- Start streaming + Sidebar:handle_chat_content_received({ + chatId = 'chat-1', + content = { + type = 'text', + text = 'First message', + }, + }) + ]]) + + flush(30) + + child.lua([[ + local Sidebar = _G.Sidebar + _G.queue_size_before = Sidebar._stream_queue:size() + + -- Reset for new chat + Sidebar:new_chat() + + _G.queue_size_after = Sidebar._stream_queue:size() + ]]) + + local size_after = child.lua_get("_G.queue_size_after") + + eq(size_after, 0) + eq(child.lua_get("_G.Sidebar._stream_queue:is_empty()"), true) +end + +T["sidebar integration"]["handles multiple text chunks in sequence"] = function() + child.lua([[ + local Sidebar = _G.Sidebar + + -- Simulate multiple streaming chunks + Sidebar:handle_chat_content_received({ + chatId = 'chat-multi', + content = { + type = 'text', + text = 'First ', + }, + }) + + Sidebar:handle_chat_content_received({ + chatId = 'chat-multi', + content = { + type = 'text', + text = 'Second ', + }, + }) + + Sidebar:handle_chat_content_received({ + chatId = 'chat-multi', + content = { + type = 'text', + text = 'Third', + }, + }) + ]]) + + -- Wait for all chunks to be processed + flush(300) + + local final = child.lua_get("_G.Sidebar._current_response_buffer") + eq(final, "First Second Third") +end + +return T diff --git a/tests/test_utils.lua b/tests/test_utils.lua new file mode 100644 index 0000000..0f2403d --- /dev/null +++ b/tests/test_utils.lua @@ -0,0 +1,188 @@ +local MiniTest = require("mini.test") +local eq = MiniTest.expect.equality +local child = MiniTest.new_child_neovim() + +local T = MiniTest.new_set({ + hooks = { + pre_case = function() + child.restart({ "-u", "scripts/minimal_init.lua" }) + end, + post_once = child.stop, + }, +}) + +T["utils"] = MiniTest.new_set() + +T["utils"]["shorten_tokens formats numbers correctly"] = function() + child.lua([[ + local Utils = require('eca.utils') + _G.results = { + small = Utils.shorten_tokens(999), + exact_k = Utils.shorten_tokens(1000), + over_k = Utils.shorten_tokens(1500), + large = Utils.shorten_tokens(42000), + very_large = Utils.shorten_tokens(1234567), + nil_input = Utils.shorten_tokens(nil), + string_input = Utils.shorten_tokens("1500"), + } + ]]) + + local results = child.lua_get("_G.results") + + eq(results.small, "999") + eq(results.exact_k, "1k") + eq(results.over_k, "2k") -- Rounds 1500 to 2k + eq(results.large, "42k") + eq(results.very_large, "1235k") + eq(results.nil_input, "0") + eq(results.string_input, "2k") +end + +T["utils"]["get_chat_config merges legacy and new config"] = function() + child.lua([[ + local Config = require('eca.config') + Config.override({ + chat = { + headers = { + user = "OLD> ", + }, + }, + windows = { + chat = { + headers = { + user = "NEW> ", + assistant = "AI: ", + }, + }, + }, + }) + + local Utils = require('eca.utils') + local merged = Utils.get_chat_config() + _G.merged_config = { + user_header = merged.headers and merged.headers.user or nil, + assistant_header = merged.headers and merged.headers.assistant or nil, + } + ]]) + + local merged = child.lua_get("_G.merged_config") + + -- Legacy chat.headers overrides windows.chat.headers via deep_extend + eq(merged.user_header, "OLD> ") + eq(merged.assistant_header, "AI: ") +end + +T["utils"]["get_chat_config returns windows.chat when no legacy config"] = function() + child.lua([[ + local Config = require('eca.config') + Config.override({ + windows = { + chat = { + headers = { + user = "> ", + assistant = "", + }, + }, + }, + }) + + local Utils = require('eca.utils') + local merged = Utils.get_chat_config() + _G.merged_config = { + user_header = merged.headers and merged.headers.user or nil, + assistant_header = merged.headers and merged.headers.assistant or nil, + } + ]]) + + local merged = child.lua_get("_G.merged_config") + + eq(merged.user_header, "> ") + eq(merged.assistant_header, "") +end + +T["utils"]["should_start_diff_expanded respects windows.chat.tool_call.diff.expanded"] = function() + child.lua([[ + local Config = require('eca.config') + Config.override({ + windows = { + chat = { + tool_call = { + diff = { + expanded = true, + }, + }, + }, + }, + }) + + local Utils = require('eca.utils') + _G.should_expand = Utils.should_start_diff_expanded() + ]]) + + eq(child.lua_get("_G.should_expand"), true) +end + +T["utils"]["should_start_diff_expanded checks diff.expanded only"] = function() + child.lua([[ + local Config = require('eca.config') + Config.override({ + windows = { + chat = { + tool_call = { + diff = { + expanded = false, + }, + }, + }, + }, + }) + + local Utils = require('eca.utils') + _G.should_expand = Utils.should_start_diff_expanded() + ]]) + + eq(child.lua_get("_G.should_expand"), false) +end + +T["utils"]["should_start_diff_expanded defaults to false"] = function() + child.lua([[ + local Config = require('eca.config') + Config.override({}) + + local Utils = require('eca.utils') + _G.should_expand = Utils.should_start_diff_expanded() + ]]) + + eq(child.lua_get("_G.should_expand"), false) +end + +T["utils"]["split_lines handles various line endings"] = function() + child.lua([[ + local Utils = require('eca.utils') + _G.results = { + unix = Utils.split_lines("line1\nline2\nline3"), + empty = Utils.split_lines(""), + single = Utils.split_lines("single"), + trailing = Utils.split_lines("line1\nline2\n"), + } + ]]) + + local results = child.lua_get("_G.results") + + eq(#results.unix, 3) + eq(results.unix[1], "line1") + eq(results.unix[2], "line2") + eq(results.unix[3], "line3") + + eq(#results.empty, 1) + eq(results.empty[1], "") + + eq(#results.single, 1) + eq(results.single[1], "single") + + -- Trailing newline should create an empty last line + eq(#results.trailing, 3) + eq(results.trailing[3], "") +end + +return T From 5f85aae10a55d64dc776775be668d7db23686699 Mon Sep 17 00:00:00 2001 From: joaopluigi Date: Tue, 6 Jan 2026 15:16:12 -0300 Subject: [PATCH 24/31] update doc to reflect the typing configuration --- docs/configuration.md | 48 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/docs/configuration.md b/docs/configuration.md index b8372b2..b0cd49f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -128,6 +128,13 @@ require("eca").setup({ }, }, + -- Typewriter effect for streaming responses + typing = { + enabled = true, -- Enable/disable typewriter effect + chars_per_tick = 1, -- Characters to display per tick (1 = realistic typing) + tick_delay = 10, -- Delay in ms between ticks (lower = faster typing) + }, + -- Tool call display settings tool_call = { icons = { @@ -212,6 +219,47 @@ require("eca").setup({ }) ``` +### Typing Speed Presets + +```lua +-- Fast typing (2x speed) +require("eca").setup({ + windows = { + chat = { + typing = { + enabled = true, + chars_per_tick = 2, -- 2 characters at a time + tick_delay = 5, -- 5ms between ticks + }, + }, + }, +}) + +-- Slow/realistic typing +require("eca").setup({ + windows = { + chat = { + typing = { + enabled = true, + chars_per_tick = 1, -- 1 character at a time + tick_delay = 30, -- 30ms between ticks (~33 chars/sec) + }, + }, + }, +}) + +-- Instant display (no typing effect) +require("eca").setup({ + windows = { + chat = { + typing = { + enabled = false, -- Disable typing effect + }, + }, + }, +}) +``` + --- ## Notes From fa37bf7660a26a7b7dee879b7f1ee37f97b85f50 Mon Sep 17 00:00:00 2001 From: joaopluigi Date: Tue, 6 Jan 2026 15:21:37 -0300 Subject: [PATCH 25/31] update docs --- docs/configuration.md | 48 ++++++++++++++++++++ docs/development.md | 29 ++++++++++-- docs/installation.md | 36 +++++++++------ docs/troubleshooting.md | 24 +++++++++- docs/usage.md | 99 ++++++++++++++++++++++++++++++++++++++++- 5 files changed, 216 insertions(+), 20 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index b0cd49f..ce32655 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -262,6 +262,54 @@ require("eca").setup({ --- +## Migration Guide + +### Upgrading from older versions + +If you have existing configuration, note these changes: + +**Config structure changes:** +- `debug` option removed → Use `log.level = vim.log.levels.DEBUG` +- `usage_string_format` moved → Now `windows.usage.format` +- `chat.*` options moved → Now nested under `windows.chat.*` + +**Legacy config is still supported** through automatic merging, but the new structure is recommended: + +```lua +-- Old (still works) +require("eca").setup({ + debug = true, + usage_string_format = "{messageCost} / {sessionCost}", + chat = { + headers = { user = "> " }, + }, +}) + +-- New (recommended) +require("eca").setup({ + log = { + level = vim.log.levels.DEBUG, + }, + windows = { + usage = { + format = "{session_cost}", + }, + chat = { + headers = { user = "> " }, + }, + }, +}) +``` + +**New placeholders for usage format:** +- `{session_tokens}` → Raw session token count +- `{limit_tokens}` → Raw token limit +- `{session_tokens_short}` → Shortened format (e.g., "30k") +- `{limit_tokens_short}` → Shortened format (e.g., "400k") +- `{session_cost}` → Session cost + +--- + ## Notes - Set `server_path` if you prefer using a local ECA binary. - Use the `log` block to control verbosity and where logs are written. diff --git a/docs/development.md b/docs/development.md index 0813acd..afd70b5 100644 --- a/docs/development.md +++ b/docs/development.md @@ -39,9 +39,32 @@ Run tests before submitting a PR: ```bash -# Unit tests -nvim --headless -c "lua require('eca.tests').run_all()" +# Run all tests with mini.test +nvim --headless -u scripts/minimal_init.lua -c "lua require('mini.test').setup(); MiniTest.run_file('tests/test_eca.lua')" + +# Run specific test files +nvim --headless -u scripts/minimal_init.lua -c "lua require('mini.test').setup(); MiniTest.run_file('tests/test_stream_queue.lua')" +nvim --headless -u scripts/minimal_init.lua -c "lua require('mini.test').setup(); MiniTest.run_file('tests/test_sidebar_usage_and_tools.lua')" # Manual test -nvim -c "lua require('eca').setup({debug=true})" +nvim -c "lua require('eca').setup({log = {level = vim.log.levels.DEBUG}})" ``` + +### Test Coverage + +The plugin includes comprehensive tests for: +- Core configuration and utilities (`test_eca.lua`, `test_utils.lua`) +- Stream queue and typewriter effect (`test_stream_queue.lua`) +- Sidebar tool calls and reasoning blocks (`test_sidebar_usage_and_tools.lua`) +- Picker commands (`test_picker.lua`, `test_server_picker_commands.lua`) +- Highlight groups (`test_highlights.lua`) + +### Highlight Groups + +ECA defines custom highlight groups for UI elements: +- `EcaToolCall` - Tool call headers +- `EcaHyperlink` - Clickable diff labels +- `EcaLabel` - Muted text (context labels, reasoning headers) +- `EcaSuccess`, `EcaWarning`, `EcaInfo` - Status indicators + +These can be customized in your colorscheme or via `:highlight` commands. diff --git a/docs/installation.md b/docs/installation.md index 7167653..b653006 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -12,6 +12,7 @@ This guide covers system requirements and how to install the ECA Neovim plugin w ### Optional - plenary.nvim — Utility functions used by some distributions +- snacks.nvim — Required for `:EcaServerMessages` and `:EcaServerTools` commands (picker functionality) ### Tested Systems - macOS (Intel and Apple Silicon) @@ -29,8 +30,9 @@ This guide covers system requirements and how to install the ECA Neovim plugin w { "editor-code-assistant/eca-nvim", dependencies = { - "MunifTanjim/nui.nvim", -- Required: UI framework - "nvim-lua/plenary.nvim", -- Optional: Enhanced async operations + "MunifTanjim/nui.nvim", -- Required: UI framework + "nvim-lua/plenary.nvim", -- Optional: Enhanced async operations + "folke/snacks.nvim", -- Optional: Picker for server messages/tools }, opts = {} } @@ -42,8 +44,9 @@ Advanced setup example: { "editor-code-assistant/eca-nvim", dependencies = { - "MunifTanjim/nui.nvim", -- Required: UI framework - "nvim-lua/plenary.nvim", -- Optional: Enhanced async operations + "MunifTanjim/nui.nvim", -- Required: UI framework + "nvim-lua/plenary.nvim", -- Optional: Enhanced async operations + "folke/snacks.nvim", -- Optional: Picker for server messages/tools }, keys = { { "ec", "EcaChat", desc = "Open ECA chat" }, @@ -67,8 +70,9 @@ Advanced setup example: use { "editor-code-assistant/eca-nvim", requires = { - "MunifTanjim/nui.nvim", -- Required: UI framework - "nvim-lua/plenary.nvim", -- Optional: Enhanced async operations + "MunifTanjim/nui.nvim", -- Required: UI framework + "nvim-lua/plenary.nvim", -- Optional: Enhanced async operations + "folke/snacks.nvim", -- Optional: Picker for server messages/tools }, config = function() require("eca").setup({ @@ -87,8 +91,9 @@ Plug 'editor-code-assistant/eca-nvim' " Required dependencies Plug 'MunifTanjim/nui.nvim' -" Optional dependencies (enhanced async operations) -Plug 'nvim-lua/plenary.nvim' +" Optional dependencies +Plug 'nvim-lua/plenary.nvim' " Enhanced async operations +Plug 'folke/snacks.nvim' " Picker for server messages/tools " After the plugins, add: lua << EOF @@ -106,8 +111,9 @@ call dein#add('editor-code-assistant/eca-nvim') " Required dependencies call dein#add('MunifTanjim/nui.nvim') -" Optional dependencies (enhanced async operations) -call dein#add('nvim-lua/plenary.nvim') +" Optional dependencies +call dein#add('nvim-lua/plenary.nvim') " Enhanced async operations +call dein#add('folke/snacks.nvim') " Picker for server messages/tools " Configuration lua << EOF @@ -127,8 +133,9 @@ EOF # Required dependencies "nui.nvim" = { git = "MunifTanjim/nui.nvim" } -# Optional dependencies (enhanced async operations) -"plenary.nvim" = { git = "nvim-lua/plenary.nvim" } +# Optional dependencies +"plenary.nvim" = { git = "nvim-lua/plenary.nvim" } # Enhanced async operations +"snacks.nvim" = { git = "folke/snacks.nvim" } # Picker for server messages/tools ``` ### mini.deps @@ -139,8 +146,9 @@ local add = MiniDeps.add add({ source = "editor-code-assistant/eca-nvim", depends = { - "MunifTanjim/nui.nvim", -- Required: UI framework - "nvim-lua/plenary.nvim", -- Optional: Enhanced async operations + "MunifTanjim/nui.nvim", -- Required: UI framework + "nvim-lua/plenary.nvim", -- Optional: Enhanced async operations + "folke/snacks.nvim", -- Optional: Picker for server messages/tools } }) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index d37e5fc..b9ce6b9 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -15,11 +15,18 @@ Solutions: ```lua -- Debug configuration require("eca").setup({ - debug = true, server_args = "--log-level debug", + log = { + level = vim.log.levels.DEBUG, + display = "split", + }, }) ``` +You can also use these debug commands to inspect the server state: +- `:EcaServerMessages` - View all server messages +- `:EcaServerTools` - View all registered tools + ## Connectivity issues Symptoms: Download fails, timeouts, network errors @@ -57,9 +64,22 @@ Solutions: ## Performance issues -Symptoms: Lag when typing, slow responses +Symptoms: Lag when typing, slow responses, slow streaming Solutions: - Reduce window width: `windows.width = 25` - Disable visual updates: `behavior.show_status_updates = false` +- Speed up or disable typewriter effect: + ```lua + windows = { + chat = { + typing = { + enabled = false, -- Instant display + -- OR + chars_per_tick = 10, -- Much faster typing + tick_delay = 1, + }, + }, + } + ``` - Use the minimalist configuration preset diff --git a/docs/usage.md b/docs/usage.md index 6abd51a..fef8a22 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -2,6 +2,18 @@ Everything you need to get productive with ECA inside Neovim. +## What's New + +Recent updates include: + +- **Expandable tool calls**: Click `Enter` on tool call headers to show/hide arguments, outputs, and diffs +- **Reasoning blocks**: See ECA's "thinking" process with expandable reasoning content +- **Typewriter effect**: Responses stream with a configurable typing animation (can be disabled) +- **Enhanced MCP display**: See active vs. registered MCP server counts with status indicators +- **Debug commands**: `:EcaServerMessages` and `:EcaServerTools` for inspecting server state +- **Better usage display**: Shortened token counts (e.g., "30k / 400k") with customizable format +- **Improved highlights**: New `EcaToolCall`, `EcaHyperlink`, and `EcaLabel` highlight groups + ## Quick Start 1. Install the plugin using any package manager @@ -30,6 +42,8 @@ Everything you need to get productive with ECA inside Neovim. | `:EcaServerStart` | Start ECA server manually | `:EcaServerStart` | | `:EcaServerStop` | Stop ECA server | `:EcaServerStop` | | `:EcaServerRestart` | Restart ECA server | `:EcaServerRestart` | +| `:EcaServerMessages` | Display server messages (for debugging) | `:EcaServerMessages` | +| `:EcaServerTools` | Display registered server tools | `:EcaServerTools` | | `: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. @@ -52,6 +66,7 @@ Deprecated aliases (still available but log a warning): `:EcaAddFile`, `:EcaAddS |----------|--------|---------| | `Ctrl+S` | Send message | Insert/Normal mode | | `Enter` | New line | Insert mode | +| `Enter` (in chat buffer) | Toggle tool call/reasoning block | Normal mode on tool call or reasoning header | | `Esc` | Exit insert mode | Insert mode | --- @@ -62,7 +77,23 @@ Deprecated aliases (still available but log a warning): `:EcaAddFile`, `:EcaAddS - Type in the input line starting with `> ` - Press `Enter` to insert a new line - Press `Ctrl+S` to send -- Responses stream in real time +- Responses stream in real time with a typewriter effect (configurable) + +### Interacting with responses + +#### Tool calls +When ECA uses tools (like file editing), tool calls appear in the chat with: +- **Header line**: Shows tool name and status icon (⏳ running, ✅ success, ❌ error) +- **Expandable details**: Press `Enter` on the header to show/hide arguments and outputs +- **Diff view**: If a tool modifies files, a "view diff" label appears below the header. Press `Enter` on this label to expand/collapse the diff + +#### Reasoning blocks +When ECA is "thinking" (extended reasoning), you'll see: +- **"Thinking..."** label while reasoning is active +- **"Thought X.XX s"** label when complete, showing elapsed time +- Press `Enter` on the header to expand/collapse the reasoning content + +These blocks can be configured to start expanded or collapsed (see Configuration) ### Examples @@ -190,6 +221,27 @@ When typing paths directly with `@` to trigger completion, the input might brief 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. +--- + +## Model Context Protocol (MCP) Servers + +ECA supports MCP servers for extended functionality. The config display line at the bottom of the sidebar shows: + +``` +model: behavior: mcps: 2/3 +``` + +Where: +- The first number (2) is the count of **active MCPs** (starting + running) +- The second number (3) is the **total registered MCPs** + +**Status indicators**: +- Gray text: One or more MCPs are still starting +- Red text: One or more MCPs failed to start +- Normal text: All MCPs are running successfully + +Use `:EcaServerTools` to see which tools are available from your MCP servers. + ### Context completion and `@` / `#` path shortcuts Inside the input (filetype `eca-input`): @@ -280,6 +332,12 @@ are first expanded to absolute paths on the Neovim side (including `~` expansion " Start again :EcaServerStart + +" Debug: view server messages +:EcaServerMessages + +" Debug: view registered tools +:EcaServerTools ``` ### Quick commands @@ -297,6 +355,45 @@ are first expanded to absolute paths on the Neovim side (including `~` expansion --- +## Typewriter Effect + +ECA displays streaming responses with a configurable typewriter effect for a more natural reading experience. + +### Configuration + +```lua +require("eca").setup({ + windows = { + chat = { + typing = { + enabled = true, -- Enable/disable typewriter effect + chars_per_tick = 1, -- Characters per tick (higher = faster) + tick_delay = 10, -- Delay in ms between ticks (lower = faster) + }, + }, + }, +}) +``` + +### Presets + +**Fast typing (2x speed)**: +```lua +typing = { enabled = true, chars_per_tick = 2, tick_delay = 5 } +``` + +**Slow/realistic typing**: +```lua +typing = { enabled = true, chars_per_tick = 1, tick_delay = 30 } +``` + +**Instant display (no effect)**: +```lua +typing = { enabled = false } +``` + +--- + ## Tips and Tricks ### Productivity From ac4faa2842dc167a482166ab5facd4b531f35ab6 Mon Sep 17 00:00:00 2001 From: joaopluigi Date: Tue, 6 Jan 2026 16:51:09 -0300 Subject: [PATCH 26/31] fix stream queue typing behavior --- lua/eca/stream_queue.lua | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/lua/eca/stream_queue.lua b/lua/eca/stream_queue.lua index 0ac305c..92f2fba 100644 --- a/lua/eca/stream_queue.lua +++ b/lua/eca/stream_queue.lua @@ -44,19 +44,22 @@ function M:process() self.running = true - -- Get the next text chunk from the queue - local text = table.remove(self.queue, 1) - - -- Create a local queue of characters from this text chunk + -- Combine all queued text into a single character queue for smooth continuous streaming + local combined_text = table.concat(self.queue, "") + self.queue = {} + + -- Create a local queue of characters from all text chunks local char_queue = {} - for i = 1, #text do - table.insert(char_queue, text:sub(i, i)) + for i = 1, #combined_text do + table.insert(char_queue, combined_text:sub(i, i)) end local function done() self.running = false - -- Process next item in queue if available - self:process() + -- Process next item in queue if available (in case new items were added during processing) + if #self.queue > 0 then + self:process() + end end local function step() @@ -66,6 +69,16 @@ function M:process() return end + -- Check if new items were added to the queue while we were processing + -- If so, add them to the current character queue to maintain smooth animation + if #self.queue > 0 then + local new_text = table.concat(self.queue, "") + self.queue = {} + for i = 1, #new_text do + table.insert(char_queue, new_text:sub(i, i)) + end + end + -- If no more characters in this chunk, mark as done if #char_queue == 0 then done() From 0abbf53eb37ba5bf86e75a56b98b52b1b7b58db4 Mon Sep 17 00:00:00 2001 From: joaopluigi Date: Wed, 7 Jan 2026 09:19:56 -0300 Subject: [PATCH 27/31] add preserve cursor behavior --- docs/configuration.md | 19 ++ lua/eca/config.lua | 1 + lua/eca/sidebar.lua | 84 +++++- lua/eca/utils.lua | 9 + tests/test_config.lua | 612 ++++++++++++++++++++++++++++++++++++++++++ tests/test_utils.lua | 118 -------- 6 files changed, 722 insertions(+), 121 deletions(-) create mode 100644 tests/test_config.lua diff --git a/docs/configuration.md b/docs/configuration.md index ce32655..501cc95 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -149,6 +149,7 @@ require("eca").setup({ expanded_label = "- view diff", -- Label when the diff is expanded expanded = false, -- When true, tool diffs start expanded }, + preserve_cursor = true, -- When true, cursor stays in place when expanding/collapsing }, -- Reasoning ("Thinking") block behavior @@ -260,6 +261,24 @@ require("eca").setup({ }) ``` +### Tool Call Behavior + +```lua +-- Keep cursor in place when expanding/collapsing tool calls +require("eca").setup({ + windows = { + chat = { + tool_call = { + preserve_cursor = true, -- Don't move cursor on expand/collapse + diff = { + expanded = true, -- Start with diffs expanded + }, + }, + }, + }, +}) +``` + --- ## Migration Guide diff --git a/lua/eca/config.lua b/lua/eca/config.lua index 1dff2ff..9ae10d1 100644 --- a/lua/eca/config.lua +++ b/lua/eca/config.lua @@ -90,6 +90,7 @@ M._defaults = { expanded_label = "- view diff", -- Label when the diff is expanded expanded = false, -- When true, tool diffs start expanded }, + preserve_cursor = true, -- When true, cursor position is preserved when expanding/collapsing }, reasoning = { expanded = false, -- When true, "Thinking" blocks start expanded diff --git a/lua/eca/sidebar.lua b/lua/eca/sidebar.lua index 4658e30..d2c7fd8 100644 --- a/lua/eca/sidebar.lua +++ b/lua/eca/sidebar.lua @@ -2517,6 +2517,13 @@ function M:_expand_tool_call(call) return end + -- Save cursor position before expansion (if configured) + local saved_cursor = nil + local preserve_cursor = Utils.should_preserve_cursor() + if preserve_cursor and chat.winid and vim.api.nvim_win_is_valid(chat.winid) then + saved_cursor = vim.api.nvim_win_get_cursor(chat.winid) + end + -- Reasoning ("Thinking") entries behave slightly differently: we never -- wrap them in code fences and the streaming handler is responsible for -- keeping the body up to date. Here we just insert the current body once. @@ -2552,7 +2559,15 @@ function M:_expand_tool_call(call) call.expanded = true self:_update_tool_call_header_line(call) - if chat.winid and vim.api.nvim_win_is_valid(chat.winid) then + -- Restore cursor position or move to last line based on config + if saved_cursor and chat.winid and vim.api.nvim_win_is_valid(chat.winid) then + -- Adjust cursor row if it was after the insertion point + if saved_cursor[1] > call.header_line then + saved_cursor[1] = saved_cursor[1] + count + end + vim.api.nvim_win_set_cursor(chat.winid, saved_cursor) + elseif not preserve_cursor and chat.winid and vim.api.nvim_win_is_valid(chat.winid) then + -- Default behavior: move cursor to last line of expanded content local last_line = call.header_line + (call._last_arguments_count or 0) vim.api.nvim_win_set_cursor(chat.winid, { last_line, 0 }) vim.api.nvim_win_call(chat.winid, function() @@ -2588,8 +2603,15 @@ function M:_expand_tool_call(call) -- Update header arrow self:_update_tool_call_header_line(call) - -- Move cursor to show the full arguments block - if chat.winid and vim.api.nvim_win_is_valid(chat.winid) then + -- Restore cursor position or move to last line based on config + if saved_cursor and chat.winid and vim.api.nvim_win_is_valid(chat.winid) then + -- Adjust cursor row if it was after the insertion point + if saved_cursor[1] > call.header_line then + saved_cursor[1] = saved_cursor[1] + count + end + vim.api.nvim_win_set_cursor(chat.winid, saved_cursor) + elseif not preserve_cursor and chat.winid and vim.api.nvim_win_is_valid(chat.winid) then + -- Default behavior: move cursor to last line of expanded content local last_line = call.header_line + count vim.api.nvim_win_set_cursor(chat.winid, { last_line, 0 }) vim.api.nvim_win_call(chat.winid, function() @@ -2609,6 +2631,13 @@ function M:_collapse_tool_call(call) return end + -- Save cursor position before collapsing (if configured) + local saved_cursor = nil + local preserve_cursor = Utils.should_preserve_cursor() + if preserve_cursor and chat.winid and vim.api.nvim_win_is_valid(chat.winid) then + saved_cursor = vim.api.nvim_win_get_cursor(chat.winid) + end + local count = call.arguments_lines and #call.arguments_lines or 0 if count == 0 then call.expanded = false @@ -2633,6 +2662,19 @@ function M:_collapse_tool_call(call) -- Update header arrow self:_update_tool_call_header_line(call) + + -- Restore cursor position, adjusting if it was after the collapsed region + if saved_cursor and chat.winid and vim.api.nvim_win_is_valid(chat.winid) then + -- If cursor was inside the collapsed region, move it to the header + if saved_cursor[1] > call.header_line and saved_cursor[1] <= call.header_line + count then + saved_cursor[1] = call.header_line + saved_cursor[2] = 0 + -- If cursor was after the collapsed region, adjust it up + elseif saved_cursor[1] > call.header_line + count then + saved_cursor[1] = saved_cursor[1] - count + end + vim.api.nvim_win_set_cursor(chat.winid, saved_cursor) + end end -- Expand a tool call's diff, inserting it below the diff label @@ -2646,6 +2688,13 @@ function M:_expand_tool_call_diff(call) return end + -- Save cursor position before expansion (if configured) + local saved_cursor = nil + local preserve_cursor = Utils.should_preserve_cursor() + if preserve_cursor and chat.winid and vim.api.nvim_win_is_valid(chat.winid) then + saved_cursor = vim.api.nvim_win_get_cursor(chat.winid) + end + call.diff_lines = call.diff_lines or self:_build_tool_call_diff_lines(call.details) local count = call.diff_lines and #call.diff_lines or 0 if count == 0 then @@ -2664,6 +2713,15 @@ function M:_expand_tool_call_diff(call) -- Update the diff label to show the collapse indicator self:_update_tool_call_diff_label_line(call) + + -- Restore cursor position (no default cursor movement for diff expansion) + if saved_cursor and chat.winid and vim.api.nvim_win_is_valid(chat.winid) then + -- Adjust cursor row if it was after the insertion point + if saved_cursor[1] > call.label_line then + saved_cursor[1] = saved_cursor[1] + count + end + vim.api.nvim_win_set_cursor(chat.winid, saved_cursor) + end end -- Collapse a tool call's diff, removing it from the buffer @@ -2677,6 +2735,13 @@ function M:_collapse_tool_call_diff(call) return end + -- Save cursor position before collapsing (if configured) + local saved_cursor = nil + local preserve_cursor = Utils.should_preserve_cursor() + if preserve_cursor and chat.winid and vim.api.nvim_win_is_valid(chat.winid) then + saved_cursor = vim.api.nvim_win_get_cursor(chat.winid) + end + local count = call.diff_lines and #call.diff_lines or 0 if count == 0 then call.diff_expanded = false @@ -2696,6 +2761,19 @@ function M:_collapse_tool_call_diff(call) -- Update the diff label to show the expand indicator again self:_update_tool_call_diff_label_line(call) + + -- Restore cursor position, adjusting if it was after the collapsed region + if saved_cursor and chat.winid and vim.api.nvim_win_is_valid(chat.winid) then + -- If cursor was inside the collapsed region, move it to the label + if saved_cursor[1] > call.label_line and saved_cursor[1] <= call.label_line + count then + saved_cursor[1] = call.label_line + saved_cursor[2] = 0 + -- If cursor was after the collapsed region, adjust it up + elseif saved_cursor[1] > call.label_line + count then + saved_cursor[1] = saved_cursor[1] - count + end + vim.api.nvim_win_set_cursor(chat.winid, saved_cursor) + end end -- Toggle tool call details at the current cursor position in the chat window diff --git a/lua/eca/utils.lua b/lua/eca/utils.lua index 7fc1b15..96e4366 100644 --- a/lua/eca/utils.lua +++ b/lua/eca/utils.lua @@ -189,6 +189,15 @@ function M.should_start_diff_expanded() return diff_cfg.expanded == true end +---Check if cursor position should be preserved when expanding/collapsing tool calls +---@return boolean +function M.should_preserve_cursor() + local chat_cfg = M.get_chat_config() + local cfg = chat_cfg.tool_call or {} + + return cfg.preserve_cursor == true +end + ---Get reasoning labels configuration ---@return table function M.get_reasoning_labels() diff --git a/tests/test_config.lua b/tests/test_config.lua new file mode 100644 index 0000000..7c981f2 --- /dev/null +++ b/tests/test_config.lua @@ -0,0 +1,612 @@ +local MiniTest = require("mini.test") +local eq = MiniTest.expect.equality +local child = MiniTest.new_child_neovim() + +local T = MiniTest.new_set({ + hooks = { + pre_case = function() + child.restart({ "-u", "scripts/minimal_init.lua" }) + end, + post_once = child.stop, + }, +}) + +-- ===== Chat config merging tests ===== + +T["chat_config"] = MiniTest.new_set() + +T["chat_config"]["get_chat_config merges legacy and new config"] = function() + child.lua([[ + local Config = require('eca.config') + Config.override({ + chat = { + headers = { + user = "OLD> ", + }, + }, + windows = { + chat = { + headers = { + user = "NEW> ", + assistant = "AI: ", + }, + }, + }, + }) + + local Utils = require('eca.utils') + local merged = Utils.get_chat_config() + _G.merged_config = { + user_header = merged.headers and merged.headers.user or nil, + assistant_header = merged.headers and merged.headers.assistant or nil, + } + ]]) + + local merged = child.lua_get("_G.merged_config") + + -- Legacy chat.headers overrides windows.chat.headers via deep_extend + eq(merged.user_header, "OLD> ") + eq(merged.assistant_header, "AI: ") +end + +T["chat_config"]["get_chat_config returns windows.chat when no legacy config"] = function() + child.lua([[ + local Config = require('eca.config') + Config.override({ + windows = { + chat = { + headers = { + user = "> ", + assistant = "", + }, + }, + }, + }) + + local Utils = require('eca.utils') + local merged = Utils.get_chat_config() + _G.merged_config = { + user_header = merged.headers and merged.headers.user or nil, + assistant_header = merged.headers and merged.headers.assistant or nil, + } + ]]) + + local merged = child.lua_get("_G.merged_config") + + eq(merged.user_header, "> ") + eq(merged.assistant_header, "") +end + +-- ===== Diff expansion config tests ===== + +T["diff_config"] = MiniTest.new_set() + +T["diff_config"]["should_start_diff_expanded respects windows.chat.tool_call.diff.expanded"] = function() + child.lua([[ + local Config = require('eca.config') + Config.override({ + windows = { + chat = { + tool_call = { + diff = { + expanded = true, + }, + }, + }, + }, + }) + + local Utils = require('eca.utils') + _G.should_expand = Utils.should_start_diff_expanded() + ]]) + + eq(child.lua_get("_G.should_expand"), true) +end + +T["diff_config"]["should_start_diff_expanded checks diff.expanded only"] = function() + child.lua([[ + local Config = require('eca.config') + Config.override({ + windows = { + chat = { + tool_call = { + diff = { + expanded = false, + }, + }, + }, + }, + }) + + local Utils = require('eca.utils') + _G.should_expand = Utils.should_start_diff_expanded() + ]]) + + eq(child.lua_get("_G.should_expand"), false) +end + +T["diff_config"]["should_start_diff_expanded defaults to false"] = function() + child.lua([[ + local Config = require('eca.config') + Config.override({}) + + local Utils = require('eca.utils') + _G.should_expand = Utils.should_start_diff_expanded() + ]]) + + eq(child.lua_get("_G.should_expand"), false) +end + +-- ===== Preserve cursor config tests ===== + +T["preserve_cursor_config"] = MiniTest.new_set() + +T["preserve_cursor_config"]["should_preserve_cursor returns true when enabled"] = function() + child.lua([[ + local Config = require('eca.config') + Config.override({ + windows = { + chat = { + tool_call = { + preserve_cursor = true, + }, + }, + }, + }) + + local Utils = require('eca.utils') + _G.preserve = Utils.should_preserve_cursor() + ]]) + + eq(child.lua_get("_G.preserve"), true) +end + +T["preserve_cursor_config"]["should_preserve_cursor returns false when disabled"] = function() + child.lua([[ + local Config = require('eca.config') + Config.override({ + windows = { + chat = { + tool_call = { + preserve_cursor = false, + }, + }, + }, + }) + + local Utils = require('eca.utils') + _G.preserve = Utils.should_preserve_cursor() + ]]) + + eq(child.lua_get("_G.preserve"), false) +end + +T["preserve_cursor_config"]["should_preserve_cursor defaults to true"] = function() + child.lua([[ + local Config = require('eca.config') + Config.override({}) + + local Utils = require('eca.utils') + _G.preserve = Utils.should_preserve_cursor() + ]]) + + eq(child.lua_get("_G.preserve"), true) +end + +T["preserve_cursor_config"]["should_preserve_cursor respects legacy chat.tool_call config"] = function() + child.lua([[ + local Config = require('eca.config') + Config.override({ + chat = { + tool_call = { + preserve_cursor = true, + }, + }, + }) + + local Utils = require('eca.utils') + _G.preserve = Utils.should_preserve_cursor() + ]]) + + eq(child.lua_get("_G.preserve"), true) +end + +T["preserve_cursor_config"]["should_preserve_cursor merges windows.chat and chat config"] = function() + child.lua([[ + local Config = require('eca.config') + Config.override({ + windows = { + chat = { + tool_call = { + preserve_cursor = false, + }, + }, + }, + chat = { + tool_call = { + preserve_cursor = true, + }, + }, + }) + + local Utils = require('eca.utils') + _G.preserve = Utils.should_preserve_cursor() + ]]) + + -- Legacy chat config should override windows.chat via deep_extend + eq(child.lua_get("_G.preserve"), true) +end + +-- ===== Behavioral validation tests ===== +-- These tests verify that config changes actually affect sidebar behavior + +T["behavior_validation"] = MiniTest.new_set() + +T["behavior_validation"]["preserve_cursor=true actually preserves cursor position on expand"] = function() + child.lua([[ + local Config = require('eca.config') + Config.override({ + windows = { + chat = { + tool_call = { + preserve_cursor = true, + }, + }, + }, + }) + + local Server = require('eca.server').new() + local State = require('eca.state').new() + local Mediator = require('eca.mediator').new(Server, State) + local Sidebar = require('eca.sidebar') + local sidebar = Sidebar.new(1, Mediator) + + sidebar:open() + + local chat = sidebar.containers.chat + vim.api.nvim_buf_set_lines(chat.bufnr, 0, -1, false, { + "Line 1", + "Line 2", + "Line 3", + "Line 4", + "Line 5", + }) + + sidebar._tool_calls = { + { + id = "test-id", + title = "Test", + header_line = 2, + expanded = false, + arguments = "{}", + arguments_lines = {"arg1", "arg2"}, + details = {}, + has_diff = false, + } + } + + vim.api.nvim_win_set_cursor(chat.winid, {5, 0}) + sidebar:_expand_tool_call(sidebar._tool_calls[1]) + + local cursor = vim.api.nvim_win_get_cursor(chat.winid) + _G.cursor_after = cursor[1] + _G.expected = 7 -- Line 5 + 2 inserted lines + ]]) + + eq(child.lua_get("_G.cursor_after"), child.lua_get("_G.expected")) +end + +T["behavior_validation"]["preserve_cursor=false moves cursor to end on expand"] = function() + child.lua([[ + local Config = require('eca.config') + Config.override({ + windows = { + chat = { + tool_call = { + preserve_cursor = false, + }, + }, + }, + }) + + local Server = require('eca.server').new() + local State = require('eca.state').new() + local Mediator = require('eca.mediator').new(Server, State) + local Sidebar = require('eca.sidebar') + local sidebar = Sidebar.new(1, Mediator) + + sidebar:open() + + local chat = sidebar.containers.chat + vim.api.nvim_buf_set_lines(chat.bufnr, 0, -1, false, { + "Line 1", + "Line 2", + "Line 3", + "Line 4", + }) + + sidebar._tool_calls = { + { + id = "test-id", + title = "Test", + header_line = 2, + expanded = false, + arguments = "{}", + arguments_lines = {"arg1", "arg2"}, + details = {}, + has_diff = false, + } + } + + vim.api.nvim_win_set_cursor(chat.winid, {1, 0}) + sidebar:_expand_tool_call(sidebar._tool_calls[1]) + + local cursor = vim.api.nvim_win_get_cursor(chat.winid) + _G.cursor_after = cursor[1] + _G.expected = 4 -- header_line (2) + arguments_lines count (2) + ]]) + + eq(child.lua_get("_G.cursor_after"), child.lua_get("_G.expected")) +end + +T["behavior_validation"]["diff.expanded=true causes diffs to start expanded"] = function() + child.lua([[ + local Config = require('eca.config') + Config.override({ + windows = { + chat = { + tool_call = { + diff = { + expanded = true, + }, + }, + }, + }, + }) + + local Server = require('eca.server').new() + local State = require('eca.state').new() + local Mediator = require('eca.mediator').new(Server, State) + local Sidebar = require('eca.sidebar') + local sidebar = Sidebar.new(1, Mediator) + + sidebar:open() + + local chat = sidebar.containers.chat + vim.api.nvim_buf_set_lines(chat.bufnr, 0, -1, false, {"Line 1"}) + + -- Simulate a tool call with diff that should auto-expand + sidebar:handle_chat_content_received({ + chatId = 'test', + content = { + type = 'toolCallPrepare', + id = 'tool-1', + name = 'test_tool', + summary = 'Test', + argumentsText = '{}', + details = { + diff = '@@ -1 +1 @@\n-old\n+new', + }, + }, + }) + + sidebar:handle_chat_content_received({ + chatId = 'test', + content = { + type = 'toolCalled', + id = 'tool-1', + name = 'test_tool', + details = { + diff = '@@ -1 +1 @@\n-old\n+new', + }, + outputs = {}, + }, + }) + + -- Find the tool call and check if diff is expanded + local call = sidebar._tool_calls[1] + _G.diff_expanded = call and call.diff_expanded or false + ]]) + + eq(child.lua_get("_G.diff_expanded"), true) +end + +T["behavior_validation"]["diff.expanded=false causes diffs to start collapsed"] = function() + child.lua([[ + local Config = require('eca.config') + Config.override({ + windows = { + chat = { + tool_call = { + diff = { + expanded = false, + }, + }, + }, + }, + }) + + local Server = require('eca.server').new() + local State = require('eca.state').new() + local Mediator = require('eca.mediator').new(Server, State) + local Sidebar = require('eca.sidebar') + local sidebar = Sidebar.new(1, Mediator) + + sidebar:open() + + local chat = sidebar.containers.chat + vim.api.nvim_buf_set_lines(chat.bufnr, 0, -1, false, {"Line 1"}) + + -- Simulate a tool call with diff that should NOT auto-expand + sidebar:handle_chat_content_received({ + chatId = 'test', + content = { + type = 'toolCallPrepare', + id = 'tool-1', + name = 'test_tool', + summary = 'Test', + argumentsText = '{}', + details = { + diff = '@@ -1 +1 @@\n-old\n+new', + }, + }, + }) + + sidebar:handle_chat_content_received({ + chatId = 'test', + content = { + type = 'toolCalled', + id = 'tool-1', + name = 'test_tool', + details = { + diff = '@@ -1 +1 @@\n-old\n+new', + }, + outputs = {}, + }, + }) + + -- Find the tool call and check if diff is collapsed + local call = sidebar._tool_calls[1] + _G.diff_expanded = call and call.diff_expanded or false + ]]) + + eq(child.lua_get("_G.diff_expanded"), false) +end + +T["behavior_validation"]["reasoning.expanded=true causes reasoning to start expanded"] = function() + child.lua([[ + local Config = require('eca.config') + Config.override({ + windows = { + chat = { + reasoning = { + expanded = true, + }, + }, + }, + }) + + local Server = require('eca.server').new() + local State = require('eca.state').new() + local Mediator = require('eca.mediator').new(Server, State) + local Sidebar = require('eca.sidebar') + local sidebar = Sidebar.new(1, Mediator) + + sidebar:open() + + -- Simulate reasoning started event + sidebar:handle_chat_content_received({ + chatId = 'test', + content = { + type = 'reasonStarted', + id = 'reason-1', + }, + }) + + -- Check if reasoning block started expanded + local call = sidebar._reasons['reason-1'] + _G.expanded = call and call.expanded or false + ]]) + + eq(child.lua_get("_G.expanded"), true) +end + +T["behavior_validation"]["reasoning.expanded=false causes reasoning to start collapsed"] = function() + child.lua([[ + local Config = require('eca.config') + Config.override({ + windows = { + chat = { + reasoning = { + expanded = false, + }, + }, + }, + }) + + local Server = require('eca.server').new() + local State = require('eca.state').new() + local Mediator = require('eca.mediator').new(Server, State) + local Sidebar = require('eca.sidebar') + local sidebar = Sidebar.new(1, Mediator) + + sidebar:open() + + -- Simulate reasoning started event + sidebar:handle_chat_content_received({ + chatId = 'test', + content = { + type = 'reasonStarted', + id = 'reason-1', + }, + }) + + -- Check if reasoning block started collapsed + local call = sidebar._reasons['reason-1'] + _G.expanded = call and call.expanded or false + ]]) + + eq(child.lua_get("_G.expanded"), false) +end + +T["behavior_validation"]["typing.enabled=false displays text instantly"] = function() + child.lua([[ + local Config = require('eca.config') + Config.override({ + windows = { + chat = { + typing = { + enabled = false, + }, + }, + }, + }) + + local Server = require('eca.server').new() + local State = require('eca.state').new() + local Mediator = require('eca.mediator').new(Server, State) + local Sidebar = require('eca.sidebar') + local sidebar = Sidebar.new(1, Mediator) + + -- Check that stream queue was configured for instant display + local queue = sidebar._stream_queue + _G.chars_per_tick = queue.chars_per_tick + -- When typing is disabled, chars_per_tick should be a large number (instant) + _G.is_instant = _G.chars_per_tick >= 1000 + ]]) + + eq(child.lua_get("_G.is_instant"), true) +end + +T["behavior_validation"]["typing.enabled=true enables gradual display"] = function() + child.lua([[ + local Config = require('eca.config') + Config.override({ + windows = { + chat = { + typing = { + enabled = true, + chars_per_tick = 2, + tick_delay = 5, + }, + }, + }, + }) + + local Server = require('eca.server').new() + local State = require('eca.state').new() + local Mediator = require('eca.mediator').new(Server, State) + local Sidebar = require('eca.sidebar') + local sidebar = Sidebar.new(1, Mediator) + + -- Check that stream queue was configured with custom values + local queue = sidebar._stream_queue + _G.chars_per_tick = queue.chars_per_tick + _G.tick_delay = queue.tick_delay + ]]) + + eq(child.lua_get("_G.chars_per_tick"), 2) + eq(child.lua_get("_G.tick_delay"), 5) +end + +return T diff --git a/tests/test_utils.lua b/tests/test_utils.lua index 0f2403d..ec6671a 100644 --- a/tests/test_utils.lua +++ b/tests/test_utils.lua @@ -38,124 +38,6 @@ T["utils"]["shorten_tokens formats numbers correctly"] = function() eq(results.string_input, "2k") end -T["utils"]["get_chat_config merges legacy and new config"] = function() - child.lua([[ - local Config = require('eca.config') - Config.override({ - chat = { - headers = { - user = "OLD> ", - }, - }, - windows = { - chat = { - headers = { - user = "NEW> ", - assistant = "AI: ", - }, - }, - }, - }) - - local Utils = require('eca.utils') - local merged = Utils.get_chat_config() - _G.merged_config = { - user_header = merged.headers and merged.headers.user or nil, - assistant_header = merged.headers and merged.headers.assistant or nil, - } - ]]) - - local merged = child.lua_get("_G.merged_config") - - -- Legacy chat.headers overrides windows.chat.headers via deep_extend - eq(merged.user_header, "OLD> ") - eq(merged.assistant_header, "AI: ") -end - -T["utils"]["get_chat_config returns windows.chat when no legacy config"] = function() - child.lua([[ - local Config = require('eca.config') - Config.override({ - windows = { - chat = { - headers = { - user = "> ", - assistant = "", - }, - }, - }, - }) - - local Utils = require('eca.utils') - local merged = Utils.get_chat_config() - _G.merged_config = { - user_header = merged.headers and merged.headers.user or nil, - assistant_header = merged.headers and merged.headers.assistant or nil, - } - ]]) - - local merged = child.lua_get("_G.merged_config") - - eq(merged.user_header, "> ") - eq(merged.assistant_header, "") -end - -T["utils"]["should_start_diff_expanded respects windows.chat.tool_call.diff.expanded"] = function() - child.lua([[ - local Config = require('eca.config') - Config.override({ - windows = { - chat = { - tool_call = { - diff = { - expanded = true, - }, - }, - }, - }, - }) - - local Utils = require('eca.utils') - _G.should_expand = Utils.should_start_diff_expanded() - ]]) - - eq(child.lua_get("_G.should_expand"), true) -end - -T["utils"]["should_start_diff_expanded checks diff.expanded only"] = function() - child.lua([[ - local Config = require('eca.config') - Config.override({ - windows = { - chat = { - tool_call = { - diff = { - expanded = false, - }, - }, - }, - }, - }) - - local Utils = require('eca.utils') - _G.should_expand = Utils.should_start_diff_expanded() - ]]) - - eq(child.lua_get("_G.should_expand"), false) -end - -T["utils"]["should_start_diff_expanded defaults to false"] = function() - child.lua([[ - local Config = require('eca.config') - Config.override({}) - - local Utils = require('eca.utils') - _G.should_expand = Utils.should_start_diff_expanded() - ]]) - - eq(child.lua_get("_G.should_expand"), false) -end - T["utils"]["split_lines handles various line endings"] = function() child.lua([[ local Utils = require('eca.utils') From 75d58e98ebcb0f159bccc400305797656cac849c Mon Sep 17 00:00:00 2001 From: joaopluigi Date: Wed, 7 Jan 2026 10:39:25 -0300 Subject: [PATCH 28/31] address pr review comments --- lua/eca/commands.lua | 5 +- lua/eca/sidebar.lua | 316 +++++++++++++++++++++++++++------------ lua/eca/stream_queue.lua | 4 +- 3 files changed, 224 insertions(+), 101 deletions(-) diff --git a/lua/eca/commands.lua b/lua/eca/commands.lua index 54d62c7..676435b 100644 --- a/lua/eca/commands.lua +++ b/lua/eca/commands.lua @@ -246,9 +246,12 @@ function M.setup() local flat_preview = entry.flat_preview or "" -- Truncate overly long headers so the separator stays fixed. + -- + -- NOTE: We truncate to exactly `header_width` characters so that the + -- padding logic below can reliably keep the separator aligned. local display_header = header if #display_header > header_width then - display_header = display_header:sub(1, header_width - 1) + display_header = display_header:sub(1, header_width) end -- Pad headers (or empty ones) up to header_width so the diff --git a/lua/eca/sidebar.lua b/lua/eca/sidebar.lua index d2c7fd8..c88f73c 100644 --- a/lua/eca/sidebar.lua +++ b/lua/eca/sidebar.lua @@ -34,16 +34,16 @@ local M = {} M.__index = M -- Height calculation constants -local MIN_CHAT_HEIGHT = 10 -- Minimum lines for chat container to remain usable -local WINDOW_MARGIN = 3 -- Additional margin for window borders and spacing +local MIN_CHAT_HEIGHT = 10 -- Minimum lines for chat container to remain usable +local WINDOW_MARGIN = 3 -- Additional margin for window borders and spacing local UI_ELEMENTS_HEIGHT = 2 -- Reserve space for statusline and tabline -local SAFETY_MARGIN = 2 -- Extra margin to prevent "Not enough room" errors +local SAFETY_MARGIN = 2 -- Extra margin to prevent "Not enough room" errors local function _format_usage(tokens, limit, costs) local usage_cfg = (Config.windows and Config.windows.usage) or {} local fmt = usage_cfg.format - or Config.usage_string_format -- backwards compatibility - or "{session_tokens_short} / {limit_tokens_short} (${session_cost})" + or Config.usage_string_format -- backwards compatibility + or "{session_tokens_short} / {limit_tokens_short} (${session_cost})" local placeholders = { session_tokens = tostring(tokens or 0), @@ -95,8 +95,8 @@ function M.new(id, mediator) -- Get typing configuration local typing_cfg = chat_cfg.typing or {} - local typing_enabled = typing_cfg.enabled ~= false -- Default to true - local chars_per_tick = typing_enabled and (typing_cfg.chars_per_tick or 1) or 1000 -- Large number = instant + local typing_enabled = typing_cfg.enabled ~= false -- Default to true + local chars_per_tick = typing_enabled and (typing_cfg.chars_per_tick or 1) or 1000 -- Large number = instant local tick_delay = typing_enabled and (typing_cfg.tick_delay or 10) or 0 -- Initialize stream queue with callback to update display @@ -280,9 +280,9 @@ function M:_create_containers() -- Validate total height to prevent "Not enough room" error local total_height = chat_height - + input_height - + usage_height - + config_height + + input_height + + usage_height + + config_height -- Always calculate from total screen minus UI elements (more accurate than current window) local available_height = vim.o.lines - UI_ELEMENTS_HEIGHT @@ -524,7 +524,7 @@ function M:_setup_input_events(container) local contexts = self.mediator:contexts() local row, col = unpack(vim.api.nvim_win_get_cursor(container.winid)) - local context = contexts[col+1] + local context = contexts[col + 1] if row == 1 and context then self.mediator:remove_context(context) @@ -549,7 +549,6 @@ function M:_setup_input_events(container) self:_update_input_display() return end - end) end }) @@ -610,10 +609,10 @@ function M:get_chat_height() return math.max( MIN_CHAT_HEIGHT, total_height - - input_height - - usage_height - - WINDOW_MARGIN - - config_height + - input_height + - usage_height + - WINDOW_MARGIN + - config_height ) end @@ -829,7 +828,9 @@ function M:_update_input_display(opts) self.extmarks.contexts._ns, 0, i, - vim.tbl_extend("force", { virt_text = { { context_name, "EcaLabel" } }, virt_text_pos = "inline", hl_mode = "replace" }, { id = self.extmarks.contexts._id[i] }) + vim.tbl_extend("force", + { virt_text = { { context_name, "EcaLabel" } }, virt_text_pos = "inline", hl_mode = "replace" }, + { id = self.extmarks.contexts._id[i] }) ) end @@ -856,13 +857,15 @@ function M:_update_input_display(opts) self.extmarks.prefix._ns, 1, 0, - vim.tbl_extend("force", { virt_text = { { prefix, "Normal" } }, virt_text_pos = "inline", right_gravity = false }, { id = self.extmarks.prefix._id[1] }) + vim.tbl_extend("force", { virt_text = { { prefix, "Normal" } }, virt_text_pos = "inline", right_gravity = false }, + { id = self.extmarks.prefix._id[1] }) ) -- Set cursor to end of input line if vim.api.nvim_win_is_valid(input.winid) then 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 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 @@ -991,7 +994,7 @@ function M:_update_config_display() local texts = { { "model:", "EcaLabel" }, { model, "Normal" }, { " " }, { "behavior:", "EcaLabel" }, { behavior, "Normal" }, { " " }, - { "mcps:", "EcaLabel" }, { tostring(active_count), active_hl }, { "/", "EcaLabel" }, + { "mcps:", "EcaLabel" }, { tostring(active_count), active_hl }, { "/", "EcaLabel" }, { tostring(registered_count), registered_hl }, } @@ -1326,7 +1329,7 @@ function M:handle_chat_content_received(params) id = content.id, title = tool_text or (content.name or "Tool call"), header_line = nil, - expanded = false, -- controls argument visibility + expanded = false, -- controls argument visibility diff_expanded = false, -- controls diff visibility status = status_icon, arguments = arguments, @@ -1439,7 +1442,9 @@ function M:_handle_streaming_text(text) self._stream_queue:enqueue(text) end - Logger.debug("DEBUG: Buffer now has " .. #self._current_response_buffer .. " chars (queue size: " .. (self._stream_queue and self._stream_queue:size() or 0) .. ")") + Logger.debug("DEBUG: Buffer now has " .. + #self._current_response_buffer .. + " chars (queue size: " .. (self._stream_queue and self._stream_queue:size() or 0) .. ")") end ---@param content string @@ -1474,7 +1479,8 @@ function M:_update_streaming_message(content) -- Resolve assistant start line using extmark if available 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, {}) + 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 start_line = pos[1] + 1 end @@ -1555,12 +1561,12 @@ function M:_add_message(role, content) -- Check if content looks like code (starts with common programming patterns) local is_code = content:match("^%s*function") - or content:match("^%s*class") - or content:match("^%s*def ") - or content:match("^%s*import") - or content:match("^%s*#include") - or content:match("^%s*<%?") - or content:match("^%s* call.header_line and saved_cursor[1] <= call.header_line + count then saved_cursor[1] = call.header_line saved_cursor[2] = 0 - -- If cursor was after the collapsed region, adjust it up + -- If cursor was after the collapsed region, adjust it up elseif saved_cursor[1] > call.header_line + count then saved_cursor[1] = saved_cursor[1] - count end @@ -2677,7 +2790,11 @@ function M:_collapse_tool_call(call) end end --- Expand a tool call's diff, inserting it below the diff label +---Expand a tool call diff section below its "view diff" label. +--- +---Inserts `call.diff_lines` into the buffer, updates the diff label text, and +---shifts subsequent tool-call line markers. +---@param call table Tool-call entry with a diff (`has_diff` and `label_line`). function M:_expand_tool_call_diff(call) local chat = self.containers.chat if not chat or not vim.api.nvim_buf_is_valid(chat.bufnr) then @@ -2724,7 +2841,11 @@ function M:_expand_tool_call_diff(call) end end --- Collapse a tool call's diff, removing it from the buffer +---Collapse a tool call diff section, removing it from the chat buffer. +--- +---Also updates the diff label text and shifts subsequent tool-call line markers +---back up. +---@param call table Tool-call entry with `diff_expanded` and `label_line`. function M:_collapse_tool_call_diff(call) local chat = self.containers.chat if not chat or not vim.api.nvim_buf_is_valid(chat.bufnr) then @@ -2768,7 +2889,7 @@ function M:_collapse_tool_call_diff(call) if saved_cursor[1] > call.label_line and saved_cursor[1] <= call.label_line + count then saved_cursor[1] = call.label_line saved_cursor[2] = 0 - -- If cursor was after the collapsed region, adjust it up + -- If cursor was after the collapsed region, adjust it up elseif saved_cursor[1] > call.label_line + count then saved_cursor[1] = saved_cursor[1] - count end @@ -2776,11 +2897,10 @@ function M:_collapse_tool_call_diff(call) end end --- Toggle tool call details at the current cursor position in the chat window --- --- When a tool call has a diff available, the header toggle (arrow) controls --- visibility of the tool arguments, while the "view diff" label controls --- visibility of the diff only. +---Toggle tool call details at the current cursor position in the chat window. +--- +---If the cursor is on/under the diff label, toggles the diff block only. +---Otherwise toggles the arguments/reasoning body via the header arrow. function M:_toggle_tool_call_at_cursor() local chat = self.containers.chat if not chat or not vim.api.nvim_win_is_valid(chat.winid) then diff --git a/lua/eca/stream_queue.lua b/lua/eca/stream_queue.lua index 92f2fba..4a00b2f 100644 --- a/lua/eca/stream_queue.lua +++ b/lua/eca/stream_queue.lua @@ -88,7 +88,7 @@ function M:process() -- Render a small batch of characters per tick local count = math.min(self.chars_per_tick, #char_queue) local chunk = "" - for i = 1, count do + for _ = 1, count do chunk = chunk .. table.remove(char_queue, 1) end @@ -102,7 +102,7 @@ function M:process() end -- Start processing this chunk - vim.defer_fn(step, 1) + vim.defer_fn(step, math.min(1, self.tick_delay)) end ---Clear the queue and stop processing From 157af2a95e082dbe324f38e8fa6940ada7c4d2b7 Mon Sep 17 00:00:00 2001 From: joaopluigi Date: Wed, 7 Jan 2026 10:51:54 -0300 Subject: [PATCH 29/31] add better auto scroll behavior --- lua/eca/sidebar.lua | 124 ++++++++++++++++++++---- tests/test_sidebar_autoscroll.lua | 154 ++++++++++++++++++++++++++++++ 2 files changed, 258 insertions(+), 20 deletions(-) create mode 100644 tests/test_sidebar_autoscroll.lua diff --git a/lua/eca/sidebar.lua b/lua/eca/sidebar.lua index c88f73c..b64804b 100644 --- a/lua/eca/sidebar.lua +++ b/lua/eca/sidebar.lua @@ -1462,6 +1462,10 @@ function M:_update_streaming_message(content) return end + -- Capture the current chat view so we only auto-scroll if the user was + -- already at (or very near) the bottom, and hasn't moved since. + local anchor = self:_capture_chat_view_state() + -- Simple and direct buffer update that only rewrites the assistant's -- own streaming region. This avoids clobbering content that may have -- been appended after it (e.g. tool calls or reasoning blocks). @@ -1530,8 +1534,8 @@ function M:_update_streaming_message(content) -- Reapply highlights for existing tool calls and reasoning blocks, -- since full-buffer updates can drop extmark-based styling. self:_reapply_tool_call_highlights() - -- Auto-scroll to bottom during streaming to follow the text - self:_scroll_to_bottom() + -- Auto-scroll only if the user was already at the bottom. + self:_scroll_to_bottom({ anchor = anchor }) end end @@ -1543,6 +1547,12 @@ function M:_add_message(role, content) return end + -- Capture the current chat view before appending. We'll only auto-scroll if: + -- - this is a user message (force scroll), or + -- - the user was already at the bottom and hasn't moved since. + local anchor = self:_capture_chat_view_state() + local force_scroll = role == "user" + self:_safe_buffer_update(chat.bufnr, function() local lines = vim.api.nvim_buf_get_lines(chat.bufnr, 0, -1, false) local header = "" @@ -1589,8 +1599,8 @@ function M:_add_message(role, content) -- Update buffer safely vim.api.nvim_buf_set_lines(chat.bufnr, 0, -1, false, lines) - -- Auto-scroll to bottom after adding new message - self:_scroll_to_bottom() + -- Only auto-scroll if appropriate (see anchor/force_scroll above) + self:_scroll_to_bottom({ force = force_scroll, anchor = anchor }) end) -- After appending a new message, previously highlighted tool calls and @@ -1629,29 +1639,103 @@ function M:_finalize_streaming_response() end end ----Auto-scroll to bottom of the chat -function M:_scroll_to_bottom() +---@private +---@return table|nil +function M:_capture_chat_view_state() local chat = self.containers.chat - if not chat or not vim.api.nvim_win_is_valid(chat.winid) then + if not chat + or not chat.winid + or not vim.api.nvim_win_is_valid(chat.winid) + or not chat.bufnr + or not vim.api.nvim_buf_is_valid(chat.bufnr) then + return nil + end + + local chat_cfg = (Config.windows and Config.windows.chat) or {} + local threshold = tonumber(chat_cfg.autoscroll_threshold) or 1 + if threshold < 0 then + threshold = 0 + end + + local current_win = vim.api.nvim_get_current_win() + + local cursor = vim.api.nvim_win_get_cursor(chat.winid) + local cursor_line = cursor and cursor[1] or 1 + + return vim.api.nvim_win_call(chat.winid, function() + local view = vim.fn.winsaveview() + local bottomline = vim.fn.line("w$") + local line_count = vim.api.nvim_buf_line_count(chat.bufnr) + + return { + view = view, + bottomline = bottomline, + line_count = line_count, + is_current = current_win == chat.winid, + cursor_line = cursor_line, + cursor_at_bottom = cursor_line >= math.max(1, line_count - threshold), + -- Keep a view-based "at bottom" as well, mainly for debugging/telemetry. + at_bottom = bottomline >= math.max(1, line_count - threshold), + } + end) +end + +---Auto-scroll to bottom of the chat. +--- +---By default, we only scroll if the user was already at (or very near) the bottom. +---Pass `{ force = true }` to always scroll (e.g. after sending a user message). +---@param opts? { force?: boolean, anchor?: table } +function M:_scroll_to_bottom(opts) + opts = opts or {} + + local chat = self.containers.chat + if not chat or not vim.api.nvim_win_is_valid(chat.winid) or not vim.api.nvim_buf_is_valid(chat.bufnr) then return end - -- Get total number of lines in buffer - local line_count = vim.api.nvim_buf_line_count(chat.bufnr) + local anchor = opts.anchor + -- Scroll if: + -- - explicitly forced (e.g. user just sent a message), OR + -- - the chat window is NOT focused (user isn't interacting with it), OR + -- - the chat window is focused AND the user is already at the bottom. + local should_scroll = opts.force == true + or (anchor == nil) + or (anchor and (not anchor.is_current or anchor.cursor_at_bottom)) - -- Set cursor to the last line and scroll to bottom - vim.defer_fn(function() - if vim.api.nvim_win_is_valid(chat.winid) and vim.api.nvim_buf_is_valid(chat.bufnr) then - -- Refresh line count in case it changed - local current_line_count = vim.api.nvim_buf_line_count(chat.bufnr) - -- Set cursor to last line - vim.api.nvim_win_set_cursor(chat.winid, { current_line_count, 0 }) - -- Ensure the last line is visible - vim.api.nvim_win_call(chat.winid, function() - vim.cmd("normal! zb") -- scroll so cursor line is at bottom of window + if not should_scroll then + return + end + + -- Defer to the next scheduler tick so any buffer updates have been applied. + vim.schedule(function() + local chat2 = self.containers.chat + if not chat2 or not vim.api.nvim_win_is_valid(chat2.winid) or not vim.api.nvim_buf_is_valid(chat2.bufnr) then + return + end + + -- If the chat window was focused at capture time, only scroll if the user + -- hasn't moved the chat view since we captured `anchor`. + if opts.force ~= true and anchor and anchor.is_current and anchor.view then + local unchanged = vim.api.nvim_win_call(chat2.winid, function() + local view = vim.fn.winsaveview() + return view.topline == anchor.view.topline and view.lnum == anchor.view.lnum end) + if not unchanged then + return + end + end + + local current_line_count = vim.api.nvim_buf_line_count(chat2.bufnr) + if current_line_count < 1 then + current_line_count = 1 end - end, 10) -- Reduced delay for faster streaming response + + -- Set cursor to last line and scroll to bottom + vim.api.nvim_win_set_cursor(chat2.winid, { current_line_count, 0 }) + vim.api.nvim_win_call(chat2.winid, function() + vim.cmd("normal! zb") -- scroll so cursor line is at bottom of window + end) + end) end ---@param bufnr integer diff --git a/tests/test_sidebar_autoscroll.lua b/tests/test_sidebar_autoscroll.lua new file mode 100644 index 0000000..0d233c0 --- /dev/null +++ b/tests/test_sidebar_autoscroll.lua @@ -0,0 +1,154 @@ +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 120) + child.api.nvim_eval("1") +end + +local function setup_env() + _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.Sidebar:open() + + _G.fill_chat = function(n) + local chat = _G.Sidebar.containers.chat + vim.api.nvim_set_option_value('modifiable', true, { buf = chat.bufnr }) + + local lines = {} + for i = 1, n do + lines[i] = string.format('line %03d', i) + end + + vim.api.nvim_buf_set_lines(chat.bufnr, 0, -1, false, lines) + end + + _G.focus_chat = function() + vim.api.nvim_set_current_win(_G.Sidebar.containers.chat.winid) + end + + _G.focus_input = function() + vim.api.nvim_set_current_win(_G.Sidebar.containers.input.winid) + end + + _G.set_chat_cursor = function(row) + local win = _G.Sidebar.containers.chat.winid + vim.api.nvim_win_set_cursor(win, { row, 0 }) + end + + _G.add_assistant_message = function(text) + _G.Sidebar:_add_message('assistant', text) + end + + _G.get_chat_view = function() + local chat = _G.Sidebar.containers.chat + local view = vim.api.nvim_win_call(chat.winid, function() + local v = vim.fn.winsaveview() + return { topline = v.topline, lnum = v.lnum } + end) + + local bottomline = vim.api.nvim_win_call(chat.winid, function() + return vim.fn.line('w$') + end) + + return { + current_win = vim.api.nvim_get_current_win(), + chat_win = chat.winid, + input_win = _G.Sidebar.containers.input.winid, + cursor = vim.api.nvim_win_get_cursor(chat.winid), + topline = view.topline, + lnum = view.lnum, + bottomline = bottomline, + line_count = vim.api.nvim_buf_line_count(chat.bufnr), + } + end +end + +local T = MiniTest.new_set({ + hooks = { + pre_case = function() + child.restart({ "-u", "scripts/minimal_init.lua" }) + child.lua_func(setup_env) + end, + post_case = function() + child.lua([[ if _G.Sidebar then _G.Sidebar:close() end ]]) + end, + post_once = child.stop, + }, +}) + +T["sidebar autoscroll"] = MiniTest.new_set() + +T["sidebar autoscroll"]["auto-scrolls when chat is not focused"] = function() + -- Let deferred sidebar setup (like initial focus) settle. + flush(200) + + child.lua([[ + _G.fill_chat(100) + _G.focus_chat() + _G.set_chat_cursor(1) + _G.focus_input() + ]]) + + -- Add a new assistant message while focus is on input. + child.lua([[_G.add_assistant_message('incoming')]]) + + flush(220) + + local view = child.lua_get("_G.get_chat_view()") + + -- Focus should remain on input. + eq(view.current_win, view.input_win) + -- Chat cursor should be moved to the bottom. + eq(view.cursor[1], view.line_count) +end + +T["sidebar autoscroll"]["does not auto-scroll when chat is focused and not at bottom"] = function() + -- Let deferred sidebar setup (like initial focus) settle. + flush(200) + + child.lua([[ + _G.fill_chat(100) + _G.focus_chat() + _G.set_chat_cursor(10) + _G.before = _G.get_chat_view() + ]]) + + child.lua([[_G.add_assistant_message('incoming')]]) + + flush(220) + + local before = child.lua_get("_G.before") + local after = child.lua_get("_G.get_chat_view()") + + eq(after.current_win, after.chat_win) + eq(after.cursor[1], before.cursor[1]) + eq(after.topline, before.topline) +end + +T["sidebar autoscroll"]["auto-scrolls when chat is focused and at bottom"] = function() + -- Let deferred sidebar setup (like initial focus) settle. + flush(200) + + child.lua([[ + _G.fill_chat(100) + _G.focus_chat() + _G.set_chat_cursor(100) + _G.before = _G.get_chat_view() + ]]) + + child.lua([[_G.add_assistant_message('incoming')]]) + + flush(220) + + local after = child.lua_get("_G.get_chat_view()") + + eq(after.current_win, after.chat_win) + eq(after.cursor[1], after.line_count) +end + +return T From 63c61936318db0878a2043c89785b8525d9c1e82 Mon Sep 17 00:00:00 2001 From: joaopluigi Date: Wed, 7 Jan 2026 10:52:18 -0300 Subject: [PATCH 30/31] add test workflow for more recent nvim versions --- .github/workflows/test.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5914ba2..f600dc7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,15 +11,15 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - neovim_version: ['v0.9.5', 'v0.10.0', 'nightly'] - + neovim_version: ['v0.9.5', 'v0.10.0', 'v0.11.4', 'v0.12.0', 'nightly'] + steps: - uses: actions/checkout@v4 - + - name: Install Neovim uses: rhysd/action-setup-vim@v1 with: neovim: true - + - name: Run tests run: make test From a307556a3790fa306ee0d3d2d9f71879675cbd73 Mon Sep 17 00:00:00 2001 From: joaopluigi Date: Wed, 7 Jan 2026 10:58:13 -0300 Subject: [PATCH 31/31] fix EcaFixTreeSitter command and update docs --- docs/configuration.md | 2 +- lua/eca/commands.lua | 7 +++---- lua/eca/config.lua | 8 -------- 3 files changed, 4 insertions(+), 13 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 501cc95..e6d6c40 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -333,7 +333,7 @@ require("eca").setup({ - Set `server_path` if you prefer using a local ECA binary. - Use the `log` block to control verbosity and where logs are written. - `context.auto_repo_map` controls whether repo context is attached automatically. -- `todos` and `selected_code` can be disabled entirely if you prefer a simpler UI. + - Adjust `windows.width` to fit your layout. - Keymaps can be set manually by turning off `behavior.auto_set_keymaps` and defining your own mappings. - The `windows.usage.format` string controls how token and cost usage are displayed. diff --git a/lua/eca/commands.lua b/lua/eca/commands.lua index 676435b..667f228 100644 --- a/lua/eca/commands.lua +++ b/lua/eca/commands.lua @@ -407,13 +407,12 @@ function M.setup() }) vim.api.nvim_create_user_command("EcaFixTreesitter", function() - local Utils = require("eca.utils") - -- Emergency treesitter fix for chat buffer vim.schedule(function() local eca = require("eca") - if eca.sidebar and eca.sidebar.containers and eca.sidebar.containers.chat then - local bufnr = eca.sidebar.containers.chat.bufnr + local sidebar = eca.get() + if sidebar and sidebar.containers and sidebar.containers.chat then + local bufnr = sidebar.containers.chat.bufnr if bufnr and vim.api.nvim_buf_is_valid(bufnr) then -- Disable all highlighting for this buffer pcall(vim.api.nvim_set_option_value, "syntax", "off", { buf = bufnr }) diff --git a/lua/eca/config.lua b/lua/eca/config.lua index 9ae10d1..397bccc 100644 --- a/lua/eca/config.lua +++ b/lua/eca/config.lua @@ -21,14 +21,6 @@ M._defaults = { context = { auto_repo_map = true, -- Automatically add repoMap context when starting new chat }, - todos = { - enabled = true, -- Enable todos functionality - max_height = 5, -- Maximum height for todos container - }, - selected_code = { - enabled = true, -- Enable selected code display - max_height = 8, -- Maximum height for selected code container - }, mappings = { chat = "ec", focus = "ef",