diff --git a/crates/owlen-tui/Cargo.toml b/crates/owlen-tui/Cargo.toml index 4f52c0b..de83d9a 100644 --- a/crates/owlen-tui/Cargo.toml +++ b/crates/owlen-tui/Cargo.toml @@ -31,6 +31,7 @@ syntect = "5.3" once_cell = "1.19" owlen-markdown = { path = "../owlen-markdown" } shellexpand = { workspace = true } +regex = { workspace = true } # Async runtime tokio = { workspace = true } diff --git a/crates/owlen-tui/keymap.toml b/crates/owlen-tui/keymap.toml index a37857f..7d926b2 100644 --- a/crates/owlen-tui/keymap.toml +++ b/crates/owlen-tui/keymap.toml @@ -72,3 +72,28 @@ command = "composer.submit" mode = "normal" keys = ["Ctrl+;"] command = "mode.command" + +[[binding]] +mode = "normal" +keys = ["F12"] +command = "debug.toggle" + +[[binding]] +mode = "editing" +keys = ["F12"] +command = "debug.toggle" + +[[binding]] +mode = "visual" +keys = ["F12"] +command = "debug.toggle" + +[[binding]] +mode = "command" +keys = ["F12"] +command = "debug.toggle" + +[[binding]] +mode = "help" +keys = ["F12"] +command = "debug.toggle" diff --git a/crates/owlen-tui/src/chat_app.rs b/crates/owlen-tui/src/chat_app.rs index 6fe271f..ae92197 100644 --- a/crates/owlen-tui/src/chat_app.rs +++ b/crates/owlen-tui/src/chat_app.rs @@ -48,10 +48,11 @@ use crate::events::Event; use crate::model_info_panel::ModelInfoPanel; use crate::slash::{self, McpSlashCommand, SlashCommand}; use crate::state::{ - CodeWorkspace, CommandPalette, FileFilterMode, FileIconResolver, FileNode, FileTreeState, - Keymap, ModelPaletteEntry, PaletteSuggestion, PaneDirection, PaneRestoreRequest, - RepoSearchMessage, RepoSearchState, SplitAxis, SymbolSearchMessage, SymbolSearchState, - WorkspaceSnapshot, spawn_repo_search_task, spawn_symbol_search_task, + CodeWorkspace, CommandPalette, DebugLogEntry, DebugLogState, FileFilterMode, FileIconResolver, + FileNode, FileTreeState, Keymap, ModelPaletteEntry, PaletteSuggestion, PaneDirection, + PaneRestoreRequest, RepoSearchMessage, RepoSearchState, SplitAxis, SymbolSearchMessage, + SymbolSearchState, WorkspaceSnapshot, install_global_logger, spawn_repo_search_task, + spawn_symbol_search_task, }; use crate::toast::{Toast, ToastLevel, ToastManager}; use crate::ui::format_tool_output; @@ -75,6 +76,7 @@ use std::sync::Arc; use std::time::{Duration, Instant, SystemTime}; use dirs::{config_dir, data_local_dir}; +use log::Level; use serde_json::{Value, json}; const ONBOARDING_STATUS_LINE: &str = @@ -481,6 +483,7 @@ pub struct ChatApp { queued_consents: VecDeque, // Backlog of consent requests system_status: String, // System/status messages (tool execution, status, etc) toasts: ToastManager, + debug_log: DebugLogState, /// Simple execution budget: maximum number of tool calls allowed per session. _execution_budget: usize, /// Agent mode enabled @@ -655,6 +658,8 @@ impl ChatApp { let file_tree = FileTreeState::new(workspace_root); let file_icons = FileIconResolver::from_mode(icon_mode); + install_global_logger(); + let mut app = Self { controller, mode: InputMode::Normal, @@ -748,6 +753,7 @@ impl ChatApp { String::new() }, toasts: ToastManager::new(), + debug_log: DebugLogState::new(), _execution_budget: 50, agent_mode: false, agent_running: false, @@ -1866,6 +1872,25 @@ impl ChatApp { &self.theme } + pub fn is_debug_log_visible(&self) -> bool { + self.debug_log.is_visible() + } + + pub fn toggle_debug_log_panel(&mut self) { + let now_visible = self.debug_log.toggle_visible(); + if now_visible { + self.status = "Debug log open — F12 to hide".to_string(); + self.error = None; + } else { + self.status = "Debug log hidden".to_string(); + self.error = None; + } + } + + pub fn debug_log_entries(&self) -> Vec { + self.debug_log.entries() + } + pub fn toasts(&self) -> impl Iterator { self.toasts.iter() } @@ -1878,6 +1903,56 @@ impl ChatApp { self.toasts.retain_active(); } + fn poll_debug_log_updates(&mut self) { + let new_entries = self.debug_log.take_unseen(); + if new_entries.is_empty() { + return; + } + + let mut latest_summary: Option<(Level, String)> = None; + + for entry in new_entries.iter() { + let toast_level = match entry.level { + Level::Error => ToastLevel::Error, + Level::Warn => ToastLevel::Warning, + _ => continue, + }; + + let summary = format!("{}: {}", entry.target, entry.message); + let clipped = Self::ellipsize(&summary, 120); + self.push_toast(toast_level, clipped.clone()); + latest_summary = Some((entry.level, clipped)); + } + + if !self.debug_log.is_visible() { + if let Some((level, message)) = latest_summary { + let level_label = match level { + Level::Error => "Error", + Level::Warn => "Warning", + _ => "Log", + }; + self.status = format!("{level_label}: {message} (F12 to open debug log)"); + self.error = None; + } + } + } + + fn ellipsize(message: &str, max_len: usize) -> String { + if message.chars().count() <= max_len { + return message.to_string(); + } + + let mut truncated = String::new(); + for (idx, ch) in message.chars().enumerate() { + if idx + 1 >= max_len { + truncated.push('…'); + break; + } + truncated.push(ch); + } + truncated + } + pub fn input_max_rows(&self) -> u16 { let config = self.controller.config(); config.ui.input_max_rows.max(1) @@ -3264,6 +3339,11 @@ impl ChatApp { self.handle_app_effects(effects).await?; Ok(true) } + AppCommand::ToggleDebugLog => { + self.pending_key = None; + self.toggle_debug_log_panel(); + Ok(true) + } } } @@ -4877,6 +4957,7 @@ impl ChatApp { Event::Tick => { self.poll_repo_search(); self.poll_symbol_search(); + self.poll_debug_log_updates(); self.prune_toasts(); // Future: update streaming timers } diff --git a/crates/owlen-tui/src/commands/mod.rs b/crates/owlen-tui/src/commands/mod.rs index 5192915..50fbdda 100644 --- a/crates/owlen-tui/src/commands/mod.rs +++ b/crates/owlen-tui/src/commands/mod.rs @@ -243,6 +243,10 @@ const COMMANDS: &[CommandSpec] = &[ keyword: "explorer", description: "Alias for files", }, + CommandSpec { + keyword: "debug log", + description: "Toggle the debug log panel", + }, ]; /// Return the static catalog of commands. diff --git a/crates/owlen-tui/src/commands/registry.rs b/crates/owlen-tui/src/commands/registry.rs index c704b1c..7aa625f 100644 --- a/crates/owlen-tui/src/commands/registry.rs +++ b/crates/owlen-tui/src/commands/registry.rs @@ -13,6 +13,7 @@ pub enum AppCommand { FocusPanel(FocusedPanel), ComposerSubmit, EnterCommandMode, + ToggleDebugLog, } #[derive(Debug)] @@ -65,6 +66,7 @@ impl CommandRegistry { ); commands.insert("composer.submit".to_string(), AppCommand::ComposerSubmit); commands.insert("mode.command".to_string(), AppCommand::EnterCommandMode); + commands.insert("debug.toggle".to_string(), AppCommand::ToggleDebugLog); Self { commands } } diff --git a/crates/owlen-tui/src/state/debug_log.rs b/crates/owlen-tui/src/state/debug_log.rs new file mode 100644 index 0000000..f503684 --- /dev/null +++ b/crates/owlen-tui/src/state/debug_log.rs @@ -0,0 +1,235 @@ +use chrono::{DateTime, Local}; +use log::{Level, LevelFilter, Metadata, Record}; +use once_cell::sync::{Lazy, OnceCell}; +use regex::Regex; +use std::collections::VecDeque; +use std::sync::Mutex; + +/// Maximum number of entries to retain in the in-memory ring buffer. +const MAX_ENTRIES: usize = 256; + +/// Global access handle for the debug log store. +static STORE: Lazy = Lazy::new(DebugLogStore::default); +static LOGGER: OnceCell<()> = OnceCell::new(); +static DEBUG_LOGGER: DebugLogger = DebugLogger; + +/// Install the in-process logger that feeds the debug log ring buffer. +pub fn install_global_logger() { + LOGGER.get_or_init(|| { + if log::set_logger(&DEBUG_LOGGER).is_ok() { + log::set_max_level(LevelFilter::Trace); + } + }); +} + +/// Per-application state for presenting and acknowledging debug log entries. +#[derive(Debug)] +pub struct DebugLogState { + visible: bool, + last_seen_id: u64, +} + +impl DebugLogState { + pub fn new() -> Self { + let last_seen_id = STORE.latest_id(); + Self { + visible: false, + last_seen_id, + } + } + + pub fn toggle_visible(&mut self) -> bool { + self.visible = !self.visible; + if self.visible { + self.mark_seen(); + } + self.visible + } + + pub fn set_visible(&mut self, visible: bool) { + self.visible = visible; + if visible { + self.mark_seen(); + } + } + + pub fn is_visible(&self) -> bool { + self.visible + } + + pub fn entries(&self) -> Vec { + STORE.snapshot() + } + + pub fn take_unseen(&mut self) -> Vec { + let entries = STORE.entries_since(self.last_seen_id); + if let Some(entry) = entries.last() { + self.last_seen_id = entry.id; + } + entries + } + + pub fn has_unseen(&self) -> bool { + STORE.latest_id() > self.last_seen_id + } + + fn mark_seen(&mut self) { + self.last_seen_id = STORE.latest_id(); + } +} + +impl Default for DebugLogState { + fn default() -> Self { + Self::new() + } +} + +/// Metadata describing a single debug log entry. +#[derive(Clone, Debug)] +pub struct DebugLogEntry { + pub id: u64, + pub timestamp: DateTime, + pub level: Level, + pub target: String, + pub message: String, +} + +#[derive(Default)] +struct DebugLogStore { + inner: Mutex, +} + +#[derive(Default)] +struct Inner { + entries: VecDeque, + next_id: u64, +} + +impl DebugLogStore { + fn snapshot(&self) -> Vec { + let inner = self.inner.lock().unwrap(); + inner.entries.iter().cloned().collect() + } + + fn latest_id(&self) -> u64 { + let inner = self.inner.lock().unwrap(); + inner.next_id + } + + fn entries_since(&self, last_seen_id: u64) -> Vec { + let inner = self.inner.lock().unwrap(); + inner + .entries + .iter() + .filter(|entry| entry.id > last_seen_id) + .cloned() + .collect() + } + + fn push(&self, level: Level, target: &str, message: &str) -> DebugLogEntry { + let sanitized = sanitize_message(message); + let mut inner = self.inner.lock().unwrap(); + inner.next_id = inner.next_id.saturating_add(1); + let entry = DebugLogEntry { + id: inner.next_id, + timestamp: Local::now(), + level, + target: target.to_string(), + message: sanitized, + }; + inner.entries.push_back(entry.clone()); + while inner.entries.len() > MAX_ENTRIES { + inner.entries.pop_front(); + } + entry + } +} + +struct DebugLogger; + +impl log::Log for DebugLogger { + fn enabled(&self, metadata: &Metadata) -> bool { + metadata.level() <= LevelFilter::Trace + } + + fn log(&self, record: &Record) { + if !self.enabled(record.metadata()) { + return; + } + + // Only persist warnings and errors in the in-memory buffer. + if record.level() < Level::Warn { + return; + } + + let message = record.args().to_string(); + let entry = STORE.push(record.level(), record.target(), &message); + + if record.level() == Level::Error { + eprintln!( + "[owlen:error][{}] {}", + entry.timestamp.format("%Y-%m-%d %H:%M:%S"), + entry.message + ); + } else if record.level() == Level::Warn { + eprintln!( + "[owlen:warn][{}] {}", + entry.timestamp.format("%Y-%m-%d %H:%M:%S"), + entry.message + ); + } + } + + fn flush(&self) {} +} + +fn sanitize_message(message: &str) -> String { + static AUTH_HEADER: Lazy = + Lazy::new(|| Regex::new(r"(?i)\b(authorization)(\s*[:=]\s*)([^\r\n]+)").unwrap()); + static GENERIC_SECRET: Lazy = + Lazy::new(|| Regex::new(r"(?i)\b(api[_-]?key|token)(\s*[:=]\s*)([^,\s;]+)").unwrap()); + static BEARER_TOKEN: Lazy = + Lazy::new(|| Regex::new(r"(?i)\bBearer\s+[A-Za-z0-9._\-+/=]+").unwrap()); + + let step = AUTH_HEADER.replace_all(message, |caps: ®ex::Captures<'_>| { + format!("{}{}", &caps[1], &caps[2]) + }); + + let step = GENERIC_SECRET.replace_all(&step, |caps: ®ex::Captures<'_>| { + format!("{}{}", &caps[1], &caps[2]) + }); + + BEARER_TOKEN + .replace_all(&step, "Bearer ") + .into_owned() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sanitize_masks_common_tokens() { + let input = + "Authorization: Bearer abc123 token=xyz456 KEY=value Authorization=Token secretStuff"; + let sanitized = sanitize_message(input); + assert!(!sanitized.contains("abc123")); + assert!(!sanitized.contains("xyz456")); + assert!(!sanitized.contains("secretStuff")); + assert_eq!(sanitized, "Authorization: "); + } + + #[test] + fn ring_buffer_discards_old_entries() { + install_global_logger(); + let initial_latest = STORE.latest_id(); + for idx in 0..(MAX_ENTRIES as u64 + 10) { + let message = format!("warn #{idx}"); + STORE.push(Level::Warn, "test", &message); + } + + let entries = STORE.snapshot(); + assert_eq!(entries.len(), MAX_ENTRIES); + assert!(entries.first().unwrap().id > initial_latest); + } +} diff --git a/crates/owlen-tui/src/state/keymap.rs b/crates/owlen-tui/src/state/keymap.rs index f9e9f03..7d9e60f 100644 --- a/crates/owlen-tui/src/state/keymap.rs +++ b/crates/owlen-tui/src/state/keymap.rs @@ -284,7 +284,6 @@ fn normalize_modifiers(modifiers: KeyModifiers) -> KeyModifiers { #[cfg(test)] mod tests { use super::*; - use crate::widgets::model_picker::FilterMode; use crossterm::event::{KeyCode, KeyModifiers}; #[test] diff --git a/crates/owlen-tui/src/state/mod.rs b/crates/owlen-tui/src/state/mod.rs index 3b802e5..d1dd0c3 100644 --- a/crates/owlen-tui/src/state/mod.rs +++ b/crates/owlen-tui/src/state/mod.rs @@ -6,6 +6,7 @@ //! to test in isolation. mod command_palette; +mod debug_log; mod file_icons; mod file_tree; mod keymap; @@ -13,6 +14,7 @@ mod search; mod workspace; pub use command_palette::{CommandPalette, ModelPaletteEntry, PaletteGroup, PaletteSuggestion}; +pub use debug_log::{DebugLogEntry, DebugLogState, install_global_logger}; pub use file_icons::{FileIconResolver, FileIconSet, IconDetection}; pub use file_tree::{ FileNode, FileTreeState, FilterMode as FileFilterMode, GitDecoration, VisibleFileEntry, diff --git a/crates/owlen-tui/src/ui.rs b/crates/owlen-tui/src/ui.rs index ed257ce..d74202c 100644 --- a/crates/owlen-tui/src/ui.rs +++ b/crates/owlen-tui/src/ui.rs @@ -1,3 +1,4 @@ +use log::Level; use pathdiff::diff_paths; use ratatui::Frame; use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; @@ -366,6 +367,20 @@ pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) { render_code_workspace(frame, area, app); } + if app.is_debug_log_visible() { + let min_height = 6; + let computed_height = content_area.height.saturating_div(3).max(min_height); + let panel_height = computed_height.min(content_area.height); + + if panel_height >= 4 { + let y = content_area + .y + .saturating_add(content_area.height.saturating_sub(panel_height)); + let log_area = Rect::new(content_area.x, y, content_area.width, panel_height); + render_debug_log_panel(frame, log_area, app); + } + } + render_toasts(frame, app, content_area); } @@ -1964,6 +1979,134 @@ fn render_system_output(frame: &mut Frame<'_>, area: Rect, app: &ChatApp, messag frame.render_widget(paragraph, area); } +fn render_debug_log_panel(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) { + let theme = app.theme(); + frame.render_widget(Clear, area); + + let title = Line::from(vec![ + Span::styled( + " Debug log ", + Style::default() + .fg(theme.pane_header_active) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + "warnings & errors", + Style::default() + .fg(theme.pane_hint_text) + .add_modifier(Modifier::DIM), + ), + ]); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(theme.focused_panel_border)) + .style(Style::default().bg(theme.background).fg(theme.text)) + .title(title); + + let inner = block.inner(area); + frame.render_widget(block, area); + + if inner.width == 0 || inner.height == 0 { + return; + } + + let entries = app.debug_log_entries(); + let available_rows = inner.height as usize; + let mut lines: Vec = Vec::new(); + + if entries.is_empty() { + lines.push(Line::styled( + "No warnings captured this session.", + Style::default() + .fg(theme.pane_hint_text) + .add_modifier(Modifier::DIM), + )); + } else { + let total_entries = entries.len(); + let mut subset: Vec<_> = entries.into_iter().rev().take(available_rows).collect(); + subset.reverse(); + + if total_entries > subset.len() && subset.len() == available_rows && !subset.is_empty() { + subset.remove(0); + } + + let overflow = total_entries.saturating_sub(subset.len()); + if overflow > 0 { + lines.push(Line::styled( + format!("… {overflow} older entries not shown"), + Style::default() + .fg(theme.pane_hint_text) + .add_modifier(Modifier::DIM), + )); + } + + for entry in subset { + let (label, badge_style, message_style) = debug_level_styles(entry.level, theme); + let timestamp = entry.timestamp.format("%H:%M:%S"); + + let mut spans = vec![ + Span::styled(format!(" {label} "), badge_style), + Span::raw(" "), + Span::styled( + timestamp.to_string(), + Style::default() + .fg(theme.pane_hint_text) + .add_modifier(Modifier::DIM), + ), + ]; + + if !entry.target.is_empty() { + spans.push(Span::raw(" ")); + spans.push(Span::styled( + entry.target, + Style::default().fg(theme.pane_header_active), + )); + } + + spans.push(Span::raw(" ")); + spans.push(Span::styled(entry.message, message_style)); + lines.push(Line::from(spans)); + } + } + + let paragraph = Paragraph::new(lines) + .wrap(Wrap { trim: true }) + .alignment(Alignment::Left) + .style(Style::default().bg(theme.background)); + + frame.render_widget(paragraph, inner); +} + +fn debug_level_styles(level: Level, theme: &Theme) -> (&'static str, Style, Style) { + match level { + Level::Error => ( + "ERR", + Style::default() + .fg(theme.background) + .bg(theme.error) + .add_modifier(Modifier::BOLD), + Style::default().fg(theme.error), + ), + Level::Warn => ( + "WARN", + Style::default() + .fg(theme.background) + .bg(theme.agent_action) + .add_modifier(Modifier::BOLD), + Style::default().fg(theme.agent_action), + ), + _ => ( + "INFO", + Style::default() + .fg(theme.background) + .bg(theme.info) + .add_modifier(Modifier::BOLD), + Style::default().fg(theme.text), + ), + } +} + fn calculate_wrapped_line_count<'a, I>(lines: I, available_width: u16) -> usize where I: IntoIterator, @@ -2944,6 +3087,7 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) { Line::from(" Ctrl+↑/↓ → adjust chat ↔ thinking split"), Line::from(" Alt+←/→/↑/↓ → resize focused code pane"), Line::from(" g then t → expand files panel and focus it"), + Line::from(" F12 → toggle debug log panel"), Line::from(" F1 or ? → toggle this help overlay"), Line::from(""), Line::from(vec![Span::styled( @@ -3086,6 +3230,7 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) { )]), Line::from(" :h, :help → show this help"), Line::from(" F1 or ? → toggle help overlay"), + Line::from(" F12 → toggle debug log panel"), Line::from(" :files, :explorer → toggle files panel"), Line::from(" :markdown [on|off] → toggle markdown rendering"), Line::from(" Ctrl+←/→ → resize files panel"),