Skip to content

Commit f075264

Browse files
committed
feat(chat): add plan fold for outputting the current state of plans
fix: update icons fix: update as dump renderer fix: . fix: update api fix: update api
1 parent fe792b3 commit f075264

11 files changed

Lines changed: 457 additions & 50 deletions

File tree

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,9 @@ function ACPHandler:create_and_send_prompt(payload)
174174
:on_thought_chunk(function(content)
175175
self:handle_thought_chunk(content)
176176
end)
177+
:on_plan(function(entries)
178+
self:handle_plan(entries)
179+
end)
177180
:on_tool_call(function(tool_call)
178181
self:process_tool_call(tool_call)
179182
end)
@@ -215,6 +218,20 @@ function ACPHandler:handle_thought_chunk(content)
215218
end
216219
end
217220

221+
---Handle plan updates from the agent
222+
---@param entries table[] Array of plan entries with content, status, priority
223+
function ACPHandler:handle_plan(entries)
224+
local lines = {}
225+
for _, entry in ipairs(entries) do
226+
table.insert(lines, entry.content or "Untitled")
227+
end
228+
local content = table.concat(lines, "\n")
229+
self.chat:add_buf_message(
230+
{ role = config.constants.LLM_ROLE, content = content },
231+
{ type = self.chat.MESSAGE_TYPES.PLAN_MESSAGE, _plan_entries = entries }
232+
)
233+
end
234+
218235
---Output tool call to the chat
219236
---@param tool_call table
220237
---@return nil

lua/codecompanion/interactions/chat/init.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,7 @@ local Chat = {}
344344

