Skip to content

Commit 5f157c8

Browse files
authored
Merge pull request #59 from tamercuba/feature/chat-history+clear-chat
Chat history "persistence"
2 parents 2929c1c + 939f2e6 commit 5f157c8

File tree

4 files changed

+311
-3
lines changed

4 files changed

+311
-3
lines changed

lua/eca/commands.lua

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -547,6 +547,15 @@ function M.setup()
547547
desc = "Display ECA server tools (yank preview on confirm)",
548548
})
549549

550+
vim.api.nvim_create_user_command("EcaChatClear", function()
551+
local sidebar = require("eca").get()
552+
if sidebar then
553+
sidebar:clear_chat()
554+
end
555+
end, {
556+
desc = "Clear ECA chat buffer",
557+
})
558+
550559
Logger.debug("ECA commands registered")
551560
end
552561

lua/eca/config.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ M._defaults = {
1717
auto_start_server = false, -- Automatically start server on setup
1818
auto_download = true, -- Automatically download server if not found
1919
show_status_updates = true, -- Show status updates in notifications
20+
preserve_chat_history = false, -- When true, chat history is preserved across sidebar open/close cycles
2021
},
2122
context = {
2223
auto_repo_map = true, -- Automatically add repoMap context when starting new chat

lua/eca/sidebar.lua

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -157,11 +157,19 @@ function M:close()
157157
end
158158

159159
function M:_close_windows_only()
160+
local preserve = Config.behavior and Config.behavior.preserve_chat_history
161+
160162
for name, container in pairs(self.containers) do
161163
if container and container.winid and vim.api.nvim_win_is_valid(container.winid) then
162-
container:unmount()
163-
-- Keep the container reference but mark window as invalid
164-
container.winid = nil
164+
if preserve and name == "chat" then
165+
-- Close only the window, keep the buffer alive
166+
pcall(vim.api.nvim_win_close, container.winid, true)
167+
container.winid = nil
168+
else
169+
container:unmount()
170+
-- Keep the container reference but mark window as invalid
171+
container.winid = nil
172+
end
165173
end
166174
end
167175
Logger.debug("ECA sidebar windows closed")
@@ -245,6 +253,34 @@ function M:reset()
245253
end
246254
end
247255

256+
function M:clear_chat()
257+
local chat = self.containers and self.containers.chat
258+
if chat and chat.bufnr and vim.api.nvim_buf_is_valid(chat.bufnr) then
259+
-- Reset chat content state to prevent stale line numbers / extmark IDs.
260+
self._tool_calls = {}
261+
self._reasons = {}
262+
self._current_tool_call = nil
263+
self._is_tool_call_streaming = false
264+
self._is_streaming = false
265+
self._current_response_buffer = ""
266+
self._last_user_message = ""
267+
self._stream_visible_buffer = ""
268+
if self._stream_queue then
269+
self._stream_queue:clear()
270+
end
271+
-- Reset chat extmark refs (marks are invalidated when the buffer is wiped).
272+
if self.extmarks then
273+
self.extmarks.assistant = nil
274+
self.extmarks.tool_header = nil
275+
self.extmarks.tool_diff_label = nil
276+
end
277+
-- Prevent state/updated events from repopulating the cleared buffer.
278+
self._welcome_message_applied = true
279+
self._force_welcome = false
280+
vim.api.nvim_buf_set_lines(chat.bufnr, 0, -1, false, {})
281+
end
282+
end
283+
248284
function M:new_chat()
249285
self:reset()
250286
self._force_welcome = true
@@ -318,6 +354,23 @@ function M:_create_containers()
318354
winfixwidth = false,
319355
}
320356

357+
local preserve = Config.behavior and Config.behavior.preserve_chat_history
358+
local existing_chat_bufnr = preserve
359+
and self.containers.chat
360+
and self.containers.chat.bufnr
361+
and vim.api.nvim_buf_is_valid(self.containers.chat.bufnr)
362+
and self.containers.chat.bufnr
363+
or nil
364+
365+
-- Always unmount the old Split to clean up its autocmds.
366+
local old_chat = self.containers.chat
367+
if old_chat then
368+
if existing_chat_bufnr then
369+
old_chat.bufnr = nil -- detach so unmount() doesn't delete the preserved buffer
370+
end
371+
pcall(old_chat.unmount, old_chat)
372+
end
373+
321374
-- Create and mount main chat container first
322375
self.containers.chat = Split({
323376
relative = "editor",
@@ -332,6 +385,13 @@ function M:_create_containers()
332385
}),
333386
win_options = base_win_options,
334387
})
388+
389+
if existing_chat_bufnr then
390+
pcall(vim.api.nvim_buf_delete, self.containers.chat.bufnr, { force = true })
391+
self.containers.chat.bufnr = existing_chat_bufnr
392+
Logger.debug("Reusing existing chat buffer: " .. existing_chat_bufnr)
393+
end
394+
335395
self.containers.chat:mount()
336396
self:_setup_container_events(self.containers.chat, "chat")
337397

