Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions lua/eca/commands.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions lua/eca/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
66 changes: 63 additions & 3 deletions lua/eca/sidebar.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand All @@ -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")

Expand Down
238 changes: 238 additions & 0 deletions tests/test_chat_clear.lua
Original file line number Diff line number Diff line change
@@ -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
Loading