Knopper
A functional-reactive terminal UI framework with a machine-centered programming model — designed multi-user from the ground up.
Knopper is a Rust framework for building terminal user interfaces as machines:
self-contained units that project a reactive Scene from local Model state and
replicated Shared state. A terminal-native scene algebra composes layout; a
single rendering pipeline lowers scenes to backend commands with structural
diffing. The Notcurses backend (behind a feature flag) targets high-performance
terminal rendering.
Knopper is the UI substrate for Industrial Algebra's collaborative tools — Wallace, Dominic, and Tsume compose and render multi-participant sessions through it.
Why Knopper?
- Machine-first, not widget-first. You implement one trait —
Machine— with four associated types (Context,Msg,Model,Shared) and four functions (init,update,project,cursor_position). There is no widget tree to manage; the scene is a pure projection of state. - The Model/Shared split is the collaboration seam.
Modelis participant-local (focus, scroll, input buffers — never synced).Sharedis host-pushed projection state (rosters, transcripts, task boards). Multi-user support isn't bolted on; it's the type system. - Principled focus. Focus order derives from the scene, traverses with scope
policies (
Wrap/Trap/Local/Passthrough), and modals trap focus declaratively — no imperative key routing. - Embeddable by contract. Knopper owns no event loop and no process. A host
drives
Runtimeturn-by-turn:dispatchkeys,sendmessages,set_sharedprojections, commitdiffs. The embedding contract is validated against the code (see Embedding Contract).
A taste
#![allow(unused)] fn main() { use knopper::{Effect, PureMachine, Runtime, Scene}; let machine = PureMachine::new( |_ctx: &()| 0_i32, // init |model: &mut i32, msg: bool, _ctx: &()| { // update if msg { *model += 1; } Effect::None }, |model: &i32, _shared: &(), _ctx: &()| { // project Scene::text(1_u64, format!("count: {model}")) .on_activate(true) }, ); let mut runtime = Runtime::new(machine, (), ()); // Host drives turn-by-turn: dispatch events, read diffs, commit to a backend. }
Status
0.1.0 is the first experimental release: coherent, tested (130 tests), and
clippy-clean — but unstable. Expect refinement in 0.2.0, especially around the
collaboration seam and demo organization. See the
roadmap.
Getting Started
Knopper is a Rust library. Add it to your project:
[dependencies]
knopper = "0.1"
The default build is backend-agnostic — you get the full machine/scene/runtime model plus the mock backend used for tests. For the Notcurses terminal backend:
[dependencies]
knopper = { version = "0.1", features = ["notcurses"] }
System dependency. The
notcursesfeature links the system notcurses library and requires notcurses ≥ 3.0.11 (libnotcurses-devon Debian/Ubuntu 26.04+, or your distro's equivalent). The Rustvendoredfeature is a docs-only path and does not produce a linkable library.
Your first machine
#![allow(unused)] fn main() { use knopper::{Effect, PureMachine, Runtime, RuntimeEvent, Scene, Rect}; #[derive(Clone)] enum Msg { Bump } let machine = PureMachine::new( |_ctx: &()| 0_i32, |model: &mut i32, msg: Msg, _ctx: &()| match msg { Msg::Bump => { *model += 1; Effect::None } }, |model: &i32, _shared: &(), _ctx: &()| { Scene::text(1_u64, format!("bumps: {model}")).on_activate(Msg::Bump) }, ); let mut runtime = Runtime::new(machine, (), ()); let bounds = Rect::new(0, 0, 80, 24); // One host turn: send a message, read the paint, commit it. runtime.send(Msg::Bump); let patches = runtime.diff(bounds); // structural patch vs. last commit let mut backend = knopper::MockBackend::default(); runtime.render_to_backend(&mut backend, bounds).unwrap(); }
From here:
- Writing a Machine — the full trait and state lanes
- Composing Machines — building screens from standard machines
- Running the Demos — two runnable reference applications
- Embedding Knopper — driving
Runtimefrom your own event loop (the Wallace pattern)
Development setup
git clone https://github.com/Industrial-Algebra/Knopper.git
cd Knopper
./scripts/setup-hooks.sh
cargo test # default features — the library contract
cargo test --features notcurses # needs system notcurses ≥ 3.0.11
The Machine Model
Everything in Knopper is a machine. A machine is the complete specification of a UI unit: what state it holds, what messages it understands, and what scene it projects.
#![allow(unused)] fn main() { pub trait Machine { type Context; // app-level, relatively static (identity, theme, policy) type Msg; // messages the machine understands type Model; // participant-local UI state type Shared; // host-pushed projection state fn init(&self, ctx: &Self::Context) -> Self::Model; fn update( &self, model: &mut Self::Model, msg: Self::Msg, ctx: &Self::Context, ) -> Effect<Self::Msg>; fn project( &self, model: Behavior<Self::Model>, shared: Behavior<Self::Shared>, ctx: &Self::Context, ) -> SceneBehavior<Self::Msg>; fn cursor_position(...) -> Option<(u16, u16)> { None } // default } }
The four type lanes
| Lane | Owner | Examples |
|---|---|---|
Context | Host, pushed via set_context | participant identity, theme, capabilities |
Msg | Host-defined per machine | Submit, ScrollDown, ChunkReceived |
Model | The machine (participant-local) | focus, scroll offset, input buffer |
Shared | Host projections, pushed via set_shared | transcript, roster, task board |
Model and Shared must implement IntoGeometric / FromGeometric
(re-exported from knopper) — the reactivity fingerprint used by the underlying
cliffy-core Behaviors. Hash-based encodings suffice; the native value is
cached and the geometric form is only a change fingerprint.
init / update / project
init(ctx) -> Model— initial participant-local state.update(&mut Model, Msg, &Context) -> Effect<Msg>— the only placeModelchanges. Returns anEffect, which the runtime drains synchronously.project(Behavior<Model>, Behavior<Shared>, &Context) -> SceneBehavior<Msg>— the scene as a reactive function of state. The runtime re-samples the projection wheneverModelorSharedchanges; there is no manual invalidation.
PureMachine
For machines expressible as three closures, PureMachine::new(init, update, view)
(where view is the one-shot form of project) is the ergonomic path:
#![allow(unused)] fn main() { let machine = PureMachine::new( |_ctx: &MyCtx| MyModel::default(), |model, msg: MyMsg, ctx| { /* ... */ Effect::None }, |model: &MyModel, shared: &MyShared, ctx: &MyCtx| { Scene::column(1_u64, vec![ /* ... */ ]) }, ); }
Custom Machine impls remain available for machines that hold resources or
compose child machines with their own runtimes of state.
Where to next
- Scene Algebra — what
projectreturns - Collaboration: Shared and Model — why the split exists
- Writing a Machine — step-by-step guide
Scene Algebra
A Scene<Msg> is a declarative tree describing layout, content, interaction,
and focus structure. Machines project scenes; the runtime lays them out,
lowers them to render ops, and diffs them against the previous frame.
Builders
#![allow(unused)] fn main() { Scene::text(id, "content") Scene::row(id, children) // horizontal flow Scene::column(id, children) // vertical flow Scene::stack(id, children) // same cell, back-to-front Scene::overlay(id, children) // floating above the base scene Scene::padding(id, padding, child) Scene::border(id, child) Scene::sized(id, constraint, child) Scene::viewport(id, child) // clip to bounds Scene::scroll(id, offset, child) Scene::align(id, anchor, child) Scene::annotated(id, label, child) Scene::focus_scope(id, name, child) Scene::focus_scope_with_policy(id, name, policy, child) }
Every node takes a stable NodeId. Stable ids are load-bearing: the
renderer diffs by id, so an append that adds a new id produces an insert patch
without touching existing nodes (see
Streaming-Append Performance).
Modifiers
#![allow(unused)] fn main() { scene .with_style(style) // fg/bg/emphasis .with_role(Role::Button) // semantic role .focusable() // participates in Tab order .disabled() // skipped by focus + activation .on_activate(msg) // Enter/click sends msg .map_msg(&f) // translate child messages into parent space }
map_msg is the composition primitive: a child machine's Msg type maps into
the parent's, so screens compose without a global message enum.
Annotations and presence
Annotation carries out-of-band render metadata — including
PresenceSlot and RemoteCursor, the hooks for multi-participant presence
overlays. Annotations travel with the scene but don't affect layout; backends
render them as overlays.
Focus scopes
focus_scope declares a named region with a policy:
Wrap— Tab cycles within the scopeTrap— Tab clamps at scope boundaries (modal semantics: focus cannot leave)Local— order is local to the scope, no cyclingPassthrough— scope is transparent to traversal
See The Focus Model for how these interact with runtime Tab handling.
The Focus Model
Focus in Knopper is scene-derived, scope-aware, and participant-local.
Scene-derived order
The focus order is collected by walking the projected scene: nodes marked
.focusable() enter the order in scene position. There is no separately
maintained focus list — change the scene and the focus order follows.
.disabled() nodes are skipped by both focus collection and activation
routing, so disabling a control is a presentation-free state change.
Runtime-owned Tab
Tab / Shift-Tab are owned by the runtime's declarative dispatch, not by individual machines. The dispatch precedence is:
- Global shortcuts (host/machine-declared)
- Declarative Tab traversal across the scene-derived focus order
- The focused machine's
key_msghandler - Declarative fallback (activation, focus events)
Machines opt into raw keys via key_msg; everything else flows through the
declarative path. The full policy is specified in the repo at
docs/guides/08-runtime-event-policy.md.
Scope policies
Focus scopes (Scene::focus_scope_with_policy) control how Tab behaves at
boundaries:
| Policy | Tab at last node | Use |
|---|---|---|
Wrap | Cycles to the scope's first node | toolbars, tab bars |
Trap | Clamps — focus stays put | modals, dialogs |
Local | Stops; no cycling | grouped controls |
Passthrough | Scope is transparent | layout-only regions |
Trap clamps; it never wraps. A modal is a Trap scope plus an Escape
message — the declarative policy does the containment, so modal
implementations need no imperative Tab handling.
Participant-local by design
Each Runtime owns its own FocusState. In a multi-participant session every
participant has an independent focus — two runtimes over the same Shared
projection diverge in focus without cross-talk. Focus is never pushed through
Shared and there is no "global focus" concept. Remote participants' cursors
and selections appear as Annotation overlays, not focus.
Effect::RequestFocus(NodeId) lets a machine request focus movement; the
runtime applies it through the same dispatch path as user input.
Collaboration: Shared and Model
Knopper's multi-user story is a type-level discipline, not a subsystem. The
Machine trait's Shared / Model split is the collaboration seam.
The split
Shared— projection state a machine projects from. The host computes projections (roster, transcript, task board, artifact index) from its own session state and pushes them whole viaRuntime::set_shared. Multiple machines may consume slices of the same projection.Model— participant-local UI state (focus, scroll, selection, input buffer). Machine-internal; never host-pushed, never synced wholesale.
One Runtime per participant. Convergence across participants is the host's
problem (a CRDT or event-sourced core); Knopper receives already-merged
projections. This is deliberate: Knopper never sees a terminal byte stream and
never owns a network protocol.
Canonical payload types
knopper::collaboration provides the vocabulary downstream apps share:
ParticipantId— stable participant identityPresence/PresenceTone— presence state and its render toneParticipantRoster— the canonicalShared-resident roster
Remote cursors and selections render as Annotation::PresenceSlot /
Annotation::RemoteCursor overlays — they annotate the scene without
entering layout or focus.
What this buys a host
- Focus stays honest. Because focus is participant-local from day one, adding a second participant never invalidates the first's assumptions.
- Machines are testable single-user.
Sharedstarts as()or a static projection; collaboration arrives by pushing real projections later. - The seam can't be painted shut. Standard machines keep selection/commit
in
Model, so they're multi-user-safe by construction.
Roadmap
The 0.1.0 seam is whole-projection push. Designed and mapped for 0.2.0:
CRDT-backed incremental Shared updates and the Schubert capability
integration (capability-gated actions per participant). See
Collaboration-Ready Contract.
The Geometric Substrate
Every machine state in Knopper carries a second form: alongside the typed
value (ButtonState, InputState, a whole ParticipantRoster) lives a
GA3 multivector — an element of the 3D Euclidean geometric algebra
Cl(3,0). This chapter explains what that form is for, what it promises,
and what it deliberately does not promise.
The normative reference is
docs/design/geometric-encoding-contract.md
in the repository; the geometric module's blade constants
(SCALAR..E123) and Digest are its Rust surface.
Why state has a geometric form
The multivector is not storage. The typed state remains the single source of truth. The geometric lane exists because three capabilities need state in a form where algebraic operations are meaningful:
- Sound change detection. The runtime substrate (cliffy
Behavior) writes the multivector on every update. A change-gated notification — fire subscribers only when the geometry actually moved — is only correct if distinct states map to distinct multivectors. That is the contract's core demand: encodings are injective as discriminants, never lossy constants. - Merge semantics. Collaboration types encode so that combination is addition (below).
- Fingerprint derivation. Downstream systems (reactive cells, Borsalino offload) derive fingerprints from these encodings as structured projections — compositional and documented, not hash garbage.
The blade ledger
GA3 has 8 blades. Knopper assigns each a role; every non-zero coefficient in every encoding must be attributable to a named field:
| Blade | Role |
|---|---|
1 (scalar) | identity / count |
e1, e2, e3 | primary state axes; presence tones |
e12, e13, e23 | pairwise slots: markers, digest words |
e123 | aggregate / parity |
Two classes of encoding
- Class A — exact.
from_geometricinvertsinto_geometricon the nose. Required wherever the fields fit the ledger:ButtonState(scalar = activation count),ToggleState,ListState(selected + scroll),TabsState(with a marker blade distinguishingSome(0)fromNone). - Class B — structured discriminant. Unbounded fields (strings,
vecs, child states) cannot invert, so they pack multiple structured
components — lengths, exact scalars, and the versioned
Digest(SHA-256 into two 26-bit integer words) — into named blades.InputState's text lives as a digest word pair plus cursor/length blades; aggregates likeCommandPaletteStatedigest their children's encodings, so a child encoding change automatically updates every aggregate above it.
The contract's honesty clause: Class B is permitted only where
Class A is impossible, and from_geometric returns Default — the
typed cache is the reconstruction path, never the multivector.
The collaboration lane: addition is the merge
The types that participate in distributed merging encode so the algebra carries the semantics:
PresenceToneoccupies its own basis blade —Local→ e1,Collaborator→ e2,Passive→ e3 — as a unit coefficient.- A
Presenceis: one participant (scalar = 1), its tone blade, and digest words for(id, anchor). Labels are excluded by design: presentation is not merge semantics, so relabeling never moves the geometry. ParticipantRosteris the multivector sum over its participants.
Because tones occupy disjoint blades, the sum cannot cancel or collide:
the scalar lands the participant count, and the tone blades carry an
exact tone census. Summation of exact integers is order-independent,
so the merged view is correct regardless of arrival order — the roster
merge is literally +.
Geometry that reads: the presence annotation
The substrate would be ceremony if geometry were only ever written. Knopper's reader path runs end to end in 0.1.0:
use knopper::collaboration::{presence_annotation, tone_census};
let mv = roster.into_geometric(); // the sum
let census = tone_census(&mv); // exact counts off the blades
let text = presence_annotation(&mv); // "you · 2 collaborators · 1 passive"
The annotation text feeds the existing Annotation::PresenceSlot
surface — display-meaningful data derived from the multivector alone,
never from the typed roster. Tests prove it changes when tone mix or
count changes and stays put when only labels do.
What GA3 is not
GA3 is 8 floats: it is the fingerprint layer, not the place for
higher-grade mathematics. Grassmannian structure — Schubert cells,
grade-rich meet/join, capability arithmetic — exceeds GA3 by design and
lives in Schubert/amari territory. The boundary is concrete: the
capability seam (collaboration feature) consumes collaboration
identity at its edge and runs its own algebra; it never pushes results
back into GA3. See Capability Gating.
Writing a Machine
A step-by-step walk from empty type to working machine.
1. Name your four types
#![allow(unused)] fn main() { #[derive(Clone)] struct Ctx { user: String } // Context — pushed via set_context #[derive(Clone)] enum Msg { Submit, Cancel } // Msg — host/machine vocabulary #[derive(Clone, Default)] struct Form { buffer: String } // Model — participant-local type Shared = (); // Shared — host projection (none yet) }
Model and Shared need IntoGeometric/FromGeometric (re-exported from
knopper). Numbers, bool, String, (), tuples, and Option implement it
out of the box; for structs, a hash-based impl is fine — the native value is
cached and the geometric form is only a change fingerprint.
2. init — initial local state
#![allow(unused)] fn main() { |ctx: &Ctx| Form::default() }
3. update — the only place Model changes
#![allow(unused)] fn main() { |model: &mut Form, msg: Msg, _ctx: &Ctx| match msg { Msg::Submit => { model.buffer.clear(); Effect::None } Msg::Cancel => { model.buffer.clear(); Effect::None } } }
Effects are for control flow, not orchestration: Effect::Emit(msg) chains a
follow-up message synchronously, Effect::Batch applies several,
Effect::RequestFocus(id) moves focus. The runtime drains them completely —
see Runtime.
4. project — the scene as a function of state
#![allow(unused)] fn main() { |model: &Form, _shared: &(), ctx: &Ctx| { Scene::column(1_u64, vec![ Scene::text(2_u64, format!("hello, {}", ctx.user)), Scene::text(3_u64, model.buffer.clone()), Scene::text(4_u64, "[ submit ]") .focusable() .on_activate(Msg::Submit), ]) } }
Use stable ids; the diff is id-keyed. For PureMachine this closure is the
view argument; custom Machine impls get the reactive project form with
Behavior arguments.
5. Drive it
#![allow(unused)] fn main() { let mut runtime = Runtime::new(machine, Ctx { user: "ada".into() }, ()); runtime.send(Msg::Submit); let mut backend = knopper::MockBackend::default(); runtime.render_to_backend(&mut backend, Rect::new(0, 0, 80, 24)).unwrap(); }
Testing
Machines are plain values — test update directly, and test the runtime path
against MockBackend. tests/embedding_harness.rs in the repo is a complete
worked example of host-side machine definitions and assertions.
Composing Machines
Screens are built by composing standard machines inside a parent machine, using
the helpers in knopper::compose.
The composition vocabulary
update_child— route a parent'sMsgvariant into a child machine'supdate, mapping the child'sEffectback into parent spaceproject_child— embed a child's projected scene in the parent's scene, mapping messages withmap_msgmap_effect— lift anEffect<ChildMsg>toEffect<ParentMsg>child_has_focus— is focus anywhere under this child's scene root?dispatch_if_focused— forward a key to a child only when it holds focustrap_focus— keep focus inside a scope (modal support)
Pattern
#![allow(unused)] fn main() { // Parent Model holds child states; parent Msg wraps child msgs. enum Msg { Palette(PaletteMsg), List(ListMsg) } // update: route by variant Msg::Palette(inner) => update_child(&mut model.palette, inner, &ctx.palette), // project: embed with map_msg let palette_scene = project_child(&palette_machine, &model.palette, &shared, &ctx.palette) .map_msg(&Msg::Palette); }
Each standard machine exports its *Context / *Msg / *State types and a
*_key_msg handler for its raw-key behavior — see
Standard Machines.
Focus across composition
Child scenes participate in the parent's scene-derived focus order. Wrap a
child subtree in Scene::focus_scope to give it its own traversal policy —
that's how the demo's modal palette traps focus without imperative key
routing (see Focus, Modals, and Disabled Nodes).
Reference
docs/guides/07-reusable-composition-patterns.md in the repo catalogs the
extracted patterns with worked code from the demo applications.
Layout and Rendering
The pipeline from scene to screen, and what it costs.
The pipeline
Scene<Msg>
→ resolve_layout(scene, bounds) LayoutNode tree (rects per node)
→ render_ops(layout) Vec<RenderOp> (DrawText / DrawBorder / Annotate / SetCursor)
→ diff_render_ops(prev, next) Vec<PatchOp> (Insert / Update / Remove, id-keyed)
→ backend_commands(prev, patches) Vec<BackendCommand>
→ backend.execute(&commands) commit
Every stage is pure and separately testable. Runtime exposes each rung:
layout(bounds), render_ops(bounds), diff(bounds), and the committing
render* paths.
Layout
resolve_layout walks the scene under a Rect budget. Columns/rows split
space, sized constrains, viewport clips, scroll offsets, align anchors.
Layout is a pure function of (scene, bounds) — resize events re-run it from
the same scene.
Diffing
Patches are keyed by NodeId. Unchanged ids produce no patch; new ids insert;
changed content updates; vanished ids remove. Stable ids are what make
frames cheap — derive them from data identity (row index, item key), never
from render order.
Runtime::diff(bounds) is read-only: it compares against the last
committed frame but doesn't advance it. The committing paths
(render, render_to_backend*) advance the baseline. A host that reads
diff() twice without committing sees the same cumulative patch set twice.
Cost profile (measured)
Per-commit work is O(scene size) through layout/render, and the diff is currently O(n²) in op count (quadratic at thousands of ops — measured in the Tier 0 validation). Sub-millisecond at hundreds of nodes; an append-optimized diff is scheduled for 0.1.x. Project the visible window (viewport-bounded scenes) for large datasets.
Backends
MockBackend— in-memory, records commands; the test backendNotcursesBackend— real terminal rendering behind--features notcurses(system notcurses ≥ 3.0.11)
See Backends for the trait.
Runtime Event Policy
How an event becomes a message: the dispatch precedence the runtime applies.
Precedence
- Global shortcuts — host/machine-declared global key handling
- Declarative Tab traversal — Tab / Shift-Tab move focus across the
scene-derived order, honoring scope policies (
Wrap/Trap/Local/Passthrough) - Machine
key_msg— the focused machine's raw-key handler gets a shot - Declarative fallback — activation (Enter on an
on_activatenode), focus events, ignore
The policy's design rule: common behavior is declarative; machines opt into raw keys deliberately. Tab is not delivered to machines as a raw key unless no declarative traversal applies — this is what makes modal trapping a scene declaration instead of per-modal key filtering.
RuntimeEvent
#![allow(unused)] fn main() { pub enum RuntimeEvent { Key(KeyEvent), // key + ctrl/alt/shift Resize(ResizeEvent), Tick, Activate(NodeId), Focus(NodeId), Blur(NodeId), Sync(String), } }
dispatch(event) routes through the policy and, when routing yields a machine
message, applies it through update exactly like send(msg). Focus-only
outcomes update FocusState without touching Model.
Effects ride the same path
Effect::Emit re-enters update synchronously; Effect::RequestFocus
re-enters dispatch. One send can therefore cascade through a whole
message chain before returning — effects never queue for later and never
reach the host.
For hosts
Translate your input layer into RuntimeEvent and nothing else. The demo's
handle_runtime_key (raw host) and the notcurses host both do exactly this —
see Embedding Knopper.
Focus, Modals, and Disabled Nodes
Modals: declarative trapping
A modal is two declarations:
#![allow(unused)] fn main() { // 1. The modal subtree is a Trap focus scope. Scene::focus_scope_with_policy( modal_id, "confirm-dialog", FocusScopePolicy::Trap, dialog_scene, ) // 2. Escape (or a close action) sends a close message. }
Trap makes Tab clamp at the scope boundary — focus physically cannot leave
the modal via traversal. No imperative Tab interception, no key filtering in
the modal's key_msg. When the modal closes, a refocus safety net returns
focus to a sensible node (the runtime and the compose::trap_focus helper
coordinate this).
This replaced an earlier imperative design where modals handled Tab in their
own key path. The migration is described in docs/roadmap/00 (M1) and the
semantics are pinned by end-to-end tests (trap_scope_clamps_focus_within_scope_under_runtime_tab).
Disabled nodes
#![allow(unused)] fn main() { Scene::text(id, "delete").focusable().disabled() }
A .disabled() node:
- is skipped by focus collection — Tab never lands on it
- is skipped by activation routing — Enter/click on it sends nothing
It still renders (style it dim via with_style), so "present but
unavailable" is a first-class state. Toggling .disabled() per-frame from
Model is the intended way to express dynamic availability — e.g. a submit
button disabled while a form is invalid.
Eligibility rules, precisely
Focus collection includes a node iff: it is .focusable() and not
.disabled() and inside the visible scene. Scope policies then shape how
traversal moves within that collected order. Effect::RequestFocus to a
disabled or absent node is ignored.
Embedding Knopper
Knopper is a library substrate: your process, your event loop, your terminal lifecycle. This is contractual — the Wallace ↔ Knopper embedding contract (IA-documents, validated 2026-08-18 against the 0.1.0 code) defines this boundary.
What the host owns
- The async runtime and event loop (tokio, threads, whatever)
- stdin / resize / raw-mode lifecycle, signals, exit
- The domain: sessions, participants, runs, artifacts
- The collaboration transport and convergence
What Knopper provides
A synchronous, embedder-driven Runtime<M>. There is no run() loop in
the library — the only loops are in the demo binary, which drives Runtime
exactly as your host would.
The turn cycle
#![allow(unused)] fn main() { loop { match host_event().await { HostEvent::Key(k) => runtime.dispatch(translate_key(k)), HostEvent::Resize(r)=> runtime.dispatch(RuntimeEvent::Resize(r)), HostEvent::Domain(e)=> { let (msg, projection_changed) = adapter.translate(e); if let Some(m) = msg { runtime.send(m); } if projection_changed { runtime.set_shared(adapter.projection()); } } } runtime.render_to_backend(&mut backend, bounds)?; // commit let cursor = runtime.cursor(bounds); // place cursor } }
Synchronous UI turns; all async I/O stays host-side and surfaces as later
send / set_shared.
Contract facts the host must know
set_sharedre-projects. Machines whoseprojectreadsSharedsee the new projection on the next sample — no manual invalidation.set_sharedwhole-replaces. It composes fine with machineupdate(they touch different lanes), but granularity is whole-projection; see Streaming-Append Performance for cost.- Effects don't escape.
Effectis a closed enum drained insidesend/dispatch. Orchestration ("start a run") is host-triggered at send-time — the adapter saw the domain event first — or by observingModelbetween turns. - Custom
Model/Sharedtypes implementIntoGeometric/FromGeometric, re-exported fromknopper. No direct cliffy-core dependency needed. diff()is read-only;render_to_backend*commits and advances the baseline.
Reference harness
tests/embedding_harness.rs is the contract-validation harness: a complete
host with domain events, adapter translation, projection pushes, and
multi-runtime participant-locality assertions. Copy it.
Capability Gating
Multi-participant sessions need more than presence — they need policy:
who may activate what. Knopper's answer, behind the collaboration
feature, is capability gating powered by
Schubert calculus: capabilities are
Schubert conditions on a Grassmannian, grants position principals, and
access checks intersect the geometry.
[dependencies]
knopper = { version = "0.1", features = ["collaboration"] }
The payoff: impossible combinations
A set-membership ACL answers "does the principal hold X?" — and silently allows a principal who holds two capabilities that cannot coexist. Geometry knows better. The worked example from the test suite:
use knopper::capability::{AccessController, Capability, CapabilityKind};
use knopper::collaboration::ParticipantId;
// Gr(2,4): review = σ₂, deploy = σ₁₁ — σ₂·σ₁₁ = 0.
let mut acl = AccessController::new(2, 4)?;
acl.register_capability(Capability::new("review", "Approve", vec![2], CapabilityKind::ReadLike))?;
acl.register_capability(Capability::new("deploy", "Ship", vec![1, 1], CapabilityKind::WriteLike))?;
let alice = acl.create_principal("alice")?;
acl.grant(&alice, "review")?;
acl.grant(&alice, "deploy")?; // both granted — set-membership would allow
let decision = acl.check(&alice, &["review", "deploy"])?;
// AccessDecision::Impossible { conflicting } — separation of duties
// detected by the algebra, not by a hand-written exclusion rule.
Separation of duties stops being a policy you must remember to write and becomes a geometric fact the checker derives.
CapabilityGate: declare the policy
A gate wraps the controller, maps semantic NodeIds to required
capability lists, and is bridged from a collaboration ParticipantId
(grant-then-gate and gate-then-grant both work). Only
AccessDecision::Granted opens a node; underconstrained policies and
controller errors fail closed.
let mut gate = CapabilityGate::new(acl, &ParticipantId::new("alice"))?;
gate.gate_node(deploy_button_id, vec!["review".into(), "deploy".into()]);
gated_scene(&scene) projects not-granted gated nodes as disabled —
which means focus collection and activation skip them through the same
principled path used for ordinary disabled nodes. No new gating
machinery exists inside render or focus.
CapabilityRuntime: enforce at activation
CapabilityRuntime wraps a Runtime (it derefs to the plain runtime)
and consults the gate on Activate events. Not-granted activations are
suppressed — the machine never sees them — and the most recent decision
is available for host-side rendering:
runtime.dispatch(RuntimeEvent::Activate(deploy_button_id));
assert!(matches!(runtime.last_denial(), Some(AccessDecision::Impossible { .. })));
With no gate installed, CapabilityRuntime behaves exactly like the
plain runtime. The feature is off by default; the default build pays
nothing for it.
Layering
The capability seam honors the geometric substrate's boundary (see The Geometric Substrate): GA3 stays the fingerprint layer; this seam consumes collaboration identity at its edge and runs the higher-grade arithmetic in Schubert/amari territory. Nothing pushes its algebra back into GA3.
Running the Demos
Knopper ships two reference applications as its binary targets. They are reference material, not stable API — expect them to move or be feature-gated in 0.2.0.
The workspace host
cargo run --features demo # raw-key interactive host (demos are feature-gated)
cargo run --features demo,notcurses -- --notcurses
An interactive workspace exercising the standard machines: list, input,
tabs, command palette (Ctrl+K or /), modal confirm — with declarative focus
traversal and palette trapping. The raw host and the notcurses host drive the
same Runtime through different backends, which is the embedding story in
miniature.
The review workspace
cargo run --features demo -- --demo review
A review-focused composition: query input, draft editing, list-detail navigation — the shape of a code-review control panel.
What to look at in the source
src/main.rs— three host loops (run_shell,run_raw_host,run_review_raw_host): terminal lifecycle, key translation, the turn cyclesrc/demo.rs,src/demo_ui.rs— the workspace machine and its scenesrc/review_demo.rs— the review machine
Presence cues in the demos are cosmetic placeholders — they demonstrate
where PresenceSlot/RemoteCursor annotations attach, driven by hardcoded
data rather than a live ParticipantRoster. Deriving them from a real roster
in Shared is a 0.2.0 item.
Headless
Everything the demos do runs against MockBackend in tests — see
tests/runtime_pipeline.rs and tests/embedding_harness.rs for host-free
driving patterns.
Runtime
Runtime<M: Machine> is the embedder-driven engine: it owns the Behaviors
for Model and Shared, the projected SceneBehavior, the FocusState, and
the last committed frame. One Runtime per participant.
Construction
#![allow(unused)] fn main() { Runtime::new(machine, ctx, shared_initial) }
Driving (host turn cycle)
| Method | Use |
|---|---|
dispatch(RuntimeEvent) | keys, resize, focus/activate events |
send(Msg) | domain messages from the host adapter |
set_shared(Shared) | push a whole projection (re-projects on next sample) |
set_context(Context) | push app context (identity, theme) |
Reading
| Method | Returns |
|---|---|
scene() | &SceneBehavior<Msg> |
model() / shared() | current values (cloned) |
focus() | &FocusState |
layout(bounds) | LayoutNode tree |
render_ops(bounds) | Vec<RenderOp> |
cursor(bounds) | Option<(u16, u16)> (clamped to bounds) |
diff(bounds) | Vec<PatchOp> vs. last commit — read-only |
Committing
| Method | Notes |
|---|---|
render(renderer, bounds) | generic Renderer path |
render_to_backend(backend, bounds) | diff → backend commands → execute → sync order |
render_to_backend_with_cursor(.., cursor) | + explicit cursor command |
render_to_backend_auto_cursor(..) | cursor from the machine |
invalidate_render_state() | forget the baseline (next commit re-inserts everything) |
Effects
Machine::update returns Effect<Msg>:
#![allow(unused)] fn main() { pub enum Effect<Msg> { None, Emit(Msg), // synchronously re-enters update (recursive) Batch(Vec<Effect<Msg>>),// applied in order RequestFocus(NodeId), // routes through the focus dispatch path } }
Effects are closed and fully drained by the runtime inside
send/dispatch. There is no queue, no host-visible effect stream, and no
async bridge (yet — that's an open design question tracked in the embedding
contract's §O3).
Threading
Runtime is single-threaded (Rc/RefCell inside the Behaviors). Drive it
from one task; do async work elsewhere and surface results as send /
set_shared.
Machine & PureMachine
Machine
The trait every UI unit implements. Four associated types — Context, Msg,
Model, Shared (see The Machine Model) —
and:
#![allow(unused)] fn main() { fn init(&self, ctx: &Context) -> Model; fn update(&self, model: &mut Model, msg: Msg, ctx: &Context) -> Effect<Msg>; fn project( &self, model: Behavior<Model>, shared: Behavior<Shared>, ctx: &Context, ) -> SceneBehavior<Msg>; fn project_once(&self, model: &Model, shared: &Shared, ctx: &Context) -> Scene<Msg>; // provided fn cursor_position(&self, model, shared, ctx, layout) -> Option<(u16, u16)>; // default: None }
Bounds: Context: Clone; Model/Shared: Clone + IntoGeometric + FromGeometric + 'static; Msg: Clone + 'static.
PureMachine
#![allow(unused)] fn main() { PureMachine::new( init: impl Fn(&Context) -> Model, update: impl Fn(&mut Model, Msg, &Context) -> Effect<Msg>, view: impl Fn(&Model, &Shared, &Context) -> Scene<Msg>, ) }
The closure form — view is the one-shot projection; PureMachine adapts it
to the reactive project. Covers most machines; implement Machine manually
when you need stored resources or non-closure logic.
Geometric traits
IntoGeometric / FromGeometric / GA3 are re-exported from knopper.
Std impls exist for numbers, bool, String, (), tuples, Option<T>. For
custom types, a hash-fingerprint impl suffices:
#![allow(unused)] fn main() { impl IntoGeometric for MyProjection { fn into_geometric(self) -> GA3 { // hash of contents in the scalar slot, length in e1 — the native // value is cached by Behavior; this is only a change fingerprint /* see tests/embedding_harness.rs for a complete impl */ } } }
FromGeometric is never used to reconstruct real values on the sampling
path; a placeholder is acceptable (as cliffy-core's own String impl does).
SceneBehavior
The reactive scene type returned by project — a cliffy-core Behavior
specialization over scenes. The runtime re-samples it whenever Model or
Shared changes; you don't manage invalidation.
Scene Builders
Scene<Msg> nodes and modifiers. Every builder takes a stable id
(impl Into<NodeId>); see Scene Algebra for
the design rationale.
Structure
| Builder | Layout |
|---|---|
text(id, content) | leaf text node |
row(id, children) | horizontal flow |
column(id, children) | vertical flow |
stack(id, children) | same cell, back-to-front |
overlay(id, children) | floating above the base |
Wrappers
| Builder | Effect |
|---|---|
padding(id, Padding, child) | inset space |
border(id, child) | drawn border |
sized(id, SizeConstraint, child) | width/height constraint |
viewport(id, child) | clip to bounds |
scroll(id, ScrollOffset, child) | scrolled content |
align(id, Anchor, child) | anchor within available space |
annotated(id, label, child) | attach an annotation label |
Focus
| Builder | Effect |
|---|---|
focus_scope(id, name, child) | named scope, default policy |
focus_scope_with_policy(id, name, policy, child) | Wrap / Trap / Local / Passthrough |
Modifiers (chainable)
#![allow(unused)] fn main() { .with_style(Style) // Color/Emphasis .with_role(Role) // semantic role (Button, etc.) .with_annotation(Annotation) // presence/cursor overlays, labels .focusable() // enter the Tab order .disabled() // skip focus + activation .on_activate(msg) // Enter/click → msg .map_msg(&fn) // Msg → ParentMsg translation }
Supporting types
Style, Color, Emphasis · Role · Annotation · Padding ·
SizeConstraint / Size · ScrollOffset · Anchor · NodeId
All are plain data — scenes are cheap to construct per projection, and the id-keyed diff keeps commits cheap when ids are stable.
Standard Machines
Ready-made machines in knopper::standard, each exporting its *Machine,
*Msg, *State, *Context, and a *_key_msg raw-key handler. Compose them
with the helpers in knopper::compose.
| Machine | Purpose | Notes |
|---|---|---|
ButtonMachine | activation control | on_activate semantics |
ToggleMachine | boolean switch | state in ToggleState |
InputMachine | single-line text input | participant-local buffer |
TextareaMachine | multi-line editing | see roadmap doc 04 for the editor design |
ListMachine | selectable list | selection/commit in Model — multi-user-safe |
ListDetailMachine | list + detail pane | two-region navigation |
TabsMachine | tab bar | Wrap-policy scope |
CommandPaletteMachine | fuzzy command overlay | input + filtered list composition |
| modal helpers | declarative dialog trapping | ModalFocusConfig, ModalKeyAction; see modals |
Composition contract
Each standard machine:
- Keeps interaction state in its
*State(participant-localModellane) - Reads display data from
Sharedwhere the data is projection-shaped (e.g. list items) - Exposes
*_key_msgfor its raw-key behavior, wired into the host's or parent'skey_msg/dispatch as needed - Never hardcodes single-user ownership — no global focus, no process singletons
The collaboration audit (docs/roadmap/02-standard-machine-collaboration-audit.md)
verifies each machine against these rules.
Example: palette in a parent
The demo (src/demo.rs) composes the command palette inside the workspace
machine: palette state lives in the parent's Model, palette messages lift
through map_msg, and the palette's scene subtree sits in a Trap scope
while open. Read it alongside
Composing Machines.
Collaboration Types
knopper::collaboration — the canonical vocabulary for multi-participant
sessions. These types give downstream applications a shared Shared-payload
shape so presence renders consistently across hosts.
ParticipantId
Stable identity for a session participant. Construct once per participant; cheap to clone and compare.
Presence and PresenceTone
A participant's presence state plus its render tone (how the UI should color
it). Presence is display metadata — it travels in Shared projections and
renders through annotation overlays.
ParticipantRoster
The canonical roster: who is in the session, with their presence. Designed to
sit inside a host's Shared projection (e.g.
struct SessionProjection { roster: ParticipantRoster, transcript: … }).
Annotations for presence
Annotation::PresenceSlot— reserve a render slot for a participant's presence badgeAnnotation::RemoteCursor— a remote participant's cursor/selection overlay
These annotate the scene without affecting layout or focus — see Scene Algebra.
Design constraints
- One
Runtimeper participant; convergence is the host's responsibility - Focus/scroll/selection never enter these types — they're
Model-resident - The demo's hardcoded presence cues are placeholders; 0.2.0 derives them from a real roster
See Collaboration-Ready Contract for the full specification and the Schubert capability seam mapped for 0.2.0.
Backends
Knopper renders to a TerminalBackend. Two ship today; the trait is the
extension point (a future web/Sixel backend implements the same surface).
TerminalBackend
#![allow(unused)] fn main() { pub trait TerminalBackend { type Error; fn execute(&mut self, commands: &[BackendCommand]) -> Result<(), Self::Error>; fn sync_order(&mut self, order: &[NodeId]) -> Result<(), Self::Error>; } }
execute applies the translated patch commands; sync_order communicates the
front-to-back stacking order (for backends with real z-ordering, like
notcurses planes). The runtime calls both on every commit.
MockBackend
In-memory backend that records executed commands and exposes a queryable
BackendState (entries by NodeId, cursor, surface count). This is the test
backend — the entire CI suite runs against it, and host test harnesses should
too.
Also available: MockRenderer for the simpler Renderer trait path.
NotcursesBackend (feature notcurses)
Real terminal rendering via notcurses planes — one plane per scene node,
synced in render order. Requires the system notcurses library ≥ 3.0.11
(libnotcurses-dev on Ubuntu 26.04+; note Ubuntu 24.04's 3.0.7 is too old).
The crate's vendored cargo feature is a docs-only path and does not link —
install the system library.
knopper = { version = "0.1", features = ["notcurses"] }
Supporting types
BackendCommand— the instruction set (create/update/remove surface, draw text/border, set cursor, …)BackendEntry/BackendState— recorded backend state (mock inspection)backend_commands(previous, patches)— the patch → command translation, public for custom backends
Embedding Contract (Wallace)
The Wallace ↔ Knopper embedding contract lives in
IA-documents/CONTRACTS/wallace-knopper-embedding.md (v0.4). It defines the
boundary every host builds against. It was validated against the 0.1.0 code
on 2026-08-18 (the Tier 0 spike); this chapter summarizes the validated
state. The findings note with full evidence is docs/embedding-validation-2026-08.md
in the repo.
Verdicts
| § | Assumption | Status |
|---|---|---|
| §1 | Embedder-driven Runtime; no mandatory run() | ✅ Verified |
| §2 | Host-driven turn cycle | ✅ Verified end-to-end |
| §3 | set_shared reprojects; Model participant-local | ✅ Verified, signed off against the collaboration contract — they agree |
| §4 | Host-defined Msg; effect semantics | ✅ Verified, with one correction (below) |
| §5 | Cheap append-mostly diff | ❌ Quadratic today — measured, scheduled for 0.1.x |
The §4 correction
The contract originally said the host's adapter "interprets effects requiring
orchestration." In the shipped code, Effect is a closed enum
(None / Emit / Batch / RequestFocus) drained synchronously by the
runtime — no effect reaches the host. Orchestration is host-triggered at
send-time (the adapter saw the domain event first) or by observing Model.
The contract's v0.4 reflects this; a host-orchestration effect variant remains
an open question (§O3).
The §3 sign-off
The one question that could have split downstream consumers — does
participant-local UI state live in Model (embedding contract §3/§7) or
somewhere the collaboration module owns — is settled: both contracts put
focus/scroll/selection in participant-local Model, never in Shared, and
the harness proves two runtimes sharing a projection diverge without
cross-talk. Wallace, Tsume, and Dominic can pin against either document.
The harness
tests/embedding_harness.rs is the canonical example: host-defined domain
events and Msg, a hash-encoded projection type, the five-turn drive loop,
participant-locality assertions, and the streaming-append measurement.
Collaboration-Ready Contract
The full specification is docs/roadmap/06-collaboration-ready-contract.md in
the repo. This chapter is the summary a host author needs.
The architecture
- External-merge seam. Downstream hosts own convergence (CRDT, event
sourcing, whatever) and push merged projections via
Runtime::set_shared. Knopper never sees a wire protocol. - One Runtime per participant. Each participant's process runs its own
Runtimewith its ownFocusStateandModel. - Presence as overlays. Remote cursors/selections render through
Annotation::{PresenceSlot, RemoteCursor}— never through focus.
Capability seam (landed, collaboration feature)
The Schubert capability seam shipped (identity-restoration Unit 3) behind
the collaboration feature against Schubert 0.5: CapabilityGate
(controller + node→requirement registry + ParticipantId bridge,
fail-closed decisions, gated_scene projection through the existing
disabled semantics) and CapabilityRuntime (activation gating with
last_denial). The headline capability is impossible-combination
detection — separation of duties as σ₂·σ₁₁ = 0, rejected with
Impossible { conflicting }. See
Capability Gating.
Checklist for a collaboration-ready machine
- Is each piece of state local (
Model), shared (Shared), participant-local-published (presence annotation), or derived (scene)? - Does anything assume a single focus or a single selection? (It must not.)
- Are activation/commit paths expressible per-participant?
- Do ids stay stable across projection updates?
Status
0.1.0: the seam, the canonical types, and the audit are done; demos still use
cosmetic presence. 0.2.0: real roster-driven presence in the demos, the
collaboration feature, incremental Shared updates.
Streaming-Append Performance
The embedding contract's §5 requires append-mostly scenes (agent transcripts) to stay cheap. The Tier 0 spike measured this against 0.1.0 — it fails today, and the failure is quantified and scheduled.
The measurement
Transcript machine (Shared = Vec<String>, stable per-line node ids), 1,000
appends, per-turn set_shared + render_to_backend against MockBackend,
80×24 bounds:
| Appends (avg ms/append) | Debug | Release |
|---|---|---|
| 1–100 | 0.090 | 0.023 |
| 401–500 | 1.630 | 0.241 |
| 900–1000 | 6.062 | 0.778 |
Reproduce: cargo test --test embedding_harness -- --ignored --nocapture.
What's fine vs. what isn't
Fine: the committed patch surface per append is O(1) — one row insert plus a header update, bounded backend commands, no visible-window churn. Stable ids do their job.
Not fine: internal per-commit cost grows ~quadratically.
diff_render_opsis O(n²) — a linear scan of the previous op list for every op (and again for removals). Dominant at scale.render_ops(bounds)re-runs full layout + lowering per turn — O(n).set_shared/sample()clone the wholeShared— O(n) copy, no structural sharing.
The work item (0.1.x)
- HashMap-keyed O(n)
diff_render_ops(id → index), the single biggest win - Viewport-bounded projection so machines render the visible window only
- (Later) incremental
Sharedupdates instead of whole-projection replace
At 1,000 lines, release-mode commits are sub-millisecond — the first Wallace slice is unblocked. At 10k lines the current path projects to ~80 ms/append, so the append-optimized diff lands before long transcripts ship.
Roadmap
0.1.0 (this release)
- Machine model + scene algebra + full rendering pipeline
- Principled focus: scene-derived order, scope policies, declarative modal trapping, disabled-node eligibility
- Standard machines (list, input, button, toggle, tabs, textarea, list-detail, command palette, modal helpers) + composition helpers
- Collaboration seam:
Shared/Modelsplit,ParticipantRoster, presence annotations - Embedding contract validated (Tier 0); mock backend + integration harness
- Notcurses backend (feature-gated), Borsalino-pattern CI with a self-hosted notcurses runner
0.1.x
- Append-optimized diff — O(n)
diff_render_ops; viewport-bounded projection (see Streaming-Append) - Host-orchestration effects (contract §O3), if a pattern proves out
Landed since the 0.1.0 roadmap was written
- Geometric substrate made honest (identity-restoration Units 1–3):
encoding contract + blade ledger; all zero-stub encodings replaced;
semantically true collaboration encodings (roster = multivector sum);
the first GA→render reader (
tone_census/presence_annotation) collaborationfeature: Schubert capability seam (CapabilityGateCapabilityRuntime, impossible-combination detection in Gr(2,4))
- O(n) diffing after the streaming-append hot path was measured and fixed; rayon tested and reverted on the numbers
- Demo modules feature-gated (
demofeature; library-only default builds) - Viewport × presence-anchor design decided: the annotation lane —
see
docs/design/viewport-presence-anchor.md
0.2.0
- CRDT-backed incremental
Shared - Demo presence cues derived from a real
ParticipantRoster - Viewport-bounded projection per the decided design (annotation lane + anchor-position resolution)
- GA-derived presence overlays beyond the status line (offscreen indicators, per the roadmap-03 local-visibility choices)
Downstream consumers
- Wallace — the collaborative AI harness; embeds Knopper per the validated contract. First slice: streaming transcript pane.
- Tsume — control panel; follows Wallace's embedding pattern.
- Dominic — cockpit; unblocked by the Tier 0 validation.
The working documents live in the repo: docs/roadmap/ (milestone specs),
docs/architecture/ (design chapters), docs/handoff/ (session handoffs).