1pub mod mappings;
2
3pub use alacritty_terminal;
4
5mod pty_info;
6mod terminal_hyperlinks;
7pub mod terminal_settings;
8
9use alacritty_terminal::{
10 Term,
11 event::{Event as AlacTermEvent, EventListener, Notify, WindowSize},
12 event_loop::{EventLoop, Msg, Notifier},
13 grid::{Dimensions, Grid, Row, Scroll as AlacScroll},
14 index::{Boundary, Column, Direction as AlacDirection, Line, Point as AlacPoint},
15 selection::{Selection, SelectionRange, SelectionType},
16 sync::FairMutex,
17 term::{
18 Config, RenderableCursor, TermMode,
19 cell::{Cell, Flags},
20 search::{Match, RegexIter, RegexSearch},
21 },
22 tty::{self},
23 vi_mode::{ViModeCursor, ViMotion},
24 vte::ansi::{
25 ClearMode, CursorStyle as AlacCursorStyle, Handler, NamedPrivateMode, PrivateMode,
26 },
27};
28use anyhow::{Context as _, Result, bail};
29use log::trace;
30
31use futures::{
32 FutureExt,
33 channel::mpsc::{UnboundedReceiver, UnboundedSender, unbounded},
34};
35
36use itertools::Itertools as _;
37use mappings::mouse::{
38 alt_scroll, grid_point, grid_point_and_side, mouse_button_report, mouse_moved_report,
39 scroll_report,
40};
41
42use collections::{HashMap, VecDeque};
43use futures::StreamExt;
44use pty_info::{ProcessIdGetter, PtyProcessInfo};
45use serde::{Deserialize, Serialize};
46use settings::Settings;
47use smol::channel::{Receiver, Sender};
48use task::{HideStrategy, Shell, SpawnInTerminal};
49use terminal_hyperlinks::RegexSearches;
50use terminal_settings::{AlternateScroll, CursorShape, TerminalSettings};
51use theme::{ActiveTheme, Theme};
52use urlencoding;
53use util::truncate_and_trailoff;
54
55use std::{
56 borrow::Cow,
57 cmp::{self, min},
58 fmt::Display,
59 ops::{Deref, RangeInclusive},
60 path::PathBuf,
61 process::ExitStatus,
62 sync::Arc,
63 time::Instant,
64};
65use thiserror::Error;
66
67use gpui::{
68 App, AppContext as _, Bounds, ClipboardItem, Context, EventEmitter, Hsla, Keystroke, Modifiers,
69 MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, Rgba,
70 ScrollWheelEvent, SharedString, Size, Task, TouchPhase, Window, actions, black, px,
71};
72
73use crate::mappings::{colors::to_alac_rgb, keys::to_esc_str};
74
75actions!(
76 terminal,
77 [
78 /// Clears the terminal screen.
79 Clear,
80 /// Copies selected text to the clipboard.
81 Copy,
82 /// Pastes from the clipboard.
83 Paste,
84 /// Shows the character palette for special characters.
85 ShowCharacterPalette,
86 /// Searches for text in the terminal.
87 SearchTest,
88 /// Scrolls up by one line.
89 ScrollLineUp,
90 /// Scrolls down by one line.
91 ScrollLineDown,
92 /// Scrolls up by one page.
93 ScrollPageUp,
94 /// Scrolls down by one page.
95 ScrollPageDown,
96 /// Scrolls up by half a page.
97 ScrollHalfPageUp,
98 /// Scrolls down by half a page.
99 ScrollHalfPageDown,
100 /// Scrolls to the top of the terminal buffer.
101 ScrollToTop,
102 /// Scrolls to the bottom of the terminal buffer.
103 ScrollToBottom,
104 /// Toggles vi mode in the terminal.
105 ToggleViMode,
106 /// Selects all text in the terminal.
107 SelectAll,
108 ]
109);
110
111///Scrolling is unbearably sluggish by default. Alacritty supports a configurable
112///Scroll multiplier that is set to 3 by default. This will be removed when I
113///Implement scroll bars.
114#[cfg(target_os = "macos")]
115const SCROLL_MULTIPLIER: f32 = 4.;
116#[cfg(not(target_os = "macos"))]
117const SCROLL_MULTIPLIER: f32 = 1.;
118const DEBUG_TERMINAL_WIDTH: Pixels = px(500.);
119const DEBUG_TERMINAL_HEIGHT: Pixels = px(30.);
120const DEBUG_CELL_WIDTH: Pixels = px(5.);
121const DEBUG_LINE_HEIGHT: Pixels = px(5.);
122
123///Upward flowing events, for changing the title and such
124#[derive(Clone, Debug, PartialEq, Eq)]
125pub enum Event {
126 TitleChanged,
127 BreadcrumbsChanged,
128 CloseTerminal,
129 Bell,
130 Wakeup,
131 BlinkChanged(bool),
132 SelectionsChanged,
133 NewNavigationTarget(Option<MaybeNavigationTarget>),
134 Open(MaybeNavigationTarget),
135}
136
137#[derive(Clone, Debug, PartialEq, Eq)]
138pub struct PathLikeTarget {
139 /// File system path, absolute or relative, existing or not.
140 /// Might have line and column number(s) attached as `file.rs:1:23`
141 pub maybe_path: String,
142 /// Current working directory of the terminal
143 pub terminal_dir: Option<PathBuf>,
144}
145
146/// A string inside terminal, potentially useful as a URI that can be opened.
147#[derive(Clone, Debug, PartialEq, Eq)]
148pub enum MaybeNavigationTarget {
149 /// HTTP, git, etc. string determined by the `URL_REGEX` regex.
150 Url(String),
151 /// File system path, absolute or relative, existing or not.
152 /// Might have line and column number(s) attached as `file.rs:1:23`
153 PathLike(PathLikeTarget),
154}
155
156#[derive(Clone)]
157enum InternalEvent {
158 Resize(TerminalBounds),
159 Clear,
160 // FocusNextMatch,
161 Scroll(AlacScroll),
162 ScrollToAlacPoint(AlacPoint),
163 SetSelection(Option<(Selection, AlacPoint)>),
164 UpdateSelection(Point<Pixels>),
165 // Adjusted mouse position, should open
166 FindHyperlink(Point<Pixels>, bool),
167 // Whether keep selection when copy
168 Copy(Option<bool>),
169 // Vi mode events
170 ToggleViMode,
171 ViMotion(ViMotion),
172 MoveViCursorToAlacPoint(AlacPoint),
173}
174
175///A translation struct for Alacritty to communicate with us from their event loop
176#[derive(Clone)]
177pub struct ZedListener(pub UnboundedSender<AlacTermEvent>);
178
179impl EventListener for ZedListener {
180 fn send_event(&self, event: AlacTermEvent) {
181 self.0.unbounded_send(event).ok();
182 }
183}
184
185pub fn init(cx: &mut App) {
186 TerminalSettings::register(cx);
187}
188
189#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
190pub struct TerminalBounds {
191 pub cell_width: Pixels,
192 pub line_height: Pixels,
193 pub bounds: Bounds<Pixels>,
194}
195
196impl TerminalBounds {
197 pub fn new(line_height: Pixels, cell_width: Pixels, bounds: Bounds<Pixels>) -> Self {
198 TerminalBounds {
199 cell_width,
200 line_height,
201 bounds,
202 }
203 }
204
205 pub fn num_lines(&self) -> usize {
206 (self.bounds.size.height / self.line_height).floor() as usize
207 }
208
209 pub fn num_columns(&self) -> usize {
210 (self.bounds.size.width / self.cell_width).floor() as usize
211 }
212
213 pub fn height(&self) -> Pixels {
214 self.bounds.size.height
215 }
216
217 pub fn width(&self) -> Pixels {
218 self.bounds.size.width
219 }
220
221 pub fn cell_width(&self) -> Pixels {
222 self.cell_width
223 }
224
225 pub fn line_height(&self) -> Pixels {
226 self.line_height
227 }
228}
229
230impl Default for TerminalBounds {
231 fn default() -> Self {
232 TerminalBounds::new(
233 DEBUG_LINE_HEIGHT,
234 DEBUG_CELL_WIDTH,
235 Bounds {
236 origin: Point::default(),
237 size: Size {
238 width: DEBUG_TERMINAL_WIDTH,
239 height: DEBUG_TERMINAL_HEIGHT,
240 },
241 },
242 )
243 }
244}
245
246impl From<TerminalBounds> for WindowSize {
247 fn from(val: TerminalBounds) -> Self {
248 WindowSize {
249 num_lines: val.num_lines() as u16,
250 num_cols: val.num_columns() as u16,
251 cell_width: f32::from(val.cell_width()) as u16,
252 cell_height: f32::from(val.line_height()) as u16,
253 }
254 }
255}
256
257impl Dimensions for TerminalBounds {
258 /// Note: this is supposed to be for the back buffer's length,
259 /// but we exclusively use it to resize the terminal, which does not
260 /// use this method. We still have to implement it for the trait though,
261 /// hence, this comment.
262 fn total_lines(&self) -> usize {
263 self.screen_lines()
264 }
265
266 fn screen_lines(&self) -> usize {
267 self.num_lines()
268 }
269
270 fn columns(&self) -> usize {
271 self.num_columns()
272 }
273}
274
275#[derive(Error, Debug)]
276pub struct TerminalError {
277 pub directory: Option<PathBuf>,
278 pub program: Option<String>,
279 pub args: Option<Vec<String>>,
280 pub title_override: Option<SharedString>,
281 pub source: std::io::Error,
282}
283
284impl TerminalError {
285 pub fn fmt_directory(&self) -> String {
286 self.directory
287 .clone()
288 .map(|path| {
289 match path
290 .into_os_string()
291 .into_string()
292 .map_err(|os_str| format!("<non-utf8 path> {}", os_str.to_string_lossy()))
293 {
294 Ok(s) => s,
295 Err(s) => s,
296 }
297 })
298 .unwrap_or_else(|| "<none specified>".to_string())
299 }
300
301 pub fn fmt_shell(&self) -> String {
302 if let Some(title_override) = &self.title_override {
303 format!(
304 "{} {} ({})",
305 self.program.as_deref().unwrap_or("<system defined shell>"),
306 self.args.as_ref().into_iter().flatten().format(" "),
307 title_override
308 )
309 } else {
310 format!(
311 "{} {}",
312 self.program.as_deref().unwrap_or("<system defined shell>"),
313 self.args.as_ref().into_iter().flatten().format(" ")
314 )
315 }
316 }
317}
318
319impl Display for TerminalError {
320 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
321 let dir_string: String = self.fmt_directory();
322 let shell = self.fmt_shell();
323
324 write!(
325 f,
326 "Working directory: {} Shell command: `{}`, IOError: {}",
327 dir_string, shell, self.source
328 )
329 }
330}
331
332// https://github.com/alacritty/alacritty/blob/cb3a79dbf6472740daca8440d5166c1d4af5029e/extra/man/alacritty.5.scd?plain=1#L207-L213
333const DEFAULT_SCROLL_HISTORY_LINES: usize = 10_000;
334pub const MAX_SCROLL_HISTORY_LINES: usize = 100_000;
335
336pub struct TerminalBuilder {
337 terminal: Terminal,
338 events_rx: UnboundedReceiver<AlacTermEvent>,
339}
340
341impl TerminalBuilder {
342 pub fn new_display_only(
343 cursor_shape: CursorShape,
344 alternate_scroll: AlternateScroll,
345 max_scroll_history_lines: Option<usize>,
346 window_id: u64,
347 ) -> Result<TerminalBuilder> {
348 // Create a display-only terminal (no actual PTY).
349 let default_cursor_style = AlacCursorStyle::from(cursor_shape);
350 let scrolling_history = max_scroll_history_lines
351 .unwrap_or(DEFAULT_SCROLL_HISTORY_LINES)
352 .min(MAX_SCROLL_HISTORY_LINES);
353 let config = Config {
354 scrolling_history,
355 default_cursor_style,
356 ..Config::default()
357 };
358
359 let (events_tx, events_rx) = unbounded();
360 let mut term = Term::new(
361 config.clone(),
362 &TerminalBounds::default(),
363 ZedListener(events_tx),
364 );
365
366 if let AlternateScroll::Off = alternate_scroll {
367 term.unset_private_mode(PrivateMode::Named(NamedPrivateMode::AlternateScroll));
368 }
369
370 let term = Arc::new(FairMutex::new(term));
371
372 let terminal = Terminal {
373 task: None,
374 terminal_type: TerminalType::DisplayOnly,
375 completion_tx: None,
376 term,
377 term_config: config,
378 title_override: None,
379 events: VecDeque::with_capacity(10),
380 last_content: Default::default(),
381 last_mouse: None,
382 matches: Vec::new(),
383 selection_head: None,
384 breadcrumb_text: String::new(),
385 scroll_px: px(0.),
386 next_link_id: 0,
387 selection_phase: SelectionPhase::Ended,
388 hyperlink_regex_searches: RegexSearches::new(),
389 vi_mode_enabled: false,
390 is_ssh_terminal: false,
391 last_mouse_move_time: Instant::now(),
392 last_hyperlink_search_position: None,
393 #[cfg(windows)]
394 shell_program: None,
395 activation_script: Vec::new(),
396 template: CopyTemplate {
397 shell: Shell::System,
398 env: HashMap::default(),
399 cursor_shape,
400 alternate_scroll,
401 max_scroll_history_lines,
402 window_id,
403 },
404 child_exited: None,
405 };
406
407 Ok(TerminalBuilder {
408 terminal,
409 events_rx,
410 })
411 }
412 pub fn new(
413 working_directory: Option<PathBuf>,
414 task: Option<TaskState>,
415 shell: Shell,
416 mut env: HashMap<String, String>,
417 cursor_shape: CursorShape,
418 alternate_scroll: AlternateScroll,
419 max_scroll_history_lines: Option<usize>,
420 is_ssh_terminal: bool,
421 window_id: u64,
422 completion_tx: Option<Sender<Option<ExitStatus>>>,
423 cx: &App,
424 activation_script: Vec<String>,
425 ) -> Result<TerminalBuilder> {
426 // If the parent environment doesn't have a locale set
427 // (As is the case when launched from a .app on MacOS),
428 // and the Project doesn't have a locale set, then
429 // set a fallback for our child environment to use.
430 if std::env::var("LANG").is_err() {
431 env.entry("LANG".to_string())
432 .or_insert_with(|| "en_US.UTF-8".to_string());
433 }
434
435 env.insert("ZED_TERM".to_string(), "true".to_string());
436 env.insert("TERM_PROGRAM".to_string(), "zed".to_string());
437 env.insert("TERM".to_string(), "xterm-256color".to_string());
438 env.insert("COLORTERM".to_string(), "truecolor".to_string());
439 env.insert(
440 "TERM_PROGRAM_VERSION".to_string(),
441 release_channel::AppVersion::global(cx).to_string(),
442 );
443
444 #[derive(Default)]
445 struct ShellParams {
446 program: String,
447 args: Option<Vec<String>>,
448 title_override: Option<SharedString>,
449 }
450
451 impl ShellParams {
452 fn new(
453 program: String,
454 args: Option<Vec<String>>,
455 title_override: Option<SharedString>,
456 ) -> Self {
457 log::info!("Using {program} as shell");
458 Self {
459 program,
460 args,
461 title_override,
462 }
463 }
464 }
465
466 let shell_params = match shell.clone() {
467 Shell::System => {
468 #[cfg(target_os = "windows")]
469 {
470 Some(ShellParams::new(
471 util::shell::get_windows_system_shell(),
472 None,
473 None,
474 ))
475 }
476 #[cfg(not(target_os = "windows"))]
477 None
478 }
479 Shell::Program(program) => Some(ShellParams::new(program, None, None)),
480 Shell::WithArguments {
481 program,
482 args,
483 title_override,
484 } => Some(ShellParams::new(program, Some(args), title_override)),
485 };
486 let terminal_title_override = shell_params.as_ref().and_then(|e| e.title_override.clone());
487
488 #[cfg(windows)]
489 let shell_program = shell_params.as_ref().map(|params| {
490 use util::ResultExt;
491
492 Self::resolve_path(¶ms.program)
493 .log_err()
494 .unwrap_or(params.program.clone())
495 });
496
497 let pty_options = {
498 let alac_shell = shell_params.as_ref().map(|params| {
499 alacritty_terminal::tty::Shell::new(
500 params.program.clone(),
501 params.args.clone().unwrap_or_default(),
502 )
503 });
504
505 alacritty_terminal::tty::Options {
506 shell: alac_shell,
507 working_directory: working_directory.clone(),
508 drain_on_exit: true,
509 env: env.clone().into_iter().collect(),
510 #[cfg(windows)]
511 escape_args: true,
512 }
513 };
514
515 let default_cursor_style = AlacCursorStyle::from(cursor_shape);
516 let scrolling_history = if task.is_some() {
517 // Tasks like `cargo build --all` may produce a lot of output, ergo allow maximum scrolling.
518 // After the task finishes, we do not allow appending to that terminal, so small tasks output should not
519 // cause excessive memory usage over time.
520 MAX_SCROLL_HISTORY_LINES
521 } else {
522 max_scroll_history_lines
523 .unwrap_or(DEFAULT_SCROLL_HISTORY_LINES)
524 .min(MAX_SCROLL_HISTORY_LINES)
525 };
526 let config = Config {
527 scrolling_history,
528 default_cursor_style,
529 ..Config::default()
530 };
531
532 //Spawn a task so the Alacritty EventLoop can communicate with us
533 //TODO: Remove with a bounded sender which can be dispatched on &self
534 let (events_tx, events_rx) = unbounded();
535 //Set up the terminal...
536 let mut term = Term::new(
537 config.clone(),
538 &TerminalBounds::default(),
539 ZedListener(events_tx.clone()),
540 );
541
542 //Alacritty defaults to alternate scrolling being on, so we just need to turn it off.
543 if let AlternateScroll::Off = alternate_scroll {
544 term.unset_private_mode(PrivateMode::Named(NamedPrivateMode::AlternateScroll));
545 }
546
547 let term = Arc::new(FairMutex::new(term));
548
549 //Setup the pty...
550 let pty = match tty::new(&pty_options, TerminalBounds::default().into(), window_id) {
551 Ok(pty) => pty,
552 Err(error) => {
553 bail!(TerminalError {
554 directory: working_directory,
555 program: shell_params.as_ref().map(|params| params.program.clone()),
556 args: shell_params.as_ref().and_then(|params| params.args.clone()),
557 title_override: terminal_title_override,
558 source: error,
559 });
560 }
561 };
562
563 let pty_info = PtyProcessInfo::new(&pty);
564
565 //And connect them together
566 let event_loop = EventLoop::new(
567 term.clone(),
568 ZedListener(events_tx),
569 pty,
570 pty_options.drain_on_exit,
571 false,
572 )
573 .context("failed to create event loop")?;
574
575 //Kick things off
576 let pty_tx = event_loop.channel();
577 let _io_thread = event_loop.spawn(); // DANGER
578
579 let no_task = task.is_none();
580
581 let mut terminal = Terminal {
582 task,
583 terminal_type: TerminalType::Pty {
584 pty_tx: Notifier(pty_tx),
585 info: pty_info,
586 },
587 completion_tx,
588 term,
589 term_config: config,
590 title_override: terminal_title_override,
591 events: VecDeque::with_capacity(10), //Should never get this high.
592 last_content: Default::default(),
593 last_mouse: None,
594 matches: Vec::new(),
595 selection_head: None,
596 breadcrumb_text: String::new(),
597 scroll_px: px(0.),
598 next_link_id: 0,
599 selection_phase: SelectionPhase::Ended,
600 hyperlink_regex_searches: RegexSearches::new(),
601 vi_mode_enabled: false,
602 is_ssh_terminal,
603 last_mouse_move_time: Instant::now(),
604 last_hyperlink_search_position: None,
605 #[cfg(windows)]
606 shell_program,
607 activation_script: activation_script.clone(),
608 template: CopyTemplate {
609 shell,
610 env,
611 cursor_shape,
612 alternate_scroll,
613 max_scroll_history_lines,
614 window_id,
615 },
616 child_exited: None,
617 };
618
619 if cfg!(not(target_os = "windows")) && !activation_script.is_empty() && no_task {
620 for activation_script in activation_script {
621 terminal.input(activation_script.into_bytes());
622 terminal.write_to_pty(b"\n");
623 }
624 terminal.clear();
625 }
626
627 Ok(TerminalBuilder {
628 terminal,
629 events_rx,
630 })
631 }
632
633 pub fn subscribe(mut self, cx: &Context<Terminal>) -> Terminal {
634 //Event loop
635 cx.spawn(async move |terminal, cx| {
636 while let Some(event) = self.events_rx.next().await {
637 terminal.update(cx, |terminal, cx| {
638 //Process the first event immediately for lowered latency
639 terminal.process_event(event, cx);
640 })?;
641
642 'outer: loop {
643 let mut events = Vec::new();
644
645 #[cfg(any(test, feature = "test-support"))]
646 let mut timer = cx.background_executor().simulate_random_delay().fuse();
647 #[cfg(not(any(test, feature = "test-support")))]
648 let mut timer = cx
649 .background_executor()
650 .timer(std::time::Duration::from_millis(4))
651 .fuse();
652
653 let mut wakeup = false;
654 loop {
655 futures::select_biased! {
656 _ = timer => break,
657 event = self.events_rx.next() => {
658 if let Some(event) = event {
659 if matches!(event, AlacTermEvent::Wakeup) {
660 wakeup = true;
661 } else {
662 events.push(event);
663 }
664
665 if events.len() > 100 {
666 break;
667 }
668 } else {
669 break;
670 }
671 },
672 }
673 }
674
675 if events.is_empty() && !wakeup {
676 smol::future::yield_now().await;
677 break 'outer;
678 }
679
680 terminal.update(cx, |this, cx| {
681 if wakeup {
682 this.process_event(AlacTermEvent::Wakeup, cx);
683 }
684
685 for event in events {
686 this.process_event(event, cx);
687 }
688 })?;
689 smol::future::yield_now().await;
690 }
691 }
692
693 anyhow::Ok(())
694 })
695 .detach();
696
697 self.terminal
698 }
699
700 #[cfg(windows)]
701 fn resolve_path(path: &str) -> Result<String> {
702 use windows::Win32::Storage::FileSystem::SearchPathW;
703 use windows::core::HSTRING;
704
705 let path = if path.starts_with(r"\\?\") || !path.contains(&['/', '\\']) {
706 path.to_string()
707 } else {
708 r"\\?\".to_string() + path
709 };
710
711 let required_length = unsafe { SearchPathW(None, &HSTRING::from(&path), None, None, None) };
712 let mut buf = vec![0u16; required_length as usize];
713 let size = unsafe { SearchPathW(None, &HSTRING::from(&path), None, Some(&mut buf), None) };
714
715 Ok(String::from_utf16(&buf[..size as usize])?)
716 }
717}
718
719#[derive(Debug, Clone, Deserialize, Serialize)]
720pub struct IndexedCell {
721 pub point: AlacPoint,
722 pub cell: Cell,
723}
724
725impl Deref for IndexedCell {
726 type Target = Cell;
727
728 #[inline]
729 fn deref(&self) -> &Cell {
730 &self.cell
731 }
732}
733
734// TODO: Un-pub
735#[derive(Clone)]
736pub struct TerminalContent {
737 pub cells: Vec<IndexedCell>,
738 pub mode: TermMode,
739 pub display_offset: usize,
740 pub selection_text: Option<String>,
741 pub selection: Option<SelectionRange>,
742 pub cursor: RenderableCursor,
743 pub cursor_char: char,
744 pub terminal_bounds: TerminalBounds,
745 pub last_hovered_word: Option<HoveredWord>,
746 pub scrolled_to_top: bool,
747 pub scrolled_to_bottom: bool,
748}
749
750#[derive(Debug, Clone, Eq, PartialEq)]
751pub struct HoveredWord {
752 pub word: String,
753 pub word_match: RangeInclusive<AlacPoint>,
754 pub id: usize,
755}
756
757impl Default for TerminalContent {
758 fn default() -> Self {
759 TerminalContent {
760 cells: Default::default(),
761 mode: Default::default(),
762 display_offset: Default::default(),
763 selection_text: Default::default(),
764 selection: Default::default(),
765 cursor: RenderableCursor {
766 shape: alacritty_terminal::vte::ansi::CursorShape::Block,
767 point: AlacPoint::new(Line(0), Column(0)),
768 },
769 cursor_char: Default::default(),
770 terminal_bounds: Default::default(),
771 last_hovered_word: None,
772 scrolled_to_top: false,
773 scrolled_to_bottom: false,
774 }
775 }
776}
777
778#[derive(PartialEq, Eq)]
779pub enum SelectionPhase {
780 Selecting,
781 Ended,
782}
783
784enum TerminalType {
785 Pty {
786 pty_tx: Notifier,
787 info: PtyProcessInfo,
788 },
789 DisplayOnly,
790}
791
792pub struct Terminal {
793 terminal_type: TerminalType,
794 completion_tx: Option<Sender<Option<ExitStatus>>>,
795 term: Arc<FairMutex<Term<ZedListener>>>,
796 term_config: Config,
797 events: VecDeque<InternalEvent>,
798 /// This is only used for mouse mode cell change detection
799 last_mouse: Option<(AlacPoint, AlacDirection)>,
800 pub matches: Vec<RangeInclusive<AlacPoint>>,
801 pub last_content: TerminalContent,
802 pub selection_head: Option<AlacPoint>,
803 pub breadcrumb_text: String,
804 title_override: Option<SharedString>,
805 scroll_px: Pixels,
806 next_link_id: usize,
807 selection_phase: SelectionPhase,
808 hyperlink_regex_searches: RegexSearches,
809 task: Option<TaskState>,
810 vi_mode_enabled: bool,
811 is_ssh_terminal: bool,
812 last_mouse_move_time: Instant,
813 last_hyperlink_search_position: Option<Point<Pixels>>,
814 #[cfg(windows)]
815 shell_program: Option<String>,
816 template: CopyTemplate,
817 activation_script: Vec<String>,
818 child_exited: Option<ExitStatus>,
819}
820
821struct CopyTemplate {
822 shell: Shell,
823 env: HashMap<String, String>,
824 cursor_shape: CursorShape,
825 alternate_scroll: AlternateScroll,
826 max_scroll_history_lines: Option<usize>,
827 window_id: u64,
828}
829
830#[derive(Debug)]
831pub struct TaskState {
832 pub status: TaskStatus,
833 pub completion_rx: Receiver<Option<ExitStatus>>,
834 pub spawned_task: SpawnInTerminal,
835}
836
837/// A status of the current terminal tab's task.
838#[derive(Debug, Clone, Copy, PartialEq, Eq)]
839pub enum TaskStatus {
840 /// The task had been started, but got cancelled or somehow otherwise it did not
841 /// report its exit code before the terminal event loop was shut down.
842 Unknown,
843 /// The task is started and running currently.
844 Running,
845 /// After the start, the task stopped running and reported its error code back.
846 Completed { success: bool },
847}
848
849impl TaskStatus {
850 fn register_terminal_exit(&mut self) {
851 if self == &Self::Running {
852 *self = Self::Unknown;
853 }
854 }
855
856 fn register_task_exit(&mut self, error_code: i32) {
857 *self = TaskStatus::Completed {
858 success: error_code == 0,
859 };
860 }
861}
862
863impl Terminal {
864 fn process_event(&mut self, event: AlacTermEvent, cx: &mut Context<Self>) {
865 match event {
866 AlacTermEvent::Title(title) => {
867 // ignore default shell program title change as windows always sends those events
868 // and it would end up showing the shell executable path in breadcrumbs
869 #[cfg(windows)]
870 {
871 if self
872 .shell_program
873 .as_ref()
874 .map(|e| *e == title)
875 .unwrap_or(false)
876 {
877 return;
878 }
879 }
880
881 self.breadcrumb_text = title;
882 cx.emit(Event::BreadcrumbsChanged);
883 }
884 AlacTermEvent::ResetTitle => {
885 self.breadcrumb_text = String::new();
886 cx.emit(Event::BreadcrumbsChanged);
887 }
888 AlacTermEvent::ClipboardStore(_, data) => {
889 cx.write_to_clipboard(ClipboardItem::new_string(data))
890 }
891 AlacTermEvent::ClipboardLoad(_, format) => {
892 self.write_to_pty(
893 match &cx.read_from_clipboard().and_then(|item| item.text()) {
894 // The terminal only supports pasting strings, not images.
895 Some(text) => format(text),
896 _ => format(""),
897 }
898 .into_bytes(),
899 )
900 }
901 AlacTermEvent::PtyWrite(out) => self.write_to_pty(out.into_bytes()),
902 AlacTermEvent::TextAreaSizeRequest(format) => {
903 self.write_to_pty(format(self.last_content.terminal_bounds.into()).into_bytes())
904 }
905 AlacTermEvent::CursorBlinkingChange => {
906 let terminal = self.term.lock();
907 let blinking = terminal.cursor_style().blinking;
908 cx.emit(Event::BlinkChanged(blinking));
909 }
910 AlacTermEvent::Bell => {
911 cx.emit(Event::Bell);
912 }
913 AlacTermEvent::Exit => self.register_task_finished(None, cx),
914 AlacTermEvent::MouseCursorDirty => {
915 //NOOP, Handled in render
916 }
917 AlacTermEvent::Wakeup => {
918 cx.emit(Event::Wakeup);
919
920 if let TerminalType::Pty { info, .. } = &mut self.terminal_type {
921 if info.has_changed() {
922 cx.emit(Event::TitleChanged);
923 }
924 }
925 }
926 AlacTermEvent::ColorRequest(index, format) => {
927 // It's important that the color request is processed here to retain relative order
928 // with other PTY writes. Otherwise applications might witness out-of-order
929 // responses to requests. For example: An application sending `OSC 11 ; ? ST`
930 // (color request) followed by `CSI c` (request device attributes) would receive
931 // the response to `CSI c` first.
932 // Instead of locking, we could store the colors in `self.last_content`. But then
933 // we might respond with out of date value if a "set color" sequence is immediately
934 // followed by a color request sequence.
935 let color = self.term.lock().colors()[index]
936 .unwrap_or_else(|| to_alac_rgb(get_color_at_index(index, cx.theme().as_ref())));
937 self.write_to_pty(format(color).into_bytes());
938 }
939 AlacTermEvent::ChildExit(error_code) => {
940 self.register_task_finished(Some(error_code), cx);
941 }
942 }
943 }
944
945 pub fn selection_started(&self) -> bool {
946 self.selection_phase == SelectionPhase::Selecting
947 }
948
949 fn process_terminal_event(
950 &mut self,
951 event: &InternalEvent,
952 term: &mut Term<ZedListener>,
953 window: &mut Window,
954 cx: &mut Context<Self>,
955 ) {
956 match event {
957 &InternalEvent::Resize(mut new_bounds) => {
958 trace!("Resizing: new_bounds={new_bounds:?}");
959 new_bounds.bounds.size.height =
960 cmp::max(new_bounds.line_height, new_bounds.height());
961 new_bounds.bounds.size.width = cmp::max(new_bounds.cell_width, new_bounds.width());
962
963 self.last_content.terminal_bounds = new_bounds;
964
965 if let TerminalType::Pty { pty_tx, .. } = &self.terminal_type {
966 pty_tx.0.send(Msg::Resize(new_bounds.into())).ok();
967 }
968
969 term.resize(new_bounds);
970 }
971 InternalEvent::Clear => {
972 trace!("Clearing");
973 // Clear back buffer
974 term.clear_screen(ClearMode::Saved);
975
976 let cursor = term.grid().cursor.point;
977
978 // Clear the lines above
979 term.grid_mut().reset_region(..cursor.line);
980
981 // Copy the current line up
982 let line = term.grid()[cursor.line][..Column(term.grid().columns())]
983 .iter()
984 .cloned()
985 .enumerate()
986 .collect::<Vec<(usize, Cell)>>();
987
988 for (i, cell) in line {
989 term.grid_mut()[Line(0)][Column(i)] = cell;
990 }
991
992 // Reset the cursor
993 term.grid_mut().cursor.point =
994 AlacPoint::new(Line(0), term.grid_mut().cursor.point.column);
995 let new_cursor = term.grid().cursor.point;
996
997 // Clear the lines below the new cursor
998 if (new_cursor.line.0 as usize) < term.screen_lines() - 1 {
999 term.grid_mut().reset_region((new_cursor.line + 1)..);
1000 }
1001
1002 cx.emit(Event::Wakeup);
1003 }
1004 InternalEvent::Scroll(scroll) => {
1005 trace!("Scrolling: scroll={scroll:?}");
1006 term.scroll_display(*scroll);
1007 self.refresh_hovered_word(window);
1008
1009 if self.vi_mode_enabled {
1010 match *scroll {
1011 AlacScroll::Delta(delta) => {
1012 term.vi_mode_cursor = term.vi_mode_cursor.scroll(term, delta);
1013 }
1014 AlacScroll::PageUp => {
1015 let lines = term.screen_lines() as i32;
1016 term.vi_mode_cursor = term.vi_mode_cursor.scroll(term, lines);
1017 }
1018 AlacScroll::PageDown => {
1019 let lines = -(term.screen_lines() as i32);
1020 term.vi_mode_cursor = term.vi_mode_cursor.scroll(term, lines);
1021 }
1022 AlacScroll::Top => {
1023 let point = AlacPoint::new(term.topmost_line(), Column(0));
1024 term.vi_mode_cursor = ViModeCursor::new(point);
1025 }
1026 AlacScroll::Bottom => {
1027 let point = AlacPoint::new(term.bottommost_line(), Column(0));
1028 term.vi_mode_cursor = ViModeCursor::new(point);
1029 }
1030 }
1031 if let Some(mut selection) = term.selection.take() {
1032 let point = term.vi_mode_cursor.point;
1033 selection.update(point, AlacDirection::Right);
1034 term.selection = Some(selection);
1035
1036 #[cfg(any(target_os = "linux", target_os = "freebsd"))]
1037 if let Some(selection_text) = term.selection_to_string() {
1038 cx.write_to_primary(ClipboardItem::new_string(selection_text));
1039 }
1040
1041 self.selection_head = Some(point);
1042 cx.emit(Event::SelectionsChanged)
1043 }
1044 }
1045 }
1046 InternalEvent::SetSelection(selection) => {
1047 trace!("Setting selection: selection={selection:?}");
1048 term.selection = selection.as_ref().map(|(sel, _)| sel.clone());
1049
1050 #[cfg(any(target_os = "linux", target_os = "freebsd"))]
1051 if let Some(selection_text) = term.selection_to_string() {
1052 cx.write_to_primary(ClipboardItem::new_string(selection_text));
1053 }
1054
1055 if let Some((_, head)) = selection {
1056 self.selection_head = Some(*head);
1057 }
1058 cx.emit(Event::SelectionsChanged)
1059 }
1060 InternalEvent::UpdateSelection(position) => {
1061 trace!("Updating selection: position={position:?}");
1062 if let Some(mut selection) = term.selection.take() {
1063 let (point, side) = grid_point_and_side(
1064 *position,
1065 self.last_content.terminal_bounds,
1066 term.grid().display_offset(),
1067 );
1068
1069 selection.update(point, side);
1070 term.selection = Some(selection);
1071
1072 #[cfg(any(target_os = "linux", target_os = "freebsd"))]
1073 if let Some(selection_text) = term.selection_to_string() {
1074 cx.write_to_primary(ClipboardItem::new_string(selection_text));
1075 }
1076
1077 self.selection_head = Some(point);
1078 cx.emit(Event::SelectionsChanged)
1079 }
1080 }
1081
1082 InternalEvent::Copy(keep_selection) => {
1083 trace!("Copying selection: keep_selection={keep_selection:?}");
1084 if let Some(txt) = term.selection_to_string() {
1085 cx.write_to_clipboard(ClipboardItem::new_string(txt));
1086 if !keep_selection.unwrap_or_else(|| {
1087 let settings = TerminalSettings::get_global(cx);
1088 settings.keep_selection_on_copy
1089 }) {
1090 self.events.push_back(InternalEvent::SetSelection(None));
1091 }
1092 }
1093 }
1094 InternalEvent::ScrollToAlacPoint(point) => {
1095 trace!("Scrolling to point: point={point:?}");
1096 term.scroll_to_point(*point);
1097 self.refresh_hovered_word(window);
1098 }
1099 InternalEvent::MoveViCursorToAlacPoint(point) => {
1100 trace!("Move vi cursor to point: point={point:?}");
1101 term.vi_goto_point(*point);
1102 self.refresh_hovered_word(window);
1103 }
1104 InternalEvent::ToggleViMode => {
1105 trace!("Toggling vi mode");
1106 self.vi_mode_enabled = !self.vi_mode_enabled;
1107 term.toggle_vi_mode();
1108 }
1109 InternalEvent::ViMotion(motion) => {
1110 trace!("Performing vi motion: motion={motion:?}");
1111 term.vi_motion(*motion);
1112 }
1113 InternalEvent::FindHyperlink(position, open) => {
1114 trace!("Finding hyperlink at position: position={position:?}, open={open:?}");
1115 let prev_hovered_word = self.last_content.last_hovered_word.take();
1116
1117 let point = grid_point(
1118 *position,
1119 self.last_content.terminal_bounds,
1120 term.grid().display_offset(),
1121 )
1122 .grid_clamp(term, Boundary::Grid);
1123
1124 match terminal_hyperlinks::find_from_grid_point(
1125 term,
1126 point,
1127 &mut self.hyperlink_regex_searches,
1128 ) {
1129 Some((maybe_url_or_path, is_url, url_match)) => {
1130 let target = if is_url {
1131 // Treat "file://" URLs like file paths to ensure
1132 // that line numbers at the end of the path are
1133 // handled correctly.
1134 // file://{path} should be urldecoded, returning a urldecoded {path}
1135 if let Some(path) = maybe_url_or_path.strip_prefix("file://") {
1136 let decoded_path = urlencoding::decode(path)
1137 .map(|decoded| decoded.into_owned())
1138 .unwrap_or(path.to_owned());
1139
1140 MaybeNavigationTarget::PathLike(PathLikeTarget {
1141 maybe_path: decoded_path,
1142 terminal_dir: self.working_directory(),
1143 })
1144 } else {
1145 MaybeNavigationTarget::Url(maybe_url_or_path.clone())
1146 }
1147 } else {
1148 MaybeNavigationTarget::PathLike(PathLikeTarget {
1149 maybe_path: maybe_url_or_path.clone(),
1150 terminal_dir: self.working_directory(),
1151 })
1152 };
1153 if *open {
1154 cx.emit(Event::Open(target));
1155 } else {
1156 self.update_selected_word(
1157 prev_hovered_word,
1158 url_match,
1159 maybe_url_or_path,
1160 target,
1161 cx,
1162 );
1163 }
1164 }
1165 None => {
1166 cx.emit(Event::NewNavigationTarget(None));
1167 }
1168 }
1169 }
1170 }
1171 }
1172
1173 fn update_selected_word(
1174 &mut self,
1175 prev_word: Option<HoveredWord>,
1176 word_match: RangeInclusive<AlacPoint>,
1177 word: String,
1178 navigation_target: MaybeNavigationTarget,
1179 cx: &mut Context<Self>,
1180 ) {
1181 if let Some(prev_word) = prev_word
1182 && prev_word.word == word
1183 && prev_word.word_match == word_match
1184 {
1185 self.last_content.last_hovered_word = Some(HoveredWord {
1186 word,
1187 word_match,
1188 id: prev_word.id,
1189 });
1190 return;
1191 }
1192
1193 self.last_content.last_hovered_word = Some(HoveredWord {
1194 word,
1195 word_match,
1196 id: self.next_link_id(),
1197 });
1198 cx.emit(Event::NewNavigationTarget(Some(navigation_target)));
1199 cx.notify()
1200 }
1201
1202 fn next_link_id(&mut self) -> usize {
1203 let res = self.next_link_id;
1204 self.next_link_id = self.next_link_id.wrapping_add(1);
1205 res
1206 }
1207
1208 pub fn last_content(&self) -> &TerminalContent {
1209 &self.last_content
1210 }
1211
1212 pub fn set_cursor_shape(&mut self, cursor_shape: CursorShape) {
1213 self.term_config.default_cursor_style = cursor_shape.into();
1214 self.term.lock().set_options(self.term_config.clone());
1215 }
1216
1217 pub fn write_output(&mut self, bytes: &[u8], cx: &mut Context<Self>) {
1218 // Inject bytes directly into the terminal emulator and refresh the UI.
1219 // This bypasses the PTY/event loop for display-only terminals.
1220 let mut processor = alacritty_terminal::vte::ansi::Processor::<
1221 alacritty_terminal::vte::ansi::StdSyncHandler,
1222 >::new();
1223 {
1224 let mut term = self.term.lock();
1225 processor.advance(&mut *term, bytes);
1226 }
1227 cx.emit(Event::Wakeup);
1228 }
1229
1230 pub fn total_lines(&self) -> usize {
1231 let term = self.term.clone();
1232 let terminal = term.lock_unfair();
1233 terminal.total_lines()
1234 }
1235
1236 pub fn viewport_lines(&self) -> usize {
1237 let term = self.term.clone();
1238 let terminal = term.lock_unfair();
1239 terminal.screen_lines()
1240 }
1241
1242 //To test:
1243 //- Activate match on terminal (scrolling and selection)
1244 //- Editor search snapping behavior
1245
1246 pub fn activate_match(&mut self, index: usize) {
1247 if let Some(search_match) = self.matches.get(index).cloned() {
1248 self.set_selection(Some((make_selection(&search_match), *search_match.end())));
1249 if self.vi_mode_enabled {
1250 self.events
1251 .push_back(InternalEvent::MoveViCursorToAlacPoint(*search_match.end()));
1252 } else {
1253 self.events
1254 .push_back(InternalEvent::ScrollToAlacPoint(*search_match.start()));
1255 }
1256 }
1257 }
1258
1259 pub fn select_matches(&mut self, matches: &[RangeInclusive<AlacPoint>]) {
1260 let matches_to_select = self
1261 .matches
1262 .iter()
1263 .filter(|self_match| matches.contains(self_match))
1264 .cloned()
1265 .collect::<Vec<_>>();
1266 for match_to_select in matches_to_select {
1267 self.set_selection(Some((
1268 make_selection(&match_to_select),
1269 *match_to_select.end(),
1270 )));
1271 }
1272 }
1273
1274 pub fn select_all(&mut self) {
1275 let term = self.term.lock();
1276 let start = AlacPoint::new(term.topmost_line(), Column(0));
1277 let end = AlacPoint::new(term.bottommost_line(), term.last_column());
1278 drop(term);
1279 self.set_selection(Some((make_selection(&(start..=end)), end)));
1280 }
1281
1282 fn set_selection(&mut self, selection: Option<(Selection, AlacPoint)>) {
1283 self.events
1284 .push_back(InternalEvent::SetSelection(selection));
1285 }
1286
1287 pub fn copy(&mut self, keep_selection: Option<bool>) {
1288 self.events.push_back(InternalEvent::Copy(keep_selection));
1289 }
1290
1291 pub fn clear(&mut self) {
1292 self.events.push_back(InternalEvent::Clear)
1293 }
1294
1295 pub fn scroll_line_up(&mut self) {
1296 self.events
1297 .push_back(InternalEvent::Scroll(AlacScroll::Delta(1)));
1298 }
1299
1300 pub fn scroll_up_by(&mut self, lines: usize) {
1301 self.events
1302 .push_back(InternalEvent::Scroll(AlacScroll::Delta(lines as i32)));
1303 }
1304
1305 pub fn scroll_line_down(&mut self) {
1306 self.events
1307 .push_back(InternalEvent::Scroll(AlacScroll::Delta(-1)));
1308 }
1309
1310 pub fn scroll_down_by(&mut self, lines: usize) {
1311 self.events
1312 .push_back(InternalEvent::Scroll(AlacScroll::Delta(-(lines as i32))));
1313 }
1314
1315 pub fn scroll_page_up(&mut self) {
1316 self.events
1317 .push_back(InternalEvent::Scroll(AlacScroll::PageUp));
1318 }
1319
1320 pub fn scroll_page_down(&mut self) {
1321 self.events
1322 .push_back(InternalEvent::Scroll(AlacScroll::PageDown));
1323 }
1324
1325 pub fn scroll_to_top(&mut self) {
1326 self.events
1327 .push_back(InternalEvent::Scroll(AlacScroll::Top));
1328 }
1329
1330 pub fn scroll_to_bottom(&mut self) {
1331 self.events
1332 .push_back(InternalEvent::Scroll(AlacScroll::Bottom));
1333 }
1334
1335 pub fn scrolled_to_top(&self) -> bool {
1336 self.last_content.scrolled_to_top
1337 }
1338
1339 pub fn scrolled_to_bottom(&self) -> bool {
1340 self.last_content.scrolled_to_bottom
1341 }
1342
1343 ///Resize the terminal and the PTY.
1344 pub fn set_size(&mut self, new_bounds: TerminalBounds) {
1345 if self.last_content.terminal_bounds != new_bounds {
1346 self.events.push_back(InternalEvent::Resize(new_bounds))
1347 }
1348 }
1349
1350 /// Write the Input payload to the PTY, if applicable.
1351 /// (This is a no-op for display-only terminals.)
1352 fn write_to_pty(&self, input: impl Into<Cow<'static, [u8]>>) {
1353 if let TerminalType::Pty { pty_tx, .. } = &self.terminal_type {
1354 pty_tx.notify(input.into());
1355 }
1356 }
1357
1358 pub fn input(&mut self, input: impl Into<Cow<'static, [u8]>>) {
1359 self.events
1360 .push_back(InternalEvent::Scroll(AlacScroll::Bottom));
1361 self.events.push_back(InternalEvent::SetSelection(None));
1362
1363 self.write_to_pty(input);
1364 }
1365
1366 pub fn toggle_vi_mode(&mut self) {
1367 self.events.push_back(InternalEvent::ToggleViMode);
1368 }
1369
1370 pub fn vi_motion(&mut self, keystroke: &Keystroke) {
1371 if !self.vi_mode_enabled {
1372 return;
1373 }
1374
1375 let key: Cow<'_, str> = if keystroke.modifiers.shift {
1376 Cow::Owned(keystroke.key.to_uppercase())
1377 } else {
1378 Cow::Borrowed(keystroke.key.as_str())
1379 };
1380
1381 let motion: Option<ViMotion> = match key.as_ref() {
1382 "h" | "left" => Some(ViMotion::Left),
1383 "j" | "down" => Some(ViMotion::Down),
1384 "k" | "up" => Some(ViMotion::Up),
1385 "l" | "right" => Some(ViMotion::Right),
1386 "w" => Some(ViMotion::WordRight),
1387 "b" if !keystroke.modifiers.control => Some(ViMotion::WordLeft),
1388 "e" => Some(ViMotion::WordRightEnd),
1389 "%" => Some(ViMotion::Bracket),
1390 "$" => Some(ViMotion::Last),
1391 "0" => Some(ViMotion::First),
1392 "^" => Some(ViMotion::FirstOccupied),
1393 "H" => Some(ViMotion::High),
1394 "M" => Some(ViMotion::Middle),
1395 "L" => Some(ViMotion::Low),
1396 _ => None,
1397 };
1398
1399 if let Some(motion) = motion {
1400 let cursor = self.last_content.cursor.point;
1401 let cursor_pos = Point {
1402 x: cursor.column.0 as f32 * self.last_content.terminal_bounds.cell_width,
1403 y: cursor.line.0 as f32 * self.last_content.terminal_bounds.line_height,
1404 };
1405 self.events
1406 .push_back(InternalEvent::UpdateSelection(cursor_pos));
1407 self.events.push_back(InternalEvent::ViMotion(motion));
1408 return;
1409 }
1410
1411 let scroll_motion = match key.as_ref() {
1412 "g" => Some(AlacScroll::Top),
1413 "G" => Some(AlacScroll::Bottom),
1414 "b" if keystroke.modifiers.control => Some(AlacScroll::PageUp),
1415 "f" if keystroke.modifiers.control => Some(AlacScroll::PageDown),
1416 "d" if keystroke.modifiers.control => {
1417 let amount = self.last_content.terminal_bounds.line_height().to_f64() as i32 / 2;
1418 Some(AlacScroll::Delta(-amount))
1419 }
1420 "u" if keystroke.modifiers.control => {
1421 let amount = self.last_content.terminal_bounds.line_height().to_f64() as i32 / 2;
1422 Some(AlacScroll::Delta(amount))
1423 }
1424 _ => None,
1425 };
1426
1427 if let Some(scroll_motion) = scroll_motion {
1428 self.events.push_back(InternalEvent::Scroll(scroll_motion));
1429 return;
1430 }
1431
1432 match key.as_ref() {
1433 "v" => {
1434 let point = self.last_content.cursor.point;
1435 let selection_type = SelectionType::Simple;
1436 let side = AlacDirection::Right;
1437 let selection = Selection::new(selection_type, point, side);
1438 self.events
1439 .push_back(InternalEvent::SetSelection(Some((selection, point))));
1440 }
1441
1442 "escape" => {
1443 self.events.push_back(InternalEvent::SetSelection(None));
1444 }
1445
1446 "y" => {
1447 self.copy(Some(false));
1448 }
1449
1450 "i" => {
1451 self.scroll_to_bottom();
1452 self.toggle_vi_mode();
1453 }
1454 _ => {}
1455 }
1456 }
1457
1458 pub fn try_keystroke(&mut self, keystroke: &Keystroke, alt_is_meta: bool) -> bool {
1459 if self.vi_mode_enabled {
1460 self.vi_motion(keystroke);
1461 return true;
1462 }
1463
1464 // Keep default terminal behavior
1465 let esc = to_esc_str(keystroke, &self.last_content.mode, alt_is_meta);
1466 if let Some(esc) = esc {
1467 match esc {
1468 Cow::Borrowed(string) => self.input(string.as_bytes()),
1469 Cow::Owned(string) => self.input(string.into_bytes()),
1470 };
1471 true
1472 } else {
1473 false
1474 }
1475 }
1476
1477 pub fn try_modifiers_change(
1478 &mut self,
1479 modifiers: &Modifiers,
1480 window: &Window,
1481 cx: &mut Context<Self>,
1482 ) {
1483 if self
1484 .last_content
1485 .terminal_bounds
1486 .bounds
1487 .contains(&window.mouse_position())
1488 && modifiers.secondary()
1489 {
1490 self.refresh_hovered_word(window);
1491 }
1492 cx.notify();
1493 }
1494
1495 ///Paste text into the terminal
1496 pub fn paste(&mut self, text: &str) {
1497 let paste_text = if self.last_content.mode.contains(TermMode::BRACKETED_PASTE) {
1498 format!("{}{}{}", "\x1b[200~", text.replace('\x1b', ""), "\x1b[201~")
1499 } else {
1500 text.replace("\r\n", "\r").replace('\n', "\r")
1501 };
1502
1503 self.input(paste_text.into_bytes());
1504 }
1505
1506 pub fn sync(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1507 let term = self.term.clone();
1508 let mut terminal = term.lock_unfair();
1509 //Note that the ordering of events matters for event processing
1510 while let Some(e) = self.events.pop_front() {
1511 self.process_terminal_event(&e, &mut terminal, window, cx)
1512 }
1513
1514 self.last_content = Self::make_content(&terminal, &self.last_content);
1515 }
1516
1517 fn make_content(term: &Term<ZedListener>, last_content: &TerminalContent) -> TerminalContent {
1518 let content = term.renderable_content();
1519
1520 // Pre-allocate with estimated size to reduce reallocations
1521 let estimated_size = content.display_iter.size_hint().0;
1522 let mut cells = Vec::with_capacity(estimated_size);
1523
1524 cells.extend(content.display_iter.map(|ic| IndexedCell {
1525 point: ic.point,
1526 cell: ic.cell.clone(),
1527 }));
1528
1529 let selection_text = if content.selection.is_some() {
1530 term.selection_to_string()
1531 } else {
1532 None
1533 };
1534
1535 TerminalContent {
1536 cells,
1537 mode: content.mode,
1538 display_offset: content.display_offset,
1539 selection_text,
1540 selection: content.selection,
1541 cursor: content.cursor,
1542 cursor_char: term.grid()[content.cursor.point].c,
1543 terminal_bounds: last_content.terminal_bounds,
1544 last_hovered_word: last_content.last_hovered_word.clone(),
1545 scrolled_to_top: content.display_offset == term.history_size(),
1546 scrolled_to_bottom: content.display_offset == 0,
1547 }
1548 }
1549
1550 pub fn get_content(&self) -> String {
1551 let term = self.term.lock_unfair();
1552 let start = AlacPoint::new(term.topmost_line(), Column(0));
1553 let end = AlacPoint::new(term.bottommost_line(), term.last_column());
1554 term.bounds_to_string(start, end)
1555 }
1556
1557 pub fn last_n_non_empty_lines(&self, n: usize) -> Vec<String> {
1558 let term = self.term.clone();
1559 let terminal = term.lock_unfair();
1560 let grid = terminal.grid();
1561 let mut lines = Vec::new();
1562
1563 let mut current_line = grid.bottommost_line().0;
1564 let topmost_line = grid.topmost_line().0;
1565
1566 while current_line >= topmost_line && lines.len() < n {
1567 let logical_line_start = self.find_logical_line_start(grid, current_line, topmost_line);
1568 let logical_line = self.construct_logical_line(grid, logical_line_start, current_line);
1569
1570 if let Some(line) = self.process_line(logical_line) {
1571 lines.push(line);
1572 }
1573
1574 // Move to the line above the start of the current logical line
1575 current_line = logical_line_start - 1;
1576 }
1577
1578 lines.reverse();
1579 lines
1580 }
1581
1582 fn find_logical_line_start(&self, grid: &Grid<Cell>, current: i32, topmost: i32) -> i32 {
1583 let mut line_start = current;
1584 while line_start > topmost {
1585 let prev_line = Line(line_start - 1);
1586 let last_cell = &grid[prev_line][Column(grid.columns() - 1)];
1587 if !last_cell.flags.contains(Flags::WRAPLINE) {
1588 break;
1589 }
1590 line_start -= 1;
1591 }
1592 line_start
1593 }
1594
1595 fn construct_logical_line(&self, grid: &Grid<Cell>, start: i32, end: i32) -> String {
1596 let mut logical_line = String::new();
1597 for row in start..=end {
1598 let grid_row = &grid[Line(row)];
1599 logical_line.push_str(&row_to_string(grid_row));
1600 }
1601 logical_line
1602 }
1603
1604 fn process_line(&self, line: String) -> Option<String> {
1605 let trimmed = line.trim_end().to_string();
1606 if !trimmed.is_empty() {
1607 Some(trimmed)
1608 } else {
1609 None
1610 }
1611 }
1612
1613 pub fn focus_in(&self) {
1614 if self.last_content.mode.contains(TermMode::FOCUS_IN_OUT) {
1615 self.write_to_pty("\x1b[I".as_bytes());
1616 }
1617 }
1618
1619 pub fn focus_out(&mut self) {
1620 if self.last_content.mode.contains(TermMode::FOCUS_IN_OUT) {
1621 self.write_to_pty("\x1b[O".as_bytes());
1622 }
1623 }
1624
1625 pub fn mouse_changed(&mut self, point: AlacPoint, side: AlacDirection) -> bool {
1626 match self.last_mouse {
1627 Some((old_point, old_side)) => {
1628 if old_point == point && old_side == side {
1629 false
1630 } else {
1631 self.last_mouse = Some((point, side));
1632 true
1633 }
1634 }
1635 None => {
1636 self.last_mouse = Some((point, side));
1637 true
1638 }
1639 }
1640 }
1641
1642 pub fn mouse_mode(&self, shift: bool) -> bool {
1643 self.last_content.mode.intersects(TermMode::MOUSE_MODE) && !shift
1644 }
1645
1646 pub fn mouse_move(&mut self, e: &MouseMoveEvent, cx: &mut Context<Self>) {
1647 let position = e.position - self.last_content.terminal_bounds.bounds.origin;
1648 if self.mouse_mode(e.modifiers.shift) {
1649 let (point, side) = grid_point_and_side(
1650 position,
1651 self.last_content.terminal_bounds,
1652 self.last_content.display_offset,
1653 );
1654
1655 if self.mouse_changed(point, side)
1656 && let Some(bytes) =
1657 mouse_moved_report(point, e.pressed_button, e.modifiers, self.last_content.mode)
1658 {
1659 self.write_to_pty(bytes);
1660 }
1661 } else if e.modifiers.secondary() {
1662 self.word_from_position(e.position);
1663 }
1664 cx.notify();
1665 }
1666
1667 fn word_from_position(&mut self, position: Point<Pixels>) {
1668 if self.selection_phase == SelectionPhase::Selecting {
1669 self.last_content.last_hovered_word = None;
1670 } else if self.last_content.terminal_bounds.bounds.contains(&position) {
1671 // Throttle hyperlink searches to avoid excessive processing
1672 let now = Instant::now();
1673 let should_search = if let Some(last_pos) = self.last_hyperlink_search_position {
1674 // Only search if mouse moved significantly or enough time passed
1675 let distance_moved =
1676 ((position.x - last_pos.x).abs() + (position.y - last_pos.y).abs()) > px(5.0);
1677 let time_elapsed = now.duration_since(self.last_mouse_move_time).as_millis() > 100;
1678 distance_moved || time_elapsed
1679 } else {
1680 true
1681 };
1682
1683 if should_search {
1684 self.last_mouse_move_time = now;
1685 self.last_hyperlink_search_position = Some(position);
1686 self.events.push_back(InternalEvent::FindHyperlink(
1687 position - self.last_content.terminal_bounds.bounds.origin,
1688 false,
1689 ));
1690 }
1691 } else {
1692 self.last_content.last_hovered_word = None;
1693 }
1694 }
1695
1696 pub fn select_word_at_event_position(&mut self, e: &MouseDownEvent) {
1697 let position = e.position - self.last_content.terminal_bounds.bounds.origin;
1698 let (point, side) = grid_point_and_side(
1699 position,
1700 self.last_content.terminal_bounds,
1701 self.last_content.display_offset,
1702 );
1703 let selection = Selection::new(SelectionType::Semantic, point, side);
1704 self.events
1705 .push_back(InternalEvent::SetSelection(Some((selection, point))));
1706 }
1707
1708 pub fn mouse_drag(
1709 &mut self,
1710 e: &MouseMoveEvent,
1711 region: Bounds<Pixels>,
1712 cx: &mut Context<Self>,
1713 ) {
1714 let position = e.position - self.last_content.terminal_bounds.bounds.origin;
1715 if !self.mouse_mode(e.modifiers.shift) {
1716 self.selection_phase = SelectionPhase::Selecting;
1717 // Alacritty has the same ordering, of first updating the selection
1718 // then scrolling 15ms later
1719 self.events
1720 .push_back(InternalEvent::UpdateSelection(position));
1721
1722 // Doesn't make sense to scroll the alt screen
1723 if !self.last_content.mode.contains(TermMode::ALT_SCREEN) {
1724 let scroll_lines = match self.drag_line_delta(e, region) {
1725 Some(value) => value,
1726 None => return,
1727 };
1728
1729 self.events
1730 .push_back(InternalEvent::Scroll(AlacScroll::Delta(scroll_lines)));
1731 }
1732
1733 cx.notify();
1734 }
1735 }
1736
1737 fn drag_line_delta(&self, e: &MouseMoveEvent, region: Bounds<Pixels>) -> Option<i32> {
1738 let top = region.origin.y;
1739 let bottom = region.bottom_left().y;
1740
1741 let scroll_lines = if e.position.y < top {
1742 let scroll_delta = (top - e.position.y).pow(1.1);
1743 (scroll_delta / self.last_content.terminal_bounds.line_height).ceil() as i32
1744 } else if e.position.y > bottom {
1745 let scroll_delta = -((e.position.y - bottom).pow(1.1));
1746 (scroll_delta / self.last_content.terminal_bounds.line_height).floor() as i32
1747 } else {
1748 return None;
1749 };
1750
1751 Some(scroll_lines.clamp(-3, 3))
1752 }
1753
1754 pub fn mouse_down(&mut self, e: &MouseDownEvent, _cx: &mut Context<Self>) {
1755 let position = e.position - self.last_content.terminal_bounds.bounds.origin;
1756 let point = grid_point(
1757 position,
1758 self.last_content.terminal_bounds,
1759 self.last_content.display_offset,
1760 );
1761
1762 if self.mouse_mode(e.modifiers.shift) {
1763 if let Some(bytes) =
1764 mouse_button_report(point, e.button, e.modifiers, true, self.last_content.mode)
1765 {
1766 self.write_to_pty(bytes);
1767 }
1768 } else {
1769 match e.button {
1770 MouseButton::Left => {
1771 let (point, side) = grid_point_and_side(
1772 position,
1773 self.last_content.terminal_bounds,
1774 self.last_content.display_offset,
1775 );
1776
1777 let selection_type = match e.click_count {
1778 0 => return, //This is a release
1779 1 => Some(SelectionType::Simple),
1780 2 => Some(SelectionType::Semantic),
1781 3 => Some(SelectionType::Lines),
1782 _ => None,
1783 };
1784
1785 if selection_type == Some(SelectionType::Simple) && e.modifiers.shift {
1786 self.events
1787 .push_back(InternalEvent::UpdateSelection(position));
1788 return;
1789 }
1790
1791 let selection = selection_type
1792 .map(|selection_type| Selection::new(selection_type, point, side));
1793
1794 if let Some(sel) = selection {
1795 self.events
1796 .push_back(InternalEvent::SetSelection(Some((sel, point))));
1797 }
1798 }
1799 #[cfg(any(target_os = "linux", target_os = "freebsd"))]
1800 MouseButton::Middle => {
1801 if let Some(item) = _cx.read_from_primary() {
1802 let text = item.text().unwrap_or_default();
1803 self.input(text.into_bytes());
1804 }
1805 }
1806 _ => {}
1807 }
1808 }
1809 }
1810
1811 pub fn mouse_up(&mut self, e: &MouseUpEvent, cx: &Context<Self>) {
1812 let setting = TerminalSettings::get_global(cx);
1813
1814 let position = e.position - self.last_content.terminal_bounds.bounds.origin;
1815 if self.mouse_mode(e.modifiers.shift) {
1816 let point = grid_point(
1817 position,
1818 self.last_content.terminal_bounds,
1819 self.last_content.display_offset,
1820 );
1821
1822 if let Some(bytes) =
1823 mouse_button_report(point, e.button, e.modifiers, false, self.last_content.mode)
1824 {
1825 self.write_to_pty(bytes);
1826 }
1827 } else {
1828 if e.button == MouseButton::Left && setting.copy_on_select {
1829 self.copy(Some(true));
1830 }
1831
1832 //Hyperlinks
1833 if self.selection_phase == SelectionPhase::Ended {
1834 let mouse_cell_index =
1835 content_index_for_mouse(position, &self.last_content.terminal_bounds);
1836 if let Some(link) = self.last_content.cells[mouse_cell_index].hyperlink() {
1837 cx.open_url(link.uri());
1838 } else if e.modifiers.secondary() {
1839 self.events
1840 .push_back(InternalEvent::FindHyperlink(position, true));
1841 }
1842 }
1843 }
1844
1845 self.selection_phase = SelectionPhase::Ended;
1846 self.last_mouse = None;
1847 }
1848
1849 ///Scroll the terminal
1850 pub fn scroll_wheel(&mut self, e: &ScrollWheelEvent) {
1851 let mouse_mode = self.mouse_mode(e.shift);
1852
1853 if let Some(scroll_lines) = self.determine_scroll_lines(e, mouse_mode) {
1854 if mouse_mode {
1855 let point = grid_point(
1856 e.position - self.last_content.terminal_bounds.bounds.origin,
1857 self.last_content.terminal_bounds,
1858 self.last_content.display_offset,
1859 );
1860
1861 if let Some(scrolls) = scroll_report(point, scroll_lines, e, self.last_content.mode)
1862 {
1863 for scroll in scrolls {
1864 self.write_to_pty(scroll);
1865 }
1866 };
1867 } else if self
1868 .last_content
1869 .mode
1870 .contains(TermMode::ALT_SCREEN | TermMode::ALTERNATE_SCROLL)
1871 && !e.shift
1872 {
1873 self.write_to_pty(alt_scroll(scroll_lines));
1874 } else if scroll_lines != 0 {
1875 let scroll = AlacScroll::Delta(scroll_lines);
1876
1877 self.events.push_back(InternalEvent::Scroll(scroll));
1878 }
1879 }
1880 }
1881
1882 fn refresh_hovered_word(&mut self, window: &Window) {
1883 self.word_from_position(window.mouse_position());
1884 }
1885
1886 fn determine_scroll_lines(&mut self, e: &ScrollWheelEvent, mouse_mode: bool) -> Option<i32> {
1887 let scroll_multiplier = if mouse_mode { 1. } else { SCROLL_MULTIPLIER };
1888 let line_height = self.last_content.terminal_bounds.line_height;
1889 match e.touch_phase {
1890 /* Reset scroll state on started */
1891 TouchPhase::Started => {
1892 self.scroll_px = px(0.);
1893 None
1894 }
1895 /* Calculate the appropriate scroll lines */
1896 TouchPhase::Moved => {
1897 let old_offset = (self.scroll_px / line_height) as i32;
1898
1899 self.scroll_px += e.delta.pixel_delta(line_height).y * scroll_multiplier;
1900
1901 let new_offset = (self.scroll_px / line_height) as i32;
1902
1903 // Whenever we hit the edges, reset our stored scroll to 0
1904 // so we can respond to changes in direction quickly
1905 self.scroll_px %= self.last_content.terminal_bounds.height();
1906
1907 Some(new_offset - old_offset)
1908 }
1909 TouchPhase::Ended => None,
1910 }
1911 }
1912
1913 pub fn find_matches(
1914 &self,
1915 mut searcher: RegexSearch,
1916 cx: &Context<Self>,
1917 ) -> Task<Vec<RangeInclusive<AlacPoint>>> {
1918 let term = self.term.clone();
1919 cx.background_spawn(async move {
1920 let term = term.lock();
1921
1922 all_search_matches(&term, &mut searcher).collect()
1923 })
1924 }
1925
1926 pub fn working_directory(&self) -> Option<PathBuf> {
1927 if self.is_ssh_terminal {
1928 // We can't yet reliably detect the working directory of a shell on the
1929 // SSH host. Until we can do that, it doesn't make sense to display
1930 // the working directory on the client and persist that.
1931 None
1932 } else {
1933 self.client_side_working_directory()
1934 }
1935 }
1936
1937 /// Returns the working directory of the process that's connected to the PTY.
1938 /// That means it returns the working directory of the local shell or program
1939 /// that's running inside the terminal.
1940 ///
1941 /// This does *not* return the working directory of the shell that runs on the
1942 /// remote host, in case Zed is connected to a remote host.
1943 fn client_side_working_directory(&self) -> Option<PathBuf> {
1944 match &self.terminal_type {
1945 TerminalType::Pty { info, .. } => {
1946 info.current.as_ref().map(|process| process.cwd.clone())
1947 }
1948 TerminalType::DisplayOnly => None,
1949 }
1950 }
1951
1952 pub fn title(&self, truncate: bool) -> String {
1953 const MAX_CHARS: usize = 25;
1954 match &self.task {
1955 Some(task_state) => {
1956 if truncate {
1957 truncate_and_trailoff(&task_state.spawned_task.label, MAX_CHARS)
1958 } else {
1959 task_state.spawned_task.full_label.clone()
1960 }
1961 }
1962 None => self
1963 .title_override
1964 .as_ref()
1965 .map(|title_override| title_override.to_string())
1966 .unwrap_or_else(|| match &self.terminal_type {
1967 TerminalType::Pty { info, .. } => info
1968 .current
1969 .as_ref()
1970 .map(|fpi| {
1971 let process_file = fpi
1972 .cwd
1973 .file_name()
1974 .map(|name| name.to_string_lossy().into_owned())
1975 .unwrap_or_default();
1976
1977 let argv = fpi.argv.as_slice();
1978 let process_name = format!(
1979 "{}{}",
1980 fpi.name,
1981 if !argv.is_empty() {
1982 format!(" {}", (argv[1..]).join(" "))
1983 } else {
1984 "".to_string()
1985 }
1986 );
1987 let (process_file, process_name) = if truncate {
1988 (
1989 truncate_and_trailoff(&process_file, MAX_CHARS),
1990 truncate_and_trailoff(&process_name, MAX_CHARS),
1991 )
1992 } else {
1993 (process_file, process_name)
1994 };
1995 format!("{process_file} — {process_name}")
1996 })
1997 .unwrap_or_else(|| "Terminal".to_string()),
1998 TerminalType::DisplayOnly => "Terminal".to_string(),
1999 }),
2000 }
2001 }
2002
2003 pub fn kill_active_task(&mut self) {
2004 if let Some(task) = self.task()
2005 && task.status == TaskStatus::Running
2006 {
2007 if let TerminalType::Pty { info, .. } = &mut self.terminal_type {
2008 info.kill_current_process();
2009 }
2010 }
2011 }
2012
2013 pub fn pid(&self) -> Option<sysinfo::Pid> {
2014 match &self.terminal_type {
2015 TerminalType::Pty { info, .. } => info.pid(),
2016 TerminalType::DisplayOnly => None,
2017 }
2018 }
2019
2020 pub fn pid_getter(&self) -> Option<&ProcessIdGetter> {
2021 match &self.terminal_type {
2022 TerminalType::Pty { info, .. } => Some(info.pid_getter()),
2023 TerminalType::DisplayOnly => None,
2024 }
2025 }
2026
2027 pub fn task(&self) -> Option<&TaskState> {
2028 self.task.as_ref()
2029 }
2030
2031 pub fn wait_for_completed_task(&self, cx: &App) -> Task<Option<ExitStatus>> {
2032 if let Some(task) = self.task() {
2033 if task.status == TaskStatus::Running {
2034 let completion_receiver = task.completion_rx.clone();
2035 return cx.spawn(async move |_| completion_receiver.recv().await.ok().flatten());
2036 } else if let Ok(status) = task.completion_rx.try_recv() {
2037 return Task::ready(status);
2038 }
2039 }
2040 Task::ready(None)
2041 }
2042
2043 fn register_task_finished(&mut self, error_code: Option<i32>, cx: &mut Context<Terminal>) {
2044 let e: Option<ExitStatus> = error_code.map(|code| {
2045 #[cfg(unix)]
2046 {
2047 std::os::unix::process::ExitStatusExt::from_raw(code)
2048 }
2049 #[cfg(windows)]
2050 {
2051 std::os::windows::process::ExitStatusExt::from_raw(code as u32)
2052 }
2053 });
2054
2055 if let Some(tx) = &self.completion_tx {
2056 tx.try_send(e).ok();
2057 }
2058 if let Some(e) = e {
2059 self.child_exited = Some(e);
2060 }
2061 let task = match &mut self.task {
2062 Some(task) => task,
2063 None => {
2064 if self.child_exited.is_none_or(|e| e.code() == Some(0)) {
2065 cx.emit(Event::CloseTerminal);
2066 }
2067 return;
2068 }
2069 };
2070 if task.status != TaskStatus::Running {
2071 return;
2072 }
2073 match error_code {
2074 Some(error_code) => {
2075 task.status.register_task_exit(error_code);
2076 }
2077 None => {
2078 task.status.register_terminal_exit();
2079 }
2080 };
2081
2082 let (finished_successfully, task_line, command_line) = task_summary(task, error_code);
2083 let mut lines_to_show = Vec::new();
2084 if task.spawned_task.show_summary {
2085 lines_to_show.push(task_line.as_str());
2086 }
2087 if task.spawned_task.show_command {
2088 lines_to_show.push(command_line.as_str());
2089 }
2090
2091 if !lines_to_show.is_empty() {
2092 // SAFETY: the invocation happens on non `TaskStatus::Running` tasks, once,
2093 // after either `AlacTermEvent::Exit` or `AlacTermEvent::ChildExit` events that are spawned
2094 // when Zed task finishes and no more output is made.
2095 // After the task summary is output once, no more text is appended to the terminal.
2096 unsafe { append_text_to_term(&mut self.term.lock(), &lines_to_show) };
2097 }
2098
2099 match task.spawned_task.hide {
2100 HideStrategy::Never => {}
2101 HideStrategy::Always => {
2102 cx.emit(Event::CloseTerminal);
2103 }
2104 HideStrategy::OnSuccess => {
2105 if finished_successfully {
2106 cx.emit(Event::CloseTerminal);
2107 }
2108 }
2109 }
2110 }
2111
2112 pub fn vi_mode_enabled(&self) -> bool {
2113 self.vi_mode_enabled
2114 }
2115
2116 pub fn clone_builder(
2117 &self,
2118 cx: &App,
2119 cwd: impl FnOnce() -> Option<PathBuf>,
2120 ) -> Result<TerminalBuilder> {
2121 let working_directory = self.working_directory().or_else(cwd);
2122 TerminalBuilder::new(
2123 working_directory,
2124 None,
2125 self.template.shell.clone(),
2126 self.template.env.clone(),
2127 self.template.cursor_shape,
2128 self.template.alternate_scroll,
2129 self.template.max_scroll_history_lines,
2130 self.is_ssh_terminal,
2131 self.template.window_id,
2132 None,
2133 cx,
2134 self.activation_script.clone(),
2135 )
2136 }
2137}
2138
2139// Helper function to convert a grid row to a string
2140pub fn row_to_string(row: &Row<Cell>) -> String {
2141 row[..Column(row.len())]
2142 .iter()
2143 .map(|cell| cell.c)
2144 .collect::<String>()
2145}
2146
2147const TASK_DELIMITER: &str = "⏵ ";
2148fn task_summary(task: &TaskState, error_code: Option<i32>) -> (bool, String, String) {
2149 let escaped_full_label = task
2150 .spawned_task
2151 .full_label
2152 .replace("\r\n", "\r")
2153 .replace('\n', "\r");
2154 let (success, task_line) = match error_code {
2155 Some(0) => (
2156 true,
2157 format!("{TASK_DELIMITER}Task `{escaped_full_label}` finished successfully"),
2158 ),
2159 Some(error_code) => (
2160 false,
2161 format!(
2162 "{TASK_DELIMITER}Task `{escaped_full_label}` finished with non-zero error code: {error_code}"
2163 ),
2164 ),
2165 None => (
2166 false,
2167 format!("{TASK_DELIMITER}Task `{escaped_full_label}` finished"),
2168 ),
2169 };
2170 let escaped_command_label = task
2171 .spawned_task
2172 .command_label
2173 .replace("\r\n", "\r")
2174 .replace('\n', "\r");
2175 let command_line = format!("{TASK_DELIMITER}Command: {escaped_command_label}");
2176 (success, task_line, command_line)
2177}
2178
2179/// Appends a stringified task summary to the terminal, after its output.
2180///
2181/// SAFETY: This function should only be called after terminal's PTY is no longer alive.
2182/// New text being added to the terminal here, uses "less public" APIs,
2183/// which are not maintaining the entire terminal state intact.
2184///
2185///
2186/// The library
2187///
2188/// * does not increment inner grid cursor's _lines_ on `input` calls
2189/// (but displaying the lines correctly and incrementing cursor's columns)
2190///
2191/// * ignores `\n` and \r` character input, requiring the `newline` call instead
2192///
2193/// * does not alter grid state after `newline` call
2194/// so its `bottommost_line` is always the same additions, and
2195/// the cursor's `point` is not updated to the new line and column values
2196///
2197/// * ??? there could be more consequences, and any further "proper" streaming from the PTY might bug and/or panic.
2198/// Still, subsequent `append_text_to_term` invocations are possible and display the contents correctly.
2199///
2200/// Despite the quirks, this is the simplest approach to appending text to the terminal: its alternative, `grid_mut` manipulations,
2201/// do not properly set the scrolling state and display odd text after appending; also those manipulations are more tedious and error-prone.
2202/// The function achieves proper display and scrolling capabilities, at a cost of grid state not properly synchronized.
2203/// This is enough for printing moderately-sized texts like task summaries, but might break or perform poorly for larger texts.
2204unsafe fn append_text_to_term(term: &mut Term<ZedListener>, text_lines: &[&str]) {
2205 term.newline();
2206 term.grid_mut().cursor.point.column = Column(0);
2207 for line in text_lines {
2208 for c in line.chars() {
2209 term.input(c);
2210 }
2211 term.newline();
2212 term.grid_mut().cursor.point.column = Column(0);
2213 }
2214}
2215
2216impl Drop for Terminal {
2217 fn drop(&mut self) {
2218 if let TerminalType::Pty { pty_tx, .. } = &self.terminal_type {
2219 pty_tx.0.send(Msg::Shutdown).ok();
2220 }
2221 }
2222}
2223
2224impl EventEmitter<Event> for Terminal {}
2225
2226fn make_selection(range: &RangeInclusive<AlacPoint>) -> Selection {
2227 let mut selection = Selection::new(SelectionType::Simple, *range.start(), AlacDirection::Left);
2228 selection.update(*range.end(), AlacDirection::Right);
2229 selection
2230}
2231
2232fn all_search_matches<'a, T>(
2233 term: &'a Term<T>,
2234 regex: &'a mut RegexSearch,
2235) -> impl Iterator<Item = Match> + 'a {
2236 let start = AlacPoint::new(term.grid().topmost_line(), Column(0));
2237 let end = AlacPoint::new(term.grid().bottommost_line(), term.grid().last_column());
2238 RegexIter::new(start, end, AlacDirection::Right, term, regex)
2239}
2240
2241fn content_index_for_mouse(pos: Point<Pixels>, terminal_bounds: &TerminalBounds) -> usize {
2242 let col = (pos.x / terminal_bounds.cell_width()).round() as usize;
2243 let clamped_col = min(col, terminal_bounds.columns() - 1);
2244 let row = (pos.y / terminal_bounds.line_height()).round() as usize;
2245 let clamped_row = min(row, terminal_bounds.screen_lines() - 1);
2246 clamped_row * terminal_bounds.columns() + clamped_col
2247}
2248
2249/// Converts an 8 bit ANSI color to its GPUI equivalent.
2250/// Accepts `usize` for compatibility with the `alacritty::Colors` interface,
2251/// Other than that use case, should only be called with values in the `[0,255]` range
2252pub fn get_color_at_index(index: usize, theme: &Theme) -> Hsla {
2253 let colors = theme.colors();
2254
2255 match index {
2256 // 0-15 are the same as the named colors above
2257 0 => colors.terminal_ansi_black,
2258 1 => colors.terminal_ansi_red,
2259 2 => colors.terminal_ansi_green,
2260 3 => colors.terminal_ansi_yellow,
2261 4 => colors.terminal_ansi_blue,
2262 5 => colors.terminal_ansi_magenta,
2263 6 => colors.terminal_ansi_cyan,
2264 7 => colors.terminal_ansi_white,
2265 8 => colors.terminal_ansi_bright_black,
2266 9 => colors.terminal_ansi_bright_red,
2267 10 => colors.terminal_ansi_bright_green,
2268 11 => colors.terminal_ansi_bright_yellow,
2269 12 => colors.terminal_ansi_bright_blue,
2270 13 => colors.terminal_ansi_bright_magenta,
2271 14 => colors.terminal_ansi_bright_cyan,
2272 15 => colors.terminal_ansi_bright_white,
2273 // 16-231 are a 6x6x6 RGB color cube, mapped to 0-255 using steps defined by XTerm.
2274 // See: https://github.com/xterm-x11/xterm-snapshots/blob/master/256colres.pl
2275 16..=231 => {
2276 let (r, g, b) = rgb_for_index(index as u8);
2277 rgba_color(
2278 if r == 0 { 0 } else { r * 40 + 55 },
2279 if g == 0 { 0 } else { g * 40 + 55 },
2280 if b == 0 { 0 } else { b * 40 + 55 },
2281 )
2282 }
2283 // 232-255 are a 24-step grayscale ramp from (8, 8, 8) to (238, 238, 238).
2284 232..=255 => {
2285 let i = index as u8 - 232; // Align index to 0..24
2286 let value = i * 10 + 8;
2287 rgba_color(value, value, value)
2288 }
2289 // For compatibility with the alacritty::Colors interface
2290 // See: https://github.com/alacritty/alacritty/blob/master/alacritty_terminal/src/term/color.rs
2291 256 => colors.terminal_foreground,
2292 257 => colors.terminal_background,
2293 258 => theme.players().local().cursor,
2294 259 => colors.terminal_ansi_dim_black,
2295 260 => colors.terminal_ansi_dim_red,
2296 261 => colors.terminal_ansi_dim_green,
2297 262 => colors.terminal_ansi_dim_yellow,
2298 263 => colors.terminal_ansi_dim_blue,
2299 264 => colors.terminal_ansi_dim_magenta,
2300 265 => colors.terminal_ansi_dim_cyan,
2301 266 => colors.terminal_ansi_dim_white,
2302 267 => colors.terminal_bright_foreground,
2303 268 => colors.terminal_ansi_black, // 'Dim Background', non-standard color
2304
2305 _ => black(),
2306 }
2307}
2308
2309/// Generates the RGB channels in [0, 5] for a given index into the 6x6x6 ANSI color cube.
2310///
2311/// See: [8 bit ANSI color](https://en.wikipedia.org/wiki/ANSI_escape_code#8-bit).
2312///
2313/// Wikipedia gives a formula for calculating the index for a given color:
2314///
2315/// ```text
2316/// index = 16 + 36 × r + 6 × g + b (0 ≤ r, g, b ≤ 5)
2317/// ```
2318///
2319/// This function does the reverse, calculating the `r`, `g`, and `b` components from a given index.
2320fn rgb_for_index(i: u8) -> (u8, u8, u8) {
2321 debug_assert!((16..=231).contains(&i));
2322 let i = i - 16;
2323 let r = (i - (i % 36)) / 36;
2324 let g = ((i % 36) - (i % 6)) / 6;
2325 let b = (i % 36) % 6;
2326 (r, g, b)
2327}
2328
2329pub fn rgba_color(r: u8, g: u8, b: u8) -> Hsla {
2330 Rgba {
2331 r: (r as f32 / 255.),
2332 g: (g as f32 / 255.),
2333 b: (b as f32 / 255.),
2334 a: 1.,
2335 }
2336 .into()
2337}
2338
2339#[cfg(test)]
2340mod tests {
2341 use std::time::Duration;
2342
2343 use super::*;
2344 use crate::{
2345 IndexedCell, TerminalBounds, TerminalBuilder, TerminalContent, content_index_for_mouse,
2346 rgb_for_index,
2347 };
2348 use alacritty_terminal::{
2349 index::{Column, Line, Point as AlacPoint},
2350 term::cell::Cell,
2351 };
2352 use collections::HashMap;
2353 use gpui::{Pixels, Point, TestAppContext, bounds, point, size, smol_timeout};
2354 use rand::{Rng, distr, rngs::ThreadRng};
2355 use task::ShellBuilder;
2356
2357 #[gpui::test]
2358 async fn test_basic_terminal(cx: &mut TestAppContext) {
2359 cx.executor().allow_parking();
2360
2361 let (completion_tx, completion_rx) = smol::channel::unbounded();
2362 let (program, args) =
2363 ShellBuilder::new(&Shell::System).build(Some("echo".to_owned()), &["hello".to_owned()]);
2364 let terminal = cx.new(|cx| {
2365 TerminalBuilder::new(
2366 None,
2367 None,
2368 task::Shell::WithArguments {
2369 program,
2370 args,
2371 title_override: None,
2372 },
2373 HashMap::default(),
2374 CursorShape::default(),
2375 AlternateScroll::On,
2376 None,
2377 false,
2378 0,
2379 Some(completion_tx),
2380 cx,
2381 vec![],
2382 )
2383 .unwrap()
2384 .subscribe(cx)
2385 });
2386 assert_eq!(
2387 completion_rx.recv().await.unwrap(),
2388 Some(ExitStatus::default())
2389 );
2390 assert_eq!(
2391 terminal.update(cx, |term, _| term.get_content()).trim(),
2392 "hello"
2393 );
2394
2395 // Inject additional output directly into the emulator (display-only path)
2396 terminal.update(cx, |term, cx| {
2397 term.write_output(b"\nfrom_injection", cx);
2398 });
2399
2400 let content_after = terminal.update(cx, |term, _| term.get_content());
2401 assert!(
2402 content_after.contains("from_injection"),
2403 "expected injected output to appear, got: {content_after}"
2404 );
2405 }
2406
2407 // TODO should be tested on Linux too, but does not work there well
2408 #[cfg(target_os = "macos")]
2409 #[gpui::test(iterations = 10)]
2410 async fn test_terminal_eof(cx: &mut TestAppContext) {
2411 cx.executor().allow_parking();
2412
2413 let (completion_tx, completion_rx) = smol::channel::unbounded();
2414 // Build an empty command, which will result in a tty shell spawned.
2415 let terminal = cx.new(|cx| {
2416 TerminalBuilder::new(
2417 None,
2418 None,
2419 task::Shell::System,
2420 HashMap::default(),
2421 CursorShape::default(),
2422 AlternateScroll::On,
2423 None,
2424 false,
2425 0,
2426 Some(completion_tx),
2427 cx,
2428 Vec::new(),
2429 )
2430 .unwrap()
2431 .subscribe(cx)
2432 });
2433
2434 let (event_tx, event_rx) = smol::channel::unbounded::<Event>();
2435 cx.update(|cx| {
2436 cx.subscribe(&terminal, move |_, e, _| {
2437 event_tx.send_blocking(e.clone()).unwrap();
2438 })
2439 })
2440 .detach();
2441 cx.background_spawn(async move {
2442 assert_eq!(
2443 completion_rx.recv().await.unwrap(),
2444 Some(ExitStatus::default()),
2445 "EOF should result in the tty shell exiting successfully",
2446 );
2447 })
2448 .detach();
2449
2450 let first_event = Event::Wakeup;
2451 let wakeup = event_rx.recv().await.expect("No wakeup event received");
2452 assert_eq!(wakeup, first_event, "Expected wakeup, got {wakeup:?}");
2453
2454 terminal.update(cx, |terminal, _| {
2455 let success = terminal.try_keystroke(&Keystroke::parse("ctrl-c").unwrap(), false);
2456 assert!(success, "Should have registered ctrl-c sequence");
2457 });
2458 terminal.update(cx, |terminal, _| {
2459 let success = terminal.try_keystroke(&Keystroke::parse("ctrl-d").unwrap(), false);
2460 assert!(success, "Should have registered ctrl-d sequence");
2461 });
2462
2463 let mut all_events = vec![first_event];
2464 while let Ok(Ok(new_event)) = smol_timeout(Duration::from_secs(1), event_rx.recv()).await {
2465 all_events.push(new_event.clone());
2466 if new_event == Event::CloseTerminal {
2467 break;
2468 }
2469 }
2470 assert!(
2471 all_events.contains(&Event::CloseTerminal),
2472 "EOF command sequence should have triggered a TTY terminal exit, but got events: {all_events:?}",
2473 );
2474 }
2475
2476 #[gpui::test(iterations = 10)]
2477 async fn test_terminal_no_exit_on_spawn_failure(cx: &mut TestAppContext) {
2478 cx.executor().allow_parking();
2479
2480 let (completion_tx, completion_rx) = smol::channel::unbounded();
2481 let (program, args) = ShellBuilder::new(&Shell::System)
2482 .build(Some("asdasdasdasd".to_owned()), &["@@@@@".to_owned()]);
2483 let terminal = cx.new(|cx| {
2484 TerminalBuilder::new(
2485 None,
2486 None,
2487 task::Shell::WithArguments {
2488 program,
2489 args,
2490 title_override: None,
2491 },
2492 HashMap::default(),
2493 CursorShape::default(),
2494 AlternateScroll::On,
2495 None,
2496 false,
2497 0,
2498 Some(completion_tx),
2499 cx,
2500 Vec::new(),
2501 )
2502 .unwrap()
2503 .subscribe(cx)
2504 });
2505
2506 let (event_tx, event_rx) = smol::channel::unbounded::<Event>();
2507 cx.update(|cx| {
2508 cx.subscribe(&terminal, move |_, e, _| {
2509 event_tx.send_blocking(e.clone()).unwrap();
2510 })
2511 })
2512 .detach();
2513 cx.background_spawn(async move {
2514 #[cfg(target_os = "windows")]
2515 {
2516 let exit_status = completion_rx.recv().await.ok().flatten();
2517 if let Some(exit_status) = exit_status {
2518 assert!(
2519 !exit_status.success(),
2520 "Wrong shell command should result in a failure"
2521 );
2522 assert_eq!(exit_status.code(), Some(1));
2523 }
2524 }
2525 #[cfg(not(target_os = "windows"))]
2526 {
2527 let exit_status = completion_rx.recv().await.unwrap().unwrap();
2528 assert!(
2529 !exit_status.success(),
2530 "Wrong shell command should result in a failure"
2531 );
2532 assert_eq!(exit_status.code(), None);
2533 }
2534 })
2535 .detach();
2536
2537 let mut all_events = Vec::new();
2538 while let Ok(Ok(new_event)) =
2539 smol_timeout(Duration::from_millis(500), event_rx.recv()).await
2540 {
2541 all_events.push(new_event.clone());
2542 }
2543
2544 assert!(
2545 !all_events
2546 .iter()
2547 .any(|event| event == &Event::CloseTerminal),
2548 "Wrong shell command should update the title but not should not close the terminal to show the error message, but got events: {all_events:?}",
2549 );
2550 }
2551
2552 #[test]
2553 fn test_rgb_for_index() {
2554 // Test every possible value in the color cube.
2555 for i in 16..=231 {
2556 let (r, g, b) = rgb_for_index(i);
2557 assert_eq!(i, 16 + 36 * r + 6 * g + b);
2558 }
2559 }
2560
2561 #[test]
2562 fn test_mouse_to_cell_test() {
2563 let mut rng = rand::rng();
2564 const ITERATIONS: usize = 10;
2565 const PRECISION: usize = 1000;
2566
2567 for _ in 0..ITERATIONS {
2568 let viewport_cells = rng.random_range(15..20);
2569 let cell_size =
2570 rng.random_range(5 * PRECISION..20 * PRECISION) as f32 / PRECISION as f32;
2571
2572 let size = crate::TerminalBounds {
2573 cell_width: Pixels::from(cell_size),
2574 line_height: Pixels::from(cell_size),
2575 bounds: bounds(
2576 Point::default(),
2577 size(
2578 Pixels::from(cell_size * (viewport_cells as f32)),
2579 Pixels::from(cell_size * (viewport_cells as f32)),
2580 ),
2581 ),
2582 };
2583
2584 let cells = get_cells(size, &mut rng);
2585 let content = convert_cells_to_content(size, &cells);
2586
2587 for row in 0..(viewport_cells - 1) {
2588 let row = row as usize;
2589 for col in 0..(viewport_cells - 1) {
2590 let col = col as usize;
2591
2592 let row_offset = rng.random_range(0..PRECISION) as f32 / PRECISION as f32;
2593 let col_offset = rng.random_range(0..PRECISION) as f32 / PRECISION as f32;
2594
2595 let mouse_pos = point(
2596 Pixels::from(col as f32 * cell_size + col_offset),
2597 Pixels::from(row as f32 * cell_size + row_offset),
2598 );
2599
2600 let content_index =
2601 content_index_for_mouse(mouse_pos, &content.terminal_bounds);
2602 let mouse_cell = content.cells[content_index].c;
2603 let real_cell = cells[row][col];
2604
2605 assert_eq!(mouse_cell, real_cell);
2606 }
2607 }
2608 }
2609 }
2610
2611 #[test]
2612 fn test_mouse_to_cell_clamp() {
2613 let mut rng = rand::rng();
2614
2615 let size = crate::TerminalBounds {
2616 cell_width: Pixels::from(10.),
2617 line_height: Pixels::from(10.),
2618 bounds: bounds(
2619 Point::default(),
2620 size(Pixels::from(100.), Pixels::from(100.)),
2621 ),
2622 };
2623
2624 let cells = get_cells(size, &mut rng);
2625 let content = convert_cells_to_content(size, &cells);
2626
2627 assert_eq!(
2628 content.cells[content_index_for_mouse(
2629 point(Pixels::from(-10.), Pixels::from(-10.)),
2630 &content.terminal_bounds,
2631 )]
2632 .c,
2633 cells[0][0]
2634 );
2635 assert_eq!(
2636 content.cells[content_index_for_mouse(
2637 point(Pixels::from(1000.), Pixels::from(1000.)),
2638 &content.terminal_bounds,
2639 )]
2640 .c,
2641 cells[9][9]
2642 );
2643 }
2644
2645 fn get_cells(size: TerminalBounds, rng: &mut ThreadRng) -> Vec<Vec<char>> {
2646 let mut cells = Vec::new();
2647
2648 for _ in 0..((size.height() / size.line_height()) as usize) {
2649 let mut row_vec = Vec::new();
2650 for _ in 0..((size.width() / size.cell_width()) as usize) {
2651 let cell_char = rng.sample(distr::Alphanumeric) as char;
2652 row_vec.push(cell_char)
2653 }
2654 cells.push(row_vec)
2655 }
2656
2657 cells
2658 }
2659
2660 fn convert_cells_to_content(
2661 terminal_bounds: TerminalBounds,
2662 cells: &[Vec<char>],
2663 ) -> TerminalContent {
2664 let mut ic = Vec::new();
2665
2666 for (index, row) in cells.iter().enumerate() {
2667 for (cell_index, cell_char) in row.iter().enumerate() {
2668 ic.push(IndexedCell {
2669 point: AlacPoint::new(Line(index as i32), Column(cell_index)),
2670 cell: Cell {
2671 c: *cell_char,
2672 ..Default::default()
2673 },
2674 });
2675 }
2676 }
2677
2678 TerminalContent {
2679 cells: ic,
2680 terminal_bounds,
2681 ..Default::default()
2682 }
2683 }
2684}