-- 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 -- Phase 6: fence-aware highlight filter. Off by default; toggled via -- M.set_highlight(enabled, detected, highlight_fn). State machine: -- outside: pass chunks through; hold a small tail when the suffix -- could be the start of an opening fence (R1 — split fences -- from local llama.cpp need accumulation). -- inside: buffer until closing "\n```" is seen; emit -- highlight_fn(body, lang) then the closing fence verbatim. -- N1: fences only open at start-of-stream OR after a newline ("^```" -- or "\n```"); inline backticks in prose don't trigger. local hl_enabled = false local hl_detected = false local hl_fn = nil -- function(body, lang) -> rendered local hl_state = "outside" -- "outside" | "inside" local hl_tail = "" -- outside-state lookahead local hl_inside_buf = "" -- inside-state buffer local hl_lang = nil -- captured at fence open function M.set_highlight(enabled, detected, highlight_fn) hl_enabled = not not enabled hl_detected = not not detected hl_fn = highlight_fn end function M.highlight_state() return { enabled = hl_enabled, detected = hl_detected } end -- Longest suffix of `s` that is a prefix of any well-formed fence-open -- marker ("\n```\n" or "```\n" at SOL). Returns the suffix -- string. Bounded by max-lang-tag-length + 5. local function _hl_partial_suffix(s) -- Look back up to 32 chars. local hi = math.min(#s, 32) for k = hi, 1, -1 do local cand = s:sub(#s - k + 1) -- Possible prefixes of a fence-open: -- "\n", "\n`", "\n``", "\n```", "\n```" -- if k == #s (full string == cand), also bare "`", "``", "```" if cand:match("^\n`*[%w_-]*$") then return cand end if (k == #s) and cand:match("^`*[%w_-]*$") and cand:find("`") then return cand end end return "" end -- Find fence open in combined string. Returns (fence_start, content_start, -- lang) or nil. fence_start = index of first backtick; content_start = -- index after the closing newline of the fence-info line. local function _hl_find_open(combined) -- Match at start-of-string OR after a newline. local s, e, lang = combined:find("^```([%w_-]*)\n") if s then return 1, e + 1, lang end s, e, lang = combined:find("\n```([%w_-]*)\n") if s then return s + 1, e + 1, lang end return nil end local function _hl_push(chunk) if not hl_enabled or not hl_fn then emit(chunk) return end if hl_state == "outside" then local combined = hl_tail .. chunk local fs, cs, lang = _hl_find_open(combined) if fs then if fs > 1 then emit(combined:sub(1, fs - 1)) end -- Emit the fence-open line verbatim too (model + user both -- see "```python\n" — the highlighter only colorizes BODY). emit(combined:sub(fs, cs - 1)) hl_state = "inside" hl_lang = (lang ~= "" and lang) or nil hl_inside_buf = combined:sub(cs) hl_tail = "" -- If the closing fence is already in the inside buffer -- (cloud may deliver whole blocks in one chunk), drain. if hl_inside_buf:find("\n```", 1, true) then _hl_push("") -- triggers the inside branch's close detect end return end -- No opening fence — hold the trailing partial-fence so a -- split-fence ("``" then "`python\n") gets recognized. local hold = _hl_partial_suffix(combined) if #hold < #combined then emit(combined:sub(1, #combined - #hold)) end hl_tail = hold return end -- state == "inside" hl_inside_buf = hl_inside_buf .. chunk local cpos = hl_inside_buf:find("\n```", 1, true) if not cpos then return end -- still buffering local body = hl_inside_buf:sub(1, cpos - 1) local closing = hl_inside_buf:sub(cpos, cpos + 3) -- "\n```" local rest = hl_inside_buf:sub(cpos + 4) local ok, rendered = pcall(hl_fn, body, hl_lang or "") emit((ok and rendered) or body) emit(closing) hl_state = "outside" hl_inside_buf = "" hl_lang = nil if rest ~= "" then _hl_push(rest) end end 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 _hl_push(chunk) end function M.assistant_flush() if stream_buf == nil then return end -- Flush any held tail or in-progress fence body so the user sees it. if hl_state == "inside" and hl_inside_buf ~= "" then -- Stream ended mid-fence — emit raw (no highlight; no closing -- fence was seen). User sees the partial code as-is. emit(hl_inside_buf) hl_inside_buf = "" hl_state = "outside" hl_lang = nil elseif hl_tail ~= "" then emit(hl_tail) hl_tail = "" 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) -- Phase 10: "tasks_complete" is a success-ish exit (executor ran -- through all preplanned tasks but didn't explicitly say GOAL: done). local non_error = (status == "done") or (status == "tasks_complete") local color = non_error 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