diff --git a/lua/eca/commands.lua b/lua/eca/commands.lua index 667f228..522c680 100644 --- a/lua/eca/commands.lua +++ b/lua/eca/commands.lua @@ -547,6 +547,15 @@ function M.setup() desc = "Display ECA server tools (yank preview on confirm)", }) + vim.api.nvim_create_user_command("EcaChatClear", function() + local sidebar = require("eca").get() + if sidebar then + sidebar:clear_chat() + end + end, { + desc = "Clear ECA chat buffer", + }) + Logger.debug("ECA commands registered") end diff --git a/lua/eca/config.lua b/lua/eca/config.lua index 397bccc..60dbe9a 100644 --- a/lua/eca/config.lua +++ b/lua/eca/config.lua @@ -17,6 +17,7 @@ M._defaults = { auto_start_server = false, -- Automatically start server on setup auto_download = true, -- Automatically download server if not found show_status_updates = true, -- Show status updates in notifications + preserve_chat_history = false, -- When true, chat history is preserved across sidebar open/close cycles }, context = { auto_repo_map = true, -- Automatically add repoMap context when starting new chat diff --git a/lua/eca/sidebar.lua b/lua/eca/sidebar.lua index c50f112..d6c4e14 100644 --- a/lua/eca/sidebar.lua +++ b/lua/eca/sidebar.lua @@ -157,11 +157,19 @@ function M:close() end function M:_close_windows_only() + local preserve = Config.behavior and Config.behavior.preserve_chat_history + for name, container in pairs(self.containers) do if container and container.winid and vim.api.nvim_win_is_valid(container.winid) then - container:unmount() - -- Keep the container reference but mark window as invalid - container.winid = nil + if preserve and name == "chat" then + -- Close only the window, keep the buffer alive + pcall(vim.api.nvim_win_close, container.winid, true) + container.winid = nil + else + container:unmount() + -- Keep the container reference but mark window as invalid + container.winid = nil + end end end Logger.debug("ECA sidebar windows closed") @@ -245,6 +253,34 @@ function M:reset() end end +function M:clear_chat() + local chat = self.containers and self.containers.chat + if chat and chat.bufnr and vim.api.nvim_buf_is_valid(chat.bufnr) then + -- Reset chat content state to prevent stale line numbers / extmark IDs. + self._tool_calls = {} + self._reasons = {} + self._current_tool_call = nil + self._is_tool_call_streaming = false + self._is_streaming = false + self._current_response_buffer = "" + self._last_user_message = "" + self._stream_visible_buffer = "" + if self._stream_queue then + self._stream_queue:clear() + end + -- Reset chat extmark refs (marks are invalidated when the buffer is wiped). + if self.extmarks then + self.extmarks.assistant = nil + self.extmarks.tool_header = nil + self.extmarks.tool_diff_label = nil + end + -- Prevent state/updated events from repopulating the cleared buffer. + self._welcome_message_applied = true + self._force_welcome = false + vim.api.nvim_buf_set_lines(chat.bufnr, 0, -1, false, {}) + end +end + function M:new_chat() self:reset() self._force_welcome = true @@ -318,6 +354,23 @@ function M:_create_containers() winfixwidth = false, } + local preserve = Config.behavior and Config.behavior.preserve_chat_history + local existing_chat_bufnr = preserve + and self.containers.chat + and self.containers.chat.bufnr + and vim.api.nvim_buf_is_valid(self.containers.chat.bufnr) + and self.containers.chat.bufnr + or nil + + -- Always unmount the old Split to clean up its autocmds. + local old_chat = self.containers.chat + if old_chat then + if existing_chat_bufnr then + old_chat.bufnr = nil -- detach so unmount() doesn't delete the preserved buffer + end + pcall(old_chat.unmount, old_chat) + end + -- Create and mount main chat container first self.containers.chat = Split({ relative = "editor", @@ -332,6 +385,13 @@ function M:_create_containers() }), win_options = base_win_options, }) + + if existing_chat_bufnr then + pcall(vim.api.nvim_buf_delete, self.containers.chat.bufnr, { force = true }) + self.containers.chat.bufnr = existing_chat_bufnr + Logger.debug("Reusing existing chat buffer: " .. existing_chat_bufnr) + end + self.containers.chat:mount() self:_setup_container_events(self.containers.chat, "chat") diff --git a/tests/test_chat_clear.lua b/tests/test_chat_clear.lua new file mode 100644 index 0000000..bb3eb9f --- /dev/null +++ b/tests/test_chat_clear.lua @@ -0,0 +1,238 @@ +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_helpers() + _G.fill_chat = function() + local sidebar = require("eca").get() + local chat = sidebar.containers.chat + vim.api.nvim_buf_set_lines(chat.bufnr, 0, -1, false, { "hello", "world", "foo" }) + end + + _G.get_chat_lines = function() + local sidebar = require("eca").get() + if not sidebar then + return nil + end + local chat = sidebar.containers and sidebar.containers.chat + if not chat or not vim.api.nvim_buf_is_valid(chat.bufnr) then + return nil + end + return vim.api.nvim_buf_get_lines(chat.bufnr, 0, -1, false) + end + + _G.chat_has_old_content = function() + for _, line in ipairs(_G.get_chat_lines() or {}) do + if line == "hello" or line == "world" or line == "foo" then + return true + end + end + return false + end + + _G.get_sidebar_flags = function() + local sidebar = require("eca").get() + if not sidebar then + return nil + end + return { + welcome_message_applied = sidebar._welcome_message_applied, + force_welcome = sidebar._force_welcome, + } + end +end + +local function setup_env(preserve_chat_history) + child.lua( + [[ + local Eca = require("eca") + Eca.setup({ + behavior = { + auto_start_server = false, + auto_set_keymaps = false, + preserve_chat_history = ..., + }, + }) + local tab = vim.api.nvim_get_current_tabpage() + Eca._init(tab) + Eca.open_sidebar({}) + ]], + { preserve_chat_history } + ) + child.lua_func(setup_helpers) +end + +local T = MiniTest.new_set({ + hooks = { + pre_case = function() + child.restart({ "-u", "scripts/minimal_init.lua" }) + end, + post_once = child.stop, + }, +}) + +-- EcaChatClear --------------------------------------------------------------- + +T["EcaChatClear"] = MiniTest.new_set() + +T["EcaChatClear"]["command is registered"] = function() + setup_env(false) + local commands = child.lua_get("vim.api.nvim_get_commands({})") + eq(type(commands.EcaChatClear), "table") + eq(commands.EcaChatClear.name, "EcaChatClear") +end + +T["EcaChatClear"]["clears chat buffer when sidebar is open"] = function() + setup_env(false) + flush(200) + + child.lua("_G.fill_chat()") + eq(#child.lua_get("_G.get_chat_lines()"), 3) + + child.cmd("EcaChatClear") + + eq(child.lua_get("_G.get_chat_lines()"), { "" }) +end + +T["EcaChatClear"]["works without error when buffer is already empty"] = function() + setup_env(false) + flush(200) + + child.lua([[ + local sidebar = require("eca").get() + vim.api.nvim_buf_set_lines(sidebar.containers.chat.bufnr, 0, -1, false, {}) + ]]) + + child.cmd("EcaChatClear") + + eq(child.lua_get("_G.get_chat_lines()"), { "" }) +end + +T["EcaChatClear"]["clears hidden buffer when sidebar is closed with preserve=true"] = function() + setup_env(true) + flush(200) + + child.lua("_G.fill_chat()") + child.lua([[require("eca").close_sidebar()]]) + flush(100) + + child.cmd("EcaChatClear") + + eq(child.lua_get("_G.get_chat_lines()"), { "" }) +end + +T["EcaChatClear"]["buffer stays cleared on reopen with preserve=true"] = function() + setup_env(true) + flush(200) + + child.lua("_G.fill_chat()") + child.lua([[require("eca").close_sidebar()]]) + flush(100) + + child.cmd("EcaChatClear") + + eq(child.lua_get("_G.get_chat_lines()"), { "" }) + + child.lua([[require("eca").open_sidebar({})]]) + flush(200) + + eq(child.lua_get("_G.chat_has_old_content()"), false) +end + +T["EcaChatClear"]["is a no-op when sidebar is closed and buffer was destroyed (preserve=false)"] = function() + -- With preserve=false, closing the sidebar destroys the buffer, so there is + -- nothing for EcaChatClear to clear. The important guarantee is that the + -- command does not raise an error in this state. + setup_env(false) + flush(200) + + child.lua("_G.fill_chat()") + child.lua([[require("eca").close_sidebar()]]) + flush(100) + + local ok = child.lua_get("pcall(vim.cmd, 'EcaChatClear')") + eq(ok, true) +end + +T["EcaChatClear"]["marks welcome as applied and clears force_welcome after clear"] = function() + setup_env(false) + flush(200) + + child.lua("_G.fill_chat()") + child.cmd("EcaChatClear") + + local flags = child.lua_get("_G.get_sidebar_flags()") + eq(flags.welcome_message_applied, true) + eq(flags.force_welcome, false) +end + +T["EcaChatClear"]["is idempotent when called twice"] = function() + setup_env(false) + flush(200) + + child.lua("_G.fill_chat()") + child.cmd("EcaChatClear") + child.cmd("EcaChatClear") + + eq(child.lua_get("_G.get_chat_lines()"), { "" }) +end + +-- preserve_chat_history toggle cycle ----------------------------------------- + +T["preserve_chat_history"] = MiniTest.new_set() + +T["preserve_chat_history"]["reuses same bufnr and keeps content across close/open"] = function() + setup_env(true) + flush(200) + + child.lua("_G.fill_chat()") + local bufnr_before = child.lua_get("require('eca').get().containers.chat.bufnr") + + child.lua([[require("eca").close_sidebar()]]) + flush(100) + child.lua([[require("eca").open_sidebar({})]]) + flush(200) + + local bufnr_after = child.lua_get("require('eca').get().containers.chat.bufnr") + eq(bufnr_before, bufnr_after) + eq(child.lua_get("_G.chat_has_old_content()"), true) +end + +T["preserve_chat_history"]["does not leak buffers across repeated toggles"] = function() + setup_env(true) + flush(200) + + local buf_count_before = child.lua_get("#vim.api.nvim_list_bufs()") + + for _ = 1, 5 do + child.lua([[require("eca").close_sidebar()]]) + flush(100) + child.lua([[require("eca").open_sidebar({})]]) + flush(200) + end + + local buf_count_after = child.lua_get("#vim.api.nvim_list_bufs()") + -- Allow at most 1 extra buffer (nui internals), but definitely not 5+ + eq(buf_count_after - buf_count_before <= 1, true) +end + +T["preserve_chat_history"]["content is lost when preserve is disabled"] = function() + setup_env(false) + flush(200) + + child.lua("_G.fill_chat()") + + child.lua([[require("eca").close_sidebar()]]) + flush(100) + child.lua([[require("eca").open_sidebar({})]]) + flush(200) + + eq(child.lua_get("_G.chat_has_old_content()"), false) +end + +return T