Skip to content

Commit c48e4e1

Browse files
committed
add expanded config for diff and thinking
1 parent cf9546e commit c48e4e1

File tree

3 files changed

+192
-37
lines changed

3 files changed

+192
-37
lines changed

docs/configuration.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,26 @@ require("eca").setup({
5959
"Type your message and use CTRL+s to send",
6060
},
6161
},
62+
tool_call = {
63+
icons = {
64+
success = "", -- Shown when a tool call succeeds
65+
error = "", -- Shown when a tool call fails
66+
running = "", -- Shown while a tool call is running / has no final status yet
67+
expanded = "", -- Arrow when the tool call details are expanded
68+
collapsed = "", -- Arrow when the tool call details are collapsed
69+
},
70+
diff_label = {
71+
collapsed = "+ view diff", -- Label when the diff is collapsed
72+
expanded = "- view diff", -- Label when the diff is expanded
73+
},
74+
},
75+
reasoning = {
76+
-- When true, "Thinking" blocks are expanded by default
77+
expanded = false,
78+
-- Customize the labels used for the reasoning block
79+
running_label = "Thinking...",
80+
finished_label = "Thought",
81+
},
6282
},
6383

6484
-- === WINDOW SETTINGS ===

lua/eca/config.lua

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,11 +62,17 @@ M._defaults = {
6262
expanded = "", -- Arrow when the tool call details are expanded
6363
collapsed = "", -- Arrow when the tool call details are collapsed
6464
},
65-
diff_label = {
66-
collapsed = "+ view diff", -- Label when the diff is collapsed
67-
expanded = "- view diff", -- Label when the diff is expanded
65+
diff = {
66+
collapsed_label = "+ view diff", -- Label when the diff is collapsed
67+
expanded_label = "- view diff", -- Label when the diff is expanded
68+
expanded = false, -- When true, tool diffs start expanded
6869
},
6970
},
71+
reasoning = {
72+
expanded = false, -- When true, "Thinking" blocks start expanded
73+
running_label = "Thinking...", -- Label while reasoning is running
74+
finished_label = "Thought", -- Base label when reasoning is finished
75+
},
7076
},
7177
windows = {
7278
wrap = true,

lua/eca/sidebar.lua

Lines changed: 163 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -50,26 +50,67 @@ end
5050

5151
-- Label texts used for tool call diffs.
5252
--
53-
-- Configuration (under `Config.chat.tool_call`):
54-
-- - `diff_label.collapsed`: text when diff is collapsed (default: "+ view diff")
55-
-- - `diff_label.expanded`: text when diff is expanded (default: "- view diff")
53+
-- Preferred configuration (under `Config.chat.tool_call`):
54+
-- tool_call = {
55+
-- diff = {
56+
-- collapsed_label = "+ view diff", -- Label when the diff is collapsed
57+
-- expanded_label = "- view diff", -- Label when the diff is expanded
58+
-- expanded = false, -- When true, tool diffs start expanded
59+
-- },
60+
-- }
5661
--
5762
-- Backwards compatibility:
58-
-- - `diff_label_collapsed` / `diff_label_expanded` (flat keys) are still
59-
-- honored if the nested table is not provided.
63+
-- - `diff_label = { collapsed, expanded }` (older table format)
64+
-- - `diff_label_collapsed` / `diff_label_expanded` (flat keys)
65+
-- - `diff_start_expanded` (boolean flag) to control initial expansion
6066
local function get_tool_call_diff_labels()
6167
local cfg = (Config.chat and Config.chat.tool_call) or {}
68+
69+
local diff_cfg = cfg.diff or {}
6270
local labels_cfg = cfg.diff_label or {}
6371

64-
local collapsed = labels_cfg.collapsed or "+ view diff"
65-
local expanded = labels_cfg.expanded or "- view diff"
72+
local collapsed = diff_cfg.collapsed_label
73+
or labels_cfg.collapsed
74+
or cfg.diff_label_collapsed
75+
or "+ view diff"
76+
77+
local expanded = diff_cfg.expanded_label
78+
or labels_cfg.expanded
79+
or cfg.diff_label_expanded
80+
or "- view diff"
6681

6782
return {
6883
collapsed = collapsed,
6984
expanded = expanded,
7085
}
7186
end
7287

88+
local function should_start_diff_expanded()
89+
local cfg = (Config.chat and Config.chat.tool_call) or {}
90+
local diff_cfg = cfg.diff or {}
91+
92+
if diff_cfg.expanded ~= nil then
93+
return diff_cfg.expanded == true
94+
end
95+
96+
if cfg.diff_start_expanded ~= nil then
97+
return cfg.diff_start_expanded == true
98+
end
99+
100+
return false
101+
end
102+
103+
local function get_reasoning_labels()
104+
local cfg = (Config.chat and Config.chat.reasoning) or {}
105+
local running = cfg.running_label or "Thinking..."
106+
local finished = cfg.finished_label or "Thought"
107+
108+
return {
109+
running = running,
110+
finished = finished,
111+
}
112+
end
113+
73114
---@param id integer Tab ID
74115
---@param mediator eca.Mediator
75116
---@return eca.Sidebar
@@ -100,6 +141,10 @@ function M.new(id, mediator)
100141
instance._contexts = {}
101142
instance._tool_calls = {}
102143
instance._reasons = {}
144+
-- Internal queue for smooth, per-character streaming in the chat
145+
instance._stream_queue = ""
146+
instance._stream_visible_buffer = ""
147+
instance._stream_tick_scheduled = false
103148

104149
require("eca.observer").subscribe("sidebar-" .. id, function(message)
105150
instance:handle_chat_content(message)
@@ -229,6 +274,9 @@ function M:reset()
229274
self._contexts = {}
230275
self._tool_calls = {}
231276
self._reasons = {}
277+
self._stream_queue = ""
278+
self._stream_visible_buffer = ""
279+
self._stream_tick_scheduled = false
232280
end
233281

234282
function M:new_chat()
@@ -1243,6 +1291,9 @@ function M:handle_chat_content_received(params)
12431291
-- If this call now has a diff and doesn't yet have a label line, add it
12441292
if call.has_diff and not call.label_line and not call.expanded then
12451293
self:_insert_tool_call_diff_label_line(call)
1294+
if should_start_diff_expanded() then
1295+
self:_expand_tool_call_diff(call)
1296+
end
12461297
end
12471298
end
12481299

@@ -1286,6 +1337,9 @@ function M:handle_chat_content_received(params)
12861337

12871338
if call.has_diff then
12881339
self:_insert_tool_call_diff_label_line(call)
1340+
if should_start_diff_expanded() then
1341+
self:_expand_tool_call_diff(call)
1342+
end
12891343
end
12901344

12911345
table.insert(self._tool_calls, call)
@@ -1330,9 +1384,13 @@ function M:_handle_streaming_text(text)
13301384

13311385
if not self._is_streaming then
13321386
Logger.debug("Starting streaming response")
1333-
-- Start streaming - simple and direct
1387+
-- Start streaming with an internal character queue so that text
1388+
-- is rendered smoothly, one (or a few) characters at a time.
13341389
self._is_streaming = true
13351390
self._current_response_buffer = ""
1391+
self._stream_queue = ""
1392+
self._stream_visible_buffer = ""
1393+
self._stream_tick_scheduled = false
13361394

13371395
-- Determine insertion point before adding placeholder (works even with empty header)
13381396
local chat = self.containers.chat
@@ -1360,13 +1418,68 @@ function M:_handle_streaming_text(text)
13601418
end
13611419
end
13621420

1363-
-- Simple accumulation - no complex checks
1421+
-- Accumulate the full response for finalization and history
13641422
self._current_response_buffer = (self._current_response_buffer or "") .. text
1423+
-- Enqueue new characters to be rendered gradually
1424+
self._stream_queue = (self._stream_queue or "") .. text
1425+
1426+
Logger.debug("DEBUG: Buffer now has " .. #self._current_response_buffer .. " chars (queued: " .. #self._stream_queue .. ")")
1427+
1428+
-- Ensure the per-character render loop is running
1429+
self:_start_streaming_tick()
1430+
end
1431+
1432+
-- Internal helper: run a tick of the per-character streaming loop.
1433+
-- This pulls a small number of characters from `_stream_queue` and
1434+
-- appends them to the visible buffer, then reschedules itself while
1435+
-- streaming is active. The goal is to make updates feel smooth and
1436+
-- natural instead of "chunky".
1437+
function M:_start_streaming_tick()
1438+
if self._stream_tick_scheduled then
1439+
return
1440+
end
1441+
1442+
self._stream_tick_scheduled = true
1443+
1444+
local function step()
1445+
-- This runs on the main loop via vim.defer_fn
1446+
self._stream_tick_scheduled = false
1447+
1448+
-- If streaming finished in the meantime, stop here.
1449+
if not self._is_streaming then
1450+
return
1451+
end
1452+
1453+
local queue = self._stream_queue or ""
1454+
if queue == "" then
1455+
-- Nothing to render yet; poll again shortly while the server
1456+
-- continues to stream more content.
1457+
self._stream_tick_scheduled = true
1458+
vim.defer_fn(step, 10)
1459+
return
1460+
end
1461+
1462+
-- Render a small batch of characters per tick so long messages
1463+
-- don't take forever to appear but still feel "typewriter-like".
1464+
local CHARS_PER_TICK = 2
1465+
local count = math.min(CHARS_PER_TICK, #queue)
1466+
local chunk = queue:sub(1, count)
1467+
self._stream_queue = queue:sub(count + 1)
1468+
1469+
self._stream_visible_buffer = (self._stream_visible_buffer or "") .. chunk
1470+
self:_update_streaming_message(self._stream_visible_buffer)
13651471

1366-
Logger.debug("DEBUG: Buffer now has " .. #self._current_response_buffer .. " chars")
1472+
-- Keep the loop going while we still have content or the server is
1473+
-- likely to send more.
1474+
if self._is_streaming or (self._stream_queue and #self._stream_queue > 0) then
1475+
self._stream_tick_scheduled = true
1476+
vim.defer_fn(step, 10)
1477+
end
1478+
end
13671479

1368-
-- Update the assistant's message in place
1369-
self:_update_streaming_message(self._current_response_buffer)
1480+
-- Start immediately (or after a tiny delay) so the first characters
1481+
-- appear right away.
1482+
vim.defer_fn(step, 1)
13701483
end
13711484

13721485
---@param content string
@@ -1522,9 +1635,18 @@ function M:_finalize_streaming_response()
15221635
Logger.debug("DEBUG: Finalizing streaming response")
15231636
Logger.debug("DEBUG: Final buffer had " .. #(self._current_response_buffer or "") .. " chars")
15241637

1638+
-- On finalize, ensure the full response is rendered, regardless of
1639+
-- how many characters are still sitting in the internal queue.
1640+
if self._current_response_buffer and self._current_response_buffer ~= "" then
1641+
self:_update_streaming_message(self._current_response_buffer)
1642+
end
1643+
15251644
self._is_streaming = false
15261645
self._current_response_buffer = ""
15271646
self._response_start_time = 0
1647+
self._stream_queue = ""
1648+
self._stream_visible_buffer = ""
1649+
self._stream_tick_scheduled = false
15281650

15291651
-- Clear assistant placeholder tracking extmark
15301652
local chat = self.containers.chat
@@ -1771,6 +1893,9 @@ function M:_display_tool_call(content)
17711893

17721894
if call.has_diff then
17731895
self:_insert_tool_call_diff_label_line(call)
1896+
if should_start_diff_expanded() then
1897+
self:_expand_tool_call_diff(call)
1898+
end
17741899
end
17751900

17761901
table.insert(self._tool_calls, call)
@@ -1801,6 +1926,10 @@ function M:_handle_reason_started(content)
18011926
-- If a new reasoning starts while another one is still "running",
18021927
-- mark the previous one as finished so only one active "Thinking"
18031928
-- block is shown at a time.
1929+
local labels = get_reasoning_labels()
1930+
local running_label = labels.running
1931+
local finished_label = labels.finished
1932+
18041933
for existing_id, existing_call in pairs(self._reasons) do
18051934
if existing_id ~= id
18061935
and existing_call
@@ -1809,10 +1938,10 @@ function M:_handle_reason_started(content)
18091938
-- the running label, so we don't clobber completed
18101939
-- entries like "Thought 1.23 s" when a new reasoning
18111940
-- block starts later.
1812-
and existing_call.title == "Thinking..." then
1941+
and existing_call.title == running_label then
18131942
-- For reasoning entries we don't show status icons; instead we just
1814-
-- update the label from "Thinking..." to "Thought".
1815-
existing_call.title = "Thought"
1943+
-- update the label from the running label to the finished label.
1944+
existing_call.title = finished_label
18161945
existing_call.status = nil
18171946
self:_update_tool_call_header_line(existing_call)
18181947
end
@@ -1823,14 +1952,18 @@ function M:_handle_reason_started(content)
18231952
return
18241953
end
18251954

1955+
-- Whether "Thinking" blocks should start expanded by default
1956+
local reasoning_cfg = (Config.chat and Config.chat.reasoning) or {}
1957+
local expand = reasoning_cfg.expanded == true
1958+
18261959
local call = {
18271960
id = id,
1828-
title = "Thinking...", -- fixed summary label while reasoning is running
1961+
title = running_label, -- summary label while reasoning is running
18291962
header_line = nil,
1830-
expanded = false, -- controls visibility of reasoning text
1831-
diff_expanded = false, -- unused for reasoning
1832-
status = nil, -- unused for reasoning headers; no status icons
1833-
arguments = "", -- we reuse arguments as the accumulated reasoning text
1963+
expanded = expand, -- controls visibility of reasoning text
1964+
diff_expanded = false, -- unused for reasoning
1965+
status = nil, -- unused for reasoning headers; no status icons
1966+
arguments = "", -- we reuse arguments as the accumulated reasoning text
18341967
details = {},
18351968
has_diff = false,
18361969
label_line = nil,
@@ -1884,15 +2017,10 @@ function M:_handle_reason_text(content)
18842017
call.arguments_lines = {}
18852018

18862019
local lines = Utils.split_lines(call.arguments or "")
1887-
for index, line in ipairs(lines) do
2020+
for _, line in ipairs(lines) do
18882021
line = line ~= "" and line or " "
1889-
if index == 1 then
1890-
-- First line uses markdown blockquote prefix
1891-
table.insert(call.arguments_lines, "> " .. line)
1892-
else
1893-
-- Subsequent lines are indented to align with the first line's content
1894-
table.insert(call.arguments_lines, " " .. line)
1895-
end
2022+
-- Prefix each line with markdown blockquote
2023+
table.insert(call.arguments_lines, "> " .. line)
18962024
end
18972025

18982026
-- Trailing blank line for spacing
@@ -1935,14 +2063,17 @@ function M:_handle_reason_finished(content)
19352063
return
19362064
end
19372065

1938-
-- When reasoning is finished, update the label from "Thinking..." to
1939-
-- "Thought <seconds> s" when totalTimeMs is available.
2066+
local labels = get_reasoning_labels()
2067+
local finished_label = labels.finished
2068+
2069+
-- When reasoning is finished, update the label from running to a
2070+
-- finished label, appending the total time in seconds when available.
19402071
local total_ms = tonumber(content.totalTimeMs)
19412072
if total_ms and total_ms > 0 then
19422073
local seconds = total_ms / 1000
1943-
call.title = string.format("Thought %.2f s", seconds)
2074+
call.title = string.format("%s %.2f s", finished_label, seconds)
19442075
else
1945-
call.title = "Thought"
2076+
call.title = finished_label
19462077
end
19472078

19482079
call.status = nil
@@ -2319,11 +2450,9 @@ function M:_expand_tool_call(call)
23192450
if not call.arguments_lines or #call.arguments_lines == 0 then
23202451
call.arguments_lines = {}
23212452

2322-
table.insert(call.arguments_lines, "```text")
23232453
for _, line in ipairs(Utils.split_lines(call.arguments or "")) do
23242454
table.insert(call.arguments_lines, line)
23252455
end
2326-
table.insert(call.arguments_lines, "```")
23272456
table.insert(call.arguments_lines, "")
23282457
end
23292458

0 commit comments

Comments
 (0)