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
//! User message component — port of `user-message.ts`.
//!
//! Renders the user's submitted text as markdown inside a padded [`Box`] with
//! a subtle background, wrapped in [OSC 133] prompt zone markers so capable
//! terminals can detect user/assistant boundaries. Where OSC 133 is
//! unsupported the sequences are harmless no-ops.
//!
//! Replaces the plain `> text` echo in the transcript.
//!
//! [OSC 133]: https://terminalguide.namepad.de/seq/osc-133/
use std::any::Any;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Mutex;
use super::component::Component;
use crate::box_component::Box;
use crate::markdown::Markdown;
use crate::theme::{theme, theme_revision};
const OSC133_ZONE_START: &str = "\x1b]133;A\x07";
const OSC133_ZONE_END: &str = "\x1b]133;B\x07";
const OSC133_ZONE_FINAL: &str = "\x1b]133;C\x07";
/// Component that renders a user message with a background and OSC133 markers.
pub struct UserMessageComponent {
text: String,
output_pad: usize,
/// Prebuilt inner box (background `Box` + `Markdown`).
///
/// Upstream builds this **once** in the constructor (`user-message.ts`
/// `rebuild()`); rpi used to rebuild the `Box` + `Markdown` on every
/// `render`, so the inner caches could never hit and each frame re-parsed
/// every user message. Rebuild only on `invalidate` / `with_pad` / a theme
/// change (the background colour is baked into the box).
inner: Mutex<Box>,
/// Theme revision the current inner box was built against.
built_revision: AtomicU64,
}
fn build_box(text: &str, output_pad: usize) -> Box {
let bg = theme().colors.surface;
let content_box = Box::new(output_pad, 1);
content_box.set_bg_fn(Some(std::sync::Arc::new(move |s: &str| bg.bg(s))));
let md = Markdown::new(text.to_string(), 0, 0);
content_box.add_child(std::sync::Arc::new(md));
content_box
}
impl UserMessageComponent {
/// Create a new user message component.
///
/// `output_pad` is the horizontal+vertical padding inside the background
/// box (mirrors the TS `outputPad`, default 1).
pub fn new(text: impl Into<String>) -> Self {
let text = text.into();
let inner = build_box(&text, 1);
Self {
text,
output_pad: 1,
inner: Mutex::new(inner),
built_revision: AtomicU64::new(theme_revision()),
}
}
/// Override the inner padding.
pub fn with_pad(self, pad: usize) -> Self {
let inner = build_box(&self.text, pad);
Self {
text: self.text,
output_pad: pad,
inner: Mutex::new(inner),
built_revision: AtomicU64::new(theme_revision()),
}
}
}
impl Component for UserMessageComponent {
fn render(&self, width: usize) -> Vec<String> {
// The background colour is baked into the inner box; rebuild it when
// the live theme has changed since it was built.
let revision = theme_revision();
if self.built_revision.load(Ordering::Acquire) != revision {
if let Ok(mut inner) = self.inner.lock() {
*inner = build_box(&self.text, self.output_pad);
}
self.built_revision.store(revision, Ordering::Release);
}
let mut lines = match self.inner.lock() {
Ok(inner) => inner.render(width),
Err(_) => return Vec::new(),
};
if lines.is_empty() {
return lines;
}
// Prepend zone-start to the first line, append zone-end+final to the
// last line. These are zero-width escapes so visible layout is unchanged.
let first = lines.first_mut().unwrap();
first.insert_str(0, OSC133_ZONE_START);
let last = lines.last_mut().unwrap();
last.insert_str(0, OSC133_ZONE_END);
last.insert_str(OSC133_ZONE_END.len(), OSC133_ZONE_FINAL);
lines
}
fn invalidate(&self) {
// Rebuild the inner box so a theme change is picked up by the baked-in
// background colour, and so the Markdown cache is dropped.
if let Ok(mut inner) = self.inner.lock() {
*inner = build_box(&self.text, self.output_pad);
}
self.built_revision
.store(theme_revision(), Ordering::Release);
}
fn as_any(&self) -> &dyn Any {
self
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ansi::strip_ansi;
#[test]
fn test_renders_non_empty() {
let c = UserMessageComponent::new("Hello world");
let lines = c.render(40);
assert!(!lines.is_empty());
// Should contain the text (after stripping ANSI/OSC).
let joined = strip_ansi(&lines.join("\n"));
assert!(joined.contains("Hello"));
}
#[test]
fn test_osc133_markers_present() {
let c = UserMessageComponent::new("Hi");
let lines = c.render(40);
let joined = lines.join("\n");
assert!(joined.contains(OSC133_ZONE_START), "missing zone-start A");
assert!(joined.contains(OSC133_ZONE_END), "missing zone-end B");
assert!(joined.contains(OSC133_ZONE_FINAL), "missing zone-final C");
}
#[test]
fn test_cjk_no_panic() {
let c = UserMessageComponent::new("你好世界");
let lines = c.render(6);
assert!(!lines.is_empty());
}
}