Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T05:15:56.079Z 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

terminal.rs

5212 lines · 187.5 KB · rust
1mod mappings;
2
3mod alacritty;
4mod pty_info;
5pub mod terminal_settings;
6
7#[cfg(not(windows))]
8use anyhow::Context as _;
9use anyhow::{Result, bail};
10use futures_lite::future::yield_now;
11use log::trace;
12
13use futures::{
14    FutureExt,
15    channel::mpsc::{UnboundedReceiver, unbounded},
16};
17
18use itertools::Itertools as _;
19use mappings::mouse::{
20    alt_scroll, grid_point, grid_point_and_side, mouse_button_report, mouse_moved_report,
21    scroll_report,
22};
23
24use async_channel::{Receiver, Sender};
25use collections::{HashMap, VecDeque};
26use futures::StreamExt;
27use pty_info::{ProcessIdGetter, PtyProcessInfo};
28use serde::{Deserialize, Serialize};
29use settings::Settings;
30use task::{HideStrategy, Shell, ShellKind, SpawnInTerminal};
31use terminal_settings::{AlternateScroll, CursorShape as SettingsCursorShape, TerminalSettings};
32use theme::{ActiveTheme, Theme};
33use urlencoding;
34use util::{ResultExt as _, paths::PathStyle, truncate_and_trailoff};
35
36#[cfg(unix)]
37use std::os::unix::process::ExitStatusExt;
38use std::{
39    borrow::Cow,
40    cmp::{self, min},
41    fmt::{self, Display, Formatter},
42    future::Future,
43    ops::{BitOr, BitOrAssign, Deref, Range as StdRange},
44    path::{Path, PathBuf},
45    process::ExitStatus,
46    sync::{
47        Arc,
48        atomic::{AtomicU64, Ordering},
49    },
50    time::{Duration, Instant},
51};
52use thiserror::Error;
53use vte::ansi::{Attr, Handler, Processor, StdSyncHandler};
54pub use vte::ansi::{Color, NamedColor, Rgb};
55
56use gpui::{
57    App, AppContext as _, BackgroundExecutor, Bounds, ClipboardItem, Context, EventEmitter, Hsla,
58    Keystroke, Modifiers, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels,
59    Point as GpuiPoint, Rgba, ScrollWheelEvent, Size, Task, TouchPhase, Window, actions, black, px,
60};
61
62#[cfg(not(windows))]
63use crate::alacritty::current_child_signal_mask;
64use crate::alacritty::{
65    AlacrittyCell, AlacrittyGridIterator, AlacrittyHyperlink, AlacrittySearch, AlacrittyTerm,
66    AlacrittyTermConfig, AlacrittyTermLock, HyperlinkMatch, PtySender, RegexSearches,
67    append_text_to_term, apply_config, clear_saved_screen, content_text, display_offset,
68    display_only_term_config, find_from_terminal_point, full_content_range, last_non_empty_lines,
69    make_content, new_term, open_pty, pty_options, pty_term_config, resize, screen_lines,
70    scroll_display, scroll_to_point, search_matches, selection_text, set_default_cursor_style,
71    set_selection as set_term_selection, shrink_to_used, spawn_event_loop,
72    toggle_vi_mode as toggle_term_vi_mode, total_lines, update_selection as update_term_selection,
73    update_selection_to_vi_cursor, update_vi_cursor_for_scroll, vi_goto_point, vi_motion,
74};
75use crate::mappings::colors::to_vte_rgb;
76use crate::mappings::keys::to_esc_str;
77
78/// How long the shell and its foreground job get to exit gracefully after a
79/// closed terminal sends SIGHUP/SIGTERM, before being SIGKILLed. Must stay
80/// comfortably below [`gpui::SHUTDOWN_TIMEOUT`] so the escalation also
81/// completes when the whole app is quitting.
82const PROCESS_KILL_GRACE_PERIOD: Duration = Duration::from_millis(100);
83
84/// Sends SIGTERM to the terminal's shell and foreground process groups, and
85/// returns a future that SIGKILLs whatever survives [`PROCESS_KILL_GRACE_PERIOD`].
86/// Closing the PTY only delivers SIGHUP, and a foreground job that ignores
87/// SIGHUP/SIGTERM would otherwise be orphaned (#47412).
88///
89/// Must be called while the PTY master is still open (i.e. before
90/// `pty_tx.shutdown()`): reading the foreground process group requires
91/// `tcgetpgrp` on the PTY fd.
92fn terminate_processes_with_grace_period(
93    info: Arc<PtyProcessInfo>,
94    executor: BackgroundExecutor,
95) -> impl Future<Output = ()> {
96    let process_ids = info.capture_process_ids();
97    process_ids.terminate();
98    async move {
99        executor.timer(PROCESS_KILL_GRACE_PERIOD).await;
100        process_ids.kill();
101        info.kill_child_process();
102    }
103}
104
105/// Process-wide flag set by headless hosts (e.g. the eval CLI) that have no
106/// controlling TTY. In such sandboxes PTY allocation and acquiring a
107/// controlling terminal fail with `ENOTTY`, so when this is set terminals run
108/// their command as a plain subprocess with piped output instead of through a
109/// PTY. The normal editor leaves it unset to preserve the interactive PTY
110/// experience.
111#[derive(Clone, Copy, Default)]
112pub struct HeadlessTerminal(pub bool);
113
114impl gpui::Global for HeadlessTerminal {}
115
116impl HeadlessTerminal {
117    pub fn is_enabled(cx: &App) -> bool {
118        cx.try_global::<Self>().is_some_and(|headless| headless.0)
119    }
120}
121
122#[derive(Clone, Copy, Debug)]
123enum Scroll {
124    Delta(i32),
125    PageUp,
126    PageDown,
127    Top,
128    Bottom,
129}
130
131#[derive(Clone, Copy, Debug)]
132enum ViMotion {
133    Up,
134    Down,
135    Left,
136    Right,
137    First,
138    Last,
139    FirstOccupied,
140    High,
141    Middle,
142    Low,
143    WordLeft,
144    WordRight,
145    WordRightEnd,
146    Bracket,
147    ParagraphUp,
148    ParagraphDown,
149}
150
151#[derive(Clone, Debug)]
152pub struct Search {
153    search: AlacrittySearch,
154}
155
156#[derive(Clone, Debug)]
157struct Selection {
158    ty: SelectionType,
159    start: SelectionAnchor,
160    end: SelectionAnchor,
161    head: Point,
162}
163
164#[derive(Clone, Copy, Debug)]
165struct SelectionAnchor {
166    point: Point,
167    side: SelectionSide,
168}
169
170#[derive(Clone, Copy, Debug, Eq, PartialEq)]
171pub(crate) enum SelectionSide {
172    Left,
173    Right,
174}
175
176#[derive(Clone, Copy, Debug, Eq, PartialEq)]
177enum SelectionType {
178    Simple,
179    Semantic,
180    Lines,
181}
182
183impl Selection {
184    fn new(selection_type: SelectionType, point: Point, side: SelectionSide) -> Self {
185        let anchor = SelectionAnchor { point, side };
186        Self {
187            ty: selection_type,
188            start: anchor,
189            end: anchor,
190            head: point,
191        }
192    }
193
194    fn simple_range(range: Range) -> Self {
195        let mut selection = Self::new(SelectionType::Simple, range.start(), SelectionSide::Left);
196        selection.update(range.end(), SelectionSide::Right);
197        selection
198    }
199
200    fn update(&mut self, point: Point, side: SelectionSide) {
201        self.end = SelectionAnchor { point, side };
202        self.head = point;
203    }
204}
205
206pub fn is_default_background_color(color: Color) -> bool {
207    matches!(color, Color::Named(NamedColor::Background))
208}
209
210pub fn is_app_chosen_exact_color(color: Color) -> bool {
211    matches!(color, Color::Spec(_) | Color::Indexed(16..=255))
212}
213
214pub type AnsiSpans = Vec<(StdRange<usize>, Option<Color>)>;
215
216#[derive(Clone, Debug, Default, Eq, PartialEq)]
217pub struct ParsedAnsiText {
218    pub text: String,
219    pub foreground_spans: AnsiSpans,
220    pub background_spans: AnsiSpans,
221}
222
223pub fn parse_ansi_text(input: &[u8]) -> ParsedAnsiText {
224    let mut handler = StyledAnsiTextHandler::default();
225    let mut processor = Processor::<StdSyncHandler>::default();
226    processor.advance(&mut handler, input);
227    handler.finish()
228}
229
230pub fn strip_ansi_text(input: &[u8]) -> String {
231    let mut handler = PlainAnsiTextHandler::default();
232    let mut processor = Processor::<StdSyncHandler>::default();
233    processor.advance(&mut handler, input);
234    handler.text
235}
236
237#[derive(Default)]
238struct StyledAnsiTextHandler {
239    text: String,
240    foreground_spans: AnsiSpans,
241    background_spans: AnsiSpans,
242    current_foreground_range_start: usize,
243    current_background_range_start: usize,
244    current_foreground_color: Option<Color>,
245    current_background_color: Option<Color>,
246}
247
248impl StyledAnsiTextHandler {
249    fn finish(mut self) -> ParsedAnsiText {
250        if self.current_foreground_range_start < self.text.len() {
251            self.foreground_spans.push((
252                self.current_foreground_range_start..self.text.len(),
253                self.current_foreground_color,
254            ));
255        }
256
257        if self.current_background_range_start < self.text.len() {
258            self.background_spans.push((
259                self.current_background_range_start..self.text.len(),
260                self.current_background_color,
261            ));
262        }
263
264        ParsedAnsiText {
265            text: self.text,
266            foreground_spans: self.foreground_spans,
267            background_spans: self.background_spans,
268        }
269    }
270
271    fn break_foreground_span(&mut self, color: Option<Color>) {
272        self.foreground_spans.push((
273            self.current_foreground_range_start..self.text.len(),
274            self.current_foreground_color,
275        ));
276        self.current_foreground_color = color;
277        self.current_foreground_range_start = self.text.len();
278    }
279
280    fn break_background_span(&mut self, color: Option<Color>) {
281        self.background_spans.push((
282            self.current_background_range_start..self.text.len(),
283            self.current_background_color,
284        ));
285        self.current_background_color = color;
286        self.current_background_range_start = self.text.len();
287    }
288}
289
290impl Handler for StyledAnsiTextHandler {
291    fn input(&mut self, c: char) {
292        self.text.push(c);
293    }
294
295    fn linefeed(&mut self) {
296        self.text.push('\n');
297    }
298
299    fn put_tab(&mut self, count: u16) {
300        self.text.extend(std::iter::repeat_n('\t', count as usize));
301    }
302
303    fn terminal_attribute(&mut self, attr: Attr) {
304        match attr {
305            Attr::Foreground(color) => {
306                self.break_foreground_span(Some(color));
307            }
308            Attr::Background(color) => {
309                self.break_background_span(Some(color));
310            }
311            Attr::Reset => {
312                self.break_foreground_span(None);
313                self.break_background_span(None);
314            }
315            _ => {}
316        }
317    }
318}
319
320#[derive(Default)]
321struct PlainAnsiTextHandler {
322    text: String,
323    line_start: usize,
324}
325
326impl Handler for PlainAnsiTextHandler {
327    fn input(&mut self, c: char) {
328        self.text.push(c);
329    }
330
331    fn linefeed(&mut self) {
332        self.text.push('\n');
333        self.line_start = self.text.len();
334    }
335
336    fn carriage_return(&mut self) {
337        self.text.truncate(self.line_start);
338    }
339
340    fn put_tab(&mut self, count: u16) {
341        self.text.extend(std::iter::repeat_n('\t', count as usize));
342    }
343}
344
345#[derive(Debug, Clone, Eq, PartialEq)]
346pub struct Hyperlink {
347    data: HyperlinkData,
348}
349
350#[derive(Debug, Clone, Eq, PartialEq)]
351enum HyperlinkData {
352    Alacritty(AlacrittyHyperlink),
353    Owned { id: Option<Arc<str>>, uri: Arc<str> },
354}
355
356#[derive(Default, Debug, Clone, Eq, PartialEq)]
357pub struct Cell {
358    cell: AlacrittyCell,
359}
360
361pub struct RenderableCells<'a> {
362    cells: AlacrittyGridIterator<'a>,
363}
364
365#[derive(Debug, Clone)]
366pub struct IndexedCell {
367    pub point: Point,
368    pub cell: Cell,
369}
370
371impl Deref for IndexedCell {
372    type Target = Cell;
373
374    #[inline]
375    fn deref(&self) -> &Cell {
376        &self.cell
377    }
378}
379
380#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
381pub struct Modes(u32);
382
383impl Modes {
384    pub const NONE: Self = Self(0);
385    pub const APP_CURSOR: Self = Self(1 << 0);
386    pub const APP_KEYPAD: Self = Self(1 << 1);
387    pub const SHOW_CURSOR: Self = Self(1 << 2);
388    pub const LINE_WRAP: Self = Self(1 << 3);
389    pub const ORIGIN: Self = Self(1 << 4);
390    pub const INSERT: Self = Self(1 << 5);
391    pub const LINE_FEED_NEW_LINE: Self = Self(1 << 6);
392    pub const FOCUS_IN_OUT: Self = Self(1 << 7);
393    pub const ALTERNATE_SCROLL: Self = Self(1 << 8);
394    pub const BRACKETED_PASTE: Self = Self(1 << 9);
395    pub const SGR_MOUSE: Self = Self(1 << 10);
396    pub const UTF8_MOUSE: Self = Self(1 << 11);
397    pub const ALT_SCREEN: Self = Self(1 << 12);
398    pub const MOUSE_REPORT_CLICK: Self = Self(1 << 13);
399    pub const MOUSE_DRAG: Self = Self(1 << 14);
400    pub const MOUSE_MOTION: Self = Self(1 << 15);
401    pub const VI: Self = Self(1 << 16);
402    pub const MOUSE_MODE: Self =
403        Self(Self::MOUSE_REPORT_CLICK.0 | Self::MOUSE_DRAG.0 | Self::MOUSE_MOTION.0);
404
405    pub const fn empty() -> Self {
406        Self::NONE
407    }
408
409    pub const fn contains(self, other: Self) -> bool {
410        self.0 & other.0 == other.0
411    }
412
413    pub const fn intersects(self, other: Self) -> bool {
414        self.0 & other.0 != 0
415    }
416
417    pub fn insert(&mut self, other: Self) {
418        self.0 |= other.0;
419    }
420
421    pub fn remove(&mut self, other: Self) {
422        self.0 &= !other.0;
423    }
424}
425
426impl BitOr for Modes {
427    type Output = Self;
428
429    fn bitor(self, rhs: Self) -> Self::Output {
430        Self(self.0 | rhs.0)
431    }
432}
433
434impl BitOrAssign for Modes {
435    fn bitor_assign(&mut self, rhs: Self) {
436        self.insert(rhs);
437    }
438}
439
440#[derive(Clone, Copy, Debug, Eq, PartialEq)]
441pub struct Cursor {
442    pub shape: CursorShape,
443    pub point: Point,
444}
445
446#[derive(Clone, Copy, Debug, Eq, PartialEq)]
447pub enum CursorShape {
448    Block,
449    Underline,
450    Bar,
451    HollowBlock,
452    Hidden,
453}
454
455impl From<SettingsCursorShape> for CursorShape {
456    fn from(shape: SettingsCursorShape) -> Self {
457        match shape {
458            SettingsCursorShape::Block => Self::Block,
459            SettingsCursorShape::Underline => Self::Underline,
460            SettingsCursorShape::Bar => Self::Bar,
461            SettingsCursorShape::Hollow => Self::HollowBlock,
462        }
463    }
464}
465
466#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
467pub struct Point {
468    pub line: i32,
469    pub column: usize,
470}
471
472impl Point {
473    pub fn new(line: i32, column: usize) -> Self {
474        Self { line, column }
475    }
476}
477
478#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
479pub struct Range {
480    start: Point,
481    end: Point,
482}
483
484impl Range {
485    pub fn new(start: Point, end: Point) -> Self {
486        Self { start, end }
487    }
488
489    pub fn start(&self) -> Point {
490        self.start
491    }
492
493    pub fn end(&self) -> Point {
494        self.end
495    }
496
497    pub fn contains(&self, point: Point) -> bool {
498        self.start <= point && point <= self.end
499    }
500}
501
502#[derive(Clone, Copy, Debug, Eq, PartialEq)]
503pub struct SelectionRange {
504    pub start: Point,
505    pub end: Point,
506    pub is_block: bool,
507}
508
509impl SelectionRange {
510    pub fn point_range(self) -> Range {
511        Range::new(self.start, self.end)
512    }
513}
514
515// TODO: Un-pub
516#[derive(Clone)]
517pub struct Content {
518    pub cells: Vec<IndexedCell>,
519    pub mode: Modes,
520    pub display_offset: usize,
521    pub selection_text: Option<String>,
522    pub selection: Option<SelectionRange>,
523    pub cursor: Cursor,
524    pub cursor_char: char,
525    pub terminal_bounds: TerminalBounds,
526    pub last_hovered_word: Option<HoveredWord>,
527    pub scrolled_to_top: bool,
528    pub scrolled_to_bottom: bool,
529    pub bottom_row_occupied: bool,
530}
531
532#[derive(Debug, Clone, Eq, PartialEq)]
533pub struct HoveredWord {
534    pub word: String,
535    pub word_match: Range,
536    pub id: usize,
537}
538
539impl Default for Content {
540    fn default() -> Self {
541        Content {
542            cells: Default::default(),
543            mode: Default::default(),
544            display_offset: Default::default(),
545            selection_text: Default::default(),
546            selection: Default::default(),
547            cursor: Cursor {
548                shape: CursorShape::Block,
549                point: Point::new(0, 0),
550            },
551            cursor_char: Default::default(),
552            terminal_bounds: Default::default(),
553            last_hovered_word: None,
554            scrolled_to_top: false,
555            scrolled_to_bottom: false,
556            bottom_row_occupied: false,
557        }
558    }
559}
560
561#[derive(PartialEq, Eq)]
562enum SelectionPhase {
563    Selecting,
564    Ended,
565}
566
567#[cfg(test)]
568mod domain_tests {
569    use super::*;
570
571    #[test]
572    fn strip_ansi_text_removes_ansi_and_handles_carriage_returns() {
573        let cases = [
574            ("no escape codes here\n", "no escape codes here\n"),
575            ("\x1b[31mhello\x1b[0m", "hello"),
576            ("\x1b[1;32mfoo\x1b[0m bar", "foo bar"),
577            ("progress 10%\rprogress 100%\n", "progress 100%\n"),
578        ];
579
580        for (input, expected) in cases {
581            assert_eq!(strip_ansi_text(input.as_bytes()), expected);
582        }
583    }
584
585    #[test]
586    fn parse_ansi_text_records_foreground_and_background_spans() {
587        let parsed = parse_ansi_text(b"\x1b[31mred\x1b[44mblue-bg\x1b[0mplain");
588
589        assert_eq!(parsed.text, "redblue-bgplain");
590        assert_eq!(
591            parsed.foreground_spans,
592            vec![
593                (0..0, None),
594                (0..10, Some(Color::Named(NamedColor::Red))),
595                (10..15, None),
596            ]
597        );
598        assert_eq!(
599            parsed.background_spans,
600            vec![
601                (0..3, None),
602                (3..10, Some(Color::Named(NamedColor::Blue))),
603                (10..15, None),
604            ]
605        );
606    }
607
608    #[test]
609    fn terminal_cell_clone_shares_extra_storage() {
610        let mut cell = Cell::default();
611        cell.push_zerowidth('a');
612
613        let clone = cell.clone();
614
615        match (&cell.cell.extra, &clone.cell.extra) {
616            (Some(extra), Some(clone_extra)) => assert!(Arc::ptr_eq(extra, clone_extra)),
617            _ => panic!("expected extra storage on both cells"),
618        }
619    }
620}
621
622actions!(
623    terminal,
624    [
625        /// Clears the terminal screen.
626        Clear,
627        /// Copies selected text to the clipboard.
628        Copy,
629        /// Pastes from the clipboard.
630        Paste,
631        /// Pastes the text from the clipboard.
632        PasteText,
633        /// Shows the character palette for special characters.
634        ShowCharacterPalette,
635        /// Searches for text in the terminal.
636        SearchTest,
637        /// Scrolls up by one line.
638        ScrollLineUp,
639        /// Scrolls down by one line.
640        ScrollLineDown,
641        /// Scrolls up by one page.
642        ScrollPageUp,
643        /// Scrolls down by one page.
644        ScrollPageDown,
645        /// Scrolls up by half a page.
646        ScrollHalfPageUp,
647        /// Scrolls down by half a page.
648        ScrollHalfPageDown,
649        /// Scrolls to the top of the terminal buffer.
650        ScrollToTop,
651        /// Scrolls to the bottom of the terminal buffer.
652        ScrollToBottom,
653        /// Toggles vi mode in the terminal.
654        ToggleViMode,
655        /// Selects all text in the terminal.
656        SelectAll,
657    ]
658);
659
660const DEBUG_TERMINAL_WIDTH: Pixels = px(500.);
661const DEBUG_TERMINAL_HEIGHT: Pixels = px(30.);
662const DEBUG_CELL_WIDTH: Pixels = px(5.);
663const DEBUG_LINE_HEIGHT: Pixels = px(5.);
664
665/// Inserts Zed-specific environment variables for terminal sessions.
666/// Used by both local terminals and remote terminals (via SSH).
667pub fn insert_zed_terminal_env(
668    env: &mut HashMap<String, String>,
669    version: &impl std::fmt::Display,
670) {
671    env.insert("ZED_TERM".to_string(), "true".to_string());
672    env.insert("TERM_PROGRAM".to_string(), "zed".to_string());
673    env.insert("TERM".to_string(), "xterm-256color".to_string());
674    env.insert("COLORTERM".to_string(), "truecolor".to_string());
675    env.insert("TERM_PROGRAM_VERSION".to_string(), version.to_string());
676}
677
678///Upward flowing events, for changing the title and such
679#[derive(Clone, Debug, PartialEq, Eq)]
680pub enum Event {
681    TitleChanged,
682    BreadcrumbsChanged,
683    CloseTerminal,
684    Bell,
685    Wakeup,
686    BlinkChanged(bool),
687    SelectionsChanged,
688    NewNavigationTarget(Option<MaybeNavigationTarget>),
689    Open(MaybeNavigationTarget),
690}
691
692#[derive(Clone, Debug, PartialEq, Eq)]
693pub struct PathLikeTarget {
694    /// File system path, absolute or relative, existing or not.
695    /// Might have line and column number(s) attached as `file.rs:1:23`
696    pub maybe_path: String,
697    /// Current working directory of the terminal
698    pub terminal_dir: Option<PathBuf>,
699}
700
701/// A string inside terminal, potentially useful as a URI that can be opened.
702#[derive(Clone, Debug, PartialEq, Eq)]
703pub enum MaybeNavigationTarget {
704    /// HTTP, git, etc. string determined by the `URL_REGEX` regex.
705    Url(String),
706    /// File system path, absolute or relative, existing or not.
707    /// Might have line and column number(s) attached as `file.rs:1:23`
708    PathLike(PathLikeTarget),
709}
710
711#[derive(Clone)]
712enum InternalEvent {
713    Resize(TerminalBounds),
714    Clear,
715    // FocusNextMatch,
716    Scroll(Scroll),
717    ScrollToPoint(Point),
718    SetSelection(Option<Selection>),
719    UpdateSelection(GpuiPoint<Pixels>),
720    FindHyperlink(GpuiPoint<Pixels>, bool),
721    ProcessHyperlink(HyperlinkMatch, bool),
722    // Whether keep selection when copy
723    Copy(Option<bool>),
724    // Vi mode events
725    ToggleViMode,
726    ViMotion(ViMotion),
727    MoveViCursorToPoint(Point),
728}
729
730type ClipboardFormatter = Arc<dyn Fn(&str) -> String + Sync + Send + 'static>;
731type ColorFormatter = Arc<dyn Fn(Rgb) -> String + Sync + Send + 'static>;
732type TextAreaSizeFormatter = Arc<dyn Fn(TerminalBounds) -> String + Sync + Send + 'static>;
733
734#[derive(Clone)]
735pub(crate) enum TerminalBackendEvent {
736    MouseCursorDirty,
737    Title(String),
738    ResetTitle,
739    ClipboardStore(String),
740    ClipboardLoad(ClipboardFormatter),
741    ColorRequest(usize, ColorFormatter),
742    PtyWrite(String),
743    TextAreaSizeRequest(TextAreaSizeFormatter),
744    CursorBlinkingChange,
745    Wakeup,
746    Bell,
747    Exit,
748    ChildExit(ExitStatus),
749}
750
751impl fmt::Debug for TerminalBackendEvent {
752    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
753        match self {
754            Self::MouseCursorDirty => f.write_str("MouseCursorDirty"),
755            Self::Title(title) => write!(f, "Title({title})"),
756            Self::ResetTitle => f.write_str("ResetTitle"),
757            Self::ClipboardStore(data) => write!(f, "ClipboardStore({data})"),
758            Self::ClipboardLoad(_) => f.write_str("ClipboardLoad"),
759            Self::ColorRequest(index, _) => write!(f, "ColorRequest({index})"),
760            Self::PtyWrite(output) => write!(f, "PtyWrite({output})"),
761            Self::TextAreaSizeRequest(_) => f.write_str("TextAreaSizeRequest"),
762            Self::CursorBlinkingChange => f.write_str("CursorBlinkingChange"),
763            Self::Wakeup => f.write_str("Wakeup"),
764            Self::Bell => f.write_str("Bell"),
765            Self::Exit => f.write_str("Exit"),
766            Self::ChildExit(status) => write!(f, "ChildExit({status})"),
767        }
768    }
769}
770
771enum PtyEvent {
772    Event(TerminalBackendEvent),
773}
774
775#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
776pub struct TerminalBounds {
777    pub cell_width: Pixels,
778    pub line_height: Pixels,
779    pub bounds: Bounds<Pixels>,
780}
781
782impl TerminalBounds {
783    pub fn new(line_height: Pixels, cell_width: Pixels, bounds: Bounds<Pixels>) -> Self {
784        TerminalBounds {
785            cell_width,
786            line_height,
787            bounds,
788        }
789    }
790
791    pub fn num_lines(&self) -> usize {
792        // Tolerance to prevent f32 precision from losing a row:
793        // `N * line_height / line_height` can be N-epsilon, which floor()
794        // would round down, pushing the first line into invisible scrollback.
795        let raw = self.bounds.size.height / self.line_height;
796        raw.next_up().floor() as usize
797    }
798
799    pub fn num_columns(&self) -> usize {
800        let raw = self.bounds.size.width / self.cell_width;
801        raw.next_up().floor() as usize
802    }
803
804    pub fn height(&self) -> Pixels {
805        self.bounds.size.height
806    }
807
808    pub fn width(&self) -> Pixels {
809        self.bounds.size.width
810    }
811
812    pub fn cell_width(&self) -> Pixels {
813        self.cell_width
814    }
815
816    pub fn line_height(&self) -> Pixels {
817        self.line_height
818    }
819}
820
821impl Default for TerminalBounds {
822    fn default() -> Self {
823        TerminalBounds::new(
824            DEBUG_LINE_HEIGHT,
825            DEBUG_CELL_WIDTH,
826            Bounds {
827                origin: GpuiPoint::default(),
828                size: Size {
829                    width: DEBUG_TERMINAL_WIDTH,
830                    height: DEBUG_TERMINAL_HEIGHT,
831                },
832            },
833        )
834    }
835}
836
837fn normalize_terminal_bounds(mut bounds: TerminalBounds) -> TerminalBounds {
838    bounds.bounds.size.height = cmp::max(bounds.line_height, bounds.height());
839    bounds.bounds.size.width = cmp::max(bounds.cell_width, bounds.width());
840    bounds
841}
842
843#[derive(Error, Debug)]
844pub struct TerminalError {
845    pub directory: Option<PathBuf>,
846    pub program: Option<String>,
847    pub args: Option<Vec<String>>,
848    pub title_override: Option<String>,
849    pub source: std::io::Error,
850}
851
852impl TerminalError {
853    fn fmt_directory(&self) -> String {
854        self.directory
855            .clone()
856            .map(|path| {
857                match path
858                    .into_os_string()
859                    .into_string()
860                    .map_err(|os_str| format!("<non-utf8 path> {}", os_str.to_string_lossy()))
861                {
862                    Ok(s) => s,
863                    Err(s) => s,
864                }
865            })
866            .unwrap_or_else(|| "<none specified>".to_string())
867    }
868
869    fn fmt_shell(&self) -> String {
870        if let Some(title_override) = &self.title_override {
871            format!(
872                "{} {} ({})",
873                self.program.as_deref().unwrap_or("<system defined shell>"),
874                self.args.as_ref().into_iter().flatten().format(" "),
875                title_override
876            )
877        } else {
878            format!(
879                "{} {}",
880                self.program.as_deref().unwrap_or("<system defined shell>"),
881                self.args.as_ref().into_iter().flatten().format(" ")
882            )
883        }
884    }
885}
886
887impl Display for TerminalError {
888    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
889        let dir_string: String = self.fmt_directory();
890        let shell = self.fmt_shell();
891
892        write!(
893            f,
894            "Working directory: {} Shell command: `{}`, IOError: {}",
895            dir_string, shell, self.source
896        )
897    }
898}
899
900// https://github.com/alacritty/alacritty/blob/cb3a79dbf6472740daca8440d5166c1d4af5029e/extra/man/alacritty.5.scd?plain=1#L207-L213
901const DEFAULT_SCROLL_HISTORY_LINES: usize = 10_000;
902pub const MAX_SCROLL_HISTORY_LINES: usize = 100_000;
903static NEXT_INIT_COMMAND_STARTUP_MARKER_ID: AtomicU64 = AtomicU64::new(1);
904
905const INIT_COMMAND_STARTUP_MARKER_PREFIX: &str = "__zed_init_command_ready_";
906const INIT_COMMAND_STARTUP_MARKER_SUFFIX: &str = "__";
907const INIT_COMMAND_STARTUP_MARKER_SEARCH_LINES: usize = 64;
908
909fn init_command_startup_marker(marker_id: u64) -> String {
910    format!("{INIT_COMMAND_STARTUP_MARKER_PREFIX}{marker_id}{INIT_COMMAND_STARTUP_MARKER_SUFFIX}")
911}
912
913fn init_command_startup_marker_command(shell_kind: ShellKind, marker_id: u64) -> String {
914    // Split the marker across the command so its echo can't satisfy the
915    // handshake; only the command's output contains the contiguous marker.
916    match shell_kind {
917        ShellKind::PowerShell | ShellKind::Pwsh => format!(
918            "Write-Output ('{INIT_COMMAND_STARTUP_MARKER_PREFIX}' + '{marker_id}' + '{INIT_COMMAND_STARTUP_MARKER_SUFFIX}')"
919        ),
920        ShellKind::Cmd => {
921            format!(
922                "<nul set /p zed_init_ready={INIT_COMMAND_STARTUP_MARKER_PREFIX}&echo {marker_id}{INIT_COMMAND_STARTUP_MARKER_SUFFIX}"
923            )
924        }
925        ShellKind::Nushell => {
926            format!(
927                "print $\"{INIT_COMMAND_STARTUP_MARKER_PREFIX}({marker_id}){INIT_COMMAND_STARTUP_MARKER_SUFFIX}\""
928            )
929        }
930        ShellKind::Posix
931        | ShellKind::Csh
932        | ShellKind::Tcsh
933        | ShellKind::Rc
934        | ShellKind::Fish
935        | ShellKind::Xonsh
936        | ShellKind::Elvish => format!(
937            "printf '%s%s%s\\n' {INIT_COMMAND_STARTUP_MARKER_PREFIX} {marker_id} {INIT_COMMAND_STARTUP_MARKER_SUFFIX}"
938        ),
939    }
940}
941
942pub struct TerminalBuilder {
943    terminal: Terminal,
944    events_rx: UnboundedReceiver<PtyEvent>,
945}
946
947impl TerminalBuilder {
948    pub fn new_display_only(
949        cursor_shape: SettingsCursorShape,
950        alternate_scroll: AlternateScroll,
951        max_scroll_history_lines: Option<usize>,
952        window_id: u64,
953        background_executor: &BackgroundExecutor,
954        path_style: PathStyle,
955    ) -> TerminalBuilder {
956        Self::new_display_only_with_bounds(
957            cursor_shape,
958            alternate_scroll,
959            max_scroll_history_lines,
960            window_id,
961            background_executor,
962            path_style,
963            TerminalBounds::default(),
964        )
965    }
966
967    pub fn new_display_only_with_bounds(
968        cursor_shape: SettingsCursorShape,
969        alternate_scroll: AlternateScroll,
970        max_scroll_history_lines: Option<usize>,
971        window_id: u64,
972        background_executor: &BackgroundExecutor,
973        path_style: PathStyle,
974        terminal_bounds: TerminalBounds,
975    ) -> TerminalBuilder {
976        let terminal_bounds = normalize_terminal_bounds(terminal_bounds);
977
978        let scrolling_history = max_scroll_history_lines
979            .unwrap_or(DEFAULT_SCROLL_HISTORY_LINES)
980            .min(MAX_SCROLL_HISTORY_LINES);
981        let config = display_only_term_config(scrolling_history, cursor_shape);
982
983        let (events_tx, events_rx) = unbounded();
984        let term = new_term(&config, terminal_bounds, events_tx, alternate_scroll);
985
986        let terminal = Terminal {
987            task: None,
988            terminal_type: TerminalType::DisplayOnly,
989            subprocess: None,
990            completion_tx: None,
991            term,
992            term_config: config,
993            output_processor: Processor::<StdSyncHandler>::new(),
994            title_override: None,
995            events: VecDeque::with_capacity(10),
996            last_content: Content {
997                terminal_bounds,
998                ..Default::default()
999            },
1000            last_mouse: None,
1001            mouse_down_position: None,
1002            matches: Vec::new(),
1003
1004            selection_head: None,
1005            breadcrumb_text: String::new(),
1006            scroll_px: px(0.),
1007            next_link_id: 0,
1008            selection_phase: SelectionPhase::Ended,
1009            hyperlink_regex_searches: RegexSearches::default(),
1010            vi_mode_enabled: false,
1011            is_remote_terminal: false,
1012            last_mouse_move_time: Instant::now(),
1013            last_hyperlink_search_position: None,
1014            mouse_down_hyperlink: None,
1015            #[cfg(windows)]
1016            shell_program: None,
1017            activation_script: Vec::new(),
1018            template: CopyTemplate {
1019                shell: Shell::System,
1020                env: HashMap::default(),
1021                cursor_shape,
1022                alternate_scroll,
1023                max_scroll_history_lines,
1024                path_hyperlink_regexes: Vec::default(),
1025                path_hyperlink_timeout_ms: 0,
1026                window_id,
1027            },
1028            child_exited: None,
1029            keyboard_input_sent: false,
1030            init_command_startup_marker: None,
1031            init_command_startup_tx: None,
1032            event_loop_task: Task::ready(Ok(())),
1033            background_executor: background_executor.clone(),
1034            path_style,
1035            #[cfg(any(test, feature = "test-support"))]
1036            input_log: Vec::new(),
1037            #[cfg(any(test, feature = "test-support"))]
1038            pty_write_log: Default::default(),
1039        };
1040
1041        TerminalBuilder {
1042            terminal,
1043            events_rx,
1044        }
1045    }
1046
1047    pub fn new(
1048        working_directory: Option<PathBuf>,
1049        task: Option<TaskState>,
1050        shell: Shell,
1051        mut env: HashMap<String, String>,
1052        cursor_shape: SettingsCursorShape,
1053        alternate_scroll: AlternateScroll,
1054        max_scroll_history_lines: Option<usize>,
1055        path_hyperlink_regexes: Vec<String>,
1056        path_hyperlink_timeout_ms: u64,
1057        is_remote_terminal: bool,
1058        window_id: u64,
1059        completion_tx: Option<Sender<Option<ExitStatus>>>,
1060        cx: &App,
1061        activation_script: Vec<String>,
1062        path_style: PathStyle,
1063    ) -> Task<Result<TerminalBuilder>> {
1064        let version = release_channel::AppVersion::global(cx);
1065        let background_executor = cx.background_executor().clone();
1066        // Headless hosts (e.g. the eval CLI) have no controlling TTY, so PTY
1067        // allocation / acquiring a controlling terminal fails with `ENOTTY`.
1068        // When set, run the command as a plain subprocess instead.
1069        let no_pty = HeadlessTerminal::is_enabled(cx);
1070        #[cfg(not(windows))]
1071        let child_signal_mask = match current_child_signal_mask()
1072            .context("failed to capture terminal child signal mask")
1073        {
1074            Ok(signal_mask) => Some(signal_mask),
1075            Err(error) => return Task::ready(Err(error)),
1076        };
1077        let fut = async move {
1078            // Remove SHLVL so the spawned shell initializes it to 1, matching
1079            // the behavior of standalone terminal emulators like iTerm2/Kitty/Alacritty.
1080            env.remove("SHLVL");
1081
1082            // If the parent environment doesn't have a locale set
1083            // (As is the case when launched from a .app on MacOS),
1084            // and the Project doesn't have a locale set, then
1085            // set a fallback for our child environment to use.
1086            if std::env::var("LANG").is_err() {
1087                env.entry("LANG".to_string())
1088                    .or_insert_with(|| "en_US.UTF-8".to_string());
1089            }
1090
1091            insert_zed_terminal_env(&mut env, &version);
1092
1093            #[derive(Default)]
1094            struct ShellParams {
1095                program: String,
1096                args: Option<Vec<String>>,
1097                title_override: Option<String>,
1098            }
1099
1100            impl ShellParams {
1101                fn new(
1102                    program: String,
1103                    args: Option<Vec<String>>,
1104                    title_override: Option<String>,
1105                ) -> Self {
1106                    log::debug!("Using {program} as shell");
1107                    Self {
1108                        program,
1109                        args,
1110                        title_override,
1111                    }
1112                }
1113            }
1114
1115            let shell_params = match shell.clone() {
1116                Shell::System => {
1117                    if cfg!(windows) {
1118                        Some(ShellParams::new(
1119                            util::shell::get_windows_system_shell(),
1120                            None,
1121                            None,
1122                        ))
1123                    } else {
1124                        None
1125                    }
1126                }
1127                Shell::Program(program) => Some(ShellParams::new(program, None, None)),
1128                Shell::WithArguments {
1129                    program,
1130                    args,
1131                    title_override,
1132                } => Some(ShellParams::new(program, Some(args), title_override)),
1133            };
1134            let terminal_title_override =
1135                shell_params.as_ref().and_then(|e| e.title_override.clone());
1136
1137            #[cfg(windows)]
1138            let shell_program = shell_params.as_ref().map(|params| {
1139                use util::ResultExt;
1140
1141                Self::resolve_path(&params.program)
1142                    .log_err()
1143                    .unwrap_or(params.program.clone())
1144            });
1145
1146            // Note: when remoting, this shell_kind will scrutinize `ssh` or
1147            // `wsl.exe` as a shell and fall back to posix or powershell based on
1148            // the compilation target. This is fine right now due to the restricted
1149            // way we use the return value, but would become incorrect if we
1150            // supported remoting into windows.
1151            let shell_kind = shell.shell_kind(cfg!(windows));
1152
1153            let scrolling_history = if task.is_some() {
1154                // Tasks like `cargo build --all` may produce a lot of output, ergo allow maximum scrolling.
1155                // After the task finishes, we do not allow appending to that terminal, so small tasks output should not
1156                // cause excessive memory usage over time.
1157                MAX_SCROLL_HISTORY_LINES
1158            } else {
1159                max_scroll_history_lines
1160                    .unwrap_or(DEFAULT_SCROLL_HISTORY_LINES)
1161                    .min(MAX_SCROLL_HISTORY_LINES)
1162            };
1163            let config = pty_term_config(scrolling_history, cursor_shape);
1164
1165            //Spawn a task so the Alacritty EventLoop (or the subprocess reader) can communicate with us
1166            //TODO: Remove with a bounded sender which can be dispatched on &self
1167            let (events_tx, events_rx) = unbounded();
1168            //Set up the terminal...
1169            let term = new_term(
1170                &config,
1171                TerminalBounds::default(),
1172                events_tx.clone(),
1173                alternate_scroll,
1174            );
1175
1176            // When `no_pty` is set (headless hosts), run the task as a plain
1177            // subprocess and pump its piped output into the same emulator the
1178            // PTY path would feed.
1179            let (terminal_type, subprocess) = if no_pty {
1180                let (program, args) = match &shell_params {
1181                    Some(params) => (
1182                        params.program.clone(),
1183                        params.args.clone().unwrap_or_default(),
1184                    ),
1185                    None => (util::shell::get_system_shell(), Vec::new()),
1186                };
1187                let subprocess = match spawn_task_subprocess(
1188                    program,
1189                    args,
1190                    env.clone(),
1191                    working_directory.clone(),
1192                    term.clone(),
1193                    events_tx,
1194                    &background_executor,
1195                ) {
1196                    Ok(subprocess) => subprocess,
1197                    Err(error) => {
1198                        bail!(TerminalError {
1199                            directory: working_directory,
1200                            program: shell_params.as_ref().map(|params| params.program.clone()),
1201                            args: shell_params.as_ref().and_then(|params| params.args.clone()),
1202                            title_override: terminal_title_override,
1203                            source: std::io::Error::other(format!("{error:#}")),
1204                        });
1205                    }
1206                };
1207                (TerminalType::DisplayOnly, Some(subprocess))
1208            } else {
1209                let alacritty_shell = shell_params.as_ref().map(|params| {
1210                    (
1211                        params.program.clone(),
1212                        params.args.clone().unwrap_or_default(),
1213                    )
1214                });
1215                let pty_options = pty_options(
1216                    alacritty_shell,
1217                    working_directory.clone(),
1218                    env.clone(),
1219                    // We pass in the foreground thread's signal mask to the child process via pty_options,
1220                    // so terminal construction can run on a background thread without breaking Ctrl-C and other signals
1221                    // otherwise the terminal would inherit the background executor's signal mask which blocks
1222                    // some terminal signals
1223                    #[cfg(not(windows))]
1224                    child_signal_mask,
1225                    #[cfg(windows)]
1226                    shell_kind.tty_escape_args(),
1227                );
1228
1229                //Setup the pty...
1230                let pty = match open_pty(&pty_options, TerminalBounds::default(), window_id) {
1231                    Ok(pty) => pty,
1232                    Err(error) => {
1233                        bail!(TerminalError {
1234                            directory: working_directory,
1235                            program: shell_params.as_ref().map(|params| params.program.clone()),
1236                            args: shell_params.as_ref().and_then(|params| params.args.clone()),
1237                            title_override: terminal_title_override,
1238                            source: error,
1239                        });
1240                    }
1241                };
1242
1243                let pty_info = PtyProcessInfo::new(ProcessIdGetter::from(&pty));
1244
1245                //And connect them together
1246                let pty_tx =
1247                    spawn_event_loop(term.clone(), events_tx, pty, pty_options.drain_on_exit)?;
1248
1249                (
1250                    TerminalType::Pty {
1251                        pty_tx,
1252                        info: Arc::new(pty_info),
1253                    },
1254                    None,
1255                )
1256            };
1257
1258            let no_task = task.is_none();
1259            let terminal = Terminal {
1260                task,
1261                terminal_type,
1262                subprocess,
1263                completion_tx,
1264                term,
1265                term_config: config,
1266                output_processor: Processor::<StdSyncHandler>::new(),
1267                title_override: terminal_title_override,
1268                events: VecDeque::with_capacity(10), //Should never get this high.
1269                last_content: Default::default(),
1270                last_mouse: None,
1271                mouse_down_position: None,
1272                matches: Vec::new(),
1273
1274                selection_head: None,
1275                breadcrumb_text: String::new(),
1276                scroll_px: px(0.),
1277                next_link_id: 0,
1278                selection_phase: SelectionPhase::Ended,
1279                hyperlink_regex_searches: RegexSearches::new(
1280                    &path_hyperlink_regexes,
1281                    path_hyperlink_timeout_ms,
1282                ),
1283                vi_mode_enabled: false,
1284                is_remote_terminal,
1285                last_mouse_move_time: Instant::now(),
1286                last_hyperlink_search_position: None,
1287                mouse_down_hyperlink: None,
1288                #[cfg(windows)]
1289                shell_program,
1290                activation_script: activation_script.clone(),
1291                template: CopyTemplate {
1292                    shell,
1293                    env,
1294                    cursor_shape,
1295                    alternate_scroll,
1296                    max_scroll_history_lines,
1297                    path_hyperlink_regexes,
1298                    path_hyperlink_timeout_ms,
1299                    window_id,
1300                },
1301                child_exited: None,
1302                keyboard_input_sent: false,
1303                init_command_startup_marker: None,
1304                init_command_startup_tx: None,
1305                event_loop_task: Task::ready(Ok(())),
1306                background_executor,
1307                path_style,
1308                #[cfg(any(test, feature = "test-support"))]
1309                input_log: Vec::new(),
1310                #[cfg(any(test, feature = "test-support"))]
1311                pty_write_log: Default::default(),
1312            };
1313
1314            if !activation_script.is_empty() && no_task {
1315                for activation_script in activation_script {
1316                    terminal.write_to_pty(activation_script.into_bytes());
1317                    // Simulate enter key press
1318                    // NOTE(PowerShell): using `\r\n` will put PowerShell in a continuation mode (infamous >> character)
1319                    // and generally mess up the rendering.
1320                    terminal.write_to_pty(b"\x0d");
1321                }
1322                // In order to clear the screen at this point, we have two options:
1323                // 1. We can send a shell-specific command such as "clear" or "cls"
1324                // 2. We can "echo" a marker message that we will then catch when handling a Wakeup event
1325                //    and clear the screen using `terminal.clear()` method
1326                // We cannot issue a `terminal.clear()` command at this point as alacritty is evented
1327                // and while we have sent the activation script to the pty, it will be executed asynchronously.
1328                // Therefore, we somehow need to wait for the activation script to finish executing before we
1329                // can proceed with clearing the screen.
1330                terminal.write_to_pty(shell_kind.clear_screen_command().as_bytes());
1331                // Simulate enter key press
1332                terminal.write_to_pty(b"\x0d");
1333            }
1334
1335            Ok(TerminalBuilder {
1336                terminal,
1337                events_rx,
1338            })
1339        };
1340        cx.background_spawn(fut)
1341    }
1342
1343    pub fn subscribe(mut self, cx: &Context<Terminal>) -> Terminal {
1344        // `Terminal::drop` escalates to SIGKILL on a detached background task,
1345        // which never gets to run when the whole app quits: the process exits
1346        // as soon as the `on_app_quit` futures resolve. Perform the same
1347        // escalation in a quit observer, whose future keeps the app alive for
1348        // the grace period, so that processes ignoring SIGHUP/SIGTERM don't
1349        // outlive Zed (#47412). The subscription can't be stored on `Terminal`
1350        // (`Subscription` is not `Send`, and `TerminalBuilder` is built on a
1351        // background thread), so its lifetime is tied to the entity's release
1352        // instead.
1353        let app_quit_subscription = cx.on_app_quit(|terminal, cx| {
1354            let kill_processes = match &terminal.terminal_type {
1355                TerminalType::Pty { info, .. } => Some(terminate_processes_with_grace_period(
1356                    info.clone(),
1357                    cx.background_executor().clone(),
1358                )),
1359                TerminalType::DisplayOnly => None,
1360            };
1361            async move {
1362                if let Some(kill_processes) = kill_processes {
1363                    kill_processes.await;
1364                }
1365            }
1366        });
1367        cx.on_release(move |_, _| drop(app_quit_subscription))
1368            .detach();
1369
1370        //Event loop
1371        self.terminal.event_loop_task = cx.spawn(async move |terminal, cx| {
1372            while let Some(event) = self.events_rx.next().await {
1373                terminal.update(cx, |terminal, cx| {
1374                    //Process the first event immediately for lowered latency
1375                    terminal.process_pty_event(event, cx);
1376                })?;
1377
1378                'outer: loop {
1379                    let mut events = Vec::new();
1380
1381                    #[cfg(any(test, feature = "test-support"))]
1382                    let mut timer = cx.background_executor().simulate_random_delay().fuse();
1383                    #[cfg(not(any(test, feature = "test-support")))]
1384                    let mut timer = cx
1385                        .background_executor()
1386                        .timer(std::time::Duration::from_millis(4))
1387                        .fuse();
1388
1389                    let mut wakeup = false;
1390                    loop {
1391                        futures::select_biased! {
1392                            _ = timer => break,
1393                            event = self.events_rx.next() => {
1394                                if let Some(event) = event {
1395                                    if matches!(event, PtyEvent::Event(TerminalBackendEvent::Wakeup))
1396                                    {
1397                                        wakeup = true;
1398                                    } else {
1399                                        events.push(event);
1400                                    }
1401
1402                                    if events.len() > 100 {
1403                                        break;
1404                                    }
1405                                } else {
1406                                    break;
1407                                }
1408                            },
1409                        }
1410                    }
1411
1412                    if events.is_empty() && !wakeup {
1413                        yield_now().await;
1414                        break 'outer;
1415                    }
1416
1417                    terminal.update(cx, |this, cx| {
1418                        if wakeup {
1419                            this.process_event(TerminalBackendEvent::Wakeup, cx);
1420                        }
1421
1422                        for event in events {
1423                            this.process_pty_event(event, cx);
1424                        }
1425                    })?;
1426                    yield_now().await;
1427                }
1428            }
1429            anyhow::Ok(())
1430        });
1431        self.terminal
1432    }
1433
1434    #[cfg(windows)]
1435    fn resolve_path(path: &str) -> Result<String> {
1436        use windows::Win32::Storage::FileSystem::SearchPathW;
1437        use windows::core::HSTRING;
1438
1439        let path = if path.starts_with(r"\\?\") || !path.contains(&['/', '\\']) {
1440            path.to_string()
1441        } else {
1442            r"\\?\".to_string() + path
1443        };
1444
1445        let required_length = unsafe { SearchPathW(None, &HSTRING::from(&path), None, None, None) };
1446        let mut buf = vec![0u16; required_length as usize];
1447        let size = unsafe { SearchPathW(None, &HSTRING::from(&path), None, Some(&mut buf), None) };
1448
1449        Ok(String::from_utf16(&buf[..size as usize])?)
1450    }
1451}
1452
1453enum TerminalType {
1454    Pty {
1455        pty_tx: PtySender,
1456        info: Arc<PtyProcessInfo>,
1457    },
1458    DisplayOnly,
1459}
1460
1461pub struct Terminal {
1462    terminal_type: TerminalType,
1463    /// Set for non-PTY terminals (see [`HeadlessTerminal`]); owns the spawned
1464    /// subprocess and the task pumping its output into the grid.
1465    subprocess: Option<SubprocessHandle>,
1466    completion_tx: Option<Sender<Option<ExitStatus>>>,
1467    term: Arc<AlacrittyTermLock>,
1468    term_config: AlacrittyTermConfig,
1469    output_processor: Processor<StdSyncHandler>,
1470    events: VecDeque<InternalEvent>,
1471    /// This is only used for mouse mode cell change detection
1472    last_mouse: Option<(Point, SelectionSide)>,
1473    /// Window-relative position of the most recent left mouse-down. Used to
1474    /// apply a drag threshold before starting a selection (see #58970).
1475    mouse_down_position: Option<GpuiPoint<Pixels>>,
1476    pub matches: Vec<Range>,
1477    pub last_content: Content,
1478    pub selection_head: Option<Point>,
1479
1480    pub breadcrumb_text: String,
1481    title_override: Option<String>,
1482    scroll_px: Pixels,
1483    next_link_id: usize,
1484    selection_phase: SelectionPhase,
1485    hyperlink_regex_searches: RegexSearches,
1486    task: Option<TaskState>,
1487    vi_mode_enabled: bool,
1488    is_remote_terminal: bool,
1489    last_mouse_move_time: Instant,
1490    last_hyperlink_search_position: Option<GpuiPoint<Pixels>>,
1491    mouse_down_hyperlink: Option<HyperlinkMatch>,
1492    #[cfg(windows)]
1493    shell_program: Option<String>,
1494    template: CopyTemplate,
1495    activation_script: Vec<String>,
1496    child_exited: Option<ExitStatus>,
1497    keyboard_input_sent: bool,
1498    init_command_startup_marker: Option<String>,
1499    init_command_startup_tx: Option<Sender<()>>,
1500    event_loop_task: Task<Result<(), anyhow::Error>>,
1501    background_executor: BackgroundExecutor,
1502    path_style: PathStyle,
1503    #[cfg(any(test, feature = "test-support"))]
1504    input_log: Vec<Vec<u8>>,
1505    #[cfg(any(test, feature = "test-support"))]
1506    pty_write_log: std::cell::RefCell<Vec<Vec<u8>>>,
1507}
1508
1509struct CopyTemplate {
1510    shell: Shell,
1511    env: HashMap<String, String>,
1512    cursor_shape: SettingsCursorShape,
1513    alternate_scroll: AlternateScroll,
1514    max_scroll_history_lines: Option<usize>,
1515    path_hyperlink_regexes: Vec<String>,
1516    path_hyperlink_timeout_ms: u64,
1517    window_id: u64,
1518}
1519
1520#[derive(Debug)]
1521pub struct TaskState {
1522    pub status: TaskStatus,
1523    pub completion_rx: Receiver<Option<ExitStatus>>,
1524    pub spawned_task: SpawnInTerminal,
1525}
1526
1527/// A status of the current terminal tab's task.
1528#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1529pub enum TaskStatus {
1530    /// The task had been started, but got cancelled or somehow otherwise it did not
1531    /// report its exit code before the terminal event loop was shut down.
1532    Unknown,
1533    /// The task is started and running currently.
1534    Running,
1535    /// After the start, the task stopped running and reported its error code back.
1536    Completed { success: bool },
1537}
1538
1539impl TaskStatus {
1540    fn register_terminal_exit(&mut self) {
1541        if self == &Self::Running {
1542            *self = Self::Unknown;
1543        }
1544    }
1545
1546    fn register_task_exit(&mut self, error_code: i32) {
1547        *self = TaskStatus::Completed {
1548            success: error_code == 0,
1549        };
1550    }
1551}
1552
1553const FIND_HYPERLINK_THROTTLE_PX: Pixels = px(5.0);
1554
1555/// Minimum pointer movement before a left click begins a selection. This keeps
1556/// a click that jitters by a pixel or two (such as the window-focusing click)
1557/// from starting a selection and, with `copy_on_select` enabled, clobbering the
1558/// clipboard. Mirrors the drag threshold used by gpui's `div` element.
1559const SELECTION_DRAG_THRESHOLD: f64 = 2.0;
1560
1561impl Terminal {
1562    fn process_pty_event(&mut self, event: PtyEvent, cx: &mut Context<Self>) {
1563        match event {
1564            PtyEvent::Event(event) => self.process_event(event, cx),
1565        }
1566    }
1567
1568    fn process_event(&mut self, event: TerminalBackendEvent, cx: &mut Context<Self>) {
1569        match event {
1570            TerminalBackendEvent::Title(title) => {
1571                // ignore default shell program title change as windows always sends those events
1572                // and it would end up showing the shell executable path in breadcrumbs
1573                #[cfg(windows)]
1574                if self
1575                    .shell_program
1576                    .as_ref()
1577                    .map(|e| *e == title)
1578                    .unwrap_or(false)
1579                {
1580                    return;
1581                }
1582
1583                self.breadcrumb_text = title;
1584                cx.emit(Event::BreadcrumbsChanged);
1585            }
1586            TerminalBackendEvent::ResetTitle => {
1587                self.breadcrumb_text = String::new();
1588                cx.emit(Event::BreadcrumbsChanged);
1589            }
1590            TerminalBackendEvent::ClipboardStore(data) => {
1591                cx.write_to_clipboard(ClipboardItem::new_string(data))
1592            }
1593            TerminalBackendEvent::ClipboardLoad(format) => {
1594                self.write_to_pty(
1595                    match &cx.read_from_clipboard().and_then(|item| item.text()) {
1596                        // The terminal only supports pasting strings, not images.
1597                        Some(text) => format(text),
1598                        _ => format(""),
1599                    }
1600                    .into_bytes(),
1601                )
1602            }
1603            TerminalBackendEvent::PtyWrite(out) => self.write_to_pty(out.into_bytes()),
1604            TerminalBackendEvent::TextAreaSizeRequest(format) => {
1605                self.write_to_pty(format(self.last_content.terminal_bounds).into_bytes())
1606            }
1607            TerminalBackendEvent::CursorBlinkingChange => {
1608                let terminal = self.term.lock();
1609                let blinking = terminal.cursor_style().blinking;
1610                cx.emit(Event::BlinkChanged(blinking));
1611            }
1612            TerminalBackendEvent::Bell => {
1613                cx.emit(Event::Bell);
1614            }
1615            TerminalBackendEvent::Exit => self.register_task_finished(None, cx),
1616            TerminalBackendEvent::MouseCursorDirty => {
1617                //NOOP, Handled in render
1618            }
1619            TerminalBackendEvent::Wakeup => {
1620                self.detect_init_command_startup_marker();
1621                cx.emit(Event::Wakeup);
1622
1623                if let TerminalType::Pty { info, .. } = &self.terminal_type {
1624                    info.emit_title_changed_if_changed(cx);
1625                }
1626            }
1627            TerminalBackendEvent::ColorRequest(index, format) => {
1628                // It's important that the color request is processed here to retain relative order
1629                // with other PTY writes. Otherwise applications might witness out-of-order
1630                // responses to requests. For example: An application sending `OSC 11 ; ? ST`
1631                // (color request) followed by `CSI c` (request device attributes) would receive
1632                // the response to `CSI c` first.
1633                // Instead of locking, we could store the colors in `self.last_content`. But then
1634                // we might respond with out of date value if a "set color" sequence is immediately
1635                // followed by a color request sequence.
1636
1637                let color = self.term.lock().colors()[index]
1638                    .unwrap_or_else(|| to_vte_rgb(get_color_at_index(index, cx.theme().as_ref())));
1639                self.write_to_pty(format(color).into_bytes());
1640            }
1641            TerminalBackendEvent::ChildExit(exit_status) => {
1642                self.register_task_finished(Some(exit_status), cx);
1643            }
1644        }
1645    }
1646
1647    pub fn selection_started(&self) -> bool {
1648        self.selection_phase == SelectionPhase::Selecting
1649    }
1650
1651    fn process_terminal_event(
1652        &mut self,
1653        event: &InternalEvent,
1654        term: &mut AlacrittyTerm,
1655        window: &mut Window,
1656        cx: &mut Context<Self>,
1657    ) {
1658        match event {
1659            &InternalEvent::Resize(new_bounds) => {
1660                let new_bounds = normalize_terminal_bounds(new_bounds);
1661                trace!("Resizing: new_bounds={new_bounds:?}");
1662
1663                self.last_content.terminal_bounds = new_bounds;
1664
1665                if let TerminalType::Pty { pty_tx, .. } = &self.terminal_type {
1666                    pty_tx.resize(new_bounds);
1667                }
1668
1669                resize(term, new_bounds);
1670                // If there are matches we need to emit a wake up event to
1671                // invalidate the matches and recalculate their locations
1672                // in the new terminal layout
1673                if !self.matches.is_empty() {
1674                    cx.emit(Event::Wakeup);
1675                }
1676            }
1677            InternalEvent::Clear => {
1678                trace!("Clearing");
1679                clear_saved_screen(term);
1680                cx.emit(Event::Wakeup);
1681            }
1682            InternalEvent::Scroll(scroll) => {
1683                trace!("Scrolling: scroll={scroll:?}");
1684                scroll_display(term, *scroll);
1685                self.refresh_hovered_word(window);
1686
1687                if self.vi_mode_enabled {
1688                    update_vi_cursor_for_scroll(term, *scroll);
1689                    if let Some(selection_head) = update_selection_to_vi_cursor(term) {
1690                        #[cfg(any(target_os = "linux", target_os = "freebsd"))]
1691                        if let Some(selection_text) = selection_text(term) {
1692                            cx.write_to_primary(ClipboardItem::new_string(selection_text));
1693                        }
1694
1695                        self.selection_head = Some(selection_head);
1696                        cx.emit(Event::SelectionsChanged)
1697                    }
1698                }
1699            }
1700            InternalEvent::SetSelection(selection) => {
1701                trace!("Setting selection: selection={selection:?}");
1702                set_term_selection(term, selection.as_ref());
1703
1704                #[cfg(any(target_os = "linux", target_os = "freebsd"))]
1705                if let Some(selection_text) = selection_text(term) {
1706                    cx.write_to_primary(ClipboardItem::new_string(selection_text));
1707                }
1708
1709                if let Some(selection) = selection {
1710                    self.selection_head = Some(selection.head);
1711                }
1712                cx.emit(Event::SelectionsChanged)
1713            }
1714            InternalEvent::UpdateSelection(position) => {
1715                trace!("Updating selection: position={position:?}");
1716                let (point, side) = grid_point_and_side(
1717                    *position,
1718                    self.last_content.terminal_bounds,
1719                    display_offset(term),
1720                );
1721
1722                if update_term_selection(term, point, side) {
1723                    #[cfg(any(target_os = "linux", target_os = "freebsd"))]
1724                    if let Some(selection_text) = selection_text(term) {
1725                        cx.write_to_primary(ClipboardItem::new_string(selection_text));
1726                    }
1727
1728                    self.selection_head = Some(point);
1729                    cx.emit(Event::SelectionsChanged)
1730                }
1731            }
1732
1733            InternalEvent::Copy(keep_selection) => {
1734                trace!("Copying selection: keep_selection={keep_selection:?}");
1735                if let Some(txt) = selection_text(term) {
1736                    cx.write_to_clipboard(ClipboardItem::new_string(txt));
1737                    if !keep_selection.unwrap_or_else(|| {
1738                        let settings = TerminalSettings::get_global(cx);
1739                        settings.keep_selection_on_copy
1740                    }) {
1741                        self.events.push_back(InternalEvent::SetSelection(None));
1742                    }
1743                }
1744            }
1745            InternalEvent::ScrollToPoint(point) => {
1746                trace!("Scrolling to point: point={point:?}");
1747                scroll_to_point(term, *point);
1748                self.refresh_hovered_word(window);
1749            }
1750            InternalEvent::MoveViCursorToPoint(point) => {
1751                trace!("Move vi cursor to point: point={point:?}");
1752                vi_goto_point(term, *point);
1753                self.refresh_hovered_word(window);
1754            }
1755            InternalEvent::ToggleViMode => {
1756                trace!("Toggling vi mode");
1757                self.vi_mode_enabled = !self.vi_mode_enabled;
1758                toggle_term_vi_mode(term);
1759            }
1760            InternalEvent::ViMotion(motion) => {
1761                trace!("Performing vi motion: motion={motion:?}");
1762                vi_motion(term, *motion);
1763            }
1764            InternalEvent::FindHyperlink(position, open) => {
1765                trace!("Finding hyperlink at position: position={position:?}, open={open:?}");
1766
1767                let point = grid_point(
1768                    *position,
1769                    self.last_content.terminal_bounds,
1770                    display_offset(term),
1771                );
1772
1773                match find_from_terminal_point(
1774                    term,
1775                    point,
1776                    &mut self.hyperlink_regex_searches,
1777                    self.path_style,
1778                ) {
1779                    Some(hyperlink) => {
1780                        self.process_hyperlink(hyperlink, *open, cx);
1781                    }
1782                    None => {
1783                        self.last_content.last_hovered_word = None;
1784                        cx.emit(Event::NewNavigationTarget(None));
1785                    }
1786                }
1787            }
1788            InternalEvent::ProcessHyperlink(hyperlink, open) => {
1789                self.process_hyperlink(hyperlink.clone(), *open, cx);
1790            }
1791        }
1792    }
1793
1794    fn process_hyperlink(&mut self, hyperlink: HyperlinkMatch, open: bool, cx: &mut Context<Self>) {
1795        let HyperlinkMatch {
1796            text: maybe_url_or_path,
1797            is_url,
1798            range,
1799        } = hyperlink;
1800        let prev_hovered_word = self.last_content.last_hovered_word.take();
1801
1802        let target = if is_url {
1803            if let Some(path) = maybe_url_or_path.strip_prefix("file://") {
1804                let decoded_path = urlencoding::decode(path)
1805                    .map(|decoded| decoded.into_owned())
1806                    .unwrap_or(path.to_owned());
1807
1808                MaybeNavigationTarget::PathLike(PathLikeTarget {
1809                    maybe_path: decoded_path,
1810                    terminal_dir: self.working_directory(),
1811                })
1812            } else {
1813                MaybeNavigationTarget::Url(maybe_url_or_path.clone())
1814            }
1815        } else {
1816            MaybeNavigationTarget::PathLike(PathLikeTarget {
1817                maybe_path: maybe_url_or_path.clone(),
1818                terminal_dir: self.working_directory(),
1819            })
1820        };
1821
1822        if open {
1823            cx.emit(Event::Open(target));
1824        } else {
1825            self.update_selected_word(prev_hovered_word, range, maybe_url_or_path, target, cx);
1826        }
1827    }
1828
1829    fn find_hyperlink_at_point(&mut self, point: Point) -> Option<HyperlinkMatch> {
1830        let term_lock = self.term.lock();
1831        find_from_terminal_point(
1832            &term_lock,
1833            point,
1834            &mut self.hyperlink_regex_searches,
1835            self.path_style,
1836        )
1837    }
1838
1839    fn update_selected_word(
1840        &mut self,
1841        prev_word: Option<HoveredWord>,
1842        word_match: Range,
1843        word: String,
1844        navigation_target: MaybeNavigationTarget,
1845        cx: &mut Context<Self>,
1846    ) {
1847        if let Some(prev_word) = prev_word
1848            && prev_word.word == word
1849            && prev_word.word_match == word_match
1850        {
1851            self.last_content.last_hovered_word = Some(HoveredWord {
1852                word,
1853                word_match,
1854                id: prev_word.id,
1855            });
1856            return;
1857        }
1858
1859        self.last_content.last_hovered_word = Some(HoveredWord {
1860            word,
1861            word_match,
1862            id: self.next_link_id(),
1863        });
1864        cx.emit(Event::NewNavigationTarget(Some(navigation_target)));
1865        cx.notify()
1866    }
1867
1868    fn next_link_id(&mut self) -> usize {
1869        let res = self.next_link_id;
1870        self.next_link_id = self.next_link_id.wrapping_add(1);
1871        res
1872    }
1873
1874    pub fn last_content(&self) -> &Content {
1875        &self.last_content
1876    }
1877
1878    pub fn set_cursor_shape(&mut self, cursor_shape: SettingsCursorShape) {
1879        set_default_cursor_style(&mut self.term_config, cursor_shape);
1880        apply_config(&self.term, &self.term_config);
1881    }
1882
1883    pub fn write_output(&mut self, bytes: &[u8], cx: &mut Context<Self>) {
1884        // Inject bytes directly into the terminal emulator and refresh the UI.
1885        // This bypasses the PTY/event loop for display-only terminals.
1886        let mut previous_byte_was_cr = false;
1887        let converted = convert_lf_to_crlf(bytes, &mut previous_byte_was_cr);
1888
1889        let mut term = self.term.lock();
1890        self.output_processor.advance(&mut *term, &converted);
1891        drop(term);
1892        self.detect_init_command_startup_marker();
1893        cx.emit(Event::Wakeup);
1894    }
1895
1896    pub fn total_lines(&self) -> usize {
1897        total_lines(&self.term.lock_unfair())
1898    }
1899
1900    pub fn viewport_lines(&self) -> usize {
1901        screen_lines(&self.term.lock_unfair())
1902    }
1903
1904    //To test:
1905    //- Activate match on terminal (scrolling and selection)
1906    //- Editor search snapping behavior
1907
1908    pub fn activate_match(&mut self, index: usize) {
1909        if let Some(search_match) = self.matches.get(index).cloned() {
1910            self.set_selection(Some(Selection::simple_range(search_match)));
1911            if self.vi_mode_enabled {
1912                self.events
1913                    .push_back(InternalEvent::MoveViCursorToPoint(search_match.end()));
1914            } else {
1915                self.events
1916                    .push_back(InternalEvent::ScrollToPoint(search_match.start()));
1917            }
1918        }
1919    }
1920
1921    pub fn select_matches(&mut self, matches: &[Range]) {
1922        let matches_to_select = self
1923            .matches
1924            .iter()
1925            .filter(|self_match| matches.contains(self_match))
1926            .cloned()
1927            .collect::<Vec<_>>();
1928        for match_to_select in matches_to_select {
1929            self.set_selection(Some(Selection::simple_range(match_to_select)));
1930        }
1931    }
1932
1933    pub fn select_all(&mut self) {
1934        let term = self.term.lock();
1935        let range = full_content_range(&term);
1936        drop(term);
1937        self.set_selection(Some(Selection::simple_range(range)));
1938    }
1939
1940    fn set_selection(&mut self, selection: Option<Selection>) {
1941        self.events
1942            .push_back(InternalEvent::SetSelection(selection));
1943    }
1944
1945    pub fn copy(&mut self, keep_selection: Option<bool>) {
1946        self.events.push_back(InternalEvent::Copy(keep_selection));
1947    }
1948
1949    pub fn clear(&mut self) {
1950        self.events.push_back(InternalEvent::Clear)
1951    }
1952
1953    pub fn shrink_to_used(&mut self) {
1954        shrink_to_used(&mut self.term.lock());
1955    }
1956
1957    pub fn scroll_line_up(&mut self) {
1958        self.events
1959            .push_back(InternalEvent::Scroll(Scroll::Delta(1)));
1960    }
1961
1962    pub fn scroll_up_by(&mut self, lines: usize) {
1963        self.events
1964            .push_back(InternalEvent::Scroll(Scroll::Delta(lines as i32)));
1965    }
1966
1967    pub fn scroll_line_down(&mut self) {
1968        self.events
1969            .push_back(InternalEvent::Scroll(Scroll::Delta(-1)));
1970    }
1971
1972    pub fn scroll_down_by(&mut self, lines: usize) {
1973        self.events
1974            .push_back(InternalEvent::Scroll(Scroll::Delta(-(lines as i32))));
1975    }
1976
1977    pub fn scroll_page_up(&mut self) {
1978        self.events.push_back(InternalEvent::Scroll(Scroll::PageUp));
1979    }
1980
1981    pub fn scroll_page_down(&mut self) {
1982        self.events
1983            .push_back(InternalEvent::Scroll(Scroll::PageDown));
1984    }
1985
1986    pub fn scroll_to_top(&mut self) {
1987        self.events.push_back(InternalEvent::Scroll(Scroll::Top));
1988    }
1989
1990    pub fn scroll_to_bottom(&mut self) {
1991        self.events.push_back(InternalEvent::Scroll(Scroll::Bottom));
1992    }
1993
1994    pub fn scrolled_to_top(&self) -> bool {
1995        self.last_content.scrolled_to_top
1996    }
1997
1998    pub fn scrolled_to_bottom(&self) -> bool {
1999        self.last_content.scrolled_to_bottom
2000    }
2001
2002    ///Resize the terminal and the PTY.
2003    pub fn set_size(&mut self, new_bounds: TerminalBounds) {
2004        let new_bounds = normalize_terminal_bounds(new_bounds);
2005
2006        let old_bounds = self.last_content.terminal_bounds;
2007        self.last_content.terminal_bounds = new_bounds;
2008
2009        // Avoid spamming PTY resizes on pixel-level size changes (e.g. while dragging edges),
2010        // since those can generate excessive SIGWINCH/reflows and cause visible flicker.
2011        let requires_resize = old_bounds.num_lines() != new_bounds.num_lines()
2012            || old_bounds.num_columns() != new_bounds.num_columns()
2013            || old_bounds.cell_width != new_bounds.cell_width
2014            || old_bounds.line_height != new_bounds.line_height;
2015
2016        if !requires_resize {
2017            return;
2018        }
2019
2020        match self.events.back_mut() {
2021            Some(InternalEvent::Resize(pending_bounds)) => *pending_bounds = new_bounds,
2022            _ => self.events.push_back(InternalEvent::Resize(new_bounds)),
2023        }
2024    }
2025
2026    /// Write the Input payload to the PTY, if applicable.
2027    /// (This is a no-op for display-only terminals.)
2028    fn write_to_pty(&self, input: impl Into<Cow<'static, [u8]>>) {
2029        let input = input.into();
2030        #[cfg(any(test, feature = "test-support"))]
2031        self.pty_write_log.borrow_mut().push(input.to_vec());
2032        if let TerminalType::Pty { pty_tx, .. } = &self.terminal_type {
2033            if log::log_enabled!(log::Level::Debug) {
2034                if let Ok(str) = str::from_utf8(&input) {
2035                    log::debug!("Writing to PTY: {:?}", str);
2036                } else {
2037                    log::debug!("Writing to PTY: {:?}", input);
2038                }
2039            }
2040            pty_tx.notify(input);
2041        }
2042    }
2043
2044    pub fn input(&mut self, input: impl Into<Cow<'static, [u8]>>) {
2045        self.keyboard_input_sent = true;
2046        self.complete_init_command_startup_handshake();
2047        self.write_input(input);
2048    }
2049
2050    /// Sends a shell-level marker command and returns a task that completes when
2051    /// the marker appears in terminal output. Already complete for non-PTY
2052    /// terminals or those whose child has exited.
2053    ///
2054    /// Call at most once per terminal: a second handshake drops the previous
2055    /// `Sender`, which would write the init command twice.
2056    pub fn start_init_command_startup_handshake(&mut self) -> Task<()> {
2057        if !self.is_pty() || self.child_exited.is_some() {
2058            return Task::ready(());
2059        }
2060
2061        debug_assert!(
2062            self.init_command_startup_tx.is_none(),
2063            "start_init_command_startup_handshake called while a handshake is already in flight"
2064        );
2065
2066        let (startup_tx, startup_rx) = async_channel::bounded(1);
2067        let startup_task = self.background_executor.spawn(async move {
2068            match startup_rx.recv().await {
2069                Ok(()) | Err(_) => {}
2070            }
2071        });
2072
2073        let marker_id = NEXT_INIT_COMMAND_STARTUP_MARKER_ID.fetch_add(1, Ordering::Relaxed);
2074        self.init_command_startup_marker = Some(init_command_startup_marker(marker_id));
2075        self.init_command_startup_tx = Some(startup_tx);
2076
2077        let shell_kind = self.template.shell.shell_kind(self.path_style.is_windows());
2078        let mut input = init_command_startup_marker_command(shell_kind, marker_id).into_bytes();
2079        input.push(b'\x0d');
2080        self.write_to_pty(input);
2081
2082        startup_task
2083    }
2084
2085    fn detect_init_command_startup_marker(&mut self) {
2086        let Some(marker) = self.init_command_startup_marker.as_deref() else {
2087            return;
2088        };
2089
2090        let has_marker = {
2091            let term = self.term.lock_unfair();
2092            last_non_empty_lines(&term, INIT_COMMAND_STARTUP_MARKER_SEARCH_LINES)
2093                .iter()
2094                .any(|line| line.contains(marker))
2095        };
2096
2097        if has_marker {
2098            self.complete_init_command_startup_handshake();
2099        }
2100    }
2101
2102    fn complete_init_command_startup_handshake(&mut self) {
2103        self.init_command_startup_marker = None;
2104        if let Some(startup_tx) = self.init_command_startup_tx.take() {
2105            match startup_tx.try_send(()) {
2106                Ok(()) | Err(async_channel::TrySendError::Full(())) => {}
2107                Err(async_channel::TrySendError::Closed(())) => {}
2108            }
2109        }
2110    }
2111
2112    /// Write a programmatically-generated command to the PTY as if it had been
2113    /// typed, without marking the terminal as having received user keyboard
2114    /// input.
2115    pub fn write_init_command(&mut self, input: impl Into<Cow<'static, [u8]>>) {
2116        self.write_input(input);
2117    }
2118
2119    pub fn is_pty(&self) -> bool {
2120        matches!(self.terminal_type, TerminalType::Pty { .. })
2121    }
2122
2123    pub fn write_init_command_after_startup(
2124        &mut self,
2125        input: impl Into<Cow<'static, [u8]>>,
2126        cx: &mut Context<Self>,
2127    ) -> bool {
2128        // Ends the handshake even if the marker was never seen (timeout
2129        // fallback), so detection stops scanning on every wakeup.
2130        self.complete_init_command_startup_handshake();
2131
2132        if self.keyboard_input_sent || self.child_exited.is_some() {
2133            return false;
2134        }
2135
2136        self.clear_for_init_command(cx);
2137        self.write_init_command(input);
2138        true
2139    }
2140
2141    fn clear_for_init_command(&mut self, cx: &mut Context<Self>) {
2142        let mut term = self.term.lock_unfair();
2143        clear_saved_screen(&mut term);
2144        self.last_content = make_content(&term, &self.last_content);
2145        cx.emit(Event::Wakeup);
2146    }
2147
2148    fn write_input(&mut self, input: impl Into<Cow<'static, [u8]>>) {
2149        self.events.push_back(InternalEvent::Scroll(Scroll::Bottom));
2150        self.events.push_back(InternalEvent::SetSelection(None));
2151
2152        let input = input.into();
2153        #[cfg(any(test, feature = "test-support"))]
2154        self.input_log.push(input.to_vec());
2155
2156        self.write_to_pty(input);
2157    }
2158
2159    #[cfg(any(test, feature = "test-support"))]
2160    pub fn take_input_log(&mut self) -> Vec<Vec<u8>> {
2161        std::mem::take(&mut self.input_log)
2162    }
2163
2164    #[cfg(any(test, feature = "test-support"))]
2165    pub fn take_pty_write_log(&mut self) -> Vec<Vec<u8>> {
2166        std::mem::take(self.pty_write_log.get_mut())
2167    }
2168
2169    #[cfg(any(test, feature = "test-support"))]
2170    pub fn keyboard_input_sent(&self) -> bool {
2171        self.keyboard_input_sent
2172    }
2173
2174    pub fn toggle_vi_mode(&mut self) {
2175        self.events.push_back(InternalEvent::ToggleViMode);
2176    }
2177
2178    pub fn vi_motion(&mut self, keystroke: &Keystroke) {
2179        if !self.vi_mode_enabled {
2180            return;
2181        }
2182
2183        let key: Cow<'_, str> = if keystroke.modifiers.shift {
2184            Cow::Owned(keystroke.key.to_uppercase())
2185        } else {
2186            Cow::Borrowed(keystroke.key.as_str())
2187        };
2188
2189        let motion: Option<ViMotion> = match key.as_ref() {
2190            "h" | "left" => Some(ViMotion::Left),
2191            "j" | "down" => Some(ViMotion::Down),
2192            "k" | "up" => Some(ViMotion::Up),
2193            "l" | "right" => Some(ViMotion::Right),
2194            "w" => Some(ViMotion::WordRight),
2195            "b" if !keystroke.modifiers.control => Some(ViMotion::WordLeft),
2196            "e" => Some(ViMotion::WordRightEnd),
2197            "%" => Some(ViMotion::Bracket),
2198            "$" => Some(ViMotion::Last),
2199            "0" => Some(ViMotion::First),
2200            "^" => Some(ViMotion::FirstOccupied),
2201            "H" => Some(ViMotion::High),
2202            "M" => Some(ViMotion::Middle),
2203            "L" => Some(ViMotion::Low),
2204            "{" => Some(ViMotion::ParagraphUp),
2205            "}" => Some(ViMotion::ParagraphDown),
2206            _ => None,
2207        };
2208
2209        if let Some(motion) = motion {
2210            let cursor = self.last_content.cursor.point;
2211            let cursor_pos = GpuiPoint {
2212                x: cursor.column as f32 * self.last_content.terminal_bounds.cell_width,
2213                y: cursor.line as f32 * self.last_content.terminal_bounds.line_height,
2214            };
2215            self.events
2216                .push_back(InternalEvent::UpdateSelection(cursor_pos));
2217            self.events.push_back(InternalEvent::ViMotion(motion));
2218            return;
2219        }
2220
2221        let scroll_motion = match key.as_ref() {
2222            "g" => Some(Scroll::Top),
2223            "G" => Some(Scroll::Bottom),
2224            "b" if keystroke.modifiers.control => Some(Scroll::PageUp),
2225            "f" if keystroke.modifiers.control => Some(Scroll::PageDown),
2226            "d" if keystroke.modifiers.control => {
2227                let amount = self.last_content.terminal_bounds.line_height().to_f64() as i32 / 2;
2228                Some(Scroll::Delta(-amount))
2229            }
2230            "u" if keystroke.modifiers.control => {
2231                let amount = self.last_content.terminal_bounds.line_height().to_f64() as i32 / 2;
2232                Some(Scroll::Delta(amount))
2233            }
2234            _ => None,
2235        };
2236
2237        if let Some(scroll_motion) = scroll_motion {
2238            self.events.push_back(InternalEvent::Scroll(scroll_motion));
2239            return;
2240        }
2241
2242        match key.as_ref() {
2243            "v" => {
2244                let point = self.last_content.cursor.point;
2245                let selection_type = SelectionType::Simple;
2246                let side = SelectionSide::Right;
2247                let selection = Selection::new(selection_type, point, side);
2248                self.events
2249                    .push_back(InternalEvent::SetSelection(Some(selection)));
2250            }
2251
2252            "escape" => {
2253                self.events.push_back(InternalEvent::SetSelection(None));
2254            }
2255
2256            "y" => {
2257                self.copy(Some(false));
2258            }
2259
2260            "i" => {
2261                self.scroll_to_bottom();
2262                self.toggle_vi_mode();
2263            }
2264            _ => {}
2265        }
2266    }
2267
2268    pub fn try_keystroke(&mut self, keystroke: &Keystroke, option_as_meta: bool) -> bool {
2269        if self.vi_mode_enabled {
2270            self.vi_motion(keystroke);
2271            return true;
2272        }
2273
2274        // Keep default terminal behavior
2275        let esc = to_esc_str(keystroke, self.last_content.mode, option_as_meta);
2276        if let Some(esc) = esc {
2277            match esc {
2278                Cow::Borrowed(string) => self.input(string.as_bytes()),
2279                Cow::Owned(string) => self.input(string.into_bytes()),
2280            };
2281            true
2282        } else {
2283            false
2284        }
2285    }
2286
2287    pub fn try_modifiers_change(
2288        &mut self,
2289        modifiers: &Modifiers,
2290        window: &Window,
2291        cx: &mut Context<Self>,
2292    ) {
2293        if self
2294            .last_content
2295            .terminal_bounds
2296            .bounds
2297            .contains(&window.mouse_position())
2298            && modifiers.secondary()
2299        {
2300            self.refresh_hovered_word(window);
2301        }
2302        cx.notify();
2303    }
2304
2305    ///Paste text into the terminal
2306    pub fn paste(&mut self, text: &str) {
2307        let paste_text = if self.last_content.mode.contains(Modes::BRACKETED_PASTE) {
2308            format!("{}{}{}", "\x1b[200~", text.replace('\x1b', ""), "\x1b[201~")
2309        } else {
2310            text.replace("\r\n", "\r").replace('\n', "\r")
2311        };
2312
2313        self.input(paste_text.into_bytes());
2314    }
2315
2316    pub fn sync(&mut self, window: &mut Window, cx: &mut Context<Self>) {
2317        let term = self.term.clone();
2318        let mut terminal = term.lock_unfair();
2319        //Note that the ordering of events matters for event processing
2320        while let Some(e) = self.events.pop_front() {
2321            self.process_terminal_event(&e, &mut terminal, window, cx)
2322        }
2323
2324        self.last_content = make_content(&terminal, &self.last_content);
2325    }
2326
2327    pub fn with_renderable_cells<R>(&self, f: impl for<'a> FnOnce(RenderableCells<'a>) -> R) -> R {
2328        let term = self.term.lock_unfair();
2329        let content = term.renderable_content();
2330        f(RenderableCells::new(content.display_iter))
2331    }
2332
2333    pub fn get_content(&self) -> String {
2334        let term = self.term.lock_unfair();
2335        content_text(&term)
2336    }
2337
2338    pub fn last_n_non_empty_lines(&self, n: usize) -> Vec<String> {
2339        let terminal = self.term.lock_unfair();
2340        last_non_empty_lines(&terminal, n)
2341    }
2342
2343    pub fn focus_in(&self) {
2344        if self.last_content.mode.contains(Modes::FOCUS_IN_OUT) {
2345            self.write_to_pty("\x1b[I".as_bytes());
2346        }
2347    }
2348
2349    pub fn focus_out(&mut self) {
2350        if self.last_content.mode.contains(Modes::FOCUS_IN_OUT) {
2351            self.write_to_pty("\x1b[O".as_bytes());
2352        }
2353    }
2354
2355    fn mouse_changed(&mut self, point: Point, side: SelectionSide) -> bool {
2356        match self.last_mouse {
2357            Some((old_point, old_side)) => {
2358                if old_point == point && old_side == side {
2359                    false
2360                } else {
2361                    self.last_mouse = Some((point, side));
2362                    true
2363                }
2364            }
2365            None => {
2366                self.last_mouse = Some((point, side));
2367                true
2368            }
2369        }
2370    }
2371
2372    pub fn mouse_mode(&self, shift: bool) -> bool {
2373        self.last_content.mode.intersects(Modes::MOUSE_MODE) && !shift
2374    }
2375
2376    pub fn mouse_move(&mut self, e: &MouseMoveEvent, cx: &mut Context<Self>) {
2377        let position = e.position - self.last_content.terminal_bounds.bounds.origin;
2378        if self.mouse_mode(e.modifiers.shift) {
2379            // A ctrl/cmd press on a link suppressed its button-press report in
2380            // `mouse_down`. Since the app never saw the press, we must swallow
2381            // the whole gesture rather than forward later motion/release
2382            // reports, which would be a press-less (malformed) sequence.
2383            // `mouse_up` resolves it: release on the same link opens it,
2384            // otherwise the gesture is dropped.
2385            if self.mouse_down_hyperlink.is_none() {
2386                let (point, side) = grid_point_and_side(
2387                    position,
2388                    self.last_content.terminal_bounds,
2389                    self.last_content.display_offset,
2390                );
2391
2392                if self.mouse_changed(point, side) {
2393                    let bytes = mouse_moved_report(
2394                        point,
2395                        e.pressed_button,
2396                        e.modifiers,
2397                        self.last_content.mode,
2398                    );
2399
2400                    if let Some(bytes) = bytes {
2401                        self.write_to_pty(bytes);
2402                    }
2403                }
2404            }
2405        } else {
2406            self.schedule_find_hyperlink(e.modifiers, e.position);
2407        }
2408        cx.notify();
2409    }
2410
2411    fn schedule_find_hyperlink(&mut self, modifiers: Modifiers, position: GpuiPoint<Pixels>) {
2412        if self.selection_phase == SelectionPhase::Selecting
2413            || !modifiers.secondary()
2414            || !self.last_content.terminal_bounds.bounds.contains(&position)
2415        {
2416            self.last_content.last_hovered_word = None;
2417            return;
2418        }
2419
2420        // Throttle hyperlink searches to avoid excessive processing
2421        let now = Instant::now();
2422        if self
2423            .last_hyperlink_search_position
2424            .map_or(true, |last_pos| {
2425                // Only search if mouse moved significantly or enough time passed
2426                let distance_moved = ((position.x - last_pos.x).abs()
2427                    + (position.y - last_pos.y).abs())
2428                    > FIND_HYPERLINK_THROTTLE_PX;
2429                let time_elapsed = now.duration_since(self.last_mouse_move_time).as_millis() > 100;
2430                distance_moved || time_elapsed
2431            })
2432        {
2433            self.last_mouse_move_time = now;
2434            self.last_hyperlink_search_position = Some(position);
2435            self.events.push_back(InternalEvent::FindHyperlink(
2436                position - self.last_content.terminal_bounds.bounds.origin,
2437                false,
2438            ));
2439        }
2440    }
2441
2442    pub fn select_word_at_event_position(&mut self, e: &MouseDownEvent) {
2443        let position = e.position - self.last_content.terminal_bounds.bounds.origin;
2444        let (point, side) = grid_point_and_side(
2445            position,
2446            self.last_content.terminal_bounds,
2447            self.last_content.display_offset,
2448        );
2449        let selection = Selection::new(SelectionType::Semantic, point, side);
2450        self.events
2451            .push_back(InternalEvent::SetSelection(Some(selection)));
2452    }
2453
2454    pub fn mouse_drag(
2455        &mut self,
2456        e: &MouseMoveEvent,
2457        region: Bounds<Pixels>,
2458        cx: &mut Context<Self>,
2459    ) {
2460        let position = e.position - self.last_content.terminal_bounds.bounds.origin;
2461        if !self.mouse_mode(e.modifiers.shift) {
2462            if let Some(hyperlink) = &self.mouse_down_hyperlink {
2463                let point = grid_point(
2464                    position,
2465                    self.last_content.terminal_bounds,
2466                    self.last_content.display_offset,
2467                );
2468
2469                if !hyperlink.range.contains(point) {
2470                    self.mouse_down_hyperlink = None;
2471                } else {
2472                    return;
2473                }
2474            }
2475
2476            // Ignore tiny pointer movements so that a click that jitters by a
2477            // pixel or two (e.g. the window-focusing click) does not begin a
2478            // selection. Mirrors the drag threshold used by gpui's `div`.
2479            if self.selection_phase != SelectionPhase::Selecting
2480                && let Some(mouse_down_position) = self.mouse_down_position
2481                && (e.position - mouse_down_position).magnitude() <= SELECTION_DRAG_THRESHOLD
2482            {
2483                return;
2484            }
2485
2486            self.selection_phase = SelectionPhase::Selecting;
2487            // Alacritty has the same ordering, of first updating the selection
2488            // then scrolling 15ms later
2489            self.events
2490                .push_back(InternalEvent::UpdateSelection(position));
2491
2492            // Doesn't make sense to scroll the alt screen
2493            if !self.last_content.mode.contains(Modes::ALT_SCREEN) {
2494                let scroll_lines = match self.drag_line_delta(e, region) {
2495                    Some(value) => value,
2496                    None => return,
2497                };
2498
2499                self.events
2500                    .push_back(InternalEvent::Scroll(Scroll::Delta(scroll_lines)));
2501            }
2502
2503            cx.notify();
2504        }
2505    }
2506
2507    fn drag_line_delta(&self, e: &MouseMoveEvent, region: Bounds<Pixels>) -> Option<i32> {
2508        let top = region.origin.y;
2509        let bottom = region.bottom_left().y;
2510
2511        let scroll_lines = if e.position.y < top {
2512            let scroll_delta = (top - e.position.y).pow(1.1);
2513            (scroll_delta / self.last_content.terminal_bounds.line_height).ceil() as i32
2514        } else if e.position.y > bottom {
2515            let scroll_delta = -((e.position.y - bottom).pow(1.1));
2516            (scroll_delta / self.last_content.terminal_bounds.line_height).floor() as i32
2517        } else {
2518            return None;
2519        };
2520
2521        Some(scroll_lines.clamp(-3, 3))
2522    }
2523
2524    pub fn mouse_down(&mut self, e: &MouseDownEvent, cx: &mut Context<Self>) {
2525        let position = e.position - self.last_content.terminal_bounds.bounds.origin;
2526        let point = grid_point(
2527            position,
2528            self.last_content.terminal_bounds,
2529            self.last_content.display_offset,
2530        );
2531
2532        if e.button == MouseButton::Left
2533            && e.modifiers.secondary()
2534            && (TerminalSettings::get_global(cx).open_links_in_mouse_mode
2535                || !self.mouse_mode(e.modifiers.shift))
2536        {
2537            self.mouse_down_hyperlink = self.find_hyperlink_at_point(point);
2538
2539            if self.mouse_down_hyperlink.is_some() {
2540                return;
2541            }
2542        }
2543
2544        if self.mouse_mode(e.modifiers.shift) {
2545            let bytes =
2546                mouse_button_report(point, e.button, e.modifiers, true, self.last_content.mode);
2547
2548            if let Some(bytes) = bytes {
2549                self.write_to_pty(bytes);
2550            }
2551        } else {
2552            match e.button {
2553                MouseButton::Left => {
2554                    self.mouse_down_position = Some(e.position);
2555                    let (point, side) = grid_point_and_side(
2556                        position,
2557                        self.last_content.terminal_bounds,
2558                        self.last_content.display_offset,
2559                    );
2560
2561                    let selection_type = match e.click_count {
2562                        0 => return, //This is a release
2563                        1 => Some(SelectionType::Simple),
2564                        2 => Some(SelectionType::Semantic),
2565                        3 => Some(SelectionType::Lines),
2566                        _ => None,
2567                    };
2568
2569                    if selection_type == Some(SelectionType::Simple) && e.modifiers.shift {
2570                        if self.last_content.selection.is_some() {
2571                            // Shift+click extends the existing selection to this point.
2572                            self.events
2573                                .push_back(InternalEvent::UpdateSelection(position));
2574                        } else {
2575                            // With no selection yet, Shift is the escape hatch for
2576                            // selecting text while an app has mouse tracking enabled,
2577                            // so anchor a selection here for the drag to extend.
2578                            self.events.push_back(InternalEvent::SetSelection(Some(
2579                                Selection::new(SelectionType::Simple, point, side),
2580                            )));
2581                        }
2582                        return;
2583                    }
2584
2585                    let selection = selection_type
2586                        .map(|selection_type| Selection::new(selection_type, point, side));
2587
2588                    if let Some(selection) = selection {
2589                        self.events
2590                            .push_back(InternalEvent::SetSelection(Some(selection)));
2591                    }
2592                }
2593                #[cfg(any(target_os = "linux", target_os = "freebsd"))]
2594                MouseButton::Middle => {
2595                    if let Some(item) = cx.read_from_primary() {
2596                        let text = item.text().unwrap_or_default();
2597                        self.paste(&text);
2598                    }
2599                }
2600                _ => {}
2601            }
2602        }
2603    }
2604
2605    pub fn mouse_up(&mut self, e: &MouseUpEvent, cx: &Context<Self>) {
2606        let setting = TerminalSettings::get_global(cx);
2607
2608        let position = e.position - self.last_content.terminal_bounds.bounds.origin;
2609        if let Some(mouse_down_hyperlink) = self.mouse_down_hyperlink.take() {
2610            let point = grid_point(
2611                position,
2612                self.last_content.terminal_bounds,
2613                self.last_content.display_offset,
2614            );
2615
2616            if self
2617                .find_hyperlink_at_point(point)
2618                .is_some_and(|mouse_up_hyperlink| mouse_up_hyperlink == mouse_down_hyperlink)
2619            {
2620                self.events
2621                    .push_back(InternalEvent::ProcessHyperlink(mouse_down_hyperlink, true));
2622                self.selection_phase = SelectionPhase::Ended;
2623                self.last_mouse = None;
2624                self.mouse_down_position = None;
2625                return;
2626            }
2627
2628            if self.mouse_mode(e.modifiers.shift) {
2629                self.selection_phase = SelectionPhase::Ended;
2630                self.last_mouse = None;
2631                self.mouse_down_position = None;
2632                return;
2633            }
2634        }
2635
2636        if self.mouse_mode(e.modifiers.shift) {
2637            let point = grid_point(
2638                position,
2639                self.last_content.terminal_bounds,
2640                self.last_content.display_offset,
2641            );
2642
2643            let bytes =
2644                mouse_button_report(point, e.button, e.modifiers, false, self.last_content.mode);
2645
2646            if let Some(bytes) = bytes {
2647                self.write_to_pty(bytes);
2648            }
2649        } else {
2650            if e.button == MouseButton::Left && setting.copy_on_select {
2651                self.copy(Some(true));
2652            }
2653
2654            //Hyperlinks
2655            if self.selection_phase == SelectionPhase::Ended {
2656                let mouse_cell_index =
2657                    content_index_for_mouse(position, &self.last_content.terminal_bounds);
2658                if let Some(link) = self
2659                    .last_content
2660                    .cells
2661                    .get(mouse_cell_index)
2662                    .and_then(|cell| cell.hyperlink())
2663                {
2664                    cx.open_url(link.uri());
2665                } else if e.modifiers.secondary() {
2666                    self.events
2667                        .push_back(InternalEvent::FindHyperlink(position, true));
2668                }
2669            }
2670        }
2671
2672        self.selection_phase = SelectionPhase::Ended;
2673        self.last_mouse = None;
2674        self.mouse_down_position = None;
2675    }
2676
2677    ///Scroll the terminal
2678    pub fn scroll_wheel(&mut self, e: &ScrollWheelEvent, scroll_multiplier: f32) {
2679        let mouse_mode = self.mouse_mode(e.shift);
2680        let scroll_multiplier = if mouse_mode { 1. } else { scroll_multiplier };
2681
2682        if let Some(scroll_lines) = self.determine_scroll_lines(e, scroll_multiplier)
2683            && scroll_lines != 0
2684        {
2685            if mouse_mode {
2686                let point = grid_point(
2687                    e.position - self.last_content.terminal_bounds.bounds.origin,
2688                    self.last_content.terminal_bounds,
2689                    self.last_content.display_offset,
2690                );
2691
2692                if let Some(scrolls) = scroll_report(point, scroll_lines, e, self.last_content.mode)
2693                {
2694                    for scroll in scrolls {
2695                        self.write_to_pty(scroll);
2696                    }
2697                };
2698            } else if self
2699                .last_content
2700                .mode
2701                .contains(Modes::ALT_SCREEN | Modes::ALTERNATE_SCROLL)
2702                && !e.shift
2703            {
2704                self.write_to_pty(alt_scroll(scroll_lines));
2705            } else {
2706                self.events
2707                    .push_back(InternalEvent::Scroll(Scroll::Delta(scroll_lines)));
2708            }
2709        }
2710    }
2711
2712    fn refresh_hovered_word(&mut self, window: &Window) {
2713        self.schedule_find_hyperlink(window.modifiers(), window.mouse_position());
2714    }
2715
2716    fn determine_scroll_lines(
2717        &mut self,
2718        e: &ScrollWheelEvent,
2719        scroll_multiplier: f32,
2720    ) -> Option<i32> {
2721        let line_height = self.last_content.terminal_bounds.line_height;
2722        match e.touch_phase {
2723            /* Reset scroll state on started */
2724            TouchPhase::Started => {
2725                self.scroll_px = px(0.);
2726                None
2727            }
2728            /* Calculate the appropriate scroll lines */
2729            TouchPhase::Moved => {
2730                let old_offset = (self.scroll_px / line_height) as i32;
2731
2732                self.scroll_px += e.delta.pixel_delta(line_height).y * scroll_multiplier;
2733
2734                let new_offset = (self.scroll_px / line_height) as i32;
2735
2736                // Whenever we hit the edges, reset our stored scroll to 0
2737                // so we can respond to changes in direction quickly
2738                self.scroll_px %= self.last_content.terminal_bounds.height();
2739
2740                Some(new_offset - old_offset)
2741            }
2742            // Cancellation does not commit a scroll, same as a plain end.
2743            TouchPhase::Ended | TouchPhase::Cancelled => None,
2744        }
2745    }
2746
2747    pub fn find_matches(&self, searcher: Search, cx: &Context<Self>) -> Task<Vec<Range>> {
2748        let term = self.term.clone();
2749        cx.background_spawn(async move {
2750            let term = term.lock();
2751            search_matches(&term, searcher)
2752        })
2753    }
2754
2755    pub fn working_directory(&self) -> Option<PathBuf> {
2756        if self.is_remote_terminal {
2757            // We can't yet reliably detect the working directory of a shell on the
2758            // SSH host. Until we can do that, it doesn't make sense to display
2759            // the working directory on the client and persist that.
2760            None
2761        } else {
2762            self.client_side_working_directory()
2763        }
2764    }
2765
2766    /// Normalizes the command name of the foreground process, if one is known.
2767    pub fn foreground_process_command_name(&self) -> Option<String> {
2768        match &self.terminal_type {
2769            TerminalType::Pty { info, .. } => info
2770                .current
2771                .read()
2772                .as_ref()
2773                .and_then(|process| foreground_process_command_from_argv(&process.argv)),
2774            TerminalType::DisplayOnly => None,
2775        }
2776    }
2777
2778    /// Returns the working directory of the process that's connected to the PTY.
2779    /// That means it returns the working directory of the local shell or program
2780    /// that's running inside the terminal.
2781    ///
2782    /// This does *not* return the working directory of the shell that runs on the
2783    /// remote host, in case Zed is connected to a remote host.
2784    fn client_side_working_directory(&self) -> Option<PathBuf> {
2785        match &self.terminal_type {
2786            TerminalType::Pty { info, .. } => info
2787                .current
2788                .read()
2789                .as_ref()
2790                .map(|process| process.cwd.clone()),
2791            TerminalType::DisplayOnly => None,
2792        }
2793    }
2794
2795    pub fn title(&self, truncate: bool) -> String {
2796        const MAX_CHARS: usize = 25;
2797        match &self.task {
2798            Some(task_state) => {
2799                if truncate {
2800                    truncate_and_trailoff(&task_state.spawned_task.label, MAX_CHARS)
2801                } else {
2802                    task_state.spawned_task.full_label.clone()
2803                }
2804            }
2805            None => self
2806                .title_override
2807                .as_ref()
2808                .map(|title_override| title_override.to_string())
2809                .unwrap_or_else(|| match &self.terminal_type {
2810                    TerminalType::Pty { info, .. } => info
2811                        .current
2812                        .read()
2813                        .as_ref()
2814                        .map(|fpi| {
2815                            let process_file = fpi
2816                                .cwd
2817                                .file_name()
2818                                .map(|name| name.to_string_lossy().into_owned())
2819                                .unwrap_or_default();
2820
2821                            let argv = fpi.argv.as_slice();
2822                            let process_name = format!(
2823                                "{}{}",
2824                                fpi.name,
2825                                if !argv.is_empty() {
2826                                    format!(" {}", (argv[1..]).join(" "))
2827                                } else {
2828                                    "".to_string()
2829                                }
2830                            );
2831                            let (process_file, process_name) = if truncate {
2832                                (
2833                                    truncate_and_trailoff(&process_file, MAX_CHARS),
2834                                    truncate_and_trailoff(&process_name, MAX_CHARS),
2835                                )
2836                            } else {
2837                                (process_file, process_name)
2838                            };
2839                            format!("{process_file} — {process_name}")
2840                        })
2841                        .unwrap_or_else(|| "Terminal".to_string()),
2842                    TerminalType::DisplayOnly => "Terminal".to_string(),
2843                }),
2844        }
2845    }
2846
2847    pub fn kill_active_task(&mut self) {
2848        if let Some(task) = self.task()
2849            && task.status == TaskStatus::Running
2850        {
2851            match &self.terminal_type {
2852                TerminalType::Pty { info, .. } => {
2853                    // First kill the foreground process group (the command running in the shell)
2854                    info.kill_current_process();
2855                    // Then kill the shell itself so that the terminal exits properly
2856                    // and wait_for_completed_task can complete
2857                    info.kill_child_process();
2858                }
2859                TerminalType::DisplayOnly => {
2860                    // Non-PTY task terminals own their subprocess directly.
2861                    if let Some(subprocess) = &self.subprocess {
2862                        subprocess.kill();
2863                    }
2864                }
2865            }
2866        }
2867    }
2868
2869    pub fn pid(&self) -> Option<sysinfo::Pid> {
2870        match &self.terminal_type {
2871            TerminalType::Pty { info, .. } => info.pid(),
2872            TerminalType::DisplayOnly => None,
2873        }
2874    }
2875
2876    pub fn pid_getter(&self) -> Option<&ProcessIdGetter> {
2877        match &self.terminal_type {
2878            TerminalType::Pty { info, .. } => Some(info.pid_getter()),
2879            TerminalType::DisplayOnly => None,
2880        }
2881    }
2882
2883    pub fn task(&self) -> Option<&TaskState> {
2884        self.task.as_ref()
2885    }
2886
2887    pub fn wait_for_completed_task(&self, cx: &App) -> Task<Option<ExitStatus>> {
2888        if let Some(task) = self.task() {
2889            if task.status == TaskStatus::Running {
2890                let completion_receiver = task.completion_rx.clone();
2891                return cx.spawn(async move |_| completion_receiver.recv().await.ok().flatten());
2892            } else if let Ok(status) = task.completion_rx.try_recv() {
2893                return Task::ready(status);
2894            }
2895        }
2896        Task::ready(None)
2897    }
2898
2899    fn register_task_finished(
2900        &mut self,
2901        exit_status: Option<ExitStatus>,
2902        cx: &mut Context<Terminal>,
2903    ) {
2904        if let Some(tx) = &self.completion_tx {
2905            tx.try_send(exit_status).ok();
2906        }
2907        if let Some(e) = exit_status {
2908            self.child_exited = Some(e);
2909        }
2910        self.complete_init_command_startup_handshake();
2911        let task = match &mut self.task {
2912            Some(task) => task,
2913            None => {
2914                // For interactive shells (no task), we need to differentiate:
2915                // 1. User-initiated exits (typed "exit", Ctrl+D, etc.) - always close,
2916                //    even if the shell exits with a non-zero code (e.g. after `false`).
2917                // 2. Shell spawn failures (bad $SHELL) - don't close, so the user sees
2918                //    the error. Spawn failures never receive keyboard input.
2919                let should_close = if self.keyboard_input_sent {
2920                    true
2921                } else {
2922                    self.child_exited.is_none_or(|e| e.code() == Some(0))
2923                };
2924                if should_close {
2925                    cx.emit(Event::CloseTerminal);
2926                }
2927                return;
2928            }
2929        };
2930        if task.status != TaskStatus::Running {
2931            return;
2932        }
2933        match exit_status.and_then(|e| e.code()) {
2934            Some(error_code) => {
2935                task.status.register_task_exit(error_code);
2936            }
2937            None => {
2938                task.status.register_terminal_exit();
2939            }
2940        };
2941
2942        let (finished_successfully, task_line, command_line) = task_summary(task, exit_status);
2943        let mut lines_to_show = Vec::new();
2944        if task.spawned_task.show_summary {
2945            lines_to_show.push(task_line.as_str());
2946        }
2947        if task.spawned_task.show_command {
2948            lines_to_show.push(command_line.as_str());
2949        }
2950        let hide = task.spawned_task.hide;
2951
2952        if !lines_to_show.is_empty() {
2953            // SAFETY: the invocation happens on non `TaskStatus::Running` tasks, once,
2954            // after either `AlacTermEvent::Exit` or `AlacTermEvent::ChildExit` events that are spawned
2955            // when Zed task finishes and no more output is made.
2956            // After the task summary is output once, no more text is appended to the terminal.
2957            unsafe { append_text_to_term(&mut self.term.lock(), &lines_to_show) };
2958        }
2959
2960        match hide {
2961            HideStrategy::Never => {}
2962            HideStrategy::Always => {
2963                cx.emit(Event::CloseTerminal);
2964            }
2965            HideStrategy::OnSuccess => {
2966                if finished_successfully {
2967                    cx.emit(Event::CloseTerminal);
2968                }
2969            }
2970        }
2971    }
2972
2973    pub fn vi_mode_enabled(&self) -> bool {
2974        self.vi_mode_enabled
2975    }
2976
2977    pub fn clone_builder(&self, cx: &App, cwd: Option<PathBuf>) -> Task<Result<TerminalBuilder>> {
2978        let working_directory = self.working_directory().or_else(|| cwd);
2979        TerminalBuilder::new(
2980            working_directory,
2981            None,
2982            self.template.shell.clone(),
2983            self.template.env.clone(),
2984            self.template.cursor_shape,
2985            self.template.alternate_scroll,
2986            self.template.max_scroll_history_lines,
2987            self.template.path_hyperlink_regexes.clone(),
2988            self.template.path_hyperlink_timeout_ms,
2989            self.is_remote_terminal,
2990            self.template.window_id,
2991            None,
2992            cx,
2993            self.activation_script.clone(),
2994            self.path_style,
2995        )
2996    }
2997}
2998
2999const TASK_DELIMITER: &str = "⏵ ";
3000fn task_summary(task: &TaskState, exit_status: Option<ExitStatus>) -> (bool, String, String) {
3001    let escaped_full_label = task
3002        .spawned_task
3003        .full_label
3004        .replace("\r\n", "\r")
3005        .replace('\n', "\r");
3006    let task_label = |suffix: &str| format!("{TASK_DELIMITER}Task `{escaped_full_label}` {suffix}");
3007    let (success, task_line) = match exit_status {
3008        Some(status) => {
3009            let code = status.code();
3010            #[cfg(unix)]
3011            let signal = status.signal();
3012            #[cfg(not(unix))]
3013            let signal: Option<i32> = None;
3014
3015            match (code, signal) {
3016                (Some(0), _) => (true, task_label("finished successfully")),
3017                (Some(code), _) => (
3018                    false,
3019                    task_label(&format!("finished with exit code: {code}")),
3020                ),
3021                (None, Some(signal)) => (
3022                    false,
3023                    task_label(&format!("terminated by signal: {signal}")),
3024                ),
3025                (None, None) => (false, task_label("finished")),
3026            }
3027        }
3028        None => (false, task_label("finished")),
3029    };
3030    let escaped_command_label = task
3031        .spawned_task
3032        .command_label
3033        .replace("\r\n", "\r")
3034        .replace('\n', "\r");
3035    let command_line = format!("{TASK_DELIMITER}Command: {escaped_command_label}");
3036    (success, task_line, command_line)
3037}
3038
3039/// Converts bare LFs into CRLFs so output captured from a pipe (rather than a
3040/// PTY) wraps correctly in Alacritty. A PTY's line discipline performs this
3041/// `ONLCR` translation for us; piped output (e.g. `ls` run outside a PTY) only
3042/// emits `\n`, which moves Alacritty's cursor down without returning it to
3043/// column zero and makes the rendered output look misaligned. Alacritty has no
3044/// setting for this, so we insert a `\r` before each `\n` that lacks one.
3045fn convert_lf_to_crlf(bytes: &[u8], previous_byte_was_cr: &mut bool) -> Vec<u8> {
3046    let mut converted = Vec::with_capacity(bytes.len());
3047    for &byte in bytes {
3048        if byte == b'\n' && !*previous_byte_was_cr {
3049            converted.push(b'\r');
3050        }
3051        converted.push(byte);
3052        *previous_byte_was_cr = byte == b'\r';
3053    }
3054    converted
3055}
3056
3057/// Owns a non-PTY task subprocess and the background task pumping its output
3058/// into the terminal emulator. Used by headless hosts (e.g. the eval CLI) where
3059/// PTY allocation fails with `ENOTTY`. Dropping this kills the child.
3060struct SubprocessHandle {
3061    child: Arc<parking_lot::Mutex<Option<util::process::Child>>>,
3062    _reader: Task<()>,
3063}
3064
3065impl SubprocessHandle {
3066    fn kill(&self) {
3067        if let Some(child) = self.child.lock().as_mut() {
3068            child.kill().log_err();
3069        }
3070    }
3071}
3072
3073/// Spawns `program`/`args` as a plain subprocess with piped stdout/stderr and
3074/// drives its output into `term`, mirroring what the Alacritty event loop does
3075/// for a PTY but without one. Used when [`HeadlessTerminal`] is enabled.
3076fn spawn_task_subprocess(
3077    program: String,
3078    args: Vec<String>,
3079    env: HashMap<String, String>,
3080    working_directory: Option<PathBuf>,
3081    term: Arc<AlacrittyTermLock>,
3082    events_tx: futures::channel::mpsc::UnboundedSender<PtyEvent>,
3083    executor: &BackgroundExecutor,
3084) -> Result<SubprocessHandle> {
3085    use futures::io::AsyncReadExt as _;
3086    use std::process::Stdio;
3087
3088    let mut command = util::command::new_std_command(&program);
3089    command.args(&args);
3090    command.envs(&env);
3091    if let Some(directory) = &working_directory {
3092        command.current_dir(directory);
3093    }
3094
3095    let mut child =
3096        util::process::Child::spawn(command, Stdio::null(), Stdio::piped(), Stdio::piped())?;
3097    let stdout = child.stdout.take();
3098    let stderr = child.stderr.take();
3099    let child = Arc::new(parking_lot::Mutex::new(Some(child)));
3100
3101    let reader = executor.spawn({
3102        let child = child.clone();
3103        let executor = executor.clone();
3104        async move {
3105            // stdout and stderr are pumped concurrently, each through its own
3106            // parser; the shared term mutex serializes grid mutation.
3107            type BoxedReader = Box<dyn futures::io::AsyncRead + Unpin + Send>;
3108            let pump = |reader: Option<BoxedReader>| {
3109                let term = term.clone();
3110                let events_tx = events_tx.clone();
3111                async move {
3112                    let Some(mut reader) = reader else { return };
3113                    let mut processor = Processor::<StdSyncHandler>::new();
3114                    let mut buffer = [0u8; 8192];
3115                    let mut previous_byte_was_cr = false;
3116                    loop {
3117                        match reader.read(&mut buffer).await {
3118                            Ok(0) => return,
3119                            Err(error) => {
3120                                log::warn!("failed to read subprocess output: {error}");
3121                                return;
3122                            }
3123                            Ok(count) => {
3124                                let converted =
3125                                    convert_lf_to_crlf(&buffer[..count], &mut previous_byte_was_cr);
3126                                {
3127                                    let mut term = term.lock();
3128                                    processor.advance(&mut *term, &converted);
3129                                }
3130                                events_tx
3131                                    .unbounded_send(PtyEvent::Event(TerminalBackendEvent::Wakeup))
3132                                    .ok();
3133                            }
3134                        }
3135                    }
3136                }
3137            };
3138            let stdout = stdout.map(|reader| Box::new(reader) as BoxedReader);
3139            let stderr = stderr.map(|reader| Box::new(reader) as BoxedReader);
3140            futures::future::join(pump(stdout), pump(stderr)).await;
3141
3142            // Both pipes are closed, so the child has exited or is about to.
3143            // Poll for its status without holding the lock across an await.
3144            let status = loop {
3145                let status = match child.lock().as_mut() {
3146                    Some(child) => match child.try_status() {
3147                        Ok(status) => status,
3148                        Err(error) => {
3149                            log::warn!("failed to get subprocess exit status: {error}");
3150                            break None;
3151                        }
3152                    },
3153                    None => Some(ExitStatus::default()),
3154                };
3155                match status {
3156                    Some(status) => break Some(status),
3157                    None => executor.timer(Duration::from_millis(20)).await,
3158                }
3159            };
3160            child.lock().take();
3161            let event = match status {
3162                Some(status) => TerminalBackendEvent::ChildExit(status),
3163                None => TerminalBackendEvent::Exit,
3164            };
3165            events_tx.unbounded_send(PtyEvent::Event(event)).ok();
3166        }
3167    });
3168
3169    Ok(SubprocessHandle {
3170        child,
3171        _reader: reader,
3172    })
3173}
3174
3175impl Drop for Terminal {
3176    fn drop(&mut self) {
3177        if let Some(subprocess) = self.subprocess.take() {
3178            subprocess.kill();
3179        }
3180        if let TerminalType::Pty { pty_tx, info } =
3181            std::mem::replace(&mut self.terminal_type, TerminalType::DisplayOnly)
3182        {
3183            let kill_processes =
3184                terminate_processes_with_grace_period(info, self.background_executor.clone());
3185            pty_tx.shutdown();
3186            self.background_executor.spawn(kill_processes).detach();
3187        }
3188    }
3189}
3190
3191impl EventEmitter<Event> for Terminal {}
3192
3193fn normalize_path_command_name(command: &str) -> Option<String> {
3194    const MAX_COMMAND_NAME_LENGTH: usize = 64;
3195
3196    let command = command.trim();
3197    if command.is_empty()
3198        || command.len() > MAX_COMMAND_NAME_LENGTH
3199        || command.starts_with('.')
3200        || command.starts_with('-')
3201        || command.contains('/')
3202        || command.contains('\\')
3203    {
3204        return None;
3205    }
3206
3207    let mut command = command.to_ascii_lowercase();
3208    for suffix in [".exe", ".cmd", ".bat", ".ps1"] {
3209        if command.ends_with(suffix) {
3210            command.truncate(command.len() - suffix.len());
3211            break;
3212        }
3213    }
3214
3215    if command.is_empty()
3216        || !command.chars().all(|character| {
3217            character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.')
3218        })
3219    {
3220        return None;
3221    }
3222
3223    Some(command)
3224}
3225
3226fn foreground_process_command_from_argv(argv: &[String]) -> Option<String> {
3227    let command = argv
3228        .first()
3229        .and_then(|command| normalize_path_command_name(command));
3230
3231    if !matches!(
3232        command.as_deref(),
3233        Some("node" | "python" | "python3" | "bun" | "deno")
3234    ) {
3235        return command;
3236    }
3237
3238    argv.iter()
3239        .skip(1)
3240        .filter_map(|argument| normalize_script_command_name(argument))
3241        .next()
3242        .or(command)
3243}
3244
3245fn normalize_script_command_name(argument: &str) -> Option<String> {
3246    let path = Path::new(argument);
3247    let file_stem = path
3248        .file_stem()
3249        .and_then(|file_stem| file_stem.to_str())
3250        .and_then(normalize_path_command_name)?;
3251
3252    if file_stem != "index" {
3253        return Some(file_stem);
3254    }
3255
3256    path.parent()
3257        .and_then(|parent| parent.parent())
3258        .and_then(|package_path| package_path.file_name())
3259        .and_then(|package_name| package_name.to_str())
3260        .and_then(|package_name| package_name.strip_suffix("-cli").or(Some(package_name)))
3261        .and_then(normalize_path_command_name)
3262}
3263
3264fn content_index_for_mouse(pos: GpuiPoint<Pixels>, terminal_bounds: &TerminalBounds) -> usize {
3265    let col = (pos.x / terminal_bounds.cell_width()).round() as usize;
3266    let clamped_col = min(col, terminal_bounds.num_columns().saturating_sub(1));
3267    let row = (pos.y / terminal_bounds.line_height()).round() as usize;
3268    let clamped_row = min(row, terminal_bounds.num_lines().saturating_sub(1));
3269    clamped_row * terminal_bounds.num_columns() + clamped_col
3270}
3271
3272/// Converts an 8 bit ANSI color to its GPUI equivalent.
3273/// Accepts `usize` for compatibility with the `alacritty::Colors` interface,
3274/// Other than that use case, should only be called with values in the `[0,255]` range
3275pub fn get_color_at_index(index: usize, theme: &Theme) -> Hsla {
3276    let colors = theme.colors();
3277
3278    match index {
3279        // 0-15 are the same as the named colors above
3280        0 => colors.terminal_ansi_black,
3281        1 => colors.terminal_ansi_red,
3282        2 => colors.terminal_ansi_green,
3283        3 => colors.terminal_ansi_yellow,
3284        4 => colors.terminal_ansi_blue,
3285        5 => colors.terminal_ansi_magenta,
3286        6 => colors.terminal_ansi_cyan,
3287        7 => colors.terminal_ansi_white,
3288        8 => colors.terminal_ansi_bright_black,
3289        9 => colors.terminal_ansi_bright_red,
3290        10 => colors.terminal_ansi_bright_green,
3291        11 => colors.terminal_ansi_bright_yellow,
3292        12 => colors.terminal_ansi_bright_blue,
3293        13 => colors.terminal_ansi_bright_magenta,
3294        14 => colors.terminal_ansi_bright_cyan,
3295        15 => colors.terminal_ansi_bright_white,
3296        // 16-231 are a 6x6x6 RGB color cube, mapped to 0-255 using steps defined by XTerm.
3297        // See: https://github.com/xterm-x11/xterm-snapshots/blob/master/256colres.pl
3298        16..=231 => {
3299            let (r, g, b) = rgb_for_index(index as u8);
3300            rgba_color(
3301                if r == 0 { 0 } else { r * 40 + 55 },
3302                if g == 0 { 0 } else { g * 40 + 55 },
3303                if b == 0 { 0 } else { b * 40 + 55 },
3304            )
3305        }
3306        // 232-255 are a 24-step grayscale ramp from (8, 8, 8) to (238, 238, 238).
3307        232..=255 => {
3308            let i = index as u8 - 232; // Align index to 0..24
3309            let value = i * 10 + 8;
3310            rgba_color(value, value, value)
3311        }
3312        // For compatibility with the alacritty::Colors interface
3313        // See: https://github.com/alacritty/alacritty/blob/master/alacritty_terminal/src/term/color.rs
3314        256 => colors.terminal_foreground,
3315        257 => colors.terminal_background,
3316        258 => theme.players().local().cursor,
3317        259 => colors.terminal_ansi_dim_black,
3318        260 => colors.terminal_ansi_dim_red,
3319        261 => colors.terminal_ansi_dim_green,
3320        262 => colors.terminal_ansi_dim_yellow,
3321        263 => colors.terminal_ansi_dim_blue,
3322        264 => colors.terminal_ansi_dim_magenta,
3323        265 => colors.terminal_ansi_dim_cyan,
3324        266 => colors.terminal_ansi_dim_white,
3325        267 => colors.terminal_bright_foreground,
3326        268 => colors.terminal_ansi_black, // 'Dim Background', non-standard color
3327
3328        _ => black(),
3329    }
3330}
3331
3332/// Generates the RGB channels in [0, 5] for a given index into the 6x6x6 ANSI color cube.
3333///
3334/// See: [8 bit ANSI color](https://en.wikipedia.org/wiki/ANSI_escape_code#8-bit).
3335///
3336/// Wikipedia gives a formula for calculating the index for a given color:
3337///
3338/// ```text
3339/// index = 16 + 36 × r + 6 × g + b (0 ≤ r, g, b ≤ 5)
3340/// ```
3341///
3342/// This function does the reverse, calculating the `r`, `g`, and `b` components from a given index.
3343fn rgb_for_index(i: u8) -> (u8, u8, u8) {
3344    debug_assert!((16..=231).contains(&i));
3345    let i = i - 16;
3346    let r = (i - (i % 36)) / 36;
3347    let g = ((i % 36) - (i % 6)) / 6;
3348    let b = (i % 36) % 6;
3349    (r, g, b)
3350}
3351
3352pub fn rgba_color(r: u8, g: u8, b: u8) -> Hsla {
3353    Rgba {
3354        r: (r as f32 / 255.),
3355        g: (g as f32 / 255.),
3356        b: (b as f32 / 255.),
3357        a: 1.,
3358    }
3359    .into()
3360}
3361
3362#[cfg(test)]
3363mod tests {
3364    use std::time::Duration;
3365
3366    use super::*;
3367    use crate::{
3368        Cell, Content, IndexedCell, TerminalBounds, TerminalBuilder, content_index_for_mouse,
3369        rgb_for_index,
3370    };
3371    use async_channel::Receiver;
3372    use collections::HashMap;
3373    use gpui::MouseMoveEvent;
3374    use gpui::{
3375        ClipboardItem, Entity, Modifiers, MouseButton, MouseDownEvent, MouseUpEvent, Pixels,
3376        TestAppContext, bounds, point, size,
3377    };
3378    use parking_lot::Mutex;
3379    use rand::{Rng, distr, rngs::StdRng};
3380    use task::{Shell, ShellBuilder};
3381
3382    #[test]
3383    fn test_init_command_startup_marker_commands_do_not_contain_marker() {
3384        let marker_id = 42;
3385        let marker = init_command_startup_marker(marker_id);
3386
3387        for shell_kind in [
3388            ShellKind::Posix,
3389            ShellKind::Csh,
3390            ShellKind::Tcsh,
3391            ShellKind::Rc,
3392            ShellKind::Fish,
3393            ShellKind::PowerShell,
3394            ShellKind::Pwsh,
3395            ShellKind::Nushell,
3396            ShellKind::Cmd,
3397            ShellKind::Xonsh,
3398            ShellKind::Elvish,
3399        ] {
3400            let command = init_command_startup_marker_command(shell_kind, marker_id);
3401            assert!(
3402                !command.contains(&marker),
3403                "startup marker command for {shell_kind:?} should not contain the full marker, got {command:?}"
3404            );
3405        }
3406    }
3407
3408    #[gpui::test]
3409    async fn test_init_command_startup_marker_ignores_echoed_command(cx: &mut TestAppContext) {
3410        let terminal = cx.new(|cx| {
3411            TerminalBuilder::new_display_only(
3412                SettingsCursorShape::default(),
3413                AlternateScroll::On,
3414                None,
3415                0,
3416                cx.background_executor(),
3417                PathStyle::local(),
3418            )
3419            .subscribe(cx)
3420        });
3421        let marker_id = 4242;
3422        let marker = init_command_startup_marker(marker_id);
3423        let command = init_command_startup_marker_command(ShellKind::Posix, marker_id);
3424        let (startup_tx, startup_rx) = async_channel::bounded(1);
3425
3426        terminal.update(cx, |terminal, cx| {
3427            terminal.init_command_startup_marker = Some(marker.clone());
3428            terminal.init_command_startup_tx = Some(startup_tx);
3429            terminal.write_output(command.as_bytes(), cx);
3430        });
3431        assert!(matches!(
3432            startup_rx.try_recv(),
3433            Err(async_channel::TryRecvError::Empty)
3434        ));
3435
3436        terminal.update(cx, |terminal, cx| {
3437            terminal.write_output(marker.as_bytes(), cx);
3438        });
3439        assert!(startup_rx.try_recv().is_ok());
3440    }
3441
3442    #[test]
3443    fn test_normalize_path_command_name() {
3444        assert_eq!(normalize_path_command_name("claude"), Some("claude".into()));
3445        assert_eq!(normalize_path_command_name("Cargo"), Some("cargo".into()));
3446        assert_eq!(normalize_path_command_name("node.exe"), Some("node".into()));
3447        assert_eq!(
3448            normalize_path_command_name("my-agent_cli.1"),
3449            Some("my-agent_cli.1".into())
3450        );
3451        assert_eq!(normalize_path_command_name("./local-agent"), None);
3452        assert_eq!(normalize_path_command_name("../local-agent"), None);
3453        assert_eq!(normalize_path_command_name("/usr/local/bin/cargo"), None);
3454        assert_eq!(
3455            normalize_path_command_name("target\\debug\\agent.exe"),
3456            None
3457        );
3458        assert_eq!(normalize_path_command_name(".hidden-agent"), None);
3459        assert_eq!(normalize_path_command_name("agent with spaces"), None);
3460        assert_eq!(normalize_path_command_name("zsh"), Some("zsh".into()));
3461        assert_eq!(normalize_path_command_name("-zsh"), None);
3462        assert_eq!(normalize_path_command_name("pwsh.exe"), Some("pwsh".into()));
3463    }
3464
3465    #[test]
3466    fn test_foreground_process_command_from_interpreter_wrapper() {
3467        assert_eq!(
3468            foreground_process_command_from_argv(&[
3469                "node".to_string(),
3470                "/opt/homebrew/lib/node_modules/@google/gemini-cli/dist/index.js".to_string(),
3471            ]),
3472            Some("gemini".to_string())
3473        );
3474        assert_eq!(
3475            foreground_process_command_from_argv(&[
3476                "python3".to_string(),
3477                "/Users/me/.local/bin/codex.py".to_string(),
3478            ]),
3479            Some("codex".to_string())
3480        );
3481        assert_eq!(
3482            foreground_process_command_from_argv(&[
3483                "node".to_string(),
3484                "/Users/me/private-project/scripts/customer-data-export.js".to_string(),
3485            ]),
3486            Some("customer-data-export".to_string())
3487        );
3488    }
3489
3490    #[cfg(not(target_os = "windows"))]
3491    fn init_test(cx: &mut TestAppContext) {
3492        cx.update(|cx| {
3493            let settings_store = settings::SettingsStore::test(cx);
3494            cx.set_global(settings_store);
3495            theme_settings::init(theme::LoadThemes::JustBase, cx);
3496        });
3497    }
3498
3499    /// Helper to build a test terminal running a shell command.
3500    /// Returns the terminal entity and a receiver for the completion signal.
3501    async fn build_test_terminal(
3502        cx: &mut TestAppContext,
3503        command: &str,
3504        args: &[&str],
3505    ) -> (Entity<Terminal>, Receiver<Option<ExitStatus>>) {
3506        let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
3507        let (program, args) =
3508            ShellBuilder::new(&Shell::System, false).build(Some(command.to_owned()), &args);
3509        build_test_terminal_with_arguments(cx, program, args).await
3510    }
3511
3512    async fn build_test_terminal_with_arguments(
3513        cx: &mut TestAppContext,
3514        program: String,
3515        args: Vec<String>,
3516    ) -> (Entity<Terminal>, Receiver<Option<ExitStatus>>) {
3517        let (completion_tx, completion_rx) = async_channel::unbounded();
3518        let builder = cx
3519            .update(|cx| {
3520                TerminalBuilder::new(
3521                    None,
3522                    None,
3523                    task::Shell::WithArguments {
3524                        program,
3525                        args,
3526                        title_override: None,
3527                    },
3528                    HashMap::default(),
3529                    SettingsCursorShape::default(),
3530                    AlternateScroll::On,
3531                    None,
3532                    vec![],
3533                    0,
3534                    false,
3535                    0,
3536                    Some(completion_tx),
3537                    cx,
3538                    vec![],
3539                    PathStyle::local(),
3540                )
3541            })
3542            .await
3543            .unwrap();
3544        let terminal = cx.new(|cx| builder.subscribe(cx));
3545        (terminal, completion_rx)
3546    }
3547
3548    /// Builds a non-PTY (`no_pty`) task terminal, exercising the path used by
3549    /// headless hosts (e.g. the eval CLI) where PTY allocation fails with
3550    /// `ENOTTY`. The command runs as a plain subprocess whose piped output is
3551    /// pumped into the emulator.
3552    #[cfg(not(target_os = "windows"))]
3553    async fn build_test_subprocess_terminal(
3554        cx: &mut TestAppContext,
3555        program: String,
3556        args: Vec<String>,
3557    ) -> (Entity<Terminal>, Receiver<Option<ExitStatus>>) {
3558        let (completion_tx, completion_rx) = async_channel::unbounded();
3559        let task_state = TaskState {
3560            status: TaskStatus::Running,
3561            completion_rx: completion_rx.clone(),
3562            spawned_task: SpawnInTerminal {
3563                command: Some(program.clone()),
3564                args: args.clone(),
3565                ..Default::default()
3566            },
3567        };
3568        let builder = cx
3569            .update(|cx| {
3570                cx.set_global(HeadlessTerminal(true));
3571                TerminalBuilder::new(
3572                    None,
3573                    Some(task_state),
3574                    task::Shell::WithArguments {
3575                        program,
3576                        args,
3577                        title_override: None,
3578                    },
3579                    HashMap::default(),
3580                    SettingsCursorShape::default(),
3581                    AlternateScroll::On,
3582                    None,
3583                    vec![],
3584                    0,
3585                    false,
3586                    0,
3587                    Some(completion_tx),
3588                    cx,
3589                    vec![],
3590                    PathStyle::local(),
3591                )
3592            })
3593            .await
3594            .unwrap();
3595        let terminal = cx.new(|cx| builder.subscribe(cx));
3596        (terminal, completion_rx)
3597    }
3598
3599    #[test]
3600    fn test_convert_lf_to_crlf_preserves_split_crlf() {
3601        let mut previous_byte_was_cr = false;
3602        assert_eq!(
3603            convert_lf_to_crlf(b"one\n", &mut previous_byte_was_cr),
3604            b"one\r\n"
3605        );
3606        assert!(!previous_byte_was_cr);
3607
3608        let mut previous_byte_was_cr = false;
3609        assert_eq!(
3610            convert_lf_to_crlf(b"two\r", &mut previous_byte_was_cr),
3611            b"two\r"
3612        );
3613        assert!(previous_byte_was_cr);
3614        assert_eq!(
3615            convert_lf_to_crlf(b"\nthree", &mut previous_byte_was_cr),
3616            b"\nthree"
3617        );
3618        assert!(!previous_byte_was_cr);
3619    }
3620
3621    /// Regression test for the agent terminal failing with `Not a tty (os error
3622    /// 25)` in headless/eval sandboxes: a `no_pty` task terminal must run
3623    /// without a PTY, capture stdout, and report its exit status.
3624    #[cfg(not(target_os = "windows"))]
3625    #[gpui::test]
3626    async fn test_no_pty_task_terminal_captures_output(cx: &mut TestAppContext) {
3627        cx.executor().allow_parking();
3628
3629        let (program, args) = ShellBuilder::new(&Shell::System, false)
3630            .non_interactive()
3631            .build(Some("echo hello-from-subprocess".to_owned()), &[]);
3632        let (terminal, completion_rx) = build_test_subprocess_terminal(cx, program, args).await;
3633
3634        assert!(
3635            !terminal.update(cx, |term, _| term.is_pty()),
3636            "no_pty terminal should not be PTY-backed"
3637        );
3638        assert_eq!(
3639            completion_rx.recv().await.unwrap(),
3640            Some(ExitStatus::default())
3641        );
3642        assert_content_eventually(&terminal, "hello-from-subprocess", cx).await;
3643    }
3644
3645    fn init_ctrl_click_hyperlink_test(cx: &mut TestAppContext, output: &[u8]) -> Entity<Terminal> {
3646        cx.update(|cx| {
3647            let settings_store = settings::SettingsStore::test(cx);
3648            cx.set_global(settings_store);
3649        });
3650
3651        let terminal = cx.new(|cx| {
3652            TerminalBuilder::new_display_only(
3653                SettingsCursorShape::default(),
3654                AlternateScroll::On,
3655                None,
3656                0,
3657                cx.background_executor(),
3658                PathStyle::local(),
3659            )
3660            .subscribe(cx)
3661        });
3662
3663        terminal.update(cx, |terminal, cx| {
3664            terminal.write_output(output, cx);
3665        });
3666
3667        cx.run_until_parked();
3668
3669        terminal.update(cx, |terminal, _cx| {
3670            let term_lock = terminal.term.lock();
3671            terminal.last_content = make_content(&term_lock, &terminal.last_content);
3672            drop(term_lock);
3673
3674            let terminal_bounds = TerminalBounds::new(
3675                px(20.0),
3676                px(10.0),
3677                bounds(point(px(0.0), px(0.0)), size(px(400.0), px(400.0))),
3678            );
3679            terminal.last_content.terminal_bounds = terminal_bounds;
3680            terminal.events.clear();
3681            terminal.take_pty_write_log();
3682        });
3683
3684        terminal
3685    }
3686
3687    fn ctrl_mouse_down_at(
3688        terminal: &mut Terminal,
3689        position: GpuiPoint<Pixels>,
3690        cx: &mut Context<Terminal>,
3691    ) {
3692        let mouse_down = MouseDownEvent {
3693            button: MouseButton::Left,
3694            position,
3695            modifiers: Modifiers::secondary_key(),
3696            click_count: 1,
3697            first_mouse: true,
3698        };
3699        terminal.mouse_down(&mouse_down, cx);
3700    }
3701
3702    fn ctrl_mouse_move_to(
3703        terminal: &mut Terminal,
3704        position: GpuiPoint<Pixels>,
3705        cx: &mut Context<Terminal>,
3706    ) {
3707        let terminal_bounds = terminal.last_content.terminal_bounds.bounds;
3708        let drag_event = MouseMoveEvent {
3709            position,
3710            pressed_button: Some(MouseButton::Left),
3711            modifiers: Modifiers::secondary_key(),
3712        };
3713        terminal.mouse_drag(&drag_event, terminal_bounds, cx);
3714    }
3715
3716    fn ctrl_mouse_up_at(
3717        terminal: &mut Terminal,
3718        position: GpuiPoint<Pixels>,
3719        cx: &mut Context<Terminal>,
3720    ) {
3721        let mouse_up = MouseUpEvent {
3722            button: MouseButton::Left,
3723            position,
3724            modifiers: Modifiers::secondary_key(),
3725            click_count: 1,
3726        };
3727        terminal.mouse_up(&mouse_up, cx);
3728    }
3729
3730    fn left_mouse_down_at(
3731        terminal: &mut Terminal,
3732        position: GpuiPoint<Pixels>,
3733        cx: &mut Context<Terminal>,
3734    ) {
3735        let mouse_down = MouseDownEvent {
3736            button: MouseButton::Left,
3737            position,
3738            modifiers: Modifiers::none(),
3739            click_count: 1,
3740            first_mouse: true,
3741        };
3742        terminal.mouse_down(&mouse_down, cx);
3743    }
3744
3745    fn left_mouse_up_at(
3746        terminal: &mut Terminal,
3747        position: GpuiPoint<Pixels>,
3748        cx: &mut Context<Terminal>,
3749    ) {
3750        let mouse_up = MouseUpEvent {
3751            button: MouseButton::Left,
3752            position,
3753            modifiers: Modifiers::none(),
3754            click_count: 1,
3755        };
3756        terminal.mouse_up(&mouse_up, cx);
3757    }
3758
3759    fn left_mouse_drag_to(
3760        terminal: &mut Terminal,
3761        position: GpuiPoint<Pixels>,
3762        cx: &mut Context<Terminal>,
3763    ) {
3764        let region = terminal.last_content.terminal_bounds.bounds;
3765        let drag_event = MouseMoveEvent {
3766            position,
3767            pressed_button: Some(MouseButton::Left),
3768            modifiers: Modifiers::none(),
3769        };
3770        terminal.mouse_drag(&drag_event, region, cx);
3771    }
3772
3773    /// A left click that jitters by a pixel or two (e.g. the window-focusing
3774    /// click) must not begin a selection, otherwise `copy_on_select` would
3775    /// overwrite the clipboard. Regression test for #58970.
3776    #[gpui::test]
3777    async fn test_terminal_click_jitter_does_not_start_selection(cx: &mut TestAppContext) {
3778        let terminal = init_ctrl_click_hyperlink_test(cx, b"hello world\r\n");
3779
3780        terminal.update(cx, |terminal, cx| {
3781            left_mouse_down_at(terminal, point(px(50.0), px(10.0)), cx);
3782            terminal.events.clear();
3783
3784            // One pixel of movement is below the drag threshold.
3785            left_mouse_drag_to(terminal, point(px(51.0), px(10.0)), cx);
3786
3787            assert!(
3788                !terminal
3789                    .events
3790                    .iter()
3791                    .any(|event| matches!(event, InternalEvent::UpdateSelection(_))),
3792                "a sub-threshold click jitter should not start a selection"
3793            );
3794            assert!(terminal.selection_phase == SelectionPhase::Ended);
3795        });
3796    }
3797
3798    /// A deliberate drag past the threshold must still start a selection.
3799    #[gpui::test]
3800    async fn test_terminal_deliberate_drag_starts_selection(cx: &mut TestAppContext) {
3801        let terminal = init_ctrl_click_hyperlink_test(cx, b"hello world\r\n");
3802
3803        terminal.update(cx, |terminal, cx| {
3804            left_mouse_down_at(terminal, point(px(50.0), px(10.0)), cx);
3805            terminal.events.clear();
3806
3807            // Well beyond the drag threshold.
3808            left_mouse_drag_to(terminal, point(px(90.0), px(10.0)), cx);
3809
3810            assert!(
3811                terminal
3812                    .events
3813                    .iter()
3814                    .any(|event| matches!(event, InternalEvent::UpdateSelection(_))),
3815                "a deliberate drag should start a selection"
3816            );
3817            assert!(terminal.selection_phase == SelectionPhase::Selecting);
3818        });
3819    }
3820
3821    /// With mouse tracking active (e.g. htop), Shift is the escape hatch to
3822    /// select terminal text. Shift+drag must start a selection rather than being
3823    /// swallowed as a "extend existing selection" no-op. Regression test for #60254.
3824    #[gpui::test]
3825    async fn test_terminal_shift_drag_selects_while_mouse_tracking(cx: &mut TestAppContext) {
3826        // `?1002h` enables button-event mouse tracking, `?1006h` selects SGR encoding.
3827        let terminal = init_ctrl_click_hyperlink_test(cx, b"\x1b[?1002h\x1b[?1006hhello world\r\n");
3828
3829        terminal.update(cx, |terminal, cx| {
3830            assert!(
3831                terminal.last_content.mode.intersects(Modes::MOUSE_MODE),
3832                "mouse tracking should be active"
3833            );
3834
3835            let shift = Modifiers {
3836                shift: true,
3837                ..Modifiers::none()
3838            };
3839            terminal.mouse_down(
3840                &MouseDownEvent {
3841                    button: MouseButton::Left,
3842                    position: point(px(50.0), px(10.0)),
3843                    modifiers: shift,
3844                    click_count: 1,
3845                    first_mouse: true,
3846                },
3847                cx,
3848            );
3849
3850            // With no selection yet, the shift press must anchor a new selection
3851            // so the following drag has something to extend.
3852            assert!(
3853                terminal
3854                    .events
3855                    .iter()
3856                    .any(|event| matches!(event, InternalEvent::SetSelection(Some(_)))),
3857                "shift+click with no existing selection should anchor a selection"
3858            );
3859            terminal.events.clear();
3860
3861            let region = terminal.last_content.terminal_bounds.bounds;
3862            terminal.mouse_drag(
3863                &MouseMoveEvent {
3864                    position: point(px(90.0), px(10.0)),
3865                    pressed_button: Some(MouseButton::Left),
3866                    modifiers: shift,
3867                },
3868                region,
3869                cx,
3870            );
3871
3872            assert!(
3873                terminal
3874                    .events
3875                    .iter()
3876                    .any(|event| matches!(event, InternalEvent::UpdateSelection(_))),
3877                "shift+drag should extend the selection while mouse tracking is active"
3878            );
3879            assert!(terminal.selection_phase == SelectionPhase::Selecting);
3880        });
3881    }
3882
3883    /// Shift+click with a selection already on screen must keep extending it
3884    /// (the behavior added in #25143), not re-anchor a fresh one.
3885    #[gpui::test]
3886    async fn test_terminal_shift_click_extends_existing_selection(cx: &mut TestAppContext) {
3887        let terminal = init_ctrl_click_hyperlink_test(cx, b"hello world\r\n");
3888
3889        terminal.update(cx, |terminal, cx| {
3890            // A visible selection, as a sync would have populated in production.
3891            terminal.last_content.selection = Some(SelectionRange {
3892                start: Point::new(0, 0),
3893                end: Point::new(0, 5),
3894                is_block: false,
3895            });
3896            terminal.events.clear();
3897
3898            terminal.mouse_down(
3899                &MouseDownEvent {
3900                    button: MouseButton::Left,
3901                    position: point(px(90.0), px(10.0)),
3902                    modifiers: Modifiers {
3903                        shift: true,
3904                        ..Modifiers::none()
3905                    },
3906                    click_count: 1,
3907                    first_mouse: true,
3908                },
3909                cx,
3910            );
3911
3912            assert!(
3913                terminal
3914                    .events
3915                    .iter()
3916                    .any(|event| matches!(event, InternalEvent::UpdateSelection(_))),
3917                "shift+click with an existing selection should extend it"
3918            );
3919            assert!(
3920                !terminal
3921                    .events
3922                    .iter()
3923                    .any(|event| matches!(event, InternalEvent::SetSelection(Some(_)))),
3924                "shift+click should extend, not re-anchor, an existing selection"
3925            );
3926        });
3927    }
3928
3929    #[gpui::test]
3930    async fn test_basic_terminal(cx: &mut TestAppContext) {
3931        cx.executor().allow_parking();
3932
3933        let (terminal, completion_rx) = build_test_terminal(cx, "echo", &["hello"]).await;
3934        assert_eq!(
3935            completion_rx.recv().await.unwrap(),
3936            Some(ExitStatus::default())
3937        );
3938        assert_content_eventually(&terminal, "hello", cx).await;
3939
3940        // Inject additional output directly into the emulator (display-only path)
3941        terminal.update(cx, |term, cx| {
3942            term.write_output(b"\nfrom_injection", cx);
3943        });
3944
3945        let content_after = terminal.update(cx, |term, _| term.get_content());
3946        assert!(
3947            content_after.contains("from_injection"),
3948            "expected injected output to appear, got: {content_after}"
3949        );
3950    }
3951
3952    #[cfg(unix)]
3953    #[gpui::test]
3954    async fn test_foreground_process_command_tracks_path_command(cx: &mut TestAppContext) {
3955        cx.executor().allow_parking();
3956
3957        let (terminal, completion_rx) =
3958            build_test_terminal_with_arguments(cx, "sleep".to_string(), vec!["1".to_string()])
3959                .await;
3960
3961        assert_foreground_process_command_eventually(&terminal, "sleep", cx).await;
3962
3963        assert!(
3964            completion_rx.recv().await.is_ok(),
3965            "expected terminal completion after sleep exits"
3966        );
3967    }
3968
3969    // TODO should be tested on Linux too, but does not work there well
3970    #[cfg(target_os = "macos")]
3971    #[gpui::test(iterations = 10)]
3972    async fn test_terminal_eof(cx: &mut TestAppContext) {
3973        init_test(cx);
3974
3975        cx.executor().allow_parking();
3976
3977        let (completion_tx, completion_rx) = async_channel::unbounded();
3978        let builder = cx
3979            .update(|cx| {
3980                TerminalBuilder::new(
3981                    None,
3982                    None,
3983                    task::Shell::System,
3984                    HashMap::default(),
3985                    SettingsCursorShape::default(),
3986                    AlternateScroll::On,
3987                    None,
3988                    vec![],
3989                    0,
3990                    false,
3991                    0,
3992                    Some(completion_tx),
3993                    cx,
3994                    Vec::new(),
3995                    PathStyle::local(),
3996                )
3997            })
3998            .await
3999            .unwrap();
4000        // Build an empty command, which will result in a tty shell spawned.
4001        let terminal = cx.new(|cx| builder.subscribe(cx));
4002
4003        let (event_tx, event_rx) = async_channel::unbounded::<Event>();
4004        cx.update(|cx| {
4005            cx.subscribe(&terminal, move |_, e, _| {
4006                event_tx.send_blocking(e.clone()).unwrap();
4007            })
4008        })
4009        .detach();
4010        cx.background_spawn(async move {
4011            assert_eq!(
4012                completion_rx.recv().await.unwrap(),
4013                Some(ExitStatus::default()),
4014                "EOF should result in the tty shell exiting successfully",
4015            );
4016        })
4017        .detach();
4018
4019        let first_event = event_rx.recv().await.expect("No wakeup event received");
4020
4021        terminal.update(cx, |terminal, _| {
4022            let success = terminal.try_keystroke(&Keystroke::parse("ctrl-d").unwrap(), false);
4023            assert!(success, "Should have registered ctrl-d sequence");
4024        });
4025
4026        let mut all_events = vec![first_event];
4027        while let Ok(new_event) = event_rx.recv().await {
4028            all_events.push(new_event.clone());
4029            if new_event == Event::CloseTerminal {
4030                break;
4031            }
4032        }
4033        assert!(
4034            all_events.contains(&Event::CloseTerminal),
4035            "EOF command sequence should have triggered a TTY terminal exit, but got events: {all_events:?}",
4036        );
4037    }
4038
4039    #[cfg(not(target_os = "windows"))]
4040    #[gpui::test(iterations = 10)]
4041    async fn test_terminal_closes_after_nonzero_exit(cx: &mut TestAppContext) {
4042        init_test(cx);
4043
4044        cx.executor().allow_parking();
4045
4046        let builder = cx
4047            .update(|cx| {
4048                TerminalBuilder::new(
4049                    None,
4050                    None,
4051                    task::Shell::System,
4052                    HashMap::default(),
4053                    SettingsCursorShape::default(),
4054                    AlternateScroll::On,
4055                    None,
4056                    vec![],
4057                    0,
4058                    false,
4059                    0,
4060                    None,
4061                    cx,
4062                    Vec::new(),
4063                    PathStyle::local(),
4064                )
4065            })
4066            .await
4067            .unwrap();
4068        let terminal = cx.new(|cx| builder.subscribe(cx));
4069
4070        let (event_tx, event_rx) = async_channel::unbounded::<Event>();
4071        cx.update(|cx| {
4072            cx.subscribe(&terminal, move |_, e, _| {
4073                event_tx.send_blocking(e.clone()).unwrap();
4074            })
4075        })
4076        .detach();
4077
4078        let first_event = event_rx.recv().await.expect("No wakeup event received");
4079
4080        terminal.update(cx, |terminal, _| {
4081            terminal.input(b"false\r".to_vec());
4082        });
4083        cx.executor().timer(Duration::from_millis(500)).await;
4084        terminal.update(cx, |terminal, _| {
4085            terminal.input(b"exit\r".to_vec());
4086        });
4087
4088        let mut all_events = vec![first_event];
4089        while let Ok(new_event) = event_rx.recv().await {
4090            all_events.push(new_event.clone());
4091            if new_event == Event::CloseTerminal {
4092                break;
4093            }
4094        }
4095        assert!(
4096            all_events.contains(&Event::CloseTerminal),
4097            "Shell exiting after `false && exit` should close terminal, but got events: {all_events:?}",
4098        );
4099    }
4100
4101    #[gpui::test(iterations = 10)]
4102    async fn test_terminal_no_exit_on_spawn_failure(cx: &mut TestAppContext) {
4103        cx.executor().allow_parking();
4104
4105        let (completion_tx, completion_rx) = async_channel::unbounded();
4106        let (program, args) = ShellBuilder::new(&Shell::System, false)
4107            .build(Some("asdasdasdasd".to_owned()), &["@@@@@".to_owned()]);
4108        let builder = cx
4109            .update(|cx| {
4110                TerminalBuilder::new(
4111                    None,
4112                    None,
4113                    task::Shell::WithArguments {
4114                        program,
4115                        args,
4116                        title_override: None,
4117                    },
4118                    HashMap::default(),
4119                    SettingsCursorShape::default(),
4120                    AlternateScroll::On,
4121                    None,
4122                    Vec::new(),
4123                    0,
4124                    false,
4125                    0,
4126                    Some(completion_tx),
4127                    cx,
4128                    Vec::new(),
4129                    PathStyle::local(),
4130                )
4131            })
4132            .await
4133            .unwrap();
4134        let terminal = cx.new(|cx| builder.subscribe(cx));
4135
4136        let all_events: Arc<Mutex<Vec<Event>>> = Arc::new(Mutex::new(Vec::new()));
4137        cx.update({
4138            let all_events = all_events.clone();
4139            |cx| {
4140                cx.subscribe(&terminal, move |_, e, _| {
4141                    all_events.lock().push(e.clone());
4142                })
4143            }
4144        })
4145        .detach();
4146        let completion_check_task = cx.background_spawn(async move {
4147            // The channel may be closed if the terminal is dropped before sending
4148            // the completion signal, which can happen with certain task scheduling orders.
4149            let exit_status = completion_rx.recv().await.ok().flatten();
4150            if let Some(exit_status) = exit_status {
4151                assert!(
4152                    !exit_status.success(),
4153                    "Wrong shell command should result in a failure"
4154                );
4155                #[cfg(target_os = "windows")]
4156                assert_eq!(exit_status.code(), Some(1));
4157                #[cfg(not(target_os = "windows"))]
4158                assert_eq!(exit_status.code(), Some(127)); // code 127 means "command not found" on Unix
4159            }
4160        });
4161
4162        completion_check_task.await;
4163        cx.executor().timer(Duration::from_millis(500)).await;
4164
4165        assert!(
4166            !all_events
4167                .lock()
4168                .iter()
4169                .any(|event| event == &Event::CloseTerminal),
4170            "Wrong shell command should update the title but not should not close the terminal to show the error message, but got events: {all_events:?}",
4171        );
4172    }
4173
4174    #[test]
4175    fn test_rgb_for_index() {
4176        // Test every possible value in the color cube.
4177        for i in 16..=231 {
4178            let (r, g, b) = rgb_for_index(i);
4179            assert_eq!(i, 16 + 36 * r + 6 * g + b);
4180        }
4181    }
4182
4183    #[gpui::test]
4184    fn test_mouse_to_cell_test(mut rng: StdRng) {
4185        const ITERATIONS: usize = 10;
4186        const PRECISION: usize = 1000;
4187
4188        for _ in 0..ITERATIONS {
4189            let viewport_cells = rng.random_range(15..20);
4190            let cell_size =
4191                rng.random_range(5 * PRECISION..20 * PRECISION) as f32 / PRECISION as f32;
4192
4193            let size = crate::TerminalBounds {
4194                cell_width: Pixels::from(cell_size),
4195                line_height: Pixels::from(cell_size),
4196                bounds: bounds(
4197                    GpuiPoint::default(),
4198                    size(
4199                        Pixels::from(cell_size * (viewport_cells as f32)),
4200                        Pixels::from(cell_size * (viewport_cells as f32)),
4201                    ),
4202                ),
4203            };
4204
4205            let cells = get_cells(size, &mut rng);
4206            let content = convert_cells_to_content(size, &cells);
4207
4208            for row in 0..(viewport_cells - 1) {
4209                let row = row as usize;
4210                for col in 0..(viewport_cells - 1) {
4211                    let col = col as usize;
4212
4213                    let row_offset = rng.random_range(0..PRECISION) as f32 / PRECISION as f32;
4214                    let col_offset = rng.random_range(0..PRECISION) as f32 / PRECISION as f32;
4215
4216                    let mouse_pos = point(
4217                        Pixels::from(col as f32 * cell_size + col_offset),
4218                        Pixels::from(row as f32 * cell_size + row_offset),
4219                    );
4220
4221                    let content_index =
4222                        content_index_for_mouse(mouse_pos, &content.terminal_bounds);
4223                    let mouse_cell = content.cells[content_index].character();
4224                    let real_cell = cells[row][col];
4225
4226                    assert_eq!(mouse_cell, real_cell);
4227                }
4228            }
4229        }
4230    }
4231
4232    #[gpui::test]
4233    fn test_mouse_to_cell_clamp(mut rng: StdRng) {
4234        let size = crate::TerminalBounds {
4235            cell_width: Pixels::from(10.),
4236            line_height: Pixels::from(10.),
4237            bounds: bounds(
4238                GpuiPoint::default(),
4239                size(Pixels::from(100.), Pixels::from(100.)),
4240            ),
4241        };
4242
4243        let cells = get_cells(size, &mut rng);
4244        let content = convert_cells_to_content(size, &cells);
4245
4246        assert_eq!(
4247            content.cells[content_index_for_mouse(
4248                point(Pixels::from(-10.), Pixels::from(-10.)),
4249                &content.terminal_bounds,
4250            )]
4251            .character(),
4252            cells[0][0]
4253        );
4254        assert_eq!(
4255            content.cells[content_index_for_mouse(
4256                point(Pixels::from(1000.), Pixels::from(1000.)),
4257                &content.terminal_bounds,
4258            )]
4259            .character(),
4260            cells[9][9]
4261        );
4262    }
4263
4264    #[gpui::test]
4265    async fn test_set_size_coalesces_pixel_only_changes(cx: &mut TestAppContext) {
4266        let builder = cx.update(|cx| {
4267            TerminalBuilder::new_display_only(
4268                SettingsCursorShape::Block,
4269                AlternateScroll::On,
4270                None,
4271                0,
4272                cx.background_executor(),
4273                PathStyle::local(),
4274            )
4275        });
4276        let mut terminal = builder.terminal;
4277
4278        let base_bounds = TerminalBounds {
4279            cell_width: Pixels::from(10.),
4280            line_height: Pixels::from(10.),
4281            bounds: bounds(
4282                GpuiPoint::default(),
4283                size(Pixels::from(100.), Pixels::from(100.)),
4284            ),
4285        };
4286
4287        terminal.set_size(base_bounds);
4288        terminal.events.clear();
4289        assert_eq!(terminal.last_content.terminal_bounds, base_bounds);
4290
4291        // Pixel-only change: height grows by 1px but still the same number of rows/cols.
4292        let mut pixel_changed = base_bounds;
4293        pixel_changed.bounds.size.height = Pixels::from(101.);
4294        terminal.set_size(pixel_changed);
4295        assert!(terminal.events.is_empty());
4296        assert_eq!(terminal.last_content.terminal_bounds, pixel_changed);
4297
4298        // Grid change: height increases enough to add a row.
4299        let mut grid_changed = base_bounds;
4300        grid_changed.bounds.size.height = Pixels::from(110.);
4301        terminal.set_size(grid_changed);
4302        assert!(matches!(
4303            terminal.events.back(),
4304            Some(InternalEvent::Resize(_))
4305        ));
4306    }
4307
4308    fn get_cells(size: TerminalBounds, rng: &mut StdRng) -> Vec<Vec<char>> {
4309        let mut cells = Vec::new();
4310
4311        for _ in 0..size.num_lines() {
4312            let mut row_vec = Vec::new();
4313            for _ in 0..size.num_columns() {
4314                let cell_char = rng.sample(distr::Alphanumeric) as char;
4315                row_vec.push(cell_char)
4316            }
4317            cells.push(row_vec)
4318        }
4319
4320        cells
4321    }
4322
4323    fn convert_cells_to_content(terminal_bounds: TerminalBounds, cells: &[Vec<char>]) -> Content {
4324        let mut ic = Vec::new();
4325
4326        for (index, row) in cells.iter().enumerate() {
4327            for (cell_index, cell_char) in row.iter().enumerate() {
4328                let mut cell = Cell::default();
4329                cell.set_character(*cell_char);
4330                ic.push(IndexedCell {
4331                    point: Point::new(index as i32, cell_index),
4332                    cell,
4333                });
4334            }
4335        }
4336
4337        Content {
4338            cells: ic,
4339            terminal_bounds,
4340            ..Default::default()
4341        }
4342    }
4343
4344    #[gpui::test]
4345    async fn test_write_init_command_after_startup_clears_without_shell_command(
4346        cx: &mut TestAppContext,
4347    ) {
4348        let terminal = cx.new(|cx| {
4349            TerminalBuilder::new_display_only(
4350                SettingsCursorShape::default(),
4351                AlternateScroll::On,
4352                None,
4353                0,
4354                cx.background_executor(),
4355                PathStyle::local(),
4356            )
4357            .subscribe(cx)
4358        });
4359
4360        terminal.update(cx, |terminal, cx| {
4361            terminal.write_output(b"startup output\nprompt", cx);
4362        });
4363
4364        let wrote = terminal.update(cx, |terminal, cx| {
4365            terminal.write_init_command_after_startup(b"agent\r".to_vec(), cx)
4366        });
4367        assert!(wrote);
4368        let content = terminal.update(cx, |terminal, _| terminal.get_content());
4369        assert!(
4370            !content.contains("startup output"),
4371            "startup output should be cleared internally before writing the init command"
4372        );
4373        let input_log = terminal.update(cx, |terminal, _| terminal.take_input_log());
4374        assert_eq!(input_log, vec![b"agent\r".to_vec()]);
4375    }
4376
4377    #[gpui::test]
4378    async fn test_write_init_command_after_startup_skips_after_keyboard_input(
4379        cx: &mut TestAppContext,
4380    ) {
4381        let terminal = cx.new(|cx| {
4382            TerminalBuilder::new_display_only(
4383                SettingsCursorShape::default(),
4384                AlternateScroll::On,
4385                None,
4386                0,
4387                cx.background_executor(),
4388                PathStyle::local(),
4389            )
4390            .subscribe(cx)
4391        });
4392
4393        let wrote = terminal.update(cx, |terminal, cx| {
4394            terminal.write_output(b"startup output\nprompt", cx);
4395            terminal.input(b"user input".to_vec());
4396            terminal.write_init_command_after_startup(b"agent\r".to_vec(), cx)
4397        });
4398        assert!(!wrote);
4399        let content = terminal.update(cx, |terminal, _| terminal.get_content());
4400        assert!(
4401            content.contains("startup output"),
4402            "startup output should be left alone when the init command is skipped"
4403        );
4404        let input_log = terminal.update(cx, |terminal, _| terminal.take_input_log());
4405        assert_eq!(input_log, vec![b"user input".to_vec()]);
4406    }
4407
4408    #[gpui::test]
4409    async fn test_write_init_command_after_startup_skips_after_child_exit(cx: &mut TestAppContext) {
4410        let terminal = cx.new(|cx| {
4411            TerminalBuilder::new_display_only(
4412                SettingsCursorShape::default(),
4413                AlternateScroll::On,
4414                None,
4415                0,
4416                cx.background_executor(),
4417                PathStyle::local(),
4418            )
4419            .subscribe(cx)
4420        });
4421
4422        terminal.update(cx, |terminal, cx| {
4423            terminal.write_output(b"shell failed to start\nprompt", cx);
4424            #[cfg(unix)]
4425            let exit_status =
4426                <ExitStatus as std::os::unix::process::ExitStatusExt>::from_raw(1 << 8);
4427            #[cfg(windows)]
4428            let exit_status = <ExitStatus as std::os::windows::process::ExitStatusExt>::from_raw(1);
4429            terminal.register_task_finished(Some(exit_status), cx);
4430        });
4431
4432        let wrote = terminal.update(cx, |terminal, cx| {
4433            terminal.write_init_command_after_startup(b"agent\r".to_vec(), cx)
4434        });
4435        assert!(!wrote);
4436        let content = terminal.update(cx, |terminal, _| terminal.get_content());
4437        assert!(
4438            content.contains("shell failed to start"),
4439            "startup failure output should be preserved when the init command is skipped"
4440        );
4441        let input_log = terminal.update(cx, |terminal, _| terminal.take_input_log());
4442        assert!(
4443            input_log.is_empty(),
4444            "init command should not be written after the child has exited, got {input_log:?}"
4445        );
4446    }
4447
4448    #[gpui::test]
4449    async fn test_write_output_converts_lf_to_crlf(cx: &mut TestAppContext) {
4450        let terminal = cx.new(|cx| {
4451            TerminalBuilder::new_display_only(
4452                SettingsCursorShape::default(),
4453                AlternateScroll::On,
4454                None,
4455                0,
4456                cx.background_executor(),
4457                PathStyle::local(),
4458            )
4459            .subscribe(cx)
4460        });
4461
4462        // Test simple LF conversion
4463        terminal.update(cx, |terminal, cx| {
4464            terminal.write_output(b"line1\nline2\n", cx);
4465        });
4466
4467        // Get the content by directly accessing the term
4468        let content = terminal.update(cx, |terminal, _cx| {
4469            let term = terminal.term.lock_unfair();
4470            make_content(&term, &terminal.last_content)
4471        });
4472
4473        // If LF is properly converted to CRLF, each line should start at column 0
4474        // The diagonal staircase bug would cause increasing column positions
4475
4476        // Get the cells and check that lines start at column 0
4477        let cells = &content.cells;
4478        let mut line1_col0 = false;
4479        let mut line2_col0 = false;
4480
4481        for cell in cells {
4482            if cell.character() == 'l' && cell.point.column == 0 {
4483                if cell.point.line == 0 && !line1_col0 {
4484                    line1_col0 = true;
4485                } else if cell.point.line == 1 && !line2_col0 {
4486                    line2_col0 = true;
4487                }
4488            }
4489        }
4490
4491        assert!(line1_col0, "First line should start at column 0");
4492        assert!(line2_col0, "Second line should start at column 0");
4493    }
4494
4495    #[gpui::test]
4496    async fn test_write_output_preserves_existing_crlf(cx: &mut TestAppContext) {
4497        let terminal = cx.new(|cx| {
4498            TerminalBuilder::new_display_only(
4499                SettingsCursorShape::default(),
4500                AlternateScroll::On,
4501                None,
4502                0,
4503                cx.background_executor(),
4504                PathStyle::local(),
4505            )
4506            .subscribe(cx)
4507        });
4508
4509        // Test that existing CRLF doesn't get doubled
4510        terminal.update(cx, |terminal, cx| {
4511            terminal.write_output(b"line1\r\nline2\r\n", cx);
4512        });
4513
4514        // Get the content by directly accessing the term
4515        let content = terminal.update(cx, |terminal, _cx| {
4516            let term = terminal.term.lock_unfair();
4517            make_content(&term, &terminal.last_content)
4518        });
4519
4520        let cells = &content.cells;
4521
4522        // Check that both lines start at column 0
4523        let mut found_lines_at_column_0 = 0;
4524        for cell in cells {
4525            if cell.character() == 'l' && cell.point.column == 0 {
4526                found_lines_at_column_0 += 1;
4527            }
4528        }
4529
4530        assert!(
4531            found_lines_at_column_0 >= 2,
4532            "Both lines should start at column 0"
4533        );
4534    }
4535
4536    #[gpui::test]
4537    async fn test_write_output_preserves_bare_cr(cx: &mut TestAppContext) {
4538        let terminal = cx.new(|cx| {
4539            TerminalBuilder::new_display_only(
4540                SettingsCursorShape::default(),
4541                AlternateScroll::On,
4542                None,
4543                0,
4544                cx.background_executor(),
4545                PathStyle::local(),
4546            )
4547            .subscribe(cx)
4548        });
4549
4550        // Test that bare CR (without LF) is preserved
4551        terminal.update(cx, |terminal, cx| {
4552            terminal.write_output(b"hello\rworld", cx);
4553        });
4554
4555        // Get the content by directly accessing the term
4556        let content = terminal.update(cx, |terminal, _cx| {
4557            let term = terminal.term.lock_unfair();
4558            make_content(&term, &terminal.last_content)
4559        });
4560
4561        let cells = &content.cells;
4562
4563        // Check that we have "world" at the beginning of the line
4564        let mut text = String::new();
4565        for cell in cells.iter().take(5) {
4566            if cell.point.line == 0 {
4567                text.push(cell.character());
4568            }
4569        }
4570
4571        assert!(
4572            text.starts_with("world"),
4573            "Bare CR should allow overwriting: got '{}'",
4574            text
4575        );
4576    }
4577
4578    #[gpui::test]
4579    async fn test_display_only_write_output_ignores_osc52(cx: &mut TestAppContext) {
4580        cx.update(|cx| {
4581            let settings_store = settings::SettingsStore::test(cx);
4582            cx.set_global(settings_store);
4583            cx.write_to_clipboard(ClipboardItem::new_string("original".to_string()));
4584        });
4585
4586        let terminal = cx.new(|cx| {
4587            TerminalBuilder::new_display_only(
4588                SettingsCursorShape::default(),
4589                AlternateScroll::On,
4590                None,
4591                0,
4592                cx.background_executor(),
4593                PathStyle::local(),
4594            )
4595            .subscribe(cx)
4596        });
4597
4598        terminal.update(cx, |terminal, cx| {
4599            terminal.write_output(b"\x1b]52;c;b3ZlcndyaXR0ZW4=\x07", cx);
4600        });
4601        cx.run_until_parked();
4602
4603        let clipboard_text = cx.update(|cx| cx.read_from_clipboard().and_then(|item| item.text()));
4604        assert_eq!(clipboard_text.as_deref(), Some("original"));
4605    }
4606
4607    #[gpui::test]
4608    async fn test_hyperlink_ctrl_click_same_position(cx: &mut TestAppContext) {
4609        let terminal = init_ctrl_click_hyperlink_test(cx, b"Visit https://zed.dev/ for more\r\n");
4610
4611        terminal.update(cx, |terminal, cx| {
4612            let click_position = point(px(80.0), px(10.0));
4613            ctrl_mouse_down_at(terminal, click_position, cx);
4614            ctrl_mouse_up_at(terminal, click_position, cx);
4615
4616            assert!(
4617                terminal
4618                    .events
4619                    .iter()
4620                    .any(|event| matches!(event, InternalEvent::ProcessHyperlink(_, true))),
4621                "Should have ProcessHyperlink event when ctrl+clicking on same hyperlink position"
4622            );
4623        });
4624    }
4625
4626    #[gpui::test]
4627    async fn test_hyperlink_ctrl_click_same_position_in_mouse_mode(cx: &mut TestAppContext) {
4628        let terminal = init_ctrl_click_hyperlink_test(cx, b"Visit https://zed.dev/ for more\r\n");
4629
4630        terminal.update(cx, |terminal, cx| {
4631            terminal.last_content.mode = Modes::MOUSE_MODE;
4632
4633            let click_position = point(px(80.0), px(10.0));
4634            ctrl_mouse_down_at(terminal, click_position, cx);
4635            ctrl_mouse_up_at(terminal, click_position, cx);
4636
4637            assert!(
4638                terminal
4639                    .events
4640                    .iter()
4641                    .any(|event| matches!(event, InternalEvent::ProcessHyperlink(_, true))),
4642                "Should have ProcessHyperlink event when ctrl+clicking on same hyperlink position in mouse mode"
4643            );
4644            assert!(
4645                terminal.take_pty_write_log().is_empty(),
4646                "a consumed link click must not be reported to the PTY"
4647            );
4648        });
4649    }
4650
4651    #[gpui::test]
4652    async fn test_hyperlink_ctrl_click_mismatch_in_mouse_mode_consumes_gesture(
4653        cx: &mut TestAppContext,
4654    ) {
4655        let terminal = init_ctrl_click_hyperlink_test(
4656            cx,
4657            b"Visit https://zed.dev/ for more\r\nThis is another line\r\n",
4658        );
4659
4660        terminal.update(cx, |terminal, cx| {
4661            terminal.last_content.mode = Modes::MOUSE_MODE;
4662            terminal.take_pty_write_log();
4663
4664            let down_position = point(px(80.0), px(10.0));
4665            let up_position = point(px(10.0), px(30.0));
4666
4667            ctrl_mouse_down_at(terminal, down_position, cx);
4668            terminal.mouse_move(
4669                &MouseMoveEvent {
4670                    position: up_position,
4671                    pressed_button: Some(MouseButton::Left),
4672                    modifiers: Modifiers::secondary_key(),
4673                },
4674                cx,
4675            );
4676            ctrl_mouse_up_at(terminal, up_position, cx);
4677
4678            assert!(
4679                !terminal
4680                    .events
4681                    .iter()
4682                    .any(|event| matches!(event, InternalEvent::ProcessHyperlink(_, _))),
4683                "Should NOT open a link when press and release land on different hyperlinks"
4684            );
4685            let pty_writes = terminal.take_pty_write_log();
4686            assert!(
4687                pty_writes.is_empty(),
4688                "a captured press must consume the whole gesture, but reports leaked to the PTY: {pty_writes:?}"
4689            );
4690        });
4691    }
4692
4693    #[gpui::test]
4694    async fn test_plain_click_on_hyperlink_in_mouse_mode_is_reported(cx: &mut TestAppContext) {
4695        let terminal = init_ctrl_click_hyperlink_test(cx, b"Visit https://zed.dev/ for more\r\n");
4696
4697        terminal.update(cx, |terminal, cx| {
4698            terminal.last_content.mode = Modes::MOUSE_MODE;
4699            terminal.take_pty_write_log();
4700
4701            let click_position = point(px(80.0), px(10.0));
4702            left_mouse_down_at(terminal, click_position, cx);
4703            left_mouse_up_at(terminal, click_position, cx);
4704
4705            assert!(
4706                !terminal
4707                    .events
4708                    .iter()
4709                    .any(|event| matches!(event, InternalEvent::ProcessHyperlink(_, _))),
4710                "a plain click must not open a link"
4711            );
4712            let pty_writes = terminal.take_pty_write_log();
4713            assert_eq!(
4714                pty_writes.len(),
4715                2,
4716                "expected press and release reports, got {pty_writes:?}"
4717            );
4718        });
4719    }
4720
4721    #[gpui::test]
4722    async fn test_ctrl_click_on_non_hyperlink_in_mouse_mode_is_reported(cx: &mut TestAppContext) {
4723        let terminal = init_ctrl_click_hyperlink_test(cx, b"Visit https://zed.dev/ for more\r\n");
4724
4725        terminal.update(cx, |terminal, cx| {
4726            terminal.last_content.mode = Modes::MOUSE_MODE;
4727            terminal.take_pty_write_log();
4728
4729            // Past the end of the line: nothing link-like under the cursor.
4730            let click_position = point(px(370.0), px(10.0));
4731            ctrl_mouse_down_at(terminal, click_position, cx);
4732            ctrl_mouse_up_at(terminal, click_position, cx);
4733
4734            assert!(
4735                !terminal
4736                    .events
4737                    .iter()
4738                    .any(|event| matches!(event, InternalEvent::ProcessHyperlink(_, _))),
4739                "a secondary click off a link must not open anything"
4740            );
4741            let pty_writes = terminal.take_pty_write_log();
4742            assert_eq!(
4743                pty_writes.len(),
4744                2,
4745                "expected press and release reports, got {pty_writes:?}"
4746            );
4747        });
4748    }
4749
4750    #[gpui::test]
4751    async fn test_ctrl_click_in_mouse_mode_forwards_when_setting_disabled(cx: &mut TestAppContext) {
4752        let terminal = init_ctrl_click_hyperlink_test(cx, b"Visit https://zed.dev/ for more\r\n");
4753
4754        cx.update_global(|store: &mut settings::SettingsStore, cx| {
4755            store.update_user_settings(cx, |settings| {
4756                settings
4757                    .terminal
4758                    .get_or_insert_default()
4759                    .open_links_in_mouse_mode = Some(false);
4760            });
4761        });
4762
4763        terminal.update(cx, |terminal, cx| {
4764            terminal.last_content.mode = Modes::MOUSE_MODE;
4765
4766            let click_position = point(px(80.0), px(10.0));
4767            ctrl_mouse_down_at(terminal, click_position, cx);
4768            ctrl_mouse_up_at(terminal, click_position, cx);
4769
4770            assert!(
4771                !terminal
4772                    .events
4773                    .iter()
4774                    .any(|event| matches!(event, InternalEvent::ProcessHyperlink(_, _))),
4775                "with the setting disabled, ctrl+click must not open links in mouse mode"
4776            );
4777            let pty_writes = terminal.take_pty_write_log();
4778            assert_eq!(
4779                pty_writes.len(),
4780                2,
4781                "expected press and release reports, got {pty_writes:?}"
4782            );
4783        });
4784    }
4785
4786    #[gpui::test]
4787    async fn test_hyperlink_ctrl_click_drag_outside_bounds(cx: &mut TestAppContext) {
4788        let terminal = init_ctrl_click_hyperlink_test(
4789            cx,
4790            b"Visit https://zed.dev/ for more\r\nThis is another line\r\n",
4791        );
4792
4793        terminal.update(cx, |terminal, cx| {
4794            let down_position = point(px(80.0), px(10.0));
4795            let up_position = point(px(10.0), px(50.0));
4796
4797            ctrl_mouse_down_at(terminal, down_position, cx);
4798            ctrl_mouse_move_to(terminal, up_position, cx);
4799            ctrl_mouse_up_at(terminal, up_position, cx);
4800
4801            assert!(
4802                !terminal
4803                    .events
4804                    .iter()
4805                    .any(|event| matches!(event, InternalEvent::ProcessHyperlink(_, _))),
4806                "Should NOT have ProcessHyperlink event when dragging outside the hyperlink"
4807            );
4808        });
4809    }
4810
4811    #[gpui::test]
4812    async fn test_hyperlink_ctrl_click_drag_within_bounds(cx: &mut TestAppContext) {
4813        let terminal = init_ctrl_click_hyperlink_test(cx, b"Visit https://zed.dev/ for more\r\n");
4814
4815        terminal.update(cx, |terminal, cx| {
4816            let down_position = point(px(70.0), px(10.0));
4817            let up_position = point(px(130.0), px(10.0));
4818
4819            ctrl_mouse_down_at(terminal, down_position, cx);
4820            ctrl_mouse_move_to(terminal, up_position, cx);
4821            ctrl_mouse_up_at(terminal, up_position, cx);
4822
4823            assert!(
4824                terminal
4825                    .events
4826                    .iter()
4827                    .any(|event| matches!(event, InternalEvent::ProcessHyperlink(_, true))),
4828                "Should have ProcessHyperlink event when dragging within hyperlink bounds"
4829            );
4830        });
4831    }
4832
4833    /// Polls the terminal content until `expected` appears, or panics after ~1s.
4834    /// The PTY IO thread writes into the terminal grid independently of the
4835    /// GPUI executor, so we need a real-time polling loop to synchronize.
4836    async fn assert_content_eventually(
4837        terminal: &Entity<Terminal>,
4838        expected: &str,
4839        cx: &mut TestAppContext,
4840    ) {
4841        let mut content = String::new();
4842        for _ in 0..100 {
4843            content = terminal.update(cx, |term, _| term.get_content());
4844            if content.contains(expected) {
4845                return;
4846            }
4847            cx.background_executor
4848                .timer(Duration::from_millis(10))
4849                .await;
4850        }
4851        panic!("Expected terminal content to contain {expected:?}, got: {content}");
4852    }
4853
4854    #[cfg(unix)]
4855    async fn assert_foreground_process_command_eventually(
4856        terminal: &Entity<Terminal>,
4857        expected: &str,
4858        cx: &mut TestAppContext,
4859    ) {
4860        let mut command_name = None;
4861        for _ in 0..100 {
4862            terminal.update(cx, |terminal, _| {
4863                if let TerminalType::Pty { info, .. } = &terminal.terminal_type {
4864                    info.load_for_test();
4865                }
4866            });
4867            command_name =
4868                terminal.update(cx, |terminal, _| terminal.foreground_process_command_name());
4869            if command_name.as_deref() == Some(expected) {
4870                return;
4871            }
4872            cx.background_executor
4873                .timer(Duration::from_millis(10))
4874                .await;
4875        }
4876        let process_info = terminal.update(cx, |terminal, _| match &terminal.terminal_type {
4877            TerminalType::Pty { info, .. } => format!(
4878                "pid={:?}, fallback_pid={:?}, has_current_info={}",
4879                info.pid(),
4880                info.pid_getter().fallback_pid(),
4881                info.current.read().is_some()
4882            ),
4883            TerminalType::DisplayOnly => "display-only".to_string(),
4884        });
4885        panic!(
4886            "Expected foreground process command name to be {expected:?}, got {command_name:?}; process info: {process_info:?}"
4887        );
4888    }
4889
4890    /// Test that kill_active_task properly terminates both the foreground process
4891    /// and the shell, allowing wait_for_completed_task to complete and output to be captured.
4892    #[cfg(unix)]
4893    #[gpui::test]
4894    async fn test_kill_active_task_completes_and_captures_output(cx: &mut TestAppContext) {
4895        cx.executor().allow_parking();
4896
4897        // Run a command that prints output then sleeps for a long time
4898        // The echo ensures we have output to capture before killing
4899        let (terminal, completion_rx) =
4900            build_test_terminal(cx, "echo", &["test_output_before_kill; sleep 60"]).await;
4901
4902        assert_content_eventually(&terminal, "test_output_before_kill", cx).await;
4903
4904        // Kill the active task
4905        terminal.update(cx, |term, _cx| {
4906            term.kill_active_task();
4907        });
4908
4909        // wait_for_completed_task should complete within a reasonable time (not hang)
4910        let completion_result = completion_rx.recv().await;
4911        assert!(
4912            completion_result.is_ok(),
4913            "wait_for_completed_task should complete after kill_active_task, but it timed out"
4914        );
4915
4916        // The exit status should indicate the process was killed (not a clean exit)
4917        let exit_status = completion_result.unwrap();
4918        assert!(
4919            exit_status.is_some(),
4920            "Should have received an exit status after killing"
4921        );
4922
4923        // Verify that output captured before killing is still available
4924        let content = terminal.update(cx, |term, _| term.get_content());
4925        assert!(
4926            content.contains("test_output_before_kill"),
4927            "Output from before kill should be captured, got: {content}"
4928        );
4929    }
4930
4931    #[cfg(unix)]
4932    fn parse_pid_marker(content: &str, prefix: &str, suffix: &str) -> i32 {
4933        content
4934            .split(prefix)
4935            .nth(1)
4936            .and_then(|rest| rest.split(suffix).next())
4937            .and_then(|pid| pid.trim().parse().ok())
4938            .unwrap_or_else(|| {
4939                panic!("failed to parse pid between {prefix:?} and {suffix:?} from: {content}")
4940            })
4941    }
4942
4943    /// Regression test for <https://github.com/zed-industries/zed/issues/47412>:
4944    /// closing a terminal must not orphan processes that ignore SIGHUP and
4945    /// SIGTERM. The shell ignores both signals and the `sleep`s inherit the
4946    /// ignored dispositions, so only the SIGKILL escalation can terminate them.
4947    ///
4948    /// Two process groups are covered: the background `sleep` is spawned before
4949    /// `set -m` and stays in the shell's own group, while job control places
4950    /// the foreground job (an inner shell that `exec`s `sleep`) in a separate
4951    /// group that killing the shell's group never reaches — it is only found
4952    /// via the foreground-group capture (`tcgetpgrp`).
4953    #[cfg(unix)]
4954    #[gpui::test]
4955    async fn test_dropping_terminal_kills_processes_ignoring_sighup_and_sigterm(
4956        cx: &mut TestAppContext,
4957    ) {
4958        cx.executor().allow_parking();
4959
4960        let (terminal, _completion_rx) = build_test_terminal_with_arguments(
4961            cx,
4962            "/bin/sh".to_string(),
4963            vec![
4964                "-c".to_string(),
4965                "trap '' HUP TERM; sleep 300 & echo bg_marker_${!}_bgend; set -m; \
4966                 /bin/sh -c 'echo fg_marker_$$_fgend; exec sleep 300'"
4967                    .to_string(),
4968            ],
4969        )
4970        .await;
4971
4972        assert_content_eventually(&terminal, "_fgend", cx).await;
4973        let content = terminal.update(cx, |term, _| term.get_content());
4974        let background_sleep_pid = parse_pid_marker(&content, "bg_marker_", "_bgend");
4975        let foreground_sleep_pid = parse_pid_marker(&content, "fg_marker_", "_fgend");
4976
4977        let shell_pid = terminal.update(cx, |terminal, _| match &terminal.terminal_type {
4978            TerminalType::Pty { info, .. } => info.pid_getter().fallback_pid().as_u32() as i32,
4979            TerminalType::DisplayOnly => panic!("expected a PTY-backed terminal"),
4980        });
4981
4982        for pid in [background_sleep_pid, foreground_sleep_pid] {
4983            assert_eq!(
4984                unsafe { libc::kill(pid, 0) },
4985                0,
4986                "process {pid} should be running before the terminal is dropped"
4987            );
4988        }
4989
4990        // The foreground-group escalation is only exercised if `set -m`
4991        // actually placed the foreground job in its own process group; assert
4992        // the arrangement so this test fails loudly instead of silently
4993        // degrading into a shell-group-only test.
4994        let shell_pgid = unsafe { libc::getpgid(shell_pid) };
4995        let foreground_pgid = unsafe { libc::getpgid(foreground_sleep_pid) };
4996        assert!(shell_pgid > 0 && foreground_pgid > 0);
4997        assert_ne!(
4998            foreground_pgid, shell_pgid,
4999            "job control should place the foreground sleep in its own process group"
5000        );
5001        assert_eq!(
5002            unsafe { libc::getpgid(background_sleep_pid) },
5003            shell_pgid,
5004            "the background sleep should stay in the shell's process group"
5005        );
5006
5007        drop(terminal);
5008        // Flush effects so the released terminal entity is actually dropped.
5009        cx.update(|_| {});
5010
5011        for _ in 0..300 {
5012            let background_dead = unsafe { libc::kill(background_sleep_pid, 0) } != 0;
5013            let foreground_dead = unsafe { libc::kill(foreground_sleep_pid, 0) } != 0;
5014            if background_dead && foreground_dead {
5015                return;
5016            }
5017            cx.background_executor
5018                .timer(Duration::from_millis(10))
5019                .await;
5020        }
5021        panic!(
5022            "processes survived dropping the terminal: background sleep {background_sleep_pid} \
5023             alive: {}, foreground sleep {foreground_sleep_pid} alive: {}",
5024            unsafe { libc::kill(background_sleep_pid, 0) } == 0,
5025            unsafe { libc::kill(foreground_sleep_pid, 0) } == 0,
5026        );
5027    }
5028
5029    /// Test that kill_active_task on a task that's not running is a no-op
5030    #[gpui::test]
5031    async fn test_kill_active_task_on_completed_task_is_noop(cx: &mut TestAppContext) {
5032        cx.executor().allow_parking();
5033
5034        // Run a command that exits immediately
5035        let (terminal, completion_rx) = build_test_terminal(cx, "echo", &["done"]).await;
5036
5037        // Wait for the command to complete naturally
5038        let exit_status = completion_rx
5039            .recv()
5040            .await
5041            .expect("Should receive exit status");
5042        assert_eq!(exit_status, Some(ExitStatus::default()));
5043
5044        assert_content_eventually(&terminal, "done", cx).await;
5045
5046        // Now try to kill - should be a no-op since task already completed
5047        terminal.update(cx, |term, _cx| {
5048            term.kill_active_task();
5049        });
5050
5051        // Content should still be there
5052        let content = terminal.update(cx, |term, _| term.get_content());
5053        assert!(
5054            content.contains("done"),
5055            "Output should still be present after no-op kill, got: {content}"
5056        );
5057    }
5058
5059    mod perf {
5060        use super::super::*;
5061        use gpui::{
5062            Entity, ScrollDelta, ScrollWheelEvent, TestAppContext, VisualContext,
5063            VisualTestContext, point,
5064        };
5065        use util::default;
5066        use util_macros::perf;
5067
5068        async fn init_scroll_perf_test(
5069            cx: &mut TestAppContext,
5070        ) -> (Entity<Terminal>, &mut VisualTestContext) {
5071            cx.update(|cx| {
5072                let settings_store = settings::SettingsStore::test(cx);
5073                cx.set_global(settings_store);
5074            });
5075
5076            cx.executor().allow_parking();
5077
5078            let window = cx.add_empty_window();
5079            let builder = window
5080                .update(|window, cx| {
5081                    let settings = TerminalSettings::get_global(cx);
5082                    let test_path_hyperlink_timeout_ms = 100;
5083                    TerminalBuilder::new(
5084                        None,
5085                        None,
5086                        task::Shell::System,
5087                        HashMap::default(),
5088                        SettingsCursorShape::default(),
5089                        AlternateScroll::On,
5090                        None,
5091                        settings.path_hyperlink_regexes.clone(),
5092                        test_path_hyperlink_timeout_ms,
5093                        false,
5094                        window.window_handle().window_id().as_u64(),
5095                        None,
5096                        cx,
5097                        vec![],
5098                        PathStyle::local(),
5099                    )
5100                })
5101                .await
5102                .unwrap();
5103            let terminal = window.new(|cx| builder.subscribe(cx));
5104
5105            terminal.update(window, |term, cx| {
5106                term.write_output("long line ".repeat(1000).as_bytes(), cx);
5107            });
5108
5109            (terminal, window)
5110        }
5111
5112        #[perf]
5113        #[gpui::test]
5114        async fn scroll_long_line_benchmark(cx: &mut TestAppContext) {
5115            let (terminal, window) = init_scroll_perf_test(cx).await;
5116            let wobble = point(FIND_HYPERLINK_THROTTLE_PX, px(0.0));
5117            let mut scroll_by = |lines: i32| {
5118                window.update_window_entity(&terminal, |terminal, window, cx| {
5119                    let bounds = terminal.last_content.terminal_bounds.bounds;
5120                    let center = bounds.origin + bounds.center();
5121                    let position = center + wobble * lines as f32;
5122
5123                    terminal.mouse_move(
5124                        &MouseMoveEvent {
5125                            position,
5126                            ..default()
5127                        },
5128                        cx,
5129                    );
5130
5131                    terminal.scroll_wheel(
5132                        &ScrollWheelEvent {
5133                            position,
5134                            delta: ScrollDelta::Lines(GpuiPoint::new(0.0, lines as f32)),
5135                            ..default()
5136                        },
5137                        1.0,
5138                    );
5139
5140                    assert!(
5141                        terminal
5142                            .events
5143                            .iter()
5144                            .any(|event| matches!(event, InternalEvent::Scroll(_))),
5145                        "Should have Scroll event when scrolling within terminal bounds"
5146                    );
5147                    terminal.sync(window, cx);
5148                });
5149            };
5150
5151            for _ in 0..20000 {
5152                scroll_by(1);
5153                scroll_by(-1);
5154            }
5155        }
5156
5157        #[test]
5158        fn test_num_lines_float_precision() {
5159            let line_heights = [
5160                20.1f32, 16.7, 18.3, 22.9, 14.1, 15.6, 17.8, 19.4, 21.3, 23.7,
5161            ];
5162            for &line_height in &line_heights {
5163                for n in 1..=100 {
5164                    let height = n as f32 * line_height;
5165                    let bounds = TerminalBounds::new(
5166                        px(line_height),
5167                        px(8.0),
5168                        Bounds {
5169                            origin: GpuiPoint::default(),
5170                            size: Size {
5171                                width: px(800.0),
5172                                height: px(height),
5173                            },
5174                        },
5175                    );
5176                    assert_eq!(
5177                        bounds.num_lines(),
5178                        n,
5179                        "num_lines() should be {n} for height={height}, line_height={line_height}"
5180                    );
5181                }
5182            }
5183        }
5184
5185        #[test]
5186        fn test_num_columns_float_precision() {
5187            let cell_widths = [8.1f32, 7.3, 9.7, 6.9, 10.1];
5188            for &cell_width in &cell_widths {
5189                for n in 1..=200 {
5190                    let width = n as f32 * cell_width;
5191                    let bounds = TerminalBounds::new(
5192                        px(20.0),
5193                        px(cell_width),
5194                        Bounds {
5195                            origin: GpuiPoint::default(),
5196                            size: Size {
5197                                width: px(width),
5198                                height: px(400.0),
5199                            },
5200                        },
5201                    );
5202                    assert_eq!(
5203                        bounds.num_columns(),
5204                        n,
5205                        "num_columns() should be {n} for width={width}, cell_width={cell_width}"
5206                    );
5207                }
5208            }
5209        }
5210    }
5211}
5212
Served at tenant.openagents/omega Member data and write actions are omitted.