From b52122a275dea2b9b7a4831ae2251f82f413fd62 Mon Sep 17 00:00:00 2001 From: Illia Polosukhin Date: Mon, 2 Feb 2026 23:54:01 -0800 Subject: [PATCH] Remove SimpleCliChannel, add Ctrl+D twice quit, redirect logs to TUI - Remove SimpleCliChannel (only TuiChannel remains) - Add ctrl_d_pending flag to AppState for two-press quit behavior - Implement Ctrl+D twice to quit (first press shows hint, second quits) - Add TuiLogWriter with MakeWriter impl for tracing integration - Create TuiChannel event channel upfront in new() so log_writer() works - Configure tracing to send logs to TUI status line - Any key press clears the Ctrl+D pending state Co-Authored-By: Claude Opus 4.5 --- src/channels/cli/app.rs | 5 ++ src/channels/cli/events.rs | 21 ++++- src/channels/cli/mod.rs | 178 ++++++++++++------------------------- src/channels/mod.rs | 2 +- src/main.rs | 34 ++++--- 5 files changed, 104 insertions(+), 136 deletions(-) diff --git a/src/channels/cli/app.rs b/src/channels/cli/app.rs index 2f7e03ab..6ad6f08c 100644 --- a/src/channels/cli/app.rs +++ b/src/channels/cli/app.rs @@ -20,6 +20,8 @@ pub enum AppEvent { ApprovalRequested(ApprovalRequest), /// Streaming chunk received. StreamChunk(String), + /// Log message from the application. + LogMessage(String), /// Force a redraw. Redraw, /// Quit the application. @@ -116,6 +118,8 @@ pub struct AppState { pub streaming_buffer: Option, /// Status line message. pub status_message: Option, + /// Whether Ctrl+D was pressed (waiting for second press to quit). + pub ctrl_d_pending: bool, } impl AppState { @@ -133,6 +137,7 @@ impl AppState { pending_approvals: VecDeque::new(), streaming_buffer: None, status_message: None, + ctrl_d_pending: false, } } diff --git a/src/channels/cli/events.rs b/src/channels/cli/events.rs index bf5d484e..91d91fee 100644 --- a/src/channels/cli/events.rs +++ b/src/channels/cli/events.rs @@ -77,14 +77,28 @@ fn handle_key( // Quit app.should_quit = true; } + app.ctrl_d_pending = false; return Ok(()); } KeyCode::Char('d') => { - app.should_quit = true; + if app.ctrl_d_pending { + // Second Ctrl+D, quit now + app.should_quit = true; + } else { + // First Ctrl+D, show hint + app.ctrl_d_pending = true; + app.set_status("Press Ctrl+D again to quit"); + } return Ok(()); } - _ => {} + _ => { + // Any other Ctrl+ combo clears the Ctrl+D pending state + app.ctrl_d_pending = false; + } } + } else { + // Any non-Ctrl key clears the Ctrl+D pending state + app.ctrl_d_pending = false; } match app.mode { @@ -266,5 +280,8 @@ fn handle_app_event(app: &mut AppState, event: AppEvent) { AppEvent::Input(_) => { // Already handled directly } + AppEvent::LogMessage(msg) => { + app.set_status(msg); + } } } diff --git a/src/channels/cli/mod.rs b/src/channels/cli/mod.rs index 5abd9cf3..cc5d85de 100644 --- a/src/channels/cli/mod.rs +++ b/src/channels/cli/mod.rs @@ -35,17 +35,27 @@ 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: Arc>>>, + /// Channel for sending events to the TUI (created upfront for logging). + event_tx: mpsc::Sender, + /// Receiver end, taken when start() is called. + event_rx: Arc>>>, } impl TuiChannel { /// Create a new TUI channel. pub fn new() -> Self { + let (event_tx, event_rx) = mpsc::channel(64); Self { - event_tx: Arc::new(Mutex::new(None)), + event_tx, + event_rx: Arc::new(Mutex::new(Some(event_rx))), } } + + /// Get a log writer that sends messages to the TUI status line. + /// Use this to redirect tracing output to the TUI. + pub fn log_writer(&self) -> TuiLogWriter { + TuiLogWriter::new(self.event_tx.clone()) + } } impl Default for TuiChannel { @@ -62,20 +72,22 @@ impl Channel for TuiChannel { async fn start(&self) -> Result { let (msg_tx, msg_rx) = mpsc::channel(32); - let (event_tx, event_rx) = mpsc::channel(64); - // Store the event sender for respond() - { - let mut guard = self.event_tx.lock().await; - *guard = Some(event_tx); - } + // Take the event receiver (can only start once) + let event_rx = { + let mut guard = self.event_rx.lock().await; + guard.take().ok_or_else(|| ChannelError::StartupFailed { + name: "tui".to_string(), + reason: "TUI channel already started".to_string(), + })? + }; tokio::task::spawn_blocking(move || { if let Err(e) = run_tui(msg_tx, event_rx) { // Try to restore terminal even on error let _ = disable_raw_mode(); let _ = execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture); - tracing::error!("TUI error: {}", e); + eprintln!("TUI error: {}", e); } }); @@ -87,34 +99,29 @@ impl Channel for TuiChannel { _msg: &IncomingMessage, response: OutgoingResponse, ) -> Result<(), ChannelError> { - let guard = self.event_tx.lock().await; - if let Some(ref tx) = *guard { - tx.send(AppEvent::Response(response.content)) - .await - .map_err(|e| ChannelError::SendFailed { - name: "tui".to_string(), - reason: e.to_string(), - })?; - } + self.event_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> { - let guard = self.event_tx.lock().await; - if guard.is_some() { - Ok(()) - } else { + // Channel is healthy if we haven't been closed + if self.event_tx.is_closed() { Err(ChannelError::HealthCheckFailed { name: "tui".to_string(), }) + } else { + Ok(()) } } async fn shutdown(&self) -> Result<(), ChannelError> { - let guard = self.event_tx.lock().await; - if let Some(ref tx) = *guard { - let _ = tx.send(AppEvent::Quit).await; - } + let _ = self.event_tx.send(AppEvent::Quit).await; Ok(()) } } @@ -149,112 +156,39 @@ fn run_tui( result } -/// Simple blocking CLI channel (fallback when TUI not available). -pub struct SimpleCliChannel { - running: Arc, +/// TUI-compatible tracing writer that sends log messages to the TUI status line. +#[derive(Clone)] +pub struct TuiLogWriter { + tx: mpsc::Sender, } -impl SimpleCliChannel { - pub fn new() -> Self { - Self { - running: Arc::new(std::sync::atomic::AtomicBool::new(false)), - } +impl TuiLogWriter { + pub fn new(tx: mpsc::Sender) -> Self { + Self { tx } } } -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 { - 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; - } - } +impl std::io::Write for TuiLogWriter { + fn write(&mut self, buf: &[u8]) -> io::Result { + if let Ok(s) = std::str::from_utf8(buf) { + let s = s.trim(); + if !s.is_empty() { + // Fire and forget - don't block on logging + let _ = self.tx.try_send(AppEvent::LogMessage(s.to_string())); } - - 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(), - }) } + Ok(buf.len()) } - async fn shutdown(&self) -> Result<(), ChannelError> { - self.running - .store(false, std::sync::atomic::Ordering::SeqCst); + fn flush(&mut self) -> io::Result<()> { Ok(()) } } -fn print_prompt() { - use std::io::Write; - print!("agent> "); - let _ = io::stdout().flush(); +impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for TuiLogWriter { + type Writer = Self; + + fn make_writer(&'a self) -> Self::Writer { + self.clone() + } } diff --git a/src/channels/mod.rs b/src/channels/mod.rs index 054b1208..2e80fa73 100644 --- a/src/channels/mod.rs +++ b/src/channels/mod.rs @@ -11,7 +11,7 @@ mod slack; mod telegram; pub use channel::{Channel, IncomingMessage, MessageStream, OutgoingResponse}; -pub use cli::{SimpleCliChannel as CliChannel, TuiChannel}; +pub use cli::TuiChannel; pub use http::HttpChannel; pub use manager::ChannelManager; pub use slack::SlackChannel; diff --git a/src/main.rs b/src/main.rs index 05d806c0..45f3b024 100644 --- a/src/main.rs +++ b/src/main.rs @@ -35,17 +35,29 @@ struct Args { #[tokio::main] async fn main() -> anyhow::Result<()> { - // Initialize tracing - tracing_subscriber::registry() - .with( - EnvFilter::try_from_default_env() - .unwrap_or_else(|_| EnvFilter::new("near_agent=debug,tower_http=debug")), - ) - .with(tracing_subscriber::fmt::layer()) - .init(); - let args = Args::parse(); + // Create TUI channel early so we can hook up logging + // (channel is created but not started until agent.run()) + let tui_channel = TuiChannel::new(); + let tui_log_writer = tui_channel.log_writer(); + + // Initialize tracing with both stderr (for pre-TUI output) and TUI writer + let env_filter = EnvFilter::try_from_default_env() + .unwrap_or_else(|_| EnvFilter::new("near_agent=info,tower_http=debug")); + + tracing_subscriber::registry() + .with(env_filter) + // TUI layer: sends logs to TUI status line (once TUI is running) + .with( + tracing_subscriber::fmt::layer() + .with_writer(tui_log_writer) + .without_time() + .with_target(false) + .with_level(true), + ) + .init(); + tracing::info!("Starting NEAR Agent..."); // Load configuration @@ -79,9 +91,9 @@ async fn main() -> anyhow::Result<()> { // Initialize channel manager let mut channels = ChannelManager::new(); - // Always add CLI channel (TUI with full-screen interface) + // Add TUI channel (already created for logging hookup) if config.channels.cli.enabled { - channels.add(Box::new(TuiChannel::new())); + channels.add(Box::new(tui_channel)); tracing::info!("TUI channel enabled"); }