terminal.rs

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