Skip to main content

vtcode_core/utils/
transcript.rs

1use once_cell::sync::Lazy;
2use parking_lot::RwLock;
3use std::cell::RefCell;
4use std::collections::VecDeque;
5use std::sync::Arc;
6
7use crate::ui::{InlineHandle, InlineMessageKind, InlineSegment, InlineTextStyle};
8pub use crate::utils::message_style::MessageStyle;
9
10const MAX_LINES: usize = 4000;
11const MAX_QUEUE_SIZE: usize = 100;
12
13static TRANSCRIPT: Lazy<RwLock<Vec<String>>> = Lazy::new(|| RwLock::new(Vec::new()));
14static INLINE_HANDLE: Lazy<RwLock<Option<Arc<InlineHandle>>>> = Lazy::new(|| RwLock::new(None));
15
16#[derive(Clone, Copy, Debug, PartialEq, Eq)]
17enum TranscriptMode {
18    #[expect(dead_code)]
19    Normal,
20    Suppressed,
21}
22
23thread_local! {
24    static MODE_STACK: RefCell<Vec<TranscriptMode>> = const { RefCell::new(Vec::new()) };
25}
26
27fn is_suppressed() -> bool {
28    MODE_STACK.with(|stack| matches!(stack.borrow().last(), Some(TranscriptMode::Suppressed)))
29}
30
31struct SuspensionGuard {
32    active: bool,
33}
34
35impl SuspensionGuard {
36    fn new() -> Self {
37        MODE_STACK.with(|stack| stack.borrow_mut().push(TranscriptMode::Suppressed));
38        Self { active: true }
39    }
40}
41
42impl Drop for SuspensionGuard {
43    fn drop(&mut self) {
44        if !self.active {
45            return;
46        }
47        MODE_STACK.with(|stack| {
48            let mut stack = stack.borrow_mut();
49            match stack.pop() {
50                Some(TranscriptMode::Suppressed) | None => {}
51                Some(TranscriptMode::Normal) => {
52                    debug_assert!(
53                        false,
54                        "transcript suspension stack corrupted: expected Suppressed"
55                    );
56                }
57            };
58        });
59        self.active = false;
60    }
61}
62
63fn suspend() -> SuspensionGuard {
64    SuspensionGuard::new()
65}
66
67pub fn with_suppressed<F, R>(operation: F) -> R
68where
69    F: FnOnce() -> R,
70{
71    let guard = suspend();
72    let result = operation();
73    drop(guard);
74    result
75}
76
77/// Structured message with metadata for queuing
78#[derive(Clone, Debug)]
79struct QueuedMessage {
80    text: String,
81    kind: InlineMessageKind,
82    style: InlineTextStyle,
83}
84
85static MESSAGE_QUEUE: Lazy<RwLock<VecDeque<QueuedMessage>>> =
86    Lazy::new(|| RwLock::new(VecDeque::new()));
87
88pub fn append(line: &str) {
89    if is_suppressed() || line.trim().is_empty() {
90        return;
91    }
92    let mut log = TRANSCRIPT.write();
93    if log.len() == MAX_LINES {
94        let drop_count = MAX_LINES / 5;
95        log.drain(0..drop_count);
96    }
97    if log.last().is_some_and(|last| last == line) {
98        return;
99    }
100    log.push(line.to_string());
101}
102
103pub fn replace_last(count: usize, lines: &[String]) {
104    if is_suppressed() {
105        return;
106    }
107    let mut log = TRANSCRIPT.write();
108    let new_len = log.len().saturating_sub(count);
109    log.truncate(new_len);
110    for line in lines {
111        if log.len() == MAX_LINES {
112            let drop_count = MAX_LINES / 5;
113            log.drain(0..drop_count);
114        }
115        log.push(line.clone());
116    }
117}
118
119pub fn tail_matches(lines: &[String]) -> bool {
120    if lines.is_empty() {
121        return false;
122    }
123
124    let log = TRANSCRIPT.read();
125    if lines.len() > log.len() {
126        return false;
127    }
128
129    log[log.len() - lines.len()..]
130        .iter()
131        .zip(lines.iter())
132        .all(|(left, right)| left == right)
133}
134
135pub fn snapshot() -> Vec<String> {
136    TRANSCRIPT.read().clone()
137}
138
139pub fn len() -> usize {
140    TRANSCRIPT.read().len()
141}
142
143pub fn clear() {
144    TRANSCRIPT.write().clear();
145}
146
147/// Set the inline handle for immediate message display
148pub fn set_inline_handle(handle: Arc<InlineHandle>) {
149    *INLINE_HANDLE.write() = Some(handle);
150}
151
152/// Remove the inline handle
153pub fn clear_inline_handle() {
154    *INLINE_HANDLE.write() = None;
155}
156
157/// Map MessageStyle to InlineMessageKind
158fn message_kind(style: MessageStyle) -> InlineMessageKind {
159    style.message_kind()
160}
161
162/// Enqueue a message with a specific style and display it immediately
163pub fn enqueue_message(message: &str, style: MessageStyle) {
164    enqueue_message_with_kind(message, message_kind(style), InlineTextStyle::default())
165}
166
167/// Enqueue a message with a specific kind and display it immediately
168pub fn enqueue_message_with_kind(
169    message: &str,
170    kind: InlineMessageKind,
171    text_style: InlineTextStyle,
172) {
173    if message.trim().is_empty() {
174        return;
175    }
176
177    let queued = QueuedMessage {
178        text: message.to_string(),
179        kind,
180        style: text_style,
181    };
182
183    // Enqueue the message
184    {
185        let mut queue = MESSAGE_QUEUE.write();
186        if queue.len() >= MAX_QUEUE_SIZE {
187            queue.pop_front();
188        }
189        queue.push_back(queued);
190    }
191
192    // Display immediately if we have an inline handle
193    // Re-read from queue to get reference without extra clone
194    let queue_read = MESSAGE_QUEUE.read();
195    if let Some(last) = queue_read.back() {
196        display_message_now(&last.text, last.kind, &last.style);
197    }
198
199    // Also add to transcript for persistence (plain text)
200    append(message);
201}
202
203/// Display an error message in the transcript instead of the input field
204#[cold]
205pub fn display_error(message: &str) {
206    enqueue_message(message, MessageStyle::Error);
207}
208
209/// Display an info message in the transcript instead of the input field
210pub fn display_info(message: &str) {
211    enqueue_message(message, MessageStyle::Info);
212}
213
214/// Display a message immediately without queuing (low-level function)
215fn display_message_now(text: &str, kind: InlineMessageKind, style: &InlineTextStyle) {
216    if let Some(handle) = INLINE_HANDLE.read().as_ref() {
217        handle.append_line(
218            kind,
219            vec![InlineSegment {
220                text: text.to_string(),
221                style: Arc::new(style.clone()),
222            }],
223        );
224    }
225}
226
227/// Enqueue a message and display it immediately (defaults to Output/Pty style)
228pub fn enqueue(message: &str) {
229    enqueue_message(message, MessageStyle::Output);
230}
231
232/// Display a message immediately without enqueueing or adding to transcript
233pub fn display_immediate(message: &str, style: MessageStyle) {
234    display_message_now(message, message_kind(style), &InlineTextStyle::default());
235}
236
237/// Get all queued messages as plain text
238pub fn get_queued_messages() -> Vec<String> {
239    MESSAGE_QUEUE
240        .read()
241        .iter()
242        .map(|m| m.text.clone())
243        .collect()
244}
245
246/// Get all queued messages with their metadata
247pub fn get_queued_messages_with_metadata() -> Vec<(String, InlineMessageKind)> {
248    MESSAGE_QUEUE
249        .read()
250        .iter()
251        .map(|m| (m.text.clone(), m.kind))
252        .collect()
253}
254
255/// Clear the message queue
256pub fn clear_queue() {
257    MESSAGE_QUEUE.write().clear();
258}
259
260/// Get queue length
261pub fn queue_len() -> usize {
262    MESSAGE_QUEUE.read().len()
263}
264
265/// Replay all queued messages to the current inline handle (useful for recovery)
266pub fn replay_queued_messages() {
267    let messages: Vec<QueuedMessage> = MESSAGE_QUEUE.read().iter().cloned().collect();
268    for msg in messages {
269        display_message_now(&msg.text, msg.kind, &msg.style);
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276
277    #[test]
278    fn append_and_snapshot_store_lines() {
279        clear();
280        append("first");
281        append("second");
282        assert_eq!(len(), 2);
283        let snap = snapshot();
284        assert_eq!(snap, vec!["first".to_owned(), "second".to_owned()]);
285        clear();
286    }
287
288    #[test]
289    fn append_skips_adjacent_duplicate_lines() {
290        clear();
291        append("duplicate");
292        append("duplicate");
293        append("different");
294        append("duplicate");
295        let snap = snapshot();
296        assert_eq!(
297            snap,
298            vec![
299                "duplicate".to_owned(),
300                "different".to_owned(),
301                "duplicate".to_owned()
302            ]
303        );
304        clear();
305    }
306
307    #[test]
308    fn transcript_drops_oldest_chunk_when_full() {
309        clear();
310        for idx in 0..MAX_LINES {
311            append(&format!("line {idx}"));
312        }
313        assert_eq!(len(), MAX_LINES);
314        for extra in 0..10 {
315            append(&format!("extra {extra}"));
316        }
317        assert_eq!(len(), MAX_LINES - (MAX_LINES / 5) + 10);
318        let snap = snapshot();
319        assert_eq!(
320            snap.first().unwrap(),
321            &format!("line {}", MAX_LINES / 5).to_owned()
322        );
323        clear();
324    }
325
326    #[test]
327    fn message_queue_enqueue_and_retrieve() {
328        clear_queue();
329        assert_eq!(queue_len(), 0);
330
331        enqueue("first message");
332        enqueue_message("second message", MessageStyle::Info);
333
334        assert_eq!(queue_len(), 2);
335        let messages = get_queued_messages();
336        assert_eq!(messages, vec!["first message", "second message"]);
337
338        clear_queue();
339        assert_eq!(queue_len(), 0);
340    }
341
342    #[test]
343    fn message_queue_preserves_metadata() {
344        clear_queue();
345
346        enqueue_message("info message", MessageStyle::Info);
347        enqueue_message("error message", MessageStyle::Error);
348        enqueue_message("user message", MessageStyle::User);
349
350        let messages = get_queued_messages_with_metadata();
351        assert_eq!(messages.len(), 3);
352        assert_eq!(messages[0].1, InlineMessageKind::Info);
353        assert_eq!(messages[1].1, InlineMessageKind::Error);
354        assert_eq!(messages[2].1, InlineMessageKind::User);
355
356        clear_queue();
357    }
358
359    #[test]
360    fn message_queue_size_limit() {
361        clear_queue();
362
363        // Fill queue to max capacity
364        for i in 0..MAX_QUEUE_SIZE {
365            enqueue(&format!("message {}", i));
366        }
367        assert_eq!(queue_len(), MAX_QUEUE_SIZE);
368
369        // Add one more message - should drop oldest
370        enqueue("overflow message");
371        assert_eq!(queue_len(), MAX_QUEUE_SIZE);
372
373        let messages = get_queued_messages();
374        assert_eq!(messages.first().unwrap(), "message 1"); // First message should be dropped
375        assert_eq!(messages.last().unwrap(), "overflow message");
376
377        clear_queue();
378    }
379
380    #[test]
381    fn suppressed_scope_skips_transcript_entries() {
382        clear();
383        with_suppressed(|| {
384            append("hidden");
385        });
386        append("visible");
387        let snap = snapshot();
388        assert_eq!(snap, vec!["visible".to_owned()]);
389        clear();
390    }
391}