Skip to content

Commit d5fd611

Browse files
authored
feat(ui): inline diff in chat (#2934)
1 parent 34ffd4b commit d5fd611

9 files changed

Lines changed: 264 additions & 123 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,10 @@ Neovim plugin (Lua) providing LLM-powered coding assistance: chat, inline transf
1313

1414
- **Naming:** snake_case for files/functions, PascalCase for classes, underscore prefix for private functions
1515
- **Explicit names:** `pattern` not `pat`, `should_include` not `include_ok`
16+
- **Readable code:** names, variables, and control flow should read like clean English. Prefer `threshold_met` over a long inline condition. Avoid generic names like `ctx` — use domain-specific names (`permission`, `request`, `source`)
1617
- **Function params:** prefer a single table argument over positional args
1718
- **Error handling:** `pcall` + `log:error()`, return nil on failure
18-
- **Type annotations:** LuaCATS for public APIs
19+
- **Type annotations:** LuaCATS for public APIs. Keep doc blocks concise — one description line, params should be self-explanatory without inline comments
1920
- **Functions:** keep under 50 lines
2021
- **Globals:** avoid; use module-local state
2122
- **Code blocks:** use four backticks with language spec

doc/codecompanion.txt

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
*codecompanion.txt* For NVIM v0.11 Last change: 2026 March 24
1+
*codecompanion.txt* For NVIM v0.11 Last change: 2026 March 26
22

33
==============================================================================
44
Table of Contents *codecompanion-table-of-contents*
@@ -1980,7 +1980,13 @@ plugin. If you utilize the `insert_edit_into_file` tool or use an ACP adapter,
19801980
then the plugin will update files and buffers, displaying the changes in a
19811981
floating window.
19821982

1983-
There are a number of configuration option available to you:
1983+
For small changes, the diff is shown directly in the chat buffer. This can be
1984+
controlled by `threshold_for_chat`, which corresponds to the size of the diff
1985+
in terms of changed lines. For larger changes, the diff will automatically open
1986+
in a floating window when the chat buffer is active. Or, you will be prompted
1987+
to view the diff manually (`gv` by default).
1988+
1989+
There are a number of configuration options available to you:
19841990

19851991

19861992

doc/configuration/chat-buffer.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,9 @@ vim.api.nvim_create_autocmd("User", {
209209

210210
CodeCompanion has a built-in diff engine that's leveraged throughout the plugin. If you utilize the `insert_edit_into_file` tool or use an ACP adapter, then the plugin will update files and buffers, displaying the changes in a floating window.
211211

212-
There are a number of configuration option available to you:
212+
For small changes, the diff is shown directly in the chat buffer. This can be controlled by `threshold_for_chat`, which corresponds to the size of the diff in terms of changed lines. For larger changes, the diff will automatically open in a floating window when the chat buffer is active. Or, you will be prompted to view the diff manually (`gv` by default).
213+
214+
There are a number of configuration options available to you:
213215

214216
::: code-group
215217

@@ -218,6 +220,10 @@ require("codecompanion").setup({
218220
display = {
219221
diff = {
220222
enabled = true,
223+
224+
-- At or below this diff size, always display the diff in the chat buffer
225+
threshold_for_chat = 6,
226+
221227
word_highlights = {
222228
additions = true,
223229
deletions = true,

lua/codecompanion/config.lua

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1121,6 +1121,8 @@ The user is working on a %s machine. Please respond with system specific command
11211121

11221122
diff = {
11231123
enabled = true,
1124+
threshold_for_chat = 6, -- At or below this, always display the diff in the chat buffer
1125+
11241126
-- Options for any diff windows (extends from floating_window)
11251127
window = {
11261128
opts = {},

lua/codecompanion/diff/utils.lua

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,4 +234,35 @@ function M.split_words(str)
234234
return ret
235235
end
236236

237+
---Count the number of changed lines (additions + deletions)
238+
---@param from_lines string[]
239+
---@param to_lines string[]
240+
---@return number
241+
function M.changed_lines(from_lines, to_lines)
242+
local hunks = require("codecompanion.diff")._diff(from_lines, to_lines)
243+
244+
local count = 0
245+
for _, hunk in ipairs(hunks) do
246+
count = count + hunk[2] + hunk[4]
247+
end
248+
249+
return count
250+
end
251+
252+
---Generate a unified diff string suitable for inline display
253+
---@param from_lines string[]
254+
---@param to_lines string[]
255+
---@return string
256+
function M.unified(from_lines, to_lines)
257+
---@diagnostic disable-next-line: deprecated
258+
local diff_fn = vim.text.diff or vim.diff
259+
local result =
260+
diff_fn(table.concat(from_lines, "\n"), table.concat(to_lines, "\n"), { result_type = "unified", ctxlen = 3 })
261+
262+
-- Strip the newline marker
263+
result = (result or ""):gsub("\n?\\ No newline at end of file%s*", ""):gsub("\n$", "")
264+
265+
return result
266+
end
267+
237268
return M

lua/codecompanion/interactions/chat/acp/request_permission.lua

Lines changed: 102 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
1+
local config = require("codecompanion.config")
2+
local diff_utils = require("codecompanion.diff.utils")
13
local log = require("codecompanion.utils.log")
4+
local ui_utils = require("codecompanion.utils.ui")
25
local utils = require("codecompanion.utils")
36

47
local labels = require("codecompanion.interactions.chat.tools.labels")
58

9+
local fmt = string.format
10+
611
---Ref: https://agentclientprotocol.com/protocol/schema#permissionoptionkind
712
local ACP_OPTIONS = {
813
allow_once = { label = labels.accept, keymap = "accept" },
@@ -14,10 +19,10 @@ local ACP_OPTIONS = {
1419
local M = {}
1520

1621
---Find the first reject option from the request options
17-
---@param options table
22+
---@param opts table
1823
---@return string|nil optionId
19-
local function find_reject_option(options)
20-
for _, opt in ipairs(options or {}) do
24+
local function find_reject_option(opts)
25+
for _, opt in ipairs(opts or {}) do
2126
if opt.kind:find("^reject", 1, true) then
2227
return opt.optionId
2328
end
@@ -26,11 +31,11 @@ local function find_reject_option(options)
2631
end
2732

2833
---Build a map of kind -> optionId for easy lookup
29-
---@param options table
34+
---@param opts table
3035
---@return table<string, string> kind -> optionId
31-
local function build_kind_map(options)
36+
local function build_kind_map(opts)
3237
local map = {}
33-
for _, opt in ipairs(options or {}) do
38+
for _, opt in ipairs(opts or {}) do
3439
if type(opt.kind) == "string" and type(opt.optionId) == "string" then
3540
map[opt.kind] = opt.optionId
3641
end
@@ -40,7 +45,7 @@ end
4045

4146
---Get the shared keymap key for an ACP option kind
4247
---@param kind string
43-
---@param keys table Resolved keymaps from labels.keymaps()
48+
---@param keys table
4449
---@return string|nil
4550
local function key_for_kind(kind, keys)
4651
local opt = ACP_OPTIONS[kind]
@@ -51,8 +56,8 @@ local function key_for_kind(kind, keys)
5156
end
5257

5358
---Build the banner displayed in the diff window winbar
54-
---@param kind_map table<string, string> kind -> optionId
55-
---@param keys table Resolved keymaps from labels.keymaps()
59+
---@param kind_map table<string, string>
60+
---@param keys table
5661
---@return string
5762
local function build_banner(kind_map, keys)
5863
local parts = {}
@@ -63,11 +68,11 @@ local function build_banner(kind_map, keys)
6368
local lhs = key_for_kind(kind, keys)
6469
if lhs then
6570
local label = (ACP_OPTIONS[kind] and ACP_OPTIONS[kind].label) or kind:gsub("_", " ")
66-
table.insert(parts, string.format("%s %s", lhs, label))
71+
table.insert(parts, fmt("%s %s", lhs, label))
6772
end
6873
end
6974

70-
table.insert(parts, string.format("%s/%s Next/Prev", keys.next_hunk, keys.previous_hunk))
75+
table.insert(parts, fmt("%s/%s Next/Prev", keys.next_hunk, keys.previous_hunk))
7176
table.insert(parts, "q Close")
7277

7378
return table.concat(parts, " | ")
@@ -150,28 +155,26 @@ local function setup_diff_keymaps(opts)
150155
})
151156
end
152157

153-
---Display the diff preview and resolve permission by user decision
154-
---@param opts { chat: CodeCompanion.Chat, request: table, on_done: fun(choice_label: string) }
155-
---@return nil
156-
local function show_diff(opts)
157-
local d = get_diff(opts.request.tool_call)
158+
---Open the floating diff view for an ACP permission request
159+
---@param permission table
160+
local function open_diff_view(permission)
161+
local d = get_diff(permission.request.tool_call)
158162

159-
local diff_id = math.random(1000000)
160-
local kind_map = build_kind_map(opts.request.options)
163+
local kind_map = build_kind_map(permission.request.options)
161164
local keys = labels.keymaps()
162165

163166
local diff_ui = require("codecompanion.helpers").show_diff({
164167
from_lines = vim.split(d.old or "", "\n", { plain = true }),
165168
to_lines = vim.split(d.new or "", "\n", { plain = true }),
166169
banner = build_banner(kind_map, keys),
167-
chat_bufnr = opts.chat.bufnr,
168-
diff_id = diff_id,
170+
chat_bufnr = permission.chat.bufnr,
171+
diff_id = math.random(1000000),
169172
ft = vim.filetype.match({ filename = d.path }) or "text",
170173
keymaps = {
171174
on_reject = function()
172-
opts.on_done(labels.reject)
173-
local rejected = find_reject_option(opts.request.options)
174-
opts.request.respond(rejected, false)
175+
permission.on_done(labels.reject)
176+
local rejected = find_reject_option(permission.request.options)
177+
permission.request.respond(rejected, false)
175178
end,
176179
},
177180
skip_default_keymaps = true,
@@ -182,53 +185,40 @@ local function show_diff(opts)
182185
diff_ui = diff_ui,
183186
kind_map = kind_map,
184187
keys = keys,
185-
request = opts.request,
186-
on_done = opts.on_done,
188+
request = permission.request,
189+
on_done = permission.on_done,
187190
})
188191
end
189192

190-
---Show the permission request to the user and handle their response
191-
---@param chat CodeCompanion.Chat
192-
---@param request table
193-
---@return nil
194-
function M.confirm(chat, request)
195-
local approval_prompt = require("codecompanion.interactions.chat.helpers.approval_prompt")
196-
197-
local tool_call = request.tool_call
198-
local prompt = string.format(
199-
"%s: %s",
200-
utils.capitalize(tool_call and tool_call.kind or "Permission"),
201-
tool_call and tool_call.title or "Agent requested permission"
202-
)
203-
204-
local has_diff = request.tool_call and requires_diff(request.tool_call)
193+
---Build the approval choices for an ACP permission request
194+
---@param permission table
195+
---@param has_diff boolean
196+
---@return CodeCompanion.Chat.ApprovalChoice[]
197+
local function build_choices(permission, has_diff)
205198
local keys = labels.keymaps()
206-
207199
local choices = {}
208200

209-
local on_done
210-
211201
if has_diff then
212202
table.insert(choices, {
213203
keymap = keys.view,
214204
label = labels.view,
215205
preview = true,
216206
callback = function()
217207
log:debug("[acp::request_permission] Opening diff for review")
218-
show_diff({ chat = chat, request = request, on_done = on_done })
208+
open_diff_view(permission)
219209
end,
220210
})
221211
end
222212

223-
for _, opt in ipairs(request.options or {}) do
213+
for _, opt in ipairs(permission.request.options or {}) do
224214
local key = key_for_kind(opt.kind, keys)
225215
if key then
226216
table.insert(choices, {
227217
keymap = key,
228218
label = (ACP_OPTIONS[opt.kind] and ACP_OPTIONS[opt.kind].label) or opt.name,
229219
callback = function()
230220
log:debug("[acp::request_permission] User selected option %s", opt.optionId)
231-
request.respond(opt.optionId, false)
221+
permission.request.respond(opt.optionId, false)
232222
end,
233223
})
234224
end
@@ -239,17 +229,77 @@ function M.confirm(chat, request)
239229
label = labels.cancel,
240230
callback = function()
241231
log:debug("[acp::request_permission] User cancelled")
242-
request.respond(nil, true)
232+
permission.request.respond(nil, true)
243233
end,
244234
})
245235

246-
on_done = approval_prompt.request(chat, {
247-
id = request.id,
248-
name = tool_call and tool_call.kind or nil,
249-
title = has_diff and "View Proposed Edits" or nil,
250-
prompt = prompt,
236+
return choices
237+
end
238+
239+
---Allow the user to approve from within the chat buffer
240+
---@param permission table
241+
---@param choices CodeCompanion.Chat.ApprovalChoice[]
242+
---@param prompt_opts { title?: string, prompt: string }
243+
local function approve_in_chat(permission, choices, prompt_opts)
244+
local approval_prompt = require("codecompanion.interactions.chat.helpers.approval_prompt")
245+
permission.on_done = approval_prompt.request(permission.chat, {
251246
choices = choices,
247+
id = permission.request.id,
248+
name = permission.request.tool_call and permission.request.tool_call.kind or nil,
249+
prompt = prompt_opts.prompt,
250+
title = prompt_opts.title,
252251
})
253252
end
254253

254+
---Show the permission request to the user and handle their response
255+
---@param chat CodeCompanion.Chat
256+
---@param request table
257+
---@return nil
258+
function M.confirm(chat, request)
259+
local tool_call = request.tool_call
260+
local has_diff = tool_call and requires_diff(tool_call)
261+
262+
local base_prompt = fmt(
263+
"%s: %s",
264+
utils.capitalize(tool_call and tool_call.kind or "Permission"),
265+
tool_call and tool_call.title or "Agent requested permission"
266+
)
267+
268+
local permission = { chat = chat, request = request }
269+
local choices = build_choices(permission, has_diff)
270+
271+
if not has_diff then
272+
return approve_in_chat(permission, choices, { prompt = base_prompt })
273+
end
274+
275+
local d = get_diff(tool_call)
276+
local from_lines = vim.split(d.old or "", "\n", { plain = true })
277+
local to_lines = vim.split(d.new or "", "\n", { plain = true })
278+
local changed_lines = diff_utils.changed_lines(from_lines, to_lines)
279+
local threshold = config.display.diff.threshold_for_chat
280+
local threshold_met = threshold and threshold > 0 and changed_lines > 0 and changed_lines <= threshold
281+
282+
if threshold_met then
283+
-- Show small diffs in the chat buffer
284+
local diff_text = diff_utils.unified(from_lines, to_lines)
285+
local prompt = fmt(
286+
[[%s
287+
288+
`````diff
289+
%s
290+
`````]],
291+
base_prompt,
292+
diff_text
293+
)
294+
return approve_in_chat(permission, choices, { title = "Proposed Edits", prompt = prompt })
295+
elseif ui_utils.buf_is_active(chat.bufnr) then
296+
-- If the chat is active, show the diff in the floating window
297+
approve_in_chat(permission, choices, { title = "View Proposed Edits", prompt = base_prompt })
298+
return open_diff_view(permission)
299+
else
300+
-- Otherwise, don't force the diff on the user, just show the approval
301+
return approve_in_chat(permission, choices, { title = "View Proposed Edits", prompt = base_prompt })
302+
end
303+
end
304+
255305
return M

0 commit comments

Comments
 (0)