A declarative inline TUI rendering library for Rust, built on Ratatui
See the codeInline terminal UIs for Rust, built on Ratatui.
Check out the interactive examples
Inline interfaces live in your terminal's normal flow, where finished output scrolls into native scrollback and only a small live region updates in place. Ideal for CLI tools, AI agents, and interactive prompts: programs where output accumulates and history should stay visible, exactly as if the program had printed it.

eye-declare models inline applications as a timeline: an append-only sequence of finished output with a small live region at the bottom.
Emitting a finished block is I/O, irreversible like println!, so it happens in your update function (ctx.push). The live tail is the only thing that behaves like a screen, so it's the only thing described by a view function — re-run every frame, pure, cheap because it's small.
If you've written Elm, iced, or Redux, the rest is familiar: your struct is the model, messages drive update, async work arrives as streams of messages, and cancellation is dropping a handle. There's no reconciliation, no keys, no dirty tracking, no framework-owned widget state, and no hidden focus registry, because a timeline needs none of them.
use eye_declare::{App, Ctx, Element, Fluent, Keymap, Task, col, key, keymap, markdown, panel, spinner, text_area};
struct Chat {
reply: String, // the streaming response
request: Option<Task>, // in-flight work; dropping it cancels
input: TextAreaState,
input_focus: FocusHandle,
}
impl App for Chat {
type Msg = Msg;
type Output = ();
fn update(&mut self, msg: Msg, ctx: &mut Ctx<'_, Self>) {
match msg {
Msg::Submit => {
let prompt = self.input.take_text();
// ctx.push() commits as permanent, unchangeable output:
ctx.push(user_turn(&prompt));
// async work is a stream of `Msg`s:
self.request = Some(ctx.spawn(stream(prompt)));
}
Msg::Chunk(delta) if self.request.is_some() => self.reply.push_str(&delta),
Msg::Done => {
self.request = None;
ctx.push(markdown(std::mem::take(&mut self.reply)));
}
// Simply drop the task to cancel async work:
Msg::Cancel => self.request = None,
_ => {}
}
}
fn tail(&self) -> impl Element + '_ {
col()
.when(!self.reply.is_empty(), |c| c.child(markdown(self.reply.clone())))
.when(self.request.is_some() && self.reply.is_empty(), |c| c.child(spinner("Thinking…")))
.child(panel(text_area(&self.input).track_focus(&self.input_focus)).title("Ask"))
}
fn keymap(&self) -> Keymap<Msg> {
let mut km = keymap().on(key(KeyCode::Esc), Msg::Cancel);
if self.request.is_none() && !self.input.is_blank() {
// Enter is only bound to Submit when no request is in progress
// and the input box is not blank.
km = km.on(key(KeyCode::Enter), Msg::Submit);
}
km.fallthrough(&self.input_focus, Msg::Input)
}
}
For a complete, runnable example of a streaming AI chat against the OpenRouter API in one commented file, see examples/openrouter.rs:
export OPENROUTER_API_KEY=sk-or-...
cargo run --release --example openrouter
col(), .when(…), iterators), with full rust-analyzer and cargo fmt support.Keymap rebuilt from the model each update makes conditional bindings simple one-liners, and makes mode conflicts impossible.ctx.spawn returns a Task; cancellation and replacement are field assignments.The eye-declare book covers everything: the introduction explains the design; the quick start builds a working app in sixty lines; guide chapters cover the timeline, elements, input, async, components-as-a-convention, testing, and performance. Migrating from 0.5? There's a dedicated chapter.
0.6.0 is a ground-up redesign of the library around the timeline architecture. The 0.5.x component-model API is frozen and will not receive further changes. Expect the 0.6 API to settle toward 1.0 as it accumulates mileage.
MIT
186 commits
Rust
99.8%
A declarative inline TUI rendering library for Rust, built on Ratatui
See the codeInline terminal UIs for Rust, built on Ratatui.
Check out the interactive examples
Inline interfaces live in your terminal's normal flow, where finished output scrolls into native scrollback and only a small live region updates in place. Ideal for CLI tools, AI agents, and interactive prompts: programs where output accumulates and history should stay visible, exactly as if the program had printed it.

eye-declare models inline applications as a timeline: an append-only sequence of finished output with a small live region at the bottom.
Emitting a finished block is I/O, irreversible like println!, so it happens in your update function (ctx.push). The live tail is the only thing that behaves like a screen, so it's the only thing described by a view function — re-run every frame, pure, cheap because it's small.
If you've written Elm, iced, or Redux, the rest is familiar: your struct is the model, messages drive update, async work arrives as streams of messages, and cancellation is dropping a handle. There's no reconciliation, no keys, no dirty tracking, no framework-owned widget state, and no hidden focus registry, because a timeline needs none of them.
use eye_declare::{App, Ctx, Element, Fluent, Keymap, Task, col, key, keymap, markdown, panel, spinner, text_area};
struct Chat {
reply: String, // the streaming response
request: Option<Task>, // in-flight work; dropping it cancels
input: TextAreaState,
input_focus: FocusHandle,
}
impl App for Chat {
type Msg = Msg;
type Output = ();
fn update(&mut self, msg: Msg, ctx: &mut Ctx<'_, Self>) {
match msg {
Msg::Submit => {
let prompt = self.input.take_text();
// ctx.push() commits as permanent, unchangeable output:
ctx.push(user_turn(&prompt));
// async work is a stream of `Msg`s:
self.request = Some(ctx.spawn(stream(prompt)));
}
Msg::Chunk(delta) if self.request.is_some() => self.reply.push_str(&delta),
Msg::Done => {
self.request = None;
ctx.push(markdown(std::mem::take(&mut self.reply)));
}
// Simply drop the task to cancel async work:
Msg::Cancel => self.request = None,
_ => {}
}
}
fn tail(&self) -> impl Element + '_ {
col()
.when(!self.reply.is_empty(), |c| c.child(markdown(self.reply.clone())))
.when(self.request.is_some() && self.reply.is_empty(), |c| c.child(spinner("Thinking…")))
.child(panel(text_area(&self.input).track_focus(&self.input_focus)).title("Ask"))
}
fn keymap(&self) -> Keymap<Msg> {
let mut km = keymap().on(key(KeyCode::Esc), Msg::Cancel);
if self.request.is_none() && !self.input.is_blank() {
// Enter is only bound to Submit when no request is in progress
// and the input box is not blank.
km = km.on(key(KeyCode::Enter), Msg::Submit);
}
km.fallthrough(&self.input_focus, Msg::Input)
}
}
For a complete, runnable example of a streaming AI chat against the OpenRouter API in one commented file, see examples/openrouter.rs:
export OPENROUTER_API_KEY=sk-or-...
cargo run --release --example openrouter
col(), .when(…), iterators), with full rust-analyzer and cargo fmt support.Keymap rebuilt from the model each update makes conditional bindings simple one-liners, and makes mode conflicts impossible.ctx.spawn returns a Task; cancellation and replacement are field assignments.The eye-declare book covers everything: the introduction explains the design; the quick start builds a working app in sixty lines; guide chapters cover the timeline, elements, input, async, components-as-a-convention, testing, and performance. Migrating from 0.5? There's a dedicated chapter.
0.6.0 is a ground-up redesign of the library around the timeline architecture. The 0.5.x component-model API is frozen and will not receive further changes. Expect the 0.6 API to settle toward 1.0 as it accumulates mileage.
MIT
186 commits
Rust
99.8%