Files
aish/renderer.lua
T
marfrit 1f1065157e review BLOCKER: PTY input forwarding + raw mode toggle
Phase 1 review caught a structural gap: executor.exec only drained the
PTY master fd, never forwarded user keystrokes — vim/less/htop/nano
would render and hang on input. PHASE1.md §5 specified bidirectional
multiplex but only the read leg landed. tcgetattr/tcsetattr were also
missing, so even with input forwarding the parent's line discipline
would buffer until newline (breaking single-key UIs).

ffi/libc:
  - struct termios opaque buffer + tcgetattr/tcsetattr + cfmakeraw
  - M.set_raw(fd) saves termios + applies cfmakeraw; returns saved or
    (nil, err) when fd isn't a tty (scripted / piped-stdin runs)
  - M.restore_termios(fd, saved)
  - struct pollfd + M.poll (POLLIN constant)

executor:
  - multiplex(sess): poll(stdin, master); reads master on any revents
    (POLLHUP fires when child closes its slave end, not POLLIN — the
    revents != 0 check catches both); forwards stdin keystrokes to
    master; loop exits when master read returns 0 (EOF / child gone)
  - stdin polling is only enabled when stdin_is_tty (set_raw succeeded);
    piped-stdin runs (tests / scripted) would otherwise drain queued
    aish commands into the child of the *current* cmd, swallowing them
  - raw mode is restored before returning so the user lands back at the
    aish prompt in canonical mode

renderer + repl:
  - exec_output(out, code) split into exec_begin() (top rule, before
    spawn) + exec_end(code) (closing rule with exit, after wait). PTY
    multiplex streams the body live to stdout in between; the renderer
    never re-prints the body.

PHASE1.md §3:
  - tcgetattr/tcsetattr changed from "optional" to "required for
    single-key UIs to work — done-criteria #2"; poll added to the libc
    row description.

Verified:
  - non-interactive smoke (echo / false / exit 7 / ls /nonexistent /
    printf multi-line) — all exit codes correct, output streamed live,
    a\nb\nc\n preserved byte-for-byte
  - scripted-stdin run reaches all expected lines (no stdin draining
    into a non-interactive child)
  - aish prompt + framed exec block + exit-code line all render in
    correct order

Live interactive verification (vim / less / htop in a real terminal)
still needs a user-test pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:00:53 +00:00

80 lines
2.7 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
return M