Skip to repository content454 lines · 15.9 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T04:05:16.721Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
plain.rs
1//! # Plain Text Output
2//!
3//! This module provides functionality for rendering plain text output in a terminal-like format.
4//! It uses Zed's terminal emulator to process and display text, supporting ANSI escape
5//! sequences for formatting, colors, and other terminal features.
6//!
7//! The main component of this module is the `TerminalOutput` struct, which handles the parsing
8//! and rendering of text input, simulating a basic terminal environment within REPL output.
9//!
10//! This module is used for displaying:
11//!
12//! - Standard output (stdout)
13//! - Standard error (stderr)
14//! - Plain text content
15//! - Error tracebacks
16//!
17
18use gpui::{Bounds, ClipboardItem, Entity, FontStyle, Pixels, TextStyle, WhiteSpace, canvas, size};
19use language::Buffer;
20use settings::Settings as _;
21use terminal::{Terminal, TerminalBuilder, terminal_settings::TerminalSettings};
22use terminal_view::terminal_element::TerminalElement;
23use theme_settings::ThemeSettings;
24use ui::{IntoElement, prelude::*};
25use util::paths::PathStyle;
26
27use crate::outputs::OutputContent;
28use crate::repl_settings::ReplSettings;
29
30/// The `TerminalOutput` struct handles the parsing and rendering of text input,
31/// simulating a basic terminal environment within REPL output.
32///
33/// `TerminalOutput` is designed to handle various types of text-based output, including:
34///
35/// * stdout (standard output)
36/// * stderr (standard error)
37/// * text/plain content
38/// * error tracebacks
39///
40/// It uses Zed's terminal emulator backend to process and render text,
41/// supporting ANSI escape sequences for text formatting and colors.
42///
43pub struct TerminalOutput {
44 full_buffer: Option<Entity<Buffer>>,
45 terminal: Entity<Terminal>,
46}
47
48/// Returns the default text style for the terminal output.
49pub fn text_style(window: &mut Window, cx: &App) -> TextStyle {
50 let settings = ThemeSettings::get_global(cx).clone();
51
52 let font_size = settings.buffer_font_size(cx).into();
53 let font_family = settings.buffer_font.family;
54 let font_features = settings.buffer_font.features;
55 let font_weight = settings.buffer_font.weight;
56 let font_fallbacks = settings.buffer_font.fallbacks;
57
58 let theme = cx.theme();
59
60 TextStyle {
61 font_family,
62 font_features,
63 font_weight,
64 font_fallbacks,
65 font_size,
66 font_style: FontStyle::Normal,
67 line_height: window.line_height().into(),
68 background_color: Some(theme.colors().terminal_ansi_background),
69 white_space: WhiteSpace::Normal,
70 // These are going to be overridden per-cell
71 color: theme.colors().terminal_foreground,
72 ..Default::default()
73 }
74}
75
76/// Returns the default terminal size for the terminal output.
77pub fn terminal_size(window: &mut Window, cx: &mut App) -> terminal::TerminalBounds {
78 let text_style = text_style(window, cx);
79 let text_system = window.text_system();
80
81 let line_height = window.line_height();
82
83 let font_pixels = text_style.font_size.to_pixels(window.rem_size());
84 let font_id = text_system.resolve_font(&text_style.font());
85
86 let cell_width = text_system
87 .advance(font_id, font_pixels, 'w')
88 .map(|advance| advance.width)
89 .unwrap_or(Pixels::ZERO);
90
91 let num_lines = ReplSettings::get_global(cx).max_lines;
92 let columns = ReplSettings::get_global(cx).max_columns;
93
94 // Reversed math from terminal::TerminalSize to get pixel width according to terminal width
95 let width = columns as f32 * cell_width;
96 let height = num_lines as f32 * window.line_height();
97
98 terminal::TerminalBounds {
99 cell_width,
100 line_height,
101 bounds: Bounds {
102 origin: gpui::Point::default(),
103 size: size(width, height),
104 },
105 }
106}
107
108pub fn max_width_for_columns(
109 columns: usize,
110 window: &mut Window,
111 cx: &App,
112) -> Option<gpui::Pixels> {
113 if columns == 0 {
114 return None;
115 }
116
117 let text_style = text_style(window, cx);
118 let text_system = window.text_system();
119 let font_pixels = text_style.font_size.to_pixels(window.rem_size());
120 let font_id = text_system.resolve_font(&text_style.font());
121 let cell_width = text_system
122 .advance(font_id, font_pixels, 'w')
123 .map(|advance| advance.width)
124 .unwrap_or(Pixels::ZERO);
125
126 Some(cell_width * columns as f32)
127}
128
129impl TerminalOutput {
130 /// Creates a new `TerminalOutput` instance.
131 ///
132 /// This method initializes a new terminal emulator with default configuration
133 /// and sets up the necessary components for handling terminal events and rendering.
134 ///
135 pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
136 let terminal_bounds = terminal_size(window, cx);
137 let background_executor = cx.background_executor().clone();
138 let terminal_builder = TerminalBuilder::new_display_only_with_bounds(
139 TerminalSettings::get_global(cx).cursor_shape,
140 TerminalSettings::get_global(cx).alternate_scroll,
141 None,
142 0,
143 &background_executor,
144 PathStyle::local(),
145 terminal_bounds,
146 );
147
148 Self {
149 terminal: cx.new(|cx| terminal_builder.subscribe(cx)),
150 full_buffer: None,
151 }
152 }
153
154 /// Creates a new `TerminalOutput` instance with initial content.
155 ///
156 /// Initializes a new terminal output and populates it with the provided text.
157 ///
158 /// # Arguments
159 ///
160 /// * `text` - A string slice containing the initial text for the terminal output.
161 /// * `cx` - A mutable reference to the `WindowContext` for initialization.
162 ///
163 /// # Returns
164 ///
165 /// A new instance of `TerminalOutput` containing the provided text.
166 pub fn from(text: &str, window: &mut Window, cx: &mut Context<Self>) -> Self {
167 let mut output = Self::new(window, cx);
168 output.append_text(text, cx);
169 output
170 }
171
172 /// Appends text to the terminal output.
173 ///
174 /// Processes each byte of the input text, handling newline characters specially
175 /// to ensure proper cursor movement. Uses the ANSI parser to process the input
176 /// and update the terminal state.
177 ///
178 /// As an example, if the user runs the following Python code in this REPL:
179 ///
180 /// ```python
181 /// import time
182 /// print("Hello,", end="")
183 /// time.sleep(1)
184 /// print(" world!")
185 /// ```
186 ///
187 /// Then append_text will be called twice, with the following arguments:
188 ///
189 /// ```ignore
190 /// terminal_output.append_text("Hello,");
191 /// terminal_output.append_text(" world!");
192 /// ```
193 /// Resulting in a single output of "Hello, world!".
194 ///
195 /// # Arguments
196 ///
197 /// * `text` - A string slice containing the text to be appended.
198 pub fn append_text(&mut self, text: &str, cx: &mut Context<Self>) {
199 self.terminal.update(cx, |terminal, cx| {
200 terminal.write_output(text.as_bytes(), cx);
201 });
202
203 // This will keep the buffer up to date, though with some terminal codes it won't be perfect
204 if let Some(buffer) = self.full_buffer.as_ref() {
205 buffer.update(cx, |buffer, cx| {
206 buffer.edit([(buffer.len()..buffer.len(), text)], None, cx);
207 });
208 }
209 }
210
211 pub fn full_text(&self, cx: &App) -> String {
212 Self::sanitize_terminal_text(self.terminal.read(cx).get_content())
213 }
214
215 fn sanitize_terminal_text(text: String) -> String {
216 fn sanitize(mut line: String) -> Option<String> {
217 line.retain(|ch| ch != '\u{0}' && ch != '\r');
218 if line.trim().is_empty() {
219 return None;
220 }
221 let trimmed = line.trim_end_matches([' ', '\t']);
222 Some(trimmed.to_owned())
223 }
224
225 let lines = text
226 .lines()
227 .filter_map(|line| sanitize(line.to_string()))
228 .collect::<Vec<_>>();
229
230 if lines.is_empty() {
231 String::new()
232 } else {
233 let mut full_text = lines.join("\n");
234 full_text.push('\n');
235 full_text
236 }
237 }
238}
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243 use gpui::{TestAppContext, VisualTestContext};
244 use settings::SettingsStore;
245
246 fn init_test(cx: &mut TestAppContext) -> &mut VisualTestContext {
247 cx.update(|cx| {
248 let settings_store = SettingsStore::test(cx);
249 cx.set_global(settings_store);
250 theme_settings::init(theme::LoadThemes::JustBase, cx);
251 });
252 cx.add_empty_window()
253 }
254
255 #[gpui::test]
256 fn test_max_width_for_columns_zero(cx: &mut TestAppContext) {
257 let cx = init_test(cx);
258 let result = cx.update(|window, cx| max_width_for_columns(0, window, cx));
259 assert!(result.is_none());
260 }
261
262 #[gpui::test]
263 fn test_max_width_for_columns_matches_cell_width(cx: &mut TestAppContext) {
264 let cx = init_test(cx);
265 let columns = 5;
266 let (result, expected) = cx.update(|window, cx| {
267 let text_style = text_style(window, cx);
268 let text_system = window.text_system();
269 let font_pixels = text_style.font_size.to_pixels(window.rem_size());
270 let font_id = text_system.resolve_font(&text_style.font());
271 let cell_width = text_system
272 .advance(font_id, font_pixels, 'w')
273 .map(|advance| advance.width)
274 .unwrap_or(gpui::Pixels::ZERO);
275 let result = max_width_for_columns(columns, window, cx);
276 (result, cell_width * columns as f32)
277 });
278
279 let Some(result) = result else {
280 panic!("expected max width for columns {columns}");
281 };
282 let result_f32: f32 = result.into();
283 let expected_f32: f32 = expected.into();
284 assert!((result_f32 - expected_f32).abs() < 0.01);
285 }
286
287 #[gpui::test]
288 fn test_append_text_preserves_split_ansi_sequence(cx: &mut TestAppContext) {
289 let cx = init_test(cx);
290 let text = cx.update(|window, cx| {
291 let output = cx.new(|cx| TerminalOutput::new(window, cx));
292 output.update(cx, |output, cx| {
293 output.append_text("\x1b[", cx);
294 output.append_text("31mred\x1b[0m", cx);
295 output.full_text(cx)
296 })
297 });
298
299 assert_eq!(text, "red\n");
300 }
301
302 #[gpui::test]
303 fn test_full_text_reads_terminal_output(cx: &mut TestAppContext) {
304 let cx = init_test(cx);
305 cx.update(|window, cx| {
306 let output = cx.new(|cx| TerminalOutput::new(window, cx));
307 output.update(cx, |output, cx| {
308 output.append_text("hello\n", cx);
309 assert_eq!(output.full_text(cx), "hello\n");
310 });
311 });
312 }
313
314 #[gpui::test]
315 fn test_initial_text_uses_repl_terminal_size(cx: &mut TestAppContext) {
316 let cx = init_test(cx);
317 let (text, expected) = cx.update(|window, cx| {
318 let columns = ReplSettings::get_global(cx).max_columns;
319 let input = format!("\x1b[{columns}Gx");
320 let output = cx.new(|cx| TerminalOutput::from(&input, window, cx));
321 (
322 output.read(cx).full_text(cx),
323 format!("{}x\n", " ".repeat(columns - 1)),
324 )
325 });
326
327 assert_eq!(text, expected);
328 }
329
330 #[gpui::test]
331 fn test_repl_history_ignores_terminal_scrollback_setting(cx: &mut TestAppContext) {
332 let cx = init_test(cx);
333 let (text, expected) = cx.update(|window, cx| {
334 cx.update_global::<SettingsStore, _>(|settings_store, cx| {
335 settings_store.update_user_settings(cx, |settings| {
336 settings
337 .terminal
338 .get_or_insert_default()
339 .max_scroll_history_lines = Some(0);
340 });
341 });
342
343 let input = (0..40)
344 .map(|line| format!("line-{line}\n"))
345 .collect::<String>();
346 let output = cx.new(|cx| TerminalOutput::from(&input, window, cx));
347 (output.read(cx).full_text(cx), input)
348 });
349
350 assert_eq!(text, expected);
351 }
352}
353
354impl Render for TerminalOutput {
355 /// Renders the terminal output as a GPUI element.
356 ///
357 /// Converts the current terminal state into a renderable GPUI element. It handles
358 /// the layout of the terminal grid, calculates the dimensions of the output, and
359 /// creates a canvas element that paints the terminal cells and background rectangles.
360 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
361 let terminal = self.terminal.clone();
362
363 let text_style = text_style(window, cx);
364 let minimum_contrast = TerminalSettings::get_global(cx).minimum_contrast;
365 let (rects, batched_text_runs) = terminal.read(cx).with_renderable_cells(|cells| {
366 TerminalElement::layout_grid(cells, 0, &text_style, None, minimum_contrast, cx)
367 });
368
369 // lines are 0-indexed, so we must add 1 to get the number of lines
370 let text_line_height = text_style.line_height_in_pixels(window.rem_size());
371 let num_lines = batched_text_runs
372 .iter()
373 .map(|b| b.start_point.line())
374 .max()
375 .unwrap_or(0)
376 + 1;
377 let height = num_lines as f32 * text_line_height;
378
379 let text_system = window.text_system();
380 let font_pixels = text_style.font_size.to_pixels(window.rem_size());
381 let font_id = text_system.resolve_font(&text_style.font());
382
383 let cell_width = text_system
384 .advance(font_id, font_pixels, 'w')
385 .map(|advance| advance.width)
386 .unwrap_or(Pixels::ZERO);
387
388 canvas(
389 // prepaint
390 move |_bounds, _, _| {},
391 // paint
392 move |bounds, _, window, cx| {
393 for rect in rects {
394 rect.paint(
395 bounds.origin,
396 &terminal::TerminalBounds {
397 cell_width,
398 line_height: text_line_height,
399 bounds,
400 },
401 window,
402 );
403 }
404
405 for batch in batched_text_runs {
406 batch.paint(
407 bounds.origin,
408 &terminal::TerminalBounds {
409 cell_width,
410 line_height: text_line_height,
411 bounds,
412 },
413 window,
414 cx,
415 );
416 }
417 },
418 )
419 // We must set the height explicitly for the editor block to size itself correctly
420 .h(height)
421 .into_any_element()
422 }
423}
424
425impl OutputContent for TerminalOutput {
426 fn clipboard_content(&self, _window: &Window, _cx: &App) -> Option<ClipboardItem> {
427 Some(ClipboardItem::new_string(self.full_text(_cx)))
428 }
429
430 fn has_clipboard_content(&self, _window: &Window, _cx: &App) -> bool {
431 true
432 }
433
434 fn has_buffer_content(&self, _window: &Window, _cx: &App) -> bool {
435 true
436 }
437
438 fn buffer_content(&mut self, _: &mut Window, cx: &mut App) -> Option<Entity<Buffer>> {
439 if self.full_buffer.as_ref().is_some() {
440 return self.full_buffer.clone();
441 }
442
443 let buffer = cx.new(|cx| {
444 let mut buffer = Buffer::local(self.full_text(cx), cx)
445 .with_language(language::PLAIN_TEXT.clone(), cx);
446 buffer.set_capability(language::Capability::ReadOnly, cx);
447 buffer
448 });
449
450 self.full_buffer = Some(buffer.clone());
451 Some(buffer)
452 }
453}
454