Skip to content

Commit ecb41c1

Browse files
authored
feat(ui): don't stack extmarks in ACP chats (#2902)
1 parent 57d3f1b commit ecb41c1

3 files changed

Lines changed: 55 additions & 67 deletions

File tree

lua/codecompanion/interactions/chat/acp/handler.lua

Lines changed: 33 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,14 @@ local log = require("codecompanion.utils.log")
44
local utils = require("codecompanion.utils")
55
local watch = require("codecompanion.interactions.shared.watch")
66

7-
-- Keep a record of UI changes in the chat buffer
8-
97
---@class CodeCompanion.Chat.ACPHandler
108
---@field chat CodeCompanion.Chat
119
---@field output table Standard output message from the Agent
1210
---@field reasoning table Reasoning output from the Agent
1311
---@field tools table<string, table> Cache of tool calls by their ID
12+
---@field ui_state table<string, table> Cache of tool call UI states (line_number, icon_id) by tool call ID
1413
local ACPHandler = {}
1514

16-
local ACPHandlerUI = {} -- Cache of tool call UI states by chat buffer
17-
1815
---@param chat CodeCompanion.Chat
1916
---@return CodeCompanion.Chat.ACPHandler
2017
function ACPHandler.new(chat)
@@ -23,19 +20,12 @@ function ACPHandler.new(chat)
2320
output = {},
2421
reasoning = {},
2522
tools = {},
23+
ui_state = {},
2624
}, { __index = ACPHandler })
2725

28-
ACPHandlerUI[chat.bufnr] = {}
29-
3026
return self --[[@type CodeCompanion.Chat.ACPHandler]]
3127
end
3228

