rab-agent 0.1.0

rab is a lightweight, extensible, Rust-based coding agent.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
# rab TUI Library Design

This document plans the Rust port of pi-tui - a main-screen, diff-rendering terminal UI library built on crossterm. It separates the **core TUI library** (`src/tui/`) from **rab-specific UI** (`src/agent/ui/`), mirroring how pi splits `@earendil-works/pi-tui` from the coding-agent's app components.

---

## Architecture Overview

```
┌──────────────────────────────────────────────────┐
│  src/agent/ui/     rab-specific UI               │
│  ChatEditor, Messages, Footer, ModelSelector, …  │
│                                                  │
│  src/tui/          core TUI library              │
│  Component trait, diff renderer, text primitives │
│  Editor, SelectList, Loader, Key handling, Utils │
│                                                  │
│  crossterm         terminal I/O                  │
│  unicode-segmentation  grapheme clusters         │
│  unicode-width     character width               │
└──────────────────────────────────────────────────┘
```

`src/tui/` is generic and reusable. `src/agent/ui/` is rab's app. There is no ratatui dependency.

---

## Component Catalog

### Tier 1: Core TUI Library (`src/tui/`) ✅ IMPLEMENTED

All Tier 1 components are implemented and tested. 83 tests pass with zero warnings.

#### Structural Primitives

| Component | pi-tui src | Rust module | Purpose |
|---|---|---|---|
| **Component** (trait) | `tui.ts:64` | `src/tui/component.rs` (✅ 21 lines) | Core trait: `render(width) -> Vec<String>`, `handle_input(key) -> bool`, `invalidate()` |
| **Focusable** (trait) | `tui.ts:104` | `src/tui/focusable.rs` (✅ 12 lines) | `focused: bool` - enables IME cursor marker emission |
| **Container** | `tui.ts:256` | `src/tui/container.rs` (✅ 72 lines) | Extends Component. `children: Vec<Box<dyn Component>>`, `add_child()`, `clear()`. Renders children vertically. |
| **Text** | `components/text.ts` (106 lines) | `src/tui/components/text.rs` (✅ 142 lines) | Multi-line text. Word wrapping at width, configurable padding. Optional background color function. |
| **TruncatedText** | `components/truncated-text.ts` (65 lines) | `src/tui/components/truncated_text.rs` (✅ 72 lines) | Text truncated to width with configurable ellipsis. |
| **Spacer** | `components/spacer.ts` (28 lines) | `src/tui/components/spacer.rs` (✅ 38 lines) | N empty lines of vertical space. |
| **Box** | `components/box.ts` (137 lines) | `src/tui/components/box.rs` (✅ 113 lines) | Container with padding and background color function. Children rendered offset inside the box. |

#### Interactive Components

| Component | pi-tui src | Rust module | Purpose |
|---|---|---|---|
| **Editor** | `components/editor.ts` (2,307 lines) | `src/tui/components/editor.rs` (✅ 776 lines) | **Full port.** Multi-line text editor. Emacs keybindings, word-wrap layout, grapheme-aware cursor, kill-ring (C-y/M-y), undo stack, history recall (up/down), vertical scroll. Implements `Component + Focusable`. ~776 lines vs pi's 2,307 (Rust is denser). |
| **Input** | `components/input.ts` (447 lines) | `src/tui/components/input.rs` (✅ 549 lines) | **Full port.** Single-line text input. `> prompt text` layout. Horizontal scrolling, grapheme-aware cursor, kill-ring (C-w/C-u/C-k/C-y/M-y), undo stack, `Focusable` (IME marker). |
| **Loader** | `components/loader.ts` (92 lines) | `src/tui/components/loader.rs` (✅ 109 lines) | Animated spinner. Configurable frames, interval, message text. `start()`/`stop()`/`tick()`. |
| **CancellableLoader** | `components/cancellable-loader.ts` (40 lines) | `src/tui/components/cancellable_loader.rs` (✅ 82 lines) | Loader with escape-to-cancel. Shows cancel hint. |

#### Selection Components

