# rab Architecture
A minimal Rust reimplementation of [pi-coding-agent](https://pi.dev) - a
terminal coding harness that gives an LLM tools (read, write, edit, bash) and
lets it act on your codebase.
## Pi component mapping
| `pi-tui` (terminal UI, components, editor) | `src/tui/` + `src/agent/ui/` | ✅ **1/1 complete** — 29 modules, 632 tests. Direct Rust port on crossterm. See [`tui.md`](tui.md). |
| `pi-agent-core` (agent loop, session, compaction, skills) | `src/agent/` | ✅ loop, session, skills done. ⬜ compaction not implemented. |
| `coding-agent` (CLI, extensions, tools, settings, commands) | `cli.rs`, `builtin/`, `settings.rs`, `commands.rs` | ✅ tools, settings, auth, CLI done. ⬜ models.json, hooks, steering. |
| `pi-ai` (providers, streaming) | `Provider` trait + `adapter/genai.rs` | ⬜ needs multi-backend support (currently OpenCode Go only) |
| `coding-agent/modes/interactive/theme/` | `src/agent/ui/theme.rs` | ✅ JSON theme system with resolution, fallback, detection |
| `coding-agent/resource-loader.ts` | `src/agent/context_files.rs` | ✅ AGENTS.md/CLAUDE.md discovery |
| `coding-agent/skills.ts` | `src/agent/skills.rs` | ✅ Skill loading, frontmatter, prompt formatting |
| `tui/src/components/image.ts` | `src/tui/image.rs` | ✅ Basic Kitty protocol image support |
| `tui/src/terminal-image.ts` | `src/tui/image.rs` (partial) | ⬜ Missing: capabilities detection, iTerm2, cell dimensions |
| `coding-agent/utils/image-resize.ts` | — | ⬜ Not implemented |
| `coding-agent/utils/clipboard-image.ts` | — | ⬜ Not implemented |
| MCP extensions | `pi-mcp-adapter` (planned) | ⬜ Phase 2 |
| Config files | `~/.rab/` | ✅ Same schema as pi |
## Design constraints
- **One extension mechanism** - built-in tools and user extensions use the same
`Extension` trait. No separate tool registration path. `--no-builtin-tools`
just skips loading builtins; user extensions still load.
- **No live-reload of extensions** - extensions are compiled in, not hot-reloaded.
- **Provider layer is isolated behind a trait** - rab defines its own `Provider`
trait. The default implementation wraps [genai](https://github.com/jeremychone/rust-genai)
(Apache 2.0, 711★, 50 contributors). The agent loop depends only on the trait,
so genai can be swapped for another backend without touching loop logic.
- **Agent loop mirrors pi** - steering queues, follow-up queues, hook-based
tool lifecycle, event stream. Ported from pi's `runAgentLoop` in
`packages/agent/src/agent-loop.ts`.
## License
rab is **EPL-2.0**. The `genai` dependency is Apache 2.0 (compatible) but
isolated behind a trait - replaceable with no changes to core logic.
---
## Layered architecture
```
┌──────────────────────────────────────────────────────────┐
│ rab (EPL-2.0) │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ cli.rs │ │
│ │ clap-based arg parsing, env reading, │ │
│ │ mode dispatch (print / interactive) │ │
│ └────────────────────┬─────────────────────────────┘ │
│ │ │
│ ┌────────────────────▼─────────────────────────────┐ │
│ │ agent.rs │ │
│ │ Agent struct, run_agent_loop(), event stream, │ │
│ │ steering/follow-up queues, hook pipeline │ │
│ │ depends on: Provider trait (not genai) │ │
│ └────┬──────────┬──────────┬──────────┬────────────┘ │
│ │ │ │ │ │
│ ┌────▼──┐ ┌────▼──┐ ┌────▼──┐ ┌────▼──┐ ┌────▼──┐ │
│ │builtin│ │ tui/ │ │commands│ │settings│ │ sys │ │
│ │read │ │ agent/ │ │.rs │ │.rs │ │prompt │ │
│ │write │ │ ui/ │ │/quit │ │~/.rab/ │ │.rs │ │
│ │edit │ │screen │ │/model │ │settings│ │AGENTS │ │
│ │bash │ │editor │ │ │ │ │ │.md │ │
│ │commands│ list │ └───────┘ └────────┘ └───────┘ │
│ └──┬────┘ └───┬────┘ │
│ │ │ crossterm (0.28) │
│ │ │ unicode-segmentation, unicode-width │
│ └──┬────┘ impl agent::extension::Extension trait │ │
│ ┌──▼──────────────────────────────────────────────────────────┐│
│ │ agent/extension.rs (AgentTool, Extension traits) ││
│ │ pub trait Extension { ││
│ │ fn tools(&self) -> Vec<Box<dyn AgentTool>>; ││
│ │ fn commands(&self) -> Vec<SlashCommand>; ││
│ │ } ││
│ │ pub trait CommandHandler { execute, completions } ││
│ │ Builtin + user extensions share this trait ││
│ └──────────────────────────────────────────────────────────────┘│
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ agent/provider.rs (Provider trait) │ │
│ │ pub trait Provider { ... } │ │
│ │ pub struct StreamEvent { ... } │ │
│ │ Agent loop depends ONLY on this, not on genai │ │
│ └────────────────────┬─────────────────────────────┘ │
│ │ │
│ ┌────────────────────▼─────────────────────────────┐ │
│ │ adapter/genai.rs (impl Provider) │ │
│ │ struct GenaiAdapter { client: genai::Client } │ │
│ │ impl Provider for GenaiAdapter { ... } │ │
│ │ The only file that imports genai │ │
│ └────────────────────┬─────────────────────────────┘ │
│ │ │
└───────────────────────┼───────────────────────────────────┘
│
┌────────▼────────┐
│ genai (Apache │
│ 2.0) │
│ replaceable │
└─────────────────┘
```
---
## Core type system (`src/agent/types.rs`)
### AgentMessage
The universal message type. Every entry in a session transcript is one of these.
```
AgentMessage
├── id: String # UUID v4
├── parent_id: Option<String> # for session tree (MVP: linear)
├── tool_calls: Vec<ToolCall> # present on Assistant messages
├── tool_call_id: Option<String> # present on ToolResult messages
├── usage: Option<Usage> # tokens in/out/cache, present on Assistant
├── is_error: bool # for ToolResult: was execution an error?
└── timestamp: i64 # Unix millis
```
### AgentTool trait
```rust
#[async_trait]
pub trait AgentTool: Send + Sync {
fn name(&self) -> &str;
fn description(&self) -> &str;
fn parameters(&self) -> serde_json::Value; // JSON Schema
fn label(&self) -> &str; // human-readable for UI
/// Custom rendering for the tool call header (name + args).
/// Returns ANSI-styled text. When None, default rendering is used.
fn render_call(&self) -> Option<fn(&serde_json::Value) -> String> { None }
/// Custom rendering for the tool result body.
/// Returns ANSI-styled text. When None, default rendering is used.
fn render_result(&self) -> Option<fn(&str, bool) -> String> { None }
/// Guidelines for the system prompt specific to this tool.
fn prompt_guidelines(&self) -> Vec<String> { vec![] }
async fn execute(&self, tool_call_id: String, args: Value,
cancel: Cancel, on_update: Option<UnboundedSender<ToolOutput>>)
-> Result<ToolOutput>;
}
```
### AgentEvent
Emitted by the loop for consumers (print mode writes to stdout; TUI later
renders to screen). Mirrors pi's `AgentEvent` union.
```
AgentEvent
├── AgentStart
├── TurnStart
├── TextDelta { delta: String }
├── ThinkingDelta { delta: String }
├── ToolCall { id, name, args }
├── ToolResult { id, name, content, is_error }
├── TurnEnd
├── AgentEnd { messages: Vec<AgentMessage> }
```
---
## Agent loop (`src/agent/`)
Adapted directly from pi's `runAgentLoop` in `agent-loop.ts`. The loop is the
heart of the system - everything else feeds into or reads from it.
### Pseudocode
```rust
async fn run_agent_loop(
prompts: Vec<AgentMessage>,
context: &AgentContext, // system_prompt + tools (flattened from all extensions) + history
config: &LoopConfig, // model, thinking, hooks, queues
emit: &dyn EventSink,
signal: CancellationToken,
) -> Result<Vec<AgentMessage>> {
let mut messages = context.messages.clone();
messages.extend(prompts);
let mut new_messages: Vec<AgentMessage> = prompts.clone();
emit(AgentStart);
emit(TurnStart);
// Outer loop: restarts on follow-up messages
loop {
// Inner loop: stream LLM → execute tools → repeat
loop {
// 1. Convert AgentMessage[] to LLM format
let llm_messages = convert::to_llm(&messages);
// 2. Stream assistant response
let response = stream_assistant(
&config.model, &context.system_prompt,
&llm_messages, &context.tools, signal
).await?;
// 2a. Emit deltas as they arrive
emit(TextDelta { delta: response.text });
messages.push(response.as_message());
new_messages.push(response.as_message());
// 2b. Handle errors / abort
if response.stop_reason == "error" || signal.is_cancelled() {
emit(AgentEnd { messages: new_messages });
return Ok(new_messages);
}
// 3. Execute tool calls (parallel by default)
if !response.tool_calls.is_empty() {
for tc in &response.tool_calls {
emit(ToolCall { id: tc.id, name: tc.name, args: tc.args });
let result = execute_tool(&context.tools, tc, signal).await;
let msg = AgentMessage::tool_result(tc.id, &result);
emit(ToolResult { ... });
messages.push(msg);
new_messages.push(msg);
}
// Loop continues - tool results go back to LLM
continue;
}
// 4. No tool calls - turn complete
emit(TurnEnd);
// 5. Check steering queue (inject mid-run)
if let Some(steering) = config.poll_steering().await {
messages.push(steering.clone());
new_messages.push(steering);
continue; // re-enter inner loop with steering message
}
break; // inner loop done
}
// 6. Check follow-up queue (only after agent would stop)
if let Some(follow_up) = config.poll_follow_up().await {
messages.push(follow_up.clone());
new_messages.push(follow_up);
continue; // re-enter outer loop
}
break; // outer loop done
}
emit(AgentEnd { messages: new_messages });
Ok(new_messages)
}
```
### Hook pipeline
Hooks live on the `Extension` trait, not on the loop config. When a tool
is about to execute, all extensions are consulted:
```rust
// In execute_tool():
for ext in &context.extensions {
if let Some(reason) = ext.before_tool_call(&tool_call, &context).await {
return ToolResult::blocked(reason);
}
}
let result = tool.execute(args).await;
for ext in &context.extensions {
if let Some(override) = ext.after_tool_call(&tool_call, &result).await {
// patch result
}
}
```
Every hook receives the agent's `CancellationToken` and must honour it.
### Queue modes
- **Steering queue**: injected after the current assistant turn finishes
executing tool calls. Used for mid-run user input. (Not yet implemented.)
- **Follow-up queue**: injected only after the agent would otherwise stop
(no tool calls, no steering). Used for post-run follow-up questions.
(Not yet implemented.)
- **TUI-level queuing** (implemented): When `is_streaming`, `submit_message`
queues to `App.queued_messages` instead of spawning concurrent loops.
Queued messages display between chat and editor. On `AgentEnd`, next
queued message auto-submits. Ctrl+C restores queue to editor.
- Both support `one-at-a-time` and `all` drain modes.
### Tool execution modes
| `parallel` (default) | Preflight all tool calls, execute concurrent, emit results in source order |
| `sequential` | Execute one tool at a time, feed result before starting next |
A tool can override the global mode via `AgentTool::execution_mode`.
---
## Session layer (`src/agent/session.rs`)
### Format
JSONL file, one object per line. Same format as pi's sessions.
```jsonl
{"id":"01J...1","parentId":null,"role":"user","content":"list .rs files","timestamp":1700000000000}
{"id":"01J...2","parentId":"01J...1","role":"assistant","content":"Found 3 files: ...","usage":{"input":50,"output":80},"timestamp":1700000001000}
{"id":"01J...3","parentId":"01J...2","role":"toolResult","toolCallId":"tool_01","content":"src/main.rs\nsrc/lib.rs","timestamp":1700000002000}
```
### Storage
```
~/.rab/
├── agent/
│ ├── settings.json # global settings
│ └── auth.json # API keys and OAuth credentials
├── models.json # custom provider/model definitions
├── keybindings.json # custom keybinds (phase 2)
├── SYSTEM.md # custom system prompt (full override)
├── APPEND_SYSTEM.md # appended to system prompt
├── AGENTS.md # global context file
├── extensions/ # user extensions (phase 2)
├── skills/ # agent skills
├── themes/ # TUI themes (phase 2)
└── sessions/
└── <cwd-hash>/ # one directory per project
├── 01J...abc.jsonl
└── 01J...def.jsonl
./
├── .rab/
│ └── settings.json # project-local overrides
├── AGENTS.md # project context (also walks parent dirs)
└── CLAUDE.md # alias for AGENTS.md
```
### Session struct
```rust
struct SessionManager {
path: PathBuf, // path to .jsonl file
}
impl SessionManager {
fn create(cwd: &Path) -> Self;
fn open(path: &Path) -> Self;
fn continue_recent(cwd: &Path) -> Option<Self>;
fn append(&mut self, entry: &AgentMessage) -> Result<()>;
fn messages(&self) -> Result<Vec<AgentMessage>>; // walk from root
fn id(&self) -> &str;
}
```
Every entry has a `parentId`, so sessions are a tree from day one. Messages
are resolved by walking from the root along the active branch. Branching
happens when a new entry points to a non-tail parent - no format changes
needed.
## Compaction (`compaction.rs`)
When the conversation approaches the model's context window, older messages
are summarized to free space. Ported from pi's compaction algorithm.
```
Original: [sys] [user1] [asst1+tool] [user2] [asst2+tool] [user3]
Compacted: [sys] [summary_of_1_and_2] [user3]
```
Algorithm:
1. **Check threshold** - estimate total tokens. If under limit, skip.
2. **Find cut point** - walk messages from oldest to newest, accumulating
tokens. Cut where the tail (newest messages) fits in the remaining budget.
3. **Generate summary** - prompt a fast model with the older messages to
produce a concise summary. The summary replaces the older entries.
4. **Replace** - swap old messages with a single synthetic user message
containing the summary. Tool results are included in what gets summarized.
Manual trigger via `/compact` (TUI). Automatic trigger before context
overflow causes an error.
---
## Extension trait (`src/agent/extension.rs`)
All capability - built-in or user-provided - comes through the same trait.
There is no separate tool registration path. **Slash commands use the same
`Extension` trait as tools** - built-in commands (`/quit`, `/model`) and
user-provided commands go through the same `commands()` method and the same
`CommandHandler` interface.
```rust
#[async_trait]
pub trait Extension: Send + Sync {
fn name(&self) -> Cow<'static, str>;
/// Tools this extension provides (LLM-callable).
fn tools(&self) -> Vec<Box<dyn AgentTool>> { vec![] }
/// Slash commands (e.g. `/quit`, `/model`).
/// Built-in commands and extension commands use the same interface.
fn commands(&self) -> Vec<SlashCommand> { vec![] }
/// Called before any tool executes. Return Some(reason) to block.
async fn before_tool_call(&self, _tc: &ToolCall) -> Option<BlockReason> { None }
/// Called after a tool executes. Return Some(text) to replace result.
async fn after_tool_call(&self, _tc: &ToolCall, _result: &str)
-> Option<String> { None }
}
pub trait CommandHandler: Send + Sync {
/// Execute the command. Returns CommandResult (sync - no I/O needed).
fn execute(&self, args: &str) -> anyhow::Result<CommandResult>;
/// Get argument completions for autocomplete.
fn argument_completions(&self, prefix: &str) -> Vec<AutocompleteItem>;
}
pub enum CommandResult {
Info(String), // Show info message
Quit, // Request graceful shutdown
ModelChanged(String), // Switched to new model
}
pub struct SlashCommand {
pub name: String, // e.g. "quit"
pub description: String, // e.g. "Exit rab"
pub handler: Box<dyn CommandHandler>,
}
```
At startup, extensions are collected from builtins and (later) user-provided
paths. Tools and commands are derived by flattening all extensions:
```rust
fn collect_tools(exts: &[Box<dyn Extension>]) -> Vec<Box<dyn AgentTool>> {
exts.iter().flat_map(|ext| ext.tools()).collect()
}
fn collect_commands(exts: &[Box<dyn Extension>]) -> Vec<SlashCommand> {
exts.iter().flat_map(|ext| ext.commands()).collect()
}
```
`--no-builtin-tools` simply skips loading builtin extensions; user extensions
still load. `--no-extensions` skips both.
## Built-in extensions (`builtin/`)
Each built-in is an `Extension` that provides tools or commands. They serve
as the reference implementation for user extensions.
### commands
Provides core slash commands (`/quit`, `/model`) via the `CommandHandler` trait.
Same interface as user-provided commands - no special path for built-ins.
| `/quit` | `QuitCommand` | Returns `CommandResult::Quit`, TUI breaks event loop |
| `/model <name>` | `ModelCommand` | Switches active model; no args shows available models |
`/model` provides argument completions via `argument_completions()` - when the
user types `/model ` followed by a partial model name, matching models are
suggested.
### read
| **Parameters** | `path: string`, `offset?: int`, `limit?: int` |
| **Behaviour** | Reads file contents. Prefixed line numbers. Truncated at 50KB. |
| **Image support** | If path ends in `.png`/`.jpg`/`.gif`/`.webp`, reads as base64 image (passed via the `Provider` trait's multimodal payload, adapter-specific) |
### write
| **Parameters** | `path: string`, `content: string` |
| **Behaviour** | Creates parent directories. Writes to temp file, then atomic rename. Returns success/error message. |
### edit
| **Parameters** | `path: string`, `search: string`, `replace: string` |
| **Behaviour** | Reads file, finds exact-match `search`, replaces with `replace`. Error if `search` appears zero or >1 times. |
### bash
| **Parameters** | `command: string`, `timeout_secs?: int` (default 120) |
| **Behaviour** | Runs `sh -c <command>`. Captures stdout + stderr combined. Truncated to last 2000 lines / 50KB. |
| **Security** | Command deny-list (optional for MVP). Working directory is the project root. |
## Slash commands
**Slash commands use the same `Extension` trait as tools.** Built-in commands
and extension commands go through the same `CommandHandler` interface - there
is no separate path for core vs. user commands.
When the user types a slash command (e.g. `/quit`, `/model deepseek-v4-pro`),
the TUI dispatches it to the matching `CommandHandler::execute()`. The result
(`CommandResult`) determines what happens: show info, quit, or switch models.
### Built-in commands (via `CommandsExtension`)
| `/quit` | `CommandResult::Quit` | Graceful shutdown |
| `/model <name>` | `CommandResult::ModelChanged(name)` or `Info` | Switch model; no args lists available models |
More commands will be added as the session layer matures (`/new`, `/compact`, etc.).
### Extension commands
User extensions provide commands through the exact same interface:
```rust
// In any Extension impl:
fn commands(&self) -> Vec<SlashCommand> {
vec![SlashCommand {
name: "mycommand".into(),
description: "Does something useful".into(),
handler: Box::new(MyCommandHandler),
}]
}
```
Conflict resolution: first registered wins (builtins first, then user extensions
in load order). Commands are deduplicated by name when collected.
---
## System prompt (`system_prompt.rs`) ✅
Built from the same sources as pi, concatenated via `SystemPromptBuilder`:
1. **Default prompt** or **custom SYSTEM.md** - tool descriptions, response format.
Custom prompt from `~/.rab/SYSTEM.md` (global) or `.rab/SYSTEM.md` (project) replaces default.
2. **Append prompt** - `APPEND_SYSTEM.md` appended after custom or default.
3. **Project context files** - walked up from cwd, loads `AGENTS.md` and
`CLAUDE.md` (alias) from every ancestor directory. Each file wrapped in
`<project_instructions path="...">` tags inside `<project_context>`.
4. **Skills** - available skills listed as `<available_skills><skill name="...">` XML.
5. **Date and cwd** - `Current date: YYYY-MM-DD` and `Current working directory: /path`.
CLI flags: `--no-context-files` / `-nc`, `--system-prompt <text>`, `--append-system-prompt <text>`.
See `src/agent/context_files.rs` for the discovery logic.
---
## Provider trait (`src/agent/provider.rs`)
rab defines its own provider abstraction. The agent loop depends on this
trait, never on genai directly. To swap backends, write a new impl - no
changes to `src/agent/`.
```rust
/// Events emitted during a streaming LLM request.
pub enum StreamEvent {
TextDelta { text: String },
ThinkingDelta { text: String },
ToolCall { id: String, name: String, arguments: String },
Done {
text: String,
usage: Usage,
stop_reason: StopReason,
tool_calls: Vec<ToolCall>,
},
Error { message: String },
}
pub enum StopReason {
EndTurn,
ToolUse,
MaxTokens,
Error,
}
/// The one thing the agent loop needs from a provider.
#[async_trait]
pub trait Provider: Send + Sync {
async fn stream(
&self,
model: &str,
system_prompt: &str,
messages: &[AgentMessage],
tools: &[ToolDef],
signal: CancellationToken,
) -> Result<Pin<Box<dyn Stream<Item = StreamEvent> + Send>>>;
}
```
The trait takes `AgentMessage` directly - no intermediate conversion layer.
Each adapter translates rab types into its own backend format internally.
### Genai adapter (`adapter/genai.rs`)
The **only file** that imports genai. Wraps `genai::Client`, configured for the
target provider.
**PoC:** Client configured for OpenCode Go (`https://opencode.ai/zen/go/v1`)
using genai's OpenAI adapter. Models: `deepseek-v4-flash` (default), `deepseek-v4-pro`.
**Phase 1:** Extended with provider auto-detection from model name prefix -
`claude*` → Anthropic, `gpt*` → OpenAI, `gemini*` → Google, fallback → Ollama.
OpenCode Go remains available as an explicit provider.
```rust
pub struct GenaiProvider {
client: genai::Client,
}
#[async_trait]
impl Provider for GenaiProvider {
async fn stream(&self, model: &str, system: &str,
messages: &[AgentMessage], tools: &[ToolDef],
signal: CancellationToken)
-> Result<Pin<Box<dyn Stream<Item = StreamEvent> + Send>>>
{
let req = ChatRequest::new(to_genai_messages(messages))
.with_system(system)
.with_tools(to_genai_tools(tools));
let genai_stream = self.client
.exec_chat_stream(model, req, None).await?;
Ok(Box::pin(genai_stream.map(|ev| convert_event(ev))))
}
}
```
`src/agent/` only sees `Box<dyn Provider>`.
Before the provider call, `transform_context` can prune or inject
AgentMessages (e.g. for compaction, later).
---
## Settings (`src/agent/settings.rs`) — ✅
Same file names and format as pi, under `~/.rab/agent/`. Auth in `~/.rab/agent/auth.json`,
settings in `~/.rab/agent/settings.json`. Keybindings in `~/.rab/keybindings.json`.
`~/.rab/models.json` for custom provider/models is not yet implemented.
### Config files
| `~/.pi/agent/settings.json` | `~/.rab/agent/settings.json` | ✅ |
| `.pi/settings.json` | `.rab/settings.json` | ✅ |
| `~/.pi/agent/auth.json` | `~/.rab/agent/auth.json` | ✅ |
| `~/.pi/agent/models.json` | `~/.rab/models.json` | ⬜ |
| `~/.pi/agent/AGENTS.md` | `~/.rab/AGENTS.md` | ✅ |
| `AGENTS.md` / `CLAUDE.md` | `AGENTS.md` / `CLAUDE.md` | ✅ |
| `~/.pi/agent/keybindings.json` | `~/.rab/keybindings.json` | ✅ |
| `~/.pi/agent/sessions/` | `~/.rab/sessions/` | ✅ |
| `~/.pi/agent/skills/` | `~/.rab/skills/` | ✅ |
| `~/.pi/agent/themes/` | `~/.rab/themes/` + embedded | ✅ |
### `settings.json` format
Same JSON schema as pi:
```json
{
"defaultModel": "deepseek-v4-flash",
"defaultThinkingLevel": "high",
"defaultProvider": "opencode_go",
"tools": ["read", "write", "edit", "bash"],
"excludeTools": [],
"theme": "dark",
"verbose": false
}
```
### `auth.json` format
Same JSON schema as pi:
```json
{
"opencode_go": {
"type": "api_key",
"key": "oc_..."
}
}
```
Load order: global `~/.rab/agent/settings.json` first, then project `.rab/settings.json`
overlays. CLI flags (`--model`, `--thinking`, `--no-tools`) take precedence
over both.
---
## CLI (`cli.rs`)
```
rab [OPTIONS] [MESSAGE]...
Modes:
(default) Print mode with piped stdin support
Session:
-c, --continue Continue most recent session in cwd
--session PATH Open specific session file
--no-session Ephemeral, don't save
Model:
--model MODEL Model name (provider auto-detected from name via adapter)
Tools:
--no-tools Disable all tools (chat-only mode)
Context:
-nc, --no-context-files Skip AGENTS.md loading
--system-prompt <text> Replace default system prompt
--append-system-prompt <text> Append to system prompt
Other:
-h, --help
-V, --version
```
Model auto-detection: `gpt*` → OpenAI, `claude*` → Anthropic, `gemini*` → Gemini,
fallback → Ollama.
---
## Run modes
### Print mode
```
$ rab -p "What does git status do?"
Shows the current state of the working directory and staging area...
```
```
```
Streams the response to stdout. Thinking blocks shown dimmed. Tool calls and
results shown prefixed.
### Interactive mode
Same agent loop, different sink: `tui.rs` subscribes to the agent event
stream and renders to a pi-tui-style main-screen TUI instead of stdout.
Same crate - no separate abstraction layer needed.
---
## TUI (`src/tui/` + `src/agent/ui/`) — ✅ COMPLETE
The TUI library is a 1/1 port of pi's `@earendil-works/pi-tui` (29 modules, 632 tests).
See [`tui.md`](tui.md) for the full design.
**Core**: Component trait, Container, Focusable, Screen diff renderer, overlay system (show/hide/composite), cursor marker extraction, hardware cursor positioning, synchronized output.
**Terminal**: TerminalTrait, ProcessTerminal, Kitty keyboard protocol (flags 1+2+4), bracketed paste, progress indicator, drainInput, setTitle, OSC 2031 color scheme notifications. No direct crossterm in app layer.
**Keys & keybindings**: String-based key IDs (match_key_id, key_event_to_id), 27 action IDs with defaults, JSON config loading from `~/.rab/keybindings.json`, all 7 components migrated to `get_keybindings().matches()`.
**Components**:
- Editor (multi-line, word-wrap, kill ring, undo stack, paste markers, bracketed paste, history recall, character jump, sticky column, border_color, AutocompleteProvider integration)
- Input (single-line, undo coalescing, smart scroll centering, paste handling)
- SelectList (two-column layout, layout options, prefix filter, selection change callback)
- SettingsList (submenu support, two-column layout, description wrapping, search)
- Loader / CancellableLoader (color functions, timer-based animation, abort support)
- Box (render cache), Text/TruncatedText (RefCell cache), Spacer
- Image (Kitty protocol image rendering, data URL detection)
- Diff (unified diff with colored +/lines and intra-line character-level inverse)
- VisualTruncate (shared `truncate_to_visual_lines()` utility)
**Autocomplete**: AutocompleteProvider trait, CombinedAutocompleteProvider (slash commands + file path completion via read_dir)
**App layer**: ChatEditor, Messages, WorkingIndicator, Footer, ModelSelector, HelpOverlay, BashExecution. All wired through `TUI.show_overlay()`. Header/messages/editor/footer layout matching pi exactly.
### Layout (component tree)
```
Terminal (main screen, no alternate screen):
TUI.root (Container):
├── HeaderComponent
│ ├── "rab" logo
│ └── keybinding hints (expandable via Ctrl+O)
│
├── chat_container (RefContainer)
│ ├── UserMessageComponent
│ │ └── Box(userMessageBg) + Markdown(userMessageText) + OSC133
│ ├── ToolExecComponent
│ │ ├── Background: toolPendingBg → toolSuccessBg/toolErrorBg
│ │ ├── Header: per-tool formatting (read docs/path, $ command, write path, etc.)
│ │ └── Result: syntax-highlighted (read) / diff-colored (edit) / plain (toolOutput)
│ ├── RcRefCellComponent (streaming)
│ │ └── AssistantMessageComponent: Markdown + thinking blocks + OSC133
│ ├── BashExecutionComponent
│ │ ├── Borders (bashMode color), spinner, streaming output
│ │ └── Duration: "Elapsed X.Xs" / "Took X.Xs"
│ ├── InfoMessageComponent (dim text)
│ └── Spacer(1) between each (via chat_add helper)
│
├── pending_section (DynamicLines — streaming text/thinking)
├── status_section (DynamicLines — transient status)
├── queued_section (DynamicLines — ◷ queued messages)
├── working_section (DynamicLines — ⠋ Working...)
├── EditorComponent (border color: thinking level / bash mode)
└── FooterComponent (cwd, git branch, token usage, model, auto-compact)
```
The component tree is rendered recursively: `TUI.render()` calls
`root.render(width)` which calls each child's `render()` in order. The
resulting lines are composited with overlays and diff-rendered via `Screen`.
Tool execution components use `Rc<RefCell<>>` + `Weak` for in-place updates:
- `streaming_component: Option<Weak<RefCell<AssistantMessageComponent>>>`
— created on first `TextDelta`, updated with `append_text()`/`add_thinking()`
- `pending_tools: HashMap<String, Weak<RefCell<ToolExecComponent>>>`
— created on `ToolCall`, updated with `set_result()` on `ToolResult`
- `bash_component: Option<Weak<RefCell<BashExecution>>>`
— created on bang command, updated with `append_chunk()` on `ToolProgress`
### Message queuing
When the user submits a message while streaming, it is **queued** (not sent to
a new concurrent agent loop). Queued messages appear between chat and editor
(pi's `pendingMessagesContainer` style). On `AgentEnd`, the next queued
message is automatically submitted. Ctrl+C during streaming restores queued
messages to the editor for editing.
### Transient status
Toggle messages (Ctrl+T thinking, Ctrl+O tool output, model switch, interrupt)
use a transient `status_text: Option<String>` that appears for one frame then
clears, matching pi's `showStatus()` behavior. Status messages replace the
previous status - they don't accumulate in the chat history.
---
## Phase 2
### User extensions (compile-time)
Same `Extension` trait used by builtins. To add a custom tool, implement the
trait and register it at startup:
```rust
struct MyTool;
impl Extension for MyTool {
fn name(&self) -> &str { "my-tool" }
fn tools(&self) -> vec![ /* ... */ ]
}
// main.rs - alongside builtins
if !args.no_extensions {
exts.push(Box::new(MyTool));
}
```
### Dynamic plugin system (WASM via wasmtime)
The primary mechanism for user plugins. Plugins are `.wasm` components loaded
at runtime from `~/.rab/extensions/` (global) and `.rab/extensions/` (project).
WASM was chosen because: sandboxed by default (plugin crashes can't take down the
host), hot reload is trivial (recompile → replace file), and the WIT interface
is stable across Rust compiler versions (unlike C ABI for dylib). For plugins that
need C libraries (rare), a native dylib escape hatch can be added later.
#### Architecture
```
┌──────────────────────────────────────────────────────────┐
│ rab host │
│ │
│ ┌──────────────────────┐ ┌────────────────────────┐ │
│ │ BuiltinExtension │ │ WasmExtension │ │
│ │ (read/write/...) │ │ │ │
│ │ impl Extension │ │ impl Extension { │ │
│ │ │ │ name() │ │
│ │ │ │ tools() → delegates │ │
│ │ │ │ to wasm instance │ │
│ │ │ │ } │ │
│ └──────────────────────┘ └───────────┬────────────┘ │
│ │ WIT calls │
│ ┌─────────▼────────────┐ │
│ │ PluginRegistry │ │
│ │ ├─ wasmtime Engine │ │
│ │ ├─ Vec<LoadedPlugin> │ │
│ │ ├─ load(path) │ │
│ │ ├─ unload(name) │ │
│ │ └─ reload_all() │ │
│ └───────────────────────┘ │
└──────────────────────────────────────────────────────────┘
```
`WasmExtension` implements the host `Extension` trait by forwarding calls into
a wasm component via WIT bindings. The agent loop sees no difference between
a builtin and a wasm plugin.
#### WIT interface
```wit
package rab:plugin;
interface types {
record tool-def {
name: string,
description: string,
parameters: string, // JSON Schema
label: string,
}
record slash-command {
name: string,
description: string,
}
}
world rab-plugin {
export name: func() -> string;
export tools: func() -> list<tool-def>;
export commands: func() -> list<slash-command>;
// tool-call-id and args-json come as strings; host owns serialization
export execute-tool: func(
tool-name: string,
tool-call-id: string,
args-json: string
) -> result<string, string>;
}
```
#### Plugin author experience
A `rab-plugin-sdk` crate provides a `Plugin` trait and an `export_plugin!` macro
that hides WIT internals. Plugin authors write plain Rust - no `extern "C"`,
no `#[repr(C)]`, no unsafe:
```rust
// my-plugin/src/lib.rs
use rab_plugin_sdk::*;
struct MyPlugin;
impl Plugin for MyPlugin {
fn name() -> String { "my-tool".into() }
fn tools() -> Vec<ToolDef> {
vec![ToolDef {
name: "hello".into(),
description: "Says hello".into(),
parameters: json!({"type": "object", "properties": {}}),
label: "Hello Tool".into(),
}]
}
fn execute(tool: &str, call_id: &str, args: Value) -> Result<String, String> {
match tool {
"hello" => Ok(format!("Hello from plugin! call_id={call_id}")),
_ => Err(format!("Unknown tool: {tool}")),
}
}
}
export_plugin!(MyPlugin);
```
Minimal `Cargo.toml`:
```toml
[package]
name = "my-plugin"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
rab-plugin-sdk = "0.1"
serde_json = "1"
```
Build: `cargo build --target wasm32-wasip2 --release`, copy the `.wasm` to
`~/.rab/extensions/`. The host picks it up on next startup (and on `/reload`
in TUI mode).
Scaffolding: `rab plugin new my-plugin` generates the Cargo.toml, lib.rs, and
WIT file automatically.
#### Plugin lifecycle
| **Load** | `PluginRegistry::load(path)` compiles `.wasm` → native via cranelift, instantiates the component, calls `name()` and `tools()` for metadata |
| **Unload** | Drop the `WasmExtension` + `Store` + `Instance`. Wasmtime releases all guest memory |
| **Hot reload** | File watcher (`notify` crate) detects change → unload old → load new. `/reload-plugins` slash command for manual trigger |
| **Validation** | On load, call `self-test` export (optional). If it returns `err`, print warning, skip plugin |
| **Error isolation** | If tool execution panics inside wasm, wasmtime catches it as a `Trap`. `WasmExtension` returns `ToolResult::error`. Host process unaffected |
| **Resource limits** | `Store::set_fuel(1_000_000)` ~covers most tool logic. Configurable per-plugin in `.rab/extensions.toml` (future) |
#### Directory layout
```
~/.rab/extensions/ ← global plugins (available in every project)
├── hello.wasm
├── code-reviewer/
│ ├── code-reviewer.wasm
│ └── config.toml ← plugin metadata, resource limits (future)
└── sources/ ← optional: keep source alongside
./.rab/extensions/ ← project-local plugins
└── project-tool.wasm
```
#### Future: native dylib escape hatch
For plugins that need C libraries (e.g. `libgit2`, `tree-sitter`), a parallel
native path via `crate-type = ["cdylib"]` + `dlopen2`. Same `Plugin` trait,
different compile target. Requires matching Rust compiler version between host
and plugin - documented as a power-user feature, not the default path.
### Skills ✅
Skills are markdown files following the [Agent Skills standard](https://agentskills.io).
Loaded from `~/.rab/skills/` and `.rab/skills/` via `src/agent/skills.rs`.
**Implemented:**
- `load_skills()` - discovers SKILL.md files, parses YAML-like frontmatter (`name:`, `description:`, `disable-model-invocation:`)
- `format_skills_for_prompt()` - lists available skills as `<available_skills>` XML in the system prompt
- `format_skill_invocation()` - creates `<skill name="..." location="...">` blocks for explicit invocation
- `expand_skill_command()` - `/skill:name [args]` expansion in user input (pi-style)
- Startup resource listing shows skill names in the welcome message
### pi-mcp-adapter
An `Extension` that connects to MCP (Model Context Protocol) servers and
exposes their tools to the agent. Uses the `rmcp` crate for client-side MCP
protocol (stdio + SSE transports). Each connected MCP server's tools appear
as regular `AgentTool` instances, indistinguishable from builtins.
```rust
struct McpAdapter;
impl Extension for McpAdapter {
fn name(&self) -> &str { "mcp-adapter" }
fn tools(&self) -> Vec<Box<dyn AgentTool>> {
// Discover and wrap MCP server tools
self.mcp_clients.iter()
.flat_map(|client| client.list_tools())
.map(|tool| Box::new(McpToolWrapper::new(tool)))
.collect()
}
}
```
Configured via `.rab/mcp.json`:
```json
{
"servers": [
{ "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"] },
{ "url": "https://mcp.example.com/sse" }
]
}
```
This mirrors what pi users do with MCP extensions, but as a first-party
built-in Phase 2 extension.
---
## Open questions
- **Dynamic extension loading** - Resolved: WASM via wasmtime + WIT
Component Model. See §Dynamic plugin system.
- **OAuth** - genai's `AuthResolver` supports dynamic tokens, but browser
login flow, token refresh, and credential storage need a separate crate
(e.g. `oauth2`). MVP uses env API keys only.
- **Image paste in TUI** - clipboard integration differs per platform (wl-paste,
pbpaste, PowerShell). Kitty protocol covers display; input is TBD.
- **Command deny-list** - bash tool currently runs anything. A deny-list or
sandbox (bubblewrap, landlock) should be configurable per project.
- **Multi-model cycling** - Ctrl+P model switching like pi requires a model
registry. genai auto-detects from name prefix; a full registry with metadata
(context window, costs) is future work.
- **Provider fallback** - if the primary provider fails, should rab retry
with another? pi doesn't do this; worth considering.
---
## Dependency tree
```
rab (EPL-2.0)
├── genai 0.6 (Apache 2.0) - isolated: only adapter/genai.rs imports this
├── clap 4 (MIT) - CLI parsing
├── tokio 1 (MIT) - async runtime
├── serde + serde_json 1 (MIT) - JSON serialization
├── uuid 1 (MIT) - message/session IDs
├── chrono 0.4 (MIT) - timestamps
├── directories 5 (MIT) - XDG paths
├── anyhow 1 (MIT) - error handling
├── futures 0.3 (MIT) - StreamExt
├── async-trait 0.1 (MIT) - trait async fn
├── colored 2 (MPL-2.0) - terminal colors
├── tracing 0.1 (MIT) - structured logging
├── crossterm 0.29 (MIT) - terminal I/O (raw mode, events, cursor)
├── unicode-segmentation 1 (MIT) - grapheme-aware cursor movement
├── unicode-width 0.2 (MIT) - character display width
├── wasmtime 26+ (Apache 2.0) - WASM runtime for dynamic plugins (phase 2)
├── notify 7 (CC0-1.0) - file watcher for plugin hot reload (phase 2)
└── rmcp 1 (MIT) - MCP client for pi-mcp-adapter (phase 2)
```
No GPL dependencies. All are permissive (MIT / Apache 2.0 / MPL-2.0 / CC0-1.0), fully
compatible with EPL-2.0. genai is the only external provider dependency and
is swappable via the `Provider` trait - replace or remove it without touching
core logic.
Phase 2 dependencies (wasmtime, notify, rmcp)
are gated behind Cargo features: `plugins` and `mcp`. MVP compiles
without them.