11 KiB
Permission System - TUI Implementation
Overview
The TUI now has a fully functional interactive permission system that allows users to grant, deny, or permanently allow tool executions through an elegant popup interface.
Features Implemented
1. Interactive Permission Popup
Location: crates/app/ui/src/components/permission_popup.rs
-
Visual Design:
- Centered modal popup with themed border
- Tool name highlighted with icon (⚡)
- Context information (file path, command, etc.)
- Four selectable options with icons
- Keyboard shortcuts and navigation hints
-
Options Available:
- ✓
[a]Allow once - Execute this one time - ✓✓
[A]Always allow - Add permanent rule to permission manager - ✗
[d]Deny - Refuse this operation - ?
[?]Explain - Show what this operation does
- ✓
-
Navigation:
- Arrow keys (↑/↓) to select options
- Enter to confirm selection
- Keyboard shortcuts (a/A/d/?) for quick selection
- Esc to deny and close
2. Permission Flow Integration
Location: crates/app/ui/src/app.rs
New Components:
-
PendingToolCall struct:
struct PendingToolCall { tool_name: String, arguments: Value, perm_tool: PermTool, context: Option<String>, }Stores information about tool awaiting permission.
-
TuiApp fields:
pending_tool: Option<PendingToolCall>- Current pending toolpermission_tx: Option<oneshot::Sender<bool>>- Channel to signal decision
-
execute_tool_with_permission() method:
async fn execute_tool_with_permission( &mut self, tool_name: &str, arguments: &Value, ) -> Result<String>Flow:
- Maps tool name to PermTool enum (Read, Write, Edit, Bash, etc.)
- Extracts context (file path, command, etc.)
- Checks permission via PermissionManager
- If
Allow→ Execute immediately - If
Deny→ Return error - If
Ask→ Show popup and wait for user decision
Async Wait Mechanism:
- Creates oneshot channel for permission response
- Shows permission popup
- Awaits channel response (with 5-minute timeout)
- Event loop continues processing keyboard events
- When user responds, channel signals and execution resumes
3. Permission Decision Handling
Location: crates/app/ui/src/app.rs:184-254
When user makes a choice in the popup:
-
Allow Once:
- Signals permission granted (sends
truethrough channel) - Tool executes once
- No persistent changes
- Signals permission granted (sends
-
Always Allow:
- Adds new rule to PermissionManager
- Rule format:
perms.add_rule(tool, context, Action::Allow) - Example: Always allow reading from
src/directory - Signals permission granted
- All future matching operations auto-approved
-
Deny:
- Signals permission denied (sends
false) - Tool execution fails with error
- Error shown in chat
- Signals permission denied (sends
-
Explain:
- Shows explanation of what the tool does
- Popup remains open for user to choose again
- Tool-specific explanations:
read→ "read a file from disk"write→ "write or overwrite a file"edit→ "modify an existing file"bash→ "execute a shell command"grep→ "search for patterns in files"glob→ "list files matching a pattern"
4. Agent Loop Integration
Location: crates/app/ui/src/app.rs:488
Changed from:
match execute_tool(tool_name, arguments, &self.perms).await {
To:
match self.execute_tool_with_permission(tool_name, arguments).await {
This ensures all tool calls in the streaming agent loop go through the permission system.
Architecture Details
Async Concurrency Model
The implementation uses Rust's async/await with tokio to handle the permission flow without blocking the UI:
┌─────────────────────────────────────────────────────────┐
│ Event Loop │
│ (continuously running at 60 FPS) │
│ │
│ while running { │
│ terminal.draw(...) ← Always responsive │
│ if let Ok(event) = event_rx.try_recv() { │
│ handle_event(event).await │
│ } │
│ tokio::sleep(16ms).await ← Yields to runtime │
│ } │
└─────────────────────────────────────────────────────────┘
↕
┌─────────────────────────────────────────────────────────┐
│ Keyboard Event Listener │
│ (separate tokio task) │
│ │
│ loop { │
│ event = event_stream.next().await │
│ event_tx.send(Input(key)) │
│ } │
└─────────────────────────────────────────────────────────┘
↕
┌─────────────────────────────────────────────────────────┐
│ Permission Request Flow │
│ │
│ 1. Tool needs permission (PermissionDecision::Ask) │
│ 2. Create oneshot channel (tx, rx) │
│ 3. Show popup, store tx │
│ 4. await rx ← Yields to event loop │
│ 5. Event loop continues, handles keyboard │
│ 6. User presses 'a' → handle_event processes │
│ 7. tx.send(true) signals channel │
│ 8. rx.await completes, returns true │
│ 9. Tool executes with permission │
└─────────────────────────────────────────────────────────┘
Key Insight
The implementation works because:
- Awaiting is non-blocking: When we
await rx, we yield control to the tokio runtime - Event loop continues: The outer event loop continues to run its iterations
- Keyboard events processed: The separate event listener task continues reading keyboard
- Channel signals resume: When user responds, the channel completes and we resume
This creates a smooth UX where the UI remains responsive while waiting for permission.
Usage Examples
Example 1: First-time File Write
User: "Create a new file hello.txt with 'Hello World'"
Agent: [Calls write tool]
┌───────────────────────────────────────┐
│ 🔒 Permission Required │
├───────────────────────────────────────┤
│ ⚡ Tool: write │
│ 📝 Context: │
│ hello.txt │
├───────────────────────────────────────┤
│ ▶ ✓ [a] Allow once │
│ ✓✓ [A] Always allow │
│ ✗ [d] Deny │
│ ? [?] Explain │
│ │
│ ↑↓ Navigate Enter to select Esc... │
└───────────────────────────────────────┘
User presses 'a' → File created once
Example 2: Always Allow Bash in Current Directory
User: "Run npm test"
Agent: [Calls bash tool]
[Permission popup shows with context: "npm test"]
User presses 'A' → Rule added: bash("npm test*") → Allow
Future: User: "Run npm test:unit"
Agent: [Executes immediately, no popup]
Example 3: Explanation Request
User: "Read my secrets.env file"
[Permission popup appears]
User presses '?' →
System: "Tool 'read' requires permission. This operation
will read a file from disk."
[Popup remains open]
User presses 'd' → Permission denied
Testing
Build status: ✅ All tests pass
cargo build --workspace # Success
cargo test --workspace --lib # 28 tests passed
Configuration
The permission system respects three modes from PermissionManager:
-
Plan Mode (default):
- Read operations (read, grep, glob) → Auto-allowed
- Write operations (write, edit) → Ask
- System operations (bash) → Ask
-
AcceptEdits Mode:
- Read operations → Auto-allowed
- Write operations → Auto-allowed
- System operations (bash) → Ask
-
Code Mode:
- All operations → Auto-allowed
- No popups shown
User can override mode with CLI flag: --mode code
Future Enhancements
Potential improvements:
-
Permission History:
- Show recently granted/denied permissions
/permissionscommand to view active rules
-
Temporary Rules:
- "Allow for this session" option
- Rules expire when TUI closes
-
Pattern-based Rules:
- "Always allow reading from
src/directory" - "Always allow bash commands starting with
npm"
- "Always allow reading from
-
Visual Feedback:
- Show indicator when permission auto-granted by rule
- Different styling for policy-denied vs user-denied
-
Rule Management:
/clear-rulescommand- Edit/remove specific rules interactively
Files Modified
crates/app/ui/src/app.rs- Main permission flow logiccrates/app/ui/src/events.rs- Removed unused event typecrates/app/ui/src/components/permission_popup.rs- Pre-existing, now fully integrated
Summary
The TUI permission system is now fully functional, providing:
- ✅ Interactive permission popups with keyboard navigation
- ✅ Four permission options (allow once, always, deny, explain)
- ✅ Runtime permission rule updates
- ✅ Async flow that keeps UI responsive
- ✅ Integration with existing permission manager
- ✅ Tool-specific context and explanations
- ✅ Timeout handling (5 minutes)
- ✅ All tests passing
Users can now safely interact with the AI agent while maintaining control over potentially dangerous operations.