| Component | pi-tui src | Rust module | Purpose |
|---|---|---|---|
| **SelectList** | `components/select-list.ts` (229 lines) | `src/tui/components/select_list.rs` (✅ 305 lines) | Scrollable list with fuzzy search. Items have label + optional description. Arrow nav, enter to select, esc to cancel. Themed highlighting. Uses `fuzzy_filter()` internally. |
| **SettingsList** | `components/settings-list.ts` (250 lines) | `src/tui/components/settings_list.rs` (✅ 353 lines) | **Full port.** Toggleable settings list. Each item has id, label, description, currentValue, optional `values[]` to cycle. Optional fuzzy search (uses `Input` internally). Enter/Space cycles values, Esc cancels. |

#### Editor Support Modules (core utilities)

| Module | pi-tui src | Rust module | Purpose |
|---|---|---|---|
| **KillRing** | `kill-ring.ts` (46 lines) | `src/tui/kill_ring.rs` (✅ 128 lines) | Ring buffer for Emacs kill/yank. `push(text, opts)`, `peek()`, `rotate()`, `len()`. Supports prepend/append accumulation for consecutive kills. |
| **UndoStack** | `undo-stack.ts` (28 lines) | `src/tui/undo_stack.rs` (✅ 73 lines) | Generic undo stack. `push(snapshot) -> ()`, `pop() -> Option<T>`, `clear()`. Editor snapshots its full state before each mutation. |
| **WordNav** | `word-navigation.ts` (117 lines) | `src/tui/word_nav.rs` (✅ 281 lines) | `find_word_backward(text, cursor) -> usize`, `find_word_forward(text, cursor) -> usize`. Handles word boundaries, CJK, punctuation segments. |

#### Core Infrastructure (non-Component)

| Module | pi-tui src | Rust module | Purpose |
|---|---|---|---|
| **Screen** | `tui.ts:doRender()` (~500 lines) | `src/tui/screen.rs` (✅ ~390 lines) | The diff renderer. Maintains `prev_lines: Vec<String>`, computes changed ranges, emits minimal ANSI (cursor moves + line clears + new text). Handles resize, append, shrink. **Viewport tracking**: `viewport_top` updated on scroll and at end of render (`max(viewport_top, render_end - height + 1)`). `max_lines_rendered` tracked during differential renders for correct `clear_on_shrink`. Wraps output in synchronized output. |
| **Terminal** | `terminal.ts` (531 lines) | `src/tui/terminal.rs` (✅ 125 lines) | Wraps crossterm: raw mode, event polling, resize, cursor hide/show, cursor positioning, line clear, synchronized output. |
| **Key** | `keys.ts` (1,400 lines) | `src/tui/keys.rs` (✅ 267 lines) | Key identifiers (`Key::Enter`, `Key::Up`, `Key::Ctrl('c')`, `Key::CtrlShift('p')`). `matches_key(event, key) -> bool`. Wraps crossterm's `KeyEvent` - no Kitty protocol parsing needed. |
| **Util** | `utils.ts` (1,188 lines) | `src/tui/util.rs` (✅ 817 lines) | `visible_width(s) -> usize` (strip ANSI, measure Unicode). `truncate_to_width(s, w) -> String`. `wrap_text_with_ansi(s, w) -> Vec<String>`. `slice_by_column(s, start, end) -> String`. |
| **Fuzzy** | `fuzzy.ts` (137 lines) | `src/tui/fuzzy.rs` (✅ 263 lines) | `fuzzy_match(query, text) -> FuzzyMatch` with score and match positions. `fuzzy_filter(query, items) -> Vec<usize>`. Supports swapped alphanumeric tokens. |
| **Autocomplete** | `autocomplete.ts` (300 lines) | `src/tui/autocomplete.rs` (✅ 360 lines) | `AutocompleteProvider` trait, `CombinedAutocompleteProvider` (slash commands + file paths), `AutocompleteItem`, `AutocompleteSuggestions`, `SlashCommand`. File path completion via `std::fs::read_dir`. |
| **Theme** | `src/tui/theme.rs` (✅ 34 lines) | Trait for colors. `fg(color: &str, text: &str) -> String`, `bg(color: &str, text: &str) -> String`, `bold(text: &str) -> String`. Concrete implementation in `src/agent/ui/` with JSON configs, variable resolution, truecolor+256 fallback. |

