Skip to main content

ui/components/
terminal_view.rs

1use std::cell::Cell;
2use std::ops::Range;
3use std::rc::Rc;
4
5use gpui::{
6    AnyElement, Bounds, Context, FocusHandle, Focusable, Font, FontFallbacks, HighlightStyle,
7    KeyDownEvent, Keystroke, Render, StyledText, canvas, rgb,
8};
9
10use crate::prelude::*;
11
12/// Monospace font used for terminal output (matches [`crate::CodeEditor`]'s
13/// `CODE_FONT_FAMILY` so panes in the same [`crate::PaneGroup`] look
14/// consistent).
15const TERMINAL_FONT_FAMILY: &str = "IBM Plex Mono";
16const TERMINAL_FONT_SIZE: Pixels = px(12.5);
17const TERMINAL_LINE_HEIGHT: f32 = 1.5;
18
19/// Real shell prompts (starship, oh-my-posh, powerlevel10k, ...) commonly
20/// print Powerline/Nerd-Font glyphs from the Private Use Area — codepoints
21/// no ordinary typeface (including the bundled `TERMINAL_FONT_FAMILY`) has
22/// glyphs for, so they'd otherwise render as empty tofu boxes. This is a
23/// FALLBACK list, not a bundled asset: none of these are shipped by this
24/// workspace (bundling a full Nerd Font is tens of MB, well beyond this
25/// template's "no bundled assets" default — see `docs/project-overview-
26/// pdr.md`). If the user's system happens to have one of these installed
27/// (common on dev machines that already use a Nerd Font in their real
28/// terminal), `font_kit` picks it up automatically for glyphs
29/// `TERMINAL_FONT_FAMILY` doesn't cover; if none are installed, icons still
30/// show as tofu — a system font gap this crate can't fix without bundling a
31/// large asset, which is a separate decision, not something to do silently.
32fn terminal_font() -> Font {
33    Font {
34        family: TERMINAL_FONT_FAMILY.into(),
35        fallbacks: Some(FontFallbacks::from_fonts(vec![
36            "Symbols Nerd Font Mono".into(),
37            "Symbols Nerd Font".into(),
38            "JetBrainsMono Nerd Font Mono".into(),
39            "JetBrainsMono Nerd Font".into(),
40            "Hack Nerd Font Mono".into(),
41            "Hack Nerd Font".into(),
42            "MesloLGS NF".into(),
43            "FiraCode Nerd Font Mono".into(),
44            "FiraCode Nerd Font".into(),
45        ])),
46        ..Default::default()
47    }
48}
49/// Approximate cell pixel size at `TERMINAL_FONT_SIZE`/`TERMINAL_FONT_FAMILY`
50/// (monospace, so a single advance width applies to every glyph). Used both
51/// to seed the initial PTY size and to convert the pane's measured pixel
52/// bounds into a row/column count on resize — an approximation, not a real
53/// text-shaping measurement, so it can be off by a cell or two vs. what the
54/// font actually renders at. Good enough for a terminal grid (unlike, say,
55/// cursor-position math in a real text editor).
56const CELL_WIDTH: u16 = 8;
57const CELL_HEIGHT: u16 = 18;
58const DEFAULT_ROWS: u16 = 24;
59const DEFAULT_COLUMNS: u16 = 80;
60
61/// Encodes a keystroke into the bytes a real terminal program expects on
62/// stdin. Covers printable characters, Enter/Backspace/Tab/Escape, arrows,
63/// Home/End/PageUp/PageDown, F1-F12, and Ctrl+letter control codes. NOT
64/// covered (unlike a real terminal emulator): Option-as-Meta on macOS
65/// (Zed's `mappings/keys.rs` handles this via a settings-driven toggle —
66/// out of scope here, this always sends the plain character), bracketed-
67/// paste mode, modified arrow/function keys (e.g. Shift+F5). `cmd`/
68/// `platform`-modified keystrokes are passed through (returns `None`) so
69/// OS-level shortcuts on the surrounding app keep working instead of being
70/// swallowed by the terminal.
71fn encode_keystroke(keystroke: &Keystroke) -> Option<Vec<u8>> {
72    if keystroke.modifiers.platform {
73        return None;
74    }
75    if keystroke.modifiers.control
76        && let Some(ch) = keystroke.key.chars().next()
77        && ch.is_ascii_alphabetic()
78    {
79        let code = ch.to_ascii_uppercase() as u8 - b'A' + 1;
80        return Some(vec![code]);
81    }
82    match keystroke.key.as_str() {
83        "enter" => Some(b"\r".to_vec()),
84        "backspace" => Some(b"\x7f".to_vec()),
85        "tab" => Some(b"\t".to_vec()),
86        "escape" => Some(b"\x1b".to_vec()),
87        "up" => Some(b"\x1b[A".to_vec()),
88        "down" => Some(b"\x1b[B".to_vec()),
89        "right" => Some(b"\x1b[C".to_vec()),
90        "left" => Some(b"\x1b[D".to_vec()),
91        "home" => Some(b"\x1b[H".to_vec()),
92        "end" => Some(b"\x1b[F".to_vec()),
93        "pageup" => Some(b"\x1b[5~".to_vec()),
94        "pagedown" => Some(b"\x1b[6~".to_vec()),
95        "delete" => Some(b"\x1b[3~".to_vec()),
96        "insert" => Some(b"\x1b[2~".to_vec()),
97        "f1" => Some(b"\x1bOP".to_vec()),
98        "f2" => Some(b"\x1bOQ".to_vec()),
99        "f3" => Some(b"\x1bOR".to_vec()),
100        "f4" => Some(b"\x1bOS".to_vec()),
101        "f5" => Some(b"\x1b[15~".to_vec()),
102        "f6" => Some(b"\x1b[17~".to_vec()),
103        "f7" => Some(b"\x1b[18~".to_vec()),
104        "f8" => Some(b"\x1b[19~".to_vec()),
105        "f9" => Some(b"\x1b[20~".to_vec()),
106        "f10" => Some(b"\x1b[21~".to_vec()),
107        "f11" => Some(b"\x1b[23~".to_vec()),
108        "f12" => Some(b"\x1b[24~".to_vec()),
109        "space" => Some(b" ".to_vec()),
110        _ => keystroke
111            .key_char
112            .as_ref()
113            .map(|text| text.as_bytes().to_vec()),
114    }
115}
116
117fn default_terminal_size() -> terminal::TerminalSize {
118    terminal::TerminalSize {
119        rows: DEFAULT_ROWS,
120        columns: DEFAULT_COLUMNS,
121        cell_width: CELL_WIDTH,
122        cell_height: CELL_HEIGHT,
123    }
124}
125
126/// Converts measured pane pixel bounds into a row/column grid size, using
127/// the fixed [`CELL_WIDTH`]/[`CELL_HEIGHT`] approximation. Never returns
128/// zero rows/columns (a 0x0 PTY size is nonsensical and some programs
129/// divide by it) — floors at 1x1.
130fn size_for_bounds(bounds: Bounds<Pixels>) -> terminal::TerminalSize {
131    let columns = (f32::from(bounds.size.width) / CELL_WIDTH as f32).floor() as u16;
132    let rows = (f32::from(bounds.size.height) / CELL_HEIGHT as f32).floor() as u16;
133    terminal::TerminalSize {
134        rows: rows.max(1),
135        columns: columns.max(1),
136        cell_width: CELL_WIDTH,
137        cell_height: CELL_HEIGHT,
138    }
139}
140
141fn rgb_to_hsla(color: terminal::Rgb) -> gpui::Hsla {
142    gpui::rgb(((color.r as u32) << 16) | ((color.g as u32) << 8) | color.b as u32).into()
143}
144
145/// Builds one combined multi-line string plus a coalesced list of
146/// `(byte range, HighlightStyle)` spans from styled terminal cells — the
147/// same shape [`crate::CodeEditor`]'s tree-sitter highlighting feeds into
148/// `StyledText::with_highlights`. Adjacent cells sharing identical style
149/// are merged into a single span instead of emitting one per character,
150/// which would otherwise be thousands of spans for a full screen.
151fn styled_screen_text(
152    rows: Vec<Vec<terminal::TerminalCell>>,
153) -> (String, Vec<(Range<usize>, HighlightStyle)>) {
154    let mut text = String::new();
155    let mut highlights = Vec::new();
156
157    for (row_ix, row) in rows.into_iter().enumerate() {
158        if row_ix > 0 {
159            text.push('\n');
160        }
161
162        let mut span_start = text.len();
163        let mut current_style: Option<(Option<terminal::Rgb>, bool, bool, bool)> = None;
164
165        for cell in row {
166            let style_key = (cell.fg, cell.bold, cell.italic, cell.underline);
167            if current_style != Some(style_key) {
168                if let Some((fg, bold, italic, underline)) = current_style
169                    && text.len() > span_start
170                {
171                    highlights.push((
172                        span_start..text.len(),
173                        cell_highlight_style(fg, bold, italic, underline),
174                    ));
175                }
176                span_start = text.len();
177                current_style = Some(style_key);
178            }
179            text.push(cell.text);
180        }
181
182        if let Some((fg, bold, italic, underline)) = current_style
183            && text.len() > span_start
184        {
185            highlights.push((
186                span_start..text.len(),
187                cell_highlight_style(fg, bold, italic, underline),
188            ));
189        }
190    }
191
192    (text, highlights)
193}
194
195fn cell_highlight_style(
196    fg: Option<terminal::Rgb>,
197    bold: bool,
198    italic: bool,
199    underline: bool,
200) -> HighlightStyle {
201    HighlightStyle {
202        color: fg.map(rgb_to_hsla),
203        font_weight: bold.then_some(gpui::FontWeight::BOLD),
204        font_style: italic.then_some(gpui::FontStyle::Italic),
205        underline: underline.then_some(gpui::UnderlineStyle::default()),
206        ..Default::default()
207    }
208}
209
210/// A real terminal: spawns the user's shell through a PTY
211/// ([`terminal::Terminal`]) and renders its live output with real per-cell
212/// ANSI colors/bold/italic/underline. This is the Phase C counterpart to
213/// [`crate::TerminalPanel`]'s static chrome — see `plans/20260705-1722-zed-
214/// ui-component-enrichment/phase-03-real-terminal-pty-and-text-buffer.md`
215/// for what this intentionally does NOT cover: mouse support, hyperlinks,
216/// scrollback UI (only the visible screen is rendered, no history buffer),
217/// vi-mode, bracketed paste, and any verification on Linux/Windows (this
218/// was built and tested on macOS only).
219///
220/// If the PTY fails to spawn (e.g. a sandboxed/headless host with no
221/// controlling TTY), renders an inline error instead of panicking.
222///
223/// Stateful view — create with `cx.new(|cx| TerminalView::new(cx))` and
224/// store the resulting `Entity<TerminalView>`.
225pub struct TerminalView {
226    terminal: Option<terminal::Terminal>,
227    spawn_error: Option<String>,
228    focus_handle: FocusHandle,
229    /// Measured by a `canvas` child in `render` — read back at the START of
230    /// the NEXT render (same one-frame-lag pattern `PaneGroup`/
231    /// `ResizablePanelGroup` already use for their own bounds tracking) to
232    /// decide whether the PTY needs `Terminal::resize`.
233    container_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
234}
235
236impl TerminalView {
237    pub fn new(cx: &mut Context<Self>) -> Self {
238        let focus_handle = cx.focus_handle();
239        match terminal::Terminal::spawn(default_terminal_size()) {
240            Ok((terminal, events)) => {
241                cx.spawn(async move |this, cx| {
242                    while events.recv().await.is_ok() {
243                        if this.update(cx, |_, cx| cx.notify()).is_err() {
244                            break;
245                        }
246                    }
247                })
248                .detach();
249                Self {
250                    terminal: Some(terminal),
251                    spawn_error: None,
252                    focus_handle,
253                    container_bounds: Rc::new(Cell::new(None)),
254                }
255            }
256            Err(error) => Self {
257                terminal: None,
258                spawn_error: Some(error.to_string()),
259                focus_handle,
260                container_bounds: Rc::new(Cell::new(None)),
261            },
262        }
263    }
264
265    /// Moves keyboard focus onto the terminal so typed keys reach the PTY.
266    /// Callers should invoke this once after mounting (e.g. on click),
267    /// matching `TabSwitcher::focus`/`CommandPalette::focus_input`'s
268    /// convention in this crate.
269    pub fn focus(&self, window: &mut Window, cx: &mut App) {
270        window.focus(&self.focus_handle, cx);
271    }
272
273    fn handle_key_down(
274        &mut self,
275        event: &KeyDownEvent,
276        _window: &mut Window,
277        cx: &mut Context<Self>,
278    ) {
279        let Some(terminal) = &self.terminal else {
280            return;
281        };
282        if let Some(bytes) = encode_keystroke(&event.keystroke) {
283            terminal.write_input(bytes);
284            cx.notify();
285        }
286    }
287
288    /// Resizes the PTY if the last-measured container bounds imply a
289    /// different row/column count than the terminal currently has. A
290    /// no-op (skips `Terminal::resize`, which would otherwise send a
291    /// `SIGWINCH`-equivalent on every single render) when the size hasn't
292    /// actually changed.
293    fn sync_size_to_container(&self) {
294        let Some(terminal) = &self.terminal else {
295            return;
296        };
297        let Some(bounds) = self.container_bounds.get() else {
298            return;
299        };
300        let wanted = size_for_bounds(bounds);
301        let current = terminal.current_size();
302        if wanted.rows != current.rows || wanted.columns != current.columns {
303            terminal.resize(wanted);
304        }
305    }
306}
307
308impl Focusable for TerminalView {
309    fn focus_handle(&self, _cx: &App) -> FocusHandle {
310        self.focus_handle.clone()
311    }
312}
313
314impl Render for TerminalView {
315    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
316        self.sync_size_to_container();
317
318        let body: AnyElement = if let Some(error) = &self.spawn_error {
319            div()
320                .text_color(rgb(0xE06C75))
321                .child(format!("Failed to start terminal: {error}"))
322                .into_any_element()
323        } else {
324            let (text, highlights) = self
325                .terminal
326                .as_ref()
327                .map(|terminal| styled_screen_text(terminal.screen_cells()))
328                .unwrap_or_default();
329            StyledText::new(text)
330                .with_highlights(highlights)
331                .into_any_element()
332        };
333
334        let measure = self.container_bounds.clone();
335
336        div()
337            .id(("terminal-view", cx.entity_id()))
338            .track_focus(&self.focus_handle)
339            .on_key_down(cx.listener(Self::handle_key_down))
340            .relative()
341            .w_full()
342            .h_full()
343            .overflow_y_scroll()
344            .p_3()
345            .bg(rgb(0x0D1117))
346            .text_color(rgb(0xC9D1D9))
347            .font(terminal_font())
348            .text_size(TERMINAL_FONT_SIZE)
349            .line_height(relative(TERMINAL_LINE_HEIGHT))
350            .child(
351                canvas(
352                    move |bounds, _, _| measure.set(Some(bounds)),
353                    |_, _, _, _| {},
354                )
355                .absolute()
356                .size_full(),
357            )
358            .child(body)
359    }
360}
361
362/// Standalone gallery preview for `TerminalView` (not registered in the
363/// `Component` catalog since it is a stateful `Entity` that spawns a real
364/// process, matching `CodeEditor`/`TabSwitcher`'s existing convention in
365/// this crate — but unlike those, mounting this in a gallery genuinely
366/// spawns a real shell child process for as long as the entity is alive).
367pub fn terminal_view_preview(_window: &mut Window, cx: &mut App) -> AnyElement {
368    div()
369        .h(px(280.))
370        .rounded_lg()
371        .overflow_hidden()
372        .child(cx.new(|cx| TerminalView::new(cx)))
373        .into_any_element()
374}