Files
aish/renderer.lua
T
marfrit d2a53d2fc7 renderer: Norris autonomous-mode frames (Phase 3 commit #3)
Phase 3 commit #3 per docs/PHASE3.md §12. Four new renderer functions
for Norris mode visual feedback.

M.norris_begin(goal)
  Bold cyan banner on Norris entry, with the goal text on a dim
  indented line. Frames the start of the planning loop.

M.norris_step(n, max_n, descr)
  Compact one-line step counter ("─ step 3/16 ─") with optional
  description. Renders before each iteration of the planner.

M.norris_halt(step_n, max_n, reason, action)
  Bold red banner when the destructive-op gate fires. Three
  indented lines: step counter, reason (red), action text
  (truncated at 400 chars, newlines collapsed). The interactive
  proceed/skip/abort prompt is shown after this banner by repl.lua.

M.norris_end(status, reason)
  Closing banner. status ∈ {"done", "aborted", "budget_exhausted",
  "stalled", "broker_error"}. Color cyan on "done", red otherwise.
  Optional reason text on a dim line.

The interactive prompt `[aish:<model> ]>` activation lands in
commit #5 (repl.lua's prompt() function).

Smoke-tested all five frames visually — clean ANSI output, correct
truncation on long action strings, color discrimination on
done/aborted/budget_exhausted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 23:36:44 +00:00

162 lines
6.3 KiB
Lua

-- renderer.lua — output formatting and ANSI sequences.
-- Phase 0: assistant text plain-printed with `CMD: ` lines highlighted;
-- exec output framed with the exit code on the closing rule.
-- 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 A = {
reset = "\27[0m",
bold = "\27[1m",
dim = "\27[2m",
cyan = "\27[36m",
red = "\27[31m",
}
local function emit(...) io.write(...); io.flush() end
-- Print assistant response text. Lines beginning with `CMD: ` (per the §3
-- substrate-locked extraction marker) are emitted bold+cyan so the user
-- can spot the suggestion without scanning prose.
function M.assistant(text)
for line in ((text or "") .. "\n"):gmatch("([^\n]*)\n") do
if line:sub(1, 5) == "CMD: " then
emit(A.bold, A.cyan, line, A.reset, "\n")
else
emit(line, "\n")
end
end
end
-- Phase 1: executor.exec streams output live to stdout (PTY multiplex), so
-- the frame is split — exec_begin before the spawn, exec_end after wait().
-- The body is not re-rendered here; live output lands directly between the
-- two rules.
function M.exec_begin()
emit(A.dim, "─── exec output ───", A.reset, "\n")
end
function M.exec_end(exit_code)
if exit_code and exit_code ~= 0 then
emit(A.dim, "─── exit ", A.reset,
A.red, tostring(exit_code), A.reset,
A.dim, " ───", A.reset, "\n")
else
emit(A.dim, "─── exit 0 ───", A.reset, "\n")
end
end
-- Single-line dim status (e.g. §8 eviction notice, model switch confirms).
function M.status(line)
emit(A.dim, "[aish] ", tostring(line), A.reset, "\n")
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
-- Phase 2: MCP tool-call frame. Visual parity with the exec_begin/exec_end
-- frame so the user reads tool dispatch and shell dispatch the same way.
-- tool_call_begin renders the top rule + (optionally) the args as a dim
-- preview; tool_call_end renders the result content followed by a status
-- rule. Status is "ok" (dim) by default; "error" (red) if is_error is true.
-- See docs/PHASE2.md §3 renderer.lua row + §4 Tool invocation.
function M.tool_call_begin(name, args)
emit(A.dim, "─── tool: ", A.reset,
A.cyan, name, A.reset,
A.dim, " ───", A.reset, "\n")
if args and args ~= "" and args ~= "{}" then
local shown = (#args <= 200) and args or (args:sub(1, 197) .. "...")
emit(A.dim, shown, A.reset, "\n")
end
end
function M.tool_call_end(content, is_error)
if content and content ~= "" then
emit(content)
if not content:match("\n$") then emit("\n") end
end
if is_error then
emit(A.dim, "─── ", A.reset,
A.red, "error", A.reset,
A.dim, " ───", A.reset, "\n")
else
emit(A.dim, "─── ok ───", A.reset, "\n")
end
end
-- Phase 3: Norris autonomous mode frames. Banner-style on enter/exit,
-- step counter per iteration, red HALT banner when the destructive-op
-- gate fires. The interactive prompt also gets a ⚡ marker when Norris
-- is active (handled in repl.lua's prompt() function per PHASE0.md §9).
-- See docs/PHASE3.md §3 renderer row.
function M.norris_begin(goal)
emit(A.bold, A.cyan, "─── NORRIS MODE ─────────────────────────",
A.reset, "\n")
if goal and goal ~= "" then
emit(A.dim, " goal: ", A.reset, goal, "\n")
end
emit(A.bold, A.cyan, "─────────────────────────────────────────",
A.reset, "\n")
end
function M.norris_step(n, max_n, descr)
emit(A.dim, (" ─ step %d/%d ─ "):format(n, max_n), A.reset)
if descr and descr ~= "" then emit(A.dim, descr, A.reset) end
emit("\n")
end
function M.norris_halt(step_n, max_n, reason, action)
emit(A.bold, A.red, "─── NORRIS HALT ──────────────────────────",
A.reset, "\n")
emit(A.dim, " step: ", A.reset, ("%d/%d"):format(step_n, max_n), "\n")
emit(A.dim, " reason: ", A.reset, A.red, tostring(reason), A.reset, "\n")
-- action may be a long string (command line or JSON-serialized tool call);
-- truncate at 400 chars to keep the banner readable
local act = tostring(action or ""):gsub("\n", " ")
if #act > 400 then act = act:sub(1, 397) .. "..." end
emit(A.dim, " action: ", A.reset, act, "\n")
emit(A.bold, A.red, "──────────────────────────────────────────",
A.reset, "\n")
end
-- Norris loop exit. status ∈ {"done", "aborted", "budget_exhausted",
-- "stalled", "broker_error"}.
function M.norris_end(status, reason)
local color = (status == "done") and A.cyan or A.red
local label = status:upper():gsub("_", " ")
emit(A.bold, color, "─── NORRIS ", label, " ──",
(" "):rep(math.max(0, 28 - #label)),
A.reset, "\n")
if reason and reason ~= "" then
emit(A.dim, " ", reason, A.reset, "\n")
end
emit(A.bold, color, "──────────────────────────────────────────",
A.reset, "\n")
end
return M