Skip to main content

oxicode_vtui/presentation/
transcript.rs

1//! Pure transcript cell selection.
2//!
3//! The CLI controller appends logical lines; this module selects the cells
4//! which a terminal viewport should paint. It contains no agent or terminal
5//! lifecycle code, so it can be tested without a running TUI.
6
7use crate::tui::core::{InlineMessageKind, InlineSegment};
8
9/// One logical transcript line, retained in append order.
10#[derive(Debug, Clone)]
11pub struct TranscriptLine {
12    pub kind: InlineMessageKind,
13    pub segments: Vec<InlineSegment>,
14    /// Consecutive lines of the same message class share a visual cell.
15    pub block_id: usize,
16}
17
18/// Display policy for a transcript cell.
19#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
20pub enum BlockDisplayMode {
21    /// Show the cell heading only.
22    Collapsed,
23    /// Show the head and tail of long cells.
24    #[default]
25    Truncated,
26    /// Show every logical line.
27    Expanded,
28}
29
30/// A logical transcript item selected for painting.
31#[derive(Clone, Copy, Debug, PartialEq, Eq)]
32pub enum VisibleItem {
33    /// A source line. `folded` requests the collapsed-cell marker.
34    Line { source_index: usize, folded: bool },
35    /// A compact summary of hidden lines in a truncated cell.
36    Gap {
37        source_index: usize,
38        hidden_lines: usize,
39    },
40}
41
42/// Build the visual cell sequence for the current display policies.
43///
44/// Long cells retain one head and the final three lines. This preserves the
45/// existing renderer's behavior while keeping grouping and elision outside
46/// the terminal draw function.
47pub fn visible_items(
48    transcript: &[TranscriptLine],
49    mode_for: impl Fn(usize) -> BlockDisplayMode,
50) -> Vec<VisibleItem> {
51    const TRUNC_TAIL: usize = 3;
52
53    let mut visible = Vec::with_capacity(transcript.len());
54    let mut start = 0;
55    while start < transcript.len() {
56        let block_id = transcript[start].block_id;
57        let mut end = start + 1;
58        while end < transcript.len() && transcript[end].block_id == block_id {
59            end += 1;
60        }
61
62        let len = end - start;
63        match mode_for(block_id) {
64            BlockDisplayMode::Collapsed => visible.push(VisibleItem::Line {
65                source_index: start,
66                folded: true,
67            }),
68            BlockDisplayMode::Expanded => {
69                visible.extend((start..end).map(|source_index| VisibleItem::Line {
70                    source_index,
71                    folded: false,
72                }));
73            }
74            BlockDisplayMode::Truncated if len > TRUNC_TAIL + 1 => {
75                visible.push(VisibleItem::Line {
76                    source_index: start,
77                    folded: false,
78                });
79                visible.push(VisibleItem::Gap {
80                    source_index: start,
81                    hidden_lines: len - 1 - TRUNC_TAIL,
82                });
83                visible.extend(
84                    (end - TRUNC_TAIL..end).map(|source_index| VisibleItem::Line {
85                        source_index,
86                        folded: false,
87                    }),
88                );
89            }
90            BlockDisplayMode::Truncated => {
91                visible.extend((start..end).map(|source_index| VisibleItem::Line {
92                    source_index,
93                    folded: false,
94                }));
95            }
96        }
97        start = end;
98    }
99    visible
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    fn line(block_id: usize) -> TranscriptLine {
107        TranscriptLine {
108            kind: InlineMessageKind::Agent,
109            segments: Vec::new(),
110            block_id,
111        }
112    }
113
114    #[test]
115    fn truncated_cells_keep_head_gap_and_tail() {
116        let transcript = vec![line(7), line(7), line(7), line(7), line(7), line(7)];
117        assert_eq!(
118            visible_items(&transcript, |_| BlockDisplayMode::Truncated),
119            vec![
120                VisibleItem::Line {
121                    source_index: 0,
122                    folded: false
123                },
124                VisibleItem::Gap {
125                    source_index: 0,
126                    hidden_lines: 2
127                },
128                VisibleItem::Line {
129                    source_index: 3,
130                    folded: false
131                },
132                VisibleItem::Line {
133                    source_index: 4,
134                    folded: false
135                },
136                VisibleItem::Line {
137                    source_index: 5,
138                    folded: false
139                },
140            ]
141        );
142    }
143
144    #[test]
145    fn collapsed_cells_keep_only_the_head() {
146        let transcript = vec![line(1), line(1), line(2)];
147        assert_eq!(
148            visible_items(&transcript, |_| BlockDisplayMode::Collapsed),
149            vec![
150                VisibleItem::Line {
151                    source_index: 0,
152                    folded: true
153                },
154                VisibleItem::Line {
155                    source_index: 2,
156                    folded: true
157                },
158            ]
159        );
160    }
161}