Skip to content

Commit 482ee57

Browse files
serrrfiratclaude
andauthored
feat(tui): port full-featured Ratatui terminal UI onto staging (#1973)
* feat: port ratatui tui onto staging * Add TUI model picker for /model * Fix TUI CI lint failures * Format /tools output as vertical list * Restore TUI approval modal on thread switch * Re-emit pending approval events on follow-up messages * Improve TUI thread handling and activity UI * Sort TUI resume conversations by activity * fix(tui): address PR review feedback * Add TUI thread detail modal for activity sidebar * feat(tui): improve conversation scrolling UX - Mouse wheel: 1-line increments (was 3-line jumps) - PageUp/PageDown: full-page scroll based on viewport height (was 5 lines) - Add scrollbar widget on conversation right edge (track │, thumb ┃) - Add "↓ N more ↓ End to return" indicator when scrolled up - Add auto-follow (pinned_to_bottom) that disengages on scroll-up and re-engages when reaching bottom or pressing End - Clamp scroll offset to valid range (can't scroll past content) - Add End key binding to jump to bottom Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(tui): use engine context pressure data for status bar The context bar was using cumulative session tokens (total_input + total_output) which grow unboundedly across turns, making the bar always show 100% after a few exchanges. Now uses the actual context window usage from ContextPressure events when available, falling back to cumulative tokens only before the first engine update arrives. Also syncs context_window from the engine's max_tokens so the limit reflects the real model capability instead of name-based heuristics. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(tui): render markdown in thread detail modal The thread detail modal was displaying raw markdown text (plain line splitting). Now uses render_markdown() for proper formatting of headers, lists, bold, code blocks, etc. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(tui): hydrate sidebar with engine threads and routines at startup The TUI sidebar was empty until the first user message because EngineThreadList and RoutineUpdate events were only sent after processing a message. Now sends initial data right before the message loop so the activity panel shows existing threads and routines immediately on startup. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(tui): use owner_id for engine thread hydration at startup list_engine_threads filters by user_id, so passing "" matched no threads. Now uses self.owner_id() which matches the TUI channel's user_id, so threads are visible in the sidebar immediately. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(tui): fix CI — type errors and formatting in TUI tests Wrap `started_at` and `updated_at` in `Some(...)` to match `Option<DateTime<Utc>>` after upstream struct change, and run `cargo fmt` on files with formatting drift. [skip-regression-check] Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(ci): resolve clippy warnings — collapsible ifs and needless borrow Collapse three nested `if` blocks into `if && let` chains and remove a needless `&` on the `process_list_threads` call, all in agent_loop.rs. [skip-regression-check] Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(ci): add live_harness.rs with updated StatusUpdate patterns The live_harness.rs file was added to staging after this branch diverged. When CI merges the PR into staging, the file uses old StatusUpdate patterns that don't account for the new `detail` and `call_id` fields added by this branch. Add the file with `..` rest patterns to fix the merge-time compile errors. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 3df1bf3 commit 482ee57

55 files changed

Lines changed: 13209 additions & 159 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Cargo.lock

Lines changed: 421 additions & 14 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[workspace]
2-
members = [".", "crates/ironclaw_common", "crates/ironclaw_safety", "crates/ironclaw_skills", "crates/ironclaw_engine"]
2+
members = [".", "crates/ironclaw_common", "crates/ironclaw_safety", "crates/ironclaw_skills", "crates/ironclaw_engine", "crates/ironclaw_tui"]
33
exclude = [
44
"channels-src/discord",
55
"channels-src/telegram",
@@ -113,6 +113,7 @@ ironclaw_common = { path = "crates/ironclaw_common", version = "0.1.0" }
113113
ironclaw_engine = { path = "crates/ironclaw_engine", version = "0.1.0" }
114114
ironclaw_safety = { path = "crates/ironclaw_safety", version = "0.2.0" }
115115
ironclaw_skills = { path = "crates/ironclaw_skills", version = "0.1.0" }
116+
ironclaw_tui = { path = "crates/ironclaw_tui", version = "0.1.0", optional = true }
116117
regex = "1"
117118
aho-corasick = "1"
118119

@@ -239,6 +240,7 @@ libsql = ["dep:libsql"]
239240
integration = []
240241
html-to-markdown = ["dep:html-to-markdown-rs", "dep:readabilityrs"]
241242
bedrock = ["dep:aws-config", "dep:aws-sdk-bedrockruntime", "dep:aws-smithy-types"]
243+
tui = ["dep:ironclaw_tui"]
242244
import = ["dep:json5", "libsql"]
243245

244246
[[test]]

crates/ironclaw_common/src/event.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ pub enum AppEvent {
5959
ToolStarted {
6060
name: String,
6161
#[serde(skip_serializing_if = "Option::is_none")]
62+
detail: Option<String>,
63+
#[serde(skip_serializing_if = "Option::is_none")]
6264
thread_id: Option<String>,
6365
},
6466
#[serde(rename = "tool_completed")]
@@ -380,6 +382,7 @@ mod tests {
380382
},
381383
AppEvent::ToolStarted {
382384
name: String::new(),
385+
detail: None,
383386
thread_id: None,
384387
},
385388
AppEvent::ToolCompleted {

crates/ironclaw_tui/CLAUDE.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# ironclaw_tui — Module Spec
2+
3+
## Overview
4+
5+
Ratatui-based terminal UI for IronClaw. Self-contained crate that provides:
6+
- Widget system (`TuiWidget` trait) with built-in widgets (header, conversation, input, status bar, tool panel, thread list, approval modal)
7+
- Layout engine with user-configurable JSON (`tui/layout.json` in workspace)
8+
- Theme system (dark/light, custom colors)
9+
- Event loop with crossterm input polling + external event merging
10+
11+
## Dependencies
12+
13+
- No dependency on the main `ironclaw` crate (avoids circular dependency)
14+
- Channel trait bridge lives in `src/channels/tui.rs` in the main crate
15+
16+
## Communication
17+
18+
```
19+
Main Crate (TuiChannel) ironclaw_tui (TuiApp)
20+
───────────────────── ───────────────────
21+
event_tx: Sender<TuiEvent> ────→ event_rx: renders UI
22+
msg_rx: Receiver<String> ←──── msg_tx: user input
23+
```
24+
25+
## Key Bindings
26+
27+
| Key | Action |
28+
|----------|----------------------|
29+
| Enter | Submit input |
30+
| Ctrl-C | Quit |
31+
| Ctrl-B | Toggle sidebar |
32+
| Esc | Interrupt/cancel |
33+
| PgUp/Dn | Scroll conversation |
34+
| y/n/a | Approval shortcuts |
35+
36+
## Adding a Widget
37+
38+
1. Create `src/widgets/my_widget.rs`
39+
2. Implement `TuiWidget` trait
40+
3. Add to `BuiltinWidgets` in `registry.rs`
41+
4. Wire into `render_frame()` in `app.rs`

crates/ironclaw_tui/Cargo.toml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
[package]
2+
name = "ironclaw_tui"
3+
version = "0.1.0"
4+
edition = "2024"
5+
rust-version = "1.92"
6+
description = "Modular Ratatui-based TUI for IronClaw"
7+
authors = ["NEAR AI <support@near.ai>"]
8+
license = "MIT OR Apache-2.0"
9+
homepage = "https://github.com/nearai/ironclaw"
10+
repository = "https://github.com/nearai/ironclaw"
11+
12+
[package.metadata.dist]
13+
dist = false
14+
15+
[dependencies]
16+
ratatui = { version = "0.29", features = ["crossterm"] }
17+
tui-textarea = { version = "0.7", features = ["crossterm"] }
18+
serde = { version = "1", features = ["derive"] }
19+
serde_json = "1"
20+
tokio = { version = "1", features = ["sync", "macros", "rt", "time"] }
21+
chrono = "0.4"
22+
unicode-width = "0.2"
23+
pulldown-cmark = { version = "0.12", default-features = false }
24+
thiserror = "2"
25+
tracing = "0.1"
26+
arboard = "3"
27+
image = { version = "0.25", default-features = false, features = ["png"] }
28+
29+
[dev-dependencies]
30+
tokio = { version = "1", features = ["full"] }
Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
//! Standalone TUI dev harness — renders the full TUI with mock data.
2+
//!
3+
//! Usage:
4+
//! cargo run -p ironclaw_tui --example dev
5+
//!
6+
//! Hot-reload loop (recompiles + restarts on any source change):
7+
//! cargo watch -x 'run -p ironclaw_tui --example dev' -w crates/ironclaw_tui/src
8+
//!
9+
//! This compiles in ~5s instead of minutes because it skips the entire
10+
//! ironclaw binary (database, LLM, WASM, Docker, etc.).
11+
12+
use std::time::Duration;
13+
14+
use ironclaw_tui::{SkillCategory, ToolCategory, TuiAppConfig, TuiEvent, TuiLayout, start_tui};
15+
16+
fn mock_tool_categories() -> Vec<ToolCategory> {
17+
vec![
18+
ToolCategory {
19+
name: "browser".into(),
20+
tools: vec![
21+
"back".into(),
22+
"click".into(),
23+
"navigate".into(),
24+
"screenshot".into(),
25+
],
26+
},
27+
ToolCategory {
28+
name: "file".into(),
29+
tools: vec!["read".into(), "write".into(), "search".into()],
30+
},
31+
ToolCategory {
32+
name: "general".into(),
33+
tools: vec![
34+
"echo".into(),
35+
"github".into(),
36+
"gmail".into(),
37+
"http".into(),
38+
"json".into(),
39+
"time".into(),
40+
],
41+
},
42+
ToolCategory {
43+
name: "memory".into(),
44+
tools: vec![
45+
"read".into(),
46+
"search".into(),
47+
"tree".into(),
48+
"write".into(),
49+
],
50+
},
51+
ToolCategory {
52+
name: "routine".into(),
53+
tools: vec![
54+
"create".into(),
55+
"delete".into(),
56+
"list".into(),
57+
"update".into(),
58+
],
59+
},
60+
ToolCategory {
61+
name: "secret".into(),
62+
tools: vec!["delete".into(), "list".into()],
63+
},
64+
ToolCategory {
65+
name: "shell".into(),
66+
tools: vec!["exec".into()],
67+
},
68+
ToolCategory {
69+
name: "skill".into(),
70+
tools: vec![
71+
"install".into(),
72+
"list".into(),
73+
"remove".into(),
74+
"search".into(),
75+
],
76+
},
77+
ToolCategory {
78+
name: "tool".into(),
79+
tools: vec![
80+
"activate".into(),
81+
"auth".into(),
82+
"info".into(),
83+
"install".into(),
84+
"list".into(),
85+
"remove".into(),
86+
"search".into(),
87+
"upgrade".into(),
88+
],
89+
},
90+
ToolCategory {
91+
name: "web".into(),
92+
tools: vec!["fetch".into()],
93+
},
94+
]
95+
}
96+
97+
fn mock_skill_categories() -> Vec<SkillCategory> {
98+
vec![
99+
SkillCategory {
100+
name: "apple".into(),
101+
skills: vec![
102+
"apple-notes".into(),
103+
"apple-reminders".into(),
104+
"findmy".into(),
105+
],
106+
},
107+
SkillCategory {
108+
name: "creative".into(),
109+
skills: vec![
110+
"ascii-art".into(),
111+
"ascii-video".into(),
112+
"excalidraw".into(),
113+
],
114+
},
115+
SkillCategory {
116+
name: "data-science".into(),
117+
skills: vec!["jupyter-live-kernel".into()],
118+
},
119+
SkillCategory {
120+
name: "github".into(),
121+
skills: vec![
122+
"codebase-inspection".into(),
123+
"github-auth".into(),
124+
"github-code-r...".into(),
125+
],
126+
},
127+
SkillCategory {
128+
name: "media".into(),
129+
skills: vec!["gif-search".into(), "heartmula".into(), "songsee".into()],
130+
},
131+
SkillCategory {
132+
name: "productivity".into(),
133+
skills: vec![
134+
"google-workspace".into(),
135+
"linear".into(),
136+
"notion".into(),
137+
"ocr".into(),
138+
],
139+
},
140+
SkillCategory {
141+
name: "research".into(),
142+
skills: vec!["arxiv".into(), "blogwatcher".into(), "domain-intel".into()],
143+
},
144+
SkillCategory {
145+
name: "software-dev".into(),
146+
skills: vec!["code-review".into(), "plan".into(), "remote-pr".into()],
147+
},
148+
]
149+
}
150+
151+
fn main() {
152+
let config = TuiAppConfig {
153+
version: "0.22.0-dev".into(),
154+
model: "gpt-5.4".into(),
155+
layout: TuiLayout::default(),
156+
context_window: 128_000,
157+
tools: mock_tool_categories(),
158+
skills: mock_skill_categories(),
159+
workspace_path: std::env::current_dir()
160+
.map(|p| p.display().to_string())
161+
.unwrap_or_else(|_| "~/projects/ironclaw".into()),
162+
memory_count: 42,
163+
identity_files: vec!["AGENTS.md".into(), "SOUL.md".into(), "USER.md".into()],
164+
available_models: vec![
165+
"gpt-4o".into(),
166+
"gpt-5.3-codex".into(),
167+
"gpt-5.4".into(),
168+
"claude-sonnet-4-6".into(),
169+
"gemini-2.5-pro".into(),
170+
],
171+
};
172+
173+
let handle = start_tui(config);
174+
let event_tx = handle.event_tx;
175+
let mut msg_rx = handle.msg_rx;
176+
177+
// Spawn a thread that simulates agent responses to user input
178+
let sim_tx = event_tx.clone();
179+
std::thread::spawn(move || {
180+
let rt = tokio::runtime::Builder::new_current_thread()
181+
.enable_all()
182+
.build()
183+
.expect("failed to build tokio runtime"); // safety: example binary, not library code
184+
185+
rt.block_on(async move {
186+
// Simulate initial status events after a short delay
187+
tokio::time::sleep(Duration::from_millis(500)).await;
188+
let _ = sim_tx
189+
.send(TuiEvent::SandboxStatus {
190+
docker_available: true,
191+
running_containers: 0,
192+
status: "ready".into(),
193+
})
194+
.await;
195+
let _ = sim_tx
196+
.send(TuiEvent::SecretsStatus {
197+
count: 3,
198+
vault_unlocked: true,
199+
})
200+
.await;
201+
202+
// Echo user messages back as mock agent responses
203+
while let Some(user_msg) = msg_rx.recv().await {
204+
let msg = &user_msg.text;
205+
// Simulate thinking
206+
let _ = sim_tx
207+
.send(TuiEvent::Thinking("Processing...".into()))
208+
.await;
209+
tokio::time::sleep(Duration::from_millis(300)).await;
210+
211+
// Simulate tool call
212+
let truncated: String = msg.chars().take(40).collect();
213+
let _ = sim_tx
214+
.send(TuiEvent::ToolStarted {
215+
name: "echo".into(),
216+
detail: Some(format!("\"{truncated}\"")),
217+
call_id: None,
218+
})
219+
.await;
220+
tokio::time::sleep(Duration::from_millis(200)).await;
221+
let _ = sim_tx
222+
.send(TuiEvent::ToolCompleted {
223+
name: "echo".into(),
224+
success: true,
225+
error: None,
226+
call_id: None,
227+
})
228+
.await;
229+
let _ = sim_tx
230+
.send(TuiEvent::ToolResult {
231+
name: "echo".into(),
232+
preview: msg.clone(),
233+
call_id: None,
234+
})
235+
.await;
236+
237+
// Simulate streaming response
238+
let _ = sim_tx.send(TuiEvent::Thinking(String::new())).await;
239+
let response = format!(
240+
"You said: **{msg}**\n\nThis is a mock response from the dev harness. \
241+
Edit `crates/ironclaw_tui/src/` and watch it reload.",
242+
);
243+
for chunk in response.as_bytes().chunks(20) {
244+
let _ = sim_tx
245+
.send(TuiEvent::StreamChunk(
246+
String::from_utf8_lossy(chunk).to_string(),
247+
))
248+
.await;
249+
tokio::time::sleep(Duration::from_millis(30)).await;
250+
}
251+
let _ = sim_tx
252+
.send(TuiEvent::Response {
253+
content: response,
254+
thread_id: None,
255+
})
256+
.await;
257+
258+
// Simulate cost
259+
let _ = sim_tx
260+
.send(TuiEvent::TurnCost {
261+
input_tokens: 1200,
262+
output_tokens: 340,
263+
cost_usd: "$0.002".into(),
264+
})
265+
.await;
266+
267+
// Suggestions
268+
let _ = sim_tx
269+
.send(TuiEvent::Suggestions {
270+
suggestions: vec![
271+
"Tell me more".into(),
272+
"Show available tools".into(),
273+
"Search memory".into(),
274+
],
275+
})
276+
.await;
277+
}
278+
});
279+
});
280+
281+
// Block main thread until TUI exits
282+
handle.join_handle.join().expect("TUI thread panicked"); // safety: example binary, not library code
283+
}

0 commit comments

Comments
 (0)