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. Model is participant-local (focus, scroll, input buffers — never synced). Shared is 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 Runtime turn-by-turn: dispatch keys, send messages, set_shared projections, commit diffs. 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 notcurses feature links the system notcurses library and requires notcurses ≥ 3.0.11 (libnotcurses-dev on Debian/Ubuntu 26.04+, or your distro's equivalent). The Rust vendored feature 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:

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

LaneOwnerExamples
ContextHost, pushed via set_contextparticipant identity, theme, capabilities
MsgHost-defined per machineSubmit, ScrollDown, ChunkReceived
ModelThe machine (participant-local)focus, scroll offset, input buffer
SharedHost projections, pushed via set_sharedtranscript, 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 place Model changes. Returns an Effect, 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 whenever Model or Shared changes; 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

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 scope
  • Trap — Tab clamps at scope boundaries (modal semantics: focus cannot leave)
  • Local — order is local to the scope, no cycling
  • Passthrough — 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:

  1. Global shortcuts (host/machine-declared)
  2. Declarative Tab traversal across the scene-derived focus order
  3. The focused machine's key_msg handler
  4. 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:

PolicyTab at last nodeUse
WrapCycles to the scope's first nodetoolbars, tab bars
TrapClamps — focus stays putmodals, dialogs
LocalStops; no cyclinggrouped controls
PassthroughScope is transparentlayout-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 via Runtime::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 identity
  • Presence / PresenceTone — presence state and its render tone
  • ParticipantRoster — the canonical Shared-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. Shared starts 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:

  1. 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.
  2. Merge semantics. Collaboration types encode so that combination is addition (below).
  3. 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:

BladeRole
1 (scalar)identity / count
e1, e2, e3primary state axes; presence tones
e12, e13, e23pairwise slots: markers, digest words
e123aggregate / parity

Two classes of encoding

  • Class A — exact. from_geometric inverts into_geometric on the nose. Required wherever the fields fit the ledger: ButtonState (scalar = activation count), ToggleState, ListState (selected + scroll), TabsState (with a marker blade distinguishing Some(0) from None).
  • 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 like CommandPaletteState digest 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:

  • PresenceTone occupies its own basis blade — Local → e1, Collaborator → e2, Passive → e3 — as a unit coefficient.
  • A Presence is: 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.
  • ParticipantRoster is 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's Msg variant into a child machine's update, mapping the child's Effect back into parent space
  • project_child — embed a child's projected scene in the parent's scene, mapping messages with map_msg
  • map_effect — lift an Effect<ChildMsg> to Effect<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 focus
  • trap_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 backend
  • NotcursesBackend — 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

  1. Global shortcuts — host/machine-declared global key handling
  2. Declarative Tab traversal — Tab / Shift-Tab move focus across the scene-derived order, honoring scope policies (Wrap / Trap / Local / Passthrough)
  3. Machine key_msg — the focused machine's raw-key handler gets a shot
  4. Declarative fallback — activation (Enter on an on_activate node), 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_shared re-projects. Machines whose project reads Shared see the new projection on the next sample — no manual invalidation.
  • set_shared whole-replaces. It composes fine with machine update (they touch different lanes), but granularity is whole-projection; see Streaming-Append Performance for cost.
  • Effects don't escape. Effect is a closed enum drained inside send/dispatch. Orchestration ("start a run") is host-triggered at send-time — the adapter saw the domain event first — or by observing Model between turns.
  • Custom Model/Shared types implement IntoGeometric/FromGeometric, re-exported from knopper. 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 cycle
  • src/demo.rs, src/demo_ui.rs — the workspace machine and its scene
  • src/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)

MethodUse
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

MethodReturns
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

MethodNotes
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

BuilderLayout
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

BuilderEffect
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

BuilderEffect
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.

MachinePurposeNotes
ButtonMachineactivation controlon_activate semantics
ToggleMachineboolean switchstate in ToggleState
InputMachinesingle-line text inputparticipant-local buffer
TextareaMachinemulti-line editingsee roadmap doc 04 for the editor design
ListMachineselectable listselection/commit in Model — multi-user-safe
ListDetailMachinelist + detail panetwo-region navigation
TabsMachinetab barWrap-policy scope
CommandPaletteMachinefuzzy command overlayinput + filtered list composition
modal helpersdeclarative dialog trappingModalFocusConfig, ModalKeyAction; see modals

Composition contract

Each standard machine:

  1. Keeps interaction state in its *State (participant-local Model lane)
  2. Reads display data from Shared where the data is projection-shaped (e.g. list items)
  3. Exposes *_key_msg for its raw-key behavior, wired into the host's or parent's key_msg/dispatch as needed
  4. 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 badge
  • Annotation::RemoteCursor — a remote participant's cursor/selection overlay

These annotate the scene without affecting layout or focus — see Scene Algebra.

Design constraints

  • One Runtime per 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

§AssumptionStatus
§1Embedder-driven Runtime; no mandatory run()✅ Verified
§2Host-driven turn cycle✅ Verified end-to-end
§3set_shared reprojects; Model participant-local✅ Verified, signed off against the collaboration contract — they agree
§4Host-defined Msg; effect semantics✅ Verified, with one correction (below)
§5Cheap 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 Runtime with its own FocusState and Model.
  • 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

  1. Is each piece of state local (Model), shared (Shared), participant-local-published (presence annotation), or derived (scene)?
  2. Does anything assume a single focus or a single selection? (It must not.)
  3. Are activation/commit paths expressible per-participant?
  4. 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)DebugRelease
1–1000.0900.023
401–5001.6300.241
900–10006.0620.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.

  1. diff_render_ops is O(n²) — a linear scan of the previous op list for every op (and again for removals). Dominant at scale.
  2. render_ops(bounds) re-runs full layout + lowering per turn — O(n).
  3. set_shared / sample() clone the whole Shared — 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 Shared updates 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/Model split, 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)
  • collaboration feature: Schubert capability seam (CapabilityGate
    • CapabilityRuntime, 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 (demo feature; 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).