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
154
155
156
157
158
159
//! Text component for simple text rendering.
use std::sync::Mutex;
use std::any::Any;
use super::component::Component;
/// Simple text component with optional padding.
pub struct Text {
content: Mutex<String>,
padding_x: usize,
padding_y: usize,
/// Render cache keyed by (content, width). A finalized `Text` row is
/// re-rendered on every repaint of a long transcript; caching the wrapped
/// lines keeps that cheap. Mirrors native pi `Text.cachedText/cachedLines`.
cache: Mutex<Option<TextCache>>,
}
#[derive(Clone)]
struct TextCache {
content: String,
width: usize,
theme_revision: u64,
lines: Vec<String>,
}
impl Text {
/// Create a new text component.
pub fn new(content: impl Into<String>, padding_x: usize, padding_y: usize) -> Self {
Self {
content: Mutex::new(content.into()),
padding_x,
padding_y,
cache: Mutex::new(None),
}
}
/// Set the text content.
pub fn set_text(&self, text: impl Into<String>) {
if let Ok(mut content) = self.content.lock() {
*content = text.into();
}
if let Ok(mut cache) = self.cache.lock() {
*cache = None;
}
}
/// Get the text content.
pub fn get_text(&self) -> String {
self.content.lock().map(|c| c.clone()).unwrap_or_default()
}
}
impl Component for Text {
fn render(&self, width: usize) -> Vec<String> {
let content = self.content.lock().map(|c| c.clone()).unwrap_or_default();
let theme_revision = crate::theme::theme_revision();
if let Ok(cache) = self.cache.lock() {
if let Some(cached) = cache.as_ref() {
if cached.width == width
&& cached.content == content
&& cached.theme_revision == theme_revision
{
return cached.lines.clone();
}
}
}
let mut lines = Vec::new();
// Add top padding
for _ in 0..self.padding_y {
lines.push(String::new());
}
// Render each line with horizontal padding, WORD-WRAPPING to the
// content width (pi text.ts: wrapTextWithAnsi) instead of truncating —
// long model output flows onto continuation lines rather than being
// cut. Each wrapped line carries the padding prefix (matching pi's
// per-line padding).
let pad = " ".repeat(self.padding_x);
let content_width = width.saturating_sub(self.padding_x).max(1);
for line in content.lines() {
let wrapped = crate::utils::wrap_text_with_ansi(line, content_width);
for wl in wrapped {
if self.padding_x > 0 {
lines.push(format!("{pad}{wl}"));
} else {
lines.push(wl);
}
}
}
// Add bottom padding
for _ in 0..self.padding_y {
lines.push(String::new());
}
if lines.is_empty() {
lines.push(String::new());
}
if let Ok(mut cache) = self.cache.lock() {
*cache = Some(TextCache {
content,
width,
theme_revision,
lines: lines.clone(),
});
}
lines
}
fn invalidate(&self) {
if let Ok(mut cache) = self.cache.lock() {
*cache = None;
}
}
fn as_any(&self) -> &dyn Any {
self
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::component::Component;
/// Regression: rendering a line wider than the layout column used to
/// byte-slice `padded[..width]` and panic mid-character on multi-byte
/// chars — "end byte index 120 is not a char boundary; it is inside
/// '⏎' (bytes 119..122)". Truncation must never split a char: it keeps
/// whole chars whose visible width fits the column.
#[test]
fn render_truncates_multibyte_at_char_boundary() {
let t = Text::new("x⏎y", 0, 0);
// Width 1: only 'x' fits (the 3-byte ⏎ is dropped whole, never split).
let out = t.render(1);
assert_eq!(out[0], "x");
// Width 2: 'x' + '⏎' (⏎ is visible-width 1) — whole chars only.
let out = t.render(2);
assert_eq!(out[0], "x⏎");
// Width 3: everything fits unchanged.
let out = t.render(3);
assert_eq!(out[0], "x⏎y");
}
#[test]
fn render_keeps_ansi_and_truncates() {
let styled = "\x1b[31mred\x1b[0m text";
let t = Text::new(styled, 0, 0);
let out = t.render(3);
// No panic on ANSI content; visible width respected.
assert!(crate::ansi::visible_width(&out[0]) <= 3);
}
}