1f1065157e
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>
80 lines
2.7 KiB
Lua
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
|