Skip to content

Commit c2d5ae0

Browse files
committed
feat(tui): inline tool approval UI in input zone
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 │ ╰─────────────────────────────────────────────────────────╯
1 parent ac6398e commit c2d5ae0

File tree

8 files changed

+509
-6
lines changed

8 files changed

+509
-6
lines changed

src/cortex-tui/src/app/approval.rs

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,63 @@
11
use super::types::ApprovalMode;
22

3+
/// State for inline approval selection in input zone
4+
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
5+
pub enum InlineApprovalSelection {
6+
#[default]
7+
AcceptOnce, // 'y' - accept this time
8+
Reject, // 'n' - reject
9+
AcceptAndSet, // 'a' - accept and set risk level
10+
}
11+
12+
impl InlineApprovalSelection {
13+
/// Move selection to the next item
14+
pub fn next(self) -> Self {
15+
match self {
16+
Self::Reject => Self::AcceptOnce,
17+
Self::AcceptOnce => Self::AcceptAndSet,
18+
Self::AcceptAndSet => Self::Reject,
19+
}
20+
}
21+
22+
/// Move selection to the previous item
23+
pub fn prev(self) -> Self {
24+
match self {
25+
Self::Reject => Self::AcceptAndSet,
26+
Self::AcceptOnce => Self::Reject,
27+
Self::AcceptAndSet => Self::AcceptOnce,
28+
}
29+
}
30+
}
31+
32+
/// State for risk level submenu
33+
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
34+
pub enum RiskLevelSelection {
35+
#[default]
36+
Low,
37+
Medium,
38+
High,
39+
}
40+
41+
impl RiskLevelSelection {
42+
/// Move selection to the next item
43+
pub fn next(self) -> Self {
44+
match self {
45+
Self::Low => Self::Medium,
46+
Self::Medium => Self::High,
47+
Self::High => Self::Low,
48+
}
49+
}
50+
51+
/// Move selection to the previous item
52+
pub fn prev(self) -> Self {
53+
match self {
54+
Self::Low => Self::High,
55+
Self::Medium => Self::Low,
56+
Self::High => Self::Medium,
57+
}
58+
}
59+
}
60+
361
/// State for pending tool approval
462
#[derive(Debug, Clone, Default)]
563
pub struct ApprovalState {
@@ -11,6 +69,12 @@ pub struct ApprovalState {
1169
pub tool_args_json: Option<serde_json::Value>,
1270
pub diff_preview: Option<String>,
1371
pub approval_mode: ApprovalMode,
72+
/// Currently selected action in inline approval UI
73+
pub selected_action: InlineApprovalSelection,
74+
/// Whether the risk level submenu is visible
75+
pub show_risk_submenu: bool,
76+
/// Selected risk level in submenu
77+
pub selected_risk_level: RiskLevelSelection,
1478
}
1579

1680
impl ApprovalState {
@@ -23,6 +87,9 @@ impl ApprovalState {
2387
tool_args_json: Some(tool_args),
2488
diff_preview: None,
2589
approval_mode: ApprovalMode::default(),
90+
selected_action: InlineApprovalSelection::default(),
91+
show_risk_submenu: false,
92+
selected_risk_level: RiskLevelSelection::default(),
2693
}
2794
}
2895

src/cortex-tui/src/app/methods.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ impl AppState {
3232
tool_args_json,
3333
diff_preview,
3434
approval_mode: ApprovalMode::Ask,
35+
selected_action: Default::default(),
36+
show_risk_submenu: false,
37+
selected_risk_level: Default::default(),
3538
});
3639
self.set_view(AppView::Approval);
3740
}
@@ -51,6 +54,9 @@ impl AppState {
5154
tool_args_json: Some(tool_args),
5255
diff_preview,
5356
approval_mode: ApprovalMode::Ask,
57+
selected_action: Default::default(),
58+
show_risk_submenu: false,
59+
selected_risk_level: Default::default(),
5460
});
5561
self.set_view(AppView::Approval);
5662

src/cortex-tui/src/app/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ mod subagent;
1212
mod types;
1313

1414
// Re-export all public types
15-
pub use approval::{ApprovalState, PendingToolResult};
15+
pub use approval::{ApprovalState, InlineApprovalSelection, PendingToolResult, RiskLevelSelection};
1616
pub use autocomplete::{AutocompleteItem, AutocompleteState};
1717
pub use session::{ActiveModal, SessionSummary};
1818
pub use state::AppState;

