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