Tracking PR: Fullstop000/ignis#174 (feat/frontend-port-layer)
Decouple the agent core from its renderer so the same core can drive multiple frontends through one contract:
- the in-process ratatui TUI (today, single binary),
- an out-of-process Ink
ignis-tui(NDJSON over a pipe), - a web client (JSON over a WebSocket),
- a plugin (in-process, no serialization).
The transport layer is the centerpiece: it must be atomic/general enough that new frontends are new implementations of one trait, not new branches in the core loop.
- Single binary, no external runtime — the ratatui path must stay pure Rust. Introducing Node for Ink is an explicit, separately-approved decision (packaging), not a default.
- Exactly one live frontend at a time — so a blocking
ask_userhas an unambiguous answerer. - FIFO on conflict — extra frontends queue and take over (full capability, NOT read-only) in arrival order when the active one disconnects.
- Web/plugin are first-class — full capability, no assumed feature subset.
- Zero warnings / clippy clean / tests pass — quality gate every commit.
core (agent loop)
│ Box<dyn FrontendPort> (one trait, transport-agnostic)
▼
FrontendHub ── Acceptor (single-slot + FIFO successors)
── RequestBroker (oneshot ↔ correlation-id bridge)
── command classification (Submit / control / reply)
│
▼ Frame protocol (serde): Outbound / ClientCommand / ClientRequest / Snapshot
▲ ▲ ▲
│ local │ stdio(NDJSON) │ websocket
ratatui ignis-tui (Ink) web / plugin
- Outbound (core → frontend):
Event(AgentEvent)|Request(ClientRequest)|Snapshot(Snapshot). - ClientCommand (frontend → core):
Submit|Inject|Cancel|Reply|Shutdown. - ClientRequest: id + full question set (self-contained).
- Snapshot: session state for a newly-activated frontend, carrying any in-flight request so a handover never strands a blocked tool.
- Atomicity invariant: every frame is self-describing — a frontend can attach mid-session and interpret each frame without replaying earlier ones.
Tools block on a oneshot::Sender<PickerResponse> (ask_user, permission gate).
RequestBroker peels the sender into an id-keyed table and emits a
ClientRequest; the frontend's Reply{id} resolves it. Same model across all
transports; in-process skips serialization, proving the plugin path. On
disconnect with no successor, all outstanding requests resolve to Cancelled
so no tool hangs.
Acceptor holds one active Box<dyn FrontendPort> + a VecDeque of waiters.
attach activates if idle, else queues. handover promotes the next waiter
(FIFO) on disconnect; the FrontendHub then re-establishes it with a
Snapshot (or cancels requests if none remain).
protocol— wire types.broker—RequestBroker.port—FrontendPorttrait + FIFOAcceptor.command—ClientCommand→ console-signal classification (ControlSignal).local—LocalTuiPort/TuiHandle(first concrete port; non-serializing channel bridge for the bundled TUI).hub—FrontendHub(acceptor + broker + classification + disconnect/ handover/snapshot/cancel recovery).- 19 unit tests; clippy
-D warnings+ fmt clean. No runner wiring yet, so master behavior is untouched.
Reroute runner.rs::ConsoleLoop through FrontendHub:
agent_rx→hub.emit_event.picker_rx→hub.open_request.prompt_tx/cancel_tx/ inject → derived fromhub.next_commandoutcomes (Submitroutes to the core slash dispatcher; control signals map to cancel/inject/quit).- Keep the delicate anchor/DSR/scrollback machinery intact.
- Verification: extend the PTY e2e harness (
tests/tui_slash_e2e.rs,portable_pty) with submit / picker-open-and-reply / cancel / clean-exit cases through the real binary. Surface a rendered snapshot for visual sign-off (colors/spacing are the only human-eye items).
Second FrontendPort implementation: spawn a child, NDJSON over its
stdin/stdout, length-free line framing. Handshake (ready + proto version),
EOF = disconnect → handover/fallback. Offline-replayable via cat fixture.ndjson | node ui.
Node + Ink subprocess. readline → JSON.parse → switch(kind) dispatch
mirroring App::handle_event. Components map 1:1 to render/* modules.
Input via useInput → ClientCommand. resize handled natively by Ink (drops
the Rust anchor machinery for this path).
Feature flag / env (IGNIS_FRONTEND=tui) to select frontends; ratatui stays
default + fallback. Resolve the single-binary conflict: bundle Node vs require
system Node vs drop single-binary for the Ink path. Do not default; ask.
WebSocket transport as the multi-frontend validation target; reuses the same serde frames.
| Risk | Mitigation |
|---|---|
| Single-binary broken by Node | Phase 4 explicit decision; ratatui stays default |
| inline scrollback behavior (native copy/scroll/tmux) lost under Ink | approximate with Ink <Static>; enumerate diffs at review |
| request-response deadlock/leak | broker timeout + cancel-all on disconnect (done) |
| 30fps stream throughput | coalesce message_update in UI; bounded channels + try_send |
UiCommand/UiRequest→ClientCommand/ClientRequest(transport-neutral).in_processmodule →local;InProcessTuiPort→LocalTuiPort;FrontendChannels→TuiHandle;in_process()→local_tui().- Ink subprocess named
ignis-tui(leaves room forweb/plugin).