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        // Only show ERROR level logs in TUI when explicitly allowed via
152        // ui.show_diagnostics_in_transcript config
153        if level == tracing::Level::ERROR && !crate::ui::tui::panic_hook::show_diagnostics() {
154            return;
155        }
156
157        let mut visitor = FieldVisitor::default();
158        event.record(&mut visitor);
159
160        let message = visitor
161            .message
162            .unwrap_or_else(|| "(no message)".to_string());
163        let extras = if visitor.extras.is_empty() {
164            String::new()
165        } else {
166            let rendered = visitor
167                .extras
168                .into_iter()
169                .map(|(key, value)| format!("{key}={value}"))
170                .collect::<Vec<_>>()
171                .join(" ");
172            format!(" {rendered}")
173        };
174
175        // Filter out redundant system warnings that clutter the UI
176        let combined_message = format!("{}{}", message, extras);
177        if should_filter_log_message(&combined_message) {
178            return;
179        }
180
181        let timestamp = format_rfc3339_seconds(SystemTime::now()).to_string();
182        let line = format!(
183            "{} {:<5} {} {}{}",
184            timestamp,
185            event.metadata().level(),
186            event.metadata().target(),
187            message,
188            extras
189        );
190
191        let log_target = Arc::from(event.metadata().target().to_string().into_boxed_str());
192        let message_with_extras = Arc::from(format!("{message}{extras}").into_boxed_str());
193        self.forwarder.send(LogEntry {
194            formatted: Arc::from(line.into_boxed_str()),
195            timestamp: Arc::from(timestamp.into_boxed_str()),
196            level: *event.metadata().level(),
197            target: log_target,
198            message: message_with_extras,
199        });
200    }
201}
202
203/// Check if a log message should be filtered out because it's redundant or unhelpful
204fn should_filter_log_message(message: &str) -> bool {
205    let lower_message = message.to_lowercase();
206
207    // Filter out MallocStackLogging related messages
208    let malloc_filters = [
209        "mallocstacklogging",
210        "malscollogging",
211        "no such file or directory",
212        "can't turn off malloc stack logging",
213        "could not tag msl-related memory",
214        "those pages will be included in process footprint",
215        "process is not in a debuggable environment",
216        "unsetting mallocstackloggingdirectory environment variable",
217    ];
218
219    malloc_filters
220        .iter()
221        .any(|&filter| lower_message.contains(&filter.to_lowercase()))
222}
223
224pub fn make_tui_log_layer() -> TuiLogLayer {
225    TuiLogLayer::new()
226}
227
228pub fn register_tui_log_sender(sender: UnboundedSender<LogEntry>) {
229    TuiLogLayer::set_sender(sender);
230}
231
232pub fn clear_tui_log_sender() {
233    TuiLogLayer::clear_sender();
234}
235
236pub fn set_log_theme_name(theme: Option<String>) {
237    let Ok(mut slot) = LOG_THEME_NAME.write() else {
238        tracing::warn!("failed to set TUI log theme name; theme lock poisoned");
239        return;
240    };
241    *slot = theme
242        .map(|t| t.trim().to_string())
243        .filter(|t| !t.is_empty());
244    drop(slot);
245    if let Ok(mut cache) = LOG_THEME_CACHE.write() {
246        *cache = None;
247    } else {
248        tracing::warn!("failed to clear TUI log theme cache; cache lock poisoned");
249    }
250}
251
252fn theme_for_current_config() -> Theme {
253    let theme_name = {
254        let Ok(slot) = LOG_THEME_NAME.read() else {
255            tracing::warn!("failed to read TUI log theme name; falling back to default theme");
256            return load_theme(&default_theme_name(), true);
257        };
258        slot.clone()
259    };
260    let resolved_name = theme_name.clone().unwrap_or_else(default_theme_name);
261    {
262        if let Ok(cache) = LOG_THEME_CACHE.read()
263            && let Some((cached_name, cached)) = &*cache
264            && *cached_name == resolved_name
265        {
266            return cached.clone();
267        }
268    }
269
270    let theme = load_theme(&resolved_name, true);
271    if let Ok(mut cache) = LOG_THEME_CACHE.write() {
272        *cache = Some((resolved_name, theme.clone()));
273    }
274    theme
275}
276
277fn syntect_to_ratatui_style(style: syntect::highlighting::Style) -> Style {
278    let fg = style.foreground;
279    Style::default().fg(Color::Rgb(fg.r, fg.g, fg.b))
280}
281
282fn highlight_lines_to_text<'a>(
283    lines: impl Iterator<Item = &'a str>,
284    syntax: &SyntaxReference,
285) -> Text<'static> {
286    let theme = theme_for_current_config();
287    let ss = syntax_set();
288    let mut highlighter = HighlightLines::new(syntax, &theme);
289    let mut result_lines = Vec::new();
290
291    for line in lines {
292        match highlighter.highlight_line(line, ss) {
293            Ok(ranges) => {
294                let spans: Vec<Span<'static>> = ranges
295                    .into_iter()
296                    .map(|(style, text)| {
297                        Span::styled(text.to_owned(), syntect_to_ratatui_style(style))
298                    })
299                    .collect();
300                result_lines.push(Line::from(spans));
301            }
302            Err(_) => {
303                result_lines.push(Line::raw(line.to_owned()));
304            }
305        }
306    }
307
308    Text::from(result_lines)
309}
310
311fn log_level_style(level: &Level) -> Style {
312    match *level {
313        Level::ERROR => Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
314        Level::WARN => Style::default()
315            .fg(Color::Yellow)
316            .add_modifier(Modifier::BOLD),
317        Level::INFO => Style::default().fg(Color::Green),
318        Level::DEBUG => Style::default().fg(Color::Blue),
319        Level::TRACE => Style::default().fg(Color::DarkGray),
320    }
321}
322
323fn log_prefix(entry: &LogEntry) -> Vec<Span<'static>> {
324    let timestamp_style = Style::default().fg(Color::DarkGray);
325    vec![
326        Span::styled(format!("[{}]", entry.timestamp), timestamp_style),
327        Span::raw(" "),
328        Span::styled(format!("{:<5}", entry.level), log_level_style(&entry.level)),
329        Span::raw(" "),
330        Span::styled(entry.target.to_string(), Style::default().fg(Color::Gray)),
331        Span::raw(" "),
332    ]
333}
334
335fn prepend_metadata(text: &mut Text<'static>, entry: &LogEntry) {
336    let mut prefix = log_prefix(entry);
337    if let Some(first) = text.lines.first_mut() {
338        let mut merged = Vec::with_capacity(prefix.len() + first.spans.len());
339        merged.append(&mut prefix);
340        merged.append(&mut first.spans);
341        first.spans = merged;
342    } else {
343        text.lines.push(Line::from(prefix));
344    }
345}
346
347fn select_syntax(message: &str) -> &'static SyntaxReference {
348    let trimmed = message.trim_start();
349    if !trimmed.is_empty() {
350        if (trimmed.starts_with('{') || trimmed.starts_with('['))
351            && let Some(json) =
352                find_syntax_by_name("JSON").or_else(|| find_syntax_by_extension("json"))
353        {
354            return json;
355        }
356
357        if (trimmed.contains('$') || trimmed.contains(';'))
358            && let Some(shell) =
359                find_syntax_by_name("Bash").or_else(|| find_syntax_by_extension("sh"))
360        {
361            return shell;
362        }
363    }
364
365    find_syntax_by_name("Rust")
366        .or_else(|| find_syntax_by_extension("rs"))
367        .unwrap_or_else(find_syntax_plain_text)
368}
369
370pub fn highlight_log_entry(entry: &LogEntry) -> Text<'static> {
371    let ss = syntax_set();
372    if ss.syntaxes().is_empty() {
373        let mut text = Text::raw(entry.formatted.as_ref().to_string());
374        prepend_metadata(&mut text, entry);
375        return text;
376    }
377
378    let syntax = select_syntax(entry.message.as_ref());
379    let mut highlighted = highlight_lines_to_text(entry.message.lines(), syntax);
380    prepend_metadata(&mut highlighted, entry);
381    highlighted
382}