Skip to content

Instantly share code, notes, and snippets.

@Fullstop000
Created June 13, 2026 03:34
Show Gist options
  • Select an option

  • Save Fullstop000/1dd6f91b219a1e6548f68f4725e84ca8 to your computer and use it in GitHub Desktop.

Select an option

Save Fullstop000/1dd6f91b219a1e6548f68f4725e84ca8 to your computer and use it in GitHub Desktop.
Ignis: transport-agnostic frontend layer + Ink TUI exploration plan (PR #174)

Ignis: Transport-Agnostic Frontend Layer + Ink TUI Exploration

Tracking PR: Fullstop000/ignis#174 (feat/frontend-port-layer)

Goal

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.

Hard constraints (from AGENTS.md + product decisions)

  1. 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.
  2. Exactly one live frontend at a time — so a blocking ask_user has an unambiguous answerer.
  3. FIFO on conflict — extra frontends queue and take over (full capability, NOT read-only) in arrival order when the active one disconnects.
  4. Web/plugin are first-class — full capability, no assumed feature subset.
  5. Zero warnings / clippy clean / tests pass — quality gate every commit.

Architecture

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

Wire protocol (serde, transport-agnostic)

  • 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.

Request-response bridge (the hard part)

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.

Single frontend + FIFO

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).

Phases

Phase 0 — Foundation ✅ DONE (in PR #174)

  • protocol — wire types.
  • brokerRequestBroker.
  • portFrontendPort trait + FIFO Acceptor.
  • commandClientCommand → console-signal classification (ControlSignal).
  • localLocalTuiPort / TuiHandle (first concrete port; non-serializing channel bridge for the bundled TUI).
  • hubFrontendHub (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.

Phase 1 — Adopt the ratatui runner ⏳ NEXT

Reroute runner.rs::ConsoleLoop through FrontendHub:

  • agent_rxhub.emit_event.
  • picker_rxhub.open_request.
  • prompt_tx / cancel_tx / inject → derived from hub.next_command outcomes (Submit routes 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).

Phase 2 — stdio transport (NDJSON) ⬜

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.

Phase 3 — ignis-tui (Ink) ⬜

Node + Ink subprocess. readlineJSON.parseswitch(kind) dispatch mirroring App::handle_event. Components map 1:1 to render/* modules. Input via useInputClientCommand. resize handled natively by Ink (drops the Rust anchor machinery for this path).

Phase 4 — Wire-up + packaging decision ⬜ (needs explicit sign-off)

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.

Phase 6 (optional) — web FrontendPort

WebSocket transport as the multi-frontend validation target; reuses the same serde frames.

Risks

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

Naming decisions (resolved with user)

  • UiCommand/UiRequestClientCommand/ClientRequest (transport-neutral).
  • in_process module → local; InProcessTuiPortLocalTuiPort; FrontendChannelsTuiHandle; in_process()local_tui().
  • Ink subprocess named ignis-tui (leaves room for web/plugin).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment