eye-declare
Inline terminal UIs: the kind that share the terminal with your shell.
Finished output scrolls into native scrollback like anything else println! ever printed;
only a small live region keeps changing. Built for CLI tools, AI agents, and interactive prompts.
ctx.push, then owned by the terminal. Scroll up; it's just output.
live tail: a pure view of the model, replaced wholesale every frame.
To get started, run
Most TUI libraries model a screen. Inline apps aren't screens.
A full-screen app owns a fixed canvas, so a retained tree that gets re-rendered and reconciled makes sense. An inline app is different. Its output is an append-only log with a small live edge, and modeling that as a tree means dragging along machinery the shape never needed. Poke both models below and watch what each one has to do.
Fair's fair: real retained-tree frameworks memoize and skip subtrees rather than rebuilding naively. But keys, memo boundaries, and dirty flags are exactly the machinery you maintain to make that work. The timeline's claim is that the machinery has nothing to do, so it doesn't exist.
The shape of an app
If you've written Elm, iced, or Redux, this is that architecture with one addition: the timeline.
Your model is a plain struct. update takes &mut self and tail takes
&self, so the borrow checker enforces the discipline for free.
Keymapctx.spawn feed items backmatch on your Msgprintln!Push is println! for elements, and sealing is nearly free
The characteristic move of a streaming app: content lives in the tail while it's changing, and is pushed to the timeline the moment it can no longer change. Step through one streamed turn:
match msg { Msg::Chunk(delta) => self.reply.push_str(&delta), Msg::StreamDone => { let reply = std::mem::take(&mut self.reply); ctx.push(assistant_turn(&reply)); } } fn tail(&self) -> impl Element + '_ { col() .when(self.streaming(), |c| c.child(spinner("Thinking…"))) .child(text(self.reply.as_str())) }
What this dissolves
Because blocks render exactly once and the tail re-renders wholesale, whole categories of framework machinery have nothing to do, so they don't exist here.
row_view(t).key(t.id)not neededReconciliation & keys
No retained tree, so nothing is ever matched up across frames. Mapped children need no identity annotations; there's no diff to keep stable.
mark_dirty(Region::Tail)not neededDirty tracking
The tail is rebuilt every frame, unconditionally. Identical tails diff to zero bytes at the terminal layer. That's the optimization, and it needs nothing from you.
ui.state::<TextArea>(id)not neededFramework-owned state
Your model is the only state. A text area's contents and a select's cursor are plain fields you
own and mutate in update. Strict Elm, no exceptions.
focus_registry().request(id)not neededHidden focus registry
Focus is a value in your model (FocusHandle). What a key does is always derivable
from your state, never from what the framework last focused.
Elements: plain Rust values, no DSL, no messages
Views are built with fluent builders: full rust-analyzer support, and conditionals are ordinary
if statements and iterators. Elements describe pixels only; message emission lives entirely
in the keymap. Below, you are the model: mutate the fields and watch tail()
re-run. No dirty tracking decides what to update; the whole tail is simply rebuilt.
fn tail(&self) -> impl Element + '_ { let input = text_area(&self.input) .track_focus(&self.focus); col() .gap(1) .when(self.streaming, |c| c.child(spinner("Thinking…"))) .child(panel(input) .title("Ask") .footer("[Enter] Send")) .children(self.results.iter().map(hit_row)) }
pub trait Element { // Exact height at this width. Cheap and honest: // no probe rendering. fn height(&self, width: u16) -> u16; fn render(&self, area: Rect, buf: &mut Buffer); // Frame interval if self-animating (Spinner: ~80ms). fn animated(&self) -> Option<Duration> { None } // Hardware-cursor position, if this element wants it. fn cursor(&self, area: Rect) -> Option<(u16, u16)> { None } }
That's the whole trait
Note what's absent: no message type parameter, no
lifecycle, no state. Custom elements implement this directly; expensive ones (like the built-in
markdown()) cache their parse in a RefCell that dies with the frame,
so there's no invalidation story to manage. animated() covers view-only time dependence, like a
spinner glyph; time that should change your model arrives as messages through subscriptions.
There are no event handlers. Keys are data.
Every update, your app rebuilds a Keymap, a plain value, from the model.
Conditional bindings are just if statements, so a stale handler from a state you've left is
structurally impossible. Set the model's state, then press a key and watch it fall through
the four dispatch tiers.
fn keymap(&self) -> Keymap<Msg> { let mut km = keymap() .on_override(key(Char('c')).ctrl(), Msg::Quit); if !self.busy { km = km.in_scope(&self.input, key(Enter), Msg::Submit); } km.on(key(Esc), Msg::Cancel) .fallthrough(&self.input, Msg::Input) }
Tier 4 is how a text input receives
ordinary typing without the framework owning any editing logic: unclaimed keys and pastes become
Msg::Input(ev), and your update hands them to TextAreaState::handle,
which deliberately ignores policy keys like Enter, Tab, and Esc. Those belong to your keymap.
Cancellation is drop
Async work enters the app as messages: ctx.spawn takes a
Stream<Item = Msg> and returns a Task that cancels its work
when dropped. Hold the task in your model, and cancellation becomes ordinary state
manipulation: no tokens, no flags, no channels.
self.request = None
// Esc cancels the stream. The whole implementation: Msg::Cancel => self.request = None, // Staleness: validity is a property of the model, // not of the channel. Msg::Chunk(delta) => if self.request.is_some() { self.reply.push_str(&delta); }
Replacing a Task with a new
one cancels the old work the same way, and that closes a whole bug class: a replaced request can't finish later
and clobber shared state, because it was dropped at whatever await point it had reached. Cancel the demo above
and note the one already-queued chunk that still arrives: staleness is checked in the model, not the channel.
Subscriptions: recurring input is declared, not managed
After every update the driver diffs what you declare
against what's running: new keys start, missing keys cancel, changed intervals restart. "Poll while a
session is active" is a when on model state; to stop the poll, stop declaring it.
fn subscriptions(&self) -> Subscriptions<Msg> { Subscriptions::new() .when(self.session_active, |s| s.every("poll", Duration::from_secs(30), || Msg::Poll)) .stream("fs-events", || watch_files()) }
Rebuilding everything, every frame. It's fine.
The design invariant: re-presenting the tail is unconditionally cheap: cheap enough to do every frame with no dirty tracking. That's an invariant, not an optimization target. Measured on the library's benchmark scenario (streaming chat, 100×40 terminal, release build):
The driver coalesces bursts
When a fast LLM stream queues a burst of messages, they're processed as one batch and presented as one frame. You never debounce streams yourself.
one batch of updates
Whole apps test headlessly, against a real terminal
The runtime core is synchronous: events in, escape bytes out. So entire apps run in tests
with no TTY and no executor, asserted against TestTerminal, a real VTE emulator.
Tests check what a user would actually see, scrollback included.
#[test] fn submit_commits_the_line() { let mut rt = Runtime::new(my_app(), 80, 24); // TestTerminal is a real VTE emulator let mut term = TestTerminal::new(80, 24); term.feed(&rt.present()); let (bytes, _) = rt.handle(InputEvent::Key(enter())); term.feed(&bytes); let screen = term.viewport_lines().join("\n"); assert!(screen.contains("✓ hello")); }
Async flows, synchronously
Spawned work delivers messages, so tests deliver those
messages by hand via Runtime::process and skip the executor entirely. The sequence of messages
is the scenario: streaming, cancellation, and error paths become timing-free tests.
Even cost is testable
present() on an unchanged tail should return
(nearly) nothing, and sealing already-displayed content shouldn't repaint it. Output-efficiency regressions
show up as plain assertions on bytes.len(). The repo also ships a perf report with
exact, deterministic allocation counts per scenario.
Get started
The quick start builds a complete working app in about sixty lines. Every concept on this page appears once.
Read
- eye-declare.rs/book · the book
- docs.rs/eye_declare · API reference
- github.com/atuinsh/eye-declare · source
Run the examples
--example echo· the smallest useful app--example stream· a mini agent; Esc cancels--example openrouter· a real streaming AI chat in one commented file
In the box
text·markdown·spinner·paneltext_area· grapheme-aware, strict-Elm inputviewport·col/row· your ownimpl Element