Skip to content

Commit 1f3cde6

Browse files
authored
feat(ui): render tool-call borders via statuscolumn (#215)
Render tool-call borders via `'statuscolumn'` so they repeat on soft-wrap and foldtext lines. - Supersedes and closes #211. ## Design - Reuses the existing `NS_TOOL_BLOCKS` range extmark. No cache. - Per draw: `nvim_buf_get_extmarks(... overlap=true, limit=1)`. O(log N). - Write cost: zero. - `inline` + `virt_text_repeat_linebreak` not supported ([neovim/neovim#35341](neovim/neovim#35341)). - User `chat.win_opts.statuscolumn`/`winhighlight` wins via `tbl_deep_extend`. - Maps each themable column highlight group to `Normal` so the gutter blends with the chat background instead of looking like a separate column. `cursorline=false`. - 1-cell glyph (`╭ / │ / ╰`). ## Performance Real chat: 10371 lines, 272 blocks, 58 visible rows. ``` ns/call: 754 ms/redraw: 0.044 ``` ~1-2 ms/sec under heavy streaming. ## Alternatives rejected - `inline` + repeat: upstream wontfix ([neovim/neovim#35341](neovim/neovim#35341)). - `overlay` + repeat: pollutes copy/paste. - Signs: no soft-wrap repeat; shared column. - `'showbreak'`: window-wide. - Lua cache: O(N²) per session.
1 parent 3d7a8d8 commit 1f3cde6

8 files changed

Lines changed: 531 additions & 51 deletions

File tree

lua/agentic/acp/AGENTS.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,8 @@ Provider sends "tool_call"
106106
-> MessageWriter:write_tool_call_block(block)
107107
1. Renders header + body/diff lines to buffer
108108
2. Creates range extmark (NS_TOOL_BLOCKS) as position anchor
109-
3. Creates decoration extmarks (borders, status icon)
109+
3. Statuscolumn reads the range extmark for borders; status footer
110+
extmark renders the status icon
110111
4. Stores block in tool_call_blocks[id]
111112
```
112113

@@ -122,7 +123,7 @@ Provider sends "tool_call_update"
122123
2. Deep-merges via tbl_deep_extend("force", tracker, partial)
123124
3. Appends body (if both old and new exist and differ)
124125
4. Locates block position via range extmark
125-
5. Diff already rendered: refresh decorations + status only
126+
5. Diff already rendered: refresh header + status only
126127
(content frozen to prevent flicker)
127128
6. Diff is NEW: replace buffer lines, re-render everything
128129
```
@@ -140,8 +141,7 @@ Same as Phase 2, but status = "completed" | "failed"
140141
- **Updates are partial:** Only send what changed. MessageWriter merges onto the
141142
existing tracker via `tbl_deep_extend`.
142143
- **Diffs are immutable after first render:** Once a diff is written to the
143-
buffer, content is frozen. Only status/decorations refresh on subsequent
144-
updates.
144+
buffer, content is frozen. Only header/status refresh on subsequent updates.
145145
- **Body accumulates:** Multiple updates with different body content get
146146
concatenated with `---` dividers, not replaced.
147147
- **Extmarks as position anchors:** Range extmark in `NS_TOOL_BLOCKS`

lua/agentic/ui/AGENTS.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ SessionManager (per tab)
2929
├── ToolCallFold manual folds, anchor pads
3030
├── ToolCallDiff diff extraction + minimization
3131
├── DiffHighlighter line/word hl on chat buffer
32-
├── ExtmarkBlock ╭ │ ╰ fence glyphs
32+
├── ToolBlockBorder ╭ │ ╰ fence glyphs via chat statuscolumn
3333
└── PermissionManager queues + reanchors permission prompts
3434
```
3535

@@ -183,9 +183,9 @@ row N "" trailing footer, status virt_text
183183

184184
```text
185185
tracker missing ─▶ debug-log, return
186-
already_has_diff ─▶ refresh header + status + decorations only
186+
already_has_diff ─▶ refresh header + status only
187187
otherwise ─▶ rewrite body between anchors,
188-
re-apply highlights + decorations,
188+
re-apply highlights + range anchor,
189189
create fold if interior crosses threshold
190190
```
191191

lua/agentic/ui/message_writer.lua

Lines changed: 0 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,12 @@ local BufHelpers = require("agentic.utils.buf_helpers")
33
local Config = require("agentic.config")
44
local DiffHighlighter = require("agentic.utils.diff_highlighter")
55
local DiffPreview = require("agentic.ui.diff_preview")
6-
local ExtmarkBlock = require("agentic.utils.extmark_block")
76
local Fold = require("agentic.ui.tool_call_fold")
87
local JsonFormat = require("agentic.utils.json_format")
98
local Logger = require("agentic.utils.logger")
109
local Theme = require("agentic.theme")
1110

1211
local NS_TOOL_BLOCKS = vim.api.nvim_create_namespace("agentic_tool_blocks")
13-
local NS_DECORATIONS = vim.api.nvim_create_namespace("agentic_tool_decorations")
1412
local NS_PERMISSION_BUTTONS =
1513
vim.api.nvim_create_namespace("agentic_permission_buttons")
1614
local NS_DIFF_HIGHLIGHTS =
@@ -35,7 +33,6 @@ local NS_THINKING = vim.api.nvim_create_namespace("agentic_thinking")
3533
--- @field argument? string
3634
--- @field file_path? string
3735
--- @field extmark_id? integer Range extmark spanning the block
38-
--- @field decoration_extmark_ids? integer[] IDs of decoration extmarks from ExtmarkBlock
3936
--- @field status? agentic.acp.ToolCallStatus
4037
--- @field body? string[]
4138
--- @field diff? agentic.ui.MessageWriter.ToolCallDiff
@@ -425,15 +422,6 @@ function MessageWriter:write_tool_call_block(tool_call_block)
425422
highlight_ranges
426423
)
427424

428-
tool_call_block.decoration_extmark_ids =
429-
ExtmarkBlock.render_block(bufnr, NS_DECORATIONS, {
430-
header_line = start_row,
431-
body_start = start_row + 1,
432-
body_end = end_row - 1,
433-
footer_line = end_row,
434-
hl_group = Theme.HL_GROUPS.CODE_BLOCK_FENCE,
435-
})
436-
437425
tool_call_block.extmark_id =
438426
vim.api.nvim_buf_set_extmark(bufnr, NS_TOOL_BLOCKS, start_row, 0, {
439427
id = tool_call_block.extmark_id,
@@ -544,10 +532,6 @@ function MessageWriter:update_tool_call_block(tool_call_block)
544532
return false
545533
end
546534

547-
self:_clear_decoration_extmarks(tracker.decoration_extmark_ids)
548-
tracker.decoration_extmark_ids =
549-
self:_render_decorations(start_row, old_end_row)
550-
551535
self:_clear_status_namespace(start_row, old_end_row)
552536
self:_apply_status_highlights_if_present(
553537
start_row,
@@ -558,7 +542,6 @@ function MessageWriter:update_tool_call_block(tool_call_block)
558542
return false
559543
end
560544

561-
self:_clear_decoration_extmarks(tracker.decoration_extmark_ids)
562545
self:_clear_status_namespace(start_row, old_end_row)
563546

564547
local new_lines, highlight_ranges = self:_prepare_block_lines(tracker)
@@ -596,9 +579,6 @@ function MessageWriter:update_tool_call_block(tool_call_block)
596579
right_gravity = false,
597580
})
598581

599-
tracker.decoration_extmark_ids =
600-
self:_render_decorations(start_row, new_end_row)
601-
602582
self:_apply_status_highlights_if_present(
603583
start_row,
604584
new_end_row,
@@ -1146,30 +1126,6 @@ function MessageWriter:_set_thinking_extmark(start_line, end_line, id)
11461126
)
11471127
end
11481128

1149-
--- @param ids integer[]|nil
1150-
function MessageWriter:_clear_decoration_extmarks(ids)
1151-
if not ids then
1152-
return
1153-
end
1154-
1155-
for _, id in ipairs(ids) do
1156-
pcall(vim.api.nvim_buf_del_extmark, self.bufnr, NS_DECORATIONS, id)
1157-
end
1158-
end
1159-
1160-
--- @param start_row integer
1161-
--- @param end_row integer
1162-
--- @return integer[] decoration_extmark_ids
1163-
function MessageWriter:_render_decorations(start_row, end_row)
1164-
return ExtmarkBlock.render_block(self.bufnr, NS_DECORATIONS, {
1165-
header_line = start_row,
1166-
body_start = start_row + 1,
1167-
body_end = end_row - 1,
1168-
footer_line = end_row,
1169-
hl_group = Theme.HL_GROUPS.CODE_BLOCK_FENCE,
1170-
})
1171-
end
1172-
11731129
--- @param start_row integer
11741130
--- @param end_row integer
11751131
function MessageWriter:_clear_status_namespace(start_row, end_row)
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
local Theme = require("agentic.theme")
2+
3+
local NS_TOOL_BLOCKS = vim.api.nvim_create_namespace("agentic_tool_blocks")
4+
5+
local GLYPHS = {
6+
TOP_LEFT = "",
7+
BOTTOM_LEFT = "",
8+
HORIZONTAL = "",
9+
VERTICAL = "",
10+
}
11+
12+
local BLANK = " "
13+
14+
local STATUSCOLUMN_EXPR =
15+
"%!v:lua.require'agentic.ui.tool_block_border'.statuscolumn()"
16+
17+
--- @class agentic.ui.ToolBlockBorder.Range
18+
--- @field start_row integer
19+
--- @field end_row integer
20+
21+
--- @class agentic.ui.ToolBlockBorder
22+
local ToolBlockBorder = {
23+
NS_TOOL_BLOCKS = NS_TOOL_BLOCKS,
24+
STATUSCOLUMN_EXPR = STATUSCOLUMN_EXPR,
25+
}
26+
27+
--- @return integer|nil bufnr
28+
local function statuscolumn_bufnr()
29+
local winid = vim.g.statusline_winid
30+
if type(winid) == "number" and vim.api.nvim_win_is_valid(winid) then
31+
return vim.api.nvim_win_get_buf(winid)
32+
end
33+
local current = vim.api.nvim_get_current_buf()
34+
if vim.api.nvim_buf_is_valid(current) then
35+
return current
36+
end
37+
return nil
38+
end
39+
40+
--- @param text string
41+
--- @return string text
42+
local function with_highlight(text)
43+
return "%#" .. Theme.HL_GROUPS.CODE_BLOCK_FENCE .. "#" .. text .. "%*"
44+
end
45+
46+
--- @param bufnr integer
47+
--- @param row integer
48+
--- @return agentic.ui.ToolBlockBorder.Range|nil range
49+
function ToolBlockBorder.block_range_at_row(bufnr, row)
50+
if row < 0 or not vim.api.nvim_buf_is_valid(bufnr) then
51+
return nil
52+
end
53+
54+
local ok, extmarks = pcall(
55+
vim.api.nvim_buf_get_extmarks,
56+
bufnr,
57+
NS_TOOL_BLOCKS,
58+
{
59+
row,
60+
0,
61+
},
62+
{ row, -1 },
63+
{
64+
details = true,
65+
limit = 1,
66+
overlap = true,
67+
}
68+
)
69+
70+
if not ok or not extmarks or not extmarks[1] then
71+
return nil
72+
end
73+
74+
local extmark = extmarks[1]
75+
local start_row = extmark[2]
76+
local details = extmark[4] or {}
77+
local end_row = details.end_row
78+
79+
if not end_row or row < start_row or row > end_row then
80+
return nil
81+
end
82+
83+
--- @type agentic.ui.ToolBlockBorder.Range
84+
local range = {
85+
start_row = start_row,
86+
end_row = end_row,
87+
}
88+
return range
89+
end
90+
91+
--- @param bufnr integer
92+
--- @param row integer
93+
--- @param virtnum integer
94+
--- @return string glyph
95+
function ToolBlockBorder.glyph_for_line(bufnr, row, virtnum)
96+
if virtnum < 0 then
97+
return BLANK
98+
end
99+
100+
local range = ToolBlockBorder.block_range_at_row(bufnr, row)
101+
if not range then
102+
return BLANK
103+
end
104+
105+
if virtnum > 0 then
106+
return GLYPHS.VERTICAL
107+
end
108+
109+
if row == range.start_row then
110+
return GLYPHS.TOP_LEFT
111+
end
112+
113+
if row == range.end_row then
114+
return GLYPHS.BOTTOM_LEFT
115+
end
116+
117+
return GLYPHS.VERTICAL
118+
end
119+
120+
--- @return string text
121+
local function render_statuscolumn()
122+
local bufnr = statuscolumn_bufnr()
123+
if not bufnr then
124+
return BLANK
125+
end
126+
127+
local glyph =
128+
ToolBlockBorder.glyph_for_line(bufnr, vim.v.lnum - 1, vim.v.virtnum)
129+
if glyph == BLANK then
130+
return BLANK
131+
end
132+
133+
return with_highlight(glyph)
134+
end
135+
136+
--- @return string text
137+
function ToolBlockBorder.statuscolumn()
138+
local ok, text = pcall(render_statuscolumn)
139+
if ok then
140+
return text
141+
end
142+
return BLANK
143+
end
144+
145+
return ToolBlockBorder
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
local assert = require("tests.helpers.assert")
2+
local ToolBlockBorder = require("agentic.ui.tool_block_border")
3+
4+
describe("agentic.ui.ToolBlockBorder", function()
5+
local bufnr
6+
7+
before_each(function()
8+
bufnr = vim.api.nvim_create_buf(false, true)
9+
vim.bo[bufnr].buftype = "nofile"
10+
vim.bo[bufnr].swapfile = false
11+
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, {
12+
"before",
13+
"header",
14+
"",
15+
"body",
16+
"",
17+
"",
18+
"between",
19+
"header two",
20+
"body two",
21+
"",
22+
})
23+
end)
24+
25+
after_each(function()
26+
if bufnr and vim.api.nvim_buf_is_valid(bufnr) then
27+
vim.api.nvim_buf_delete(bufnr, { force = true })
28+
end
29+
end)
30+
31+
--- @param start_row integer
32+
--- @param end_row integer
33+
local function add_block(start_row, end_row)
34+
vim.api.nvim_buf_set_extmark(
35+
bufnr,
36+
ToolBlockBorder.NS_TOOL_BLOCKS,
37+
start_row,
38+
0,
39+
{
40+
end_row = end_row,
41+
right_gravity = false,
42+
}
43+
)
44+
end
45+
46+
it(
47+
"returns corner and body glyphs from the anchored block range",
48+
function()
49+
add_block(1, 5)
50+
51+
assert.equal("", ToolBlockBorder.glyph_for_line(bufnr, 1, 0))
52+
assert.equal("", ToolBlockBorder.glyph_for_line(bufnr, 2, 0))
53+
assert.equal("", ToolBlockBorder.glyph_for_line(bufnr, 3, 0))
54+
assert.equal("", ToolBlockBorder.glyph_for_line(bufnr, 5, 0))
55+
end
56+
)
57+
58+
it("uses body glyphs for wrapped continuations", function()
59+
add_block(1, 5)
60+
61+
assert.equal("", ToolBlockBorder.glyph_for_line(bufnr, 1, 1))
62+
assert.equal("", ToolBlockBorder.glyph_for_line(bufnr, 3, 2))
63+
assert.equal("", ToolBlockBorder.glyph_for_line(bufnr, 5, 1))
64+
end)
65+
66+
it("uses a body glyph for folded body screen lines", function()
67+
add_block(1, 5)
68+
69+
assert.equal("", ToolBlockBorder.glyph_for_line(bufnr, 2, 0))
70+
end)
71+
72+
it("does not bleed borders into lines outside or between blocks", function()
73+
add_block(1, 5)
74+
add_block(7, 9)
75+
76+
assert.equal(" ", ToolBlockBorder.glyph_for_line(bufnr, 0, 0))
77+
assert.equal(" ", ToolBlockBorder.glyph_for_line(bufnr, 6, 0))
78+
assert.equal("", ToolBlockBorder.glyph_for_line(bufnr, 7, 0))
79+
assert.equal("", ToolBlockBorder.glyph_for_line(bufnr, 9, 0))
80+
end)
81+
end)

0 commit comments

Comments
 (0)