diff --git a/src/app/mcp/manager.rs b/src/app/mcp/manager.rs index 80b84af..9cc4836 100644 --- a/src/app/mcp/manager.rs +++ b/src/app/mcp/manager.rs @@ -95,7 +95,7 @@ impl StdioChild { anyhow::bail!("MCP error: {}", err); } return Ok(resp.get("result").cloned().unwrap_or_else(|| { - eprintln!("[mcp] stdio response missing 'result' field: {}", trimmed); + tracing::warn!("[mcp] stdio response missing 'result' field: {}", trimmed); Value::Null })); } @@ -190,7 +190,7 @@ fn call_via_http(url: &str, tool_name: &str, tool_args: &Value) -> anyhow::Resul .connect_timeout(std::time::Duration::from_millis(MCP_CONNECT_TIMEOUT_MS)) .build() .unwrap_or_else(|e| { - eprintln!("[mcp] HTTP client builder failed: {}, using default client without timeouts", e); + tracing::warn!("[mcp] HTTP client builder failed: {}, using default client without timeouts", e); reqwest::blocking::Client::new() }); @@ -214,7 +214,7 @@ fn call_via_http(url: &str, tool_name: &str, tool_args: &Value) -> anyhow::Resul if !resp.status().is_success() { let status = resp.status(); let text = resp.text().unwrap_or_else(|e| { - eprintln!("[mcp] failed to read HTTP response body: {}", e); + tracing::warn!("[mcp] failed to read HTTP response body: {}", e); String::new() }); anyhow::bail!("MCP HTTP server returned {}: {}", status, text); @@ -228,7 +228,7 @@ fn call_via_http(url: &str, tool_name: &str, tool_args: &Value) -> anyhow::Resul } let result = response.get("result").cloned().unwrap_or_else(|| { - eprintln!("[mcp] HTTP response missing 'result' field"); + tracing::warn!("[mcp] HTTP response missing 'result' field"); Value::Null }); extract_text_content(&result) @@ -250,7 +250,7 @@ fn extract_text_content(result: &Value) -> anyhow::Result { } } Ok(serde_json::to_string_pretty(result).unwrap_or_else(|e| { - eprintln!("[mcp] failed to pretty-print result: {}", e); + tracing::warn!("[mcp] failed to pretty-print result: {}", e); result.to_string() })) } @@ -336,11 +336,11 @@ impl McpManager { Some(McpToolInfo { name: t.get("name")?.as_str()?.to_string(), description: t.get("description").and_then(|v| v.as_str()).unwrap_or_else(|| { - eprintln!("[mcp] tool {} missing description", t.get("name").and_then(|n| n.as_str()).unwrap_or("?")); + tracing::warn!("[mcp] tool {} missing description", t.get("name").and_then(|n| n.as_str()).unwrap_or("?")); "" }).to_string(), input_schema: t.get("inputSchema").cloned().unwrap_or_else(|| { - eprintln!("[mcp] tool {} missing inputSchema", t.get("name").and_then(|n| n.as_str()).unwrap_or("?")); + tracing::warn!("[mcp] tool {} missing inputSchema", t.get("name").and_then(|n| n.as_str()).unwrap_or("?")); serde_json::Value::Null }), }) diff --git a/src/app/runtime/stream/mod.rs b/src/app/runtime/stream/mod.rs index b7eb8b0..bda99ca 100644 --- a/src/app/runtime/stream/mod.rs +++ b/src/app/runtime/stream/mod.rs @@ -70,23 +70,23 @@ impl SseParser { let value: Value = match serde_json::from_str(&data) { Ok(v) => v, Err(e) => { - eprintln!("[stream] failed to parse chunk: {}", e); + tracing::warn!("[stream] failed to parse chunk: {}", e); return vec![]; } }; if let Some(usage) = value.get("usage") { if !usage.is_null() { let prompt_tokens = usage.get("prompt_tokens").and_then(|v| v.as_u64()).unwrap_or_else(|| { - eprintln!("[stream] prompt_tokens missing in usage chunk"); + tracing::warn!("[stream] prompt_tokens missing in usage chunk"); 0 }); let completion_tokens = usage.get("completion_tokens").and_then(|v| v.as_u64()).unwrap_or_else(|| { - eprintln!("[stream] completion_tokens missing in usage chunk"); + tracing::warn!("[stream] completion_tokens missing in usage chunk"); 0 }); let total_tokens = usage.get("total_tokens").and_then(|v| v.as_u64()) .unwrap_or_else(|| { - eprintln!("[stream] total_tokens missing in usage chunk"); + tracing::warn!("[stream] total_tokens missing in usage chunk"); prompt_tokens + completion_tokens }); return vec![StreamEvent::Usage { prompt_tokens, completion_tokens, total_tokens }]; @@ -125,7 +125,7 @@ impl SseParser { let mut events = Vec::with_capacity(tool_calls.len()); for tc in tool_calls { let index = tc.get("index").and_then(|i| i.as_u64()).unwrap_or_else(|| { - eprintln!("[stream] tool call delta missing index, defaulting to 0"); + tracing::warn!("[stream] tool call delta missing index, defaulting to 0"); 0 }) as usize; let id = tc.get("id").and_then(|i| i.as_str()).map(|s| s.to_string()); @@ -202,7 +202,7 @@ pub fn parse_stream_chunk(data: &str) -> Option { if let Some(tool_calls) = delta.get("tool_calls").and_then(|tc| tc.as_array()) { if let Some(tc) = tool_calls.first() { let index = tc.get("index").and_then(|i| i.as_u64()).unwrap_or_else(|| { - eprintln!("[stream] fallback parser: tool call missing index, defaulting to 0"); + tracing::warn!("[stream] fallback parser: tool call missing index, defaulting to 0"); 0 }) as usize; let id = tc.get("id").and_then(|i| i.as_str()).map(|s| s.to_string()); diff --git a/src/app/state/rest.rs b/src/app/state/rest.rs index b7f38b7..1c33e30 100644 --- a/src/app/state/rest.rs +++ b/src/app/state/rest.rs @@ -62,11 +62,11 @@ impl AppStateRest { let settings = Settings::load(); let app_config = AppConfig::load(); let download_dir = memory_dir.parent().unwrap_or_else(|| { - eprintln!("[state] memory_dir '{}' has no parent, using it for downloads", memory_dir.display()); + tracing::warn!("[state] memory_dir '{}' has no parent, using it for downloads", memory_dir.display()); &memory_dir }).join("downloads"); let worktrees_dir = memory_dir.parent().unwrap_or_else(|| { - eprintln!("[state] memory_dir '{}' has no parent, using it for worktrees", memory_dir.display()); + tracing::warn!("[state] memory_dir '{}' has no parent, using it for worktrees", memory_dir.display()); &memory_dir }).join("worktrees"); let dir_cache = DirCache::new(); @@ -74,7 +74,7 @@ impl AppStateRest { .file_name() .map(|n| n.to_string_lossy().to_string()) .unwrap_or_else(|| { - eprintln!("[state] session_dir has no file_name component, using empty session_id"); + tracing::warn!("[state] session_dir has no file_name component, using empty session_id"); String::new() }); AppStateRest { @@ -107,7 +107,7 @@ impl AppStateRest { pub fn turn_in_flight(&self) -> bool { self.turn_in_flight.lock().map(|g| *g).unwrap_or_else(|_| { - eprintln!("[state] turn_in_flight mutex poisoned"); + tracing::warn!("[state] turn_in_flight mutex poisoned"); false }) } @@ -133,11 +133,11 @@ impl AppStateRest { .and_then(|p| p.parent()) .map(|p| p.to_path_buf()) .unwrap_or_else(|| { - eprintln!("[state] session_dir '{}' has no grandparent, using parent", self.session_dir.display()); + tracing::warn!("[state] session_dir '{}' has no grandparent, using parent", self.session_dir.display()); self.session_dir.parent() .map(|p| p.to_path_buf()) .unwrap_or_else(|| { - eprintln!("[state] session_dir '{}' has no parent at all, using itself", self.session_dir.display()); + tracing::warn!("[state] session_dir '{}' has no parent at all, using itself", self.session_dir.display()); self.session_dir.clone() }) }) diff --git a/src/app/subagent/engine.rs b/src/app/subagent/engine.rs index a90dc16..156c62e 100644 --- a/src/app/subagent/engine.rs +++ b/src/app/subagent/engine.rs @@ -32,7 +32,7 @@ fn resolve_provider_config() -> (String, String, Option) { let app_config = crate::model::app_config::AppConfig::load(); let mut api_key = settings.api_keys.get(&settings.provider).cloned().unwrap_or_else(|| { - eprintln!("[subagent] no API key for provider '{}' in settings, trying env/default", settings.provider); + tracing::warn!("[subagent] no API key for provider '{}' in settings, trying env/default", settings.provider); String::new() }); let model = settings.model.clone(); @@ -45,7 +45,7 @@ fn resolve_provider_config() -> (String, String, Option) { .and_then(|env| std::env::var(env).ok()) .or_else(|| provider_cfg.default_api_key.clone()) .unwrap_or_else(|| { - eprintln!("[subagent] all API key resolution paths exhausted for '{}'", settings.provider); + tracing::warn!("[subagent] all API key resolution paths exhausted for '{}'", settings.provider); String::new() }); } diff --git a/src/controller/input.rs b/src/controller/input.rs index 29478e1..6299cae 100644 --- a/src/controller/input.rs +++ b/src/controller/input.rs @@ -245,7 +245,7 @@ fn handle_overlay_enter(state: &mut AppStateRest) -> Vec { if let Some(provider) = providers.get(state.misc.selected_index) { if let Some(cfg) = state.app_config.providers.get(provider) { let model = cfg.default_model.clone().unwrap_or_else(|| { - eprintln!("[input] provider '{}' has no default_model, using 'claude-opus-4-8'", provider); + tracing::warn!("[input] provider '{}' has no default_model, using 'claude-opus-4-8'", provider); "claude-opus-4-8".to_string() }); state.settings.provider = provider.clone(); diff --git a/src/dto/chat/tool.rs b/src/dto/chat/tool.rs index bdef954..022dff2 100644 --- a/src/dto/chat/tool.rs +++ b/src/dto/chat/tool.rs @@ -21,7 +21,7 @@ pub fn sanitize_tool_arguments(args: &Value) -> Value { match serde_json::from_str::(s) { Ok(v) => v, Err(e) => { - eprintln!( + tracing::warn!( "warning: tool argument is a JSON string but failed to parse: {}. Using raw string.", e ); diff --git a/src/main.rs b/src/main.rs index e2f3bbc..05df4bd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ use std::io; use std::io::Write; +use std::sync::Mutex; use anyhow::Result; use crossterm::execute; use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}; @@ -23,11 +24,26 @@ fn main() -> Result<()> { .position(|a| a == "--attach") .and_then(|i| args.get(i + 1).cloned()); + let log_dir = dirs::data_dir() + .unwrap_or_else(|| std::path::PathBuf::from(".")) + .join("zesdex"); + let _ = std::fs::create_dir_all(&log_dir); + let log_path = log_dir.join("zesdex.log"); + let log_file = std::fs::OpenOptions::new() + .create(true).append(true).open(&log_path) + .unwrap_or_else(|_| { + // Fallback: /dev/null so the TUI isn't corrupted by stderr writes + std::fs::OpenOptions::new() + .write(true).open("/dev/null") + .expect("cannot open /dev/null") + }); + tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), ) + .with_writer(Mutex::new(log_file)) .init(); if is_daemon && attach_session.is_some() { diff --git a/src/model/agent_def/session.rs b/src/model/agent_def/session.rs index 28cbf3f..ac0ad9c 100644 --- a/src/model/agent_def/session.rs +++ b/src/model/agent_def/session.rs @@ -9,7 +9,7 @@ pub fn load_session_agents(session_dir: &Path) -> Vec { match std::fs::read_to_string(&agents_file) { Ok(content) => { serde_json::from_str(&content).unwrap_or_else(|e| { - eprintln!("[session] failed to parse agents.json: {}", e); + tracing::warn!("[session] failed to parse agents.json: {}", e); Vec::new() }) } diff --git a/src/model/app_config.rs b/src/model/app_config.rs index 7b85321..6c4fc25 100644 --- a/src/model/app_config.rs +++ b/src/model/app_config.rs @@ -64,7 +64,7 @@ impl AppConfig { Ok(s) => match serde_json::from_str(&s) { Ok(c) => c, Err(e) => { - eprintln!( + tracing::warn!( "warning: failed to parse config file '{}': {}. Loading defaults.", path.display(), e ); diff --git a/src/service/oauth/manager.rs b/src/service/oauth/manager.rs index b1ba975..f990164 100644 --- a/src/service/oauth/manager.rs +++ b/src/service/oauth/manager.rs @@ -93,7 +93,7 @@ impl OAuthManager { let mut url = match url::Url::parse(&self.config.auth_url) { Ok(u) if !self.config.auth_url.is_empty() => u, _ => { - eprintln!( + tracing::warn!( "warning: OAuth auth_url is missing or invalid ('{}'); aborting build_auth_url", self.config.auth_url ); diff --git a/src/service/oauth/pkce.rs b/src/service/oauth/pkce.rs index e8b805a..ec2f080 100644 --- a/src/service/oauth/pkce.rs +++ b/src/service/oauth/pkce.rs @@ -28,7 +28,7 @@ fn rand_byte() -> u8 { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_else(|_| { - eprintln!("[pkce] system time before UNIX_EPOCH, using 0 for random byte"); + tracing::warn!("[pkce] system time before UNIX_EPOCH, using 0 for random byte"); std::time::Duration::default() }) .subsec_nanos(); diff --git a/src/service/provider.rs b/src/service/provider.rs index 586eb0c..8638493 100644 --- a/src/service/provider.rs +++ b/src/service/provider.rs @@ -36,7 +36,7 @@ impl LlmClient { { Ok(c) => c, Err(e) => { - eprintln!("warning: failed to build reqwest client with timeouts: {}. Using default client without timeouts.", e); + tracing::warn!("warning: failed to build reqwest client with timeouts: {}. Using default client without timeouts.", e); reqwest::blocking::Client::new() } }; @@ -118,7 +118,7 @@ impl LlmClient { if attempt >= max_retries || is_auth_error { return Err(e); } - eprintln!("Warning: {}. Retrying {}/{}...", e, attempt, max_retries); + tracing::warn!("Warning: {}. Retrying {}/{}...", e, attempt, max_retries); std::thread::sleep(Duration::from_secs(2)); } } @@ -170,7 +170,7 @@ impl LlmClient { if started || attempt >= max_retries || is_auth_error { return Err(e); } - eprintln!("Warning: {}. Retrying {}/{}...", e, attempt, max_retries); + tracing::warn!("Warning: {}. Retrying {}/{}...", e, attempt, max_retries); std::thread::sleep(Duration::from_secs(2)); } } diff --git a/src/view/mod.rs b/src/view/mod.rs index a255cfd..f270af1 100644 --- a/src/view/mod.rs +++ b/src/view/mod.rs @@ -36,6 +36,9 @@ pub fn draw(frame: &mut Frame, state: &crate::app::state::rest::AppStateRest) { render_input_bar(frame, input_area, state); status::draw_status_bar(frame, status_area, state); + + // Toast notifications at top-right (like Hyprland) + render_toasts(frame, state); } fn render_main_panel(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) { @@ -564,6 +567,49 @@ fn render_input_bar(frame: &mut Frame, area: Rect, state: &crate::app::state::re frame.render_widget(paragraph, area); } +/// Render active toasts as a floating stack at top-right of the terminal. +/// Each toast auto-expires after its lifetime_ms. Max 4 visible at once. +fn render_toasts(frame: &mut Frame, state: &crate::app::state::rest::AppStateRest) { + let now_ms = chrono::Utc::now().timestamp_millis(); + let active: Vec<&crate::app::state::types::Toast> = state.misc.toasts.iter() + .filter(|t| !t.expired(now_ms)) + .collect(); + if active.is_empty() { + return; + } + let area = frame.area(); + let toast_w: u16 = 45; + let x = area.width.saturating_sub(toast_w).saturating_sub(2); + let mut y: u16 = 1; + + for toast in active.iter().rev().take(4) { + let line_count = toast.message.lines().count().max(1) as u16; + let h = line_count + 2; // border top + border bottom + let toast_area = Rect { x, y, width: toast_w, height: h }; + if toast_area.bottom() > area.height { + break; + } + frame.render_widget(ratatui::widgets::Clear, toast_area); + + let border_color = match toast.kind { + crate::app::state::types::ToastKind::Success => Theme::SUCCESS, + crate::app::state::types::ToastKind::Warning => Theme::WARNING, + crate::app::state::types::ToastKind::Error => Theme::ERROR, + crate::app::state::types::ToastKind::Info => Theme::INFO, + crate::app::state::types::ToastKind::Lesson => Theme::PRIMARY, + }; + let block = ratatui::widgets::Block::default() + .borders(ratatui::widgets::Borders::ALL) + .border_style(ratatui::style::Style::default().fg(border_color)) + .style(ratatui::style::Style::default().bg(ratatui::style::Color::Black)); + let paragraph = ratatui::widgets::Paragraph::new(toast.message.as_str()) + .block(block) + .wrap(ratatui::widgets::Wrap { trim: false }); + frame.render_widget(paragraph, toast_area); + y = y.saturating_add(h).saturating_add(1); + } +} + fn centered_rect(area: Rect, percent_x: u16, percent_y: u16) -> Rect { let x_pad = (area.width.saturating_sub(area.width * percent_x / 100)) / 2; let y_pad = (area.height.saturating_sub(area.height * percent_y / 100)) / 2;