rust · tui

The subtlest component in Ink

Jun 8, 2026 · 12 min read

Run a test suite — jest, cargo test, whatever your CI shows you — and watch the terminal. Finished test files print as lines that scroll up, join the scrollback, and never change again. At the bottom, a small region repaints continuously: the progress bar, the file currently running, the counters. Package managers, build tools, CI runners — same screen, same split. It looks like two programs sharing one screen: a logger writing history, and an animation living in the present.

In Ink, it's one component. <Static> renders a list of items that get printed once, permanently, above everything else, while the rest of the tree keeps repainting below. The test-runner shape, in one node. And it is, I'd argue, the subtlest thing in Ink's API, because its one-line description — "print each item exactly once" — hides an awkward negotiation with the rendering model underneath. This essay reads the Rust port of that negotiation: the inkrs-runtime crate in inkrs.

Why you can't just println!

The obvious question first. If you want a line printed once, forever, the terminal already has an operation for that. It's called printing. Why does this need a component?

Because you no longer own stdout. The previous essay took apart the incremental renderer in log-update: it keeps the previous frame as previous_lines, navigates by dead reckoning — rewind the cursor up by the old frame's height, diff line by line, rewrite only what changed — and never asks the terminal anything. That whole design rested on one premise, stated in bold at the time: you are the only writer. The model is trustworthy because every byte that ever moved the cursor came out of the renderer's own buffer.

A raw println! breaks exactly that premise. Your line lands wherever the cursor happens to be — in the middle of the live frame — and pushes everything below it down by a row the renderer's ledger knows nothing about. The next repaint rewinds by the old height, erases rows that are no longer where it thinks they are, and skips rows it wrongly believes are unchanged. Your log line gets shredded by the next diff; the frame smears down the screen one phantom row at a time. Inside a diffing renderer there is no innocent print.

So append-only output can't go around the renderer. It has to go through it — as a first-class concept the bookkeeping knows about.

The split: commit above, diff below

The module docs of inkrs-runtime/src/lib.rs define the concept in one sentence: the output of past <Static> items is "committed" — plain-appended to stdout — while the dynamic frame below, the live region from the opening image, is repainted through log-update's erase-and-rewrite cycle. Every tick, the freshly built element tree is split three ways:

  • Committed static items — already emitted in a previous tick. Never touched again.
  • Pending static items — new since the last tick. Printed once, this tick.
  • Dynamic remainder — everything else, repainted every tick.

The emission path in emit_current_tree makes the ordering concrete. The code is as it stands in the repo, with its Dutch comments put into English:

let (dynamic_tree, pending) = split_static(tree, self.committed_static_count);

if !pending.is_empty() {
    // Plain-append each new static item, separated and terminated by
    // newlines. Matches upstream's "print the committed block, then
    // bump committed count".
    let opts = self.render_options();
    let mut buf = String::new();
    for item in &pending {
        let rendered = render_to_string_with_options(item, opts);
        buf.push_str(&rendered);
        if !rendered.ends_with('\n') {
            buf.push('\n');
        }
    }
    // Clear the running dynamic frame FIRST, only then write the
    // static lines. Mirror of upstream Ink (ink.tsx):
    //   this.log.clear(); stdout.write(staticOutput); this.log(output);
    self.log_update.clear();
    self.stream.write(&buf);
    self.committed_static_count += pending.len();
}

Then the dynamic remainder goes through the incremental renderer as usual. So a tick that commits something costs three moves: erase the live frame, append the new static lines where the frame used to start, repaint the frame below them. The frame migrates down the screen every time history grows above it, which is exactly what your eye sees in a test runner, and nobody ever thinks about.

This path isn't reserved for later ticks, either. Instance::new runs an initial emit immediately — its doc comment says it "mirrors upstream Ink's render(<App />) which paints the first frame immediately" — so items sitting in a <Static> at mount commit in the very first write. The unit test for that, instance_static_appends_committed_items, is worth reading because it checks the shape of the writes, not just their content:

let inst = render(|| {
    inkrs_rsx! {
        fragment {
            static { text { "A" } }
            text { "Dyn" }
        }
    }
});
let writes = inst.captured_writes();
// The committed static write should come first (a plain append, no
// log-update prefix) ...
let first = &writes[0];
assert!(first.contains('A'), "first write should be the static append");
assert!(first.ends_with('\n'), "static append should end with a newline");

The first write must be the static item, it must be a plain append — none of the cursor-movement escapes that prefix log-update's output — and it must end with a newline, because the dynamic frame is about to be painted directly below it and needs to start on a fresh row. Committed output doesn't merely happen to precede the frame; the byte order on the stream is the specification.