src/cortex-tui/src/bridge/event_adapter/approval.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ pub fn create_approval_state(request: &ExecApprovalRequestEvent) -> ApprovalStat
4949
tool_args_json: Some(tool_args),
5050
diff_preview,
5151
approval_mode: ApprovalMode::Ask,
52+
selected_action: Default::default(),
53+
show_risk_submenu: false,
54+
selected_risk_level: Default::default(),
5255
}
5356
}
5457

@@ -81,6 +84,9 @@ pub fn create_patch_approval_state(request: &ApplyPatchApprovalRequestEvent) ->
8184
tool_args_json: Some(tool_args),
8285
diff_preview: Some(request.patch.clone()),
8386
approval_mode: ApprovalMode::Ask,
87+
selected_action: Default::default(),
88+
show_risk_submenu: false,
89+
selected_risk_level: Default::default(),
8490
}
8591
}
8692

src/cortex-tui/src/runner/event_loop/actions.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ impl EventLoop {
170170
}
171171

172172
/// Handle approve action
173-
async fn handle_approve(&mut self) -> Result<()> {
173+
pub(super) async fn handle_approve(&mut self) -> Result<()> {
174174
use crate::views::tool_call::ToolStatus;
175175

176176
if let Some(approval) = self.app_state.approve() {
@@ -216,7 +216,7 @@ impl EventLoop {
216216
}
217217

218218
/// Handle reject action
219-
async fn handle_reject(&mut self) -> Result<()> {
219+
pub(super) async fn handle_reject(&mut self) -> Result<()> {
220220
if let Some(approval) = self.app_state.reject() {
221221
// Update tool status to failed
222222
self.app_state.update_tool_result(

src/cortex-tui/src/runner/event_loop/input.rs

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,13 @@ impl EventLoop {
223223
return Ok(());
224224
}
225225

226+
// Handle inline approval UI when pending approval exists
227+
if self.app_state.pending_approval.is_some() {
228+
if self.handle_inline_approval_key(key_event, terminal).await? {
229+
return Ok(());
230+
}
231+
}
232+
226233
// Check if a card is active and handle its input first
227234
if self.card_handler.is_active() && self.card_handler.handle_key(key_event) {
228235
// Process any pending card actions
@@ -502,6 +509,183 @@ impl EventLoop {
502509
}
503510
}
504511

512+
/// Handle inline approval UI key events.
513+
/// Returns true if the key was handled (approval action taken), false otherwise.
514+
async fn handle_inline_approval_key(
515+
&mut self,
516+
key_event: crossterm::event::KeyEvent,
517+
terminal: &mut CortexTerminal,
518+
) -> Result<bool> {
519+
use crossterm::event::KeyCode;
520+
use crate::app::{InlineApprovalSelection, RiskLevelSelection};
521+
522+
// Check if risk level submenu is visible
523+
let show_submenu = self.app_state.pending_approval
524+
.as_ref()
525+
.map(|a| a.show_risk_submenu)
526+
.unwrap_or(false);
527+
528+
if show_submenu {
529+
// Handle risk level submenu keys
530+
match key_event.code {
531+
KeyCode::Char('1') => {
532+
// Select Low risk level and approve
533+
self.handle_approve_with_risk_level(RiskLevelSelection::Low).await?;
534+
self.render(terminal)?;
535+
return Ok(true);
536+
}
537+
KeyCode::Char('2') => {
538+
// Select Medium risk level and approve
539+
self.handle_approve_with_risk_level(RiskLevelSelection::Medium).await?;
540+
self.render(terminal)?;
541+
return Ok(true);
542+
}
543+
KeyCode::Char('3') => {
544+
// Select High risk level and approve
545+
self.handle_approve_with_risk_level(RiskLevelSelection::High).await?;
546+
self.render(terminal)?;
547+
return Ok(true);
548+
}
549+
KeyCode::Esc => {
550+
// Close submenu, back to main approval UI
551+
if let Some(ref mut approval) = self.app_state.pending_approval {
552+
approval.show_risk_submenu = false;
553+
}
554+
self.render(terminal)?;
555+
return Ok(true);
556+
}
557+
KeyCode::Left => {
558+
// Navigate risk level selection left
559+
if let Some(ref mut approval) = self.app_state.pending_approval {
560+
approval.selected_risk_level = approval.selected_risk_level.prev();
561+
}
562+
self.render(terminal)?;
563+
return Ok(true);
564+
}
565+
KeyCode::Right => {
566+
// Navigate risk level selection right
567+
if let Some(ref mut approval) = self.app_state.pending_approval {
568+
approval.selected_risk_level = approval.selected_risk_level.next();
569+
}
570+
self.render(terminal)?;
571+
return Ok(true);
572+
}
573+
KeyCode::Enter => {
574+
// Confirm selected risk level
575+
let risk_level = self.app_state.pending_approval
576+
.as_ref()
577+
.map(|a| a.selected_risk_level)
578+
.unwrap_or_default();
579+
self.handle_approve_with_risk_level(risk_level).await?;
580+
self.render(terminal)?;
581+
return Ok(true);
582+
}
583+
_ => {
584+
// Consume other keys when submenu is visible
585+
return Ok(true);
586+
}
587+
}
588+
}
589+
590+
// Handle main approval UI keys
591+
match key_event.code {
592+
KeyCode::Char('y') | KeyCode::Char('Y') => {
593+
// Accept once
594+
self.handle_approve().await?;
595+
self.render(terminal)?;
596+
Ok(true)
597+
}
598+
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
599+
// Reject
600+
self.handle_reject().await?;
601+
self.render(terminal)?;
602+
Ok(true)
603+
}
604+
KeyCode::Char('a') | KeyCode::Char('A') => {
605+
// Show risk level submenu
606+
if let Some(ref mut approval) = self.app_state.pending_approval {
607+
approval.show_risk_submenu = true;
608+
approval.selected_risk_level = RiskLevelSelection::default();
609+
}
610+
self.render(terminal)?;
611+
Ok(true)
612+
}
613+
KeyCode::Left => {
614+
// Navigate selection left
615+
if let Some(ref mut approval) = self.app_state.pending_approval {
616+
approval.selected_action = approval.selected_action.prev();
617+
}
618+
self.render(terminal)?;
619+
Ok(true)
620+
}
621+
KeyCode::Right => {
622+
// Navigate selection right
623+
if let Some(ref mut approval) = self.app_state.pending_approval {
624+
approval.selected_action = approval.selected_action.next();
625+
}
626+
self.render(terminal)?;
627+
Ok(true)
628+
}
629+
KeyCode::Enter => {
630+
// Confirm selected action
631+
let action = self.app_state.pending_approval
632+
.as_ref()
633+
.map(|a| a.selected_action)
634+
.unwrap_or_default();
635+
match action {
636+
InlineApprovalSelection::AcceptOnce => {
637+
self.handle_approve().await?;
638+
}
639+
InlineApprovalSelection::Reject => {
640+
self.handle_reject().await?;
641+
}
642+
InlineApprovalSelection::AcceptAndSet => {
643+
// Show risk level submenu
644+
if let Some(ref mut approval) = self.app_state.pending_approval {
645+
approval.show_risk_submenu = true;
646+
approval.selected_risk_level = RiskLevelSelection::default();
647+
}
648+
}
649+
}
650+
self.render(terminal)?;
651+
Ok(true)
652+
}
653+
_ => {
654+
// Don't consume other keys - allow them to pass through
655+
// This allows things like Ctrl+C to work
656+
Ok(false)
657+
}
658+
}
659+
}
660+
661+
/// Handle approval with risk level - approves the tool and updates permission mode
662+
async fn handle_approve_with_risk_level(
663+
&mut self,
664+
risk_level: crate::app::RiskLevelSelection,
665+
) -> Result<()> {
666+
use crate::app::RiskLevelSelection;
667+
use crate::permissions::PermissionMode;
668+
669+
// Update permission mode based on selected risk level
670+
self.app_state.permission_mode = match risk_level {
671+
RiskLevelSelection::Low => PermissionMode::Low,
672+
RiskLevelSelection::Medium => PermissionMode::Medium,
673+
RiskLevelSelection::High => PermissionMode::High,
674+
};
675+
676+
// Sync permission mode with the manager
677+
self.sync_permission_mode();
678+
679+
// Show toast notification about the mode change
680+
let mode_name = self.app_state.permission_mode.display_name();
681+
self.app_state.toasts.info(&format!("Risk level set to: {}", mode_name));
682+
683+
// Now approve the tool
684+
self.handle_approve().await?;
685+
686+
Ok(())
687+
}
688+
505689
/// Handle terminal resize event
506690
fn handle_resize(
507691
&mut self,

0 commit comments

Comments
 (0)