Skip to content

feat(tui): port full-featured Ratatui terminal UI onto staging - #1973

Merged
ilblackdragon merged 21 commits into
stagingfrom
firat/feat-tui-on-staging
Apr 8, 2026
Merged

ilblackdragon merged 21 commits into
stagingfrom
firat/feat-tui-on-staging

Conversation

@serrrfirat

@serrrfirat serrrfirat commented Apr 3, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Supersedes #1870.

Ports the Ratatui TUI onto current staging, preserving the feature work from the drifted branch while integrating against the current runtime and channel contracts. This branch also adds a TUI-native /model picker flow so model switching no longer relies on parsing a raw slash-command response.

Notable pieces included here:

  • Ratatui TUI channel and startup wiring on top of current staging
  • Mouse support for tabs, approvals, thread picker, modal-aware scrolling, and overlay dismissal
  • Visible-text drag selection and copy support in the TUI
  • Plain Up / Down input history navigation at chat-bar edges, while preserving multiline editing behavior
  • Layout loading from tui/layout.json plus TUI_THEME / TUI_SIDEBAR wiring
  • Stable tool call_id propagation so duplicate same-name tool calls do not collide in the TUI
  • Thread sidebar fixes so real thread-list events populate the thread UI instead of mirroring jobs
  • Non-streaming response handling fix, terminal restore guard, and TUI log/stderr behavior cleanup
  • Slash-command /model now opens a dedicated model picker in the TUI
  • The picker is seeded from startup model discovery when available
  • If startup discovery is unavailable, the first /model response hydrates the picker and later selections submit /model <name> directly
  • If live model discovery is still unavailable, /model falls back cleanly to the raw command instead of trapping the user in the slash overlay

Test Plan

  • cargo fmt --all
  • CARGO_TARGET_DIR=/tmp/ironclaw-tui-staging-target cargo test -p ironclaw_tui app::tests:: --offline
  • CARGO_TARGET_DIR=/tmp/ironclaw-tui-staging-target cargo check -p ironclaw_tui --all-targets --offline
  • CARGO_TARGET_DIR=/tmp/ironclaw-tui-staging-target cargo check --features tui --tests --offline
  • cargo test -p ironclaw_tui slash_model
  • cargo test -p ironclaw_tui model_response_hydrates_picker_after_first_fetch
  • cargo check --features tui --bin ironclaw

Notes

This branch is the staging-based replacement for the closed drifted PR. The TUI interaction work added after the original branch drifted is included here as part of the port.

Full provider onboarding still stays in ironclaw onboard; the TUI change here is intentionally scoped to switching between already-configured models.

@github-actions github-actions Bot added scope: agent Agent core (agent loop, router, scheduler) scope: channel Channel infrastructure scope: channel/web Web gateway channel scope: channel/wasm WASM channel runtime scope: docs Documentation scope: dependencies Dependency updates size: XL 500+ changed lines risk: medium Business logic, config, or moderate-risk modules contributor: experienced 6-19 merged PRs labels Apr 3, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a modular, Ratatui-based Terminal User Interface (TUI) for IronClaw, implemented as a self-contained crate. Key features include a widget system, configurable layouts, markdown rendering with syntax highlighting, and an event loop that integrates terminal input with agent status updates. Feedback focuses on critical performance optimizations, specifically regarding inefficient screen snapshotting and redundant conversation rendering. Additionally, improvements are suggested for more robust whitespace handling in text wrapping, more flexible context window inference, and standardizing timestamp parsing to avoid incorrect thread activity displays.

