mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
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:
co-authored by
Claude Opus 4.5
parent
4ae59ef52c
commit
b52122a275
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user