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 <[email protected]>
This commit is contained in:
Illia Polosukhin
2026-02-02 23:54:01 -08:00
co-authored by Claude Opus 4.5
parent 4ae59ef52c
commit b52122a275
5 changed files with 104 additions and 136 deletions
+5
View File
@@ -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<String>,
/// Status line message.
pub status_message: Option<String>,
/// 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,
}
}
+19 -2
View File
@@ -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);
}
}
}
+56 -122
View File
@@ -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<Mutex<Option<mpsc::Sender<AppEvent>>>>,
/// Channel for sending events to the TUI (created upfront for logging).
event_tx: mpsc::Sender<AppEvent>,
/// Receiver end, taken when start() is called.
event_rx: Arc<Mutex<Option<mpsc::Receiver<AppEvent>>>>,
}
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<MessageStream, ChannelError> {
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<std::sync::atomic::AtomicBool>,
/// TUI-compatible tracing writer that sends log messages to the TUI status line.
#[derive(Clone)]
pub struct TuiLogWriter {
tx: mpsc::Sender<AppEvent>,
}
impl SimpleCliChannel {
pub fn new() -> Self {
Self {
running: Arc::new(std::sync::atomic::AtomicBool::new(false)),
}
impl TuiLogWriter {
pub fn new(tx: mpsc::Sender<AppEvent>) -> 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<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;
}
}
impl std::io::Write for TuiLogWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
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()
}
}
+1 -1
View File
@@ -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;
+23 -11
View File
@@ -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");
}