Comment on lines +2000 to +2013
fn capture_screen_snapshot(frame: &mut ratatui::Frame<'_>, state: &mut AppState) {
let area = frame.area();
let mut rows = Vec::with_capacity(area.height as usize);

for row in area.y..area.y + area.height {
let mut cells = Vec::with_capacity(area.width as usize);
for column in area.x..area.x + area.width {
cells.push(frame.buffer_mut()[(column, row)].symbol().to_string());
}
rows.push(cells);
}

state.screen_snapshot = ScreenSnapshot { area, rows };
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The capture_screen_snapshot function is extremely inefficient as it is called at the end of every frame (~30 times per second). It iterates over every cell on the screen and performs a String allocation for each symbol (symbol().to_string()). On a typical terminal, this results in thousands of allocations per frame, which will significantly impact performance and CPU usage.

Consider cloning the ratatui::buffer::Buffer directly (as suggested in the ScreenSnapshot struct change) and only performing the symbol-to-string conversion when the user actually performs a copy operation.

fn capture_screen_snapshot(frame: &mut ratatui::Frame<'_>, state: &mut AppState) {
    state.screen_snapshot = ScreenSnapshot {
        area: frame.area(),
        buffer: frame.buffer_mut().clone(),
    };
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in ef1efaf3. capture_screen_snapshot now stores a cloned ratatui::buffer::Buffer directly instead of allocating one String per cell each frame, and symbol-to-string conversion is deferred until copy extraction.

Comment thread crates/ironclaw_tui/src/widgets/mod.rs Outdated
Comment on lines +240 to +244
#[derive(Debug, Clone, Default)]
pub struct ScreenSnapshot {
pub area: Rect,
pub rows: Vec<Vec<String>>,
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using Vec<Vec> to store a screen snapshot is very memory-intensive and leads to excessive allocations during capture. Since this crate already depends on ratatui, it is much more efficient to store a ratatui::buffer::Buffer directly, which can be cloned in a single allocation.

Suggested change
#[derive(Debug, Clone, Default)]
pub struct ScreenSnapshot {
pub area: Rect,
pub rows: Vec<Vec<String>>,
}
#[derive(Debug, Clone, Default)]
pub struct ScreenSnapshot {
pub area: Rect,
pub buffer: Buffer,
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in ef1efaf3. ScreenSnapshot now holds { area, buffer: Buffer } instead of Vec<Vec<String>>, with a lightweight default backed by Buffer::empty(area).

Comment on lines +1633 to +1641
fn snapshot_symbol(snapshot: &ScreenSnapshot, column: u16, row: u16) -> Option<&str> {
let local_row = row.checked_sub(snapshot.area.y)? as usize;
let local_col = column.checked_sub(snapshot.area.x)? as usize;
snapshot
.rows
.get(local_row)?
.get(local_col)
.map(String::as_str)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This function should be updated to work with the optimized ScreenSnapshot using a ratatui::buffer::Buffer.

fn snapshot_symbol(snapshot: &ScreenSnapshot, column: u16, row: u16) -> Option<&str> {
    if column >= snapshot.area.x && column < snapshot.area.x + snapshot.area.width && row >= snapshot.area.y && row < snapshot.area.y + snapshot.area.height {
        Some(snapshot.buffer[(column, row)].symbol())
    } else {
        None
    }
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in ef1efaf3. snapshot_symbol now bounds-checks against the snapshot rect and reads directly from snapshot.buffer[(column, row)].symbol().

Comment on lines +50 to +146
fn render(&self, area: Rect, buf: &mut Buffer, state: &AppState) {
if area.height == 0 || area.width < 4 {
return;
}

let usable_width = (area.width as usize).saturating_sub(4);
let mut all_lines: Vec<Line<'_>> = Vec::new();

// Welcome block when the conversation is empty
if state.messages.is_empty() {
self.render_welcome_screen(state, usable_width, &mut all_lines);
}

for msg in &state.messages {
let (prefix, style) = match msg.role {
MessageRole::User => ("\u{25CF} ", self.theme.accent_style()),
MessageRole::Assistant => ("", Style::default().fg(self.theme.fg.to_color())),
MessageRole::System => ("\u{25CB} ", self.theme.dim_style()),
};

if msg.role == MessageRole::User {
// Blank line before user messages (except first)
if !all_lines.is_empty() {
all_lines.push(Line::from(""));
}
let time_str = msg.timestamp.format("%H:%M").to_string();
let user_line = Line::from(vec![
Span::styled(prefix.to_string(), self.theme.accent_style()),
Span::styled(msg.content.clone(), self.theme.bold_style()),
Span::styled(format!(" {time_str}"), self.theme.dim_style()),
]);
all_lines.push(user_line);
all_lines.push(Line::from(""));
} else if msg.role == MessageRole::Assistant {
// Separator with label and timestamp before assistant response
let time_str = msg.timestamp.format("%H:%M").to_string();
let turn_label = " ironclaw ";
let time_label = format!(" {time_str} ");
let sep_left_len = 2usize;
let sep_right_len = usable_width
.min(60)
.saturating_sub(sep_left_len + turn_label.len() + time_label.len());
let sep_left = "\u{2500}".repeat(sep_left_len);
let sep_right = "\u{2500}".repeat(sep_right_len);
all_lines.push(Line::from(vec![
Span::styled(format!(" {sep_left}"), self.theme.dim_style()),
Span::styled(turn_label, self.theme.accent_style()),
Span::styled(sep_right, self.theme.dim_style()),
Span::styled(time_label, self.theme.dim_style()),
]));

let wrapped =
render_markdown(&msg.content, usable_width.saturating_sub(2), &self.theme);
for line in wrapped {
let mut padded = vec![Span::raw(" ".to_string())];
padded.extend(
line.spans
.into_iter()
.map(|s| Span::styled(s.content.to_string(), s.style)),
);
all_lines.push(Line::from(padded));
}

// Per-turn cost summary
if let Some(ref cost) = msg.cost_summary {
let cost_line = format!(
" \u{25CB} {}in + {}out {}",
format_tokens(cost.input_tokens),
format_tokens(cost.output_tokens),
cost.cost_usd,
);
all_lines.push(Line::from(Span::styled(cost_line, self.theme.dim_style())));
}

all_lines.push(Line::from(""));
} else {
// System messages with timestamp
let time_str = msg.timestamp.format("%H:%M").to_string();
let wrapped = wrap_text(&msg.content, usable_width.saturating_sub(8), style);
for (i, line) in wrapped.into_iter().enumerate() {
if i == 0 {
let mut spans: Vec<Span<'_>> = line
.spans
.into_iter()
.map(|s| Span::styled(s.content.to_string(), s.style))
.collect();
spans.push(Span::styled(
format!(" {time_str}"),
self.theme.dim_style(),
));
all_lines.push(Line::from(spans));
} else {
all_lines.push(line);
}
}
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The render function re-processes the entire conversation history on every frame, including markdown parsing and text wrapping for every message. This will cause significant UI lag as the conversation grows.

Consider caching the rendered Lines for each message in AppState. You only need to re-render a message if the available width changes or if the message content is updated (e.g., during streaming).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in ef1efaf3. I added a per-message render cache in the conversation widget keyed by message content/position plus usable width, so markdown parsing and wrapping are only recomputed when a message changes or the available width changes.

Comment thread crates/ironclaw_tui/src/render.rs Outdated
continue;
}
// Simple word-wrap
let words: Vec<&str> = raw_line.split_whitespace().collect();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using split_whitespace() causes the loss of multiple consecutive spaces and other formatting whitespace within the text. This can lead to incorrect display of user input or system messages where spacing is intentional.

References
  1. When reconstructing a string from whitespace-separated fields, prefer joining the collected fields over re-trimming the original string to handle extra spaces robustly.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in ef1efaf3. wrap_text no longer uses split_whitespace(); it now preserves consecutive spaces (and expands tabs consistently) while wrapping.

Comment thread src/channels/tui.rs
Comment on lines +92 to +128
fn infer_context_window(model_id: &str) -> u64 {
let normalized = model_id
.trim()
.to_ascii_lowercase()
.rsplit('/')
.next()
.unwrap_or(model_id)
.split(':')
.next()
.unwrap_or(model_id)
.to_string();

if normalized.starts_with("gpt-5") {
if normalized.contains("-chat") {
return 128_000;
}
return 400_000;
}

if normalized.starts_with("gpt-4.1") {
return 1_047_576;
}

if normalized.contains("claude-opus-4-6") || normalized.contains("claude-sonnet-4-6") {
return 1_000_000;
}

if normalized.contains("claude") {
return 200_000;
}

if normalized.starts_with("gemini-") {
return 1_000_000;
}

128_000
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The infer_context_window function relies on hardcoded model name heuristics, some of which appear to be placeholders (e.g., gpt-5.4, gpt-4.1). This is fragile and will likely be incorrect for many models.

It would be better to retrieve the context window size from the model provider's metadata if available, or allow it to be explicitly configured in the TUI settings.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in ef1efaf3. The TUI now prefers llm.model_metadata() at startup to seed the context window shown in the UI, and only falls back to a conservative heuristic when the provider does not expose metadata.

Comment thread crates/ironclaw_tui/src/app.rs Outdated
Comment on lines +1223 to +1226
chrono::NaiveDateTime::parse_from_str(last_activity, "%Y-%m-%d %H:%M")
.map(|dt| dt.and_utc())
.unwrap_or_else(|_| chrono::Utc::now())
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The parse_thread_activity_timestamp function uses a very specific format (%Y-%m-%d %H:%M) which will fail to parse standard ISO 8601 timestamps. If parsing fails, it defaults to chrono::Utc::now(), which will cause the sidebar to display incorrect uptime (0s) for all resumed threads. Per repository rules, do not fall back to the current time if a timestamp cannot be parsed, as this makes the resource appear perpetually new. Instead, log a warning and handle the missing data.

References
  1. When calculating the age of a resource for cleanup, if the creation timestamp is unknown or cannot be parsed, treat it as un-reapable by logging a warning and skipping it. Do not fall back to the current time, as this will make the resource perpetually appear new and prevent it from ever being cleaned up.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in ef1efaf3. On this staging-based branch the problematic fallback lived in the engine thread timestamp mapping path, so I fixed it there: we now parse RFC3339 plus the legacy %Y-%m-%d %H:%M format, and invalid timestamps stay None instead of falling back to Utc::now(). The sidebar shows unknown uptime rather than misleading 0s.

@ilblackdragon ilblackdragon left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review — Needs changes (performance + correctness)

TL;DR: Full-featured Ratatui TUI port is well-structured with proper terminal restoration and correct logging levels, but has 5 performance/correctness issues that should be addressed before merge. Snapshot allocations and per-frame re-render are the big ones.

TUI quality baseline ✓

  • Logging: only `error!` used — no `info!`/`warn!` in render loops (respects CLAUDE.md rule)
  • Terminal restoration: `TerminalRestoreGuard` in Drop ensures cleanup on panic
  • Error handling: production uses `Result`; tests use `.expect()`
  • Event handling: comprehensive (key, mouse, resize, bracketed paste)
  • Signal handling: Ctrl+C exit via event loop

Findings (ordered by load-bearing impact)

# Severity File:Line Issue Suggestion
1 High `app.rs:2298` (`capture_screen_snapshot`) Allocates thousands of strings per frame (~30fps) via `symbol().to_string()` on every cell Clone `ratatui::buffer::Buffer` directly; defer string conversion until the user actually triggers a copy action
2 Medium `widgets/mod.rs:271` `Vec<Vec>` screen snapshot is memory-inefficient Store `Buffer` in `ScreenSnapshot` struct instead
3 Medium `widgets/conversation.rs:146` Entire conversation re-parsed and wrapped every frame (markdown + wrap for all messages) Cache rendered `Lines` per message; invalidate only on width change or message update
4 Medium `render.rs:23` `split_whitespace()` loses consecutive spaces → breaks intentional spacing in user input Preserve whitespace; collect-and-join instead of re-trimming with `split_whitespace`
5 Medium `src/channels/tui.rs:128` (`infer_context_window`) Hardcodes placeholder model names (gpt-5.4, gpt-4.1) Fetch from provider metadata (add to `LlmProvider` trait if needed) or expose explicit TUI config
6 Medium `src/channels/tui.rs` (`parse_thread_activity_timestamp`) On parse failure, falls back to `now()` → all threads show "0s uptime" indefinitely Log a warning and keep `None`; do not fall back to current time

Open questions

  1. Are the snapshot optimizations (#1, #2) blocking merge, or can they land post-merge as a perf follow-up? They are in the render hot path, so strongly recommend fixing before merge.
  2. Does the timestamp fallback (#6) manifest in user testing, or is it a hypothetical edge case?
  3. Context window inference: should this come from a discovery API (provider trait method) instead of hardcoded heuristics?

@github-actions github-actions Bot added contributor: core 20+ merged PRs and removed contributor: experienced 6-19 merged PRs labels Apr 6, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed ef1efaf3 to firat/feat-tui-on-staging to address the review feedback.

Covered in this update:

  • switched TUI screen snapshots to cloned ratatui::buffer::Buffer
  • updated selection/copy to read symbols lazily from that buffer
  • added per-message conversation render caching
  • preserved whitespace in plain-text wrapping
  • seeded TUI context window from provider model_metadata() when available
  • removed the now() fallback for unparseable engine thread timestamps

Verification run on this branch:

  • cargo fmt --all
  • cargo test -p ironclaw_tui --offline
  • cargo test --features tui channels::tui::tests --offline

@serrrfirat
serrrfirat requested a review from ilblackdragon April 6, 2026 06:58
serrrfirat and others added 2 commits April 6, 2026 12:55
- 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>
serrrfirat and others added 3 commits April 6, 2026 16:42
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>
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>
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>
@ilblackdragon

Copy link
Copy Markdown
Member

Interrupt during processing: input layer fixed, agent dispatch still blocked

Nice to see the TUI coming back on staging. Wanted to flag a gap I hit while tracing the Esc-interrupt path — the new TUI fixes the input-layer problem the REPL has, but the agent-dispatch bottleneck underneath is still there, so Esc mid-turn won't actually stop the running thread.

The trace

Input layer — this PR fixes it. In crates/ironclaw_tui/src/app.rs:456, InputAction::Interrupt sends TuiUserMessage::text_only("/interrupt") via msg_tx. Ratatui's event loop runs independently of the agent, so unlike the REPL (where rl.readline() is parked behind stdin_locked during processing — see src/channels/repl.rs:565), the TUI can actually produce the keystroke mid-turn. Good.

Agent dispatch layer — still blocked. The outer agent loop in src/agent/agent_loop.rs:941 awaits handle_message inline:

match self.handle_message(&message).await { ... }

For a UserInput, this routes through src/bridge/router.rs:handle_with_engine_inner → await_thread_outcome (router.rs:1981), which blocks on the thread's event stream for up to 5 minutes. Until that returns, message_stream.next() is not polled — so the /interrupt message the TUI just sent sits in the channel queue and only dequeues after the turn it was supposed to stop has ended.

The engine-v2 interrupt machinery itself is fine: handle_interrupt at src/bridge/router.rs:1435 correctly calls ThreadManager::stop_thread() on running threads. It's just unreachable from user input while a turn is in flight.

Suggested fix (TUI-local, no agent refactor)

Add an out-of-band interrupt path that bypasses the message channel:

  1. Define a narrow InterruptHandle trait in ironclaw_tui — keeps the crate decoupled from the engine:
    #[async_trait]
    pub trait InterruptHandle: Send + Sync {
        async fn interrupt_active(&self, user_id: &str, channel: &str);
    }
  2. Pass an Arc<dyn InterruptHandle> into TuiAppConfig / start_tui.
  3. In handle_event where InputAction::Interrupt is matched (app.rs:456), call handle.interrupt_active(...).await in addition to sending the /interrupt message (keep the message as a fallback / for the post-turn "Nothing to interrupt." case).
  4. In src/channels/tui.rs, implement InterruptHandle with a concrete type that calls into src/bridge/router.rs::handle_interrupt logic — i.e. look up the active engine conversation for (channel, user_id) and call ThreadManager::stop_thread() on each running thread. This mirrors what handle_interrupt already does, but runs directly from the TUI event loop instead of waiting its turn in the channel queue.

No changes needed to agent_loop.rs or the v2 engine — the machinery is all there, just needs a direct call path.

Why this matters beyond Esc

The same bottleneck affects any future "while processing" feature — cancel buttons, timeouts, status pokes, etc. Landing the out-of-band handle in this PR sets up the pattern. Happy to do the follow-up if it's out of scope here — just wanted to flag it while the TUI architecture is fresh.

Context on the trace: I was evaluating whether an older REPL branch of mine (feat/repl-interrupt-during-processing) was superseded by this PR + #1412 + #1557. The REPL-specific pieces are superseded, but the out-of-band-flag idea from that branch is still load-bearing for the agent-dispatch layer.

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>

@ilblackdragon ilblackdragon left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: Approve

  • ~9.5k LOC port into a new crates/ironclaw_tui crate plus a 704-line src/channels/tui.rs adapter. Well-isolated, has its own CLAUDE.md.
  • All tracing::warn! calls are in foreground request handlers / startup paths — none in background tasks, so the TUI corruption rule is respected. Worth a quick scan post-merge to confirm no info! fires from background tasks once the TUI is attached.
  • All .expect() instances are confined to #[cfg(test)] modules or the explicit examples/dev.rs binary. LAST_COPIED_TEXT mutex correctly handles poison via unwrap_or_else(|e| e.into_inner()).
  • src/main.rs use_repl = !tui_mode || cfg!(not(feature = "tui")) correctly falls back when feature missing.
  • Stable call_id map prevents the duplicate-tool-call collision regression.
  • Strong unit coverage: slash_model, picker hydration, thread switching, snapshot rendering, layout/theme round-trips, approval re-emit. Router changes covered via router approval re-emit test.
  • app.rs (3,342 lines) and render.rs (1,135 lines) are large — consider splitting in a follow-up.

serrrfirat and others added 4 commits April 8, 2026 13:32
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>
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>
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>
…staging

# Conflicts:
#	tests/support/live_harness.rs
@ilblackdragon
ilblackdragon merged commit 482ee57 into staging Apr 8, 2026
15 checks passed
@ilblackdragon
ilblackdragon deleted the firat/feat-tui-on-staging branch April 8, 2026 14:23
@ironclaw-ci ironclaw-ci Bot mentioned this pull request Apr 10, 2026
@ironclaw-ci ironclaw-ci Bot mentioned this pull request Apr 18, 2026
theredspoon pushed a commit to theredspoon/ironclaw that referenced this pull request Jun 21, 2026
…i#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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor: core 20+ merged PRs risk: medium Business logic, config, or moderate-risk modules scope: agent Agent core (agent loop, router, scheduler) scope: channel/wasm WASM channel runtime scope: channel/web Web gateway channel scope: channel Channel infrastructure scope: dependencies Dependency updates scope: docs Documentation size: XL 500+ changed lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants