feat(tui): debug log panel toggle

This commit is contained in:
2025-10-18 03:18:34 +02:00
parent c49e7f4b22
commit 218ebbf32f
9 changed files with 499 additions and 5 deletions

View File

@@ -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 }

View File

@@ -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"

View File

@@ -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<ConsentDialogState>, // 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<DebugLogEntry> {
self.debug_log.entries()
}
pub fn toasts(&self) -> impl Iterator<Item = &Toast> {
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
}

View File

@@ -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.

View File

@@ -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 }
}

View File

@@ -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<DebugLogStore> = 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<DebugLogEntry> {
STORE.snapshot()
}
pub fn take_unseen(&mut self) -> Vec<DebugLogEntry> {
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<Local>,
pub level: Level,
pub target: String,
pub message: String,
}
#[derive(Default)]
struct DebugLogStore {
inner: Mutex<Inner>,
}
#[derive(Default)]
struct Inner {
entries: VecDeque<DebugLogEntry>,
next_id: u64,
}
impl DebugLogStore {
fn snapshot(&self) -> Vec<DebugLogEntry> {
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<DebugLogEntry> {
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<Regex> =
Lazy::new(|| Regex::new(r"(?i)\b(authorization)(\s*[:=]\s*)([^\r\n]+)").unwrap());
static GENERIC_SECRET: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?i)\b(api[_-]?key|token)(\s*[:=]\s*)([^,\s;]+)").unwrap());
static BEARER_TOKEN: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?i)\bBearer\s+[A-Za-z0-9._\-+/=]+").unwrap());
let step = AUTH_HEADER.replace_all(message, |caps: &regex::Captures<'_>| {
format!("{}{}<redacted>", &caps[1], &caps[2])
});
let step = GENERIC_SECRET.replace_all(&step, |caps: &regex::Captures<'_>| {
format!("{}{}<redacted>", &caps[1], &caps[2])
});
BEARER_TOKEN
.replace_all(&step, "Bearer <redacted>")
.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: <redacted>");
}
#[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);
}
}

View File

@@ -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]

View File

@@ -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,

View File

@@ -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<Line> = 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<Item = &'a str>,
@@ -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"),