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.
Keymap
ctx.spawn feed items
back
match on your Msg
println!
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 needed
Reconciliation & 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 needed
Dirty 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 needed
Framework-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 needed
Hidden 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·panel text_area· grapheme-aware, strict-Elm input-
viewport·col/row· your ownimpl Element