Skip to content

Component Architecture

The TUI uses a composable component architecture built on the Oil renderer — a React-like immediate-mode UI system with flexbox layout (via taffy).

Oil is Crucible’s terminal rendering library, providing:

  • Immediate-mode rendering — UI rebuilt each frame from state
  • Node-based compositionNode tree defines UI structure
  • Flexbox layout — Powered by taffy for CSS-like layouts
  • Streaming graduation — Content transitions from viewport to stdout
// Basic Oil node construction
let view = Node::col(vec![
Node::text("Hello").bold(),
Node::row(vec![
Node::badge("OK").fg(Color::Green),
Node::spacer(),
Node::text("Status"),
]),
]);
State Change → Node Tree → Layout (taffy) → Render (buffer) → Terminal Output
  1. State Change: User input or agent event modifies InkChatApp state
  2. Node Tree: view() method builds node tree from current state
  3. Layout: Taffy calculates flex positions and sizes
  4. Render: Nodes render to terminal buffer with styles
  5. Output: Diff algorithm writes minimal changes to terminal

Components are modules in crates/crucible-cli/src/tui/oil/components/:

Renders conversation history with markdown formatting.

Location: components/message_list.rs

Features:

  • Markdown rendering with syntax highlighting
  • Tool call display with collapsible results
  • Streaming content with spinner
  • Thinking block display (toggle with Alt+T)

Text input with readline-style editing.

Location: components/input_area.rs

Features:

  • Multi-line input with cursor
  • Emacs keybindings (Ctrl+A/E/W/U/K)
  • Command prefix detection (/, @, [[, :, !)

Mode indicator and status display.

Location: components/status_bar.rs

Features:

  • Mode display (Normal, Plan, Auto)
  • Model name and token count
  • Notification display
  • Thinking token count (when visible)

Autocomplete popup for commands and references.

Location: components/popup_overlay.rs

Features:

  • Fuzzy filtering
  • Keyboard navigation
  • Multiple popup kinds (Command, File, Agent, Note, Model)

The TUI uses a two-layer “graduation” system for streaming content:

Static content graduates from the viewport to terminal stdout (scrollback):

┌─────────────────────────┐
│ STDOUT (graduated) │ ← Historical content, no longer re-rendered
├─────────────────────────┤
│ │
│ VIEWPORT (active) │ ← Live content, re-rendered each frame
│ │
└─────────────────────────┘

Key files:

  • graduation.rsGraduationState, plan_graduation(), commit_graduation()
  • planning.rsFramePlanner orchestrates graduation-first rendering

Within the viewport, streaming content graduates block-by-block:

pub struct StreamingBuffer {
graduated_blocks: Vec<String>, // Completed paragraphs
in_progress: String, // Active streaming text
}

Paragraphs (split at \n\n) graduate to graduated_blocks while new content streams into in_progress.

Key files:

  • viewport_cache.rsViewportCache, StreamingBuffer
  1. XOR Placement — Content in stdout OR viewport, never both
  2. Monotonic — Graduated count never decreases
  3. Atomic — Graduation commits before viewport filtering (no flash)

The ViewportCache manages conversation state for efficient rendering:

pub struct ViewportCache {
items: VecDeque<CachedChatItem>,
graduated_ids: HashSet<String>,
streaming: Option<StreamingBuffer>,
}

Key methods:

  • push_message() — Add user/assistant message
  • push_tool_call() / push_tool_result() — Tool call lifecycle
  • mark_graduated() — Track graduated content
  • ungraduated_items() — Iterator for viewport rendering

Events propagate through the system:

Terminal Event
ChatRunner::handle_event()
InkChatApp::update() → ChatAppMsg
State mutation + view rebuild
Terminal::draw()

High-level messages that modify app state:

pub enum ChatAppMsg {
SendMessage(String),
StreamChunk(String),
ToolCallStart { id, name },
ToolCallComplete { id, result },
SetModel(String),
SetThinkingBudget(Option<u32>),
ToggleThinking,
// ...
}

The theme.rs module defines consistent styling:

pub struct Theme {
pub user_prefix: Style,
pub assistant_prefix: Style,
pub thinking_border: Style,
pub tool_name: Style,
// ...
}

Access via Theme::default() or configure via :set commands.

Components are tested via:

  1. Unit tests — State transitions, event handling
  2. Snapshot tests — Visual output with insta
  3. Property tests — Graduation invariants with proptest

Example snapshot test:

#[test]
fn test_status_bar_normal_mode() {
let app = InkChatApp::default();
let node = app.view();
insta::assert_snapshot!(render_to_string(&node, 80, 24));
}