From 62687fd57de40b78f46f05d8432ea5c8c7659ad4 Mon Sep 17 00:00:00 2001 From: joaopluigi Date: Thu, 25 Sep 2025 11:45:44 -0300 Subject: [PATCH 01/10] get config from server, store in state and display in sidebar --- lua/eca/init.lua | 3 +- lua/eca/mediator.lua | 45 +++++++- lua/eca/observer.lua | 4 +- lua/eca/sidebar.lua | 248 ++++++++++++++++++++++++------------------- lua/eca/state.lua | 201 +++++++++++++++++++++++++++++++++++ 5 files changed, 388 insertions(+), 113 deletions(-) create mode 100644 lua/eca/state.lua diff --git a/lua/eca/init.lua b/lua/eca/init.lua index 3772395..5baed4e 100644 --- a/lua/eca/init.lua +++ b/lua/eca/init.lua @@ -242,8 +242,9 @@ function M.setup(opts) H.signs() -- Initialize the ECA server with callbacks + M.state = require("eca.state").new() M.server = Server.new() - M.mediator = require("eca.mediator").new(M.server) + M.mediator = require("eca.mediator").new(M.server, M.state) -- Start server automatically in background vim.defer_fn(function() M.server:start() diff --git a/lua/eca/mediator.lua b/lua/eca/mediator.lua index a99ec96..499267a 100644 --- a/lua/eca/mediator.lua +++ b/lua/eca/mediator.lua @@ -1,12 +1,15 @@ ---@class eca.Mediator ---@field server eca.Server +---@field state eca.State local mediator = {} ---@param server eca.Server +---@param state eca.State ---@return eca.Mediator -function mediator.new(server) +function mediator.new(server, state) return setmetatable({ server = server, + state = state, }, { __index = mediator }) end @@ -23,4 +26,44 @@ function mediator:send(method, params, callback) self.server:send_request(method, params, callback) end +function mediator:selected_behavior() + return self.state.config.behaviors.selected +end + +function mediator:selected_model() + return self.state.config.models.selected +end + +function mediator:tokens_session() + return self.state.usage.tokens.session +end + +function mediator:tokens_limit() + return self.state.usage.tokens.limit +end + +function mediator:costs_session() + return self.state.usage.costs.session +end + +function mediator:status_state() + return self.state.status.state +end + +function mediator:status_text() + return self.state.status.text +end + +function mediator:mcps() + local mcps = {} + + for _, tool in pairs(self.state.tools) do + if tool.type == "mcp" then + table.insert(mcps, tool) + end + end + + return mcps +end + return mediator diff --git a/lua/eca/observer.lua b/lua/eca/observer.lua index 7a8e2ac..e6a4c30 100644 --- a/lua/eca/observer.lua +++ b/lua/eca/observer.lua @@ -1,9 +1,9 @@ local observer = {} ----@type { [integer]: fun(message: table) } +---@type { [string]: fun(message: table) } local subscriptions = {} ----@param id integer +---@param id string ---@param on_update fun(message: table) function observer.subscribe(id, on_update) subscriptions[id] = on_update diff --git a/lua/eca/sidebar.lua b/lua/eca/sidebar.lua index d302425..09e2fbc 100644 --- a/lua/eca/sidebar.lua +++ b/lua/eca/sidebar.lua @@ -31,10 +31,7 @@ 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 UI_ELEMENTS_HEIGHT = 2 -- Reserve space for statusline and tabline -local SAFETY_MARGIN = 2 -- Extra margin to prevent "Not enough room" errors ---@param id integer Tab ID ---@param mediator eca.Mediator @@ -44,6 +41,7 @@ function M.new(id, mediator) instance.id = id instance.mediator = mediator instance.containers = {} + instance.extmarks = {} instance._initialized = false instance._current_response_buffer = "" instance._is_streaming = false @@ -65,7 +63,7 @@ function M.new(id, mediator) assistant = (Config.chat and Config.chat.headers and Config.chat.headers.assistant) or "## 🤖 ECA\n\n", } - require("eca.observer").subscribe(id, function(message) + require("eca.observer").subscribe("sidebar-" .. id, function(message) instance:handle_chat_content(message) end) return instance @@ -228,15 +226,11 @@ function M:_create_containers() local todos_height = self:get_todos_height() local original_chat_height = self:get_chat_height() local chat_height = original_chat_height + local config_height = 1 -- Validate total height to prevent "Not enough room" error local total_height = chat_height - + selected_code_height - + todos_height - + status_height - + contexts_height - + input_height - + usage_height + + config_height -- Always calculate from total screen minus UI elements (more accurate than current window) local available_height = vim.o.lines - UI_ELEMENTS_HEIGHT @@ -293,7 +287,26 @@ function M:_create_containers() local current_winid = self.containers.chat.winid Logger.debug("Mounted container: chat (winid: " .. current_winid .. ")") - -- 2. Create selected_code container (conditional) + --2. Create config container in top of chat + self.containers.config = Split({ + relative = { + type = "win", + winid = current_winid, + }, + position = "top", + size = { height = config_height }, + buf_options = vim.tbl_deep_extend("force", base_buf_options, { + modifiable = false, + }), + win_options = vim.tbl_deep_extend("force", base_win_options, { + winhighlight = "Normal:Normal", + }), + }) + self.containers.config:mount() + self:_setup_container_events(self.containers.config, "config") + Logger.debug("Mounted container: config (winid: " .. self.containers.config.winid .. ")") + + -- 3. Create selected_code container (conditional) if selected_code_height > 0 then self.containers.selected_code = Split({ relative = { @@ -316,7 +329,7 @@ function M:_create_containers() Logger.debug("Mounted container: selected_code (winid: " .. current_winid .. ")") end - -- 3. Create todos container (conditional) + -- 4. Create todos container (conditional) if todos_height > 0 then self.containers.todos = Split({ relative = { @@ -338,27 +351,7 @@ function M:_create_containers() Logger.debug("Mounted container: todos (winid: " .. current_winid .. ")") end - -- 4. Create status container (always present) - for processing messages - self.containers.status = Split({ - relative = { - type = "win", - winid = current_winid, - }, - position = "bottom", - size = { height = status_height }, - buf_options = vim.tbl_deep_extend("force", base_buf_options, { - modifiable = false, - }), - win_options = vim.tbl_deep_extend("force", base_win_options, { - winhighlight = "Normal:WarningMsg", - }), - }) - self.containers.status:mount() - self:_setup_container_events(self.containers.status, "status") - current_winid = self.containers.status.winid - Logger.debug("Mounted container: status (winid: " .. current_winid .. ")") - - -- 5. Create contexts container between status and input + -- 5. Create contexts container between chat and input self.containers.contexts = Split({ relative = { type = "win", @@ -378,7 +371,7 @@ function M:_create_containers() current_winid = self.containers.contexts.winid Logger.debug("Mounted container: contexts (winid: " .. current_winid .. ")") - -- 6. Create input container (always present) + --6. Create input container (always present) self.containers.input = Split({ relative = { type = "win", @@ -405,13 +398,14 @@ function M:_create_containers() type = "win", winid = current_winid, }, + enter = false, position = "bottom", size = { height = usage_height }, buf_options = vim.tbl_deep_extend("force", base_buf_options, { modifiable = false, }), win_options = vim.tbl_deep_extend("force", base_win_options, { - winhighlight = "Normal:StatusLine", + winhighlight = "Normal:Comment", statusline = " ", }), }) @@ -421,14 +415,15 @@ function M:_create_containers() Logger.debug( string.format( - "Created containers: contexts=%d, chat=%d, selected_code=%s, todos=%s, status=%d, input=%d, usage=%d", + "Created containers: contexts=%d, chat=%d, selected_code=%s, todos=%s, status=%d, input=%d, usage=%d, config=%d", contexts_height, chat_height, selected_code_height > 0 and tostring(selected_code_height) or "hidden", todos_height > 0 and tostring(todos_height) or "hidden", status_height, input_height, - usage_height + usage_height, + config_height ) ) end @@ -567,17 +562,12 @@ function M:get_chat_height() local contexts_height = self:get_contexts_height() local selected_code_height = self:get_selected_code_height() local todos_height = self:get_todos_height() + local config_height = 1 return math.max( MIN_CHAT_HEIGHT, total_height - - input_height - - usage_height - - status_height - - contexts_height - - selected_code_height - - todos_height - - WINDOW_MARGIN + - config_height ) end @@ -696,7 +686,7 @@ function M:_setup_containers() self:_setup_todos_container() end - self:_setup_status_container() + self:_update_config_display() self:_setup_input_container() self:_setup_usage_container() @@ -713,6 +703,10 @@ function M:_refresh_container_content() self:_set_welcome_content() end + if self.containers.config then + self:_update_config_display() + end + if self.containers.selected_code then self:_update_selected_code_display() end @@ -721,16 +715,22 @@ function M:_refresh_container_content() self:_update_todos_display() end - if self.containers.status then - self:_update_status_display() - end - if self.containers.input then self:_add_input_line() end if self.containers.usage then - self:_update_usage_info(self._usage_info) + self:_update_usage_info() + end +end + +function M:_handle_state_updated(state) + if state.usage or state.status then + self:_update_usage_info() + end + + if state.config or state.tools then + self:_update_config_display() end end @@ -799,16 +799,6 @@ function M:_setup_contexts_container() self:_update_contexts_display() end -function M:_setup_status_container() - local status = self.containers.status - if not status then - return - end - - -- Set initial status display - self:_update_status_display() -end - function M:_setup_usage_container() local usage = self.containers.usage if not usage then @@ -816,9 +806,7 @@ function M:_setup_usage_container() end -- Set initial usage info - vim.api.nvim_set_option_value("modifiable", true, { buf = usage.bufnr }) - vim.api.nvim_buf_set_lines(usage.bufnr, 0, -1, false, { "Usage: Tokens | Cost" }) - vim.api.nvim_set_option_value("modifiable", false, { buf = usage.bufnr }) + self:_update_usage_info() end function M:_setup_input_container() @@ -1074,6 +1062,41 @@ function M:_update_todos_display() vim.api.nvim_set_option_value("modifiable", false, { buf = container.bufnr }) end +function M:_update_config_display() + local config = self.containers.config + if not config or not vim.api.nvim_buf_is_valid(config.bufnr) then + return + end + + local model = self.mediator:selected_model() or "unknown" + local behavior = self.mediator:selected_behavior() or "unknown" + local mcps = vim.tbl_count(self.mediator:mcps()) + + local texts = { + { "model:", "Comment" }, { model, "Normal" }, { "\t" }, + { "behavior:", "Comment" }, { behavior, "Normal" }, { " " }, + { "mcps:", "Comment" }, { tostring(mcps), "Exception" }, + } + + local virt_opts = { virt_text = texts, hl_mode = "combine" } + + self.extmarks = self.extmarks or {} + + if not self.extmarks.config then + self.extmarks.config = { + _ns = vim.api.nvim_create_namespace('extmarks_config'), + } + end + + self.extmarks.config._id = vim.api.nvim_buf_set_extmark( + config.bufnr, + self.extmarks.config._ns, + 0, + -1, + vim.tbl_extend("force", virt_opts, { id = self.extmarks.config._id }) + ) +end + function M:_update_contexts_display() -- Similar implementation for contexts... local contexts = self.containers.contexts @@ -1106,40 +1129,65 @@ function M:_update_contexts_display() vim.api.nvim_set_option_value("modifiable", false, { buf = contexts.bufnr }) end -function M:_update_status_display() - local status = self.containers.status - if not status or not vim.api.nvim_buf_is_valid(status.bufnr) then +function M:_update_usage_info() + local usage = self.containers.usage + if not usage or not vim.api.nvim_buf_is_valid(usage.bufnr) then return end - local status_text = self._current_status or "" - if status_text == "" then - status_text = "💤 Ready" + local status_state = self.mediator:status_state() + local status_text = self.mediator:status_text() + + if status_state == "finished" then + status_text = "Idle" end - -- Update the buffer - vim.api.nvim_set_option_value("modifiable", true, { buf = status.bufnr }) - vim.api.nvim_buf_set_lines(status.bufnr, 0, -1, false, { status_text }) - vim.api.nvim_set_option_value("modifiable", false, { buf = status.bufnr }) -end + local tokens = self.mediator:tokens_session() or 0 + local limit = self.mediator:tokens_limit() or 0 + local costs = self.mediator:costs_session() or "0.00" ----@param status_text string -function M:set_status(status_text) - self._current_status = status_text or "" - self:_update_status_display() -end + self._current_status = string.format("%s", status_text) + self._usage_info = string.format("%d / %d (%s)", tokens, limit, costs) -function M:_update_usage_info(usage_text) - local usage = self.containers.usage - if not usage or not vim.api.nvim_buf_is_valid(usage.bufnr) then - return - end + self.extmarks = self.extmarks or {} - self._usage_info = usage_text or "Usage: Tokens | Cost" + if not self.extmarks.usage then + self.extmarks.usage = { + _ns = vim.api.nvim_create_namespace('extmarks_usage'), + } + end vim.api.nvim_set_option_value("modifiable", true, { buf = usage.bufnr }) - vim.api.nvim_buf_set_lines(usage.bufnr, 0, -1, false, { self._usage_info }) + vim.api.nvim_buf_set_lines(usage.bufnr, 0, -1, false, { "" }) vim.api.nvim_set_option_value("modifiable", false, { buf = usage.bufnr }) + + self.extmarks.usage._id_status = vim.api.nvim_buf_set_extmark( + usage.bufnr, + self.extmarks.usage._ns, + 0, + -1, + vim.tbl_extend("force", + { + virt_text = { { self._current_status, (status_text ~= "Idle") and "Question" or "Normal" } }, + virt_text_pos = 'eol', + hl_mode = 'combine', + }, + { id = self.extmarks.config._id_status }) + ) + + self.extmarks.usage._id_usage = vim.api.nvim_buf_set_extmark( + usage.bufnr, + self.extmarks.usage._ns, + 0, + -1, + vim.tbl_extend("force", + { + virt_text = { { self._usage_info } }, + virt_text_pos = 'right_align', + hl_mode = 'combine', + }, + { id = self.extmarks.config._id_usage }) + ) end function M:_render_header(container_name, header_text) @@ -1195,6 +1243,10 @@ function M:handle_chat_content(message) if message.params then self:handle_chat_content_received(message.params) end + + if message.type == "state/updated" then + self:_handle_state_updated(message.content) + end end ---@param params table Server content notification @@ -1210,26 +1262,10 @@ function M:handle_chat_content_received(params) -- Handle streaming text content self:_handle_streaming_text(content.text) elseif content.type == "progress" then - if content.state == "running" then - -- Show progress in status container instead of chat - self:set_status("⏳ " .. (content.text or "Processing...")) - elseif content.state == "finished" then - -- Clear status and finalize any streaming response - self:set_status("💤 Ready") + if content.state == "finished" then self:_finalize_streaming_response() self:_add_input_line() end - elseif content.type == "usage" then - -- Finalize streaming before adding usage info - self:_finalize_streaming_response() - - -- Update usage container only (remove duplication in chat) - local usage_text = - string.format("Usage: Tokens %d in, %d out", content.messageInputTokens or 0, content.messageOutputTokens or 0) - if content.messageCost then - usage_text = usage_text .. " | Cost: " .. content.messageCost - end - self:_update_usage_info(usage_text) elseif content.type == "toolCallPrepare" then self:_finalize_streaming_response() self:_handle_tool_call_prepare(content) @@ -1406,12 +1442,6 @@ 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* +local State = {} + +---@return eca.State +function State._new() + local instance = setmetatable({ + status = { + state = "idle", + text = "Idle", + }, + config = { + welcome_message = nil, + behaviors = { + list = {}, + default = nil, + selected = nil, + }, + models = { + list = {}, + default = nil, + selected = nil, + }, + }, + usage = { + tokens = { + limit = 0, + session = 0, + }, + costs = { + last_message = "0.00", + session = "0.00", + }, + }, + tools = {}, + }, { __index = State }) + + local handlers = { + ["chat/contentReceived"] = function(message) instance:_chat_content_received(message) end, + ["config/updated"] = function(message) instance:_config_updated(message) end, + ["tool/serverUpdated"] = function(message) instance:_tool_server_updated(message) end, + } + + require("eca.observer").subscribe("state-1", function(message) + if not message or not message.method then + return + end + + local handler = handlers[message.method] + + if not handler or type(handler) ~= 'function' then + return + end + + handler(message) + end) + + return instance +end + +local _instance + +---@return eca.State +function State.new() + if not _instance then + _instance = State._new() + end + + return _instance +end + +function State:_chat_content_received(message) + if not message or not message.params then + return + end + + if not message.params.content or not message.params.content.type then + return + end + + local content = message.params.content + + if content.type == "progress" then + self:_update_status(content) + end + + if content.type == "usage" then + self:_update_usage(content) + end +end + +function State:_config_updated(message) + if not message or not message.params then + return + end + + if not message.params.chat or type(message.params.chat) ~= "table" then + return + end + + self:_update_config({ chat = vim.deepcopy(message.params.chat) }) +end + +function State:_tool_server_updated(message) + if not message or not message.params then + return + end + + self:_update_tools(message.params) +end + +function State:_update_config(config) + local chat = config.chat + + if not chat or type(chat) ~= "table" then + return + end + + self.config.behaviors = { + list = (chat.behaviors and vim.deepcopy(chat.behaviors)) or self.config.behaviors.list, + default = (chat.defaultBehavior) or self.config.behaviors.default, + selected = (chat.selectBehavior) or self.config.behaviors.selected, + } + + self.config.models = { + list = (chat.models and vim.deepcopy(chat.models)) or self.config.models.list, + default = (chat.defaultModel) or self.config.models.default, + selected = (chat.selectModel) or self.config.models.selected, + } + + self.config.welcome_message = (chat and chat.welcomeMessage) or self.config.welcome_message + + vim.schedule(function() + require("eca.observer").notify({ type = "state/updated", content = { config = vim.deepcopy(self.config) } }) + end) +end + +function State:_update_status(status) + self.status.state = status.state or self.status.state + self.status.text = status.text or self.status.text + + vim.schedule(function() + require("eca.observer").notify({ type = "state/updated", content = { status = vim.deepcopy(self.status) } }) + end) +end + +function State:_update_usage(usage) + self.usage = { + tokens = { + limit = (usage.limit and usage.limit.output) or self.usage.tokens.limit, + session = usage.sessionTokens or self.usage.tokens.session, + }, + costs = { + last_message = usage.lastMessageCost or self.usage.costs.last_message, + session = usage.sessionCost or self.usage.costs.session, + }, + } + + vim.schedule(function() + require("eca.observer").notify({ type = "state/updated", content = { usage = vim.deepcopy(self.usage) } }) + end) +end + +function State:_update_tools(tool) + if not tool.name then + return + end + + self.tools[tool.name] = { + name = tool.name, + type = tool.type or (self.tools[tool.name] and self.tools[tool.name].type) or "unknown", + status = tool.status or (self.tools[tool.name] and self.tools[tool.name].status) or "unknown", + } + + vim.schedule(function() + require("eca.observer").notify({ type = "state/updated", content = { tools = vim.deepcopy(self.tools) } }) + end) +end + +return State From 61adc15a7c74c93c18b89c2f6a9372cee7d61938 Mon Sep 17 00:00:00 2001 From: joaopluigi Date: Thu, 25 Sep 2025 11:45:58 -0300 Subject: [PATCH 02/10] add EcaServerMessages command --- lua/eca/commands.lua | 46 ++++++++++++++++++++++++++++++++++++++++++++ lua/eca/server.lua | 11 ++++------- 2 files changed, 50 insertions(+), 7 deletions(-) diff --git a/lua/eca/commands.lua b/lua/eca/commands.lua index 898763d..5c14c60 100644 --- a/lua/eca/commands.lua +++ b/lua/eca/commands.lua @@ -157,6 +157,52 @@ function M.setup() desc = "Restart ECA server", }) + 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 + { + source = "eca messages", + finder = function(opts, ctx) + ---@type snacks.picker.finder.Item[] + local items = {} + local eca = require("eca") + if not eca or not eca.server then + Logger.notify("ECA plugin is not available", vim.log.levels.ERROR) + return items + end + + for msg in vim.iter(eca.server.messages) do + print(msg) + local decoded = vim.json.decode(msg.content) + table.insert(items, { + text = decoded.method, + idx = decoded.id, + preview = { + text = vim.inspect(decoded), + 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 Messages Sent to and Received by ECA server", + }) + vim.api.nvim_create_user_command("EcaLogs", function(opts) local Api = require("eca.api") local subcommand = opts.args and opts.args:match("%S+") or "show" diff --git a/lua/eca/server.lua b/lua/eca/server.lua index c1f2154..babe87e 100644 --- a/lua/eca/server.lua +++ b/lua/eca/server.lua @@ -13,7 +13,6 @@ local Logger = require("eca.logger") ---@field on_stop function Callback when the server stops ---Called when a notification is received(message without an ID) ---@field on_notification fun(server: eca.Server, message: table) ----@field capabilities eca.ServerCapabilities Server capabilities ---@field private path_finder eca.PathFinder Server path finder ---@field pending_requests {id: fun(err, data)} -- outgoing requests with callbacks local M = {} @@ -50,7 +49,6 @@ function M.new(opts) path_finder = opts.path_finder, messages = {}, pending_requests = {}, - capabilities = {}, initialized = false, next_id = 0, }, { __index = M }) @@ -67,7 +65,7 @@ local function on_stdout(server) if #message.content ~= message.content_length then return end - table.insert(messages, message) + table.insert(server.messages, message) local msg = vim.json.decode(message.content) server:handle_message(msg) end) @@ -171,20 +169,18 @@ function M:initialize() }, }, workspaceFolders = workspace_folders, - }, function(err, result) + }, function(err, _) if err then Logger.notify("Could not initialize server: " .. err, vim.log.levels.ERROR) return end - if result then - self.capabilities = result - end self:send_notification("initialized", {}) if self.on_initialize then self.on_initialize() end + self.initialized = true end) end @@ -259,6 +255,7 @@ function M:send_request(method, params, callback) end local json = vim.json.encode(message) + table.insert(self.messages, { content = json, content_length = #json }) local content = string.format("Content-Length: %d\r\n\r\n%s", #json, json) self.process:write(content) end From 223db652ef8017c78f49ee1dbd7c4c035aafd9ad Mon Sep 17 00:00:00 2001 From: joaopluigi Date: Thu, 25 Sep 2025 11:51:39 -0300 Subject: [PATCH 03/10] revert some unwanted changes --- lua/eca/sidebar.lua | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/lua/eca/sidebar.lua b/lua/eca/sidebar.lua index 09e2fbc..aafc5d7 100644 --- a/lua/eca/sidebar.lua +++ b/lua/eca/sidebar.lua @@ -31,7 +31,10 @@ 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 UI_ELEMENTS_HEIGHT = 2 -- Reserve space for statusline and tabline +local SAFETY_MARGIN = 2 -- Extra margin to prevent "Not enough room" errors ---@param id integer Tab ID ---@param mediator eca.Mediator @@ -230,6 +233,12 @@ function M:_create_containers() -- Validate total height to prevent "Not enough room" error local total_height = chat_height + + selected_code_height + + todos_height + + status_height + + contexts_height + + input_height + + usage_height + config_height -- Always calculate from total screen minus UI elements (more accurate than current window) @@ -567,6 +576,13 @@ function M:get_chat_height() return math.max( MIN_CHAT_HEIGHT, total_height + - input_height + - usage_height + - status_height + - contexts_height + - selected_code_height + - todos_height + - WINDOW_MARGIN - config_height ) end @@ -1442,6 +1458,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* Date: Thu, 25 Sep 2025 11:52:00 -0300 Subject: [PATCH 04/10] clear config buffer on update --- lua/eca/sidebar.lua | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lua/eca/sidebar.lua b/lua/eca/sidebar.lua index aafc5d7..5e0b76c 100644 --- a/lua/eca/sidebar.lua +++ b/lua/eca/sidebar.lua @@ -1104,6 +1104,10 @@ function M:_update_config_display() } end + vim.api.nvim_set_option_value("modifiable", true, { buf = config.bufnr }) + vim.api.nvim_buf_set_lines(config.bufnr, 0, -1, false, { "" }) + vim.api.nvim_set_option_value("modifiable", false, { buf = config.bufnr }) + self.extmarks.config._id = vim.api.nvim_buf_set_extmark( config.bufnr, self.extmarks.config._ns, From ffd68e292698b4d6487e38d6c2c724d6fd9950cf Mon Sep 17 00:00:00 2001 From: joaopluigi Date: Thu, 25 Sep 2025 12:00:23 -0300 Subject: [PATCH 05/10] change mcp count highlight based on mcp status --- lua/eca/sidebar.lua | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/lua/eca/sidebar.lua b/lua/eca/sidebar.lua index 5e0b76c..bec7c5c 100644 --- a/lua/eca/sidebar.lua +++ b/lua/eca/sidebar.lua @@ -1086,12 +1086,26 @@ function M:_update_config_display() local model = self.mediator:selected_model() or "unknown" local behavior = self.mediator:selected_behavior() or "unknown" - local mcps = vim.tbl_count(self.mediator:mcps()) + local mcps = self.mediator:mcps() + + local mcps_hl = "Normal" + + for _, mcp in pairs(mcps) do + if mcp.status == "starting" then + mcps_hl = "Comment" + break + end + + if mcp.status == "failed" then + mcps_hl = "Exception" + break + end + end local texts = { { "model:", "Comment" }, { model, "Normal" }, { "\t" }, { "behavior:", "Comment" }, { behavior, "Normal" }, { " " }, - { "mcps:", "Comment" }, { tostring(mcps), "Exception" }, + { "mcps:", "Comment" }, { tostring(vim.tbl_count(mcps)), mcps_hl }, } local virt_opts = { virt_text = texts, hl_mode = "combine" } @@ -1188,7 +1202,7 @@ function M:_update_usage_info() -1, vim.tbl_extend("force", { - virt_text = { { self._current_status, (status_text ~= "Idle") and "Question" or "Normal" } }, + virt_text = { { self._current_status, (status_text ~= "Idle") and "Debug" or "Normal" } }, virt_text_pos = 'eol', hl_mode = 'combine', }, From d01f46536282e2fff5a50dc3e41fbbca1e0aa585 Mon Sep 17 00:00:00 2001 From: joaopluigi Date: Thu, 25 Sep 2025 12:25:03 -0300 Subject: [PATCH 06/10] change server status hl to match previous implementation --- 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 bec7c5c..6b00f00 100644 --- a/lua/eca/sidebar.lua +++ b/lua/eca/sidebar.lua @@ -1202,7 +1202,7 @@ function M:_update_usage_info() -1, vim.tbl_extend("force", { - virt_text = { { self._current_status, (status_text ~= "Idle") and "Debug" or "Normal" } }, + virt_text = { { self._current_status, (status_text ~= "Idle") and "WarningMsg" or "Normal" } }, virt_text_pos = 'eol', hl_mode = 'combine', }, From 669c69410f5621fc37f73df906ccd5a449fe378b Mon Sep 17 00:00:00 2001 From: joaopluigi Date: Thu, 25 Sep 2025 12:26:36 -0300 Subject: [PATCH 07/10] rm leftover print --- lua/eca/commands.lua | 1 - 1 file changed, 1 deletion(-) diff --git a/lua/eca/commands.lua b/lua/eca/commands.lua index 5c14c60..6a245e6 100644 --- a/lua/eca/commands.lua +++ b/lua/eca/commands.lua @@ -178,7 +178,6 @@ function M.setup() end for msg in vim.iter(eca.server.messages) do - print(msg) local decoded = vim.json.decode(msg.content) table.insert(items, { text = decoded.method, From 22b032bffdaf3eddd08a093b839898c2afcdf71b Mon Sep 17 00:00:00 2001 From: joaopluigi Date: Thu, 25 Sep 2025 12:28:39 -0300 Subject: [PATCH 08/10] fix usage buffer extmarks id --- lua/eca/sidebar.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lua/eca/sidebar.lua b/lua/eca/sidebar.lua index 6b00f00..a5cdee6 100644 --- a/lua/eca/sidebar.lua +++ b/lua/eca/sidebar.lua @@ -1206,7 +1206,7 @@ function M:_update_usage_info() virt_text_pos = 'eol', hl_mode = 'combine', }, - { id = self.extmarks.config._id_status }) + { id = self.extmarks.usage._id_status }) ) self.extmarks.usage._id_usage = vim.api.nvim_buf_set_extmark( @@ -1220,7 +1220,7 @@ function M:_update_usage_info() virt_text_pos = 'right_align', hl_mode = 'combine', }, - { id = self.extmarks.config._id_usage }) + { id = self.extmarks.usage._id_usage }) ) end From d36ede9128f686402ec2e88de51d87a08c39757b Mon Sep 17 00:00:00 2001 From: joaopluigi Date: Thu, 25 Sep 2025 14:35:26 -0300 Subject: [PATCH 09/10] add tests for state.lua --- tests/test_state.lua | 187 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 tests/test_state.lua diff --git a/tests/test_state.lua b/tests/test_state.lua new file mode 100644 index 0000000..66a6d11 --- /dev/null +++ b/tests/test_state.lua @@ -0,0 +1,187 @@ +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 = {} + local Observer = require('eca.observer') + -- Clear any prior subscriptions by reloading the module (defensive) + package.loaded['eca.observer'] = nil + Observer = require('eca.observer') + + -- Subscribe to capture all notifications + Observer.subscribe('test-capture', function(message) + table.insert(_G.captured, message) + end) + + -- Instantiate state singleton + _G.State = require('eca.state').new() + + -- Helper to filter captured messages by a predicate + _G.filter_msgs = function(pred) + local out = {} + for _, m in ipairs(_G.captured) do + if pred(m) then table.insert(out, m) end + end + return out + end + ]]) + end, + post_case = function() + child.lua([[require('eca.observer').unsubscribe('test-capture')]]) + end, + post_once = child.stop, + }, +}) + +-- Ensure scheduled callbacks run (vim.schedule) +local function flush(ms) + vim.uv.sleep(ms or 50) + -- Force at least one main loop iteration + child.api.nvim_eval("1") +end + +T["singleton and defaults"] = MiniTest.new_set() + +T["singleton and defaults"]["returns same instance"] = function() + eq(child.lua_get("require('eca.state').new() == require('eca.state').new()"), true) +end + +T["singleton and defaults"]["has expected default values"] = function() + eq(child.lua_get("_G.State.status.state"), "idle") + eq(child.lua_get("_G.State.status.text"), "Idle") + + eq(child.lua_get("vim.tbl_isempty(_G.State.config.behaviors.list)"), true) + eq(child.lua_get("_G.State.config.behaviors.default"), vim.NIL) + eq(child.lua_get("_G.State.config.behaviors.selected"), vim.NIL) + + eq(child.lua_get("vim.tbl_isempty(_G.State.config.models.list)"), true) + eq(child.lua_get("_G.State.config.models.default"), vim.NIL) + eq(child.lua_get("_G.State.config.models.selected"), vim.NIL) + + eq(child.lua_get("_G.State.config.welcome_message"), vim.NIL) + + eq(child.lua_get("_G.State.usage.tokens.limit"), 0) + eq(child.lua_get("_G.State.usage.tokens.session"), 0) + eq(child.lua_get("_G.State.usage.costs.last_message"), "0.00") + eq(child.lua_get("_G.State.usage.costs.session"), "0.00") + + eq(child.lua_get("type(_G.State.tools)"), "table") +end + +T["updates via observer notifications"] = MiniTest.new_set() + +T["updates via observer notifications"]["updates status on progress content"] = function() + child.lua([[require('eca.observer').notify({ + method = 'chat/contentReceived', + params = { content = { type = 'progress', state = 'responding', text = 'Respondendo...' } }, + })]]) + flush() + + eq(child.lua_get("_G.State.status.state"), "responding") + eq(child.lua_get("_G.State.status.text"), "Respondendo...") + + -- Verify a state/updated notification was emitted for status + local updates = child.lua_get([[ _G.filter_msgs(function(m) + return type(m) == 'table' and m.type == 'state/updated' and type(m.content) == 'table' and m.content.status ~= nil + end) ]]) + eq(#updates >= 1, true) +end + +T["updates via observer notifications"]["updates usage on usage content"] = function() + child.lua([[require('eca.observer').notify({ + method = 'chat/contentReceived', + params = { content = { + type = 'usage', + limit = { output = 1024 }, + sessionTokens = 256, + lastMessageCost = '0.42', + sessionCost = '3.14', + } }, + })]]) + flush() + + eq(child.lua_get("_G.State.usage.tokens.limit"), 1024) + eq(child.lua_get("_G.State.usage.tokens.session"), 256) + eq(child.lua_get("_G.State.usage.costs.last_message"), "0.42") + eq(child.lua_get("_G.State.usage.costs.session"), "3.14") + + local updates = child.lua_get([[ _G.filter_msgs(function(m) + return type(m) == 'table' and m.type == 'state/updated' and type(m.content) == 'table' and m.content.usage ~= nil + end) ]]) + eq(#updates >= 1, true) +end + +T["updates via observer notifications"]["updates config on config/updated"] = function() + child.lua([[require('eca.observer').notify({ + method = 'config/updated', + params = { chat = { + behaviors = { 'agent', 'plan' }, + defaultBehavior = 'agent', + selectBehavior = 'plan', + models = { 'openai/gpt-5-mini', 'anthropic/claude' }, + defaultModel = 'openai/gpt-5-mini', + selectModel = 'anthropic/claude', + welcomeMessage = 'Bem-vindo ao ECA!', + } }, + })]]) + flush() + + eq(child.lua_get("_G.State.config.behaviors.list[1]"), "agent") + eq(child.lua_get("_G.State.config.behaviors.list[2]"), "plan") + eq(child.lua_get("_G.State.config.behaviors.default"), "agent") + eq(child.lua_get("_G.State.config.behaviors.selected"), "plan") + + eq(child.lua_get("_G.State.config.models.list[1]"), "openai/gpt-5-mini") + eq(child.lua_get("_G.State.config.models.list[2]"), "anthropic/claude") + eq(child.lua_get("_G.State.config.models.default"), "openai/gpt-5-mini") + eq(child.lua_get("_G.State.config.models.selected"), "anthropic/claude") + + eq(child.lua_get("_G.State.config.welcome_message"), "Bem-vindo ao ECA!") + + local updates = child.lua_get([[ _G.filter_msgs(function(m) + return type(m) == 'table' and m.type == 'state/updated' and type(m.content) == 'table' and m.content.config ~= nil + end) ]]) + eq(#updates >= 1, true) +end + +T["updates via observer notifications"]["updates tools on tool/serverUpdated"] = function() + -- Initial add + child.lua([[require('eca.observer').notify({ + method = 'tool/serverUpdated', + params = { name = 'server-1', type = 'mcp', status = 'connected' }, + })]]) + flush() + + eq(child.lua_get("_G.State.tools['server-1'].name"), "server-1") + eq(child.lua_get("_G.State.tools['server-1'].type"), "mcp") + eq(child.lua_get("_G.State.tools['server-1'].status"), "connected") + + -- Update only status, keep type + child.lua([[require('eca.observer').notify({ + method = 'tool/serverUpdated', + params = { name = 'server-1', status = 'disconnected' }, + })]]) + flush() + + eq(child.lua_get("_G.State.tools['server-1'].type"), "mcp") + eq(child.lua_get("_G.State.tools['server-1'].status"), "disconnected") + + -- Invalid: missing name should be ignored (no errors, no new entries) + local before = child.lua_get("vim.tbl_count(_G.State.tools)") + child.lua([[require('eca.observer').notify({ method = 'tool/serverUpdated', params = { status = 'x' } })]]) + flush() + local after = child.lua_get("vim.tbl_count(_G.State.tools)") + eq(after, before) + + local updates = child.lua_get([[ _G.filter_msgs(function(m) + return type(m) == 'table' and m.type == 'state/updated' and type(m.content) == 'table' and m.content.tools ~= nil + end) ]]) + eq(#updates >= 1, true) +end + +return T From db32bc3f487ca465b10d87b6b51fac7e6905c94a Mon Sep 17 00:00:00 2001 From: joaopluigi Date: Fri, 26 Sep 2025 09:40:21 -0300 Subject: [PATCH 10/10] fix bufnr nil when config/tool updated message arrives and chat is not open --- lua/eca/sidebar.lua | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lua/eca/sidebar.lua b/lua/eca/sidebar.lua index a5cdee6..d779313 100644 --- a/lua/eca/sidebar.lua +++ b/lua/eca/sidebar.lua @@ -179,6 +179,7 @@ function M:reset() -- Reset all state self.containers = {} + self.extmarks = {} self._initialized = false self._is_streaming = false self._current_response_buffer = "" @@ -1080,7 +1081,7 @@ end function M:_update_config_display() local config = self.containers.config - if not config or not vim.api.nvim_buf_is_valid(config.bufnr) then + if not config or not config.bufnr or not vim.api.nvim_buf_is_valid(config.bufnr) then return end @@ -1165,7 +1166,7 @@ end function M:_update_usage_info() local usage = self.containers.usage - if not usage or not vim.api.nvim_buf_is_valid(usage.bufnr) then + if not usage or not usage.bufnr or not vim.api.nvim_buf_is_valid(usage.bufnr) then return end