tests/test_chat_clear.lua

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
local MiniTest = require("mini.test")
2+
local eq = MiniTest.expect.equality
3+
local child = MiniTest.new_child_neovim()
4+
5+
local function flush(ms)
6+
vim.uv.sleep(ms or 120)
7+
child.api.nvim_eval("1")
8+
end
9+
10+
local function setup_helpers()
11+
_G.fill_chat = function()
12+
local sidebar = require("eca").get()
13+
local chat = sidebar.containers.chat
14+
vim.api.nvim_buf_set_lines(chat.bufnr, 0, -1, false, { "hello", "world", "foo" })
15+
end
16+
17+
_G.get_chat_lines = function()
18+
local sidebar = require("eca").get()
19+
if not sidebar then
20+
return nil
21+
end
22+
local chat = sidebar.containers and sidebar.containers.chat
23+
if not chat or not vim.api.nvim_buf_is_valid(chat.bufnr) then
24+
return nil
25+
end
26+
return vim.api.nvim_buf_get_lines(chat.bufnr, 0, -1, false)
27+
end
28+
29+
_G.chat_has_old_content = function()
30+
for _, line in ipairs(_G.get_chat_lines() or {}) do
31+
if line == "hello" or line == "world" or line == "foo" then
32+
return true
33+
end
34+
end
35+
return false
36+
end
37+
38+
_G.get_sidebar_flags = function()
39+
local sidebar = require("eca").get()
40+
if not sidebar then
41+
return nil
42+
end
43+
return {
44+
welcome_message_applied = sidebar._welcome_message_applied,
45+
force_welcome = sidebar._force_welcome,
46+
}
47+
end
48+
end
49+
50+
local function setup_env(preserve_chat_history)
51+
child.lua(
52+
[[
53+
local Eca = require("eca")
54+
Eca.setup({
55+
behavior = {
56+
auto_start_server = false,
57+
auto_set_keymaps = false,
58+
preserve_chat_history = ...,
59+
},
60+
})
61+
local tab = vim.api.nvim_get_current_tabpage()
62+
Eca._init(tab)
63+
Eca.open_sidebar({})
64+
]],
65+
{ preserve_chat_history }
66+
)
67+
child.lua_func(setup_helpers)
68+
end
69+
70+
local T = MiniTest.new_set({
71+
hooks = {
72+
pre_case = function()
73+
child.restart({ "-u", "scripts/minimal_init.lua" })
74+
end,
75+
post_once = child.stop,
76+
},
77+
})
78+
79+
-- EcaChatClear ---------------------------------------------------------------
80+
81+
T["EcaChatClear"] = MiniTest.new_set()
82+
83+
T["EcaChatClear"]["command is registered"] = function()
84+
setup_env(false)
85+
local commands = child.lua_get("vim.api.nvim_get_commands({})")
86+
eq(type(commands.EcaChatClear), "table")
87+
eq(commands.EcaChatClear.name, "EcaChatClear")
88+
end
89+
90+
T["EcaChatClear"]["clears chat buffer when sidebar is open"] = function()
91+
setup_env(false)
92+
flush(200)
93+
94+
child.lua("_G.fill_chat()")
95+
eq(#child.lua_get("_G.get_chat_lines()"), 3)
96+
97+
child.cmd("EcaChatClear")
98+
99+
eq(child.lua_get("_G.get_chat_lines()"), { "" })
100+
end
101+
102+
T["EcaChatClear"]["works without error when buffer is already empty"] = function()
103+
setup_env(false)
104+
flush(200)
105+
106+
child.lua([[
107+
local sidebar = require("eca").get()
108+
vim.api.nvim_buf_set_lines(sidebar.containers.chat.bufnr, 0, -1, false, {})
109+
]])
110+
111+
child.cmd("EcaChatClear")
112+
113+
eq(child.lua_get("_G.get_chat_lines()"), { "" })
114+
end
115+
116+
T["EcaChatClear"]["clears hidden buffer when sidebar is closed with preserve=true"] = function()
117+
setup_env(true)
118+
flush(200)
119+
120+
child.lua("_G.fill_chat()")
121+
child.lua([[require("eca").close_sidebar()]])
122+
flush(100)
123+
124+
child.cmd("EcaChatClear")
125+
126+
eq(child.lua_get("_G.get_chat_lines()"), { "" })
127+
end
128+
129+
T["EcaChatClear"]["buffer stays cleared on reopen with preserve=true"] = function()
130+
setup_env(true)
131+
flush(200)
132+
133+
child.lua("_G.fill_chat()")
134+
child.lua([[require("eca").close_sidebar()]])
135+
flush(100)
136+
137+
child.cmd("EcaChatClear")
138+
139+
eq(child.lua_get("_G.get_chat_lines()"), { "" })
140+
141+
child.lua([[require("eca").open_sidebar({})]])
142+
flush(200)
143+
144+
eq(child.lua_get("_G.chat_has_old_content()"), false)
145+
end
146+
147+
T["EcaChatClear"]["is a no-op when sidebar is closed and buffer was destroyed (preserve=false)"] = function()
148+
-- With preserve=false, closing the sidebar destroys the buffer, so there is
149+
-- nothing for EcaChatClear to clear. The important guarantee is that the
150+
-- command does not raise an error in this state.
151+
setup_env(false)
152+
flush(200)
153+
154+
child.lua("_G.fill_chat()")
155+
child.lua([[require("eca").close_sidebar()]])
156+
flush(100)
157+
158+
local ok = child.lua_get("pcall(vim.cmd, 'EcaChatClear')")
159+
eq(ok, true)
160+
end
161+
162+
T["EcaChatClear"]["marks welcome as applied and clears force_welcome after clear"] = function()
163+
setup_env(false)
164+
flush(200)
165+
166+
child.lua("_G.fill_chat()")
167+
child.cmd("EcaChatClear")
168+
169+
local flags = child.lua_get("_G.get_sidebar_flags()")
170+
eq(flags.welcome_message_applied, true)
171+
eq(flags.force_welcome, false)
172+
end
173+
174+
T["EcaChatClear"]["is idempotent when called twice"] = function()
175+
setup_env(false)
176+
flush(200)
177+
178+
child.lua("_G.fill_chat()")
179+
child.cmd("EcaChatClear")
180+
child.cmd("EcaChatClear")
181+
182+
eq(child.lua_get("_G.get_chat_lines()"), { "" })
183+
end
184+
185+
-- preserve_chat_history toggle cycle -----------------------------------------
186+
187+
T["preserve_chat_history"] = MiniTest.new_set()
188+
189+
T["preserve_chat_history"]["reuses same bufnr and keeps content across close/open"] = function()
190+
setup_env(true)
191+
flush(200)
192+
193+
child.lua("_G.fill_chat()")
194+
local bufnr_before = child.lua_get("require('eca').get().containers.chat.bufnr")
195+
196+
child.lua([[require("eca").close_sidebar()]])
197+
flush(100)
198+
child.lua([[require("eca").open_sidebar({})]])
199+
flush(200)
200+
201+
local bufnr_after = child.lua_get("require('eca').get().containers.chat.bufnr")
202+
eq(bufnr_before, bufnr_after)
203+
eq(child.lua_get("_G.chat_has_old_content()"), true)
204+
end
205+
206+
T["preserve_chat_history"]["does not leak buffers across repeated toggles"] = function()
207+
setup_env(true)
208+
flush(200)
209+
210+
local buf_count_before = child.lua_get("#vim.api.nvim_list_bufs()")
211+
212+
for _ = 1, 5 do
213+
child.lua([[require("eca").close_sidebar()]])
214+
flush(100)
215+
child.lua([[require("eca").open_sidebar({})]])
216+
flush(200)
217+
end
218+
219+
local buf_count_after = child.lua_get("#vim.api.nvim_list_bufs()")
220+
-- Allow at most 1 extra buffer (nui internals), but definitely not 5+
221+
eq(buf_count_after - buf_count_before <= 1, true)
222+
end
223+
224+
T["preserve_chat_history"]["content is lost when preserve is disabled"] = function()
225+
setup_env(false)
226+
flush(200)
227+
228+
child.lua("_G.fill_chat()")
229+
230+
child.lua([[require("eca").close_sidebar()]])
231+
flush(100)
232+
child.lua([[require("eca").open_sidebar({})]])
233+
flush(200)
234+
235+
eq(child.lua_get("_G.chat_has_old_content()"), false)
236+
end
237+
238+
return T

0 commit comments

Comments
 (0)