#### Deliberately Skipped (not needed for rab)

pi-tui components we are NOT porting:

| Component | Reason |
|---|---|
| Markdown | Rab doesn't render markdown in TUI |
| Image | No terminal image support needed |
| KeybindingsManager | Rab has `Keybindings` struct with defaults, JSON load/save — covers pi's functionality |
| StdinBuffer | crossterm's `event::read()` handles escape sequence parsing |
| TerminalImage, TerminalColors | Not needed |
| EditorComponent | Interface for custom editors — not needed (rab uses Editor directly) |

---

### Tier 2: App-Specific UI (`src/agent/ui/`) ✅ IMPLEMENTED

These are rab's application components, built on `src/tui/` primitives. They are NOT part of the core TUI library.

| Component | Rust module | Purpose |
|---|---|---|
| **ChatEditor** | `src/agent/ui/chat_editor.rs` (✅ 102 lines) | Thin wrapper around `tui::Editor`. Provides rab-specific behaviors: slash command list, theme integration. |
| **MessageList** | `src/agent/ui/messages.rs` (✅ 155 lines) | Renders conversation history as styled text lines. Handles: user messages, assistant text, thinking blocks, tool calls, tool results. Respects `hide_thinking`, `collapse_tool_output`. **All lines padded to `width`** via `pad_to_width()`; `pad_to_width()` truncates via `truncate_to_width()` when `visible_width > width` to prevent terminal overflow. |
| **WorkingIndicator** | `src/agent/ui/working.rs` (✅ 73 lines) | Spinner shown during streaming. **Always rendered** (returns 1 empty line when inactive) to keep the composition line count stable and avoid full-screen clears on streaming state changes. |
| **Footer** | `src/agent/ui/footer.rs` (✅ 103 lines) | Two-line footer: cwd + git branch on line 1, token stats + model on line 2. |
| **ModelSelector** | `src/agent/ui/model_selector.rs` (✅ 96 lines) | Full-screen overlay for picking a model. Uses `tui::SelectList`. Searchable. |
| **HelpOverlay** | `src/agent/ui/help.rs` (✅ 98 lines) | `/help` display showing available commands and keybindings. |
| **Theme** | `src/agent/ui/theme.rs` + `themes/dark.json`, `themes/light.json` (✅ 380+ lines) | Full JSON-based theme system. Loads `dark.json`/`light.json` embedded + custom `~/.rab/themes/*.json`. Variable resolution, truecolor + 256 fallback via cube mapping, `COLORFGBG` terminal detection, global singleton (`init_theme()`/`current_theme()`/`set_theme()`), convenience helpers (`accent()`, `dim()`, `muted()`, `bold_accent()`, etc.). |
| **BashExecution** | `src/agent/ui/components/bash_execution.rs` (✅ 130 lines) | Styled bash command rendering. Top/bottom borders in status-aware color, command header with `$`, output in `toolOutput` color, preview truncation, expand/collapse. `BashStatus` (Running/Complete/Cancelled/Error). |
| **App** | `src/agent/ui/app.rs` (✅ ~930 lines) | Main event loop and state. Owns the `tui::Screen`, composes the component tree each tick, dispatches input, handles agent events (streaming deltas → message list). **Pi-style header**, **queued messages** (submitted while streaming, displayed between chat and editor), **streaming text** (`pending_text`/`pending_thinking` rendered inline), **message queuing** (no concurrent loops), **working indicator always rendered** (empty line when inactive). |

### Pi Reference: Where App Components Live in pi

