From c2d5ae05c11ba18b4a2dda83168fffee617e550a Mon Sep 17 00:00:00 2001 From: echobt Date: Thu, 5 Feb 2026 11:12:56 +0000 Subject: [PATCH 1/2] feat(tui): inline tool approval UI in input zone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace modal-based tool approval with inline UI in the input zone (similar to settings). Features: - Tool approval now displays in the input area instead of a modal - Three action options: [n] Reject, [y] Accept Once, [a] Accept & Set Risk - Risk level submenu with [1] Low, [2] Medium, [3] High options - Keyboard navigation with arrow keys and Enter to confirm - ESC cancels or goes back from submenu to main options - Automatic permission mode update when setting risk level UI Layout: ╭─ Tool Approval ─────────────────────────────────────────╮ │ ⚠ Execute: tool_name │ │ args summary (truncated)... │ │ [n] Reject [y] Accept Once [a] Accept & Set Risk │ ╰─────────────────────────────────────────────────────────╯ --- src/cortex-tui/src/app/approval.rs | 67 +++++ src/cortex-tui/src/app/methods.rs | 6 + src/cortex-tui/src/app/mod.rs | 2 +- .../src/bridge/event_adapter/approval.rs | 6 + .../src/runner/event_loop/actions.rs | 4 +- src/cortex-tui/src/runner/event_loop/input.rs | 184 ++++++++++++++ .../src/views/minimal_session/rendering.rs | 231 +++++++++++++++++- .../src/views/minimal_session/view.rs | 15 +- 8 files changed, 509 insertions(+), 6 deletions(-) diff --git a/src/cortex-tui/src/app/approval.rs b/src/cortex-tui/src/app/approval.rs index 1cbaff7..3083a6d 100644 --- a/src/cortex-tui/src/app/approval.rs +++ b/src/cortex-tui/src/app/approval.rs @@ -1,5 +1,63 @@ use super::types::ApprovalMode; +/// State for inline approval selection in input zone +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum InlineApprovalSelection { + #[default] + AcceptOnce, // 'y' - accept this time + Reject, // 'n' - reject + AcceptAndSet, // 'a' - accept and set risk level +} + +impl InlineApprovalSelection { + /// Move selection to the next item + pub fn next(self) -> Self { + match self { + Self::Reject => Self::AcceptOnce, + Self::AcceptOnce => Self::AcceptAndSet, + Self::AcceptAndSet => Self::Reject, + } + } + + /// Move selection to the previous item + pub fn prev(self) -> Self { + match self { + Self::Reject => Self::AcceptAndSet, + Self::AcceptOnce => Self::Reject, + Self::AcceptAndSet => Self::AcceptOnce, + } + } +} + +/// State for risk level submenu +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum RiskLevelSelection { + #[default] + Low, + Medium, + High, +} + +impl RiskLevelSelection { + /// Move selection to the next item + pub fn next(self) -> Self { + match self { + Self::Low => Self::Medium, + Self::Medium => Self::High, + Self::High => Self::Low, + } + } + + /// Move selection to the previous item + pub fn prev(self) -> Self { + match self { + Self::Low => Self::High, + Self::Medium => Self::Low, + Self::High => Self::Medium, + } + } +} + /// State for pending tool approval #[derive(Debug, Clone, Default)] pub struct ApprovalState { @@ -11,6 +69,12 @@ pub struct ApprovalState { pub tool_args_json: Option, pub diff_preview: Option, pub approval_mode: ApprovalMode, + /// Currently selected action in inline approval UI + pub selected_action: InlineApprovalSelection, + /// Whether the risk level submenu is visible + pub show_risk_submenu: bool, + /// Selected risk level in submenu + pub selected_risk_level: RiskLevelSelection, } impl ApprovalState { @@ -23,6 +87,9 @@ impl ApprovalState { tool_args_json: Some(tool_args), diff_preview: None, approval_mode: ApprovalMode::default(), + selected_action: InlineApprovalSelection::default(), + show_risk_submenu: false, + selected_risk_level: RiskLevelSelection::default(), } } diff --git a/src/cortex-tui/src/app/methods.rs b/src/cortex-tui/src/app/methods.rs index d29c936..92b0a2a 100644 --- a/src/cortex-tui/src/app/methods.rs +++ b/src/cortex-tui/src/app/methods.rs @@ -32,6 +32,9 @@ impl AppState { tool_args_json, diff_preview, approval_mode: ApprovalMode::Ask, + selected_action: Default::default(), + show_risk_submenu: false, + selected_risk_level: Default::default(), }); self.set_view(AppView::Approval); } @@ -51,6 +54,9 @@ impl AppState { tool_args_json: Some(tool_args), diff_preview, approval_mode: ApprovalMode::Ask, + selected_action: Default::default(), + show_risk_submenu: false, + selected_risk_level: Default::default(), }); self.set_view(AppView::Approval); diff --git a/src/cortex-tui/src/app/mod.rs b/src/cortex-tui/src/app/mod.rs index 7b9c9fe..76ae4ea 100644 --- a/src/cortex-tui/src/app/mod.rs +++ b/src/cortex-tui/src/app/mod.rs @@ -12,7 +12,7 @@ mod subagent; mod types; // Re-export all public types -pub use approval::{ApprovalState, PendingToolResult}; +pub use approval::{ApprovalState, InlineApprovalSelection, PendingToolResult, RiskLevelSelection}; pub use autocomplete::{AutocompleteItem, AutocompleteState}; pub use session::{ActiveModal, SessionSummary}; pub use state::AppState; diff --git a/src/cortex-tui/src/bridge/event_adapter/approval.rs b/src/cortex-tui/src/bridge/event_adapter/approval.rs index 4d41941..ac5b62a 100644 --- a/src/cortex-tui/src/bridge/event_adapter/approval.rs +++ b/src/cortex-tui/src/bridge/event_adapter/approval.rs @@ -49,6 +49,9 @@ pub fn create_approval_state(request: &ExecApprovalRequestEvent) -> ApprovalStat tool_args_json: Some(tool_args), diff_preview, approval_mode: ApprovalMode::Ask, + selected_action: Default::default(), + show_risk_submenu: false, + selected_risk_level: Default::default(), } } @@ -81,6 +84,9 @@ pub fn create_patch_approval_state(request: &ApplyPatchApprovalRequestEvent) -> tool_args_json: Some(tool_args), diff_preview: Some(request.patch.clone()), approval_mode: ApprovalMode::Ask, + selected_action: Default::default(), + show_risk_submenu: false, + selected_risk_level: Default::default(), } } diff --git a/src/cortex-tui/src/runner/event_loop/actions.rs b/src/cortex-tui/src/runner/event_loop/actions.rs index df866f6..23e5c61 100644 --- a/src/cortex-tui/src/runner/event_loop/actions.rs +++ b/src/cortex-tui/src/runner/event_loop/actions.rs @@ -170,7 +170,7 @@ impl EventLoop { } /// Handle approve action - async fn handle_approve(&mut self) -> Result<()> { + pub(super) async fn handle_approve(&mut self) -> Result<()> { use crate::views::tool_call::ToolStatus; if let Some(approval) = self.app_state.approve() { @@ -216,7 +216,7 @@ impl EventLoop { } /// Handle reject action - async fn handle_reject(&mut self) -> Result<()> { + pub(super) async fn handle_reject(&mut self) -> Result<()> { if let Some(approval) = self.app_state.reject() { // Update tool status to failed self.app_state.update_tool_result( diff --git a/src/cortex-tui/src/runner/event_loop/input.rs b/src/cortex-tui/src/runner/event_loop/input.rs index 126b0d4..98beeed 100644 --- a/src/cortex-tui/src/runner/event_loop/input.rs +++ b/src/cortex-tui/src/runner/event_loop/input.rs @@ -223,6 +223,13 @@ impl EventLoop { return Ok(()); } + // Handle inline approval UI when pending approval exists + if self.app_state.pending_approval.is_some() { + if self.handle_inline_approval_key(key_event, terminal).await? { + return Ok(()); + } + } + // Check if a card is active and handle its input first if self.card_handler.is_active() && self.card_handler.handle_key(key_event) { // Process any pending card actions @@ -502,6 +509,183 @@ impl EventLoop { } } + /// Handle inline approval UI key events. + /// Returns true if the key was handled (approval action taken), false otherwise. + async fn handle_inline_approval_key( + &mut self, + key_event: crossterm::event::KeyEvent, + terminal: &mut CortexTerminal, + ) -> Result { + use crossterm::event::KeyCode; + use crate::app::{InlineApprovalSelection, RiskLevelSelection}; + + // Check if risk level submenu is visible + let show_submenu = self.app_state.pending_approval + .as_ref() + .map(|a| a.show_risk_submenu) + .unwrap_or(false); + + if show_submenu { + // Handle risk level submenu keys + match key_event.code { + KeyCode::Char('1') => { + // Select Low risk level and approve + self.handle_approve_with_risk_level(RiskLevelSelection::Low).await?; + self.render(terminal)?; + return Ok(true); + } + KeyCode::Char('2') => { + // Select Medium risk level and approve + self.handle_approve_with_risk_level(RiskLevelSelection::Medium).await?; + self.render(terminal)?; + return Ok(true); + } + KeyCode::Char('3') => { + // Select High risk level and approve + self.handle_approve_with_risk_level(RiskLevelSelection::High).await?; + self.render(terminal)?; + return Ok(true); + } + KeyCode::Esc => { + // Close submenu, back to main approval UI + if let Some(ref mut approval) = self.app_state.pending_approval { + approval.show_risk_submenu = false; + } + self.render(terminal)?; + return Ok(true); + } + KeyCode::Left => { + // Navigate risk level selection left + if let Some(ref mut approval) = self.app_state.pending_approval { + approval.selected_risk_level = approval.selected_risk_level.prev(); + } + self.render(terminal)?; + return Ok(true); + } + KeyCode::Right => { + // Navigate risk level selection right + if let Some(ref mut approval) = self.app_state.pending_approval { + approval.selected_risk_level = approval.selected_risk_level.next(); + } + self.render(terminal)?; + return Ok(true); + } + KeyCode::Enter => { + // Confirm selected risk level + let risk_level = self.app_state.pending_approval + .as_ref() + .map(|a| a.selected_risk_level) + .unwrap_or_default(); + self.handle_approve_with_risk_level(risk_level).await?; + self.render(terminal)?; + return Ok(true); + } + _ => { + // Consume other keys when submenu is visible + return Ok(true); + } + } + } + + // Handle main approval UI keys + match key_event.code { + KeyCode::Char('y') | KeyCode::Char('Y') => { + // Accept once + self.handle_approve().await?; + self.render(terminal)?; + Ok(true) + } + KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { + // Reject + self.handle_reject().await?; + self.render(terminal)?; + Ok(true) + } + KeyCode::Char('a') | KeyCode::Char('A') => { + // Show risk level submenu + if let Some(ref mut approval) = self.app_state.pending_approval { + approval.show_risk_submenu = true; + approval.selected_risk_level = RiskLevelSelection::default(); + } + self.render(terminal)?; + Ok(true) + } + KeyCode::Left => { + // Navigate selection left + if let Some(ref mut approval) = self.app_state.pending_approval { + approval.selected_action = approval.selected_action.prev(); + } + self.render(terminal)?; + Ok(true) + } + KeyCode::Right => { + // Navigate selection right + if let Some(ref mut approval) = self.app_state.pending_approval { + approval.selected_action = approval.selected_action.next(); + } + self.render(terminal)?; + Ok(true) + } + KeyCode::Enter => { + // Confirm selected action + let action = self.app_state.pending_approval + .as_ref() + .map(|a| a.selected_action) + .unwrap_or_default(); + match action { + InlineApprovalSelection::AcceptOnce => { + self.handle_approve().await?; + } + InlineApprovalSelection::Reject => { + self.handle_reject().await?; + } + InlineApprovalSelection::AcceptAndSet => { + // Show risk level submenu + if let Some(ref mut approval) = self.app_state.pending_approval { + approval.show_risk_submenu = true; + approval.selected_risk_level = RiskLevelSelection::default(); + } + } + } + self.render(terminal)?; + Ok(true) + } + _ => { + // Don't consume other keys - allow them to pass through + // This allows things like Ctrl+C to work + Ok(false) + } + } + } + + /// Handle approval with risk level - approves the tool and updates permission mode + async fn handle_approve_with_risk_level( + &mut self, + risk_level: crate::app::RiskLevelSelection, + ) -> Result<()> { + use crate::app::RiskLevelSelection; + use crate::permissions::PermissionMode; + + // Update permission mode based on selected risk level + self.app_state.permission_mode = match risk_level { + RiskLevelSelection::Low => PermissionMode::Low, + RiskLevelSelection::Medium => PermissionMode::Medium, + RiskLevelSelection::High => PermissionMode::High, + }; + + // Sync permission mode with the manager + self.sync_permission_mode(); + + // Show toast notification about the mode change + let mode_name = self.app_state.permission_mode.display_name(); + self.app_state.toasts.info(&format!("Risk level set to: {}", mode_name)); + + // Now approve the tool + self.handle_approve().await?; + + Ok(()) + } + /// Handle terminal resize event fn handle_resize( &mut self, diff --git a/src/cortex-tui/src/views/minimal_session/rendering.rs b/src/cortex-tui/src/views/minimal_session/rendering.rs index 6c8c972..d8d3e34 100644 --- a/src/cortex-tui/src/views/minimal_session/rendering.rs +++ b/src/cortex-tui/src/views/minimal_session/rendering.rs @@ -14,8 +14,12 @@ use cortex_core::markdown::MarkdownTheme; use cortex_core::widgets::{Brain, Message, MessageRole}; use cortex_tui_components::welcome_card::{InfoCard, InfoCardPair, ToLines, WelcomeCard}; -use crate::app::{AppState, SubagentDisplayStatus, SubagentTaskDisplay}; +use crate::app::{ + AppState, ApprovalState, InlineApprovalSelection, RiskLevelSelection, + SubagentDisplayStatus, SubagentTaskDisplay, +}; use crate::ui::colors::AdaptiveColors; +use crate::ui::consts::border; use crate::views::tool_call::{ContentSegment, ToolCallDisplay, ToolStatus}; use super::VERSION; @@ -975,3 +979,228 @@ pub fn render_motd_compact( Paragraph::new(lines).render(text_area, buf); } + +/// Renders the inline approval UI in the input zone area. +/// +/// Layout: +/// ```text +/// ╭─ Tool Approval ─────────────────────────────────────────╮ +/// │ ⚠ Execute: tool_name │ +/// │ args summary (truncated)... │ +/// │ [n] Reject [y] Accept Once [a] Accept & Set Risk │ +/// ╰─────────────────────────────────────────────────────────╯ +/// ``` +/// +/// When risk submenu is active: +/// ```text +/// ╭─ Set Risk Level ────────────────────────────────────────╮ +/// │ [1] Low [2] Medium [3] High [Esc] Cancel │ +/// ╰─────────────────────────────────────────────────────────╯ +/// ``` +pub fn render_inline_approval(area: Rect, buf: &mut Buffer, approval: &ApprovalState, colors: &AdaptiveColors) { + if area.is_empty() || area.height < 4 { + return; + } + + let dim_style = Style::default().fg(colors.text_dim); + let accent_style = Style::default().fg(colors.accent); + let border_style = dim_style; + + // Draw top border with title + if let Some(cell) = buf.cell_mut((area.x, area.y)) { + cell.set_char(border::TOP_LEFT).set_style(border_style); + } + if let Some(cell) = buf.cell_mut((area.right() - 1, area.y)) { + cell.set_char(border::TOP_RIGHT).set_style(border_style); + } + for x in (area.x + 1)..(area.right() - 1) { + if let Some(cell) = buf.cell_mut((x, area.y)) { + cell.set_char(border::HORIZONTAL).set_style(border_style); + } + } + + // Render title in top border + let title = if approval.show_risk_submenu { + " Set Risk Level " + } else { + " Tool Approval " + }; + let title_x = area.x + 2; + buf.set_string(title_x, area.y, title, accent_style); + + // Draw bottom border + if let Some(cell) = buf.cell_mut((area.x, area.bottom() - 1)) { + cell.set_char(border::BOTTOM_LEFT).set_style(border_style); + } + if let Some(cell) = buf.cell_mut((area.right() - 1, area.bottom() - 1)) { + cell.set_char(border::BOTTOM_RIGHT).set_style(border_style); + } + for x in (area.x + 1)..(area.right() - 1) { + if let Some(cell) = buf.cell_mut((x, area.bottom() - 1)) { + cell.set_char(border::HORIZONTAL).set_style(border_style); + } + } + + // Draw side borders + for y in (area.y + 1)..(area.bottom() - 1) { + if let Some(cell) = buf.cell_mut((area.x, y)) { + cell.set_char(border::VERTICAL).set_style(border_style); + } + if let Some(cell) = buf.cell_mut((area.right() - 1, y)) { + cell.set_char(border::VERTICAL).set_style(border_style); + } + } + + let content_x = area.x + 2; + let content_width = area.width.saturating_sub(4) as usize; + + if approval.show_risk_submenu { + // Risk level submenu content + let y = area.y + 1; + + // Build risk level options + let mut spans: Vec> = Vec::new(); + + let low_selected = approval.selected_risk_level == RiskLevelSelection::Low; + let med_selected = approval.selected_risk_level == RiskLevelSelection::Medium; + let high_selected = approval.selected_risk_level == RiskLevelSelection::High; + + // [1] Low + spans.push(Span::styled("[1]", if low_selected { accent_style } else { dim_style })); + spans.push(Span::styled(" Low ", if low_selected { + Style::default().fg(colors.success) + } else { + dim_style + })); + + // [2] Medium + spans.push(Span::styled("[2]", if med_selected { accent_style } else { dim_style })); + spans.push(Span::styled(" Medium ", if med_selected { + Style::default().fg(colors.warning) + } else { + dim_style + })); + + // [3] High + spans.push(Span::styled("[3]", if high_selected { accent_style } else { dim_style })); + spans.push(Span::styled(" High ", if high_selected { + Style::default().fg(colors.error) + } else { + dim_style + })); + + // [Esc] Cancel + spans.push(Span::styled("[Esc]", dim_style)); + spans.push(Span::styled(" Cancel", dim_style)); + + let line = Line::from(spans); + Paragraph::new(line).render( + Rect::new(content_x, y, area.width.saturating_sub(4), 1), + buf, + ); + } else { + // Main approval UI content + // Line 1: ⚠ Execute: tool_name + let y1 = area.y + 1; + let warning_char = "⚠"; + let tool_display = format!(" Execute: {}", approval.tool_name); + buf.set_string(content_x, y1, warning_char, Style::default().fg(colors.warning)); + buf.set_string( + content_x + 2, + y1, + &tool_display, + Style::default().fg(colors.text).add_modifier(Modifier::BOLD), + ); + + // Line 2: args summary (truncated) + let y2 = area.y + 2; + let args_summary = if let Some(ref args_json) = approval.tool_args_json { + // Create compact summary of args + format_args_summary(args_json, content_width.saturating_sub(4)) + } else { + truncate_string(&approval.tool_args, content_width.saturating_sub(4)) + }; + buf.set_string(content_x + 2, y2, &args_summary, dim_style); + + // Line 3: Action buttons + let y3 = area.y + 3; + + let mut spans: Vec> = Vec::new(); + + let reject_selected = approval.selected_action == InlineApprovalSelection::Reject; + let accept_selected = approval.selected_action == InlineApprovalSelection::AcceptOnce; + let set_selected = approval.selected_action == InlineApprovalSelection::AcceptAndSet; + + // [n] Reject + spans.push(Span::styled("[n]", if reject_selected { accent_style } else { dim_style })); + spans.push(Span::styled(" Reject ", if reject_selected { + Style::default().fg(colors.error) + } else { + dim_style + })); + + // [y] Accept Once + spans.push(Span::styled("[y]", if accept_selected { accent_style } else { dim_style })); + spans.push(Span::styled(" Accept Once ", if accept_selected { + Style::default().fg(colors.success) + } else { + dim_style + })); + + // [a] Accept & Set Risk + spans.push(Span::styled("[a]", if set_selected { accent_style } else { dim_style })); + spans.push(Span::styled(" Accept & Set Risk", if set_selected { + Style::default().fg(colors.warning) + } else { + dim_style + })); + + let line = Line::from(spans); + Paragraph::new(line).render( + Rect::new(content_x, y3, area.width.saturating_sub(4), 1), + buf, + ); + } +} + +/// Format JSON args into a compact summary string +fn format_args_summary(args: &serde_json::Value, max_width: usize) -> String { + match args { + serde_json::Value::Object(map) => { + let parts: Vec = map.iter() + .take(3) // Only show first 3 args + .map(|(k, v)| { + let v_str = match v { + serde_json::Value::String(s) => { + if s.len() > 30 { + format!("\"{}...\"", &s.chars().take(27).collect::()) + } else { + format!("\"{}\"", s) + } + } + serde_json::Value::Bool(b) => b.to_string(), + serde_json::Value::Number(n) => n.to_string(), + serde_json::Value::Null => "null".to_string(), + serde_json::Value::Array(a) => format!("[{} items]", a.len()), + serde_json::Value::Object(_) => "{...}".to_string(), + }; + format!("{}={}", k, v_str) + }) + .collect(); + let summary = parts.join(", "); + truncate_string(&summary, max_width) + } + _ => truncate_string(&args.to_string(), max_width), + } +} + +/// Truncate a string to fit within max_width, adding "..." if needed +fn truncate_string(s: &str, max_width: usize) -> String { + if s.len() <= max_width { + s.to_string() + } else if max_width > 3 { + format!("{}...", &s.chars().take(max_width - 3).collect::()) + } else { + s.chars().take(max_width).collect() + } +} diff --git a/src/cortex-tui/src/views/minimal_session/view.rs b/src/cortex-tui/src/views/minimal_session/view.rs index 725dd0e..9ae9aca 100644 --- a/src/cortex-tui/src/views/minimal_session/view.rs +++ b/src/cortex-tui/src/views/minimal_session/view.rs @@ -17,8 +17,9 @@ use crate::widgets::{HintContext, KeyHints, StatusIndicator}; use super::layout::LayoutManager; use super::rendering::{ - _render_motd, generate_message_lines, generate_welcome_lines, render_message, - render_scroll_to_bottom_hint, render_scrollbar, render_subagent, render_tool_call, + _render_motd, generate_message_lines, generate_welcome_lines, render_inline_approval, + render_message, render_scroll_to_bottom_hint, render_scrollbar, render_subagent, + render_tool_call, }; // Re-export for convenience @@ -263,11 +264,21 @@ impl<'a> MinimalSessionView<'a> { } /// Renders the input area. + /// If there's a pending approval, renders the inline approval UI instead. fn render_input(&self, area: Rect, buf: &mut Buffer) { if area.is_empty() || area.height < 3 { return; } + // Check for pending approval - render inline approval UI instead of normal input + if let Some(ref approval) = self.app_state.pending_approval { + // Calculate height needed for inline approval (5 lines: border + 3 content + border) + let approval_height = 5_u16.min(area.height); + let approval_area = Rect::new(area.x, area.y, area.width, approval_height); + render_inline_approval(approval_area, buf, approval, &self.colors); + return; + } + let dim_style = Style::default().fg(self.colors.text_dim); // Draw top border From d567e405305cc36a1316c246b325074081a8e584 Mon Sep 17 00:00:00 2001 From: echobt Date: Thu, 5 Feb 2026 11:21:43 +0000 Subject: [PATCH 2/2] fix(tests): add missing ApprovalState fields in event_loop tests --- src/cortex-tui/src/runner/event_loop/input.rs | 27 ++- src/cortex-tui/src/runner/event_loop/tests.rs | 8 +- .../src/views/minimal_session/rendering.rs | 183 ++++++++++++------ 3 files changed, 154 insertions(+), 64 deletions(-) diff --git a/src/cortex-tui/src/runner/event_loop/input.rs b/src/cortex-tui/src/runner/event_loop/input.rs index 98beeed..faf69f8 100644 --- a/src/cortex-tui/src/runner/event_loop/input.rs +++ b/src/cortex-tui/src/runner/event_loop/input.rs @@ -516,11 +516,13 @@ impl EventLoop { key_event: crossterm::event::KeyEvent, terminal: &mut CortexTerminal, ) -> Result { - use crossterm::event::KeyCode; use crate::app::{InlineApprovalSelection, RiskLevelSelection}; + use crossterm::event::KeyCode; // Check if risk level submenu is visible - let show_submenu = self.app_state.pending_approval + let show_submenu = self + .app_state + .pending_approval .as_ref() .map(|a| a.show_risk_submenu) .unwrap_or(false); @@ -530,19 +532,22 @@ impl EventLoop { match key_event.code { KeyCode::Char('1') => { // Select Low risk level and approve - self.handle_approve_with_risk_level(RiskLevelSelection::Low).await?; + self.handle_approve_with_risk_level(RiskLevelSelection::Low) + .await?; self.render(terminal)?; return Ok(true); } KeyCode::Char('2') => { // Select Medium risk level and approve - self.handle_approve_with_risk_level(RiskLevelSelection::Medium).await?; + self.handle_approve_with_risk_level(RiskLevelSelection::Medium) + .await?; self.render(terminal)?; return Ok(true); } KeyCode::Char('3') => { // Select High risk level and approve - self.handle_approve_with_risk_level(RiskLevelSelection::High).await?; + self.handle_approve_with_risk_level(RiskLevelSelection::High) + .await?; self.render(terminal)?; return Ok(true); } @@ -572,7 +577,9 @@ impl EventLoop { } KeyCode::Enter => { // Confirm selected risk level - let risk_level = self.app_state.pending_approval + let risk_level = self + .app_state + .pending_approval .as_ref() .map(|a| a.selected_risk_level) .unwrap_or_default(); @@ -628,7 +635,9 @@ impl EventLoop { } KeyCode::Enter => { // Confirm selected action - let action = self.app_state.pending_approval + let action = self + .app_state + .pending_approval .as_ref() .map(|a| a.selected_action) .unwrap_or_default(); @@ -678,7 +687,9 @@ impl EventLoop { // Show toast notification about the mode change let mode_name = self.app_state.permission_mode.display_name(); - self.app_state.toasts.info(&format!("Risk level set to: {}", mode_name)); + self.app_state + .toasts + .info(&format!("Risk level set to: {}", mode_name)); // Now approve the tool self.handle_approve().await?; diff --git a/src/cortex-tui/src/runner/event_loop/tests.rs b/src/cortex-tui/src/runner/event_loop/tests.rs index 53bda22..7428b86 100644 --- a/src/cortex-tui/src/runner/event_loop/tests.rs +++ b/src/cortex-tui/src/runner/event_loop/tests.rs @@ -3,7 +3,10 @@ #[cfg(test)] mod tests { use crate::actions::ActionContext; - use crate::app::{AppState, ApprovalMode, ApprovalState, FocusTarget}; + use crate::app::{ + AppState, ApprovalMode, ApprovalState, FocusTarget, InlineApprovalSelection, + RiskLevelSelection, + }; use crate::runner::event_loop::EventLoop; #[test] @@ -43,6 +46,9 @@ mod tests { tool_args_json: Some(serde_json::json!({})), diff_preview: None, approval_mode: ApprovalMode::Ask, + selected_action: InlineApprovalSelection::default(), + show_risk_submenu: false, + selected_risk_level: RiskLevelSelection::default(), }); let event_loop = EventLoop::new(app_state); diff --git a/src/cortex-tui/src/views/minimal_session/rendering.rs b/src/cortex-tui/src/views/minimal_session/rendering.rs index d8d3e34..9d13eb6 100644 --- a/src/cortex-tui/src/views/minimal_session/rendering.rs +++ b/src/cortex-tui/src/views/minimal_session/rendering.rs @@ -15,8 +15,8 @@ use cortex_core::widgets::{Brain, Message, MessageRole}; use cortex_tui_components::welcome_card::{InfoCard, InfoCardPair, ToLines, WelcomeCard}; use crate::app::{ - AppState, ApprovalState, InlineApprovalSelection, RiskLevelSelection, - SubagentDisplayStatus, SubagentTaskDisplay, + AppState, ApprovalState, InlineApprovalSelection, RiskLevelSelection, SubagentDisplayStatus, + SubagentTaskDisplay, }; use crate::ui::colors::AdaptiveColors; use crate::ui::consts::border; @@ -997,7 +997,12 @@ pub fn render_motd_compact( /// │ [1] Low [2] Medium [3] High [Esc] Cancel │ /// ╰─────────────────────────────────────────────────────────╯ /// ``` -pub fn render_inline_approval(area: Rect, buf: &mut Buffer, approval: &ApprovalState, colors: &AdaptiveColors) { +pub fn render_inline_approval( + area: Rect, + buf: &mut Buffer, + approval: &ApprovalState, + colors: &AdaptiveColors, +) { if area.is_empty() || area.height < 4 { return; } @@ -1057,42 +1062,72 @@ pub fn render_inline_approval(area: Rect, buf: &mut Buffer, approval: &ApprovalS if approval.show_risk_submenu { // Risk level submenu content let y = area.y + 1; - + // Build risk level options let mut spans: Vec> = Vec::new(); - + let low_selected = approval.selected_risk_level == RiskLevelSelection::Low; let med_selected = approval.selected_risk_level == RiskLevelSelection::Medium; let high_selected = approval.selected_risk_level == RiskLevelSelection::High; - + // [1] Low - spans.push(Span::styled("[1]", if low_selected { accent_style } else { dim_style })); - spans.push(Span::styled(" Low ", if low_selected { - Style::default().fg(colors.success) - } else { - dim_style - })); - + spans.push(Span::styled( + "[1]", + if low_selected { + accent_style + } else { + dim_style + }, + )); + spans.push(Span::styled( + " Low ", + if low_selected { + Style::default().fg(colors.success) + } else { + dim_style + }, + )); + // [2] Medium - spans.push(Span::styled("[2]", if med_selected { accent_style } else { dim_style })); - spans.push(Span::styled(" Medium ", if med_selected { - Style::default().fg(colors.warning) - } else { - dim_style - })); - + spans.push(Span::styled( + "[2]", + if med_selected { + accent_style + } else { + dim_style + }, + )); + spans.push(Span::styled( + " Medium ", + if med_selected { + Style::default().fg(colors.warning) + } else { + dim_style + }, + )); + // [3] High - spans.push(Span::styled("[3]", if high_selected { accent_style } else { dim_style })); - spans.push(Span::styled(" High ", if high_selected { - Style::default().fg(colors.error) - } else { - dim_style - })); - + spans.push(Span::styled( + "[3]", + if high_selected { + accent_style + } else { + dim_style + }, + )); + spans.push(Span::styled( + " High ", + if high_selected { + Style::default().fg(colors.error) + } else { + dim_style + }, + )); + // [Esc] Cancel spans.push(Span::styled("[Esc]", dim_style)); spans.push(Span::styled(" Cancel", dim_style)); - + let line = Line::from(spans); Paragraph::new(line).render( Rect::new(content_x, y, area.width.saturating_sub(4), 1), @@ -1104,12 +1139,19 @@ pub fn render_inline_approval(area: Rect, buf: &mut Buffer, approval: &ApprovalS let y1 = area.y + 1; let warning_char = "⚠"; let tool_display = format!(" Execute: {}", approval.tool_name); - buf.set_string(content_x, y1, warning_char, Style::default().fg(colors.warning)); + buf.set_string( + content_x, + y1, + warning_char, + Style::default().fg(colors.warning), + ); buf.set_string( content_x + 2, y1, &tool_display, - Style::default().fg(colors.text).add_modifier(Modifier::BOLD), + Style::default() + .fg(colors.text) + .add_modifier(Modifier::BOLD), ); // Line 2: args summary (truncated) @@ -1124,37 +1166,67 @@ pub fn render_inline_approval(area: Rect, buf: &mut Buffer, approval: &ApprovalS // Line 3: Action buttons let y3 = area.y + 3; - + let mut spans: Vec> = Vec::new(); - + let reject_selected = approval.selected_action == InlineApprovalSelection::Reject; let accept_selected = approval.selected_action == InlineApprovalSelection::AcceptOnce; let set_selected = approval.selected_action == InlineApprovalSelection::AcceptAndSet; - + // [n] Reject - spans.push(Span::styled("[n]", if reject_selected { accent_style } else { dim_style })); - spans.push(Span::styled(" Reject ", if reject_selected { - Style::default().fg(colors.error) - } else { - dim_style - })); - + spans.push(Span::styled( + "[n]", + if reject_selected { + accent_style + } else { + dim_style + }, + )); + spans.push(Span::styled( + " Reject ", + if reject_selected { + Style::default().fg(colors.error) + } else { + dim_style + }, + )); + // [y] Accept Once - spans.push(Span::styled("[y]", if accept_selected { accent_style } else { dim_style })); - spans.push(Span::styled(" Accept Once ", if accept_selected { - Style::default().fg(colors.success) - } else { - dim_style - })); - + spans.push(Span::styled( + "[y]", + if accept_selected { + accent_style + } else { + dim_style + }, + )); + spans.push(Span::styled( + " Accept Once ", + if accept_selected { + Style::default().fg(colors.success) + } else { + dim_style + }, + )); + // [a] Accept & Set Risk - spans.push(Span::styled("[a]", if set_selected { accent_style } else { dim_style })); - spans.push(Span::styled(" Accept & Set Risk", if set_selected { - Style::default().fg(colors.warning) - } else { - dim_style - })); - + spans.push(Span::styled( + "[a]", + if set_selected { + accent_style + } else { + dim_style + }, + )); + spans.push(Span::styled( + " Accept & Set Risk", + if set_selected { + Style::default().fg(colors.warning) + } else { + dim_style + }, + )); + let line = Line::from(spans); Paragraph::new(line).render( Rect::new(content_x, y3, area.width.saturating_sub(4), 1), @@ -1167,7 +1239,8 @@ pub fn render_inline_approval(area: Rect, buf: &mut Buffer, approval: &ApprovalS fn format_args_summary(args: &serde_json::Value, max_width: usize) -> String { match args { serde_json::Value::Object(map) => { - let parts: Vec = map.iter() + let parts: Vec = map + .iter() .take(3) // Only show first 3 args .map(|(k, v)| { let v_str = match v {