Skip to repository content5212 lines · 187.5 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T04:13:16.955Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
terminal.rs
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(¶ms.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