Files
aish/repl.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

280 lines
11 KiB
Lua

-- repl.lua — readline loop, input dispatch, prompt rendering.
-- Wires ffi/readline + router + executor + broker + context + renderer.
-- See docs/PHASE0.md §5 (dispatch), §9 (prompt + readline).
local rl = require("ffi.readline")
local router = require("router")
local executor = require("executor")
local broker = require("broker")
local renderer = require("renderer")
local Context = require("context")
local history = require("history")
local M = {}
local HELP = [[
Meta commands:
:quit / :q exit aish (current session is flushed and closed)
:clear clear screen (history kept)
:reset clear in-memory conversation history
:model <name> switch active model
:models list configured models (* = active)
:history show conversation turns
:exec <cmd> force shell execution
:ask <text> force AI query
:sessions list session log files
:save <name> rename current session log to <name>.jsonl
:resume <name> load <name>.jsonl turns into the in-memory context
:help this message
]]
function M.run(config)
assert(config and config.models, "repl.run: config.models required")
local active_name = config.default_model or next(config.models)
local active_cfg = config.models[active_name]
if not active_cfg then
error("aish: default_model '" .. tostring(active_name)
.. "' not found in config.models")
end
local ctx = Context.new(config.context or {})
-- Session log (PHASE1.md §6). Always open one on startup; auto-write
-- every user/assistant turn; close on :quit. If history.dir is set but
-- unwritable, log a status and continue without persistence.
local history_dir = (config.history and config.history.dir) or nil
local sessions_dir = history_dir and (history_dir .. "/sessions") or nil
local session_path = sessions_dir
and (sessions_dir .. "/" .. os.date("!%Y-%m-%dT%H-%M-%SZ") .. ".jsonl")
local session
if session_path then
local sess, serr = history.open(session_path, {
started = os.date("!%Y-%m-%dT%H:%M:%SZ"),
model = active_name,
aish_version = "phase1",
})
if sess then
session = sess
else
renderer.status("session log disabled: " .. tostring(serr))
end
end
local function log_turn(turn)
if session then session:append(turn) end
end
local function prompt()
return ("[aish:%s]> "):format(active_name)
end
-- Phase 1 reserved-key wiring (PHASE1.md §7). The mechanism is real; the
-- handlers are placeholders that emit a status. Phase 3 (Norris) is the
-- first consumer that replaces the body with real work.
rl.bind("\\C-n", function()
renderer.status("Norris mode not yet implemented (Phase 3)")
end)
local function status_evictions(n)
if n and n > 0 then
renderer.status(("oldest %d turns evicted"):format(n))
end
end
-- Run a shell command, framing output and (per config.shell.capture_output)
-- buffering it for the NEXT user turn — context.append_exec_output keeps
-- a [exec output] block pending until ask_ai flushes it via append_user.
-- Direct user-role injection violated chat-template alternation (mistral-
-- nemo's Jinja rejects user/user back-to-back); see PHASE0.md §6.
local function run_shell(cmd)
local chd, err = executor.maybe_chdir(cmd)
if chd ~= nil then
if chd then
local pwd = io.popen("pwd"):read("*l") or "?"
renderer.status("cwd -> " .. pwd)
else
renderer.status("cd: " .. tostring(err))
end
return
end
renderer.exec_begin()
local out, code = executor.exec(cmd)
renderer.exec_end(code)
if config.shell and config.shell.capture_output then
ctx:append_exec_output(out)
end
end
-- Send user text to the active model, render the response token-by-token
-- via broker.chat_stream, and (per §6 + config.shell.confirm_cmd) optionally
-- execute extracted CMD: lines on the reassembled full text.
local function ask_ai(text)
local prev_pending = ctx.pending_exec_output
ctx:append_user(text) -- flushes any pending [exec output] as prefix
log_turn(ctx.turns[#ctx.turns]) -- merged user turn (may include exec)
local parts = {}
local ok, err = broker.chat_stream(active_cfg, ctx:to_messages(),
function(delta)
parts[#parts + 1] = delta
renderer.assistant_delta(delta)
end)
renderer.assistant_flush()
if not ok then
renderer.status("broker error: " .. tostring(err))
table.remove(ctx.turns) -- back out the merged user turn
ctx.pending_exec_output = prev_pending -- restore buffered exec output
return
end
local resp = table.concat(parts)
ctx:append({ role = "assistant", content = resp })
log_turn(ctx.turns[#ctx.turns])
status_evictions(ctx:enforce_budget())
for _, cmd in ipairs(executor.extract_cmd_lines(resp)) do
local doit
if config.shell and config.shell.confirm_cmd then
local ans = rl.readline(("execute '%s'? [y/N] "):format(cmd)) or ""
doit = (ans:lower():sub(1, 1) == "y")
else
doit = true
end
if doit then run_shell(cmd) end
end
end
local function shutdown_session()
if session then session:close(); session = nil end
end
-- Meta dispatch table.
local meta = {
quit = function() shutdown_session(); os.exit(0) end,
q = function() shutdown_session(); os.exit(0) end,
clear = function() io.write("\27[H\27[2J"); io.flush() end,
reset = function()
ctx:reset(); renderer.status("context reset")
end,
model = function(args)
local name = args:match("^%s*(%S+)")
if not name or not config.models[name] then
renderer.status("usage: :model <name>; not found: " .. tostring(name))
return
end
active_name, active_cfg = name, config.models[name]
renderer.status("model -> " .. name)
end,
models = function()
renderer.status(("models (active: %s):"):format(active_name))
for name, cfg in pairs(config.models) do
local mark = (name == active_name) and "*" or " "
io.write((" %s %-8s %s @ %s\n"):format(
mark, name, cfg.model or "?", cfg.endpoint or "?"))
end
end,
history = function()
if #ctx.turns == 0 then
renderer.status("(empty)"); return
end
for i, t in ipairs(ctx.turns) do
io.write(("[%d] %s: %s\n"):format(
i, t.role, t.content:gsub("\n", " ")))
end
end,
exec = function(args)
args = (args or ""):match("^%s*(.-)%s*$")
if args == "" then renderer.status("usage: :exec <cmd>"); return end
run_shell(args)
end,
ask = function(args)
args = (args or ""):match("^%s*(.-)%s*$")
if args == "" then renderer.status("usage: :ask <text>"); return end
ask_ai(args)
end,
sessions = function()
if not sessions_dir then renderer.status("(no history.dir configured)"); return end
local names = history.list_sessions(sessions_dir)
if #names == 0 then renderer.status("(no sessions in " .. sessions_dir .. ")"); return end
for _, n in ipairs(names) do
local mark = (session_path and session_path:match("[^/]+$") == n)
and "*" or " "
io.write((" %s %s\n"):format(mark, n))
end
end,
save = function(args)
local name = args:match("^%s*(%S+)")
if not name then renderer.status("usage: :save <name>"); return end
if not (session and session_path and sessions_dir) then
renderer.status("no active session to save")
return
end
name = name:gsub("%.jsonl$", "")
local new_path = sessions_dir .. "/" .. name .. ".jsonl"
if new_path == session_path then
renderer.status("already named " .. name)
return
end
session:close()
local ok, rerr = os.rename(session_path, new_path)
if not ok then
renderer.status("rename failed: " .. tostring(rerr))
-- best-effort reopen of original path so logging continues
session = history.open(session_path)
return
end
session_path = new_path
session = history.open(session_path) -- reopen for continued append
renderer.status("saved as " .. name .. ".jsonl")
end,
resume = function(args)
local name = args:match("^%s*(%S+)")
if not name then renderer.status("usage: :resume <name>"); return end
if not sessions_dir then renderer.status("(no history.dir configured)"); return end
name = name:gsub("%.jsonl$", "")
local path = sessions_dir .. "/" .. name .. ".jsonl"
local meta_hdr, turns = history.load(path)
if not (meta_hdr or turns) then
renderer.status("resume failed: cannot load " .. path)
return
end
ctx:reset()
for _, t in ipairs(turns or {}) do ctx:append(t) end
renderer.status(("resumed %d turns from %s"):format(#(turns or {}), name))
end,
help = function() io.write(HELP) end,
}
-- Main loop.
while true do
local line = rl.readline(prompt())
if line == nil then -- EOF (Ctrl-D on empty line)
io.write("\n")
shutdown_session()
break
end
if line:gsub("%s", "") == "" then
-- empty / whitespace-only: skip silently
else
rl.add_history(line)
local kind, payload = router.classify(line, config)
if kind == "meta" then
local name, rest = payload:match("^(%S+)%s*(.*)$")
local handler = name and meta[name]
if handler then
handler(rest or "")
else
renderer.status("unknown meta command: :" .. tostring(name))
end
elseif kind == "shell" then
run_shell(payload)
else -- "ai"
ask_ai(payload)
end
end
end
end
-- Phase 0 module export. Meta-command list shown above lives in HELP and
-- is implemented inline in run().
return M