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::{
  9    path::PathBuf,
 10    process::ExitStatus,
 11    sync::{
 12        Arc,
 13        atomic::{AtomicBool, Ordering},
 14    },
 15    time::Instant,
 16};
 17use task::Shell;
 18use util::get_default_system_shell_preferring_bash;
 19
 20pub struct Terminal {
 21    id: acp::TerminalId,
 22    command: Entity<Markdown>,
 23    working_dir: Option<PathBuf>,
 24    terminal: Entity<terminal::Terminal>,
 25    started_at: Instant,
 26    output: Option<TerminalOutput>,
 27    output_byte_limit: Option<usize>,
 28    _output_task: Shared<Task<acp::TerminalExitStatus>>,
 29    /// Flag indicating whether this terminal was stopped by explicit user action
 30    /// (e.g., clicking the Stop button). This is set before kill() is called
 31    /// so that code awaiting wait_for_exit() can check it deterministically.
 32    user_stopped: Arc<AtomicBool>,
 33}
 34
 35pub struct TerminalOutput {
 36    pub ended_at: Instant,
 37    pub exit_status: Option<ExitStatus>,
 38    pub content: String,
 39    pub original_content_len: usize,
 40    pub content_line_count: usize,
 41}
 42
 43impl Terminal {
 44    pub fn new(
 45        id: acp::TerminalId,
 46        command_label: &str,
 47        working_dir: Option<PathBuf>,
 48        output_byte_limit: Option<usize>,
 49        terminal: Entity<terminal::Terminal>,
 50        language_registry: Arc<LanguageRegistry>,
 51        cx: &mut Context<Self>,
 52    ) -> Self {
 53        let command_task = terminal.read(cx).wait_for_completed_task(cx);
 54        Self {
 55            id,
 56            command: cx.new(|cx| {
 57                Markdown::new(
 58                    format!("```\n{}\n```", command_label).into(),
 59                    Some(language_registry.clone()),
 60                    None,
 61                    cx,
 62                )
 63            }),
 64            working_dir,
 65            terminal,
 66            started_at: Instant::now(),
 67            output: None,
 68            output_byte_limit,
 69            user_stopped: Arc::new(AtomicBool::new(false)),
 70            _output_task: cx
 71                .spawn(async move |this, cx| {
 72                    let exit_status = command_task.await;
 73
 74                    this.update(cx, |this, cx| {
 75                        let (content, original_content_len) = this.truncated_output(cx);
 76                        let content_line_count = this.terminal.read(cx).total_lines();
 77
 78                        this.output = Some(TerminalOutput {
 79                            ended_at: Instant::now(),
 80                            exit_status,
 81                            content,
 82                            original_content_len,
 83                            content_line_count,
 84                        });
 85                        cx.notify();
 86                    })
 87                    .ok();
 88
 89                    let exit_status = exit_status.map(portable_pty::ExitStatus::from);
 90
 91                    acp::TerminalExitStatus::new()
 92                        .exit_code(exit_status.as_ref().map(|e| e.exit_code()))
 93                        .signal(exit_status.and_then(|e| e.signal().map(ToOwned::to_owned)))
 94                })
 95                .shared(),
 96        }
 97    }
 98
 99    pub fn id(&self) -> &acp::TerminalId {
100        &self.id
101    }
102
103    pub fn wait_for_exit(&self) -> Shared<Task<acp::TerminalExitStatus>> {
104        self._output_task.clone()
105    }
106
107    pub fn kill(&mut self, cx: &mut App) {
108        self.terminal.update(cx, |terminal, _cx| {
109            terminal.kill_active_task();
110        });
111    }
112
113    /// Marks this terminal as stopped by user action and then kills it.
114    /// This should be called when the user explicitly clicks a Stop button.
115    pub fn stop_by_user(&mut self, cx: &mut App) {
116        self.user_stopped.store(true, Ordering::SeqCst);
117        self.kill(cx);
118    }
119
120    /// Returns whether this terminal was stopped by explicit user action.
121    pub fn was_stopped_by_user(&self) -> bool {
122        self.user_stopped.load(Ordering::SeqCst)
123    }
124
125    pub fn current_output(&self, cx: &App) -> acp::TerminalOutputResponse {
126        if let Some(output) = self.output.as_ref() {
127            let exit_status = output.exit_status.map(portable_pty::ExitStatus::from);
128
129            acp::TerminalOutputResponse::new(
130                output.content.clone(),
131                output.original_content_len > output.content.len(),
132            )
133            .exit_status(
134                acp::TerminalExitStatus::new()
135                    .exit_code(exit_status.as_ref().map(|e| e.exit_code()))
136                    .signal(exit_status.and_then(|e| e.signal().map(ToOwned::to_owned))),
137            )
138        } else {
139            let (current_content, original_len) = self.truncated_output(cx);
140            let truncated = current_content.len() < original_len;
141            acp::TerminalOutputResponse::new(current_content, truncated)
142        }
143    }
144
145    fn truncated_output(&self, cx: &App) -> (String, usize) {
146        let terminal = self.terminal.read(cx);
147        let mut content = terminal.get_content();
148
149        let original_content_len = content.len();
150
151        if let Some(limit) = self.output_byte_limit
152            && content.len() > limit
153        {
154            let mut end_ix = limit.min(content.len());
155            while !content.is_char_boundary(end_ix) {
156                end_ix -= 1;
157            }
158            // Don't truncate mid-line, clear the remainder of the last line
159            end_ix = content[..end_ix].rfind('\n').unwrap_or(end_ix);
160            content.truncate(end_ix);
161        }
162
163        (content, original_content_len)
164    }
165
166    pub fn command(&self) -> &Entity<Markdown> {
167        &self.command
168    }
169
170    pub fn working_dir(&self) -> &Option<PathBuf> {
171        &self.working_dir
172    }
173
174    pub fn started_at(&self) -> Instant {
175        self.started_at
176    }
177
178    pub fn output(&self) -> Option<&TerminalOutput> {
179        self.output.as_ref()
180    }
181
182    pub fn inner(&self) -> &Entity<terminal::Terminal> {
183        &self.terminal
184    }
185
186    pub fn to_markdown(&self, cx: &App) -> String {
187        format!(
188            "Terminal:\n```\n{}\n```\n",
189            self.terminal.read(cx).get_content()
190        )
191    }
192}
193
194pub async fn create_terminal_entity(
195    command: String,
196    args: &[String],
197    env_vars: Vec<(String, String)>,
198    cwd: Option<PathBuf>,
199    project: &Entity<Project>,
200    cx: &mut AsyncApp,
201) -> Result<Entity<terminal::Terminal>> {
202    let mut env = if let Some(dir) = &cwd {
203        project
204            .update(cx, |project, cx| {
205                project.environment().update(cx, |env, cx| {
206                    env.directory_environment(dir.clone().into(), cx)
207                })
208            })
209            .await
210            .unwrap_or_default()
211    } else {
212        Default::default()
213    };
214
215    // Disable pagers so agent/terminal commands don't hang behind interactive UIs
216    env.insert("PAGER".into(), "".into());
217    // Override user core.pager (e.g. delta) which Git prefers over PAGER
218    env.insert("GIT_PAGER".into(), "cat".into());
219    env.extend(env_vars);
220
221    // Use remote shell or default system shell, as appropriate
222    let shell = project
223        .update(cx, |project, cx| {
224            project
225                .remote_client()
226                .and_then(|r| r.read(cx).default_system_shell())
227                .map(Shell::Program)
228        })
229        .unwrap_or_else(|| Shell::Program(get_default_system_shell_preferring_bash()));
230    let is_windows = project.read_with(cx, |project, cx| project.path_style(cx).is_windows());
231    let (task_command, task_args) = task::ShellBuilder::new(&shell, is_windows)
232        .redirect_stdin_to_dev_null()
233        .build(Some(command.clone()), &args);
234
235    project
236        .update(cx, |project, cx| {
237            project.create_terminal_task(
238                task::SpawnInTerminal {
239                    command: Some(task_command),
240                    args: task_args,
241                    cwd,
242                    env,
243                    ..Default::default()
244                },
245                cx,
246            )
247        })
248        .await
249}