Skip to main content

vtcode_tui/core_tui/
log.rs

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