345345
Chat.MESSAGE_TYPES = {
346346
LLM_MESSAGE = "llm_message",
347+
PLAN_MESSAGE = "plan_message",
347348
REASONING_MESSAGE = "reasoning_message",
348349
SYSTEM_MESSAGE = "system_message",
349350
TOOL_MESSAGE = "tool_message",

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

Lines changed: 103 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,74 @@
1717
local config = require("codecompanion.config")
1818

1919
local Icons = require("codecompanion.interactions.chat.ui.icons")
20+
local Plan = require("codecompanion.interactions.chat.ui.formatters.plan")
2021
local Reasoning = require("codecompanion.interactions.chat.ui.formatters.reasoning")
2122
local Standard = require("codecompanion.interactions.chat.ui.formatters.standard")
2223
local Tools = require("codecompanion.interactions.chat.ui.formatters.tools")
2324

2425
local api = vim.api
2526

27+
---Resolve a tool status to its icon and highlight groups
28+
---Resolve a tool status to IconOpts
29+
---@param status string
30+
---@return CodeCompanion.Chat.UI.IconOpts
31+
local function resolve_tool_icon(status)
32+
local icons = config.display.chat.icons
33+
local map = {
34+
pending = {
35+
icon = icons.tool_pending,
36+
icon_hl_group = "CodeCompanionChatToolPending",
37+
line_hl_group = "CodeCompanionChatToolText",
38+
},
39+
in_progress = {
40+
icon = icons.tool_in_progress,
41+
icon_hl_group = "CodeCompanionChatToolInProgress",
42+
line_hl_group = "CodeCompanionChatToolText",
43+
},
44+
completed = {
45+
icon = icons.tool_success,
46+
icon_hl_group = "CodeCompanionChatToolSuccessIcon",
47+
line_hl_group = "CodeCompanionChatToolText",
48+
},
49+
failed = {
50+
icon = icons.tool_failure,
51+
icon_hl_group = "CodeCompanionChatToolFailureIcon",
52+
line_hl_group = "CodeCompanionChatToolText",
53+
},
54+
}
55+
return map[status] or map.pending
56+
end
57+
58+
---Resolve a plan status to IconOpts
59+
---@param status string
60+
---@return CodeCompanion.Chat.UI.IconOpts
61+
local function resolve_plan_icon(status)
62+
local icons = config.display.chat.icons
63+
local map = {
64+
pending = {
65+
icon = icons.tool_pending,
66+
icon_hl_group = "CodeCompanionChatToolPending",
67+
line_hl_group = "CodeCompanionChatPlanPending",
68+
},
69+
in_progress = {
70+
icon = icons.tool_in_progress,
71+
icon_hl_group = "CodeCompanionChatToolInProgress",
72+
line_hl_group = "CodeCompanionChatPlanInProgress",
73+
},
74+
completed = {
75+
icon = icons.tool_success,
76+
icon_hl_group = "CodeCompanionChatToolSuccessIcon",
77+
line_hl_group = "CodeCompanionChatPlanCompleted",
78+
},
79+
failed = {
80+
icon = icons.tool_failure,
81+
icon_hl_group = "CodeCompanionChatToolFailureIcon",
82+
line_hl_group = "CodeCompanionChatPlanFailed",
83+
},
84+
}
85+
return map[status] or map.pending
86+
end
87+
2688
local EPHEMERAL_STATE = {
2789
__index = {
2890
update_role = function(self, role)
@@ -32,9 +94,15 @@ local EPHEMERAL_STATE = {
3294
mark_reasoning_complete = function(self)
3395
self.has_reasoning_output = false
3496
end,
97+
mark_plan_complete = function(self)
98+
self.has_plan_output = false
99+
end,
35100
mark_reasoning_started = function(self)
36101
self.has_reasoning_output = true
37102
end,
103+
mark_plan_started = function(self)
104+
self.has_plan_output = true
105+
end,
38106
update_type = function(self, type)
39107
self.last_type = type
40108
end,
@@ -76,6 +144,7 @@ function Builder.new(args)
76144
state = {
77145
last_role = args.chat._last_role,
78146
last_type = nil,
147+
has_plan_output = false,
79148
has_reasoning_output = false,
80149

81150
-- Block tracking
@@ -99,6 +168,7 @@ function Builder.new(args)
99168
_formatters = {
100169
Tools:new(args.chat),
101170
Reasoning:new(args.chat),
171+
Plan:new(args.chat),
102172
Standard:new(args.chat),
103173
},
104174
_fmt_state = setmetatable({}, EPHEMERAL_STATE),
@@ -114,6 +184,7 @@ local function create_state(out, base_state)
114184

115185
state.last_role = base_state.last_role
116186
state.last_type = base_state.last_type
187+
state.has_plan_output = base_state.has_plan_output
117188
state.has_reasoning_output = base_state.has_reasoning_output
118189

119190
-- Block tracking
@@ -194,6 +265,11 @@ function Builder:add_message(data, opts)
194265
if opts._icon_info and opts._icon_info.has_icon and pre_content_lines > 0 then
195266
opts._icon_info.line_offset = (opts._icon_info.line_offset or 0) + pre_content_lines
196267
end
268+
if opts._plan_icons and pre_content_lines > 0 then
269+
for _, entry in ipairs(opts._plan_icons) do
270+
entry.line_offset = (entry.line_offset or 0) + pre_content_lines
271+
end
272+
end
197273

198274
local insert_line, icon_id
199275
if not vim.tbl_isempty(lines) then
@@ -288,9 +364,19 @@ function Builder:_write_to_buffer(lines, opts)
288364
local icon_id
289365
if opts._icon_info and opts._icon_info.has_icon then
290366
local target_line = insert_line + (opts._icon_info.line_offset or 0)
291-
icon_id = Icons.apply(self.chat.bufnr, target_line, opts._icon_info.status, {
292-
virt_text_pos = opts.virt_text_pos,
293-
})
367+
local icon_opts = resolve_tool_icon(opts._icon_info.status)
368+
icon_opts.virt_text_pos = opts.virt_text_pos
369+
icon_id = Icons.apply(self.chat.bufnr, target_line, icon_opts)
370+
end
371+
372+
-- Plan entry icons
373+
if opts._plan_icons then
374+
for _, entry in ipairs(opts._plan_icons) do
375+
local target_line = insert_line + (entry.line_offset or 0)
376+
local icon_opts = resolve_plan_icon(entry.status)
377+
icon_opts.virt_text_pos = "inline"
378+
Icons.apply(self.chat.bufnr, target_line, icon_opts)
379+
end
294380
end
295381

296382
-- Record write bounds
@@ -323,6 +409,15 @@ function Builder:_write_to_buffer(lines, opts)
323409
end)
324410
end
325411

412+
-- Plan folds
413+
if self.state.has_plan_output and not state.has_plan_output and config.display.chat.fold_reasoning then
414+
local range_start = self.state.current_section_start or 0
415+
local range_end = insert_line
416+
vim.schedule(function()
417+
self.chat.ui.folds:create_plan_fold(self.chat, range_start, range_end)
418+
end)
419+
end
420+
326421
if state.last_role ~= config.constants.USER_ROLE then
327422
self.chat.ui:lock_buf()
328423
end
@@ -335,6 +430,7 @@ end
335430
---Sync formatting state back to builder's persistent state
336431
---@param state table
337432
function Builder:_sync_state_from_formatting_state(state)
433+
self.state.has_plan_output = state.has_plan_output
338434
self.state.has_reasoning_output = state.has_reasoning_output
339435
self.state.last_role = state.last_role
340436
self.state.last_type = state.last_type
@@ -382,7 +478,10 @@ function Builder:update_line(line_number, content, opts)
382478
end
383479
-- Also clear by line range as a safety net
384480
Icons.clear_line(self.chat.bufnr, start_line)
385-
new_icon_id = Icons.apply(self.chat.bufnr, start_line, opts.status, opts)
481+
local icon_opts = resolve_tool_icon(opts.status)
482+
icon_opts.priority = opts.priority
483+
icon_opts.virt_text_pos = opts.virt_text_pos
484+
new_icon_id = Icons.apply(self.chat.bufnr, start_line, icon_opts)
386485
end
387486

388487
if self.state.last_role ~= config.constants.USER_ROLE then

lua/codecompanion/interactions/chat/ui/folds.lua

Lines changed: 75 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,16 @@ local Folds = {}
99
local CONSTANTS = {
1010
NS_FOLD_TOOLS = api.nvim_create_namespace("CodeCompanion-tool_fold_marks"),
1111
NS_FOLD_CONTEXT = api.nvim_create_namespace("CodeCompanion-context_fold_marks"),
12+
NS_FOLD_PLAN = api.nvim_create_namespace("CodeCompanion-plan_fold_marks"),
1213
NS_FOLD_REASONING = api.nvim_create_namespace("CodeCompanion-reasoning_fold_marks"),
1314
}
1415

1516
-- Unified fold summaries storage
16-
---@type table<number, table<number, { content: string, type: "tool"|"context"|"reasoning" }>>
17+
---@type table<number, table<number, { content: string, type: "tool"|"context"|"plan"|"reasoning" }>>
1718
Folds.fold_summaries = {}
1819

1920
---@class CodeCompanion.Chat.UI.FoldConfig
20-
---@field type "tool"|"context"|"reasoning"
21+
---@field type "tool"|"context"|"plan"|"reasoning"
2122
---@field content string
2223
---@field success_keywords? string[]
2324
---@field failure_keywords? string[]
@@ -57,7 +58,7 @@ end
5758

5859
---Format fold text based on type
5960
---@param content string
60-
---@param fold_type "tool"|"context"|"reasoning"
61+
---@param fold_type "tool"|"context"|"reasoning"|"plan"
6162
---@param opts? table
6263
---@return table[]
6364
function Folds._format_fold_text(content, fold_type, opts)
@@ -88,7 +89,7 @@ function Folds._format_fold_text(content, fold_type, opts)
8889
if not opts.show_icon_only then
8990
table.insert(chunks, { content, "CodeCompanionChatContext" })
9091
end
91-
elseif fold_type == "reasoning" then
92+
elseif fold_type == "reasoning" or fold_type == "plan" then
9293
table.insert(chunks, { content, "CodeCompanionChatFold" })
9394
end
9495

@@ -184,6 +185,8 @@ function Folds:recreate(bufnr, start_row, end_row, fold_config)
184185
ns = CONSTANTS.NS_FOLD_TOOLS
185186
elseif fold_config.type == "context" then
186187
ns = CONSTANTS.NS_FOLD_CONTEXT
188+
elseif fold_config.type == "plan" then
189+
ns = CONSTANTS.NS_FOLD_PLAN
187190
elseif fold_config.type == "reasoning" then
188191
ns = CONSTANTS.NS_FOLD_REASONING
189192
end
@@ -311,6 +314,74 @@ function Folds:create_reasoning_fold(chat, start_row, end_row)
311314
})
312315
end
313316

317+
---Fold the most recent plan section in the chat buffer
318+
---@param chat CodeCompanion.Chat
319+
---@param start_row number (0-based)
320+
---@param end_row number (0-based)
321+
---@return nil
322+
function Folds:create_plan_fold(chat, start_row, end_row)
323+
if not config.display.chat.fold_reasoning then
324+
return
325+
end
326+
327+
local summary_text = " " .. config.display.chat.icons.chat_fold .. " ..."
328+
329+
local bufnr = chat.bufnr
330+
local parser = chat.chat_parser
331+
if not (bufnr and parser) then
332+
return
333+
end
334+
335+
local ok, query = pcall(
336+
vim.treesitter.query.parse,
337+
"markdown",
338+
[[
339+
(section
340+
(atx_heading
341+
(atx_h3_marker)
342+
heading_content: (_) @block_name
343+
)
344+
(#eq? @block_name "Plan")
345+
) @plan
346+
]]
347+
)
348+
if not ok or not query then
349+
return
350+
end
351+
352+
local tree = parser:parse({ start_row, end_row })[1]
353+
if not tree then
354+
return
355+
end
356+
local root = tree:root()
357+
358+
local latest_node, latest_range = nil, -1
359+
for id, node in query:iter_captures(root, bufnr, start_row, end_row) do
360+
if query.captures[id] == "plan" then
361+
local range = node:range()
362+
if range >= latest_range then
363+
latest_node, latest_range = node, range
364+
end
365+
end
366+
end
367+
if not latest_node then
368+
return
369+
end
370+
371+
local range, _, er = latest_node:range()
372+
local fold_start = range + 1
373+
local fold_end = math.max(fold_start, er - 1)
374+
375+
if fold_start >= fold_end then
376+
return
377+
end
378+
379+
self:recreate(bufnr, fold_start, fold_end, {
380+
type = "plan",
381+
content = summary_text,
382+
})
383+
end
384+
314385
---Clean up fold data for a buffer
315386
---@param bufnr number
316387
function Folds:cleanup(bufnr)
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
local BaseFormatter = require("codecompanion.interactions.chat.ui.formatters.base")
2+
3+
---@class CodeCompanion.Chat.UI.Formatters.Plan : CodeCompanion.Chat.UI.Formatters.Base
4+
local Plan = setmetatable({}, { __index = BaseFormatter })
5+
Plan.__class = "Plan"
6+
7+
function Plan:can_handle(message, opts, tags)
8+
return opts and opts.type == tags.PLAN_MESSAGE
9+
end
10+
11+
function Plan:get_type()
12+
return self.chat.MESSAGE_TYPES.PLAN_MESSAGE
13+
end
14+
15+
function Plan:format(message, opts, state)
16+
local lines = {}
17+
18+
if state.is_new_block and state.block_index > 0 then
19+
table.insert(lines, "")
20+
table.insert(lines, "")
21+
end
22+
23+
if not state.has_plan_output then
24+
table.insert(lines, "### Plan")
25+
table.insert(lines, "")
26+
state:mark_plan_started()
27+
end
28+
29+
local content_start = #lines
30+
for _, line in ipairs(vim.split(message.content, "\n", { plain = true, trimempty = false })) do
31+
table.insert(lines, line)
32+
end
33+
34+
if opts._plan_entries then
35+
local plan_icons = {}
36+
for i, entry in ipairs(opts._plan_entries) do
37+
table.insert(plan_icons, {
38+
line_offset = content_start + (i - 1),
39+
status = entry.status or "pending",
40+
})
41+
end
42+
opts._plan_icons = plan_icons
43+
end
44+
45+
return lines, nil
46+
end
47+
48+
return Plan

0 commit comments

Comments
 (0)