Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T05:05:14.842Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

plain.rs

454 lines · 15.9 KB · rust
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
Served at tenant.openagents/omega Member data and write actions are omitted.