feat(tui): port full-featured Ratatui terminal UI onto staging - #1973
Conversation
There was a problem hiding this comment.
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.
| 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 }; | ||
| } |
There was a problem hiding this comment.
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(),
};
}There was a problem hiding this comment.
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.
| #[derive(Debug, Clone, Default)] | ||
| pub struct ScreenSnapshot { | ||
| pub area: Rect, | ||
| pub rows: Vec<Vec<String>>, | ||
| } |
There was a problem hiding this comment.
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.
| #[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, | |
| } |
There was a problem hiding this comment.
Addressed in ef1efaf3. ScreenSnapshot now holds { area, buffer: Buffer } instead of Vec<Vec<String>>, with a lightweight default backed by Buffer::empty(area).
| 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) | ||
| } |
There was a problem hiding this comment.
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
}
}There was a problem hiding this comment.
Addressed in ef1efaf3. snapshot_symbol now bounds-checks against the snapshot rect and reads directly from snapshot.buffer[(column, row)].symbol().
| 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); | ||
| } | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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.
| continue; | ||
| } | ||
| // Simple word-wrap | ||
| let words: Vec<&str> = raw_line.split_whitespace().collect(); |
There was a problem hiding this comment.
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
- When reconstructing a string from whitespace-separated fields, prefer joining the collected fields over re-trimming the original string to handle extra spaces robustly.
There was a problem hiding this comment.
Addressed in ef1efaf3. wrap_text no longer uses split_whitespace(); it now preserves consecutive spaces (and expands tabs consistently) while wrapping.
| 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 | ||
| } |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| chrono::NaiveDateTime::parse_from_str(last_activity, "%Y-%m-%d %H:%M") | ||
| .map(|dt| dt.and_utc()) | ||
| .unwrap_or_else(|_| chrono::Utc::now()) | ||
| } |
There was a problem hiding this comment.
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
- 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.
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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
- 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.
- Does the timestamp fallback (#6) manifest in user testing, or is it a hypothetical edge case?
- Context window inference: should this come from a discovery API (provider trait method) instead of hardcoded heuristics?
|
Pushed Covered in this update:
Verification run on this branch:
|
- 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>
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>
Interrupt during processing: input layer fixed, agent dispatch still blockedNice 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 traceInput layer — this PR fixes it. In Agent dispatch layer — still blocked. The outer agent loop in match self.handle_message(&message).await { ... }For a The engine-v2 interrupt machinery itself is fine: Suggested fix (TUI-local, no agent refactor)Add an out-of-band interrupt path that bypasses the message channel:
No changes needed to Why this matters beyond EscThe 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 ( |
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
left a comment
There was a problem hiding this comment.
Verdict: Approve
- ~9.5k LOC port into a new
crates/ironclaw_tuicrate plus a 704-linesrc/channels/tui.rsadapter. Well-isolated, has its ownCLAUDE.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 noinfo!fires from background tasks once the TUI is attached. - All
.expect()instances are confined to#[cfg(test)]modules or the explicitexamples/dev.rsbinary.LAST_COPIED_TEXTmutex correctly handles poison viaunwrap_or_else(|e| e.into_inner()). src/main.rsuse_repl = !tui_mode || cfg!(not(feature = "tui"))correctly falls back when feature missing.- Stable
call_idmap 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 viarouter approval re-emittest. app.rs(3,342 lines) andrender.rs(1,135 lines) are large — consider splitting in a follow-up.
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
…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>
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/modelpicker flow so model switching no longer relies on parsing a raw slash-command response.Notable pieces included here:
stagingUp/Downinput history navigation at chat-bar edges, while preserving multiline editing behaviortui/layout.jsonplusTUI_THEME/TUI_SIDEBARwiringcall_idpropagation so duplicate same-name tool calls do not collide in the TUI/modelnow opens a dedicated model picker in the TUI/modelresponse hydrates the picker and later selections submit/model <name>directly/modelfalls back cleanly to the raw command instead of trapping the user in the slash overlayTest Plan
cargo fmt --allCARGO_TARGET_DIR=/tmp/ironclaw-tui-staging-target cargo test -p ironclaw_tui app::tests:: --offlineCARGO_TARGET_DIR=/tmp/ironclaw-tui-staging-target cargo check -p ironclaw_tui --all-targets --offlineCARGO_TARGET_DIR=/tmp/ironclaw-tui-staging-target cargo check --features tui --tests --offlinecargo test -p ironclaw_tui slash_modelcargo test -p ironclaw_tui model_response_hydrates_picker_after_first_fetchcargo check --features tui --bin ironclawNotes
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.