Skip to main content

rpi_tui/
user_message.rs

1//! User message component — port of `user-message.ts`.
2//!
3//! Renders the user's submitted text as markdown inside a padded [`Box`] with
4//! a subtle background, wrapped in [OSC 133] prompt zone markers so capable
5//! terminals can detect user/assistant boundaries. Where OSC 133 is
6//! unsupported the sequences are harmless no-ops.
7//!
8//! Replaces the plain `> text` echo in the transcript.
9//!
10//! [OSC 133]: https://terminalguide.namepad.de/seq/osc-133/
11
12use std::any::Any;
13use std::sync::atomic::{AtomicU64, Ordering};
14use std::sync::Mutex;
15
16use super::component::Component;
17use crate::box_component::Box;
18use crate::markdown::Markdown;
19use crate::theme::{theme, theme_revision};
20
21const OSC133_ZONE_START: &str = "\x1b]133;A\x07";
22const OSC133_ZONE_END: &str = "\x1b]133;B\x07";
23const OSC133_ZONE_FINAL: &str = "\x1b]133;C\x07";
24
25/// Component that renders a user message with a background and OSC133 markers.
26pub struct UserMessageComponent {
27    text: String,
28    output_pad: usize,
29    /// Prebuilt inner box (background `Box` + `Markdown`).
30    ///
31    /// Upstream builds this **once** in the constructor (`user-message.ts`
32    /// `rebuild()`); rpi used to rebuild the `Box` + `Markdown` on every
33    /// `render`, so the inner caches could never hit and each frame re-parsed
34    /// every user message. Rebuild only on `invalidate` / `with_pad` / a theme
35    /// change (the background colour is baked into the box).
36    inner: Mutex<Box>,
37    /// Theme revision the current inner box was built against.
38    built_revision: AtomicU64,
39}
40
41fn build_box(text: &str, output_pad: usize) -> Box {
42    let bg = theme().colors.surface;
43    let content_box = Box::new(output_pad, 1);
44    content_box.set_bg_fn(Some(std::sync::Arc::new(move |s: &str| bg.bg(s))));
45    let md = Markdown::new(text.to_string(), 0, 0);
46    content_box.add_child(std::sync::Arc::new(md));
47    content_box
48}
49
50impl UserMessageComponent {
51    /// Create a new user message component.
52    ///
53    /// `output_pad` is the horizontal+vertical padding inside the background
54    /// box (mirrors the TS `outputPad`, default 1).
55    pub fn new(text: impl Into<String>) -> Self {
56        let text = text.into();
57        let inner = build_box(&text, 1);
58        Self {
59            text,
60            output_pad: 1,
61            inner: Mutex::new(inner),
62            built_revision: AtomicU64::new(theme_revision()),
63        }
64    }
65
66    /// Override the inner padding.
67    pub fn with_pad(self, pad: usize) -> Self {
68        let inner = build_box(&self.text, pad);
69        Self {
70            text: self.text,
71            output_pad: pad,
72            inner: Mutex::new(inner),
73            built_revision: AtomicU64::new(theme_revision()),
74        }
75    }
76}
77
78impl Component for UserMessageComponent {
79    fn render(&self, width: usize) -> Vec<String> {
80        // The background colour is baked into the inner box; rebuild it when
81        // the live theme has changed since it was built.
82        let revision = theme_revision();
83        if self.built_revision.load(Ordering::Acquire) != revision {
84            if let Ok(mut inner) = self.inner.lock() {
85                *inner = build_box(&self.text, self.output_pad);
86            }
87            self.built_revision.store(revision, Ordering::Release);
88        }
89
90        let mut lines = match self.inner.lock() {
91            Ok(inner) => inner.render(width),
92            Err(_) => return Vec::new(),
93        };
94        if lines.is_empty() {
95            return lines;
96        }
97        // Prepend zone-start to the first line, append zone-end+final to the
98        // last line. These are zero-width escapes so visible layout is unchanged.
99        let first = lines.first_mut().unwrap();
100        first.insert_str(0, OSC133_ZONE_START);
101        let last = lines.last_mut().unwrap();
102        last.insert_str(0, OSC133_ZONE_END);
103        last.insert_str(OSC133_ZONE_END.len(), OSC133_ZONE_FINAL);
104        lines
105    }
106
107    fn invalidate(&self) {
108        // Rebuild the inner box so a theme change is picked up by the baked-in
109        // background colour, and so the Markdown cache is dropped.
110        if let Ok(mut inner) = self.inner.lock() {
111            *inner = build_box(&self.text, self.output_pad);
112        }
113        self.built_revision
114            .store(theme_revision(), Ordering::Release);
115    }
116
117    fn as_any(&self) -> &dyn Any {
118        self
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125    use crate::ansi::strip_ansi;
126
127    #[test]
128    fn test_renders_non_empty() {
129        let c = UserMessageComponent::new("Hello world");
130        let lines = c.render(40);
131        assert!(!lines.is_empty());
132        // Should contain the text (after stripping ANSI/OSC).
133        let joined = strip_ansi(&lines.join("\n"));
134        assert!(joined.contains("Hello"));
135    }
136
137    #[test]
138    fn test_osc133_markers_present() {
139        let c = UserMessageComponent::new("Hi");
140        let lines = c.render(40);
141        let joined = lines.join("\n");
142        assert!(joined.contains(OSC133_ZONE_START), "missing zone-start A");
143        assert!(joined.contains(OSC133_ZONE_END), "missing zone-end B");
144        assert!(joined.contains(OSC133_ZONE_FINAL), "missing zone-final C");
145    }
146
147    #[test]
148    fn test_cjk_no_panic() {
149        let c = UserMessageComponent::new("你好世界");
150        let lines = c.render(6);
151        assert!(!lines.is_empty());
152    }
153}