The clear() before the append is the part you'd get wrong on the first try, and the comment right below it (translated) explains what happens if you do: without the clear, the old frame is still on the screen and log-update still believes its previous frame is there. The static lines get inserted in the wrong place, the subsequent erase arithmetic wipes the wrong rows — "hence the 'sticking' box borders in the todo example instead of the added items." That failure is pinned by a regression test, instance_static_append_clears_dynamic_frame_first, which asserts that in the writes following a commit, the erase sequence \x1b[2K appears before the new static text. Order of bytes on a pipe, promoted to a test invariant.

Two supporting details show how deliberately the two output paths are kept apart. First, plumbing: LogUpdate owns its stream by value, but the static append has to bypass the incremental renderer and write the same sink, so the runtime wraps the stream in a SharedStream, an Rc<RefCell<S>> where the instance and the LogUpdate both forward writes to the same underlying sink. Second, policy: the synchronized-update markers from the previous essay (BSU/ESU, DEC mode 2026) bracket only dynamic-frame writes, never the static append — in the words of the comment, "committed items are emit-once and don't flicker, so the extra escapes would only add noise." The commit path isn't a special case of the repaint path. It's a different kind of write, and every layer treats it as one. (It even flips a flag from last time: on the alt-screen, a static append shifts the frame's origin downward, so the renderer switches from absolute homing back to relative rewinds.)

Commit semantics: a counter, not keys

How does the runtime know which items are new? If you've internalized React, you expect keys, reconciliation, identity tracking. The answer is much blunter. From the module docs:

Implementation: we track a single committed_static_count: usize on the instance. On each tick we walk the tree to find the first <Static> node (depth-first, document order). Its children.len() minus committed_static_count gives the slice of items to commit.

The mechanism is one integer. Identity is positional. The walker's <Static> arm shows there is nothing more hiding underneath:

InkElement::Static { children } => {
    if !*found {
        *found = true;
        if children.len() > already_committed {
            pending.extend(children.into_iter().skip(already_committed));
        }
    }
    // Either way, drop this Static from the dynamic frame.
    InkElement::Fragment { children: vec![] }
}

skip(already_committed), take the rest, and replace the <Static> subtree with an empty Fragment so the dynamic frame never repaints content that already lives in the scrollback. The tests state the resulting contract from the outside. instance_static_grows_on_state_change renders a <static> block over a shared vec containing "A", asserts one committed item, grows the vec with "B" and "C", ticks, and then checks that the new writes contain B and C but not a fresh A — already committed, never re-emitted — and that committed_static_count() now reads 3. instance_multiple_ticks_preserve_committed_static ticks an unchanged tree three more times and counts occurrences of the committed text across every write ever made: exactly one.

The neighbouring tests fence the mechanism in from both sides. instance_without_static_node renders a tree with no <static> at all and asserts the counter stays at zero — the split is pay-for-what-you-use, and a program that never commits anything is just the plain diffing renderer from last time. And instance_static_with_box_wrapper_in_column nests the <static> block inside a column box next to dynamic text, then asserts the first write is still the static append, with the items in document order, still newline-terminated. The accumulator doesn't need to be the root's first child; the walker finds it wherever the layout put it, and the commit path looks identical from the stream's point of view.

A counter instead of keys buys simplicity and pays in assumptions, and the module docs are explicit about the bill:

There is no diffing between successive renders of the same <Static> node — we assume the children vec only grows. If the prefix changes (e.g. items get removed or reordered), the committed output stays as it was — there's no way to un-print to stdout.

That last clause is the entire component in eight words. Committed output isn't state, it's history — it lives in the terminal's scrollback, a data structure your program can append to but never edit. A keyed diff would be pointless sophistication: detecting that item 3 was removed gains you nothing when item 3 is already ink on paper. The counter is the honest data structure for a write-once medium, not a shortcut around proper reconciliation.

static state outlives the call that touched it successive calls to one function — time runs left to right → call 1 locals born & die call 2 locals born & die call 3 locals born & die n += 1 n += 1 n += 1 static counter / buffer — one instance for the whole program n = 1 n = 2 n = 3 lives only during the call outlives every call
The accumulator's shape: each tick is a transient call whose locals come and go, but a single persistent counter survives between them, growing 1 → 2 → 3 as committed items are appended once and never revisited.

First Static is the accumulator

Which leaves the question the section title spoils: what if the tree contains two <Static> nodes? There is one counter and it isn't keyed to anything, so it can serve one accumulator. The README names the rule when it lists upstream behaviour that was deliberately not ported — "a handful of <Static> corner cases that depend on React's useLayoutEffect ordering" — and points at the module docs for, quote, "the 'first Static is the accumulator' rule we implement." The module docs' formulation:

Only the first <Static> encountered in document order is tracked as an accumulator.

And the walker's doc comment finishes the thought for every other <Static> it meets: still replace it with an empty Fragment "so we don't repaint stale content, but DON'T emit pending items — only one Static is accumulator-tracked." Look back at the code above and you can see it's two lines doing the work: the found flag gates the pending.extend, but the excision happens either way. A second <Static>'s items never reach the dynamic frame and never reach the commit path. They simply don't print.

Is that a good behaviour? On its own, no — it's a limitation. But compare it to the alternatives. Upstream Ink only supports one <Static> per render tree anyway; its corner cases around mounting order lean on React's useLayoutEffect timing, machinery this port doesn't have and — per the README — chose not to fake. The remaining design space is: share the one counter between two accumulators and let their items interleave and double-count nondeterministically, or pick one winner by a boring, stable rule (document order), neutralize the rest deterministically, and write the rule down in the module docs where the next person will actually find it. A limitation you can cite is a feature of the documentation; a limitation you discover by debugging is a bug with an alibi.

In practice: a todo list

The todo example in the inkrs repo (examples/src/bin/todo.rs) is the whole pattern in one file. A shared vec holds committed todos; a draft string lives below. The input handler's Enter branch is where an item crosses the line from state to history:

if key.return_key {
    // Commit the draft as a new todo. Skip empty drafts
    // so accidental Enters don't bloat the list.
    let mut d = draft_for_input.lock().unwrap();
    let trimmed = d.trim().to_string();
    if !trimmed.is_empty() {
        todos_for_input.lock().unwrap().push(trimmed);
        d.clear();
    }
    return;
}

Note what it doesn't do: no printing, no talking to the renderer. It pushes onto a vec. The view is just a declaration of the split:

fragment {
    // The static accumulator: every entry committed here is
    // plain-appended to stdout once and never repainted.
    static {
        for item in items.iter() {
            text { "\u{2022} {item}" }
        }
    }
    // Dynamic prompt — repainted on every tick.
    box {
        border_style: "single",
        text { "> {shown}_" }
    }
}

The main loop is four lines, and none of them mention <Static> either:

let stdin = StdinChannel::spawn();
while !inst.is_exited() {
    inst.pump_stdin(&stdin);
    std::thread::sleep(Duration::from_millis(16));
}
inst.unmount();

Pump input, sleep sixteen milliseconds, repeat. When Enter lands, the handler pushes onto the vec; on the resulting re-render the runtime rebuilds the tree, finds the <static> block one child longer than committed_static_count, and runs the sequence from earlier: clear the prompt box, append • buy milk, repaint the box one row lower. The example's own comment states the payoff plainly: the runtime "only appends the new suffix, so this can grow unboundedly without re-printing old lines." A session that commits ten thousand todos costs the same per tick as one that committed none — history is free, because history is never revisited.

That unmount() at the end is where the split pays out one last time. Its doc comment: erase the dynamic frame and stop accepting further ticks — but "committed static output is not retracted — it has already left the building." Quit the app and the prompt box vanishes, exactly as a live region should; the committed todos stay in your scrollback, exactly as a log should. One caveat travels with the alt-screen, and the docs are upfront about it: committed output emitted while on the alternate buffer stays on the alternate buffer, and "terminals discard it on the switch back, just like upstream." Append-only history only outlives the program on the primary screen, because scrollback is the primary screen's feature — the component gives you a durable log precisely where durability exists, and can't conjure it where it doesn't.

Append-only is a contract

"Print each item exactly once" sounds like the simplest possible spec — until the printer is a renderer whose core loop exists to erase and rewrite. Then it decomposes into everything above: a second write path that bypasses the diff but shares its sink, a clear-append-repaint ordering pinned by a regression test, a counter that encodes the physics of scrollback, and a document-order rule for who gets to accumulate.

Which is to say: append-only inside a diffing renderer isn't an operation, it's a contract. The runtime promises each item hits stdout once — one counter, bumped after the write, checked by tests that literally count occurrences. You promise the list only grows — because past the write there is no diff, no reconciliation, no undo; there's no way to un-print to stdout. What <Static> manages is the boundary every terminal program lives on: the line above which output stops being your program's state and becomes the terminal's history.


Thoughts? Find me on LinkedIn.