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::new()
 79                        .exit_code(exit_status.as_ref().map(|e| e.exit_code()))
 80                        .signal(exit_status.and_then(|e| e.signal().map(ToOwned::to_owned)))
 81                })
 82                .shared(),
 83        }
 84    }
 85
 86    pub fn id(&self) -> &acp::TerminalId {
 87        &self.id
 88    }
 89
 90    pub fn wait_for_exit(&self) -> Shared<Task<acp::TerminalExitStatus>> {
 91        self._output_task.clone()
 92    }
 93
 94    pub fn kill(&mut self, cx: &mut App) {
 95        self.terminal.update(cx, |terminal, _cx| {
 96            terminal.kill_active_task();
 97        });
 98    }
 99
100    pub fn current_output(&self, cx: &App) -> acp::TerminalOutputResponse {
101        if let Some(output) = self.output.as_ref() {
102            let exit_status = output.exit_status.map(portable_pty::ExitStatus::from);
103
104            acp::TerminalOutputResponse::new(
105                output.content.clone(),
106                output.original_content_len > output.content.len(),
107            )
108            .exit_status(
109                acp::TerminalExitStatus::new()
110                    .exit_code(exit_status.as_ref().map(|e| e.exit_code()))
111                    .signal(exit_status.and_then(|e| e.signal().map(ToOwned::to_owned))),
112            )
113        } else {
114            let (current_content, original_len) = self.truncated_output(cx);
115            let truncated = current_content.len() < original_len;
116            acp::TerminalOutputResponse::new(current_content, truncated)
117        }
118    }
119
120    fn truncated_output(&self, cx: &App) -> (String, usize) {
121        let terminal = self.terminal.read(cx);
122        let mut content = terminal.get_content();
123
124        let original_content_len = content.len();
125
126        if let Some(limit) = self.output_byte_limit
127            && content.len() > limit
128        {
129            let mut end_ix = limit.min(content.len());
130            while !content.is_char_boundary(end_ix) {
131                end_ix -= 1;
132            }
133            // Don't truncate mid-line, clear the remainder of the last line
134            end_ix = content[..end_ix].rfind('\n').unwrap_or(end_ix);
135            content.truncate(end_ix);
136        }
137
138        (content, original_content_len)
139    }
140
141    pub fn command(&self) -> &Entity<Markdown> {
142        &self.command
143    }
144
145    pub fn working_dir(&self) -> &Option<PathBuf> {
146        &self.working_dir
147    }
148
149    pub fn started_at(&self) -> Instant {
150        self.started_at
151    }
152
153    pub fn output(&self) -> Option<&TerminalOutput> {
154        self.output.as_ref()
155    }
156
157    pub fn inner(&self) -> &Entity<terminal::Terminal> {
158        &self.terminal
159    }
160
161    pub fn to_markdown(&self, cx: &App) -> String {
162        format!(
163            "Terminal:\n```\n{}\n```\n",
164            self.terminal.read(cx).get_content()
165        )
166    }
167}
168
169pub async fn create_terminal_entity(
170    command: String,
171    args: &[String],
172    env_vars: Vec<(String, String)>,
173    cwd: Option<PathBuf>,
174    project: &Entity<Project>,
175    cx: &mut AsyncApp,
176) -> Result<Entity<terminal::Terminal>> {
177    let mut env = if let Some(dir) = &cwd {
178        project
179            .update(cx, |project, cx| {
180                project.environment().update(cx, |env, cx| {
181                    env.directory_environment(dir.clone().into(), cx)
182                })
183            })?
184            .await
185            .unwrap_or_default()
186    } else {
187        Default::default()
188    };
189
190    // Disable pagers so agent/terminal commands don't hang behind interactive UIs
191    env.insert("PAGER".into(), "".into());
192    // Override user core.pager (e.g. delta) which Git prefers over PAGER
193    env.insert("GIT_PAGER".into(), "cat".into());
194    env.extend(env_vars);
195
196    // Use remote shell or default system shell, as appropriate
197    let shell = project
198        .update(cx, |project, cx| {
199            project
200                .remote_client()
201                .and_then(|r| r.read(cx).default_system_shell())
202                .map(Shell::Program)
203        })?
204        .unwrap_or_else(|| Shell::Program(get_default_system_shell_preferring_bash()));
205    let is_windows = project
206        .read_with(cx, |project, cx| project.path_style(cx).is_windows())
207        .unwrap_or(cfg!(windows));
208    let (task_command, task_args) = task::ShellBuilder::new(&shell, is_windows)
209        .redirect_stdin_to_dev_null()
210        .build(Some(command.clone()), &args);
211
212    project
213        .update(cx, |project, cx| {
214            project.create_terminal_task(
215                task::SpawnInTerminal {
216                    command: Some(task_command),
217                    args: task_args,
218                    cwd,
219                    env,
220                    ..Default::default()
221                },
222                cx,
223            )
224        })?
225        .await
226}