33-
---Return the ACP client
34-
---@return CodeCompanion.ACP.Connection
35-
local get_client = function()
36-
return require("codecompanion.acp")
37-
end
38-
3929
---Merge an incoming tool call/update into the cache
4030
---@param existing table|nil
4131
---@param incoming table|nil
@@ -66,7 +56,7 @@ end
6656
---@return boolean success
6757
function ACPHandler:ensure_connection()
6858
if not self.chat.acp_connection then
69-
self.chat.acp_connection = get_client().new({
59+
self.chat.acp_connection = require("codecompanion.acp").new({
7060
adapter = self.chat.adapter, --[[@type CodeCompanion.ACPAdapter]]
7161
})
7262

@@ -155,16 +145,16 @@ function ACPHandler:create_and_send_prompt(payload)
155145
self:handle_thought_chunk(content)
156146
end)
157147
:on_tool_call(function(tool_call)
158-
self:handle_tool_call(tool_call)
148+
self:process_tool_call(tool_call)
159149
end)
160-
:on_tool_update(function(tool_update)
161-
self:handle_tool_update(tool_update)
150+
:on_tool_update(function(tool_call)
151+
self:process_tool_call(tool_call)
162152
end)
163153
:on_permission_request(function(request)
164154
self:handle_permission_request(request)
165155
end)
166-
:on_complete(function(stop_reason)
167-
self:handle_completion(stop_reason)
156+
:on_complete(function()
157+
self:handle_completion()
168158
end)
169159
:on_error(function(error)
170160
self:handle_error(error)
@@ -199,12 +189,10 @@ end
199189
---@param tool_call table
200190
---@return nil
201191
function ACPHandler:process_tool_call(tool_call)
202-
-- Cache the tool call to handle processing later on, such as a later permission request
203192
local id = tool_call.toolCallId
204193

205-
local prev = self.tools[id]
206-
local merged = merge_tool_call(prev, tool_call)
207-
tool_call = merged or tool_call
194+
local merged = merge_tool_call(self.tools[id], tool_call)
195+
tool_call = merged
208196

209197
local ok, content = pcall(formatter.tool_message, tool_call, self.chat.adapter)
210198
if not ok then
@@ -218,31 +206,31 @@ function ACPHandler:process_tool_call(tool_call)
218206
self.tools[id] = merged
219207
end
220208

221-
-- If the tool call has already written output to the chat buffer, then we can
222-
-- update it rather than adding a new line. We do this by keeping track in
223-
-- a global cache, segmented by chat buffer and tool call IDs
224-
if ACPHandlerUI[self.chat.bufnr][id] then
225-
local match = ACPHandlerUI[self.chat.bufnr][id]
226-
-- Whilst I've tried to account for all types of ACP tool output, I'm taking
227-
-- a cautious approach and wrapping line updates. Any failures and we'll
228-
-- just write the tool output onto a new line in the chat buffer
229-
ok, _ = pcall(function()
230-
self.chat:update_buf_line(
231-
match.line_number,
232-
content,
233-
{ status = tool_call.status, icon_id = match.icon_id, priority = 120, virt_text_pos = "inline" }
234-
)
235-
end)
209+
-- If the tool call has already written output to the chat buffer, update the
210+
-- existing line rather than adding a new one
211+
local cached = self.ui_state[id]
212+
if cached then
213+
local update_ok, _, new_icon_id = pcall(
214+
self.chat.update_buf_line,
215+
self.chat,
216+
cached.line_number,
217+
content,
218+
{ status = tool_call.status, icon_id = cached.icon_id, priority = 120, virt_text_pos = "inline" }
219+
)
236220

237-
-- Cleanup the cache
238-
if tool_call.status == "completed" then
239-
ACPHandlerUI[self.chat.bufnr][id] = nil
221+
if update_ok then
222+
if tool_call.status == "completed" then
223+
self.ui_state[id] = nil
224+
elseif new_icon_id then
225+
cached.icon_id = new_icon_id
226+
end
227+
return
240228
end
241229

242-
if ok then
243-
return
230+
if tool_call.status == "completed" then
231+
self.ui_state[id] = nil
244232
end
245-
log:debug("[ACP::Handler] Failed to update tool call line for toolCallId %s", tool_call.toolCallId)
233+
log:debug("[ACP::Handler] Failed to update tool call line for toolCallId %s", id)
246234
end
247235

248236
table.insert(self.output, content)
@@ -257,19 +245,7 @@ function ACPHandler:process_tool_call(tool_call)
257245
type = self.chat.MESSAGE_TYPES.TOOL_MESSAGE,
258246
})
259247

260-
ACPHandlerUI[self.chat.bufnr][id] = { line_number = line_number, icon_id = icon_id }
261-
end
262-
263-
---Handle tool call notifications
264-
---@param tool_call table
265-
function ACPHandler:handle_tool_call(tool_call)
266-
return self:process_tool_call(tool_call)
267-
end
268-
269-
---Handle tool call updates and their respective status
270-
---@param tool_call table
271-
function ACPHandler:handle_tool_update(tool_call)
272-
return self:process_tool_call(tool_call)
248+
self.ui_state[id] = { line_number = line_number, icon_id = icon_id }
273249
end
274250

275251
---Handle permission requests from the agent
@@ -302,8 +278,7 @@ function ACPHandler:handle_permission_request(request)
302278
end
303279

304280
---Handle completion
305-
---@param stop_reason string|nil
306-
function ACPHandler:handle_completion(stop_reason)
281+
function ACPHandler:handle_completion()
307282
if not self.chat.status or self.chat.status == "" then
308283
self.chat.status = "success"
309284
end

lua/codecompanion/interactions/chat/ui/builder.lua

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ function Builder:add_message(data, opts)
150150

151151
if needs_header then
152152
state:update_role(data.role)
153-
self:_add_header_spacing(lines, state)
153+
self:_add_header_spacing(lines)
154154
self.chat.ui:set_header(lines, config.interactions.chat.roles[data.role])
155155

156156
-- Section started: reset block trackers
@@ -243,9 +243,8 @@ end
243243

244244
---Add appropriate spacing before header
245245
---@param lines table
246-
---@param state table
247246
---@return nil
248-
function Builder:_add_header_spacing(lines, state)
247+
function Builder:_add_header_spacing(lines)
249248
table.insert(lines, "")
250249
table.insert(lines, "")
251250
end
@@ -348,6 +347,7 @@ end
348347
---@param content string The new content for the line
349348
---@param opts? table Optional parameters
350349
---@return boolean success Whether the update was successful
350+
---@return number|nil icon_id The new icon extmark ID, if an icon was placed
351351
function Builder:update_line(line_number, content, opts)
352352
opts = opts or {}
353353

@@ -370,19 +370,23 @@ function Builder:update_line(line_number, content, opts)
370370
local start_line = zero_based_line
371371
local end_line = zero_based_line + 1
372372

373+
local new_icon_id
373374
local ok, _ = pcall(api.nvim_buf_set_lines, self.chat.bufnr, start_line, end_line, false, { content })
374375
if ok and opts.status then
375-
vim.schedule(function()
376-
Icons.clear_line(self.chat.bufnr, start_line)
377-
Icons.apply(self.chat.bufnr, start_line, opts.status, opts)
378-
end)
376+
-- Clear by extmark ID first (handles extmarks that may have moved to a different line)
377+
if opts.icon_id then
378+
pcall(api.nvim_buf_del_extmark, self.chat.bufnr, Icons.ns(), opts.icon_id)
379+
end
380+
-- Also clear by line range as a safety net
381+
Icons.clear_line(self.chat.bufnr, start_line)
382+
new_icon_id = Icons.apply(self.chat.bufnr, start_line, opts.status, opts)
379383
end
380384

381385
if self.state.last_role ~= config.constants.USER_ROLE then
382386
self.chat.ui:lock_buf()
383387
end
384388

385-
return true
389+
return true, new_icon_id
386390
end
387391

388392
return Builder

lua/codecompanion/interactions/chat/ui/icons.lua

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ function Icons.apply(bufnr, line, status, opts)
4444
return
4545
end
4646

47+
-- Clear any existing tool icons on this line to prevent duplicates
48+
api.nvim_buf_clear_namespace(bufnr, CONSTANTS.NS_TOOL_ICONS, line, line + 1)
49+
4750
return api.nvim_buf_set_extmark(bufnr, CONSTANTS.NS_TOOL_ICONS, line, 0, {
4851
virt_text = { { config_entry.icon, config_entry.hl_group } },
4952
virt_text_pos = opts.virt_text_pos,
@@ -55,7 +58,7 @@ end
5558
---@param bufnr number
5659
---@param extmark_id number
5760
---@return nil
58-
function Icons:clear_icon(bufnr, extmark_id)
61+
function Icons.clear_icon(bufnr, extmark_id)
5962
if not extmark_id then
6063
return
6164
end
@@ -75,4 +78,10 @@ function Icons.clear_icons(bufnr)
7578
api.nvim_buf_clear_namespace(bufnr, CONSTANTS.NS_TOOL_ICONS, 0, -1)
7679
end
7780

81+
---Return the tool icons namespace ID
82+
---@return number
83+
function Icons.ns()
84+
return CONSTANTS.NS_TOOL_ICONS
85+
end
86+
7887
return Icons

0 commit comments

Comments
 (0)