Files
aish/renderer.lua
T
marfrit 11d0e599cd repl + renderer: tree-sitter highlighter (Phase 6 commit #5)
The largest Phase 6 commit — fence-aware stream filter in renderer.lua
+ external tree-sitter dispatch + :highlight meta in repl.lua.

renderer.lua — fence-aware filter wrapping assistant_delta:

  M.set_highlight(enabled, detected, highlight_fn)
      Called by repl.lua at startup AND on every :highlight toggle.
      Stores state in module-locals (off by default).

  State machine inside _hl_push:
    outside: pass chunks through; HOLD trailing partial-fence chars
             (per R1 — local llama.cpp splits ```python as `'``'`
             then `'`python\n'`, so naive pass-through drops the
             leading "``" and never recovers).
    inside:  buffer cumulatively until "\n```" appears; emit
             highlight_fn(body, lang) then the closing fence verbatim.
             Recursive call handles "rest" after the closing fence.

  N1: fences only open at start-of-stream OR after a newline
      (`^```` or `\n```` only). Inline backticks in prose
      ("use ``` to mark code") do not open a fence.

  R3 (PTY raw-mode toggle per highlight call): no change here — every
      executor.exec call already toggles raw-mode (existing behavior
      since Phase 1). The risk is theoretical; smoke-test interactively
      after install if multi-fence renders show flicker.

  assistant_flush handles end-of-stream gracefully: drains any held
  partial-fence tail OR an unterminated inside-fence buffer.

repl.lua — _detect_treesitter + highlighted + :highlight meta:

  _detect_treesitter()  one-shot popen probe of `tree-sitter --version`.
                        Run once at startup; cached as
                        highlight_detected.

  highlighted(body, lang_tag)   R2-placed in repl.lua (has _shq +
                                executor access). Translates the fence
                                tag (`py`, `python`, `lua`, etc.) to
                                a canonical lang via LANG_TAG, picks
                                the canonical extension via LANG_EXTENSION,
                                writes body to a tmpfile with that
                                extension, runs `tree-sitter highlight
                                <tmpfile>` via executor.exec, returns
                                the output. On ANY failure (CLI absent,
                                non-zero exit, empty output), returns
                                `body` unchanged — silent pass-through.

  R4 RESOLVED VIA REAL INSTALL: probed `tree-sitter highlight --help`
      on noether; confirmed:
        - NO `--lang` flag exists (formulate-time assumption wrong)
        - takes a PATH; language inferred from file extension
        - alternative `--scope source.X` exists but also unreliable
          without configured grammars
      Resolution: write tmpfile with `os.tmpname() .. LANG_EXTENSION[lang]`
      and pass the path. Matches the documented upstream contract.

  B4-followup: even with the CLI installed, highlighting requires
      `~/.config/tree-sitter/config.json` parser-directories with
      cloned + built `tree-sitter-<lang>` grammars. Without parsers,
      every call exits non-zero and we silently pass through. The
      :highlight install hint surfaces all three install steps so the
      user knows what's actually needed.

  :highlight [on|off|status] meta:
      no arg     -> flip
      on/off     -> set explicit
      status     -> report toggle + CLI detection state
      When toggled on AND CLI absent: emit a 4-line install hint
        (CLI install, init-config, grammar clone reminder).
      When toggled on AND CLI present: emit a 1-line note that
        parser-directories must be set up for actual highlighting.

HELP gains :highlight entry.

Tested:
  10/10 unit cases on the renderer state machine, including:
    - plain prose passthrough
    - single-chunk fence
    - B2 split fence ("``" + "`python\n" + "x=42" + "\n```")
    - N1 SOL anchor (mid-line ``` does not open)
    - trailing \n properly emitted across chunks
    - SOL-only fence open
    - prose after closing fence preserved
    - two fences in one stream
    - highlight off = passthrough (callback never fires)

  E2E :highlight meta verified:
    :highlight status -> off / detected
    :highlight on     -> toggles + emits parser-dir reminder
    :highlight status -> on / detected
    :highlight off    -> off

Regression: test_safety 87/87, test_router_model 31/31, repl loads.

Pillars 1 + 2 + 3 of Phase 6 now all implemented. Commit #6 is config
example block + status -> Implement.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 22:27:04 +00:00

282 lines
11 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
-- 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```<lang>\n" or "```<lang>\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```<langchars>"
-- 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)
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