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}