Skip to main content

vtcode_tui/core_tui/
log.rs

1use std::fmt;
2use std::sync::{Arc, Mutex, RwLock};
3use std::time::SystemTime;
4
5use humantime::format_rfc3339_seconds;
6use once_cell::sync::Lazy;
7use ratatui::{
8    style::{Color, Modifier, Style},
9    text::{Line, Span, Text},
10};
11use syntect::easy::HighlightLines;
12use syntect::highlighting::Theme;
13use syntect::parsing::SyntaxReference;
14use tokio::sync::mpsc::UnboundedSender;
15use tracing::{Event, Level, Subscriber, field::Visit};
16use tracing_subscriber::layer::{Context, Layer};
17use tracing_subscriber::registry::LookupSpan;
18
19use crate::ui::syntax_highlight::{
20    default_theme_name, find_syntax_by_extension, find_syntax_by_name, find_syntax_plain_text,
21    load_theme, syntax_set,
22};
23
24#[derive(Debug, Clone)]
25pub struct LogEntry {
26    pub formatted: Arc<str>,
27    pub timestamp: Arc<str>,
28    pub level: Level,
29    pub target: Arc<str>,
30    pub message: Arc<str>,
31}
32
33#[derive(Default)]
34struct LogForwarder {
35    sender: Mutex<Option<UnboundedSender<LogEntry>>>,
36}
37
38impl LogForwarder {
39    fn set_sender(&self, sender: UnboundedSender<LogEntry>) {
40        match self.sender.lock() {
41            Ok(mut guard) => {
42                *guard = Some(sender);
43            }
44            Err(err) => {
45                tracing::warn!("failed to set TUI log sender; lock poisoned: {err}");
46            }
47        }
48    }
49
50    fn clear_sender(&self) {
51        match self.sender.lock() {
52            Ok(mut guard) => {
53                *guard = None;
54            }
55            Err(err) => {
56                tracing::warn!("failed to clear TUI log sender; lock poisoned: {err}");
57            }
58        }
59    }
60
61    fn send(&self, entry: LogEntry) {
62        match self.sender.lock() {
63            Ok(guard) => {
64                if let Some(sender) = guard.as_ref() {
65                    let _ = sender.send(entry);
66                }
67            }
68            Err(err) => {
69                tracing::warn!("failed to forward TUI log entry; lock poisoned: {err}");
70            }
71        }
72    }
73}
74
75static LOG_FORWARDER: Lazy<Arc<LogForwarder>> = Lazy::new(|| Arc::new(LogForwarder::default()));
76
77static LOG_THEME_NAME: Lazy<RwLock<Option<String>>> = Lazy::new(|| RwLock::new(None));
78static LOG_THEME_CACHE: Lazy<RwLock<Option<(String, Theme)>>> = Lazy::new(|| RwLock::new(None));
79
80#[derive(Default)]
81struct FieldVisitor {
82    message: Option<String>,
83    extras: Vec<(String, String)>,
84}
85
86impl Visit for FieldVisitor {
87    fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn fmt::Debug) {
88        let rendered = format!("{:?}", value);
89        if field.name() == "message" {
90            self.message = Some(rendered);
91        } else {
92            self.extras.push((field.name().to_string(), rendered));
93        }
94    }
95
96    fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
97        if field.name() == "message" {
98            self.message = Some(value.to_string());
99        } else {
100            self.extras
101                .push((field.name().to_string(), value.to_string()));
102        }
103    }
104}
105
106pub struct TuiLogLayer {
107    forwarder: Arc<LogForwarder>,
108}
109
110impl TuiLogLayer {
111    pub fn new() -> Self {
112        Self {
113            forwarder: LOG_FORWARDER.clone(),
114        }
115    }
116}
117
118impl Default for TuiLogLayer {
119    fn default() -> Self {
120        Self::new()
121    }
122}
123
124impl TuiLogLayer {
125    pub fn set_sender(sender: UnboundedSender<LogEntry>) {
126        LOG_FORWARDER.set_sender(sender);
127    }
128
129    pub fn clear_sender() {
130        LOG_FORWARDER.clear_sender();
131    }
132}
133
134impl<S> Layer<S> for TuiLogLayer
135where
136    S: Subscriber + for<'span> LookupSpan<'span>,
137{
138    fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
139        let level = *event.metadata().level();
140
141        // Always filter out TRACE level
142        if level == tracing::Level::TRACE {
143            return;
144        }
145
146        // Only show DEBUG level logs when in debug mode
147        if level == tracing::Level::DEBUG && !crate::ui::tui::panic_hook::is_debug_mode() {
148            return;
149        }
150
151        let mut visitor = FieldVisitor::default();
152        event.record(&mut visitor);
153
154        let message = visitor
155            .message
156            .unwrap_or_else(|| "(no message)".to_string());
157        let extras = if visitor.extras.is_empty() {
158            String::new()
159        } else {
160            let rendered = visitor
161                .extras
162                .into_iter()
163                .map(|(key, value)| format!("{key}={value}"))
164                .collect::<Vec<_>>()
165                .join(" ");
166            format!(" {rendered}")
167        };
168
169        // Filter out redundant system warnings that clutter the UI
170        let combined_message = format!("{}{}", message, extras);
171        if should_filter_log_message(&combined_message) {
172            return;
173        }
174
175        let timestamp = format_rfc3339_seconds(SystemTime::now()).to_string();
176        let line = format!(
177            "{} {:<5} {} {}{}",
178            timestamp,
179            event.metadata().level(),
180            event.metadata().target(),
181            message,
182            extras
183        );
184
185        let log_target = Arc::from(event.metadata().target().to_string().into_boxed_str());
186        let message_with_extras = Arc::from(format!("{message}{extras}").into_boxed_str());
187        self.forwarder.send(LogEntry {
188            formatted: Arc::from(line.into_boxed_str()),
189            timestamp: Arc::from(timestamp.into_boxed_str()),
190            level: *event.metadata().level(),
191            target: log_target,
192            message: message_with_extras,
193        });
194    }
195}
196
197/// Check if a log message should be filtered out because it's redundant or unhelpful
198fn should_filter_log_message(message: &str) -> bool {
199    let lower_message = message.to_lowercase();
200
201    // Filter out MallocStackLogging related messages
202    let malloc_filters = [
203        "mallocstacklogging",
204        "malscollogging",
205        "no such file or directory",
206        "can't turn off malloc stack logging",
207        "could not tag msl-related memory",
208        "those pages will be included in process footprint",
209        "process is not in a debuggable environment",
210        "unsetting mallocstackloggingdirectory environment variable",
211    ];
212
213    malloc_filters
214        .iter()
215        .any(|&filter| lower_message.contains(&filter.to_lowercase()))
216}
217
218pub fn make_tui_log_layer() -> TuiLogLayer {
219    TuiLogLayer::new()
220}
221
222pub fn register_tui_log_sender(sender: UnboundedSender<LogEntry>) {
223    TuiLogLayer::set_sender(sender);
224}
225
226pub fn clear_tui_log_sender() {
227    TuiLogLayer::clear_sender();
228}
229
230pub fn set_log_theme_name(theme: Option<String>) {
231    let Ok(mut slot) = LOG_THEME_NAME.write() else {
232        tracing::warn!("failed to set TUI log theme name; theme lock poisoned");
233        return;
234    };
235    *slot = theme
236        .map(|t| t.trim().to_string())
237        .filter(|t| !t.is_empty());
238    drop(slot);
239    if let Ok(mut cache) = LOG_THEME_CACHE.write() {
240        *cache = None;
241    } else {
242        tracing::warn!("failed to clear TUI log theme cache; cache lock poisoned");
243    }
244}
245
246fn theme_for_current_config() -> Theme {
247    let theme_name = {
248        let Ok(slot) = LOG_THEME_NAME.read() else {
249            tracing::warn!("failed to read TUI log theme name; falling back to default theme");
250            return load_theme(&default_theme_name(), true);
251        };
252        slot.clone()
253    };
254    let resolved_name = theme_name.clone().unwrap_or_else(default_theme_name);
255    {
256        if let Ok(cache) = LOG_THEME_CACHE.read()
257            && let Some((cached_name, cached)) = &*cache
258            && *cached_name == resolved_name
259        {
260            return cached.clone();
261        }
262    }
263
264    let theme = load_theme(&resolved_name, true);
265    if let Ok(mut cache) = LOG_THEME_CACHE.write() {
266        *cache = Some((resolved_name, theme.clone()));
267    }
268    theme
269}
270
271fn syntect_to_ratatui_style(style: syntect::highlighting::Style) -> Style {
272    let fg = style.foreground;
273    Style::default().fg(Color::Rgb(fg.r, fg.g, fg.b))
274}
275
276fn highlight_lines_to_text<'a>(
277    lines: impl Iterator<Item = &'a str>,
278    syntax: &SyntaxReference,
279) -> Text<'static> {
280    let theme = theme_for_current_config();
281    let ss = syntax_set();
282    let mut highlighter = HighlightLines::new(syntax, &theme);
283    let mut result_lines = Vec::new();
284
285    for line in lines {
286        match highlighter.highlight_line(line, ss) {
287            Ok(ranges) => {
288                let spans: Vec<Span<'static>> = ranges
289                    .into_iter()
290                    .map(|(style, text)| {
291                        Span::styled(text.to_owned(), syntect_to_ratatui_style(style))
292                    })
293                    .collect();
294                result_lines.push(Line::from(spans));
295            }
296            Err(_) => {
297                result_lines.push(Line::raw(line.to_owned()));
298            }
299        }
300    }
301
302    Text::from(result_lines)
303}
304
305fn log_level_style(level: &Level) -> Style {
306    match *level {
307        Level::ERROR => Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
308        Level::WARN => Style::default()
309            .fg(Color::Yellow)
310            .add_modifier(Modifier::BOLD),
311        Level::INFO => Style::default().fg(Color::Green),
312        Level::DEBUG => Style::default().fg(Color::Blue),
313        Level::TRACE => Style::default().fg(Color::DarkGray),
314    }
315}
316
317fn log_prefix(entry: &LogEntry) -> Vec<Span<'static>> {
318    let timestamp_style = Style::default().fg(Color::DarkGray);
319    vec![
320        Span::styled(format!("[{}]", entry.timestamp), timestamp_style),
321        Span::raw(" "),
322        Span::styled(format!("{:<5}", entry.level), log_level_style(&entry.level)),
323        Span::raw(" "),
324        Span::styled(entry.target.to_string(), Style::default().fg(Color::Gray)),
325        Span::raw(" "),
326    ]
327}
328
329fn prepend_metadata(text: &mut Text<'static>, entry: &LogEntry) {
330    let mut prefix = log_prefix(entry);
331    if let Some(first) = text.lines.first_mut() {
332        let mut merged = Vec::with_capacity(prefix.len() + first.spans.len());
333        merged.append(&mut prefix);
334        merged.append(&mut first.spans);
335        first.spans = merged;
336    } else {
337        text.lines.push(Line::from(prefix));
338    }
339}
340
341fn select_syntax(message: &str) -> &'static SyntaxReference {
342    let trimmed = message.trim_start();
343    if !trimmed.is_empty() {
344        if (trimmed.starts_with('{') || trimmed.starts_with('['))
345            && let Some(json) =
346                find_syntax_by_name("JSON").or_else(|| find_syntax_by_extension("json"))
347        {
348            return json;
349        }
350
351        if (trimmed.contains('$') || trimmed.contains(';'))
352            && let Some(shell) =
353                find_syntax_by_name("Bash").or_else(|| find_syntax_by_extension("sh"))
354        {
355            return shell;
356        }
357    }
358
359    find_syntax_by_name("Rust")
360        .or_else(|| find_syntax_by_extension("rs"))
361        .unwrap_or_else(find_syntax_plain_text)
362}
363
364pub fn highlight_log_entry(entry: &LogEntry) -> Text<'static> {
365    let ss = syntax_set();
366    if ss.syntaxes().is_empty() {
367        let mut text = Text::raw(entry.formatted.as_ref().to_string());
368        prepend_metadata(&mut text, entry);
369        return text;
370    }
371
372    let syntax = select_syntax(entry.message.as_ref());
373    let mut highlighted = highlight_lines_to_text(entry.message.lines(), syntax);
374    prepend_metadata(&mut highlighted, entry);
375    highlighted
376}