Skip to main content

ralph_proto/
ux_event.rs

1//! UX event types for terminal and TUI capture.
2//!
3//! These events enable recording and replaying terminal output with timing
4//! and color information preserved. The design follows the observer pattern:
5//! events are captured during execution and can be replayed later.
6
7use serde::{Deserialize, Serialize};
8
9/// A UX event captured during session recording.
10#[derive(Debug, Clone, Serialize, Deserialize)]
11#[serde(tag = "event", content = "data")]
12pub enum UxEvent {
13    /// Raw bytes written to terminal (stdout/stderr).
14    #[serde(rename = "ux.terminal.write")]
15    TerminalWrite(TerminalWrite),
16
17    /// Terminal resize event.
18    #[serde(rename = "ux.terminal.resize")]
19    TerminalResize(TerminalResize),
20
21    /// Color mode detection result.
22    #[serde(rename = "ux.terminal.color_mode")]
23    TerminalColorMode(TerminalColorMode),
24
25    /// TUI frame capture (reserved for future ralph-tui integration).
26    #[serde(rename = "ux.tui.frame")]
27    TuiFrame(TuiFrame),
28}
29
30/// Raw bytes written to stdout or stderr.
31///
32/// Bytes are stored as base64 to preserve ANSI escape sequences
33/// and binary data without JSON escaping issues.
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct TerminalWrite {
36    /// Base64-encoded raw bytes.
37    pub bytes: String,
38
39    /// True for stdout, false for stderr.
40    pub stdout: bool,
41
42    /// Milliseconds since session start.
43    pub offset_ms: u64,
44}
45
46impl TerminalWrite {
47    /// Creates a new terminal write event.
48    pub fn new(raw_bytes: &[u8], stdout: bool, offset_ms: u64) -> Self {
49        use base64::Engine;
50        Self {
51            bytes: base64::engine::general_purpose::STANDARD.encode(raw_bytes),
52            stdout,
53            offset_ms,
54        }
55    }
56
57    /// Decodes the base64 bytes back to raw bytes.
58    ///
59    /// # Errors
60    ///
61    /// Returns an error if the base64 data is malformed.
62    pub fn decode_bytes(&self) -> Result<Vec<u8>, base64::DecodeError> {
63        use base64::Engine;
64        base64::engine::general_purpose::STANDARD.decode(&self.bytes)
65    }
66}
67
68/// Terminal dimension change event.
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct TerminalResize {
71    /// Terminal width in columns.
72    pub width: u16,
73
74    /// Terminal height in rows.
75    pub height: u16,
76
77    /// Milliseconds since session start.
78    pub offset_ms: u64,
79}
80
81impl TerminalResize {
82    /// Creates a new resize event.
83    pub fn new(width: u16, height: u16, offset_ms: u64) -> Self {
84        Self {
85            width,
86            height,
87            offset_ms,
88        }
89    }
90}
91
92/// Color mode detection result.
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct TerminalColorMode {
95    /// Requested color mode (auto, always, never).
96    pub mode: String,
97
98    /// Actual detected mode after auto-detection.
99    pub detected: String,
100
101    /// Milliseconds since session start.
102    pub offset_ms: u64,
103}
104
105impl TerminalColorMode {
106    /// Creates a new color mode event.
107    pub fn new(mode: impl Into<String>, detected: impl Into<String>, offset_ms: u64) -> Self {
108        Self {
109            mode: mode.into(),
110            detected: detected.into(),
111            offset_ms,
112        }
113    }
114}
115
116/// TUI frame capture for future ralph-tui integration.
117///
118/// This is a placeholder for when TUI support is implemented.
119/// Frame buffers will be captured from ratatui's `TestBackend`.
120#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct TuiFrame {
122    /// Sequential frame identifier.
123    pub frame_id: u64,
124
125    /// Frame width in columns.
126    pub width: u16,
127
128    /// Frame height in rows.
129    pub height: u16,
130
131    /// Serialized cell buffer (format TBD when TUI is implemented).
132    pub cells: String,
133
134    /// Milliseconds since session start.
135    pub offset_ms: u64,
136}
137
138impl TuiFrame {
139    /// Creates a new TUI frame event.
140    pub fn new(frame_id: u64, width: u16, height: u16, cells: String, offset_ms: u64) -> Self {
141        Self {
142            frame_id,
143            width,
144            height,
145            cells,
146            offset_ms,
147        }
148    }
149}
150
151/// Abstract interface for capturing rendered output.
152///
153/// Implementations capture frames from either CLI mode (terminal bytes)
154/// or TUI mode (ratatui frame buffers). Both produce the same `UxEvent`
155/// format for unified replay and export.
156pub trait FrameCapture: Send + Sync {
157    /// Returns captured events and clears the internal buffer.
158    fn take_captures(&mut self) -> Vec<UxEvent>;
159
160    /// Returns true if any events have been captured.
161    fn has_captures(&self) -> bool;
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    #[test]
169    fn test_terminal_write_roundtrip() {
170        let original = b"Hello, \x1b[32mWorld\x1b[0m!";
171        let write = TerminalWrite::new(original, true, 100);
172
173        assert!(write.stdout);
174        assert_eq!(write.offset_ms, 100);
175
176        let decoded = write.decode_bytes().unwrap();
177        assert_eq!(decoded, original);
178    }
179
180    #[test]
181    fn test_ux_event_serialization() {
182        let event = UxEvent::TerminalWrite(TerminalWrite::new(b"test", true, 0));
183        let json = serde_json::to_string(&event).unwrap();
184
185        assert!(json.contains("ux.terminal.write"));
186
187        let parsed: UxEvent = serde_json::from_str(&json).unwrap();
188        if let UxEvent::TerminalWrite(write) = parsed {
189            assert!(write.stdout);
190        } else {
191            panic!("Expected TerminalWrite variant");
192        }
193    }
194
195    #[test]
196    fn test_terminal_resize_serialization() {
197        let event = UxEvent::TerminalResize(TerminalResize::new(120, 30, 500));
198        let json = serde_json::to_string(&event).unwrap();
199
200        assert!(json.contains("ux.terminal.resize"));
201        assert!(json.contains("120"));
202        assert!(json.contains("30"));
203    }
204}