From 5151f2e28893a28cc6b08c9d7ca28196a761b14f Mon Sep 17 00:00:00 2001 From: Carlos Escobar Date: Sun, 1 Mar 2026 20:17:13 -0500 Subject: [PATCH 1/5] =?UTF-8?q?refactor(tui):=20Phase=201=20=E2=80=94=20co?= =?UTF-8?q?mponent-based=20architecture,=20scroll,=20status=20bar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure arcan-tui from flat module layout to component-based architecture with models/, widgets/, and shared infrastructure modules. New modules: - event.rs: Unified TuiEvent enum + event_pump merging terminal/network/tick - focus.rs: FocusTarget enum with Tab cycling between ChatLog and InputBar - theme.rs: Centralized Theme struct replacing hardcoded colors - models/scroll.rs: ScrollState with offset-from-bottom, auto-follow, page navigation - models/state.rs: Migrated AppState with scroll, focus, connection_status, error flash - models/ui_block.rs: Extracted UiBlock, ToolStatus, ApprovalRequest - widgets/chat_log.rs: Scrollable chat log with timestamps - widgets/status_bar.rs: Session, branch, mode, connection dot, error flash - widgets/spinner.rs: Animated Unicode spinner Modified: - app.rs: Uses event_pump, split key handling by focus, /help command - lib.rs: Registers new modules - models.rs: Re-export hub for backward compatibility - ui.rs: Thin coordinator with 3-chunk layout (chat + status + input) Tests: 26 (up from 14, all original tests migrated) Co-Authored-By: Claude Opus 4.6 --- crates/arcan-tui/src/app.rs | 271 +++++++++++++-------- crates/arcan-tui/src/event.rs | 69 ++++++ crates/arcan-tui/src/focus.rs | 34 +++ crates/arcan-tui/src/lib.rs | 4 + crates/arcan-tui/src/models.rs | 217 +---------------- crates/arcan-tui/src/models/scroll.rs | 168 +++++++++++++ crates/arcan-tui/src/models/state.rs | 227 +++++++++++++++++ crates/arcan-tui/src/models/ui_block.rs | 44 ++++ crates/arcan-tui/src/theme.rs | 73 ++++++ crates/arcan-tui/src/ui.rs | 114 ++++----- crates/arcan-tui/src/widgets.rs | 3 + crates/arcan-tui/src/widgets/chat_log.rs | 104 ++++++++ crates/arcan-tui/src/widgets/spinner.rs | 47 ++++ crates/arcan-tui/src/widgets/status_bar.rs | 49 ++++ 14 files changed, 1036 insertions(+), 388 deletions(-) create mode 100644 crates/arcan-tui/src/event.rs create mode 100644 crates/arcan-tui/src/focus.rs create mode 100644 crates/arcan-tui/src/models/scroll.rs create mode 100644 crates/arcan-tui/src/models/state.rs create mode 100644 crates/arcan-tui/src/models/ui_block.rs create mode 100644 crates/arcan-tui/src/theme.rs create mode 100644 crates/arcan-tui/src/widgets.rs create mode 100644 crates/arcan-tui/src/widgets/chat_log.rs create mode 100644 crates/arcan-tui/src/widgets/spinner.rs create mode 100644 crates/arcan-tui/src/widgets/status_bar.rs diff --git a/crates/arcan-tui/src/app.rs b/crates/arcan-tui/src/app.rs index 8dd3e74..63bf4e0 100644 --- a/crates/arcan-tui/src/app.rs +++ b/crates/arcan-tui/src/app.rs @@ -1,9 +1,10 @@ -use crate::models::{AppState, UiBlock}; +use crate::event::{TuiEvent, event_pump}; +use crate::models::state::AppState; +use crate::models::ui_block::UiBlock; use crate::network::{NetworkClient, NetworkConfig}; use crate::ui; -use arcan_core::protocol::AgentEvent; use chrono::Utc; -use crossterm::event::{self, Event, KeyCode, KeyEventKind}; +use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers}; use ratatui::{Terminal, backend::Backend}; use std::sync::Arc; use std::time::Duration; @@ -53,7 +54,7 @@ pub struct App { pub state: AppState, pub should_quit: bool, pub client: Arc, - pub event_rx: mpsc::Receiver, + events: mpsc::Receiver, } impl App { @@ -69,11 +70,14 @@ impl App { } }); + // Merge terminal + network + ticks into a single event stream + let events = event_pump(rx, Duration::from_millis(50)); + Self { state: AppState::new(), should_quit: false, client, - event_rx: rx, + events, } } @@ -119,119 +123,170 @@ impl App { where B::Error: Send + Sync + 'static, { - loop { - // Draw UI - terminal.draw(|f| ui::draw(f, &self.state))?; - - // Process external AgentEvents non-blocking - while let Ok(event) = self.event_rx.try_recv() { - self.state.apply_event(event); - } + // Initial draw + terminal.draw(|f| ui::draw(f, &mut self.state))?; - // Handle UI events non-blocking - if event::poll(Duration::from_millis(50))? { - if let Event::Key(key) = event::read()? { - if key.kind == KeyEventKind::Press { - match key.code { - KeyCode::Esc => self.should_quit = true, - KeyCode::Char('c') - if key - .modifiers - .contains(crossterm::event::KeyModifiers::CONTROL) => - { - self.should_quit = true; - } - KeyCode::Char(c) => { - self.state.input_buffer.push(c); - } - KeyCode::Backspace => { - self.state.input_buffer.pop(); - } - KeyCode::Enter => { - let msg = self.state.input_buffer.trim().to_string(); - if !msg.is_empty() { - if msg == "/clear" { - self.state.blocks.clear(); - self.state.streaming_text = None; - self.state.is_busy = false; - } else if self.handle_model_command(&msg).await { - } else if msg.starts_with("/approve") { - let parts: Vec<&str> = msg.split_whitespace().collect(); - if parts.len() >= 3 { - let approval_id = parts[1].to_string(); - let decision = match parts[2] - .to_ascii_lowercase() - .as_str() - { - "yes" | "y" | "approved" | "approve" => { - "approved".to_string() - } - "no" | "n" | "denied" | "deny" => { - "denied".to_string() - } - invalid => { - tracing::warn!( - "Invalid approval decision '{}'. Use yes/no.", - invalid - ); - self.state.input_buffer.clear(); - continue; - } - }; - let reason = if parts.len() > 3 { - Some(parts[3..].join(" ")) - } else { - None - }; - - let submit_client = self.client.clone(); - tokio::spawn(async move { - if let Err(e) = submit_client - .submit_approval( - &approval_id, - &decision, - reason.as_deref(), - ) - .await - { - tracing::error!("Submit approval error: {}", e); - } - }); - } else { - tracing::warn!( - "Invalid /approve syntax. Use: /approve [reason]" - ); - } - } else { - self.state.is_busy = true; - self.state.blocks.push(UiBlock::HumanMessage { - text: msg.clone(), - timestamp: Utc::now(), - }); - let submit_client = self.client.clone(); - tokio::spawn(async move { - if let Err(e) = - submit_client.submit_run(&msg, None).await - { - tracing::error!("Submit error: {}", e); - } - }); - } - self.state.input_buffer.clear(); - } - } - _ => {} - } - } + while let Some(event) = self.events.recv().await { + match event { + TuiEvent::Key(key) if key.kind == KeyEventKind::Press => { + self.handle_key(key.code, key.modifiers).await; + } + TuiEvent::Network(agent_event) => { + self.state.apply_event(agent_event); + } + TuiEvent::Tick => { + // Clear expired error flashes (5 second TTL) + self.state + .clear_expired_errors(chrono::Duration::seconds(5)); } + TuiEvent::Resize(_, _) => { + // Will redraw below + } + _ => {} } + // Redraw after every event + terminal.draw(|f| ui::draw(f, &mut self.state))?; + if self.should_quit { break; } } + Ok(()) } + + async fn handle_key(&mut self, code: KeyCode, modifiers: KeyModifiers) { + // Focus-independent keys + match code { + KeyCode::Esc => { + self.should_quit = true; + return; + } + KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => { + self.should_quit = true; + return; + } + KeyCode::Tab => { + self.state.focus = self.state.focus.next(); + return; + } + _ => {} + } + + // Focus-dependent key handling + match self.state.focus { + crate::focus::FocusTarget::ChatLog => self.handle_scroll_key(code), + crate::focus::FocusTarget::InputBar => self.handle_input_key(code).await, + } + } + + fn handle_scroll_key(&mut self, code: KeyCode) { + match code { + KeyCode::Up | KeyCode::Char('k') => self.state.scroll.scroll_up(1), + KeyCode::Down | KeyCode::Char('j') => self.state.scroll.scroll_down(1), + KeyCode::PageUp => self.state.scroll.page_up(), + KeyCode::PageDown => self.state.scroll.page_down(), + KeyCode::Home | KeyCode::Char('g') => { + let max = self.state.scroll.total_lines; + self.state.scroll.scroll_up(max); + } + KeyCode::End | KeyCode::Char('G') => self.state.scroll.scroll_to_bottom(), + _ => {} + } + } + + async fn handle_input_key(&mut self, code: KeyCode) { + match code { + KeyCode::Char(c) => { + self.state.input_buffer.push(c); + } + KeyCode::Backspace => { + self.state.input_buffer.pop(); + } + KeyCode::Enter => { + self.handle_submit().await; + } + // Allow PageUp/PageDown even in input mode for convenience + KeyCode::PageUp => self.state.scroll.page_up(), + KeyCode::PageDown => self.state.scroll.scroll_to_bottom(), + _ => {} + } + } + + async fn handle_submit(&mut self) { + let msg = self.state.input_buffer.trim().to_string(); + if msg.is_empty() { + return; + } + + if msg == "/clear" { + self.state.blocks.clear(); + self.state.streaming_text = None; + self.state.is_busy = false; + } else if msg == "/help" { + self.push_system_alert( + "Commands: /clear, /model [provider[:model]], /approve [reason], /help", + ); + } else if self.handle_model_command(&msg).await { + // handled + } else if msg.starts_with("/approve") { + self.handle_approve_command(&msg); + } else { + // Normal message — submit run + self.state.is_busy = true; + self.state.blocks.push(UiBlock::HumanMessage { + text: msg.clone(), + timestamp: Utc::now(), + }); + + // Auto-follow on new message + self.state.scroll.scroll_to_bottom(); + + let submit_client = self.client.clone(); + tokio::spawn(async move { + if let Err(e) = submit_client.submit_run(&msg, None).await { + tracing::error!("Submit error: {}", e); + } + }); + } + self.state.input_buffer.clear(); + } + + fn handle_approve_command(&mut self, msg: &str) { + let parts: Vec<&str> = msg.split_whitespace().collect(); + if parts.len() >= 3 { + let approval_id = parts[1].to_string(); + let decision = match parts[2].to_ascii_lowercase().as_str() { + "yes" | "y" | "approved" | "approve" => "approved".to_string(), + "no" | "n" | "denied" | "deny" => "denied".to_string(), + invalid => { + self.push_system_alert(format!( + "Invalid approval decision '{}'. Use yes/no.", + invalid + )); + return; + } + }; + let reason = if parts.len() > 3 { + Some(parts[3..].join(" ")) + } else { + None + }; + + let submit_client = self.client.clone(); + tokio::spawn(async move { + if let Err(e) = submit_client + .submit_approval(&approval_id, &decision, reason.as_deref()) + .await + { + tracing::error!("Submit approval error: {}", e); + } + }); + } else { + self.push_system_alert("Usage: /approve [reason]"); + } + } } #[cfg(test)] diff --git a/crates/arcan-tui/src/event.rs b/crates/arcan-tui/src/event.rs new file mode 100644 index 0000000..7f966a2 --- /dev/null +++ b/crates/arcan-tui/src/event.rs @@ -0,0 +1,69 @@ +use arcan_core::protocol::AgentEvent; +use crossterm::event::{self, Event, KeyEvent}; +use std::time::Duration; +use tokio::sync::mpsc; + +/// Unified TUI event type merging terminal, network, and timer events. +pub enum TuiEvent { + /// A key press from the terminal. + Key(KeyEvent), + /// Periodic tick for UI refresh and animation. + Tick, + /// An agent event from the daemon SSE stream. + Network(AgentEvent), + /// Terminal resize event. + Resize(u16, u16), +} + +/// Spawn background producers that merge terminal input, network events, +/// and periodic ticks into a single `mpsc::Receiver`. +/// +/// - Terminal events are read on a dedicated OS thread (crossterm is blocking). +/// - Network events are forwarded from the given receiver. +/// - Ticks are emitted whenever the crossterm poll times out. +pub fn event_pump( + network_rx: mpsc::Receiver, + tick_rate: Duration, +) -> mpsc::Receiver { + let (tx, rx) = mpsc::channel(256); + + // Terminal events — must run on a dedicated OS thread (crossterm is blocking) + let term_tx = tx.clone(); + std::thread::spawn(move || { + loop { + if event::poll(tick_rate).unwrap_or(false) { + match event::read() { + Ok(Event::Key(key)) => { + if term_tx.blocking_send(TuiEvent::Key(key)).is_err() { + break; + } + } + Ok(Event::Resize(w, h)) => { + if term_tx.blocking_send(TuiEvent::Resize(w, h)).is_err() { + break; + } + } + _ => {} + } + } else { + // Poll timeout = emit tick + if term_tx.blocking_send(TuiEvent::Tick).is_err() { + break; + } + } + } + }); + + // Forward network events into the unified channel + let net_tx = tx; + tokio::spawn(async move { + let mut network_rx = network_rx; + while let Some(agent_event) = network_rx.recv().await { + if net_tx.send(TuiEvent::Network(agent_event)).await.is_err() { + break; + } + } + }); + + rx +} diff --git a/crates/arcan-tui/src/focus.rs b/crates/arcan-tui/src/focus.rs new file mode 100644 index 0000000..70892da --- /dev/null +++ b/crates/arcan-tui/src/focus.rs @@ -0,0 +1,34 @@ +/// Focus targets in the TUI layout. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub enum FocusTarget { + ChatLog, + #[default] + InputBar, +} + +impl FocusTarget { + /// Cycle to the next focus target (Tab key behavior). + pub fn next(self) -> Self { + match self { + Self::ChatLog => Self::InputBar, + Self::InputBar => Self::ChatLog, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn focus_cycles_between_targets() { + let focus = FocusTarget::InputBar; + assert_eq!(focus.next(), FocusTarget::ChatLog); + assert_eq!(focus.next().next(), FocusTarget::InputBar); + } + + #[test] + fn default_focus_is_input_bar() { + assert_eq!(FocusTarget::default(), FocusTarget::InputBar); + } +} diff --git a/crates/arcan-tui/src/lib.rs b/crates/arcan-tui/src/lib.rs index 00f4d84..366b1ee 100644 --- a/crates/arcan-tui/src/lib.rs +++ b/crates/arcan-tui/src/lib.rs @@ -1,9 +1,13 @@ pub mod app; +pub mod event; +pub mod focus; pub mod models; #[cfg(test)] mod models_test; pub mod network; +pub mod theme; pub mod ui; +pub mod widgets; use app::App; use crossterm::{ diff --git a/crates/arcan-tui/src/models.rs b/crates/arcan-tui/src/models.rs index a57a17c..ac39629 100644 --- a/crates/arcan-tui/src/models.rs +++ b/crates/arcan-tui/src/models.rs @@ -1,209 +1,8 @@ -use arcan_core::protocol::AgentEvent; -use chrono::{DateTime, Utc}; - -/// Pure logical representation of the UI state. -/// This model has zero dependency on ratatui or IO, making it easily testable. -#[derive(Debug, Default)] -pub struct AppState { - pub session_id: Option, - pub current_branch: String, - - /// The conversation history (messages, tool executions, errors) - pub blocks: Vec, - - /// Pending text delta buffer (assistant is typing) - pub streaming_text: Option, - - /// User input string - pub input_buffer: String, - - /// True if the UI is blocked waiting for user approval of a policy intervention - pub pending_approval: Option, - - /// Tracks if we're currently fetching from daemon - pub is_busy: bool, -} - -#[derive(Debug, Clone)] -pub enum UiBlock { - HumanMessage { - text: String, - timestamp: DateTime, - }, - AssistantMessage { - text: String, - timestamp: DateTime, - }, - ToolExecution { - call_id: String, - tool_name: String, - arguments: serde_json::Value, - status: ToolStatus, - result: Option, - timestamp: DateTime, - }, - SystemAlert { - text: String, - timestamp: DateTime, - }, -} - -#[derive(Debug, Clone, PartialEq)] -pub enum ToolStatus { - Running, - Success, - Error(String), -} - -#[derive(Debug, Clone)] -pub struct ApprovalRequest { - pub approval_id: String, - pub call_id: String, - pub tool_name: String, - pub arguments: serde_json::Value, - pub risk_level: String, -} - -impl AppState { - pub fn new() -> Self { - Self { - session_id: None, - current_branch: "main".to_string(), - blocks: Vec::new(), - streaming_text: None, - input_buffer: String::new(), - pending_approval: None, - is_busy: false, - } - } - - /// Process a raw AgentEvent from the daemon and mutate the view state. - pub fn apply_event(&mut self, event: AgentEvent) { - let now = Utc::now(); - match event { - AgentEvent::RunStarted { .. } => { - self.is_busy = true; - self.streaming_text = None; - } - AgentEvent::TextDelta { delta, .. } => { - if let Some(mut text) = self.streaming_text.take() { - text.push_str(&delta); - self.streaming_text = Some(text); - } else { - self.streaming_text = Some(delta); - } - } - AgentEvent::ToolCallRequested { call, .. } => { - // Flush streaming text if it exists (model stopped reasoning, started acting) - if let Some(text) = self.streaming_text.take() { - self.blocks.push(UiBlock::AssistantMessage { - text, - timestamp: now, - }); - } - - self.blocks.push(UiBlock::ToolExecution { - call_id: call.call_id, - tool_name: call.tool_name, - arguments: call.input, - status: ToolStatus::Running, - result: None, - timestamp: now, - }); - } - AgentEvent::ToolCallCompleted { result, .. } => { - // Find and update the running tool block - if let Some(UiBlock::ToolExecution { - status, - result: block_result, - .. - }) = self.blocks.iter_mut().find(|b| { - if let UiBlock::ToolExecution { call_id, .. } = b { - call_id == &result.call_id - } else { - false - } - }) { - *status = ToolStatus::Success; - *block_result = Some(result.output); - } - } - AgentEvent::ToolCallFailed { call_id, error, .. } => { - if let Some(UiBlock::ToolExecution { status, .. }) = - self.blocks.iter_mut().find(|b| { - if let UiBlock::ToolExecution { call_id: id, .. } = b { - id == &call_id - } else { - false - } - }) - { - *status = ToolStatus::Error(error); - } - } - AgentEvent::RunFinished { final_answer, .. } => { - if let Some(text) = self.streaming_text.take() { - if !self.last_assistant_message_matches(&text) { - self.blocks.push(UiBlock::AssistantMessage { - text, - timestamp: now, - }); - } - } else if let Some(ans) = final_answer { - if !self.last_assistant_message_matches(&ans) { - self.blocks.push(UiBlock::AssistantMessage { - text: ans, - timestamp: now, - }); - } - } - self.is_busy = false; - } - AgentEvent::RunErrored { error, .. } => { - self.blocks.push(UiBlock::SystemAlert { - text: format!("Run Error: {}", error), - timestamp: now, - }); - self.is_busy = false; - } - AgentEvent::ApprovalRequested { - approval_id, - call_id, - tool_name, - arguments, - risk, - .. - } => { - self.is_busy = false; - self.pending_approval = Some(ApprovalRequest { - approval_id, - call_id, - tool_name, - arguments, - risk_level: risk, - }); - } - AgentEvent::ApprovalResolved { decision, .. } => { - self.pending_approval = None; - self.blocks.push(UiBlock::SystemAlert { - text: format!("Tool execution was {}", decision), - timestamp: now, - }); - self.is_busy = true; // Loop resumes - } - _ => { - // Ignore other events for pure UI view state - } - } - } - - fn last_assistant_message_matches(&self, text: &str) -> bool { - self.blocks - .last() - .and_then(|block| match block { - UiBlock::AssistantMessage { text, .. } => Some(text.as_str()), - _ => None, - }) - .is_some_and(|last| last == text) - } -} +pub mod scroll; +pub mod state; +pub mod ui_block; + +// Re-export commonly used types for backward compatibility +pub use scroll::ScrollState; +pub use state::{AppState, ConnectionStatus, ErrorFlash}; +pub use ui_block::{ApprovalRequest, ToolStatus, UiBlock}; diff --git a/crates/arcan-tui/src/models/scroll.rs b/crates/arcan-tui/src/models/scroll.rs new file mode 100644 index 0000000..3356356 --- /dev/null +++ b/crates/arcan-tui/src/models/scroll.rs @@ -0,0 +1,168 @@ +/// Scroll state using offset-from-bottom semantics. +/// +/// `offset = 0` means the latest content is visible (auto-follow mode). +/// Scrolling up increases offset; reaching bottom re-enables auto-follow. +#[derive(Debug, Clone)] +pub struct ScrollState { + /// Distance from bottom (0 = latest message visible). + pub offset: usize, + /// Whether to auto-follow new messages. + pub auto_follow: bool, + /// Total number of rendered lines (set each frame). + pub total_lines: usize, + /// Visible height of the viewport (set each frame). + pub viewport_height: usize, +} + +impl Default for ScrollState { + fn default() -> Self { + Self { + offset: 0, + auto_follow: true, + total_lines: 0, + viewport_height: 0, + } + } +} + +impl ScrollState { + pub fn new() -> Self { + Self::default() + } + + /// Scroll up by `lines` rows. Disables auto-follow. + pub fn scroll_up(&mut self, lines: usize) { + let max_offset = self.max_offset(); + self.offset = (self.offset + lines).min(max_offset); + if self.offset > 0 { + self.auto_follow = false; + } + } + + /// Scroll down by `lines` rows. Re-enables auto-follow if we reach bottom. + pub fn scroll_down(&mut self, lines: usize) { + self.offset = self.offset.saturating_sub(lines); + if self.offset == 0 { + self.auto_follow = true; + } + } + + /// Scroll up by one page (viewport height - 1). + pub fn page_up(&mut self) { + self.scroll_up(self.viewport_height.saturating_sub(1).max(1)); + } + + /// Scroll down by one page (viewport height - 1). + pub fn page_down(&mut self) { + self.scroll_down(self.viewport_height.saturating_sub(1).max(1)); + } + + /// Jump to the bottom and re-enable auto-follow. + pub fn scroll_to_bottom(&mut self) { + self.offset = 0; + self.auto_follow = true; + } + + /// Compute the scroll position for ratatui's `Paragraph::scroll()`. + /// Returns `(row_offset_from_top, 0)`. + pub fn compute_scroll_position(&self) -> (u16, u16) { + let max = self.max_offset(); + let from_top = max.saturating_sub(self.offset); + (from_top as u16, 0) + } + + /// Update dimensions each frame. Clamps offset if content shrinks. + pub fn update_dimensions(&mut self, total_lines: usize, viewport_height: usize) { + self.total_lines = total_lines; + self.viewport_height = viewport_height; + if self.auto_follow { + self.offset = 0; + } else { + self.offset = self.offset.min(self.max_offset()); + } + } + + fn max_offset(&self) -> usize { + self.total_lines.saturating_sub(self.viewport_height) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn scroll_starts_at_bottom() { + let s = ScrollState::new(); + assert_eq!(s.offset, 0); + assert!(s.auto_follow); + } + + #[test] + fn scroll_up_disables_auto_follow() { + let mut s = ScrollState::new(); + s.update_dimensions(100, 20); + s.scroll_up(5); + assert_eq!(s.offset, 5); + assert!(!s.auto_follow); + } + + #[test] + fn scroll_down_to_bottom_enables_auto_follow() { + let mut s = ScrollState::new(); + s.update_dimensions(100, 20); + s.scroll_up(10); + assert!(!s.auto_follow); + s.scroll_down(10); + assert_eq!(s.offset, 0); + assert!(s.auto_follow); + } + + #[test] + fn scroll_up_clamps_to_max() { + let mut s = ScrollState::new(); + s.update_dimensions(50, 20); + s.scroll_up(999); + assert_eq!(s.offset, 30); // 50 - 20 + } + + #[test] + fn page_up_and_down() { + let mut s = ScrollState::new(); + s.update_dimensions(100, 20); + s.page_up(); + assert_eq!(s.offset, 19); // viewport_height - 1 + s.page_down(); + assert_eq!(s.offset, 0); + assert!(s.auto_follow); + } + + #[test] + fn compute_scroll_position_at_bottom() { + let mut s = ScrollState::new(); + s.update_dimensions(100, 20); + // offset=0 means we're at bottom, so from_top = max_offset + let (row, _) = s.compute_scroll_position(); + assert_eq!(row, 80); // 100 - 20 + } + + #[test] + fn compute_scroll_position_scrolled_up() { + let mut s = ScrollState::new(); + s.update_dimensions(100, 20); + s.scroll_up(30); + let (row, _) = s.compute_scroll_position(); + assert_eq!(row, 50); // (100-20) - 30 + } + + #[test] + fn update_dimensions_clamps_offset() { + let mut s = ScrollState::new(); + s.update_dimensions(100, 20); + s.scroll_up(50); + assert_eq!(s.offset, 50); + // Content shrinks + s.update_dimensions(30, 20); + assert_eq!(s.offset, 10); // clamped to 30 - 20 + } +} diff --git a/crates/arcan-tui/src/models/state.rs b/crates/arcan-tui/src/models/state.rs new file mode 100644 index 0000000..8863d58 --- /dev/null +++ b/crates/arcan-tui/src/models/state.rs @@ -0,0 +1,227 @@ +use super::scroll::ScrollState; +use super::ui_block::{ApprovalRequest, ToolStatus, UiBlock}; +use crate::focus::FocusTarget; +use arcan_core::protocol::AgentEvent; +use chrono::{DateTime, Utc}; + +/// Connection status for the daemon. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub enum ConnectionStatus { + Connected, + Disconnected, + #[default] + Connecting, +} + +/// Error flash message with timestamp for TTL-based expiry. +#[derive(Debug, Clone)] +pub struct ErrorFlash { + pub message: String, + pub timestamp: DateTime, +} + +/// Pure logical representation of the UI state. +/// This model has zero dependency on ratatui or IO, making it easily testable. +#[derive(Debug)] +pub struct AppState { + pub session_id: Option, + pub current_branch: String, + + /// The conversation history (messages, tool executions, errors) + pub blocks: Vec, + + /// Pending text delta buffer (assistant is typing) + pub streaming_text: Option, + + /// User input string + pub input_buffer: String, + + /// True if the UI is blocked waiting for user approval of a policy intervention + pub pending_approval: Option, + + /// Tracks if we're currently fetching from daemon + pub is_busy: bool, + + /// Scroll state for the chat log + pub scroll: ScrollState, + + /// Which widget currently has keyboard focus + pub focus: FocusTarget, + + /// Connection status to the daemon + pub connection_status: ConnectionStatus, + + /// Transient error shown in the status bar + pub last_error: Option, +} + +impl Default for AppState { + fn default() -> Self { + Self::new() + } +} + +impl AppState { + pub fn new() -> Self { + Self { + session_id: None, + current_branch: "main".to_string(), + blocks: Vec::new(), + streaming_text: None, + input_buffer: String::new(), + pending_approval: None, + is_busy: false, + scroll: ScrollState::new(), + focus: FocusTarget::InputBar, + connection_status: ConnectionStatus::Connecting, + last_error: None, + } + } + + /// Set an error flash that will be displayed in the status bar. + pub fn flash_error(&mut self, message: impl Into) { + self.last_error = Some(ErrorFlash { + message: message.into(), + timestamp: Utc::now(), + }); + } + + /// Clear expired error flashes (older than `ttl`). + pub fn clear_expired_errors(&mut self, ttl: chrono::Duration) { + if let Some(ref flash) = self.last_error { + if Utc::now() - flash.timestamp > ttl { + self.last_error = None; + } + } + } + + /// Process a raw AgentEvent from the daemon and mutate the view state. + pub fn apply_event(&mut self, event: AgentEvent) { + let now = Utc::now(); + match event { + AgentEvent::RunStarted { .. } => { + self.is_busy = true; + self.streaming_text = None; + } + AgentEvent::TextDelta { delta, .. } => { + if let Some(mut text) = self.streaming_text.take() { + text.push_str(&delta); + self.streaming_text = Some(text); + } else { + self.streaming_text = Some(delta); + } + } + AgentEvent::ToolCallRequested { call, .. } => { + // Flush streaming text if it exists (model stopped reasoning, started acting) + if let Some(text) = self.streaming_text.take() { + self.blocks.push(UiBlock::AssistantMessage { + text, + timestamp: now, + }); + } + + self.blocks.push(UiBlock::ToolExecution { + call_id: call.call_id, + tool_name: call.tool_name, + arguments: call.input, + status: ToolStatus::Running, + result: None, + timestamp: now, + }); + } + AgentEvent::ToolCallCompleted { result, .. } => { + // Find and update the running tool block + if let Some(UiBlock::ToolExecution { + status, + result: block_result, + .. + }) = self.blocks.iter_mut().find(|b| { + if let UiBlock::ToolExecution { call_id, .. } = b { + call_id == &result.call_id + } else { + false + } + }) { + *status = ToolStatus::Success; + *block_result = Some(result.output); + } + } + AgentEvent::ToolCallFailed { call_id, error, .. } => { + if let Some(UiBlock::ToolExecution { status, .. }) = + self.blocks.iter_mut().find(|b| { + if let UiBlock::ToolExecution { call_id: id, .. } = b { + id == &call_id + } else { + false + } + }) + { + *status = ToolStatus::Error(error); + } + } + AgentEvent::RunFinished { final_answer, .. } => { + if let Some(text) = self.streaming_text.take() { + if !self.last_assistant_message_matches(&text) { + self.blocks.push(UiBlock::AssistantMessage { + text, + timestamp: now, + }); + } + } else if let Some(ans) = final_answer { + if !self.last_assistant_message_matches(&ans) { + self.blocks.push(UiBlock::AssistantMessage { + text: ans, + timestamp: now, + }); + } + } + self.is_busy = false; + } + AgentEvent::RunErrored { error, .. } => { + self.blocks.push(UiBlock::SystemAlert { + text: format!("Run Error: {}", error), + timestamp: now, + }); + self.is_busy = false; + } + AgentEvent::ApprovalRequested { + approval_id, + call_id, + tool_name, + arguments, + risk, + .. + } => { + self.is_busy = false; + self.pending_approval = Some(ApprovalRequest { + approval_id, + call_id, + tool_name, + arguments, + risk_level: risk, + }); + } + AgentEvent::ApprovalResolved { decision, .. } => { + self.pending_approval = None; + self.blocks.push(UiBlock::SystemAlert { + text: format!("Tool execution was {}", decision), + timestamp: now, + }); + self.is_busy = true; // Loop resumes + } + _ => { + // Ignore other events for pure UI view state + } + } + } + + fn last_assistant_message_matches(&self, text: &str) -> bool { + self.blocks + .last() + .and_then(|block| match block { + UiBlock::AssistantMessage { text, .. } => Some(text.as_str()), + _ => None, + }) + .is_some_and(|last| last == text) + } +} diff --git a/crates/arcan-tui/src/models/ui_block.rs b/crates/arcan-tui/src/models/ui_block.rs new file mode 100644 index 0000000..ff87eb5 --- /dev/null +++ b/crates/arcan-tui/src/models/ui_block.rs @@ -0,0 +1,44 @@ +use chrono::{DateTime, Utc}; + +/// A single rendered block in the conversation view. +#[derive(Debug, Clone)] +pub enum UiBlock { + HumanMessage { + text: String, + timestamp: DateTime, + }, + AssistantMessage { + text: String, + timestamp: DateTime, + }, + ToolExecution { + call_id: String, + tool_name: String, + arguments: serde_json::Value, + status: ToolStatus, + result: Option, + timestamp: DateTime, + }, + SystemAlert { + text: String, + timestamp: DateTime, + }, +} + +/// Execution status for a tool call. +#[derive(Debug, Clone, PartialEq)] +pub enum ToolStatus { + Running, + Success, + Error(String), +} + +/// A pending approval request awaiting user decision. +#[derive(Debug, Clone)] +pub struct ApprovalRequest { + pub approval_id: String, + pub call_id: String, + pub tool_name: String, + pub arguments: serde_json::Value, + pub risk_level: String, +} diff --git a/crates/arcan-tui/src/theme.rs b/crates/arcan-tui/src/theme.rs new file mode 100644 index 0000000..ca9210f --- /dev/null +++ b/crates/arcan-tui/src/theme.rs @@ -0,0 +1,73 @@ +use ratatui::style::{Color, Modifier, Style}; + +/// Centralized theme for the TUI. +/// All widget renderers should use this instead of hardcoding colors. +pub struct Theme { + pub human_label: Style, + pub assistant_label: Style, + pub system_label: Style, + pub tool_label: Style, + pub tool_success: Style, + pub tool_error: Style, + pub streaming_cursor: Style, + pub input_normal: Style, + pub input_approval: Style, + pub border: Style, + pub title: Style, + pub status_bar_bg: Style, + pub status_connected: Style, + pub status_disconnected: Style, + pub status_connecting: Style, + pub error_flash: Style, + pub timestamp: Style, + pub spinner: Style, +} + +impl Default for Theme { + fn default() -> Self { + Self { + human_label: Style::default() + .fg(Color::Blue) + .add_modifier(Modifier::BOLD), + assistant_label: Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + system_label: Style::default().fg(Color::Red), + tool_label: Style::default().fg(Color::Yellow), + tool_success: Style::default().fg(Color::Green), + tool_error: Style::default().fg(Color::Red), + streaming_cursor: Style::default().fg(Color::Gray), + input_normal: Style::default().fg(Color::White), + input_approval: Style::default().fg(Color::Red), + border: Style::default().fg(Color::DarkGray), + title: Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + status_bar_bg: Style::default().fg(Color::White).bg(Color::DarkGray), + status_connected: Style::default().fg(Color::Green), + status_disconnected: Style::default().fg(Color::Red), + status_connecting: Style::default().fg(Color::Yellow), + error_flash: Style::default().fg(Color::White).bg(Color::Red), + timestamp: Style::default().fg(Color::DarkGray), + spinner: Style::default().fg(Color::Cyan), + } + } +} + +impl Theme { + pub fn new() -> Self { + Self::default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn theme_default_has_distinct_label_colors() { + let t = Theme::new(); + assert_ne!(t.human_label.fg, t.assistant_label.fg); + assert_ne!(t.system_label.fg, t.tool_label.fg); + } +} diff --git a/crates/arcan-tui/src/ui.rs b/crates/arcan-tui/src/ui.rs index a688d73..9bd6a3a 100644 --- a/crates/arcan-tui/src/ui.rs +++ b/crates/arcan-tui/src/ui.rs @@ -1,96 +1,68 @@ -use crate::models::{AppState, UiBlock}; +use crate::focus::FocusTarget; +use crate::models::state::AppState; +use crate::theme::Theme; +use crate::widgets; use ratatui::{ Frame, - layout::{Constraint, Direction, Layout}, - style::{Color, Style}, - text::{Line, Span}, - widgets::{Block, Borders, Paragraph, Wrap}, + layout::{Constraint, Direction, Layout, Rect}, + text::Line, + widgets::{Block, Borders, Paragraph}, }; -pub fn draw(f: &mut Frame, state: &AppState) { +/// Top-level draw function. Orchestrates the three-chunk layout: +/// chat log, status bar, and input box. +pub fn draw(f: &mut Frame, state: &mut AppState) { + let theme = Theme::new(); + let chunks = Layout::default() .direction(Direction::Vertical) .margin(1) .constraints([ Constraint::Min(3), // Chat log + Constraint::Length(1), // Status bar Constraint::Length(3), // Input box ]) .split(f.area()); - // Messages Area - let mut message_lines = Vec::new(); - for block in &state.blocks { - match block { - UiBlock::HumanMessage { text, timestamp: _ } => { - message_lines.push(Line::from(vec![ - Span::styled("You: ", Style::default().fg(Color::Blue)), - Span::raw(text.clone()), - ])); - } - UiBlock::AssistantMessage { text, timestamp: _ } => { - message_lines.push(Line::from(vec![ - Span::styled("Assistant: ", Style::default().fg(Color::Green)), - Span::raw(text.clone()), - ])); - } - UiBlock::ToolExecution { - tool_name, status, .. - } => { - let status_str = match status { - crate::models::ToolStatus::Running => "(Running...)", - crate::models::ToolStatus::Success => "[OK]", - crate::models::ToolStatus::Error(_) => "[ERR]", - }; - message_lines.push(Line::from(vec![ - Span::styled("Tool ", Style::default().fg(Color::Yellow)), - Span::styled( - format!("{} {}", tool_name, status_str), - Style::default().fg(Color::Yellow), - ), - ])); - } - UiBlock::SystemAlert { text, .. } => { - message_lines.push(Line::from(vec![ - Span::styled("System: ", Style::default().fg(Color::Red)), - Span::styled(text.clone(), Style::default().fg(Color::Red)), - ])); - } - } - } + // Chat log (scrollable) + widgets::chat_log::render(f, chunks[0], state, &theme); - if let Some(streaming) = &state.streaming_text { - message_lines.push(Line::from(vec![ - Span::styled("Assistant: ", Style::default().fg(Color::Green)), - Span::raw(streaming.clone()), - Span::styled(" █", Style::default().fg(Color::Gray)), - ])); - } + // Status bar + widgets::status_bar::render(f, chunks[1], state, &theme); - let messages_block = Paragraph::new(message_lines) - .block( - Block::default() - .borders(Borders::ALL) - .title(" Session Log "), - ) - .wrap(Wrap { trim: false }); - f.render_widget(messages_block, chunks[0]); + // Input area + render_input(f, chunks[2], state, &theme); +} - // Input Area +fn render_input(f: &mut Frame, area: Rect, state: &AppState, theme: &Theme) { let prompt = if let Some(approval) = &state.pending_approval { format!( "Approval Req for {}: (yes/no) > {}", approval.tool_name, state.input_buffer ) } else { - format!("❯ {}", state.input_buffer) + format!("\u{276f} {}", state.input_buffer) // ❯ + }; + + let style = if state.pending_approval.is_some() { + theme.input_approval + } else { + theme.input_normal + }; + + let border_style = if state.focus == FocusTarget::InputBar { + theme.title + } else { + theme.border }; - let input_block = Paragraph::new(prompt) - .block(Block::default().borders(Borders::ALL).title(" Input ")) - .style(if state.pending_approval.is_some() { - Style::default().fg(Color::Red) - } else { - Style::default().fg(Color::White) - }); - f.render_widget(input_block, chunks[1]); + let input_block = Paragraph::new(Line::from(prompt)) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(border_style) + .title(" Input "), + ) + .style(style); + f.render_widget(input_block, area); } diff --git a/crates/arcan-tui/src/widgets.rs b/crates/arcan-tui/src/widgets.rs new file mode 100644 index 0000000..d3a963f --- /dev/null +++ b/crates/arcan-tui/src/widgets.rs @@ -0,0 +1,3 @@ +pub mod chat_log; +pub mod spinner; +pub mod status_bar; diff --git a/crates/arcan-tui/src/widgets/chat_log.rs b/crates/arcan-tui/src/widgets/chat_log.rs new file mode 100644 index 0000000..235b6c4 --- /dev/null +++ b/crates/arcan-tui/src/widgets/chat_log.rs @@ -0,0 +1,104 @@ +use crate::focus::FocusTarget; +use crate::models::state::AppState; +use crate::models::ui_block::{ToolStatus, UiBlock}; +use crate::theme::Theme; +use ratatui::{ + Frame, + layout::Rect, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph, Wrap}, +}; + +/// Render the chat log into the given area, respecting scroll state. +pub fn render(f: &mut Frame, area: Rect, state: &mut AppState, theme: &Theme) { + let mut lines: Vec = Vec::new(); + + for block in &state.blocks { + match block { + UiBlock::HumanMessage { text, timestamp } => { + let ts = timestamp.format("%H:%M"); + lines.push(Line::from(vec![ + Span::styled(format!("[{ts}] "), theme.timestamp), + Span::styled("You: ", theme.human_label), + Span::raw(text.clone()), + ])); + } + UiBlock::AssistantMessage { text, timestamp } => { + let ts = timestamp.format("%H:%M"); + lines.push(Line::from(vec![ + Span::styled(format!("[{ts}] "), theme.timestamp), + Span::styled("Assistant: ", theme.assistant_label), + Span::raw(text.clone()), + ])); + } + UiBlock::ToolExecution { + tool_name, + status, + timestamp, + .. + } => { + let ts = timestamp.format("%H:%M"); + let (status_str, status_style) = match status { + ToolStatus::Running => ("(Running...)", theme.tool_label), + ToolStatus::Success => ("[OK]", theme.tool_success), + ToolStatus::Error(_) => ("[ERR]", theme.tool_error), + }; + lines.push(Line::from(vec![ + Span::styled(format!("[{ts}] "), theme.timestamp), + Span::styled("Tool ", theme.tool_label), + Span::styled(format!("{tool_name} "), theme.tool_label), + Span::styled(status_str, status_style), + ])); + } + UiBlock::SystemAlert { text, timestamp } => { + let ts = timestamp.format("%H:%M"); + lines.push(Line::from(vec![ + Span::styled(format!("[{ts}] "), theme.timestamp), + Span::styled("System: ", theme.system_label), + Span::styled(text.clone(), theme.system_label), + ])); + } + } + } + + // Streaming text with cursor + if let Some(streaming) = &state.streaming_text { + lines.push(Line::from(vec![ + Span::styled("Assistant: ", theme.assistant_label), + Span::raw(streaming.clone()), + Span::styled(" █", theme.streaming_cursor), + ])); + } + + // Busy indicator when no streaming text yet + if state.is_busy && state.streaming_text.is_none() { + lines.push(Line::from(vec![Span::styled( + " Thinking...", + theme.spinner, + )])); + } + + // Update scroll dimensions (viewport = area minus 2 border rows) + let inner_height = area.height.saturating_sub(2) as usize; + let total_lines = lines.len(); + state.scroll.update_dimensions(total_lines, inner_height); + let scroll_pos = state.scroll.compute_scroll_position(); + + let border_style = if state.focus == FocusTarget::ChatLog { + theme.title + } else { + theme.border + }; + + let chat_block = Paragraph::new(lines) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(border_style) + .title(" Session Log "), + ) + .wrap(Wrap { trim: false }) + .scroll(scroll_pos); + + f.render_widget(chat_block, area); +} diff --git a/crates/arcan-tui/src/widgets/spinner.rs b/crates/arcan-tui/src/widgets/spinner.rs new file mode 100644 index 0000000..0766fe3 --- /dev/null +++ b/crates/arcan-tui/src/widgets/spinner.rs @@ -0,0 +1,47 @@ +const FRAMES: &[char] = &[ + '\u{280b}', '\u{2819}', '\u{2839}', '\u{2838}', '\u{283c}', '\u{2834}', '\u{2826}', '\u{2827}', + '\u{2807}', '\u{280f}', +]; // ⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ + +/// A simple Unicode spinner for indicating busy state. +#[derive(Debug, Default, Clone)] +pub struct Spinner { + frame: usize, +} + +impl Spinner { + pub fn new() -> Self { + Self::default() + } + + /// Advance to the next frame. + pub fn tick(&mut self) { + self.frame = (self.frame + 1) % FRAMES.len(); + } + + /// Return the current spinner character. + pub fn current(&self) -> char { + FRAMES[self.frame] + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn spinner_cycles_through_frames() { + let mut s = Spinner::new(); + let first = s.current(); + s.tick(); + let second = s.current(); + assert_ne!(first, second); + + // Cycle through all frames + for _ in 0..FRAMES.len() - 1 { + s.tick(); + } + // Back to the start + assert_eq!(s.current(), first); + } +} diff --git a/crates/arcan-tui/src/widgets/status_bar.rs b/crates/arcan-tui/src/widgets/status_bar.rs new file mode 100644 index 0000000..68c3852 --- /dev/null +++ b/crates/arcan-tui/src/widgets/status_bar.rs @@ -0,0 +1,49 @@ +use crate::models::state::{AppState, ConnectionStatus}; +use crate::theme::Theme; +use ratatui::{ + Frame, + layout::Rect, + text::{Line, Span}, + widgets::Paragraph, +}; + +/// Render the status bar showing session, branch, mode, and errors. +pub fn render(f: &mut Frame, area: Rect, state: &AppState, theme: &Theme) { + let session_str = state.session_id.as_deref().unwrap_or("no session"); + let branch_str = &state.current_branch; + + let (conn_dot, conn_style) = match state.connection_status { + ConnectionStatus::Connected => ("\u{25cf}", theme.status_connected), // ● + ConnectionStatus::Disconnected => ("\u{25cb}", theme.status_disconnected), // ○ + ConnectionStatus::Connecting => ("\u{25cc}", theme.status_connecting), // ◌ + }; + + let mode_str = if state.is_busy { + "busy" + } else if state.pending_approval.is_some() { + "approval" + } else { + "idle" + }; + + let mut spans = vec![ + Span::styled(format!(" {conn_dot} "), conn_style), + Span::styled(session_str.to_string(), theme.status_bar_bg), + Span::styled(" \u{2502} ", theme.status_bar_bg), // │ + Span::styled(format!("\u{2387} {branch_str}"), theme.status_bar_bg), // ⎇ + Span::styled(" \u{2502} ", theme.status_bar_bg), + Span::styled(mode_str, theme.status_bar_bg), + ]; + + // Error flash + if let Some(ref flash) = state.last_error { + spans.push(Span::styled(" \u{2502} ", theme.status_bar_bg)); + spans.push(Span::styled( + format!("\u{26a0} {}", flash.message), // ⚠ + theme.error_flash, + )); + } + + let status_line = Paragraph::new(Line::from(spans)).style(theme.status_bar_bg); + f.render_widget(status_line, area); +} From 8752e20853e1360263270496e3b970d9c0f212de Mon Sep 17 00:00:00 2001 From: Carlos Escobar Date: Sun, 1 Mar 2026 20:22:54 -0500 Subject: [PATCH 2/5] =?UTF-8?q?feat(tui):=20Phase=202=20=E2=80=94=20comman?= =?UTF-8?q?d=20parser,=20input=20history,=20cursor=20navigation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add unified command parser, ring-buffer input history with Up/Down navigation, and cursor-aware input bar with word movement. New modules: - command.rs: Unified parser for /clear, /help, /model, /approve + plain messages (14 tests covering all command variants and error cases) - history.rs: InputHistory ring buffer (capacity 100) with draft preservation and dedup (8 tests) - widgets/input_bar.rs: InputBarState with cursor tracking, Home/End, Ctrl+Left/Right word movement, Delete key support (5 tests) Modified: - app.rs: Uses command::parse() instead of inline parsing, delegates to InputBarState for all text input. Removed 6 old parse_model_command tests (now covered by command.rs tests with better coverage) - ui.rs: Accepts InputBarState, delegates rendering to input_bar widget - lib.rs/widgets.rs: Register new modules Tests: 47 (up from 26, +27 new, -6 migrated to command.rs) Co-Authored-By: Claude Opus 4.6 --- crates/arcan-tui/src/app.rs | 296 +++++++-------------- crates/arcan-tui/src/command.rs | 227 ++++++++++++++++ crates/arcan-tui/src/history.rs | 198 ++++++++++++++ crates/arcan-tui/src/lib.rs | 2 + crates/arcan-tui/src/ui.rs | 46 +--- crates/arcan-tui/src/widgets.rs | 1 + crates/arcan-tui/src/widgets/input_bar.rs | 305 ++++++++++++++++++++++ 7 files changed, 830 insertions(+), 245 deletions(-) create mode 100644 crates/arcan-tui/src/command.rs create mode 100644 crates/arcan-tui/src/history.rs create mode 100644 crates/arcan-tui/src/widgets/input_bar.rs diff --git a/crates/arcan-tui/src/app.rs b/crates/arcan-tui/src/app.rs index 63bf4e0..60a543a 100644 --- a/crates/arcan-tui/src/app.rs +++ b/crates/arcan-tui/src/app.rs @@ -1,57 +1,20 @@ +use crate::command::{self, Command, ModelSubcommand}; use crate::event::{TuiEvent, event_pump}; use crate::models::state::AppState; use crate::models::ui_block::UiBlock; use crate::network::{NetworkClient, NetworkConfig}; use crate::ui; +use crate::widgets::input_bar::InputBarState; use chrono::Utc; -use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers}; +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use ratatui::{Terminal, backend::Backend}; use std::sync::Arc; use std::time::Duration; use tokio::sync::mpsc; -#[derive(Debug, PartialEq, Eq)] -enum ModelCommand { - ShowCurrent, - Set { - provider: String, - model: Option, - }, -} - -fn parse_model_command(message: &str) -> Result, String> { - let trimmed = message.trim(); - if !trimmed.starts_with("/model") { - return Ok(None); - } - - let remainder = trimmed.trim_start_matches("/model").trim(); - if remainder.is_empty() { - return Ok(Some(ModelCommand::ShowCurrent)); - } - - if remainder.contains(char::is_whitespace) { - return Err("Usage: /model | /model | /model :".to_string()); - } - - if let Some((provider, model)) = remainder.split_once(':') { - if provider.is_empty() || model.is_empty() { - return Err("Usage: /model : (both values are required)".to_string()); - } - return Ok(Some(ModelCommand::Set { - provider: provider.to_string(), - model: Some(model.to_string()), - })); - } - - Ok(Some(ModelCommand::Set { - provider: remainder.to_string(), - model: None, - })) -} - pub struct App { pub state: AppState, + pub input_bar: InputBarState, pub should_quit: bool, pub client: Arc, events: mpsc::Receiver, @@ -75,6 +38,7 @@ impl App { Self { state: AppState::new(), + input_bar: InputBarState::new(), should_quit: false, client, events, @@ -88,54 +52,22 @@ impl App { }); } - async fn handle_model_command(&mut self, message: &str) -> bool { - let parsed = match parse_model_command(message) { - Ok(command) => command, - Err(usage) => { - self.push_system_alert(usage); - return true; - } - }; - - let Some(command) = parsed else { - return false; - }; - - match command { - ModelCommand::ShowCurrent => match self.client.get_model().await { - Ok(model) => self.push_system_alert(format!("Current model: {model}")), - Err(err) => self.push_system_alert(format!("Failed to fetch model: {err}")), - }, - ModelCommand::Set { provider, model } => { - match self.client.set_model(&provider, model.as_deref()).await { - Ok(active_model) => { - self.push_system_alert(format!("Switched model: {active_model}")) - } - Err(err) => self.push_system_alert(format!("Failed to switch model: {err}")), - } - } - } - - true - } - pub async fn run(&mut self, terminal: &mut Terminal) -> anyhow::Result<()> where B::Error: Send + Sync + 'static, { // Initial draw - terminal.draw(|f| ui::draw(f, &mut self.state))?; + terminal.draw(|f| ui::draw(f, &mut self.state, &self.input_bar))?; while let Some(event) = self.events.recv().await { match event { TuiEvent::Key(key) if key.kind == KeyEventKind::Press => { - self.handle_key(key.code, key.modifiers).await; + self.handle_key(key).await; } TuiEvent::Network(agent_event) => { self.state.apply_event(agent_event); } TuiEvent::Tick => { - // Clear expired error flashes (5 second TTL) self.state .clear_expired_errors(chrono::Duration::seconds(5)); } @@ -145,8 +77,7 @@ impl App { _ => {} } - // Redraw after every event - terminal.draw(|f| ui::draw(f, &mut self.state))?; + terminal.draw(|f| ui::draw(f, &mut self.state, &self.input_bar))?; if self.should_quit { break; @@ -156,14 +87,14 @@ impl App { Ok(()) } - async fn handle_key(&mut self, code: KeyCode, modifiers: KeyModifiers) { + async fn handle_key(&mut self, key: KeyEvent) { // Focus-independent keys - match code { + match key.code { KeyCode::Esc => { self.should_quit = true; return; } - KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => { + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { self.should_quit = true; return; } @@ -176,8 +107,8 @@ impl App { // Focus-dependent key handling match self.state.focus { - crate::focus::FocusTarget::ChatLog => self.handle_scroll_key(code), - crate::focus::FocusTarget::InputBar => self.handle_input_key(code).await, + crate::focus::FocusTarget::ChatLog => self.handle_scroll_key(key.code), + crate::focus::FocusTarget::InputBar => self.handle_input_key(key).await, } } @@ -196,147 +127,102 @@ impl App { } } - async fn handle_input_key(&mut self, code: KeyCode) { - match code { - KeyCode::Char(c) => { - self.state.input_buffer.push(c); - } - KeyCode::Backspace => { - self.state.input_buffer.pop(); - } + async fn handle_input_key(&mut self, key: KeyEvent) { + match key.code { KeyCode::Enter => { self.handle_submit().await; } - // Allow PageUp/PageDown even in input mode for convenience + KeyCode::Up => { + self.input_bar.history_up(); + } + KeyCode::Down => { + self.input_bar.history_down(); + } KeyCode::PageUp => self.state.scroll.page_up(), KeyCode::PageDown => self.state.scroll.scroll_to_bottom(), - _ => {} + _ => { + // Forward all other keys to tui-textarea + self.input_bar.input(key); + } } } async fn handle_submit(&mut self) { - let msg = self.state.input_buffer.trim().to_string(); - if msg.is_empty() { + let msg = self.input_bar.submit(); + let trimmed = msg.trim().to_string(); + if trimmed.is_empty() { return; } - if msg == "/clear" { - self.state.blocks.clear(); - self.state.streaming_text = None; - self.state.is_busy = false; - } else if msg == "/help" { - self.push_system_alert( - "Commands: /clear, /model [provider[:model]], /approve [reason], /help", - ); - } else if self.handle_model_command(&msg).await { - // handled - } else if msg.starts_with("/approve") { - self.handle_approve_command(&msg); - } else { - // Normal message — submit run - self.state.is_busy = true; - self.state.blocks.push(UiBlock::HumanMessage { - text: msg.clone(), - timestamp: Utc::now(), - }); - - // Auto-follow on new message - self.state.scroll.scroll_to_bottom(); + // Sync to input_buffer for backward compat + self.state.input_buffer.clear(); - let submit_client = self.client.clone(); - tokio::spawn(async move { - if let Err(e) = submit_client.submit_run(&msg, None).await { - tracing::error!("Submit error: {}", e); - } - }); + match command::parse(&trimmed) { + Ok(Command::Clear) => { + self.state.blocks.clear(); + self.state.streaming_text = None; + self.state.is_busy = false; + } + Ok(Command::Help) => { + self.push_system_alert( + "Commands: /clear, /model [provider[:model]], /approve [reason], /help", + ); + } + Ok(Command::Model(subcmd)) => { + self.execute_model_command(subcmd).await; + } + Ok(Command::Approve { + approval_id, + decision, + reason, + }) => { + let submit_client = self.client.clone(); + tokio::spawn(async move { + if let Err(e) = submit_client + .submit_approval(&approval_id, &decision, reason.as_deref()) + .await + { + tracing::error!("Submit approval error: {}", e); + } + }); + } + Ok(Command::SendMessage(text)) => { + self.state.is_busy = true; + self.state.blocks.push(UiBlock::HumanMessage { + text: text.clone(), + timestamp: Utc::now(), + }); + self.state.scroll.scroll_to_bottom(); + + let submit_client = self.client.clone(); + tokio::spawn(async move { + if let Err(e) = submit_client.submit_run(&text, None).await { + tracing::error!("Submit error: {}", e); + } + }); + } + Err(err) => { + self.push_system_alert(err); + } } - self.state.input_buffer.clear(); } - fn handle_approve_command(&mut self, msg: &str) { - let parts: Vec<&str> = msg.split_whitespace().collect(); - if parts.len() >= 3 { - let approval_id = parts[1].to_string(); - let decision = match parts[2].to_ascii_lowercase().as_str() { - "yes" | "y" | "approved" | "approve" => "approved".to_string(), - "no" | "n" | "denied" | "deny" => "denied".to_string(), - invalid => { - self.push_system_alert(format!( - "Invalid approval decision '{}'. Use yes/no.", - invalid - )); - return; - } - }; - let reason = if parts.len() > 3 { - Some(parts[3..].join(" ")) - } else { - None - }; - - let submit_client = self.client.clone(); - tokio::spawn(async move { - if let Err(e) = submit_client - .submit_approval(&approval_id, &decision, reason.as_deref()) - .await - { - tracing::error!("Submit approval error: {}", e); + async fn execute_model_command(&mut self, subcmd: ModelSubcommand) { + match subcmd { + ModelSubcommand::ShowCurrent => match self.client.get_model().await { + Ok(model) => self.push_system_alert(format!("Current model: {model}")), + Err(err) => self.push_system_alert(format!("Failed to fetch model: {err}")), + }, + ModelSubcommand::Set { provider, model } => { + match self.client.set_model(&provider, model.as_deref()).await { + Ok(active_model) => { + self.push_system_alert(format!("Switched model: {active_model}")) + } + Err(err) => self.push_system_alert(format!("Failed to switch model: {err}")), } - }); - } else { - self.push_system_alert("Usage: /approve [reason]"); + } } } } -#[cfg(test)] -mod tests { - use super::{ModelCommand, parse_model_command}; - - #[test] - fn parse_non_model_command() { - assert_eq!(parse_model_command("hello").unwrap(), None); - } - - #[test] - fn parse_show_current_model() { - assert_eq!( - parse_model_command("/model").unwrap(), - Some(ModelCommand::ShowCurrent) - ); - } - - #[test] - fn parse_set_provider_only() { - assert_eq!( - parse_model_command("/model mock").unwrap(), - Some(ModelCommand::Set { - provider: "mock".to_string(), - model: None - }) - ); - } - - #[test] - fn parse_set_provider_with_model() { - assert_eq!( - parse_model_command("/model ollama:qwen2.5").unwrap(), - Some(ModelCommand::Set { - provider: "ollama".to_string(), - model: Some("qwen2.5".to_string()) - }) - ); - } - - #[test] - fn parse_rejects_incomplete_provider_model() { - let err = parse_model_command("/model ollama:").unwrap_err(); - assert!(err.contains("required"), "got: {err}"); - } - - #[test] - fn parse_rejects_extra_spaces() { - let err = parse_model_command("/model ollama qwen2.5").unwrap_err(); - assert!(err.contains("Usage"), "got: {err}"); - } -} +// Command parsing tests are now in command.rs diff --git a/crates/arcan-tui/src/command.rs b/crates/arcan-tui/src/command.rs new file mode 100644 index 0000000..7a1a249 --- /dev/null +++ b/crates/arcan-tui/src/command.rs @@ -0,0 +1,227 @@ +/// Parsed TUI command from user input. +#[derive(Debug, PartialEq, Eq)] +pub enum Command { + /// Clear the conversation log. + Clear, + /// Show available commands. + Help, + /// Model inspection or switching. + Model(ModelSubcommand), + /// Submit an approval decision. + Approve { + approval_id: String, + decision: String, + reason: Option, + }, + /// Send a plain message to the agent. + SendMessage(String), +} + +/// Model-related subcommands. +#[derive(Debug, PartialEq, Eq)] +pub enum ModelSubcommand { + /// Show the current model. + ShowCurrent, + /// Set the provider (and optionally the model). + Set { + provider: String, + model: Option, + }, +} + +/// Parse a user input string into a `Command`. +/// +/// Slash-prefixed inputs are parsed as commands; everything else is a message. +pub fn parse(input: &str) -> Result { + let trimmed = input.trim(); + if trimmed.is_empty() { + return Err("Empty input".to_string()); + } + + if !trimmed.starts_with('/') { + return Ok(Command::SendMessage(trimmed.to_string())); + } + + let (cmd, args) = trimmed + .split_once(' ') + .map(|(c, a)| (c, a.trim())) + .unwrap_or((trimmed, "")); + + match cmd { + "/clear" => Ok(Command::Clear), + "/help" => Ok(Command::Help), + "/model" => parse_model(args), + "/approve" => parse_approve(args), + unknown => Err(format!( + "Unknown command: {unknown}. Type /help for available commands." + )), + } +} + +fn parse_model(args: &str) -> Result { + if args.is_empty() { + return Ok(Command::Model(ModelSubcommand::ShowCurrent)); + } + + if args.contains(char::is_whitespace) { + return Err("Usage: /model | /model | /model :".to_string()); + } + + if let Some((provider, model)) = args.split_once(':') { + if provider.is_empty() || model.is_empty() { + return Err("Usage: /model : (both values are required)".to_string()); + } + return Ok(Command::Model(ModelSubcommand::Set { + provider: provider.to_string(), + model: Some(model.to_string()), + })); + } + + Ok(Command::Model(ModelSubcommand::Set { + provider: args.to_string(), + model: None, + })) +} + +fn parse_approve(args: &str) -> Result { + let parts: Vec<&str> = args.split_whitespace().collect(); + if parts.len() < 2 { + return Err("Usage: /approve [reason]".to_string()); + } + + let approval_id = parts[0].to_string(); + let decision = match parts[1].to_ascii_lowercase().as_str() { + "yes" | "y" | "approved" | "approve" => "approved".to_string(), + "no" | "n" | "denied" | "deny" => "denied".to_string(), + invalid => { + return Err(format!( + "Invalid approval decision '{invalid}'. Use yes/no." + )); + } + }; + + let reason = if parts.len() > 2 { + Some(parts[2..].join(" ")) + } else { + None + }; + + Ok(Command::Approve { + approval_id, + decision, + reason, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn plain_message() { + assert_eq!( + parse("hello world").unwrap(), + Command::SendMessage("hello world".to_string()) + ); + } + + #[test] + fn clear_command() { + assert_eq!(parse("/clear").unwrap(), Command::Clear); + } + + #[test] + fn help_command() { + assert_eq!(parse("/help").unwrap(), Command::Help); + } + + #[test] + fn model_show() { + assert_eq!( + parse("/model").unwrap(), + Command::Model(ModelSubcommand::ShowCurrent) + ); + } + + #[test] + fn model_set_provider() { + assert_eq!( + parse("/model mock").unwrap(), + Command::Model(ModelSubcommand::Set { + provider: "mock".to_string(), + model: None, + }) + ); + } + + #[test] + fn model_set_provider_with_model() { + assert_eq!( + parse("/model ollama:qwen2.5").unwrap(), + Command::Model(ModelSubcommand::Set { + provider: "ollama".to_string(), + model: Some("qwen2.5".to_string()), + }) + ); + } + + #[test] + fn model_rejects_incomplete() { + let err = parse("/model ollama:").unwrap_err(); + assert!(err.contains("required"), "got: {err}"); + } + + #[test] + fn model_rejects_spaces() { + let err = parse("/model ollama qwen").unwrap_err(); + assert!(err.contains("Usage"), "got: {err}"); + } + + #[test] + fn approve_yes() { + assert_eq!( + parse("/approve ap-1 yes because").unwrap(), + Command::Approve { + approval_id: "ap-1".to_string(), + decision: "approved".to_string(), + reason: Some("because".to_string()), + } + ); + } + + #[test] + fn approve_no_reason() { + assert_eq!( + parse("/approve ap-2 no").unwrap(), + Command::Approve { + approval_id: "ap-2".to_string(), + decision: "denied".to_string(), + reason: None, + } + ); + } + + #[test] + fn approve_missing_args() { + let err = parse("/approve ap-1").unwrap_err(); + assert!(err.contains("Usage"), "got: {err}"); + } + + #[test] + fn approve_invalid_decision() { + let err = parse("/approve ap-1 maybe").unwrap_err(); + assert!(err.contains("Invalid"), "got: {err}"); + } + + #[test] + fn unknown_command() { + let err = parse("/foobar").unwrap_err(); + assert!(err.contains("Unknown"), "got: {err}"); + } + + #[test] + fn empty_input() { + assert!(parse("").is_err()); + assert!(parse(" ").is_err()); + } +} diff --git a/crates/arcan-tui/src/history.rs b/crates/arcan-tui/src/history.rs new file mode 100644 index 0000000..4165303 --- /dev/null +++ b/crates/arcan-tui/src/history.rs @@ -0,0 +1,198 @@ +use std::collections::VecDeque; + +/// Ring buffer storing command history with cursor-based navigation. +/// +/// - Pushing deduplicates consecutive identical entries. +/// - Up/Down navigation preserves the current draft (unsent text). +/// - Cursor is `None` when not navigating history. +pub struct InputHistory { + entries: VecDeque, + capacity: usize, + /// `None` = at the live draft; `Some(idx)` = viewing `entries[idx]` + cursor: Option, + /// The text the user was typing before they started navigating + draft: String, +} + +impl InputHistory { + pub fn new(capacity: usize) -> Self { + Self { + entries: VecDeque::with_capacity(capacity), + capacity, + cursor: None, + draft: String::new(), + } + } + + /// Record a submitted entry. Deduplicates consecutive identical entries. + pub fn push(&mut self, entry: String) { + if entry.is_empty() { + return; + } + // Dedup: skip if identical to the most recent entry + if self.entries.front().is_some_and(|last| last == &entry) { + self.reset_cursor(); + return; + } + if self.entries.len() >= self.capacity { + self.entries.pop_back(); + } + self.entries.push_front(entry); + self.reset_cursor(); + } + + /// Navigate up (older entries). Returns the entry to display, or `None` if + /// already at the oldest entry. + /// + /// On the first Up press, `current_input` is saved as the draft. + pub fn up(&mut self, current_input: &str) -> Option<&str> { + if self.entries.is_empty() { + return None; + } + match self.cursor { + None => { + // Entering history mode: save current input as draft + self.draft = current_input.to_string(); + self.cursor = Some(0); + Some(&self.entries[0]) + } + Some(idx) => { + let next = idx + 1; + if next < self.entries.len() { + self.cursor = Some(next); + Some(&self.entries[next]) + } else { + // Already at oldest — stay put + Some(&self.entries[idx]) + } + } + } + } + + /// Navigate down (newer entries). Returns the entry to display. + /// When reaching the bottom, restores the draft. + pub fn down(&mut self) -> Option<&str> { + match self.cursor { + None => None, // Not navigating + Some(0) => { + // Back to draft + self.cursor = None; + Some(&self.draft) + } + Some(idx) => { + self.cursor = Some(idx - 1); + Some(&self.entries[idx - 1]) + } + } + } + + /// Reset navigation state (e.g., after submitting). + pub fn reset_cursor(&mut self) { + self.cursor = None; + self.draft.clear(); + } + + /// Check if currently navigating history. + pub fn is_navigating(&self) -> bool { + self.cursor.is_some() + } + + /// Number of stored entries. + pub fn len(&self) -> usize { + self.entries.len() + } + + /// Whether the history is empty. + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_history_returns_none_on_up() { + let mut h = InputHistory::new(10); + assert!(h.up("draft").is_none()); + } + + #[test] + fn up_saves_draft_and_returns_last_entry() { + let mut h = InputHistory::new(10); + h.push("first".to_string()); + h.push("second".to_string()); + let entry = h.up("typing").unwrap(); + assert_eq!(entry, "second"); + } + + #[test] + fn down_restores_draft() { + let mut h = InputHistory::new(10); + h.push("cmd1".to_string()); + h.up("my draft"); + let restored = h.down().unwrap(); + assert_eq!(restored, "my draft"); + } + + #[test] + fn full_cycle_up_and_down() { + let mut h = InputHistory::new(10); + h.push("a".to_string()); + h.push("b".to_string()); + h.push("c".to_string()); + // Up: c -> b -> a + assert_eq!(h.up("draft").unwrap(), "c"); + assert_eq!(h.up("draft").unwrap(), "b"); + assert_eq!(h.up("draft").unwrap(), "a"); + // Can't go further up + assert_eq!(h.up("draft").unwrap(), "a"); + // Down: b -> c -> draft + assert_eq!(h.down().unwrap(), "b"); + assert_eq!(h.down().unwrap(), "c"); + assert_eq!(h.down().unwrap(), "draft"); + // Down again => None (not navigating) + assert!(h.down().is_none()); + } + + #[test] + fn deduplicates_consecutive() { + let mut h = InputHistory::new(10); + h.push("same".to_string()); + h.push("same".to_string()); + assert_eq!(h.len(), 1); + } + + #[test] + fn respects_capacity() { + let mut h = InputHistory::new(3); + h.push("a".to_string()); + h.push("b".to_string()); + h.push("c".to_string()); + h.push("d".to_string()); + assert_eq!(h.len(), 3); + // Oldest ("a") was evicted + assert_eq!(h.up("").unwrap(), "d"); + assert_eq!(h.up("").unwrap(), "c"); + assert_eq!(h.up("").unwrap(), "b"); + assert_eq!(h.up("").unwrap(), "b"); // can't go further + } + + #[test] + fn push_resets_cursor() { + let mut h = InputHistory::new(10); + h.push("a".to_string()); + h.up("draft"); + assert!(h.is_navigating()); + h.push("b".to_string()); + assert!(!h.is_navigating()); + } + + #[test] + fn empty_string_not_pushed() { + let mut h = InputHistory::new(10); + h.push(String::new()); + assert!(h.is_empty()); + } +} diff --git a/crates/arcan-tui/src/lib.rs b/crates/arcan-tui/src/lib.rs index 366b1ee..4a051c1 100644 --- a/crates/arcan-tui/src/lib.rs +++ b/crates/arcan-tui/src/lib.rs @@ -1,6 +1,8 @@ pub mod app; +pub mod command; pub mod event; pub mod focus; +pub mod history; pub mod models; #[cfg(test)] mod models_test; diff --git a/crates/arcan-tui/src/ui.rs b/crates/arcan-tui/src/ui.rs index 9bd6a3a..bca8897 100644 --- a/crates/arcan-tui/src/ui.rs +++ b/crates/arcan-tui/src/ui.rs @@ -1,17 +1,15 @@ -use crate::focus::FocusTarget; use crate::models::state::AppState; use crate::theme::Theme; use crate::widgets; +use crate::widgets::input_bar::InputBarState; use ratatui::{ Frame, - layout::{Constraint, Direction, Layout, Rect}, - text::Line, - widgets::{Block, Borders, Paragraph}, + layout::{Constraint, Direction, Layout}, }; /// Top-level draw function. Orchestrates the three-chunk layout: /// chat log, status bar, and input box. -pub fn draw(f: &mut Frame, state: &mut AppState) { +pub fn draw(f: &mut Frame, state: &mut AppState, input_bar: &InputBarState) { let theme = Theme::new(); let chunks = Layout::default() @@ -30,39 +28,7 @@ pub fn draw(f: &mut Frame, state: &mut AppState) { // Status bar widgets::status_bar::render(f, chunks[1], state, &theme); - // Input area - render_input(f, chunks[2], state, &theme); -} - -fn render_input(f: &mut Frame, area: Rect, state: &AppState, theme: &Theme) { - let prompt = if let Some(approval) = &state.pending_approval { - format!( - "Approval Req for {}: (yes/no) > {}", - approval.tool_name, state.input_buffer - ) - } else { - format!("\u{276f} {}", state.input_buffer) // ❯ - }; - - let style = if state.pending_approval.is_some() { - theme.input_approval - } else { - theme.input_normal - }; - - let border_style = if state.focus == FocusTarget::InputBar { - theme.title - } else { - theme.border - }; - - let input_block = Paragraph::new(Line::from(prompt)) - .block( - Block::default() - .borders(Borders::ALL) - .border_style(border_style) - .title(" Input "), - ) - .style(style); - f.render_widget(input_block, area); + // Input area (tui-textarea) + let has_approval = state.pending_approval.is_some(); + widgets::input_bar::render(f, chunks[2], input_bar, state.focus, has_approval, &theme); } diff --git a/crates/arcan-tui/src/widgets.rs b/crates/arcan-tui/src/widgets.rs index d3a963f..013b634 100644 --- a/crates/arcan-tui/src/widgets.rs +++ b/crates/arcan-tui/src/widgets.rs @@ -1,3 +1,4 @@ pub mod chat_log; +pub mod input_bar; pub mod spinner; pub mod status_bar; diff --git a/crates/arcan-tui/src/widgets/input_bar.rs b/crates/arcan-tui/src/widgets/input_bar.rs new file mode 100644 index 0000000..81cc877 --- /dev/null +++ b/crates/arcan-tui/src/widgets/input_bar.rs @@ -0,0 +1,305 @@ +use crate::focus::FocusTarget; +use crate::history::InputHistory; +use crate::theme::Theme; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use ratatui::{ + Frame, + layout::Rect, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph}, +}; + +/// Wraps a text buffer with cursor, command history, and mode tracking. +pub struct InputBarState { + /// Current text content + buffer: String, + /// Cursor position (byte offset in buffer) + cursor: usize, + /// Command history + pub history: InputHistory, +} + +impl InputBarState { + pub fn new() -> Self { + Self { + buffer: String::new(), + cursor: 0, + history: InputHistory::new(100), + } + } + + /// Get the current text content. + pub fn text(&self) -> &str { + &self.buffer + } + + /// Clear the input buffer and reset cursor. + pub fn clear(&mut self) { + self.buffer.clear(); + self.cursor = 0; + } + + /// Submit the current input: records to history and clears the buffer. + /// Returns the submitted text. + pub fn submit(&mut self) -> String { + let text = self.buffer.clone(); + if !text.trim().is_empty() { + self.history.push(text.clone()); + } + self.clear(); + text + } + + /// Navigate history upward (older entries). + pub fn history_up(&mut self) { + let current = self.buffer.clone(); + if let Some(entry) = self.history.up(¤t) { + let entry = entry.to_string(); + self.buffer = entry; + self.cursor = self.buffer.len(); + } + } + + /// Navigate history downward (newer entries / draft). + pub fn history_down(&mut self) { + if let Some(entry) = self.history.down() { + let entry = entry.to_string(); + self.buffer = entry; + self.cursor = self.buffer.len(); + } + } + + /// Handle a key event for text editing. + pub fn input(&mut self, key: KeyEvent) { + match key.code { + KeyCode::Char(c) => { + self.buffer.insert(self.cursor, c); + self.cursor += c.len_utf8(); + } + KeyCode::Backspace => { + if self.cursor > 0 { + // Find the previous character boundary + let prev = self.buffer[..self.cursor] + .char_indices() + .next_back() + .map(|(i, _)| i) + .unwrap_or(0); + self.buffer.drain(prev..self.cursor); + self.cursor = prev; + } + } + KeyCode::Delete => { + if self.cursor < self.buffer.len() { + let next = self.buffer[self.cursor..] + .char_indices() + .nth(1) + .map(|(i, _)| self.cursor + i) + .unwrap_or(self.buffer.len()); + self.buffer.drain(self.cursor..next); + } + } + KeyCode::Left => { + if key.modifiers.contains(KeyModifiers::CONTROL) { + // Word-left: skip to previous word boundary + self.cursor = self.prev_word_boundary(); + } else if self.cursor > 0 { + self.cursor = self.buffer[..self.cursor] + .char_indices() + .next_back() + .map(|(i, _)| i) + .unwrap_or(0); + } + } + KeyCode::Right => { + if key.modifiers.contains(KeyModifiers::CONTROL) { + // Word-right: skip to next word boundary + self.cursor = self.next_word_boundary(); + } else if self.cursor < self.buffer.len() { + self.cursor = self.buffer[self.cursor..] + .char_indices() + .nth(1) + .map(|(i, _)| self.cursor + i) + .unwrap_or(self.buffer.len()); + } + } + KeyCode::Home => { + self.cursor = 0; + } + KeyCode::End => { + self.cursor = self.buffer.len(); + } + _ => {} + } + } + + fn prev_word_boundary(&self) -> usize { + let before = &self.buffer[..self.cursor]; + // Skip trailing whitespace, then skip word chars + let trimmed = before.trim_end(); + if trimmed.is_empty() { + return 0; + } + trimmed + .rfind(|c: char| c.is_whitespace()) + .map(|i| i + 1) + .unwrap_or(0) + } + + fn next_word_boundary(&self) -> usize { + let after = &self.buffer[self.cursor..]; + // Skip current word chars, then skip whitespace + let skip_word = after + .find(|c: char| c.is_whitespace()) + .unwrap_or(after.len()); + let remaining = &after[skip_word..]; + let skip_space = remaining + .find(|c: char| !c.is_whitespace()) + .unwrap_or(remaining.len()); + self.cursor + skip_word + skip_space + } + + /// Get cursor column position (character count, not byte offset). + fn cursor_col(&self) -> usize { + self.buffer[..self.cursor].chars().count() + } +} + +impl Default for InputBarState { + fn default() -> Self { + Self::new() + } +} + +/// Render the input bar widget. +pub fn render( + f: &mut Frame, + area: Rect, + input_bar: &InputBarState, + focus: FocusTarget, + has_approval: bool, + theme: &Theme, +) { + let style = if has_approval { + theme.input_approval + } else { + theme.input_normal + }; + + let border_style = if focus == FocusTarget::InputBar { + theme.title + } else { + theme.border + }; + + let title = if has_approval { + " Approval (yes/no) " + } else { + " Input " + }; + + let prompt = "\u{276f} "; // ❯ + let display_text = format!("{prompt}{}", input_bar.text()); + + let input_widget = Paragraph::new(Line::from(vec![Span::raw(display_text)])) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(border_style) + .title(title), + ) + .style(style); + f.render_widget(input_widget, area); + + // Position cursor within the input box (account for border + prompt) + if focus == FocusTarget::InputBar { + let cursor_x = area.x + 1 + prompt.chars().count() as u16 + input_bar.cursor_col() as u16; + let cursor_y = area.y + 1; + if cursor_x < area.x + area.width - 1 { + f.set_cursor_position((cursor_x, cursor_y)); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_input_bar_is_empty() { + let bar = InputBarState::new(); + assert_eq!(bar.text(), ""); + } + + #[test] + fn submit_returns_text_and_clears() { + let mut bar = InputBarState::new(); + bar.input(key('h')); + bar.input(key('i')); + let submitted = bar.submit(); + assert_eq!(submitted, "hi"); + assert_eq!(bar.text(), ""); + } + + #[test] + fn history_navigation() { + let mut bar = InputBarState::new(); + bar.buffer = "first".to_string(); + bar.cursor = 5; + bar.submit(); + bar.buffer = "second".to_string(); + bar.cursor = 6; + bar.submit(); + + // Type something new, then navigate up + bar.buffer = "draft".to_string(); + bar.cursor = 5; + bar.history_up(); + assert_eq!(bar.text(), "second"); + + bar.history_up(); + assert_eq!(bar.text(), "first"); + + bar.history_down(); + assert_eq!(bar.text(), "second"); + + bar.history_down(); + assert_eq!(bar.text(), "draft"); + } + + #[test] + fn cursor_movement() { + let mut bar = InputBarState::new(); + for c in "hello".chars() { + bar.input(key(c)); + } + assert_eq!(bar.cursor, 5); + + bar.input(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)); + assert_eq!(bar.cursor, 4); + + bar.input(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE)); + assert_eq!(bar.cursor, 0); + + bar.input(KeyEvent::new(KeyCode::End, KeyModifiers::NONE)); + assert_eq!(bar.cursor, 5); + } + + #[test] + fn backspace_at_cursor() { + let mut bar = InputBarState::new(); + for c in "abc".chars() { + bar.input(key(c)); + } + // Cursor at end: "abc|" + bar.input(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)); + // Cursor: "ab|c" + bar.input(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + // Should delete 'b': "a|c" + assert_eq!(bar.text(), "ac"); + assert_eq!(bar.cursor, 1); + } + + fn key(c: char) -> KeyEvent { + KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE) + } +} From 9b2ce417040b76a6728e95381e62baf01ded7bca Mon Sep 17 00:00:00 2001 From: Carlos Escobar Date: Sun, 1 Mar 2026 20:27:35 -0500 Subject: [PATCH 3/5] =?UTF-8?q?feat(tui):=20Phase=204=20=E2=80=94=20approv?= =?UTF-8?q?al=20banner,=20connection=20status,=20error=20surfacing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add risk-colored approval banner widget with dynamic layout (conditionally shown between chat log and status bar). Extract submit_message and submit_approval helpers. Update connection status on network events. 3-tier error surfacing: status bar flash, inline SystemAlert, approval banner. 50 tests passing, clippy clean. Co-Authored-By: Claude Opus 4.6 --- crates/arcan-tui/src/app.rs | 55 ++++++----- crates/arcan-tui/src/ui.rs | 41 +++++--- crates/arcan-tui/src/widgets.rs | 1 + .../arcan-tui/src/widgets/approval_banner.rs | 99 +++++++++++++++++++ 4 files changed, 161 insertions(+), 35 deletions(-) create mode 100644 crates/arcan-tui/src/widgets/approval_banner.rs diff --git a/crates/arcan-tui/src/app.rs b/crates/arcan-tui/src/app.rs index 60a543a..3d74c73 100644 --- a/crates/arcan-tui/src/app.rs +++ b/crates/arcan-tui/src/app.rs @@ -1,6 +1,6 @@ use crate::command::{self, Command, ModelSubcommand}; use crate::event::{TuiEvent, event_pump}; -use crate::models::state::AppState; +use crate::models::state::{AppState, ConnectionStatus}; use crate::models::ui_block::UiBlock; use crate::network::{NetworkClient, NetworkConfig}; use crate::ui; @@ -65,6 +65,7 @@ impl App { self.handle_key(key).await; } TuiEvent::Network(agent_event) => { + self.state.connection_status = ConnectionStatus::Connected; self.state.apply_event(agent_event); } TuiEvent::Tick => { @@ -176,30 +177,10 @@ impl App { decision, reason, }) => { - let submit_client = self.client.clone(); - tokio::spawn(async move { - if let Err(e) = submit_client - .submit_approval(&approval_id, &decision, reason.as_deref()) - .await - { - tracing::error!("Submit approval error: {}", e); - } - }); + self.submit_approval(approval_id, decision, reason); } Ok(Command::SendMessage(text)) => { - self.state.is_busy = true; - self.state.blocks.push(UiBlock::HumanMessage { - text: text.clone(), - timestamp: Utc::now(), - }); - self.state.scroll.scroll_to_bottom(); - - let submit_client = self.client.clone(); - tokio::spawn(async move { - if let Err(e) = submit_client.submit_run(&text, None).await { - tracing::error!("Submit error: {}", e); - } - }); + self.submit_message(text); } Err(err) => { self.push_system_alert(err); @@ -223,6 +204,34 @@ impl App { } } } + + fn submit_message(&mut self, text: String) { + self.state.is_busy = true; + self.state.blocks.push(UiBlock::HumanMessage { + text: text.clone(), + timestamp: Utc::now(), + }); + self.state.scroll.scroll_to_bottom(); + + let submit_client = self.client.clone(); + tokio::spawn(async move { + if let Err(e) = submit_client.submit_run(&text, None).await { + tracing::error!("Submit error: {}", e); + } + }); + } + + fn submit_approval(&mut self, approval_id: String, decision: String, reason: Option) { + let submit_client = self.client.clone(); + tokio::spawn(async move { + if let Err(e) = submit_client + .submit_approval(&approval_id, &decision, reason.as_deref()) + .await + { + tracing::error!("Submit approval error: {}", e); + } + }); + } } // Command parsing tests are now in command.rs diff --git a/crates/arcan-tui/src/ui.rs b/crates/arcan-tui/src/ui.rs index bca8897..a55d503 100644 --- a/crates/arcan-tui/src/ui.rs +++ b/crates/arcan-tui/src/ui.rs @@ -7,28 +7,45 @@ use ratatui::{ layout::{Constraint, Direction, Layout}, }; -/// Top-level draw function. Orchestrates the three-chunk layout: -/// chat log, status bar, and input box. +/// Top-level draw function. Orchestrates the layout: +/// - Chat log (scrollable, fills remaining space) +/// - Approval banner (shown only when pending, 6 lines) +/// - Status bar (1 line) +/// - Input box (3 lines) pub fn draw(f: &mut Frame, state: &mut AppState, input_bar: &InputBarState) { let theme = Theme::new(); + let has_approval = state.pending_approval.is_some(); + + let mut constraints = vec![Constraint::Min(3)]; // Chat log + if has_approval { + constraints.push(Constraint::Length(6)); // Approval banner + } + constraints.push(Constraint::Length(1)); // Status bar + constraints.push(Constraint::Length(3)); // Input box + let chunks = Layout::default() .direction(Direction::Vertical) .margin(1) - .constraints([ - Constraint::Min(3), // Chat log - Constraint::Length(1), // Status bar - Constraint::Length(3), // Input box - ]) + .constraints(constraints) .split(f.area()); + let mut idx = 0; + // Chat log (scrollable) - widgets::chat_log::render(f, chunks[0], state, &theme); + widgets::chat_log::render(f, chunks[idx], state, &theme); + idx += 1; + + // Approval banner (conditional) + if let Some(ref approval) = state.pending_approval { + widgets::approval_banner::render(f, chunks[idx], approval, &theme); + idx += 1; + } // Status bar - widgets::status_bar::render(f, chunks[1], state, &theme); + widgets::status_bar::render(f, chunks[idx], state, &theme); + idx += 1; - // Input area (tui-textarea) - let has_approval = state.pending_approval.is_some(); - widgets::input_bar::render(f, chunks[2], input_bar, state.focus, has_approval, &theme); + // Input area + widgets::input_bar::render(f, chunks[idx], input_bar, state.focus, has_approval, &theme); } diff --git a/crates/arcan-tui/src/widgets.rs b/crates/arcan-tui/src/widgets.rs index 013b634..86691be 100644 --- a/crates/arcan-tui/src/widgets.rs +++ b/crates/arcan-tui/src/widgets.rs @@ -1,3 +1,4 @@ +pub mod approval_banner; pub mod chat_log; pub mod input_bar; pub mod spinner; diff --git a/crates/arcan-tui/src/widgets/approval_banner.rs b/crates/arcan-tui/src/widgets/approval_banner.rs new file mode 100644 index 0000000..9fc78de --- /dev/null +++ b/crates/arcan-tui/src/widgets/approval_banner.rs @@ -0,0 +1,99 @@ +use crate::models::ui_block::ApprovalRequest; +use crate::theme::Theme; +use ratatui::{ + Frame, + layout::Rect, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph, Wrap}, +}; + +/// Render an inline approval banner when a tool needs user authorization. +pub fn render(f: &mut Frame, area: Rect, approval: &ApprovalRequest, _theme: &Theme) { + let risk_style = risk_color(&approval.risk_level); + + let args_preview = + serde_json::to_string(&approval.arguments).unwrap_or_else(|_| "{}".to_string()); + let args_short = if args_preview.len() > 60 { + format!("{}...", &args_preview[..57]) + } else { + args_preview + }; + + let lines = vec![ + Line::from(vec![ + Span::styled( + " \u{26a0} Approval Required ".to_string(), // ⚠ + Style::default() + .fg(Color::White) + .bg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled(format!("Risk: {}", approval.risk_level), risk_style), + ]), + Line::from(vec![ + Span::styled("Tool: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(&approval.tool_name), + Span::raw(" "), + Span::styled("ID: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(&approval.approval_id), + ]), + Line::from(vec![ + Span::styled("Args: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(args_short), + ]), + Line::from(vec![Span::styled( + " /approve yes | /approve no ", + Style::default().fg(Color::DarkGray), + )]), + ]; + + let banner = Paragraph::new(lines) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(risk_style) + .title(" Pending Approval "), + ) + .wrap(Wrap { trim: false }); + + f.render_widget(banner, area); +} + +fn risk_color(level: &str) -> Style { + match level.to_ascii_lowercase().as_str() { + "critical" => Style::default() + .fg(Color::White) + .bg(Color::Red) + .add_modifier(Modifier::BOLD), + "high" => Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + "medium" => Style::default().fg(Color::Yellow), + "low" => Style::default().fg(Color::Green), + _ => Style::default().fg(Color::Gray), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn risk_color_critical_has_red_bg() { + let style = risk_color("critical"); + assert_eq!(style.bg, Some(Color::Red)); + } + + #[test] + fn risk_color_is_case_insensitive() { + let high = risk_color("High"); + let high_lower = risk_color("high"); + assert_eq!(high.fg, high_lower.fg); + } + + #[test] + fn risk_color_unknown_is_gray() { + let style = risk_color("unknown-level"); + assert_eq!(style.fg, Some(Color::Gray)); + } +} From 87f27e2f6067ff94385d2085d0ea6a43d3422ae6 Mon Sep 17 00:00:00 2001 From: Carlos Escobar Date: Sun, 1 Mar 2026 20:58:01 -0500 Subject: [PATCH 4/5] =?UTF-8?q?feat(tui):=20Phase=205=20=E2=80=94=20sessio?= =?UTF-8?q?n=20browser,=20state=20inspector,=20panel=20layout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add session browser widget (list sessions from daemon, keyboard navigation, selection) and state inspector widget (agent state vector, budget, mode, progress bars). New /sessions and /state commands toggle side panels. Extend FocusTarget with SessionBrowser and StateInspector variants. Add network client methods for list_sessions and get_session_state. Dynamic horizontal split layout: 70% main chat, 30% panels. 64 tests passing (up from 50), clippy clean, full workspace builds. Co-Authored-By: Claude Opus 4.6 --- crates/arcan-tui/src/app.rs | 181 +++++++++++++- crates/arcan-tui/src/command.rs | 16 ++ crates/arcan-tui/src/focus.rs | 34 +++ crates/arcan-tui/src/network.rs | 31 +++ crates/arcan-tui/src/ui.rs | 76 +++++- crates/arcan-tui/src/widgets.rs | 2 + .../arcan-tui/src/widgets/session_browser.rs | 210 ++++++++++++++++ .../arcan-tui/src/widgets/state_inspector.rs | 230 ++++++++++++++++++ 8 files changed, 763 insertions(+), 17 deletions(-) create mode 100644 crates/arcan-tui/src/widgets/session_browser.rs create mode 100644 crates/arcan-tui/src/widgets/state_inspector.rs diff --git a/crates/arcan-tui/src/app.rs b/crates/arcan-tui/src/app.rs index 3d74c73..44fe73d 100644 --- a/crates/arcan-tui/src/app.rs +++ b/crates/arcan-tui/src/app.rs @@ -1,10 +1,13 @@ use crate::command::{self, Command, ModelSubcommand}; use crate::event::{TuiEvent, event_pump}; +use crate::focus::FocusTarget; use crate::models::state::{AppState, ConnectionStatus}; use crate::models::ui_block::UiBlock; use crate::network::{NetworkClient, NetworkConfig}; use crate::ui; use crate::widgets::input_bar::InputBarState; +use crate::widgets::session_browser::{SessionBrowserState, SessionEntry}; +use crate::widgets::state_inspector::{AgentStateSnapshot, StateInspectorState}; use chrono::Utc; use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use ratatui::{Terminal, backend::Backend}; @@ -17,6 +20,10 @@ pub struct App { pub input_bar: InputBarState, pub should_quit: bool, pub client: Arc, + pub session_browser: SessionBrowserState, + pub state_inspector: StateInspectorState, + /// Whether the side panels (session browser + state inspector) are visible. + pub show_panels: bool, events: mpsc::Receiver, } @@ -41,6 +48,9 @@ impl App { input_bar: InputBarState::new(), should_quit: false, client, + session_browser: SessionBrowserState::new(), + state_inspector: StateInspectorState::new(), + show_panels: false, events, } } @@ -57,7 +67,7 @@ impl App { B::Error: Send + Sync + 'static, { // Initial draw - terminal.draw(|f| ui::draw(f, &mut self.state, &self.input_bar))?; + terminal.draw(|f| ui::draw(f, self))?; while let Some(event) = self.events.recv().await { match event { @@ -78,7 +88,7 @@ impl App { _ => {} } - terminal.draw(|f| ui::draw(f, &mut self.state, &self.input_bar))?; + terminal.draw(|f| ui::draw(f, self))?; if self.should_quit { break; @@ -92,6 +102,12 @@ impl App { // Focus-independent keys match key.code { KeyCode::Esc => { + // If panels are shown, close them first; otherwise quit + if self.show_panels { + self.show_panels = false; + self.state.focus = FocusTarget::InputBar; + return; + } self.should_quit = true; return; } @@ -100,7 +116,11 @@ impl App { return; } KeyCode::Tab => { - self.state.focus = self.state.focus.next(); + if self.show_panels && key.modifiers.contains(KeyModifiers::SHIFT) { + self.state.focus = self.state.focus.next_all(); + } else { + self.state.focus = self.state.focus.next(); + } return; } _ => {} @@ -108,8 +128,12 @@ impl App { // Focus-dependent key handling match self.state.focus { - crate::focus::FocusTarget::ChatLog => self.handle_scroll_key(key.code), - crate::focus::FocusTarget::InputBar => self.handle_input_key(key).await, + FocusTarget::ChatLog => self.handle_scroll_key(key.code), + FocusTarget::InputBar => self.handle_input_key(key).await, + FocusTarget::SessionBrowser => self.handle_session_browser_key(key.code).await, + FocusTarget::StateInspector => { + // State inspector is read-only; scroll keys could be added later + } } } @@ -142,12 +166,31 @@ impl App { KeyCode::PageUp => self.state.scroll.page_up(), KeyCode::PageDown => self.state.scroll.scroll_to_bottom(), _ => { - // Forward all other keys to tui-textarea self.input_bar.input(key); } } } + async fn handle_session_browser_key(&mut self, code: KeyCode) { + match code { + KeyCode::Up | KeyCode::Char('k') => self.session_browser.previous(), + KeyCode::Down | KeyCode::Char('j') => self.session_browser.next(), + KeyCode::Enter => { + // Switch to selected session + if let Some(id) = self.session_browser.selected_session_id() { + let id = id.to_string(); + self.push_system_alert(format!("Selected session: {id}")); + // Could wire up session switching here in the future + } + } + KeyCode::Char('r') => { + // Refresh session list + self.fetch_sessions().await; + } + _ => {} + } + } + async fn handle_submit(&mut self) { let msg = self.input_bar.submit(); let trimmed = msg.trim().to_string(); @@ -166,7 +209,7 @@ impl App { } Ok(Command::Help) => { self.push_system_alert( - "Commands: /clear, /model [provider[:model]], /approve [reason], /help", + "Commands: /clear, /model, /approve , /sessions, /state, /help", ); } Ok(Command::Model(subcmd)) => { @@ -179,6 +222,16 @@ impl App { }) => { self.submit_approval(approval_id, decision, reason); } + Ok(Command::Sessions) => { + self.show_panels = true; + self.state.focus = FocusTarget::SessionBrowser; + self.fetch_sessions().await; + } + Ok(Command::State) => { + self.show_panels = true; + self.state.focus = FocusTarget::StateInspector; + self.fetch_state().await; + } Ok(Command::SendMessage(text)) => { self.submit_message(text); } @@ -232,6 +285,120 @@ impl App { } }); } + + async fn fetch_sessions(&mut self) { + self.session_browser.set_loading(); + let client = self.client.clone(); + match client.list_sessions().await { + Ok(sessions) => { + let entries: Vec = sessions + .into_iter() + .filter_map(|v| { + Some(SessionEntry { + session_id: v.get("session_id")?.as_str()?.to_string(), + owner: v + .get("owner") + .and_then(|o| o.as_str()) + .unwrap_or("unknown") + .to_string(), + created_at: v + .get("created_at") + .and_then(|t| t.as_str()) + .and_then(|s| s.parse().ok()) + .unwrap_or_else(Utc::now), + }) + }) + .collect(); + self.session_browser.set_sessions(entries); + } + Err(e) => { + self.session_browser.set_error(e.to_string()); + } + } + } + + async fn fetch_state(&mut self) { + self.state_inspector.set_loading(); + let client = self.client.clone(); + match client.get_session_state(None).await { + Ok(v) => { + let state_obj = v.get("state").cloned().unwrap_or_default(); + let budget = state_obj.get("budget").cloned().unwrap_or_default(); + + let snapshot = AgentStateSnapshot { + session_id: v + .get("session_id") + .and_then(|s| s.as_str()) + .unwrap_or("") + .to_string(), + branch: v + .get("branch") + .and_then(|s| s.as_str()) + .unwrap_or("main") + .to_string(), + mode: v + .get("mode") + .and_then(|s| s.as_str()) + .unwrap_or("Unknown") + .to_string(), + progress: state_obj + .get("progress") + .and_then(|n| n.as_f64()) + .unwrap_or(0.0) as f32, + uncertainty: state_obj + .get("uncertainty") + .and_then(|n| n.as_f64()) + .unwrap_or(0.0) as f32, + risk_level: state_obj + .get("risk_level") + .and_then(|s| s.as_str()) + .unwrap_or("Low") + .to_string(), + error_streak: state_obj + .get("error_streak") + .and_then(|n| n.as_u64()) + .unwrap_or(0) as u32, + context_pressure: state_obj + .get("context_pressure") + .and_then(|n| n.as_f64()) + .unwrap_or(0.0) as f32, + side_effect_pressure: state_obj + .get("side_effect_pressure") + .and_then(|n| n.as_f64()) + .unwrap_or(0.0) as f32, + human_dependency: state_obj + .get("human_dependency") + .and_then(|n| n.as_f64()) + .unwrap_or(0.0) as f32, + tokens_remaining: budget + .get("tokens_remaining") + .and_then(|n| n.as_u64()) + .unwrap_or(0), + time_remaining_ms: budget + .get("time_remaining_ms") + .and_then(|n| n.as_u64()) + .unwrap_or(0), + cost_remaining_usd: budget + .get("cost_remaining_usd") + .and_then(|n| n.as_f64()) + .unwrap_or(0.0), + tool_calls_remaining: budget + .get("tool_calls_remaining") + .and_then(|n| n.as_u64()) + .unwrap_or(0) as u32, + error_budget_remaining: budget + .get("error_budget_remaining") + .and_then(|n| n.as_u64()) + .unwrap_or(0) as u32, + version: v.get("version").and_then(|n| n.as_u64()).unwrap_or(0), + }; + self.state_inspector.set_snapshot(snapshot); + } + Err(e) => { + self.state_inspector.set_error(e.to_string()); + } + } + } } // Command parsing tests are now in command.rs diff --git a/crates/arcan-tui/src/command.rs b/crates/arcan-tui/src/command.rs index 7a1a249..9179550 100644 --- a/crates/arcan-tui/src/command.rs +++ b/crates/arcan-tui/src/command.rs @@ -13,6 +13,10 @@ pub enum Command { decision: String, reason: Option, }, + /// Toggle or browse sessions. + Sessions, + /// Fetch and display agent state. + State, /// Send a plain message to the agent. SendMessage(String), } @@ -52,6 +56,8 @@ pub fn parse(input: &str) -> Result { "/help" => Ok(Command::Help), "/model" => parse_model(args), "/approve" => parse_approve(args), + "/sessions" => Ok(Command::Sessions), + "/state" => Ok(Command::State), unknown => Err(format!( "Unknown command: {unknown}. Type /help for available commands." )), @@ -224,4 +230,14 @@ mod tests { assert!(parse("").is_err()); assert!(parse(" ").is_err()); } + + #[test] + fn sessions_command() { + assert_eq!(parse("/sessions").unwrap(), Command::Sessions); + } + + #[test] + fn state_command() { + assert_eq!(parse("/state").unwrap(), Command::State); + } } diff --git a/crates/arcan-tui/src/focus.rs b/crates/arcan-tui/src/focus.rs index 70892da..8d449ae 100644 --- a/crates/arcan-tui/src/focus.rs +++ b/crates/arcan-tui/src/focus.rs @@ -4,14 +4,29 @@ pub enum FocusTarget { ChatLog, #[default] InputBar, + SessionBrowser, + StateInspector, } impl FocusTarget { /// Cycle to the next focus target (Tab key behavior). + /// Only cycles through targets that are currently visible. pub fn next(self) -> Self { match self { Self::ChatLog => Self::InputBar, Self::InputBar => Self::ChatLog, + Self::SessionBrowser => Self::InputBar, + Self::StateInspector => Self::InputBar, + } + } + + /// Cycle through all panels including side panels (Shift+Tab or explicit). + pub fn next_all(self) -> Self { + match self { + Self::ChatLog => Self::InputBar, + Self::InputBar => Self::SessionBrowser, + Self::SessionBrowser => Self::StateInspector, + Self::StateInspector => Self::ChatLog, } } } @@ -31,4 +46,23 @@ mod tests { fn default_focus_is_input_bar() { assert_eq!(FocusTarget::default(), FocusTarget::InputBar); } + + #[test] + fn next_all_cycles_through_all_panels() { + let mut focus = FocusTarget::ChatLog; + focus = focus.next_all(); // InputBar + assert_eq!(focus, FocusTarget::InputBar); + focus = focus.next_all(); // SessionBrowser + assert_eq!(focus, FocusTarget::SessionBrowser); + focus = focus.next_all(); // StateInspector + assert_eq!(focus, FocusTarget::StateInspector); + focus = focus.next_all(); // ChatLog (wrap) + assert_eq!(focus, FocusTarget::ChatLog); + } + + #[test] + fn side_panels_tab_back_to_input() { + assert_eq!(FocusTarget::SessionBrowser.next(), FocusTarget::InputBar); + assert_eq!(FocusTarget::StateInspector.next(), FocusTarget::InputBar); + } } diff --git a/crates/arcan-tui/src/network.rs b/crates/arcan-tui/src/network.rs index d0c307a..be6b7c1 100644 --- a/crates/arcan-tui/src/network.rs +++ b/crates/arcan-tui/src/network.rs @@ -454,6 +454,37 @@ impl NetworkClient { Ok(()) } + /// Fetches all sessions from the daemon. + pub async fn list_sessions(&self) -> anyhow::Result> { + let url = format!("{}/sessions", self.config.base_url); + let res = self.client.get(&url).send().await?; + if !res.status().is_success() { + let error_text = res.text().await?; + anyhow::bail!("Failed to list sessions: {}", error_text); + } + let sessions: Vec = res.json().await?; + Ok(sessions) + } + + /// Fetches the agent state for the current session. + pub async fn get_session_state( + &self, + branch: Option<&str>, + ) -> anyhow::Result { + let branch_param = branch.unwrap_or("main"); + let url = format!( + "{}/sessions/{}/state?branch={}", + self.config.base_url, self.config.session_id, branch_param + ); + let res = self.client.get(&url).send().await?; + if !res.status().is_success() { + let error_text = res.text().await?; + anyhow::bail!("Failed to get state: {}", error_text); + } + let state: serde_json::Value = res.json().await?; + Ok(state) + } + /// Fetches the currently selected model from the daemon. pub async fn get_model(&self) -> anyhow::Result { anyhow::bail!("Model inspection is not exposed in the canonical session API") diff --git a/crates/arcan-tui/src/ui.rs b/crates/arcan-tui/src/ui.rs index a55d503..1880f87 100644 --- a/crates/arcan-tui/src/ui.rs +++ b/crates/arcan-tui/src/ui.rs @@ -1,21 +1,36 @@ -use crate::models::state::AppState; +use crate::app::App; +use crate::focus::FocusTarget; use crate::theme::Theme; use crate::widgets; -use crate::widgets::input_bar::InputBarState; use ratatui::{ Frame, layout::{Constraint, Direction, Layout}, }; /// Top-level draw function. Orchestrates the layout: +/// +/// **Normal mode** (no panels): /// - Chat log (scrollable, fills remaining space) /// - Approval banner (shown only when pending, 6 lines) /// - Status bar (1 line) /// - Input box (3 lines) -pub fn draw(f: &mut Frame, state: &mut AppState, input_bar: &InputBarState) { +/// +/// **Panel mode** (`/sessions` or `/state`): +/// - Left 70%: normal chat layout +/// - Right 30%: session browser (top) + state inspector (bottom) +pub fn draw(f: &mut Frame, app: &mut App) { let theme = Theme::new(); - let has_approval = state.pending_approval.is_some(); + if app.show_panels { + draw_with_panels(f, app, &theme); + } else { + draw_main(f, f.area(), app, &theme); + } +} + +/// Draw the main chat area within the given rect. +fn draw_main(f: &mut Frame, area: ratatui::layout::Rect, app: &mut App, theme: &Theme) { + let has_approval = app.state.pending_approval.is_some(); let mut constraints = vec![Constraint::Min(3)]; // Chat log if has_approval { @@ -28,24 +43,65 @@ pub fn draw(f: &mut Frame, state: &mut AppState, input_bar: &InputBarState) { .direction(Direction::Vertical) .margin(1) .constraints(constraints) - .split(f.area()); + .split(area); let mut idx = 0; // Chat log (scrollable) - widgets::chat_log::render(f, chunks[idx], state, &theme); + widgets::chat_log::render(f, chunks[idx], &mut app.state, theme); idx += 1; // Approval banner (conditional) - if let Some(ref approval) = state.pending_approval { - widgets::approval_banner::render(f, chunks[idx], approval, &theme); + if let Some(ref approval) = app.state.pending_approval { + widgets::approval_banner::render(f, chunks[idx], approval, theme); idx += 1; } // Status bar - widgets::status_bar::render(f, chunks[idx], state, &theme); + widgets::status_bar::render(f, chunks[idx], &app.state, theme); idx += 1; // Input area - widgets::input_bar::render(f, chunks[idx], input_bar, state.focus, has_approval, &theme); + widgets::input_bar::render( + f, + chunks[idx], + &app.input_bar, + app.state.focus, + has_approval, + theme, + ); +} + +/// Draw with side panels (session browser + state inspector). +fn draw_with_panels(f: &mut Frame, app: &mut App, theme: &Theme) { + // Split horizontally: 70% main, 30% panels + let h_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(70), Constraint::Percentage(30)]) + .split(f.area()); + + // Left: main chat area + draw_main(f, h_chunks[0], app, theme); + + // Right: split vertically — session browser (top) + state inspector (bottom) + let panel_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(h_chunks[1]); + + widgets::session_browser::render( + f, + panel_chunks[0], + &mut app.session_browser, + app.state.focus == FocusTarget::SessionBrowser, + theme, + ); + + widgets::state_inspector::render( + f, + panel_chunks[1], + &app.state_inspector, + app.state.focus == FocusTarget::StateInspector, + theme, + ); } diff --git a/crates/arcan-tui/src/widgets.rs b/crates/arcan-tui/src/widgets.rs index 86691be..2a22b9e 100644 --- a/crates/arcan-tui/src/widgets.rs +++ b/crates/arcan-tui/src/widgets.rs @@ -1,5 +1,7 @@ pub mod approval_banner; pub mod chat_log; pub mod input_bar; +pub mod session_browser; pub mod spinner; +pub mod state_inspector; pub mod status_bar; diff --git a/crates/arcan-tui/src/widgets/session_browser.rs b/crates/arcan-tui/src/widgets/session_browser.rs new file mode 100644 index 0000000..095700a --- /dev/null +++ b/crates/arcan-tui/src/widgets/session_browser.rs @@ -0,0 +1,210 @@ +use crate::theme::Theme; +use chrono::{DateTime, Utc}; +use ratatui::{ + Frame, + layout::Rect, + style::Modifier, + text::{Line, Span}, + widgets::{Block, Borders, List, ListItem, ListState}, +}; + +/// Summary of a session returned by the daemon. +#[derive(Debug, Clone)] +pub struct SessionEntry { + pub session_id: String, + pub owner: String, + pub created_at: DateTime, +} + +/// State for the session browser panel. +#[derive(Debug, Default)] +pub struct SessionBrowserState { + pub sessions: Vec, + pub list_state: ListState, + pub loading: bool, + pub error: Option, +} + +impl SessionBrowserState { + pub fn new() -> Self { + Self::default() + } + + /// Select the next session in the list. + pub fn next(&mut self) { + if self.sessions.is_empty() { + return; + } + let i = self + .list_state + .selected() + .map(|i| (i + 1) % self.sessions.len()) + .unwrap_or(0); + self.list_state.select(Some(i)); + } + + /// Select the previous session in the list. + pub fn previous(&mut self) { + if self.sessions.is_empty() { + return; + } + let i = self + .list_state + .selected() + .map(|i| { + if i == 0 { + self.sessions.len() - 1 + } else { + i - 1 + } + }) + .unwrap_or(0); + self.list_state.select(Some(i)); + } + + /// Get the currently selected session ID, if any. + pub fn selected_session_id(&self) -> Option<&str> { + self.list_state + .selected() + .and_then(|i| self.sessions.get(i)) + .map(|s| s.session_id.as_str()) + } + + /// Update the session list (typically after a fetch). + pub fn set_sessions(&mut self, sessions: Vec) { + self.sessions = sessions; + self.loading = false; + self.error = None; + // Select first if none selected + if self.list_state.selected().is_none() && !self.sessions.is_empty() { + self.list_state.select(Some(0)); + } + } + + /// Mark as loading. + pub fn set_loading(&mut self) { + self.loading = true; + self.error = None; + } + + /// Mark as errored. + pub fn set_error(&mut self, msg: String) { + self.loading = false; + self.error = Some(msg); + } +} + +/// Render the session browser panel. +pub fn render( + f: &mut Frame, + area: Rect, + state: &mut SessionBrowserState, + focused: bool, + theme: &Theme, +) { + let border_style = if focused { theme.title } else { theme.border }; + + let items: Vec = if state.loading { + vec![ListItem::new(Line::from(Span::styled( + "Loading sessions...", + theme.timestamp, + )))] + } else if let Some(ref err) = state.error { + vec![ListItem::new(Line::from(Span::styled( + format!("Error: {err}"), + theme.tool_error, + )))] + } else if state.sessions.is_empty() { + vec![ListItem::new(Line::from(Span::styled( + "No sessions found", + theme.timestamp, + )))] + } else { + state + .sessions + .iter() + .map(|s| { + let time = s.created_at.format("%m-%d %H:%M"); + ListItem::new(Line::from(vec![ + Span::styled(format!("{time} "), theme.timestamp), + Span::styled(&s.session_id, theme.assistant_label), + Span::raw(" "), + Span::styled(format!("({})", s.owner), theme.timestamp), + ])) + }) + .collect() + }; + + let list = List::new(items) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(border_style) + .title(" Sessions "), + ) + .highlight_style(theme.title.add_modifier(Modifier::REVERSED)) + .highlight_symbol("▸ "); + + f.render_stateful_widget(list, area, &mut state.list_state); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn navigation_wraps_around() { + let mut state = SessionBrowserState::new(); + state.set_sessions(vec![ + SessionEntry { + session_id: "s1".into(), + owner: "alice".into(), + created_at: Utc::now(), + }, + SessionEntry { + session_id: "s2".into(), + owner: "bob".into(), + created_at: Utc::now(), + }, + ]); + state.list_state.select(Some(0)); + + state.previous(); // wraps to last + assert_eq!(state.list_state.selected(), Some(1)); + + state.next(); // wraps to first + assert_eq!(state.list_state.selected(), Some(0)); + } + + #[test] + fn selected_session_id_returns_correct_id() { + let mut state = SessionBrowserState::new(); + state.set_sessions(vec![SessionEntry { + session_id: "sess-abc".into(), + owner: "user".into(), + created_at: Utc::now(), + }]); + assert_eq!(state.selected_session_id(), Some("sess-abc")); + } + + #[test] + fn set_sessions_selects_first_entry() { + let mut state = SessionBrowserState::new(); + assert_eq!(state.list_state.selected(), None); + + state.set_sessions(vec![SessionEntry { + session_id: "s1".into(), + owner: "o".into(), + created_at: Utc::now(), + }]); + assert_eq!(state.list_state.selected(), Some(0)); + } + + #[test] + fn empty_list_navigation_is_safe() { + let mut state = SessionBrowserState::new(); + state.next(); // should not panic + state.previous(); // should not panic + assert_eq!(state.selected_session_id(), None); + } +} diff --git a/crates/arcan-tui/src/widgets/state_inspector.rs b/crates/arcan-tui/src/widgets/state_inspector.rs new file mode 100644 index 0000000..625d53d --- /dev/null +++ b/crates/arcan-tui/src/widgets/state_inspector.rs @@ -0,0 +1,230 @@ +use crate::theme::Theme; +use ratatui::{ + Frame, + layout::Rect, + style::Modifier, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph, Wrap}, +}; + +/// Snapshot of the agent state from the daemon's `/state` endpoint. +#[derive(Debug, Clone, Default)] +pub struct AgentStateSnapshot { + pub session_id: String, + pub branch: String, + pub mode: String, + pub progress: f32, + pub uncertainty: f32, + pub risk_level: String, + pub error_streak: u32, + pub context_pressure: f32, + pub side_effect_pressure: f32, + pub human_dependency: f32, + pub tokens_remaining: u64, + pub time_remaining_ms: u64, + pub cost_remaining_usd: f64, + pub tool_calls_remaining: u32, + pub error_budget_remaining: u32, + pub version: u64, +} + +/// State for the inspector panel. +#[derive(Debug, Default)] +pub struct StateInspectorState { + pub snapshot: Option, + pub loading: bool, + pub error: Option, +} + +impl StateInspectorState { + pub fn new() -> Self { + Self::default() + } + + pub fn set_snapshot(&mut self, snapshot: AgentStateSnapshot) { + self.snapshot = Some(snapshot); + self.loading = false; + self.error = None; + } + + pub fn set_loading(&mut self) { + self.loading = true; + self.error = None; + } + + pub fn set_error(&mut self, msg: String) { + self.loading = false; + self.error = Some(msg); + } +} + +/// Render the state inspector panel. +pub fn render( + f: &mut Frame, + area: Rect, + state: &StateInspectorState, + focused: bool, + theme: &Theme, +) { + let border_style = if focused { theme.title } else { theme.border }; + + let lines = if state.loading { + vec![Line::from(Span::styled( + "Loading state...", + theme.timestamp, + ))] + } else if let Some(ref err) = state.error { + vec![Line::from(Span::styled( + format!("Error: {err}"), + theme.tool_error, + ))] + } else if let Some(ref snap) = state.snapshot { + build_state_lines(snap, theme) + } else { + vec![Line::from(Span::styled( + "No state loaded. Use /state to fetch.", + theme.timestamp, + ))] + }; + + let widget = Paragraph::new(lines) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(border_style) + .title(" Agent State "), + ) + .wrap(Wrap { trim: false }); + + f.render_widget(widget, area); +} + +fn build_state_lines<'a>(snap: &'a AgentStateSnapshot, theme: &'a Theme) -> Vec> { + let bold = theme.title; + let val = theme.assistant_label; + let dim = theme.timestamp; + + let mode_style = match snap.mode.as_str() { + "Explore" => theme.assistant_label, + "Execute" => theme.human_label, + "Verify" => theme.tool_label, + "Recover" | "AskHuman" => theme.tool_error, + "Sleep" => theme.timestamp, + _ => theme.timestamp, + }; + + let risk_style = match snap.risk_level.as_str() { + "Critical" => theme.tool_error.add_modifier(Modifier::BOLD), + "High" => theme.tool_error, + "Medium" => theme.tool_label, + "Low" => theme.tool_success, + _ => dim, + }; + + vec![ + Line::from(vec![ + Span::styled("Mode: ", bold), + Span::styled(&snap.mode, mode_style), + Span::styled(" Branch: ", bold), + Span::styled(&snap.branch, val), + Span::styled(format!(" v{}", snap.version), dim), + ]), + Line::from(vec![ + Span::styled("Progress: ", bold), + Span::styled(format_bar(snap.progress), val), + Span::styled(format!(" {:.0}%", snap.progress * 100.0), dim), + Span::styled(" Risk: ", bold), + Span::styled(&snap.risk_level, risk_style), + ]), + Line::from(vec![ + Span::styled("Uncertainty: ", bold), + Span::styled(format_bar(snap.uncertainty), val), + Span::styled(" Errors: ", bold), + Span::styled( + format!("{}", snap.error_streak), + if snap.error_streak > 0 { + theme.tool_error + } else { + val + }, + ), + ]), + Line::from(vec![ + Span::styled("Context: ", bold), + Span::styled(format_bar(snap.context_pressure), val), + Span::styled(" Side-FX: ", bold), + Span::styled(format_bar(snap.side_effect_pressure), val), + Span::styled(" Human: ", bold), + Span::styled(format_bar(snap.human_dependency), val), + ]), + Line::from(Span::styled("─── Budget ───", dim)), + Line::from(vec![ + Span::styled("Tokens: ", bold), + Span::styled(format!("{}", snap.tokens_remaining), val), + Span::styled(" Tools: ", bold), + Span::styled(format!("{}", snap.tool_calls_remaining), val), + Span::styled(" Err budget: ", bold), + Span::styled(format!("{}", snap.error_budget_remaining), val), + ]), + Line::from(vec![ + Span::styled("Cost: ", bold), + Span::styled(format!("${:.4}", snap.cost_remaining_usd), val), + Span::styled(" Time: ", bold), + Span::styled(format!("{}ms", snap.time_remaining_ms), val), + ]), + ] +} + +/// Render a simple 10-char bar: ████░░░░░░ +fn format_bar(value: f32) -> String { + let filled = (value.clamp(0.0, 1.0) * 10.0).round() as usize; + let empty = 10 - filled; + format!("{}{}", "█".repeat(filled), "░".repeat(empty)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn format_bar_zero() { + assert_eq!(format_bar(0.0), "░░░░░░░░░░"); + } + + #[test] + fn format_bar_full() { + assert_eq!(format_bar(1.0), "██████████"); + } + + #[test] + fn format_bar_half() { + assert_eq!(format_bar(0.5), "█████░░░░░"); + } + + #[test] + fn format_bar_clamps_above_one() { + assert_eq!(format_bar(1.5), "██████████"); + } + + #[test] + fn snapshot_defaults() { + let snap = AgentStateSnapshot::default(); + assert_eq!(snap.mode, ""); + assert_eq!(snap.progress, 0.0); + } + + #[test] + fn inspector_state_set_snapshot() { + let mut state = StateInspectorState::new(); + state.set_loading(); + assert!(state.loading); + + state.set_snapshot(AgentStateSnapshot { + mode: "Explore".into(), + progress: 0.5, + ..Default::default() + }); + assert!(!state.loading); + assert!(state.snapshot.is_some()); + } +} From 980777dcb9533887aba9ce33624928751adfefc5 Mon Sep 17 00:00:00 2001 From: Carlos Escobar Date: Sun, 1 Mar 2026 21:08:23 -0500 Subject: [PATCH 5/5] =?UTF-8?q?test(tui):=20close=20coverage=20gaps=20?= =?UTF-8?q?=E2=80=94=20word=20boundaries,=20error=20TTL,=20HTTP=20client?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 15 new tests covering previously untested pure logic: - InputBarState: Ctrl+Left/Right word boundaries (3 tests), Delete key, cursor_col UTF-8 char counting - AppState: flash_error, clear_expired_errors TTL expiry/retention, default state assertions - NetworkClient: wiremock HTTP tests for list_sessions (success/error), get_session_state (default/custom branch), submit_run, submit_approval 79 tests passing (up from 64), clippy clean. Co-Authored-By: Claude Opus 4.6 --- crates/arcan-tui/src/models/state.rs | 52 ++++++++ crates/arcan-tui/src/network.rs | 154 +++++++++++++++++++++- crates/arcan-tui/src/widgets/input_bar.rs | 73 ++++++++++ 3 files changed, 278 insertions(+), 1 deletion(-) diff --git a/crates/arcan-tui/src/models/state.rs b/crates/arcan-tui/src/models/state.rs index 8863d58..ab8a051 100644 --- a/crates/arcan-tui/src/models/state.rs +++ b/crates/arcan-tui/src/models/state.rs @@ -225,3 +225,55 @@ impl AppState { .is_some_and(|last| last == text) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn flash_error_sets_message() { + let mut state = AppState::new(); + assert!(state.last_error.is_none()); + + state.flash_error("something went wrong"); + assert!(state.last_error.is_some()); + assert_eq!( + state.last_error.as_ref().unwrap().message, + "something went wrong" + ); + } + + #[test] + fn clear_expired_errors_removes_old_flash() { + let mut state = AppState::new(); + // Set an error flash with a timestamp in the past + state.last_error = Some(ErrorFlash { + message: "old error".to_string(), + timestamp: Utc::now() - chrono::Duration::seconds(10), + }); + + // TTL of 5 seconds → should clear the 10s-old error + state.clear_expired_errors(chrono::Duration::seconds(5)); + assert!(state.last_error.is_none()); + } + + #[test] + fn clear_expired_errors_keeps_fresh_flash() { + let mut state = AppState::new(); + state.flash_error("fresh error"); + + // TTL of 5 seconds → just-created error should survive + state.clear_expired_errors(chrono::Duration::seconds(5)); + assert!(state.last_error.is_some()); + } + + #[test] + fn new_state_has_sensible_defaults() { + let state = AppState::new(); + assert_eq!(state.current_branch, "main"); + assert!(state.blocks.is_empty()); + assert!(!state.is_busy); + assert_eq!(state.connection_status, ConnectionStatus::Connecting); + assert_eq!(state.focus, FocusTarget::InputBar); + } +} diff --git a/crates/arcan-tui/src/network.rs b/crates/arcan-tui/src/network.rs index be6b7c1..c47e8eb 100644 --- a/crates/arcan-tui/src/network.rs +++ b/crates/arcan-tui/src/network.rs @@ -552,13 +552,15 @@ impl NetworkClient { #[cfg(test)] mod tests { - use super::{parse_canonical_event, parse_vercel_v6_part}; + use super::*; use aios_protocol::{ BranchId as ProtocolBranchId, EventKind as ProtocolEventKind, EventRecord as ProtocolEventRecord, SessionId as ProtocolSessionId, }; use arcan_core::protocol::AgentEvent; use serde_json::json; + use wiremock::matchers::{method, path, query_param}; + use wiremock::{Mock, MockServer, ResponseTemplate}; #[test] fn parses_assistant_delta_event() { @@ -672,4 +674,154 @@ mod tests { _ => panic!("expected TextDelta"), } } + + // --- HTTP client tests with wiremock --- + + #[tokio::test] + async fn list_sessions_returns_parsed_sessions() { + let server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/sessions")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!([ + {"session_id": "s1", "owner": "alice", "created_at": "2026-03-01T00:00:00Z"}, + {"session_id": "s2", "owner": "bob", "created_at": "2026-02-28T00:00:00Z"} + ]))) + .mount(&server) + .await; + + let client = NetworkClient::new(NetworkConfig { + base_url: server.uri(), + session_id: "test".to_string(), + }); + + let sessions = client.list_sessions().await.unwrap(); + assert_eq!(sessions.len(), 2); + assert_eq!(sessions[0]["session_id"], "s1"); + assert_eq!(sessions[1]["owner"], "bob"); + } + + #[tokio::test] + async fn list_sessions_returns_error_on_failure() { + let server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/sessions")) + .respond_with(ResponseTemplate::new(500).set_body_string("internal error")) + .mount(&server) + .await; + + let client = NetworkClient::new(NetworkConfig { + base_url: server.uri(), + session_id: "test".to_string(), + }); + + let result = client.list_sessions().await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn get_session_state_returns_state_snapshot() { + let server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/sessions/sess-123/state")) + .and(query_param("branch", "main")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "session_id": "sess-123", + "branch": "main", + "mode": "Explore", + "state": { + "progress": 0.5, + "uncertainty": 0.2, + "risk_level": "Low", + "error_streak": 0, + "budget": { + "tokens_remaining": 100000, + "tool_calls_remaining": 50 + } + }, + "version": 42 + }))) + .mount(&server) + .await; + + let client = NetworkClient::new(NetworkConfig { + base_url: server.uri(), + session_id: "sess-123".to_string(), + }); + + let state = client.get_session_state(None).await.unwrap(); + assert_eq!(state["session_id"], "sess-123"); + assert_eq!(state["mode"], "Explore"); + assert_eq!(state["state"]["progress"], 0.5); + assert_eq!(state["version"], 42); + } + + #[tokio::test] + async fn get_session_state_with_custom_branch() { + let server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/sessions/sess-123/state")) + .and(query_param("branch", "feature")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "session_id": "sess-123", + "branch": "feature", + "mode": "Execute", + "state": {}, + "version": 1 + }))) + .mount(&server) + .await; + + let client = NetworkClient::new(NetworkConfig { + base_url: server.uri(), + session_id: "sess-123".to_string(), + }); + + let state = client.get_session_state(Some("feature")).await.unwrap(); + assert_eq!(state["branch"], "feature"); + } + + #[tokio::test] + async fn submit_run_sends_correct_payload() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/sessions/sess-1/runs")) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&server) + .await; + + let client = NetworkClient::new(NetworkConfig { + base_url: server.uri(), + session_id: "sess-1".to_string(), + }); + + client.submit_run("hello agent", None).await.unwrap(); + } + + #[tokio::test] + async fn submit_approval_sends_correct_payload() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/sessions/sess-1/approvals/ap-42")) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&server) + .await; + + let client = NetworkClient::new(NetworkConfig { + base_url: server.uri(), + session_id: "sess-1".to_string(), + }); + + client + .submit_approval("ap-42", "yes", Some("looks good")) + .await + .unwrap(); + } } diff --git a/crates/arcan-tui/src/widgets/input_bar.rs b/crates/arcan-tui/src/widgets/input_bar.rs index 81cc877..9ef45b7 100644 --- a/crates/arcan-tui/src/widgets/input_bar.rs +++ b/crates/arcan-tui/src/widgets/input_bar.rs @@ -299,6 +299,79 @@ mod tests { assert_eq!(bar.cursor, 1); } + #[test] + fn ctrl_left_word_boundary() { + let mut bar = InputBarState::new(); + bar.buffer = "hello world test".to_string(); + bar.cursor = 16; // end of "test" + + // Ctrl+Left from end → start of "test" + bar.input(KeyEvent::new(KeyCode::Left, KeyModifiers::CONTROL)); + assert_eq!(bar.cursor, 12); + + // Ctrl+Left → start of "world" + bar.input(KeyEvent::new(KeyCode::Left, KeyModifiers::CONTROL)); + assert_eq!(bar.cursor, 6); + + // Ctrl+Left → start of "hello" + bar.input(KeyEvent::new(KeyCode::Left, KeyModifiers::CONTROL)); + assert_eq!(bar.cursor, 0); + + // Ctrl+Left at start stays at 0 + bar.input(KeyEvent::new(KeyCode::Left, KeyModifiers::CONTROL)); + assert_eq!(bar.cursor, 0); + } + + #[test] + fn ctrl_right_word_boundary() { + let mut bar = InputBarState::new(); + bar.buffer = "hello world test".to_string(); + bar.cursor = 0; + + // Ctrl+Right → start of "world" + bar.input(KeyEvent::new(KeyCode::Right, KeyModifiers::CONTROL)); + assert_eq!(bar.cursor, 6); + + // Ctrl+Right → start of "test" + bar.input(KeyEvent::new(KeyCode::Right, KeyModifiers::CONTROL)); + assert_eq!(bar.cursor, 12); + + // Ctrl+Right → end of buffer + bar.input(KeyEvent::new(KeyCode::Right, KeyModifiers::CONTROL)); + assert_eq!(bar.cursor, 16); + } + + #[test] + fn word_boundary_with_multiple_spaces() { + let mut bar = InputBarState::new(); + bar.buffer = "hello world".to_string(); + bar.cursor = 0; + + // Ctrl+Right skips multiple spaces → start of "world" + bar.input(KeyEvent::new(KeyCode::Right, KeyModifiers::CONTROL)); + assert_eq!(bar.cursor, 9); + } + + #[test] + fn delete_key_removes_char_after_cursor() { + let mut bar = InputBarState::new(); + bar.buffer = "abcd".to_string(); + bar.cursor = 1; // after 'a' + + bar.input(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE)); + assert_eq!(bar.text(), "acd"); + assert_eq!(bar.cursor, 1); // cursor stays + } + + #[test] + fn cursor_col_counts_chars_not_bytes() { + let mut bar = InputBarState::new(); + // 'é' is 2 bytes in UTF-8 + bar.buffer = "café".to_string(); + bar.cursor = bar.buffer.len(); // byte offset at end + assert_eq!(bar.cursor_col(), 4); // 4 characters + } + fn key(c: char) -> KeyEvent { KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE) }