vtcode-tui 0.98.2

Reusable TUI primitives and session API for VT Code-style terminal interfaces
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
use std::fmt;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, RwLock};
use std::time::SystemTime;

use humantime::format_rfc3339_seconds;
use once_cell::sync::Lazy;
use ratatui::{
    style::{Color, Modifier, Style},
    text::{Line, Span, Text},
};
use syntect::easy::HighlightLines;
use syntect::highlighting::Theme;
use syntect::parsing::SyntaxReference;
use tokio::sync::mpsc::UnboundedSender;
use tracing::{Event, Level, Subscriber, field::Visit};
use tracing_subscriber::layer::{Context, Layer};
use tracing_subscriber::registry::LookupSpan;

use crate::ui::syntax_highlight::{
    default_theme_name, find_syntax_by_extension, find_syntax_by_name, find_syntax_plain_text,
    load_theme, syntax_set,
};

#[derive(Debug, Clone)]
pub struct LogEntry {
    pub formatted: Arc<str>,
    pub timestamp: Arc<str>,
    pub level: Level,
    pub target: Arc<str>,
    pub message: Arc<str>,
}

#[derive(Default)]
struct LogForwarder {
    sender: Mutex<Option<UnboundedSender<LogEntry>>>,
}

impl LogForwarder {
    fn has_sender(&self) -> bool {
        match self.sender.lock() {
            Ok(guard) => guard.is_some(),
            Err(_) => false,
        }
    }

    fn set_sender(&self, sender: UnboundedSender<LogEntry>) {
        match self.sender.lock() {
            Ok(mut guard) => {
                *guard = Some(sender);
            }
            Err(err) => {
                tracing::warn!("failed to set TUI log sender; lock poisoned: {err}");
            }
        }
    }

    fn clear_sender(&self) {
        match self.sender.lock() {
            Ok(mut guard) => {
                *guard = None;
            }
            Err(err) => {
                tracing::warn!("failed to clear TUI log sender; lock poisoned: {err}");
            }
        }
    }

    fn send(&self, entry: LogEntry) {
        match self.sender.lock() {
            Ok(guard) => {
                if let Some(sender) = guard.as_ref() {
                    let _ = sender.send(entry);
                }
            }
            Err(err) => {
                tracing::warn!("failed to forward TUI log entry; lock poisoned: {err}");
            }
        }
    }
}

static LOG_FORWARDER: Lazy<Arc<LogForwarder>> = Lazy::new(|| Arc::new(LogForwarder::default()));
static TUI_LOG_CAPTURE_ENABLED: AtomicBool = AtomicBool::new(cfg!(debug_assertions));

static LOG_THEME_NAME: Lazy<RwLock<Option<String>>> = Lazy::new(|| RwLock::new(None));
static LOG_THEME_CACHE: Lazy<RwLock<Option<(String, Theme)>>> = Lazy::new(|| RwLock::new(None));

#[derive(Default)]
struct FieldVisitor {
    message: Option<String>,
    extras: Vec<(String, String)>,
}

impl Visit for FieldVisitor {
    fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn fmt::Debug) {
        let rendered = format!("{:?}", value);
        if field.name() == "message" {
            self.message = Some(rendered);
        } else {
            self.extras.push((field.name().to_string(), rendered));
        }
    }

    fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
        if field.name() == "message" {
            self.message = Some(value.to_string());
        } else {
            self.extras
                .push((field.name().to_string(), value.to_string()));
        }
    }
}

pub struct TuiLogLayer {
    forwarder: Arc<LogForwarder>,
}

impl TuiLogLayer {
    pub fn new() -> Self {
        Self {
            forwarder: LOG_FORWARDER.clone(),
        }
    }
}

impl Default for TuiLogLayer {
    fn default() -> Self {
        Self::new()
    }
}

impl TuiLogLayer {
    pub fn set_sender(sender: UnboundedSender<LogEntry>) {
        LOG_FORWARDER.set_sender(sender);
    }

    pub fn clear_sender() {
        LOG_FORWARDER.clear_sender();
    }
}

pub fn set_tui_log_capture_enabled(enabled: bool) {
    TUI_LOG_CAPTURE_ENABLED.store(enabled, Ordering::SeqCst);
}