```
packages/tui/src/                    ← @earendil-works/pi-tui (core library)
├── components/
│   ├── text.ts                      → text.rs ✅
│   ├── spacer.ts                    → spacer.rs ✅
│   ├── box.ts                       → box.rs ✅
│   ├── loader.ts                    → loader.rs ✅
│   ├── cancellable-loader.ts        → cancellable_loader.rs ✅
│   ├── select-list.ts               → select_list.rs ✅
│   ├── settings-list.ts             → settings_list.rs ✅
│   ├── editor.ts                    → editor.rs ✅
│   ├── input.ts                     → input.rs ✅
│   ├── markdown.ts                  (skipped - rab doesn't need Markdown rendering)
│   ├── image.ts                     (skipped - no terminal image support)
│   └── truncated-text.ts           → truncated_text.rs ✅
├── tui.ts                           → screen.rs + component.rs + focusable.rs + container.rs ✅
├── terminal.ts                      → terminal.rs ✅
├── keys.ts                          → keys.rs ✅
├── utils.ts                         → util.rs ✅
├── fuzzy.ts                         → fuzzy.rs ✅
├── kill-ring.ts                     → kill_ring.rs ✅
├── undo-stack.ts                    → undo_stack.rs ✅
├── word-navigation.ts               → word_nav.rs ✅
└── ...

packages/coding-agent/src/modes/interactive/components/
├── bordered-loader.ts               (skipped - not needed in rab)
├── dynamic-border.ts                (skipped - theme handles borders)
├── assistant-message.ts             → messages.rs ✅
├── model-selector.ts                → model_selector.rs ✅
├── session-selector.ts              (skipped - not needed)
├── settings-selector.ts             (skipped - not needed)
├── tree-selector.ts                 (skipped - not needed)
└── ...
```

---

## File Structure (✅ implemented)

```
src/
├── tui/                             # Core TUI library
│   ├── mod.rs                       # ✅ Re-exports, module declarations
│   ├── component.rs                 # ✅ Component trait
│   ├── focusable.rs                 # ✅ Focusable trait, CURSOR_MARKER
│   ├── container.rs                 # ✅ Container struct
│   ├── screen.rs                    # ✅ Diff renderer
│   ├── terminal.rs                  # ✅ Crossterm wrapper
│   ├── keys.rs                      # ✅ Key identifiers, matches_key()
│   ├── util.rs                      # ✅ ANSI-aware width, wrap, truncate, slice
│   ├── fuzzy.rs                     # ✅ Fuzzy matching/filtering
│   ├── autocomplete.rs              # ✅ AutocompleteProvider trait, CombinedAutocompleteProvider
│   ├── theme.rs                     # ✅ Theme trait (fg, bg, bold)
│   ├── kill_ring.rs                 # ✅ KillRing
│   ├── undo_stack.rs                # ✅ UndoStack
│   ├── word_nav.rs                  # ✅ Word boundary navigation
│   │
│   └── components/                  # ✅ Built-in Component impls
│       ├── mod.rs
│       ├── text.rs                  # ✅ Text
│       ├── truncated_text.rs        # ✅ TruncatedText
│       ├── spacer.rs                # ✅ Spacer
│       ├── box.rs                   # ✅ Box (TuiBox)
│       ├── loader.rs                # ✅ Loader
│       ├── cancellable_loader.rs    # ✅ CancellableLoader
│       ├── select_list.rs           # ✅ SelectList
│       ├── settings_list.rs         # ✅ SettingsList
│       ├── input.rs                 # ✅ Input
│       └── editor.rs                # ✅ Editor
│
├── agent/                           # ✅ Agent framework
│   ├── mod.rs                       # ✅ Re-exports
│   ├── loop.rs                      # ✅ AgentEvent, LoopConfig, run_agent_loop()
│   ├── extension.rs                 # ✅ AgentTool, Extension, CommandHandler traits
│   ├── types.rs                     # ✅ AgentMessage, Role, ToolCall, Usage
│   ├── provider.rs                  # ✅ Provider trait, StreamEvent, ToolDef
│   ├── settings.rs                  # ✅ Settings load/save
│   ├── session.rs                   # ✅ SessionManager (1985 lines - future split)
│   └── ui/                          # ✅ Interactive mode
│       ├── mod.rs
│       ├── app.rs                   # ✅ Main event loop, App state, run()
│       ├── chat_editor.rs           # ✅ ChatEditor
│       ├── messages.rs              # ✅ MessageList
│       ├── working.rs               # ✅ WorkingIndicator
│       ├── footer.rs                # ✅ Footer
│       ├── model_selector.rs        # ✅ ModelSelector
│       ├── help.rs                  # ✅ HelpOverlay
│       └── theme.rs                 # ✅ RabTheme
│
├── tui/                             # ✅ Generic TUI library
├── builtin/                         # ✅ Tool implementations
├── adapter.rs                       # ✅ GenaiProvider (top-level - external adapter)
├── auth.rs                          # (unchanged)
├── lib.rs                           # ✅ pub mod agent; pub mod adapter; pub mod tui;
└── main.rs                          # ✅ CLI entry point

---

## Dependency Changes in Cargo.toml

```diff
- ratatui = "0.30"
+ crossterm = "0.28"
  unicode-segmentation = "1"   # keep - needed for grapheme-aware editor cursor
