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
6066local 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 }
7186end
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
232280end
233281
234282function 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 )
13701483end
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