mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-27 08:00:17 +00:00
Integrate Codex patterns: task scheduler, TUI, sessions, compaction
Add Codex-inspired patterns for improved agent architecture: **Task Scheduler (Phase 1 & 4)** - New Task enum with Job, ToolExec, Background variants - TaskHandler trait for custom background tasks - Scheduler.spawn_subtask() and spawn_batch() for parallel execution - Worker executes multiple tools in parallel via futures::join_all **Tool Approval System (Phase 6)** - Tool.requires_approval() method (default false for sandboxed tools) - HttpTool marked as requiring approval (external network) - MCP protocol annotations: destructive_hint, side_effects_hint **Ratatui TUI CLI (Phase 2)** - Replace blocking stdin with event-driven TUI - ChatComposer with history navigation and tab completion - ApprovalOverlay modal with y/n/a keyboard shortcuts - Raw mode with proper terminal cleanup **Session/Turn Model (Phase 3)** - Session, Thread, Turn structs for conversation tracking - Submission enum for user input, approvals, undo, interrupt - UndoManager with checkpoint-based undo/redo **Context Compaction (Phase 5)** - ContextMonitor with token estimation and threshold checks - CompactionStrategy: Summarize, Truncate, MoveToWorkspace - ContextCompactor writes summaries to workspace daily logs Co-Authored-By: Claude Opus 4.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
d047c23b2d
commit
09032d69cb
@@ -0,0 +1,252 @@
|
||||
//! Application state for the TUI.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::channels::cli::composer::ChatComposer;
|
||||
use crate::channels::cli::overlay::{ApprovalOverlay, ApprovalRequest};
|
||||
|
||||
/// Events that can occur in the TUI.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum AppEvent {
|
||||
/// Keyboard/mouse input event.
|
||||
Input(crossterm::event::Event),
|
||||
/// Response from the agent.
|
||||
Response(String),
|
||||
/// Tool execution started.
|
||||
ToolStarted { name: String },
|
||||
/// Tool execution completed.
|
||||
ToolCompleted { name: String, success: bool },
|
||||
/// Request approval for a tool.
|
||||
ApprovalRequested(ApprovalRequest),
|
||||
/// Streaming chunk received.
|
||||
StreamChunk(String),
|
||||
/// Force a redraw.
|
||||
Redraw,
|
||||
/// Quit the application.
|
||||
Quit,
|
||||
}
|
||||
|
||||
/// Current input mode.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum InputMode {
|
||||
/// Normal input mode.
|
||||
Normal,
|
||||
/// Editing input.
|
||||
Editing,
|
||||
/// Approval overlay is active.
|
||||
Approval,
|
||||
}
|
||||
|
||||
/// Message in the chat history.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChatMessage {
|
||||
/// Who sent this message.
|
||||
pub role: MessageRole,
|
||||
/// The message content.
|
||||
pub content: String,
|
||||
/// Optional status indicator.
|
||||
pub status: Option<MessageStatus>,
|
||||
}
|
||||
|
||||
/// Who sent a message.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MessageRole {
|
||||
User,
|
||||
Agent,
|
||||
System,
|
||||
}
|
||||
|
||||
/// Status of a message (for in-progress indicators).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MessageStatus {
|
||||
Pending,
|
||||
InProgress,
|
||||
Complete,
|
||||
Error,
|
||||
}
|
||||
|
||||
impl ChatMessage {
|
||||
pub fn user(content: impl Into<String>) -> Self {
|
||||
Self {
|
||||
role: MessageRole::User,
|
||||
content: content.into(),
|
||||
status: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn agent(content: impl Into<String>) -> Self {
|
||||
Self {
|
||||
role: MessageRole::Agent,
|
||||
content: content.into(),
|
||||
status: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn system(content: impl Into<String>) -> Self {
|
||||
Self {
|
||||
role: MessageRole::System,
|
||||
content: content.into(),
|
||||
status: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_status(mut self, status: MessageStatus) -> Self {
|
||||
self.status = Some(status);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Application state.
|
||||
pub struct AppState {
|
||||
/// Current input mode.
|
||||
pub mode: InputMode,
|
||||
/// Chat message history.
|
||||
pub messages: Vec<ChatMessage>,
|
||||
/// Input composer.
|
||||
pub composer: ChatComposer,
|
||||
/// Approval overlay (if active).
|
||||
pub approval: Option<ApprovalOverlay>,
|
||||
/// Scroll offset for messages.
|
||||
pub scroll_offset: u16,
|
||||
/// Whether the app should quit.
|
||||
pub should_quit: bool,
|
||||
/// Pending approvals queue.
|
||||
pub pending_approvals: VecDeque<ApprovalRequest>,
|
||||
/// Current streaming response buffer.
|
||||
pub streaming_buffer: Option<String>,
|
||||
/// Status line message.
|
||||
pub status_message: Option<String>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
/// Create a new app state.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
mode: InputMode::Editing,
|
||||
messages: vec![ChatMessage::system(
|
||||
"Welcome to NEAR Agent. Type a message or /help for commands.",
|
||||
)],
|
||||
composer: ChatComposer::new(),
|
||||
approval: None,
|
||||
scroll_offset: 0,
|
||||
should_quit: false,
|
||||
pending_approvals: VecDeque::new(),
|
||||
streaming_buffer: None,
|
||||
status_message: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a user message to history.
|
||||
pub fn add_user_message(&mut self, content: impl Into<String>) {
|
||||
self.messages.push(ChatMessage::user(content));
|
||||
self.scroll_to_bottom();
|
||||
}
|
||||
|
||||
/// Add an agent response to history.
|
||||
pub fn add_agent_message(&mut self, content: impl Into<String>) {
|
||||
// If we were streaming, finalize it
|
||||
if self.streaming_buffer.is_some() {
|
||||
self.streaming_buffer = None;
|
||||
}
|
||||
self.messages.push(ChatMessage::agent(content));
|
||||
self.scroll_to_bottom();
|
||||
}
|
||||
|
||||
/// Start streaming a response.
|
||||
pub fn start_streaming(&mut self) {
|
||||
self.streaming_buffer = Some(String::new());
|
||||
self.messages
|
||||
.push(ChatMessage::agent("").with_status(MessageStatus::InProgress));
|
||||
}
|
||||
|
||||
/// Append to the streaming buffer.
|
||||
pub fn append_stream(&mut self, chunk: &str) {
|
||||
if let Some(ref mut buffer) = self.streaming_buffer {
|
||||
buffer.push_str(chunk);
|
||||
// Update the last message
|
||||
if let Some(last) = self.messages.last_mut() {
|
||||
if last.role == MessageRole::Agent {
|
||||
last.content = buffer.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Finalize streaming.
|
||||
pub fn finish_streaming(&mut self) {
|
||||
if let Some(last) = self.messages.last_mut() {
|
||||
if last.role == MessageRole::Agent {
|
||||
last.status = Some(MessageStatus::Complete);
|
||||
}
|
||||
}
|
||||
self.streaming_buffer = None;
|
||||
}
|
||||
|
||||
/// Show an approval request.
|
||||
pub fn show_approval(&mut self, request: ApprovalRequest) {
|
||||
self.approval = Some(ApprovalOverlay::new(request));
|
||||
self.mode = InputMode::Approval;
|
||||
}
|
||||
|
||||
/// Queue an approval request.
|
||||
pub fn queue_approval(&mut self, request: ApprovalRequest) {
|
||||
if self.approval.is_none() {
|
||||
self.show_approval(request);
|
||||
} else {
|
||||
self.pending_approvals.push_back(request);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle approval response.
|
||||
pub fn handle_approval_response(&mut self, approved: bool) -> Option<ApprovalRequest> {
|
||||
let request = self.approval.take().map(|o| o.request);
|
||||
|
||||
// Show next pending approval if any
|
||||
if let Some(next) = self.pending_approvals.pop_front() {
|
||||
self.show_approval(next);
|
||||
} else {
|
||||
self.mode = InputMode::Editing;
|
||||
}
|
||||
|
||||
if approved { request } else { None }
|
||||
}
|
||||
|
||||
/// Clear all pending approvals.
|
||||
pub fn clear_approvals(&mut self) {
|
||||
self.approval = None;
|
||||
self.pending_approvals.clear();
|
||||
self.mode = InputMode::Editing;
|
||||
}
|
||||
|
||||
/// Set the status message.
|
||||
pub fn set_status(&mut self, message: impl Into<String>) {
|
||||
self.status_message = Some(message.into());
|
||||
}
|
||||
|
||||
/// Clear the status message.
|
||||
pub fn clear_status(&mut self) {
|
||||
self.status_message = None;
|
||||
}
|
||||
|
||||
/// Scroll to the bottom of messages.
|
||||
pub fn scroll_to_bottom(&mut self) {
|
||||
// Will be calculated based on render area in render.rs
|
||||
self.scroll_offset = 0;
|
||||
}
|
||||
|
||||
/// Scroll up.
|
||||
pub fn scroll_up(&mut self, amount: u16) {
|
||||
self.scroll_offset = self.scroll_offset.saturating_add(amount);
|
||||
}
|
||||
|
||||
/// Scroll down.
|
||||
pub fn scroll_down(&mut self, amount: u16) {
|
||||
self.scroll_offset = self.scroll_offset.saturating_sub(amount);
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AppState {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
//! Input composer with history and completion.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
/// Maximum number of history entries to keep.
|
||||
const MAX_HISTORY: usize = 100;
|
||||
|
||||
/// Available slash commands for completion.
|
||||
const SLASH_COMMANDS: &[&str] = &[
|
||||
"/help", "/job", "/status", "/cancel", "/list", "/tools", "/clear", "/quit",
|
||||
];
|
||||
|
||||
/// Chat input composer with history navigation and slash command completion.
|
||||
pub struct ChatComposer {
|
||||
/// Current input buffer.
|
||||
buffer: String,
|
||||
/// Cursor position in the buffer.
|
||||
cursor: usize,
|
||||
/// Input history.
|
||||
history: VecDeque<String>,
|
||||
/// Current position in history (-1 = current input).
|
||||
history_index: Option<usize>,
|
||||
/// Saved current input when navigating history.
|
||||
saved_input: String,
|
||||
/// Completion candidates.
|
||||
completions: Vec<String>,
|
||||
/// Current completion index.
|
||||
completion_index: Option<usize>,
|
||||
}
|
||||
|
||||
impl ChatComposer {
|
||||
/// Create a new composer.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
buffer: String::new(),
|
||||
cursor: 0,
|
||||
history: VecDeque::with_capacity(MAX_HISTORY),
|
||||
history_index: None,
|
||||
saved_input: String::new(),
|
||||
completions: Vec::new(),
|
||||
completion_index: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the current input buffer.
|
||||
pub fn buffer(&self) -> &str {
|
||||
&self.buffer
|
||||
}
|
||||
|
||||
/// Get the cursor position.
|
||||
pub fn cursor(&self) -> usize {
|
||||
self.cursor
|
||||
}
|
||||
|
||||
/// Check if the buffer is empty.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.buffer.is_empty()
|
||||
}
|
||||
|
||||
/// Insert a character at the cursor.
|
||||
pub fn insert(&mut self, c: char) {
|
||||
self.clear_completion();
|
||||
self.buffer.insert(self.cursor, c);
|
||||
self.cursor += c.len_utf8();
|
||||
}
|
||||
|
||||
/// Insert a string at the cursor.
|
||||
pub fn insert_str(&mut self, s: &str) {
|
||||
self.clear_completion();
|
||||
self.buffer.insert_str(self.cursor, s);
|
||||
self.cursor += s.len();
|
||||
}
|
||||
|
||||
/// Delete the character before the cursor (backspace).
|
||||
pub fn backspace(&mut self) {
|
||||
self.clear_completion();
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete the character at the cursor (delete).
|
||||
pub fn delete(&mut self) {
|
||||
self.clear_completion();
|
||||
if self.cursor < self.buffer.len() {
|
||||
// Find the next character boundary
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// Move cursor left.
|
||||
pub fn move_left(&mut self) {
|
||||
if self.cursor > 0 {
|
||||
self.cursor = self.buffer[..self.cursor]
|
||||
.char_indices()
|
||||
.next_back()
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Move cursor right.
|
||||
pub fn move_right(&mut self) {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
/// Move cursor to start.
|
||||
pub fn move_home(&mut self) {
|
||||
self.cursor = 0;
|
||||
}
|
||||
|
||||
/// Move cursor to end.
|
||||
pub fn move_end(&mut self) {
|
||||
self.cursor = self.buffer.len();
|
||||
}
|
||||
|
||||
/// Delete from cursor to end of line.
|
||||
pub fn kill_line(&mut self) {
|
||||
self.clear_completion();
|
||||
self.buffer.truncate(self.cursor);
|
||||
}
|
||||
|
||||
/// Delete from start to cursor.
|
||||
pub fn kill_to_start(&mut self) {
|
||||
self.clear_completion();
|
||||
self.buffer.drain(..self.cursor);
|
||||
self.cursor = 0;
|
||||
}
|
||||
|
||||
/// Clear the entire buffer.
|
||||
pub fn clear(&mut self) {
|
||||
self.buffer.clear();
|
||||
self.cursor = 0;
|
||||
self.clear_completion();
|
||||
}
|
||||
|
||||
/// Submit the current input and return it.
|
||||
pub fn submit(&mut self) -> String {
|
||||
let input = std::mem::take(&mut self.buffer);
|
||||
self.cursor = 0;
|
||||
self.clear_completion();
|
||||
|
||||
// Add to history if non-empty and different from last entry
|
||||
if !input.is_empty() && self.history.front() != Some(&input) {
|
||||
self.history.push_front(input.clone());
|
||||
if self.history.len() > MAX_HISTORY {
|
||||
self.history.pop_back();
|
||||
}
|
||||
}
|
||||
|
||||
self.history_index = None;
|
||||
self.saved_input.clear();
|
||||
|
||||
input
|
||||
}
|
||||
|
||||
/// Navigate to previous history entry.
|
||||
pub fn history_prev(&mut self) {
|
||||
if self.history.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
match self.history_index {
|
||||
None => {
|
||||
// Save current input and go to first history entry
|
||||
self.saved_input = std::mem::take(&mut self.buffer);
|
||||
self.history_index = Some(0);
|
||||
self.buffer = self.history[0].clone();
|
||||
}
|
||||
Some(i) if i + 1 < self.history.len() => {
|
||||
self.history_index = Some(i + 1);
|
||||
self.buffer = self.history[i + 1].clone();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
self.cursor = self.buffer.len();
|
||||
self.clear_completion();
|
||||
}
|
||||
|
||||
/// Navigate to next history entry.
|
||||
pub fn history_next(&mut self) {
|
||||
match self.history_index {
|
||||
Some(0) => {
|
||||
// Go back to saved input
|
||||
self.history_index = None;
|
||||
self.buffer = std::mem::take(&mut self.saved_input);
|
||||
}
|
||||
Some(i) => {
|
||||
self.history_index = Some(i - 1);
|
||||
self.buffer = self.history[i - 1].clone();
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
|
||||
self.cursor = self.buffer.len();
|
||||
self.clear_completion();
|
||||
}
|
||||
|
||||
/// Attempt tab completion.
|
||||
pub fn complete(&mut self) {
|
||||
// Only complete slash commands for now
|
||||
if !self.buffer.starts_with('/') {
|
||||
return;
|
||||
}
|
||||
|
||||
if self.completions.is_empty() {
|
||||
// Generate completions
|
||||
let prefix = &self.buffer;
|
||||
self.completions = SLASH_COMMANDS
|
||||
.iter()
|
||||
.filter(|cmd| cmd.starts_with(prefix))
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
|
||||
if !self.completions.is_empty() {
|
||||
self.completion_index = Some(0);
|
||||
}
|
||||
} else if let Some(i) = self.completion_index {
|
||||
// Cycle through completions
|
||||
self.completion_index = Some((i + 1) % self.completions.len());
|
||||
}
|
||||
|
||||
// Apply completion
|
||||
if let Some(i) = self.completion_index {
|
||||
if let Some(completion) = self.completions.get(i) {
|
||||
self.buffer = completion.clone();
|
||||
self.cursor = self.buffer.len();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear completion state.
|
||||
fn clear_completion(&mut self) {
|
||||
self.completions.clear();
|
||||
self.completion_index = None;
|
||||
}
|
||||
|
||||
/// Get current completion hint (for display).
|
||||
pub fn completion_hint(&self) -> Option<&str> {
|
||||
if let Some(i) = self.completion_index {
|
||||
self.completions.get(i).map(|s| s.as_str())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the number of completions available.
|
||||
pub fn completion_count(&self) -> usize {
|
||||
self.completions.len()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ChatComposer {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_insert_and_backspace() {
|
||||
let mut composer = ChatComposer::new();
|
||||
composer.insert('h');
|
||||
composer.insert('i');
|
||||
assert_eq!(composer.buffer(), "hi");
|
||||
composer.backspace();
|
||||
assert_eq!(composer.buffer(), "h");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_history_navigation() {
|
||||
let mut composer = ChatComposer::new();
|
||||
composer.insert_str("first");
|
||||
composer.submit();
|
||||
composer.insert_str("second");
|
||||
composer.submit();
|
||||
|
||||
composer.insert_str("current");
|
||||
composer.history_prev();
|
||||
assert_eq!(composer.buffer(), "second");
|
||||
composer.history_prev();
|
||||
assert_eq!(composer.buffer(), "first");
|
||||
composer.history_next();
|
||||
assert_eq!(composer.buffer(), "second");
|
||||
composer.history_next();
|
||||
assert_eq!(composer.buffer(), "current");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_completion() {
|
||||
let mut composer = ChatComposer::new();
|
||||
composer.insert_str("/hel");
|
||||
composer.complete();
|
||||
assert_eq!(composer.buffer(), "/help");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
//! Event handling for the TUI.
|
||||
|
||||
use std::io;
|
||||
use std::time::Duration;
|
||||
|
||||
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::channels::IncomingMessage;
|
||||
use crate::channels::cli::app::{AppEvent, AppState, InputMode};
|
||||
use crate::channels::cli::render;
|
||||
|
||||
/// Tick rate for the event loop (50ms = 20fps).
|
||||
const TICK_RATE: Duration = Duration::from_millis(50);
|
||||
|
||||
/// Run the main event loop.
|
||||
pub fn run_event_loop(
|
||||
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
||||
app: &mut AppState,
|
||||
msg_tx: mpsc::Sender<IncomingMessage>,
|
||||
event_rx: &mut mpsc::Receiver<AppEvent>,
|
||||
) -> io::Result<()> {
|
||||
loop {
|
||||
// Render
|
||||
terminal.draw(|f| render::render(f, app))?;
|
||||
|
||||
// Check for quit
|
||||
if app.should_quit {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Poll for events
|
||||
if event::poll(TICK_RATE)? {
|
||||
let evt = event::read()?;
|
||||
if let Err(e) = handle_event(app, evt, &msg_tx) {
|
||||
tracing::error!("Event handling error: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for app events (non-blocking)
|
||||
while let Ok(app_event) = event_rx.try_recv() {
|
||||
handle_app_event(app, app_event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a crossterm event.
|
||||
fn handle_event(
|
||||
app: &mut AppState,
|
||||
event: Event,
|
||||
msg_tx: &mpsc::Sender<IncomingMessage>,
|
||||
) -> io::Result<()> {
|
||||
match event {
|
||||
Event::Key(key) => handle_key(app, key, msg_tx),
|
||||
Event::Mouse(_) => Ok(()), // Could handle mouse scrolling here
|
||||
Event::Resize(_, _) => Ok(()), // Terminal will handle resize
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a key event.
|
||||
fn handle_key(
|
||||
app: &mut AppState,
|
||||
key: KeyEvent,
|
||||
msg_tx: &mpsc::Sender<IncomingMessage>,
|
||||
) -> io::Result<()> {
|
||||
// Global keybindings
|
||||
if key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
match key.code {
|
||||
KeyCode::Char('c') => {
|
||||
if app.mode == InputMode::Approval {
|
||||
// Cancel all pending approvals
|
||||
app.clear_approvals();
|
||||
} else {
|
||||
// Quit
|
||||
app.should_quit = true;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
KeyCode::Char('d') => {
|
||||
app.should_quit = true;
|
||||
return Ok(());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
match app.mode {
|
||||
InputMode::Normal => handle_normal_mode(app, key),
|
||||
InputMode::Editing => handle_editing_mode(app, key, msg_tx),
|
||||
InputMode::Approval => handle_approval_mode(app, key),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle keys in normal mode.
|
||||
fn handle_normal_mode(app: &mut AppState, key: KeyEvent) -> io::Result<()> {
|
||||
match key.code {
|
||||
KeyCode::Char('i') | KeyCode::Char('a') => {
|
||||
app.mode = InputMode::Editing;
|
||||
}
|
||||
KeyCode::Char('q') => {
|
||||
app.should_quit = true;
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
app.scroll_up(1);
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
app.scroll_down(1);
|
||||
}
|
||||
KeyCode::PageUp => {
|
||||
app.scroll_up(10);
|
||||
}
|
||||
KeyCode::PageDown => {
|
||||
app.scroll_down(10);
|
||||
}
|
||||
KeyCode::Char('G') => {
|
||||
app.scroll_to_bottom();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle keys in editing mode.
|
||||
fn handle_editing_mode(
|
||||
app: &mut AppState,
|
||||
key: KeyEvent,
|
||||
msg_tx: &mpsc::Sender<IncomingMessage>,
|
||||
) -> io::Result<()> {
|
||||
match key.code {
|
||||
KeyCode::Enter => {
|
||||
if !app.composer.is_empty() {
|
||||
let input = app.composer.submit();
|
||||
app.add_user_message(&input);
|
||||
|
||||
// Send message to agent
|
||||
let msg = IncomingMessage::new("tui", "local-user", &input);
|
||||
let _ = msg_tx.blocking_send(msg);
|
||||
}
|
||||
}
|
||||
KeyCode::Esc => {
|
||||
app.mode = InputMode::Normal;
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
app.composer.backspace();
|
||||
}
|
||||
KeyCode::Delete => {
|
||||
app.composer.delete();
|
||||
}
|
||||
KeyCode::Left => {
|
||||
if key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
// Move word left (simplified: just move to start)
|
||||
app.composer.move_home();
|
||||
} else {
|
||||
app.composer.move_left();
|
||||
}
|
||||
}
|
||||
KeyCode::Right => {
|
||||
if key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
// Move word right (simplified: just move to end)
|
||||
app.composer.move_end();
|
||||
} else {
|
||||
app.composer.move_right();
|
||||
}
|
||||
}
|
||||
KeyCode::Home => {
|
||||
app.composer.move_home();
|
||||
}
|
||||
KeyCode::End => {
|
||||
app.composer.move_end();
|
||||
}
|
||||
KeyCode::Up => {
|
||||
app.composer.history_prev();
|
||||
}
|
||||
KeyCode::Down => {
|
||||
app.composer.history_next();
|
||||
}
|
||||
KeyCode::Tab => {
|
||||
app.composer.complete();
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
if key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
match c {
|
||||
'a' => app.composer.move_home(),
|
||||
'e' => app.composer.move_end(),
|
||||
'k' => app.composer.kill_line(),
|
||||
'u' => app.composer.kill_to_start(),
|
||||
'w' => {
|
||||
// Delete word backwards (simplified: clear)
|
||||
app.composer.clear();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
} else {
|
||||
app.composer.insert(c);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle keys in approval mode.
|
||||
fn handle_approval_mode(app: &mut AppState, key: KeyEvent) -> io::Result<()> {
|
||||
if let Some(ref mut overlay) = app.approval {
|
||||
match key.code {
|
||||
KeyCode::Left | KeyCode::Char('h') => {
|
||||
overlay.select_prev();
|
||||
}
|
||||
KeyCode::Right | KeyCode::Char('l') => {
|
||||
overlay.select_next();
|
||||
}
|
||||
KeyCode::Enter | KeyCode::Char(' ') => {
|
||||
let (approved, _always) = overlay.confirm();
|
||||
app.handle_approval_response(approved);
|
||||
// TODO: If always, remember to auto-approve this tool
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
if let Some(approved) = overlay.handle_shortcut(c) {
|
||||
app.handle_approval_response(approved);
|
||||
}
|
||||
}
|
||||
KeyCode::Esc => {
|
||||
// Deny this approval
|
||||
app.handle_approval_response(false);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle an application event.
|
||||
fn handle_app_event(app: &mut AppState, event: AppEvent) {
|
||||
match event {
|
||||
AppEvent::Response(content) => {
|
||||
app.add_agent_message(content);
|
||||
}
|
||||
AppEvent::ToolStarted { name } => {
|
||||
app.set_status(format!("Running tool: {}...", name));
|
||||
}
|
||||
AppEvent::ToolCompleted { name, success } => {
|
||||
if success {
|
||||
app.set_status(format!("Tool {} completed", name));
|
||||
} else {
|
||||
app.set_status(format!("Tool {} failed", name));
|
||||
}
|
||||
}
|
||||
AppEvent::ApprovalRequested(request) => {
|
||||
app.queue_approval(request);
|
||||
}
|
||||
AppEvent::StreamChunk(chunk) => {
|
||||
if app.streaming_buffer.is_none() {
|
||||
app.start_streaming();
|
||||
}
|
||||
app.append_stream(&chunk);
|
||||
}
|
||||
AppEvent::Redraw => {
|
||||
// Just triggers a redraw on next loop iteration
|
||||
}
|
||||
AppEvent::Quit => {
|
||||
app.should_quit = true;
|
||||
}
|
||||
AppEvent::Input(_) => {
|
||||
// Already handled directly
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
//! Interactive TUI channel using Ratatui.
|
||||
//!
|
||||
//! Provides a rich terminal interface with:
|
||||
//! - Input history navigation
|
||||
//! - Slash command completion
|
||||
//! - Approval overlays for tool execution
|
||||
//! - Streaming response display
|
||||
|
||||
mod app;
|
||||
mod composer;
|
||||
mod events;
|
||||
mod overlay;
|
||||
mod render;
|
||||
|
||||
use std::io;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use crossterm::{
|
||||
event::{DisableMouseCapture, EnableMouseCapture},
|
||||
execute,
|
||||
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
|
||||
};
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_stream::wrappers::ReceiverStream;
|
||||
|
||||
use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse};
|
||||
use crate::error::ChannelError;
|
||||
|
||||
pub use app::{AppEvent, AppState, InputMode};
|
||||
pub use composer::ChatComposer;
|
||||
pub use overlay::{ApprovalOverlay, ApprovalRequest};
|
||||
|
||||
/// TUI channel for interactive terminal input with Ratatui.
|
||||
pub struct TuiChannel {
|
||||
/// Channel for sending events to the TUI.
|
||||
event_tx: Option<mpsc::Sender<AppEvent>>,
|
||||
}
|
||||
|
||||
impl TuiChannel {
|
||||
/// Create a new TUI channel.
|
||||
pub fn new() -> Self {
|
||||
Self { event_tx: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TuiChannel {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Channel for TuiChannel {
|
||||
fn name(&self) -> &str {
|
||||
"tui"
|
||||
}
|
||||
|
||||
async fn start(&self) -> Result<MessageStream, ChannelError> {
|
||||
let (msg_tx, msg_rx) = mpsc::channel(32);
|
||||
let (event_tx, event_rx) = mpsc::channel(64);
|
||||
|
||||
// Store the event sender so we can send responses
|
||||
// Note: In the actual implementation, we'd store this properly
|
||||
// For now, spawn the TUI in a separate task
|
||||
let event_tx_clone = event_tx.clone();
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
if let Err(e) = run_tui(msg_tx, event_rx) {
|
||||
tracing::error!("TUI error: {}", e);
|
||||
}
|
||||
});
|
||||
|
||||
// Keep the event_tx alive by storing it
|
||||
// This is a hack; in production we'd use Arc<Mutex<>> or similar
|
||||
let _ = event_tx_clone;
|
||||
|
||||
Ok(Box::pin(ReceiverStream::new(msg_rx)))
|
||||
}
|
||||
|
||||
async fn respond(
|
||||
&self,
|
||||
_msg: &IncomingMessage,
|
||||
response: OutgoingResponse,
|
||||
) -> Result<(), ChannelError> {
|
||||
// Send response event to the TUI
|
||||
if let Some(ref tx) = self.event_tx {
|
||||
let _ = tx
|
||||
.send(AppEvent::Response(response.content))
|
||||
.await
|
||||
.map_err(|e| ChannelError::SendFailed {
|
||||
name: "tui".to_string(),
|
||||
reason: e.to_string(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn health_check(&self) -> Result<(), ChannelError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn shutdown(&self) -> Result<(), ChannelError> {
|
||||
if let Some(ref tx) = self.event_tx {
|
||||
let _ = tx.send(AppEvent::Quit).await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the TUI event loop (blocking).
|
||||
fn run_tui(
|
||||
msg_tx: mpsc::Sender<IncomingMessage>,
|
||||
mut event_rx: mpsc::Receiver<AppEvent>,
|
||||
) -> io::Result<()> {
|
||||
// Setup terminal
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
|
||||
// Create app state
|
||||
let mut app = AppState::new();
|
||||
|
||||
// Run event loop
|
||||
let result = events::run_event_loop(&mut terminal, &mut app, msg_tx, &mut event_rx);
|
||||
|
||||
// Restore terminal
|
||||
disable_raw_mode()?;
|
||||
execute!(
|
||||
terminal.backend_mut(),
|
||||
LeaveAlternateScreen,
|
||||
DisableMouseCapture
|
||||
)?;
|
||||
terminal.show_cursor()?;
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Simple blocking CLI channel (fallback when TUI not available).
|
||||
pub struct SimpleCliChannel {
|
||||
running: Arc<std::sync::atomic::AtomicBool>,
|
||||
}
|
||||
|
||||
impl SimpleCliChannel {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
running: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SimpleCliChannel {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Channel for SimpleCliChannel {
|
||||
fn name(&self) -> &str {
|
||||
"cli"
|
||||
}
|
||||
|
||||
async fn start(&self) -> Result<MessageStream, ChannelError> {
|
||||
self.running
|
||||
.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
let running = self.running.clone();
|
||||
|
||||
let (tx, rx) = mpsc::channel(32);
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
use std::io::BufRead;
|
||||
|
||||
let stdin = io::stdin();
|
||||
let reader = stdin.lock();
|
||||
|
||||
print_prompt();
|
||||
|
||||
for line in reader.lines() {
|
||||
if !running.load(std::sync::atomic::Ordering::SeqCst) {
|
||||
break;
|
||||
}
|
||||
|
||||
match line {
|
||||
Ok(content) => {
|
||||
let content = content.trim();
|
||||
if content.is_empty() {
|
||||
print_prompt();
|
||||
continue;
|
||||
}
|
||||
|
||||
if content == "exit" || content == "quit" || content == "/quit" {
|
||||
running.store(false, std::sync::atomic::Ordering::SeqCst);
|
||||
break;
|
||||
}
|
||||
|
||||
let msg = IncomingMessage::new("cli", "local-user", content);
|
||||
|
||||
if tx.blocking_send(msg).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Error reading stdin: {}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracing::debug!("CLI input loop ended");
|
||||
});
|
||||
|
||||
Ok(Box::pin(ReceiverStream::new(rx)))
|
||||
}
|
||||
|
||||
async fn respond(
|
||||
&self,
|
||||
_msg: &IncomingMessage,
|
||||
response: OutgoingResponse,
|
||||
) -> Result<(), ChannelError> {
|
||||
println!("\n{}\n", response.content);
|
||||
print_prompt();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn health_check(&self) -> Result<(), ChannelError> {
|
||||
if self.running.load(std::sync::atomic::Ordering::SeqCst) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ChannelError::HealthCheckFailed {
|
||||
name: "cli".to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn shutdown(&self) -> Result<(), ChannelError> {
|
||||
self.running
|
||||
.store(false, std::sync::atomic::Ordering::SeqCst);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn print_prompt() {
|
||||
use std::io::Write;
|
||||
print!("agent> ");
|
||||
let _ = io::stdout().flush();
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
//! Approval overlay modal.
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
/// A request for user approval before executing a tool.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ApprovalRequest {
|
||||
/// Unique ID for this request.
|
||||
pub id: Uuid,
|
||||
/// Name of the tool requesting approval.
|
||||
pub tool_name: String,
|
||||
/// Description of what the tool will do.
|
||||
pub description: String,
|
||||
/// Parameters being passed to the tool.
|
||||
pub parameters: serde_json::Value,
|
||||
/// Whether this is a destructive operation.
|
||||
pub destructive: bool,
|
||||
}
|
||||
|
||||
impl ApprovalRequest {
|
||||
/// Create a new approval request.
|
||||
pub fn new(
|
||||
tool_name: impl Into<String>,
|
||||
description: impl Into<String>,
|
||||
parameters: serde_json::Value,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: Uuid::new_v4(),
|
||||
tool_name: tool_name.into(),
|
||||
description: description.into(),
|
||||
parameters,
|
||||
destructive: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark as destructive operation.
|
||||
pub fn destructive(mut self) -> Self {
|
||||
self.destructive = true;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Current selection in the approval overlay.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ApprovalSelection {
|
||||
/// Yes, approve this action.
|
||||
Yes,
|
||||
/// No, deny this action.
|
||||
No,
|
||||
/// Always approve this tool (for this session).
|
||||
Always,
|
||||
}
|
||||
|
||||
impl ApprovalSelection {
|
||||
/// Get the next selection (cycling).
|
||||
pub fn next(self) -> Self {
|
||||
match self {
|
||||
Self::Yes => Self::No,
|
||||
Self::No => Self::Always,
|
||||
Self::Always => Self::Yes,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the previous selection (cycling).
|
||||
pub fn prev(self) -> Self {
|
||||
match self {
|
||||
Self::Yes => Self::Always,
|
||||
Self::No => Self::Yes,
|
||||
Self::Always => Self::No,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Approval overlay state.
|
||||
pub struct ApprovalOverlay {
|
||||
/// The request being shown.
|
||||
pub request: ApprovalRequest,
|
||||
/// Current selection.
|
||||
pub selection: ApprovalSelection,
|
||||
}
|
||||
|
||||
impl ApprovalOverlay {
|
||||
/// Create a new approval overlay.
|
||||
pub fn new(request: ApprovalRequest) -> Self {
|
||||
Self {
|
||||
request,
|
||||
selection: ApprovalSelection::Yes,
|
||||
}
|
||||
}
|
||||
|
||||
/// Move selection left.
|
||||
pub fn select_prev(&mut self) {
|
||||
self.selection = self.selection.prev();
|
||||
}
|
||||
|
||||
/// Move selection right.
|
||||
pub fn select_next(&mut self) {
|
||||
self.selection = self.selection.next();
|
||||
}
|
||||
|
||||
/// Handle keyboard shortcut.
|
||||
pub fn handle_shortcut(&mut self, c: char) -> Option<bool> {
|
||||
match c.to_ascii_lowercase() {
|
||||
'y' => Some(true),
|
||||
'n' => Some(false),
|
||||
'a' => {
|
||||
self.selection = ApprovalSelection::Always;
|
||||
Some(true)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Confirm the current selection.
|
||||
pub fn confirm(&self) -> (bool, bool) {
|
||||
match self.selection {
|
||||
ApprovalSelection::Yes => (true, false),
|
||||
ApprovalSelection::No => (false, false),
|
||||
ApprovalSelection::Always => (true, true),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_approval_selection_cycle() {
|
||||
let sel = ApprovalSelection::Yes;
|
||||
assert_eq!(sel.next(), ApprovalSelection::No);
|
||||
assert_eq!(sel.next().next(), ApprovalSelection::Always);
|
||||
assert_eq!(sel.next().next().next(), ApprovalSelection::Yes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_approval_shortcuts() {
|
||||
let request = ApprovalRequest::new("test", "Test operation", serde_json::json!({}));
|
||||
let mut overlay = ApprovalOverlay::new(request);
|
||||
|
||||
assert_eq!(overlay.handle_shortcut('y'), Some(true));
|
||||
assert_eq!(overlay.handle_shortcut('n'), Some(false));
|
||||
assert_eq!(overlay.handle_shortcut('x'), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
//! TUI rendering with Ratatui.
|
||||
|
||||
use ratatui::{
|
||||
Frame,
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span, Text},
|
||||
widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap},
|
||||
};
|
||||
|
||||
use crate::channels::cli::app::{AppState, InputMode, MessageRole, MessageStatus};
|
||||
use crate::channels::cli::overlay::ApprovalSelection;
|
||||
|
||||
/// Render the entire UI.
|
||||
pub fn render(frame: &mut Frame, app: &AppState) {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Min(3), // Messages
|
||||
Constraint::Length(3), // Input
|
||||
Constraint::Length(1), // Status
|
||||
])
|
||||
.split(frame.area());
|
||||
|
||||
render_messages(frame, app, chunks[0]);
|
||||
render_input(frame, app, chunks[1]);
|
||||
render_status(frame, app, chunks[2]);
|
||||
|
||||
// Render approval overlay if active
|
||||
if app.mode == InputMode::Approval {
|
||||
render_approval_overlay(frame, app);
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the message history.
|
||||
fn render_messages(frame: &mut Frame, app: &AppState, area: Rect) {
|
||||
let items: Vec<ListItem> = app
|
||||
.messages
|
||||
.iter()
|
||||
.map(|msg| {
|
||||
let (prefix, style) = match msg.role {
|
||||
MessageRole::User => ("You: ", Style::default().fg(Color::Cyan)),
|
||||
MessageRole::Agent => ("Agent: ", Style::default().fg(Color::Green)),
|
||||
MessageRole::System => (
|
||||
"",
|
||||
Style::default()
|
||||
.fg(Color::DarkGray)
|
||||
.add_modifier(Modifier::ITALIC),
|
||||
),
|
||||
};
|
||||
|
||||
let status_indicator = match msg.status {
|
||||
Some(MessageStatus::Pending) => " ⏳",
|
||||
Some(MessageStatus::InProgress) => " ⚙️",
|
||||
Some(MessageStatus::Complete) => " ✓",
|
||||
Some(MessageStatus::Error) => " ✗",
|
||||
None => "",
|
||||
};
|
||||
|
||||
let content = format!("{}{}{}", prefix, msg.content, status_indicator);
|
||||
ListItem::new(Text::styled(content, style))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let messages = List::new(items).block(Block::default().borders(Borders::ALL).title("Chat"));
|
||||
|
||||
frame.render_widget(messages, area);
|
||||
}
|
||||
|
||||
/// Render the input area.
|
||||
fn render_input(frame: &mut Frame, app: &AppState, area: Rect) {
|
||||
let input_style = match app.mode {
|
||||
InputMode::Editing => Style::default().fg(Color::Yellow),
|
||||
InputMode::Normal => Style::default(),
|
||||
InputMode::Approval => Style::default().fg(Color::DarkGray),
|
||||
};
|
||||
|
||||
let buffer = app.composer.buffer();
|
||||
let cursor = app.composer.cursor();
|
||||
|
||||
// Build the input text with cursor
|
||||
let (before, after) = buffer.split_at(cursor.min(buffer.len()));
|
||||
let cursor_char = after.chars().next().unwrap_or(' ');
|
||||
let after_cursor = if after.is_empty() {
|
||||
""
|
||||
} else {
|
||||
&after[cursor_char.len_utf8()..]
|
||||
};
|
||||
|
||||
let input = Paragraph::new(Line::from(vec![
|
||||
Span::raw(before),
|
||||
Span::styled(
|
||||
cursor_char.to_string(),
|
||||
Style::default().bg(Color::White).fg(Color::Black),
|
||||
),
|
||||
Span::raw(after_cursor),
|
||||
]))
|
||||
.style(input_style)
|
||||
.block(Block::default().borders(Borders::ALL).title("Input"));
|
||||
|
||||
frame.render_widget(input, area);
|
||||
|
||||
// Show cursor in editing mode
|
||||
if app.mode == InputMode::Editing {
|
||||
// Calculate cursor position accounting for the block border
|
||||
let cursor_x = area.x + 1 + cursor as u16;
|
||||
let cursor_y = area.y + 1;
|
||||
frame.set_cursor_position((cursor_x, cursor_y));
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the status line.
|
||||
fn render_status(frame: &mut Frame, app: &AppState, area: Rect) {
|
||||
let status_text = if let Some(ref msg) = app.status_message {
|
||||
msg.clone()
|
||||
} else {
|
||||
match app.mode {
|
||||
InputMode::Normal => "Press 'i' to edit, 'q' to quit".to_string(),
|
||||
InputMode::Editing => "Type message, Enter to send, Esc to cancel".to_string(),
|
||||
InputMode::Approval => "y=Yes, n=No, a=Always, Ctrl+C=Cancel all".to_string(),
|
||||
}
|
||||
};
|
||||
|
||||
let status = Paragraph::new(status_text).style(Style::default().fg(Color::DarkGray));
|
||||
|
||||
frame.render_widget(status, area);
|
||||
}
|
||||
|
||||
/// Render the approval overlay.
|
||||
fn render_approval_overlay(frame: &mut Frame, app: &AppState) {
|
||||
let Some(ref overlay) = app.approval else {
|
||||
return;
|
||||
};
|
||||
|
||||
let area = frame.area();
|
||||
|
||||
// Calculate overlay size and position
|
||||
let overlay_width = (area.width * 60 / 100).min(60);
|
||||
let overlay_height = 12;
|
||||
let overlay_x = (area.width - overlay_width) / 2;
|
||||
let overlay_y = (area.height - overlay_height) / 2;
|
||||
|
||||
let overlay_area = Rect::new(overlay_x, overlay_y, overlay_width, overlay_height);
|
||||
|
||||
// Clear the area behind the overlay
|
||||
frame.render_widget(Clear, overlay_area);
|
||||
|
||||
// Build overlay content
|
||||
let title = if overlay.request.destructive {
|
||||
"⚠️ Approval Required (Destructive)"
|
||||
} else {
|
||||
"Approval Required"
|
||||
};
|
||||
|
||||
let title_style = if overlay.request.destructive {
|
||||
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
};
|
||||
|
||||
// Build the text content
|
||||
let mut lines = vec![
|
||||
Line::from(vec![
|
||||
Span::styled("Tool: ", Style::default().add_modifier(Modifier::BOLD)),
|
||||
Span::raw(&overlay.request.tool_name),
|
||||
]),
|
||||
Line::from(""),
|
||||
Line::from(overlay.request.description.as_str()),
|
||||
Line::from(""),
|
||||
];
|
||||
|
||||
// Add parameters preview (truncated)
|
||||
let params_str = serde_json::to_string_pretty(&overlay.request.parameters)
|
||||
.unwrap_or_else(|_| "{}".to_string());
|
||||
let params_preview: String = params_str.chars().take(100).collect();
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled("Params: ", Style::default().add_modifier(Modifier::BOLD)),
|
||||
Span::styled(params_preview, Style::default().fg(Color::DarkGray)),
|
||||
]));
|
||||
lines.push(Line::from(""));
|
||||
|
||||
// Add selection buttons
|
||||
let yes_style = if overlay.selection == ApprovalSelection::Yes {
|
||||
Style::default().bg(Color::Green).fg(Color::Black)
|
||||
} else {
|
||||
Style::default().fg(Color::Green)
|
||||
};
|
||||
|
||||
let no_style = if overlay.selection == ApprovalSelection::No {
|
||||
Style::default().bg(Color::Red).fg(Color::Black)
|
||||
} else {
|
||||
Style::default().fg(Color::Red)
|
||||
};
|
||||
|
||||
let always_style = if overlay.selection == ApprovalSelection::Always {
|
||||
Style::default().bg(Color::Blue).fg(Color::Black)
|
||||
} else {
|
||||
Style::default().fg(Color::Blue)
|
||||
};
|
||||
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(" [Y]es ", yes_style),
|
||||
Span::raw(" "),
|
||||
Span::styled(" [N]o ", no_style),
|
||||
Span::raw(" "),
|
||||
Span::styled(" [A]lways ", always_style),
|
||||
]));
|
||||
|
||||
let content = Paragraph::new(lines)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(Span::styled(title, title_style)),
|
||||
)
|
||||
.wrap(Wrap { trim: true });
|
||||
|
||||
frame.render_widget(content, overlay_area);
|
||||
}
|
||||
Reference in New Issue
Block a user