```

Keep `unicode-segmentation` - the editor needs `UnicodeSegmentation::graphemes()` for correct cursor movement through emoji, combining characters, and CJK characters. `unicode-width` alone only measures display width, it doesn't iterate grapheme clusters.

Add `unicode-width` for the Util module's `visible_width()`:

```diff
+ unicode-width = "0.2"
```

---

## Component Trait Design

```rust
// src/tui/component.rs

use crossterm::event::KeyEvent;

/// Every renderable UI element.
pub trait Component {
    /// Render to lines for the given viewport width.
    /// Each returned string MUST NOT exceed `width` in visible width.
    fn render(&self, width: usize) -> Vec<String>;

    /// Handle keyboard input. Return `true` if consumed.
    fn handle_input(&mut self, _key: &KeyEvent) -> bool { false }

    /// Clear cached render state. Called on theme changes or resize.
    fn invalidate(&mut self) {}

    /// Whether this component wants focus (for IME cursor positioning).
    fn is_focusable(&self) -> bool { false }
}

/// Components that display a text cursor and need IME support.
pub trait Focusable: Component {
    fn set_focused(&mut self, focused: bool);
    fn focused(&self) -> bool;
}

/// Zero-width APC sequence marking cursor position for IME.
pub const CURSOR_MARKER: &str = "\x1b_pi:c\x07";
```

---

## Editor: Full pi-tui Port

The Editor is pi-tui's most complex component (2,307 lines). It is ported in full to `src/tui/components/editor.rs`.

### Internal State

```rust
struct EditorState {
    lines: Vec<String>,     // logical lines (no wrapping)
    cursor_line: usize,     // logical line index
    cursor_col: usize,      // byte offset into lines[cursor_line]
}
```

### Public API

```rust
impl Editor {
    // -- Construction --
    fn new(tui: &TUI, theme: &EditorTheme) -> Self;
    fn set_autocomplete_provider(&mut self, provider: Box<dyn AutocompleteProvider>);

    // -- Content --
    fn get_text(&self) -> String;             // lines joined by \n
    fn get_expanded_text(&self) -> String;    // paste markers expanded
    fn get_lines(&self) -> &[String];
    fn get_cursor(&self) -> (usize, usize);   // (line, col)
    fn set_text(&mut self, text: &str);
    fn insert_text_at_cursor(&mut self, text: &str);

    // -- History --
    fn add_to_history(&mut self, text: &str);

    // -- Callbacks --
    fn set_on_submit(&mut self, cb: Box<dyn Fn(String)>);
    fn set_on_change(&mut self, cb: Box<dyn Fn(&str)>);
    fn set_disable_submit(&mut self, disabled: bool);

    // -- Appearance --
    fn set_padding_x(&mut self, padding: usize);
    fn set_border_color(&mut self, color: fn(&str) -> String);