pub fn is_tui_log_capture_enabled() -> bool {
    TUI_LOG_CAPTURE_ENABLED.load(Ordering::SeqCst)
}

impl<S> Layer<S> for TuiLogLayer
where
    S: Subscriber + for<'span> LookupSpan<'span>,
{
    fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
        if !is_tui_log_capture_enabled() {
            return;
        }

        // If no inline consumer is attached, avoid formatting work entirely.
        if !self.forwarder.has_sender() {
            return;
        }

        let level = *event.metadata().level();

        // Always filter out TRACE level
        if level == Level::TRACE {
            return;
        }

        // Only show DEBUG level logs when in debug mode
        if level == Level::DEBUG && !crate::ui::tui::panic_hook::is_debug_mode() {
            return;
        }

        // Only show ERROR level logs in TUI when explicitly allowed via
        // ui.show_diagnostics_in_transcript config
        if level == Level::ERROR && !crate::ui::tui::panic_hook::show_diagnostics() {
            return;
        }

        let mut visitor = FieldVisitor::default();
        event.record(&mut visitor);

        let message = visitor
            .message
            .unwrap_or_else(|| "(no message)".to_string());
        let extras = if visitor.extras.is_empty() {
            String::new()
        } else {
            let rendered = visitor
                .extras
                .into_iter()
                .map(|(key, value)| format!("{key}={value}"))
                .collect::<Vec<_>>()
                .join(" ");
            format!(" {rendered}")
        };

        // Filter out redundant system warnings that clutter the UI
        let combined_message = format!("{}{}", message, extras);
        if should_filter_log_message(&combined_message) {
            return;
        }

        let timestamp = format_rfc3339_seconds(SystemTime::now()).to_string();
        let line = format!(
            "{} {:<5} {} {}{}",
            timestamp,
            event.metadata().level(),
            event.metadata().target(),
            message,
            extras
        );

        let log_target = Arc::from(event.metadata().target().to_string().into_boxed_str());
        let message_with_extras = Arc::from(format!("{message}{extras}").into_boxed_str());
        self.forwarder.send(LogEntry {
            formatted: Arc::from(line.into_boxed_str()),
            timestamp: Arc::from(timestamp.into_boxed_str()),
            level: *event.metadata().level(),
            target: log_target,
            message: message_with_extras,
        });
    }
}

/// Check if a log message should be filtered out because it's redundant or unhelpful
fn should_filter_log_message(message: &str) -> bool {
    let lower_message = message.to_lowercase();

    // Filter out MallocStackLogging related messages
    let malloc_filters = [
        "mallocstacklogging",
        "malscollogging",
        "no such file or directory",
        "can't turn off malloc stack logging",
        "could not tag msl-related memory",
        "those pages will be included in process footprint",
        "process is not in a debuggable environment",
        "unsetting mallocstackloggingdirectory environment variable",
    ];

    malloc_filters
        .iter()
        .any(|&filter| lower_message.contains(&filter.to_lowercase()))
}

pub fn make_tui_log_layer() -> TuiLogLayer {
    TuiLogLayer::new()
}

pub fn register_tui_log_sender(sender: UnboundedSender<LogEntry>) {
    TuiLogLayer::set_sender(sender);
}

pub fn clear_tui_log_sender() {
    TuiLogLayer::clear_sender();
}

pub fn set_log_theme_name(theme: Option<String>) {
    let Ok(mut slot) = LOG_THEME_NAME.write() else {
        tracing::warn!("failed to set TUI log theme name; theme lock poisoned");
        return;
    };
    *slot = theme
        .map(|t| t.trim().to_string())
        .filter(|t| !t.is_empty());
    drop(slot);
    if let Ok(mut cache) = LOG_THEME_CACHE.write() {
        *cache = None;
    } else {
        tracing::warn!("failed to clear TUI log theme cache; cache lock poisoned");
    }
}

