repl + renderer: streaming assistant output (Phase 1)

repl.ask_ai now drives broker.chat_stream and pumps each delta into
renderer.assistant_delta(delta) as it arrives. renderer.assistant_flush
is called when the stream ends to add a trailing newline if missing.
The full reassembled response is then handed to executor.extract_cmd_lines
for the CMD: confirm-and-execute path (unchanged from Phase 0).

renderer.assistant() is kept for non-streaming callers (none in tree
right now, but cheap to keep around). assistant_delta/flush share no
state with assistant(); they use a module-local stream_buf that tracks
the in-progress streamed block.

Q12 deferred: incremental CMD: highlighting (cursor-positioning re-
render on flush) is not implemented in Phase 1 — deltas emit raw. The
§6 CMD: marker is still extractable on the reassembled string post-
stream, which is what executor cares about. Renderer's bold+cyan
treatment for CMD: lines stays available via M.assistant().

Broker error / SSE-framed api-error path still pops the user turn and
restores ctx.pending_exec_output. Order: assistant_flush always runs
(even on error) so the cursor lands on a fresh line before the broker-
error status renders.

Live verification: `Count one to ten` against hossenfelder fast streams
deltas through to stdout incrementally; CMD: extraction works on the
reassembled string; confirm gate intact.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-10 19:17:27 +00:00
parent e46a5c385d
commit a722f576ac
2 changed files with 41 additions and 7 deletions
+27 -2
View File
@@ -1,7 +1,10 @@
-- renderer.lua — output formatting and ANSI sequences. -- renderer.lua — output formatting and ANSI sequences.
-- Phase 0: assistant text plain-printed with `CMD: ` lines highlighted; -- Phase 0: assistant text plain-printed with `CMD: ` lines highlighted;
-- exec output framed with the exit code on the closing rule. Syntax -- exec output framed with the exit code on the closing rule.
-- highlighting hooks land in Phase 6 (was Phase 5 pre-MCP renumber). -- Phase 1: assistant_delta + assistant_flush for streaming render. CMD:
-- highlighting in streaming mode is deferred (Q12); deltas print raw, the
-- §6 substrate `CMD: ` line is still extractable by executor afterwards.
-- Syntax highlighting hooks land in Phase 6 (was Phase 5 pre-MCP renumber).
local M = {} local M = {}
@@ -48,4 +51,26 @@ function M.status(line)
emit(A.dim, "[aish] ", tostring(line), A.reset, "\n") emit(A.dim, "[aish] ", tostring(line), A.reset, "\n")
end end
-- Streaming assistant output. Phase 1: deltas are written raw — the §6 CMD:
-- highlighting from M.assistant() is not applied incrementally because
-- mid-line cursor manipulation isn't worth the complexity for Phase 1.
-- Q12 (PHASE1.md §10) tracks the upgrade. The full assistant text is still
-- captured by repl.lua and CMD: extraction works against the reassembled
-- string after the stream ends.
local stream_buf = nil -- non-nil while a stream is in progress
function M.assistant_delta(chunk)
if not chunk or chunk == "" then return end
if stream_buf == nil then stream_buf = "" end
stream_buf = stream_buf .. chunk
emit(chunk)
end
function M.assistant_flush()
if stream_buf == nil then return end
if not stream_buf:match("\n$") then emit("\n") end
stream_buf = nil
end
return M return M
+14 -5
View File
@@ -68,21 +68,30 @@ function M.run(config)
end end
end end
-- Send user text to the active model, render the response, and (per -- Send user text to the active model, render the response token-by-token
-- §6 + config.shell.confirm_cmd) optionally execute extracted CMD: lines. -- via broker.chat_stream, and (per §6 + config.shell.confirm_cmd) optionally
-- execute extracted CMD: lines on the reassembled full text.
local function ask_ai(text) local function ask_ai(text)
local prev_pending = ctx.pending_exec_output local prev_pending = ctx.pending_exec_output
ctx:append_user(text) -- flushes any pending [exec output] as prefix ctx:append_user(text) -- flushes any pending [exec output] as prefix
local resp, err = broker.chat(active_cfg, ctx:to_messages())
if not resp then local parts = {}
local ok, err = broker.chat_stream(active_cfg, ctx:to_messages(),
function(delta)
parts[#parts + 1] = delta
renderer.assistant_delta(delta)
end)
renderer.assistant_flush()
if not ok then
renderer.status("broker error: " .. tostring(err)) renderer.status("broker error: " .. tostring(err))
table.remove(ctx.turns) -- back out the merged user turn table.remove(ctx.turns) -- back out the merged user turn
ctx.pending_exec_output = prev_pending -- restore buffered exec output ctx.pending_exec_output = prev_pending -- restore buffered exec output
return return
end end
local resp = table.concat(parts)
ctx:append({ role = "assistant", content = resp }) ctx:append({ role = "assistant", content = resp })
status_evictions(ctx:enforce_budget()) status_evictions(ctx:enforce_budget())
renderer.assistant(resp)
for _, cmd in ipairs(executor.extract_cmd_lines(resp)) do for _, cmd in ipairs(executor.extract_cmd_lines(resp)) do
local doit local doit