repl: readline loop, dispatch, all Phase 0 meta commands
Phase 0 implementation per PHASE0.md §5, §9.
Wires the lower-half modules into a single REPL:
ffi/readline -> input + history
router -> classify(line) -> meta/shell/ai
executor -> run_shell with cd interception, frame output, capture
broker -> ask_ai, then extract+confirm CMD: lines from response
context -> turn list + eviction; status line on evict
renderer -> assistant text + exec frame + status
Prompt format `[aish:<model>]> ` per §9.
Meta commands all wired (§5.2): :quit/:q, :clear, :reset, :model <name>,
:models, :history, :exec <cmd>, :ask <text>, :help. Unknown meta names
report via renderer.status rather than crashing.
End-of-input (Ctrl-D on empty line) breaks the loop cleanly. Empty /
whitespace-only lines are skipped silently before dispatch — router
would otherwise classify them as ai with empty payload and pollute
context.
`CMD: ` extraction + confirm-and-execute is wired: when broker returns
an assistant turn, the response is scanned for §6 CMD: lines; each is
prompted via readline ("execute '...'? [y/N]") when config.shell
.confirm_cmd is true (default), else auto-executed.
On broker error, the user turn just appended is popped so the context
isn't polluted with a turn that has no assistant response.
Smoke covers :help, :models, shell exec via known_commands allowlist,
and Ctrl-D break. Live broker exchange deferred per issue #12.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,25 +1,164 @@
|
||||
-- repl.lua — readline loop, input dispatch, prompt rendering.
|
||||
-- See docs/PHASE0.md §5, §9.
|
||||
-- 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 M = {}
|
||||
|
||||
-- Phase 0 stub.
|
||||
local HELP = [[
|
||||
Meta commands (Phase 0):
|
||||
:quit / :q exit aish
|
||||
: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
|
||||
:help this message
|
||||
]]
|
||||
|
||||
function M.run(config)
|
||||
error("repl.run: not implemented (Phase 0 pending)")
|
||||
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 {})
|
||||
|
||||
local function prompt()
|
||||
return ("[aish:%s]> "):format(active_name)
|
||||
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)
|
||||
-- injecting it back into context as a `[exec output]`-tagged user turn.
|
||||
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
|
||||
local out, code = executor.exec(cmd)
|
||||
renderer.exec_output(out, code)
|
||||
if config.shell and config.shell.capture_output then
|
||||
ctx:append({ role = "user", content = "[exec output]\n" .. out })
|
||||
status_evictions(ctx:enforce_budget())
|
||||
end
|
||||
end
|
||||
|
||||
-- Send user text to the active model, render the response, and (per
|
||||
-- §6 + config.shell.confirm_cmd) optionally execute extracted CMD: lines.
|
||||
local function ask_ai(text)
|
||||
ctx:append({ role = "user", content = text })
|
||||
local resp, err = broker.chat(active_cfg, ctx:to_messages())
|
||||
if not resp then
|
||||
renderer.status("broker error: " .. tostring(err))
|
||||
table.remove(ctx.turns) -- back out the user turn we just added
|
||||
return
|
||||
end
|
||||
ctx:append({ role = "assistant", content = resp })
|
||||
status_evictions(ctx:enforce_budget())
|
||||
renderer.assistant(resp)
|
||||
|
||||
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
|
||||
|
||||
-- Meta dispatch table.
|
||||
local meta = {
|
||||
quit = function() os.exit(0) end,
|
||||
q = function() 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) run_shell(args) end,
|
||||
ask = function(args) ask_ai(args) 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"); 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
|
||||
|
||||
-- Meta command table per PHASE0.md §5.2.
|
||||
M.meta_commands = {
|
||||
quit = function(ctx) os.exit(0) end,
|
||||
q = function(ctx) os.exit(0) end,
|
||||
clear = nil, -- TODO Phase 0 impl
|
||||
reset = nil,
|
||||
model = nil,
|
||||
models = nil,
|
||||
history = nil,
|
||||
exec = nil,
|
||||
ask = nil,
|
||||
help = nil,
|
||||
}
|
||||
|
||||
-- Phase 0 module export. Meta-command list shown above lives in HELP and
|
||||
-- is implemented inline in run().
|
||||
return M
|
||||
|
||||
Reference in New Issue
Block a user