How the ratatui frontend works, in standalone mode and when connected to the daemon.
The mew TUI is a ratatui application that can run in two modes:
- Standalone: it builds the
Agentdirectly and drives the provider loop itself. This is the defaultmew chatpath. - Daemon client: it connects to
mew-daemonover WebSocket and receivesAgentEvents over the wire. This ismew chat --connect <url>.
This doc covers the TUI’s event loop, display state, streaming markdown, and how a keystroke becomes a provider stream. For the daemon and session model, see Architecture. For recording captures against a live daemon, see TUI capture methods.
The pipeline
Section titled “The pipeline”Keyboard → crossterm EventStream → EventLoop (mpsc::channel(256)) │ ├─ Event::Input(crossterm::Event) ├─ Event::Agent(AgentEvent) ├─ Event::Tick (60fps) └─ Event::Quit │ handle_input_event() → Action::Submit(text) │ agent.run_with_parts(prompt, attachments, token) returns mpsc::Receiver<AgentEvent> │ event_loop.forward_agent_events(agent_rx) spawns tokio task pumping AgentEvent → EventLoop │ app.handle_agent_event(event) → draw()In daemon mode the same pipeline runs, but the AgentEvents arrive through
mew-daemon/src/client.rs instead of a local Agent.
The event loop
Section titled “The event loop”EventLoop (events.rs) is a thin wrapper around mpsc::channel(256):
pub struct EventLoop { tx: mpsc::Sender<Event>,}Three tokio tasks feed events into the channel:
- Crossterm reader: reads keyboard/mouse events and forwards as
Event::Input. - Tick generator: fires every 16ms (60fps) as
Event::Tick. Skipped when idle (seetick_interval_msfor adaptive polling). - Agent forwarder: per-prompt.
forward_agent_eventsspawns a task that pumpsmpsc::Receiver<AgentEvent>intoEvent::Agent:
pub fn forward_agent_events(&self, mut agent_rx: Receiver<AgentEvent>) { let tx = self.tx.clone(); tokio::spawn(async move { while let Some(event) = agent_rx.recv().await { if tx.send(Event::Agent(event)).await.is_err() { break; } } });}The main loop
Section titled “The main loop”The TUI main loop (run_tui in main.rs):
- Render:
terminal.draw(|f| mew_tui::ui::draw(f, &mut app)). Skipped when idle:if !last_event_was_tick || app.needs_redraw(). - Wait for event:
event_rx.recv().await. - Process: match on
Event::Input/Event::Agent/Event::Tick/Event::Quit. - Drain loop: after processing the first event, coalesces rapid events
via
try_recv(). Capped atSTREAMING_DRAIN_LIMIT = 4agent events per frame so streaming text appears incrementally instead of all at once.
How a keystroke becomes a provider stream
Section titled “How a keystroke becomes a provider stream”- User types text and presses Enter. Crossterm fires
Event::Key(Enter). handle_input_eventcallsapp.submit_input(), returnsAction::Submit(text).- The main loop calls
agent.run_with_parts(prompt, attachments, token). run_with_partsspawns a tokio task runningrun_loop, which callsturn_loop. Returnsmpsc::Receiver<AgentEvent>immediately.event_loop.forward_agent_events(agent_rx)pumps the receiver into the main event channel.- Each
AgentEvent::Provider(pe)updates App state viahandle_agent_event:PartStartcreates a new part,PartDeltaappends text,MessageEndfinalizes the stream. draw()renders the updated state.
In daemon mode steps 3–5 are replaced by sending ClientMessage::Prompt over
WebSocket and receiving ServerMessages that are translated back into
AgentEvents by DaemonClient.
Display store vs API history store
Section titled “Display store vs API history store”Two separate message stores exist:
app.messages(display): what the TUI renders. All parts from a multi-turn agentic loop (text, tool calls, follow-up text) merge into one assistant message entry. Synthetic messages (alerts, cost reports) live here too.agent.messages(API history): what gets sent to the provider. Each provider turn produces a separate assistantMessage. Tool calls and results are separate parts. This is the canonical conversation state persisted to disk.
The display store is rebuilt from the API history on session resume. The
streaming markdown cache (rendered_md_cache) maps message IDs to rendered
ratatui Lines, invalidated when the terminal width changes.
Streaming markdown
Section titled “Streaming markdown”app.md_stream / app.md_state track the currently-streaming text part:
- The last
Part::Textin the active message usesrender_streaming(md_state)for incremental rendering. - Earlier text parts (before tool calls in the same message) use the cached
path:
render_markdown(tp.text, md_width, theme). - On
MessageEnd, the stream is finalized andpending_md_rerendertriggers a full re-render fromtp.texton the next frame. - Cache invalidation:
rendered_md_cacheis cleared whenmd_widthchanges (terminal resize).
Related crates
Section titled “Related crates”| Crate | Purpose | Key Types |
|---|---|---|
mew-tui |
Event loop, ratatui UI, App state | Event, EventLoop, App, Action |
ratatui-mdstream |
Streaming markdown to ratatui Lines | MdStream, DocumentState |
See Architecture for the daemon, session model, and provider pipeline.