    // -- Autocomplete query --
    fn is_showing_autocomplete(&self) -> bool;
}
```

### Keybindings (Emacs-style, hard-coded)

| Binding | Action |
|---|---|
| Enter | Submit (unless `\` prefix, then literal newline) |
| Shift+Enter | Literal newline |
| Ctrl+C | Let parent handle (abort/exit) |
| Ctrl+Z | Undo |
| Ctrl+Y | Yank (paste from kill ring) |
| Alt+Y | Yank-pop (cycle kill ring after yank) |
| Tab | Trigger completion (slash-command, file, symbol) |
| Escape | Cancel autocomplete if open |
| Up | Move cursor up / history recall (at first visual line) |
| Down | Move cursor down / history recall (at last visual line) |
| Left / Ctrl+B | Move cursor left (grapheme-aware) |
| Right / Ctrl+F | Move cursor right (grapheme-aware) |
| Ctrl+Left / Alt+B | Move to previous word start |
| Ctrl+Right / Alt+F | Move to next word start |
| Home / Ctrl+A | Move to line start |
| End / Ctrl+E | Move to line end |
| PageUp | Scroll page up |
| PageDown | Scroll page down |
| Backspace / Ctrl+H | Delete grapheme before cursor (grapheme-aware) |
| Delete / Ctrl+D | Delete grapheme at cursor |
| Ctrl+W | Delete word backward (kill) |
| Alt+D | Delete word forward (kill) |
| Ctrl+U | Delete to line start (kill) |
| Ctrl+K | Delete to line end (kill) |
| Ctrl+T | Character jump forward (type char to jump to) |
| Ctrl+Shift+T | Character jump backward |
| Shift+Space | Insert literal space |

### Render Layout

```
─── ↑ 2 more ────────────────────   ← top border (scroll indicator if scrolled)
│                                   ← left padding (padding_x spaces)
│  the text cursor is here█more     ← content area (width - 2*padding_x)
│                                   ← right padding
─── ↓ 1 more ─────────────────────   ← bottom border (scroll indicator)
│  autocomplete item 1              ← autocomplete dropdown (below border)
│> autocomplete item 2              ← selected
│  autocomplete item 3
```

The editor computes a **visual line map** from logical lines + word-wrap. It renders only the visible viewport (max 30% of terminal height), with scroll indicators on the borders. The cursor is rendered as an inverted character (`\x1b[7m...\x1b[0m`). When focused, `CURSOR_MARKER` is emitted before the fake cursor for IME positioning.

### Porting Breakdown (2,307 lines → ~1,450 Rust)

| Subsystem | TS lines | Rust est. | Key differences |
|---|---|---|---|
| State + cursor movement | ~300 | ~250 | `UnicodeSegmentation::graphemes()` replaces `Intl.Segmenter` |
| Word-wrap layout | ~150 | ~150 | Direct port; same algorithm |
| Render (visual line map + scroll) | ~200 | ~180 | `Vec<String>` output, no JSX-style composition |
| Input dispatch | ~200 | ~200 | `match` on crossterm `KeyEvent` instead of `matchesKey()` |
| Text mutations (insert, delete, newline) | ~150 | ~120 | Same logic, Rust borrow checker will need care |
| Kill ring + yank/yank-pop | ~80 | ~80 | `Vec<String>` ring with rotation |
| Undo stack | ~40 | ~30 | Generic `Vec<T>` with `pop()` |
| Word navigation | ~50 | ~50 | Direct port of `findWordBackward`/`findWordForward` |
| Character jump | ~40 | ~30 | Simpler in Rust (direct char search) |
| History navigation | ~80 | ~60 | Vec of strings with index |
| Paste handling (bracketed + markers) | ~120 | ~100 | `\x1b[200~...\x1b[201~` → `String` + marker logic |
| Autocomplete integration | ~300 | ~250 | `AutocompleteProvider` trait, `CombinedAutocompleteProvider` (slash commands + file paths). `check_autocomplete_trigger()` on `/`, `@`, `#`, letters in slash context. |
| **Total** | **~2,300** | **~1,450** | |

Rust reduces line count because: no need for `isPasteMarker()` segmenter wrapping (grapheme iteration is simpler), no `structuredClone()` for undo (just clone the struct), no async autocomplete preamble (use `tokio::spawn` in the app layer).

### What Changes vs pi-tui

1. **Autocomplete is async-simplified.** pi-tui debounces autocomplete requests with `AbortController` and request tokens. In Rust, the app layer (`src/agent/ui/app.rs`) spawns autocomplete tasks and the Editor just holds the current `AutocompleteSuggestions` result.

2. **No `Intl.Segmenter`.** We use `unicode-segmentation` crate for grapheme iteration and `unicode-width` for display width.

3. **Paste markers are simpler.** pi-tui uses them for >10-line or >1000-char pastes to avoid editor slowdown. Same logic, but stored in a `HashMap<usize, String>` (paste ID → content).

4. **No global `getKeybindings()`.** Keybindings are hard-coded in the `handle_input()` match statement. Simple, fast, no config parsing needed.

5. **Theme passed at construction.** Not pulled from a global.

---

## Input: Single-Line Text Input

Port of `components/input.ts` (447 lines → ~300 Rust). Lives at `src/tui/components/input.rs`.

### Difference from Editor

| Aspect | Input | Editor |
|---|---|---|
| Lines | Single line only | Multi-line |
| Rendering | Horizontal scroll within `> prompt text` | Vertical scroll, word-wrap layout, border frames |
| Newline | Submits (or ignored, depending on parent) | Inserts literal newline |
| Autocomplete | None | Full autocomplete integration |
| History | None (parent manages if needed) | Built-in up/down history recall |
| Character jump | None | Ctrl+T jump-to-char |
| Paste markers | None (always inline) | Compaction for >10 line pastes |
| Kill ring | Yes (C-w, C-u, C-k, C-y, M-y) | Yes |
| Undo | Yes | Yes |
| Focusable | Yes | Yes |

### Public API

```rust
impl Input {
    fn new() -> Self;
    fn get_value(&self) -> &str;
    fn set_value(&mut self, value: &str);
    fn set_on_submit(&mut self, cb: Box<dyn Fn(String)>);
    fn set_on_escape(&mut self, cb: Box<dyn Fn()>);
}
```

### Keybindings

Same Emacs-style deletions and cursor movement as Editor, minus multi-line operations:

| Binding | Action |
|---|---|
| Enter | Submit (calls `on_submit`) |
| Escape | Cancel (calls `on_escape`) |
| Ctrl+Z | Undo |
| Ctrl+Y | Yank |
| Alt+Y | Yank-pop |
| Ctrl+W | Delete word backward (kill) |
| Ctrl+U | Delete to start (kill) |
| Ctrl+K | Delete to end (kill) |
| Alt+D | Delete word forward (kill) |
| Backspace | Delete grapheme before cursor |
| Delete | Delete grapheme at cursor |
| Left/Right | Move cursor (grapheme-aware) |
| Ctrl+Left/Right | Move by word |
| Home/End | Move to start/end |

### Render Layout

```
> visible text█padding...
```

Horizontal scrolling: when text exceeds available width, the visible window follows the cursor (centered when possible). The cursor character is rendered with inverse video (`\x1b[7m`). When focused, `CURSOR_MARKER` is emitted before the fake cursor for IME positioning.

### Porting Notes

- 447 TS lines → ~300 Rust lines. Simpler than Editor because no word-wrap, no visual line map, no autocomplete.
- Shares `KillRing`, `UndoStack`, `WordNav` with Editor.
- Uses `unicode-segmentation` for grapheme-aware cursor and backspace.
- Uses `slice_by_column()` from `util.rs` for horizontal scroll window.
- Bracketed paste: strips newlines and tabs (→ 4 spaces), inserts inline.

---

## SettingsList: Toggleable Settings Picker

Port of `components/settings-list.ts` (250 lines → ~200 Rust). Lives at `src/tui/components/settings_list.rs`.

### Purpose

A scrollable list of labeled settings where each item can:
- **Cycle values**: Press Enter/Space to advance through `values[]` (e.g., on/off, light/dark)
- **Open a submenu**: Press Enter to open a child `Component` that fully takes over rendering and input
- **Show a description**: The selected item's description renders below the list

Each item has: `id`, `label`, `description?`, `currentValue`, `values[]?`, `submenu?`.

### Public API

```rust
struct SettingItem {
    id: String,
    label: String,
    description: Option<String>,
    current_value: String,
    values: Option<Vec<String>>,           // cycle through on Enter
    submenu: Option<SubmenuFactory>,        // open nested Component on Enter
}

impl SettingsList {
    fn new(
        items: Vec<SettingItem>,
        max_visible: usize,
        theme: &SettingsListTheme,
        on_change: Box<dyn Fn(&str, &str)>,  // (id, new_value)
        on_cancel: Box<dyn Fn()>,
        options: SettingsListOptions,
    ) -> Self;
    fn update_value(&mut self, id: &str, new_value: &str);
}
```

### Render Layout

```
> search query                      ← optional search Input (if enableSearch)
                                    ← blank line
  Label One              off        ← unselected item
> Label Two              on         ← selected item (cursor prefix, highlighted)
  Label Three            auto       ←
  (2/5)                             ← scroll indicator
                                    ←
  Description of selected item...   ← wrapped description
                                    ←
  Enter/Space to change · Esc to cancel  ← hint line
```

When a submenu is active, the submenu `Component` takes over completely - `render()` and `handleInput()` both delegate to it. The submenu receives `done(selectedValue?)` to close itself.

### Keybindings

| Binding | Action |
|---|---|
| Up/Down | Move selection |
| Enter / Space | Activate item (cycle value or open submenu) |
| Escape | Cancel (close list or close submenu if open) |
| Printable chars | Type into search box (if `enableSearch`) |

### Submenu Pattern

The submenu factory receives the current value and a `done` callback:

```rust
type SubmenuFactory = Box<dyn Fn(String, Box<dyn Fn(Option<String>)>) -> Box<dyn Component>>;
```

This allows a SettingsList item to open an arbitrary Component (e.g., a SelectList for model choice) inline. When the user picks or cancels, `done(Some(new_value))` or `done(None)` is called, the submenu closes, and selection returns to the parent item.

### Porting Notes

- 250 TS lines → ~200 Rust lines. Straightforward port.
- The `Input` component is used for the search box when `enableSearch` is true.
- `fuzzy_filter()` from `fuzzy.rs` handles search matching against item labels.
- Submenu uses trait objects (`Box<dyn Component>`) - the factory pattern maps naturally to Rust closures.

---

## Core Diff Renderer Design

```rust
// src/tui/screen.rs

pub struct Screen {
    prev_lines: Vec<String>,
    prev_width: u16,
    prev_height: u16,
    cursor_row: usize,
    // ...
}

impl Screen {
    /// Compare `new_lines` to the previous frame and emit minimal
    /// ANSI to update the terminal. Returns the new hardware cursor row.
    pub fn render(&mut self, new_lines: Vec<String>) -> io::Result<()> {
        // 1. Width/height changed? → full redraw (clear + re-render all)
        // 2. First render? → write all without clearing
        // 3. Content shrunk? → full redraw (clear empty rows)
        // 4. Find first_changed / last_changed indices
        // 5. New lines appended (streaming)? → \r\n for each new line
        // 6. Move cursor to first_changed, [2K-clear, write line] for each
        // 7. Extract CURSOR_MARKER, position hardware cursor for IME
        // 8. Wrap in \x1b[?2026h/l synchronized output
    }
}
```

The algorithm is a direct port of `TUI.doRender()` from `tui.ts` (lines ~1050-1570), minus overlay compositing and Kitty image logic.

---

## Key Design Decisions

1. **No async in Component trait.** `render()` and `handle_input()` are synchronous. Async lives in the app event loop (`src/agent/ui/app.rs`), which feeds events to components and triggers re-renders.

2. **Components own their state.** No global state. `Editor` owns its text buffer, cursor, history. `SelectList` owns its items, selection index, search query. `Loader` owns its frame counter.

3. **Width is passed in, not stored.** Every `render(width)` call receives the current viewport width. Components cache their output for the given width and invalidate on state change.

4. **Theme is a trait, not a global.** Components accept theme via constructor or setter. The app layer provides the concrete theme.

5. **No overlay stack in core library.** pi-tui's overlay compositing adds ~600 lines of complexity to the diff renderer. For rab, overlays (model selector, help) are implemented as full-screen component swaps in the app event loop - much simpler.

6. **Line-level diffing, not cell-level.** pi-tui compares strings. ratatui compares `Cell` structs (char + style). Line-level is simpler and sufficient for a chat UI where most changes are full-line replacements or appends.

7. **Editor lives in tui/components/ not ui/.** The Editor is a general-purpose component (like Text or SelectList). rab's app wrap it with `ChatEditor` in `src/agent/ui/chat_editor.rs` for app-specific behavior (slash commands, file paths, submission hook).

8. **Input is separate from Editor.** The `Input` component provides single-line text entry with horizontal scrolling. It is lighter than `Editor` (no word-wrap, no multiline, no autocomplete, no character jump) but shares kill-ring, undo-stack, grapheme-aware cursor, and `Focusable` support. `SettingsList` uses `Input` for its search box.