fn theme_for_current_config() -> Theme {
    let theme_name = {
        let Ok(slot) = LOG_THEME_NAME.read() else {
            tracing::warn!("failed to read TUI log theme name; falling back to default theme");
            return load_theme(&default_theme_name(), true);
        };
        slot.clone()
    };
    let resolved_name = theme_name.clone().unwrap_or_else(default_theme_name);
    {
        if let Ok(cache) = LOG_THEME_CACHE.read()
            && let Some((cached_name, cached)) = &*cache
            && *cached_name == resolved_name
        {
            return cached.clone();
        }
    }

    let theme = load_theme(&resolved_name, true);
    if let Ok(mut cache) = LOG_THEME_CACHE.write() {
        *cache = Some((resolved_name, theme.clone()));
    }
    theme
}

fn syntect_to_ratatui_style(style: syntect::highlighting::Style) -> Style {
    let fg = style.foreground;
    Style::default().fg(Color::Rgb(fg.r, fg.g, fg.b))
}

fn highlight_lines_to_text<'a>(
    lines: impl Iterator<Item = &'a str>,
    syntax: &SyntaxReference,
) -> Text<'static> {
    let theme = theme_for_current_config();
    let ss = syntax_set();
    let mut highlighter = HighlightLines::new(syntax, &theme);
    let mut result_lines = Vec::new();

    for line in lines {
        match highlighter.highlight_line(line, ss) {
            Ok(ranges) => {
                let spans: Vec<Span<'static>> = ranges
                    .into_iter()
                    .map(|(style, text)| {
                        Span::styled(text.to_owned(), syntect_to_ratatui_style(style))
                    })
                    .collect();
                result_lines.push(Line::from(spans));
            }
            Err(_) => {
                result_lines.push(Line::raw(line.to_owned()));
            }
        }
    }

    Text::from(result_lines)
}

fn log_level_style(level: &Level) -> Style {
    match *level {
        Level::ERROR => Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
        Level::WARN => Style::default()
            .fg(Color::Yellow)
            .add_modifier(Modifier::BOLD),
        Level::INFO => Style::default().fg(Color::Green),
        Level::DEBUG => Style::default().fg(Color::Blue),
        Level::TRACE => Style::default().fg(Color::DarkGray),
    }
}

fn log_prefix(entry: &LogEntry) -> Vec<Span<'static>> {
    let timestamp_style = Style::default().fg(Color::DarkGray);
    vec![
        Span::styled(format!("[{}]", entry.timestamp), timestamp_style),
        Span::raw(" "),
        Span::styled(format!("{:<5}", entry.level), log_level_style(&entry.level)),
        Span::raw(" "),
        Span::styled(entry.target.to_string(), Style::default().fg(Color::Gray)),
        Span::raw(" "),
    ]
}

fn prepend_metadata(text: &mut Text<'static>, entry: &LogEntry) {
    let mut prefix = log_prefix(entry);
    if let Some(first) = text.lines.first_mut() {
        let mut merged = Vec::with_capacity(prefix.len() + first.spans.len());
        merged.append(&mut prefix);
        merged.append(&mut first.spans);
        first.spans = merged;
    } else {
        text.lines.push(Line::from(prefix));
    }
}

fn select_syntax(message: &str) -> &'static SyntaxReference {
    let trimmed = message.trim_start();
    if !trimmed.is_empty() {
        if (trimmed.starts_with('{') || trimmed.starts_with('['))
            && let Some(json) =
                find_syntax_by_name("JSON").or_else(|| find_syntax_by_extension("json"))
        {
            return json;
        }

        if (trimmed.contains('$') || trimmed.contains(';'))
            && let Some(shell) =
                find_syntax_by_name("Bash").or_else(|| find_syntax_by_extension("sh"))
        {
            return shell;
        }
    }

    find_syntax_by_name("Rust")
        .or_else(|| find_syntax_by_extension("rs"))
        .unwrap_or_else(find_syntax_plain_text)
}

pub fn highlight_log_entry(entry: &LogEntry) -> Text<'static> {
    let ss = syntax_set();
    if ss.syntaxes().is_empty() {
        let mut text = Text::raw(entry.formatted.as_ref().to_string());
        prepend_metadata(&mut text, entry);
        return text;
    }

    let syntax = select_syntax(entry.message.as_ref());
    let mut highlighted = highlight_lines_to_text(entry.message.lines(), syntax);
    prepend_metadata(&mut highlighted, entry);
    highlighted
}