name eye-declare origin the Atuin team docs docs.rs/eye_declare book eye-declare.rs/book repo atuinsh/eye-declare

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.

effect · ctx.pushCommitted output is an effect.
view · tail(&self)The live tail is a view.
zsh · a streaming agent, inline
live tail
committed: rendered once via 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

$ cargo add eye_declare

Or, read on to learn more

01

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.

the screen model: a retained tree
diff<App>
diff<Header/>
diff<Chat>
diff<Turn key=1/>
diff<Turn key=2/>
diff<Composer/>
The framework retains every turn you've ever rendered. Keys, identity, dirty flags: it reconciles all of it to discover that almost nothing changed.
the timeline: committed blocks + a live tail
❯ hello
Hi! Ask me anything.
tail❯ how does this wor█
Committed blocks are terminal output; the library holds no memory of them at all. Only the tail is live, and it's rebuilt from scratch every frame.

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.

02

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.

The three traces above tell one story: a prompt is submitted, the reply streams in, the turn seals. Press them in order and follow each message through the loop.
terminal eventskeys resolve to messages through your Keymap
task messagesstreams spawned with ctx.spawn feed items back
subscription ticksrecurring input, declared from the model
Msg
update(&mut model, msg, ctx)the only place state changes: plain Rust, a match on your Msg
ctx.push(block) →effect: render once, commit to scrollback. Irreversible, like println!
ctx.spawn(stream) ⟳effect: async work whose items come back as messages ↰
then, once per batch
tail(&self)view: pure function of the model; rebuilt wholesale
diffidentical tails diff to zero bytes; that's the whole optimization
terminalminimal escape sequences; the engine owns cursor discipline
⟳ ctx.spawn's stream items (and one queued straggler after a cancel) re-enter as messages at the top
03

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()))
}
one turn, frame by frame
why is sealing cheap?
live tail
04

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.

05

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.

the model: plain fields you own
streaming: bool false
results: Vec<Hit> len = 2
input: TextAreaState
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))
}
what tail(&self) renders
❯ previous turns live up here, committed
live tail
tail() calls: 1 · retained between frames: nothing · unchanged tails diff to 0 bytes
click into the input field; track_focus drives the cursor and the panel's border
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.

06

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)
}
focus: self.busy:
1 · on_overrideCtrl+C → Msg::Quit·
2 · in_scope(&input)Enter → Msg::Submit·
3 · on (global)Esc → Msg::Cancel·
4 · fallthrough(&input)everything unclaimed → Msg::Input(ev)·
press a key ↑first match wins, top tier first

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.

07

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.

a stream you can kill
tell me a very long story
live tail
→ Msg::Cancel → self.request = None
the model, live
request: Option<Task> = None
messages → update()
// 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())
}
08

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

unchanged frame
~780µs
with 10KB of live markdown in the tail; roughly 1% of a core at animation cadence
a keystroke
~790µs
rebuild, re-measure, re-render, diff, present. All of it.
tree construction
0.5µs
16 allocations for a realistic tail. View builders are not the place to optimize.
cost model
O(tail)
per-frame cost scales with the tail's content, never the conversation's. Seal early, stay small.

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.

64 queued chunks
──▶
one batch of updates
1 frame · ~1.7ms totalnot 64 frames
09

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.

10

Get started

The quick start builds a complete working app in about sixty lines. Every concept on this page appears once.

$ cargo add eye_declare

Read

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 own impl Element

eye-declare renders inline only, on purpose. If you want a full-screen, alternate-screen application, use Ratatui directly. eye-declare is built on Ratatui's primitives and hands you its Buffer and Style types, but it deliberately does not do full-screen layout.