terminal.rs

  1use agent_client_protocol as acp;
  2use anyhow::Result;
  3use futures::{FutureExt as _, future::Shared};
  4use gpui::{App, AppContext, AsyncApp, Context, Entity, Task};
  5use language::LanguageRegistry;
  6use markdown::Markdown;
  7use project::Project;
  8use std::{path::PathBuf, process::ExitStatus, sync::Arc, time::Instant};
  9use task::Shell;
 10use util::get_default_system_shell_preferring_bash;
 11
 12pub struct Terminal {
 13    id: acp::TerminalId,
 14    command: Entity<Markdown>,
 15    working_dir: Option<PathBuf>,
 16    terminal: Entity<terminal::Terminal>,
 17    started_at: Instant,
 18    output: Option<TerminalOutput>,
 19    output_byte_limit: Option<usize>,
 20    _output_task: Shared<Task<acp::TerminalExitStatus>>,
 21}
 22
 23pub struct TerminalOutput {
 24    pub ended_at: Instant,
 25    pub exit_status: Option<ExitStatus>,
 26    pub content: String,
 27    pub original_content_len: usize,
 28    pub content_line_count: usize,
 29}
 30
 31impl Terminal {
 32    pub fn new(
 33        id: acp::TerminalId,
 34        command_label: &str,
 35        working_dir: Option<PathBuf>,
 36        output_byte_limit: Option<usize>,
 37        terminal: Entity<terminal::Terminal>,
 38        language_registry: Arc<LanguageRegistry>,
 39        cx: &mut Context<Self>,
 40    ) -> Self {
 41        let command_task = terminal.read(cx).wait_for_completed_task(cx);
 42        Self {
 43            id,
 44            command: cx.new(|cx| {
 45                Markdown::new(
 46                    format!("```\n{}\n```", command_label).into(),
 47                    Some(language_registry.clone()),
 48                    None,
 49                    cx,
 50                )
 51            }),
 52            working_dir,
 53            terminal,
 54            started_at: Instant::now(),
 55            output: None,
 56            output_byte_limit,
 57            _output_task: cx
 58                .spawn(async move |this, cx| {
 59                    let exit_status = command_task.await;
 60
 61                    this.update(cx, |this, cx| {
 62                        let (content, original_content_len) = this.truncated_output(cx);
 63                        let content_line_count = this.terminal.read(cx).total_lines();
 64
 65                        this.output = Some(TerminalOutput {
 66                            ended_at: Instant::now(),
 67                            exit_status,
 68                            content,
 69                            original_content_len,
 70                            content_line_count,
 71                        });
 72                        cx.notify();
 73                    })
 74                    .ok();
 75
 76                    let exit_status = exit_status.map(portable_pty::ExitStatus::from);
 77
 78                    acp::TerminalExitStatus {
 79                        exit_code: exit_status.as_ref().map(|e| e.exit_code()),
 80                        signal: exit_status.and_then(|e| e.signal().map(Into::into)),
 81                        meta: None,
 82                    }
 83                })
 84                .shared(),
 85        }
 86    }
 87
 88    pub fn id(&self) -> &acp::TerminalId {
 89        &self.id
 90    }
 91
 92    pub fn wait_for_exit(&self) -> Shared<Task<acp::TerminalExitStatus>> {
 93        self._output_task.clone()
 94    }
 95
 96    pub fn kill(&mut self, cx: &mut App) {
 97        self.terminal.update(cx, |terminal, _cx| {
 98            terminal.kill_active_task();
 99        });
100    }
101
102    pub fn current_output(&self, cx: &App) -> acp::TerminalOutputResponse {
103        if let Some(output) = self.output.as_ref() {
104            let exit_status = output.exit_status.map(portable_pty::ExitStatus::from);
105
106            acp::TerminalOutputResponse {
107                output: output.content.clone(),
108                truncated: output.original_content_len > output.content.len(),
109                exit_status: Some(acp::TerminalExitStatus {
110                    exit_code: exit_status.as_ref().map(|e| e.exit_code()),
111                    signal: exit_status.and_then(|e| e.signal().map(Into::into)),
112                    meta: None,
113                }),
114                meta: None,
115            }
116        } else {
117            let (current_content, original_len) = self.truncated_output(cx);
118
119            acp::TerminalOutputResponse {
120                truncated: current_content.len() < original_len,
121                output: current_content,
122                exit_status: None,
123                meta: None,
124            }
125        }
126    }
127
128    fn truncated_output(&self, cx: &App) -> (String, usize) {
129        let terminal = self.terminal.read(cx);
130        let mut content = terminal.get_content();
131
132        let original_content_len = content.len();
133
134        if let Some(limit) = self.output_byte_limit
135            && content.len() > limit
136        {
137            let mut end_ix = limit.min(content.len());
138            while !content.is_char_boundary(end_ix) {
139                end_ix -= 1;
140            }
141            // Don't truncate mid-line, clear the remainder of the last line
142            end_ix = content[..end_ix].rfind('\n').unwrap_or(end_ix);
143            content.truncate(end_ix);
144        }
145
146        (content, original_content_len)
147    }
148
149    pub fn command(&self) -> &Entity<Markdown> {
150        &self.command
151    }
152
153    pub fn working_dir(&self) -> &Option<PathBuf> {
154        &self.working_dir
155    }
156
157    pub fn started_at(&self) -> Instant {
158        self.started_at
159    }
160
161    pub fn output(&self) -> Option<&TerminalOutput> {
162        self.output.as_ref()
163    }
164
165    pub fn inner(&self) -> &Entity<terminal::Terminal> {
166        &self.terminal
167    }
168
169    pub fn to_markdown(&self, cx: &App) -> String {
170        format!(
171            "Terminal:\n```\n{}\n```\n",
172            self.terminal.read(cx).get_content()
173        )
174    }
175}
176
177pub async fn create_terminal_entity(
178    command: String,
179    args: &[String],
180    env_vars: Vec<(String, String)>,
181    cwd: Option<PathBuf>,
182    project: &Entity<Project>,
183    cx: &mut AsyncApp,
184) -> Result<Entity<terminal::Terminal>> {
185    let mut env = if let Some(dir) = &cwd {
186        project
187            .update(cx, |project, cx| {
188                project.environment().update(cx, |env, cx| {
189                    env.directory_environment(dir.clone().into(), cx)
190                })
191            })?
192            .await
193            .unwrap_or_default()
194    } else {
195        Default::default()
196    };
197
198    // Disables paging for `git` and hopefully other commands
199    env.insert("PAGER".into(), "".into());
200    env.extend(env_vars);
201
202    // Use remote shell or default system shell, as appropriate
203    let shell = project
204        .update(cx, |project, cx| {
205            project
206                .remote_client()
207                .and_then(|r| r.read(cx).default_system_shell())
208                .map(Shell::Program)
209        })?
210        .unwrap_or_else(|| Shell::Program(get_default_system_shell_preferring_bash()));
211    let is_windows = project
212        .read_with(cx, |project, cx| project.path_style(cx).is_windows())
213        .unwrap_or(cfg!(windows));
214    let (task_command, task_args) = task::ShellBuilder::new(&shell, is_windows)
215        .redirect_stdin_to_dev_null()
216        .build(Some(command.clone()), &args);
217
218    project
219        .update(cx, |project, cx| {
220            project.create_terminal_task(
221                task::SpawnInTerminal {
222                    command: Some(task_command),
223                    args: task_args,
224                    cwd,
225                    env,
226                    ..Default::default()
227                },
228                cx,
229            )
230        })?
231        .await
232}