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 update_command_label(&self, label: &str, cx: &mut App) {
171 self.command.update(cx, |command, cx| {
172 command.replace(format!("```\n{}\n```", label), cx);
173 });
174 }
175
176 pub fn working_dir(&self) -> &Option<PathBuf> {
177 &self.working_dir
178 }
179
180 pub fn started_at(&self) -> Instant {
181 self.started_at
182 }
183
184 pub fn output(&self) -> Option<&TerminalOutput> {
185 self.output.as_ref()
186 }
187
188 pub fn inner(&self) -> &Entity<terminal::Terminal> {
189 &self.terminal
190 }
191
192 pub fn to_markdown(&self, cx: &App) -> String {
193 format!(
194 "Terminal:\n```\n{}\n```\n",
195 self.terminal.read(cx).get_content()
196 )
197 }
198}
199
200pub async fn create_terminal_entity(
201 command: String,
202 args: &[String],
203 env_vars: Vec<(String, String)>,
204 cwd: Option<PathBuf>,
205 project: &Entity<Project>,
206 cx: &mut AsyncApp,
207) -> Result<Entity<terminal::Terminal>> {
208 let mut env = if let Some(dir) = &cwd {
209 project
210 .update(cx, |project, cx| {
211 project.environment().update(cx, |env, cx| {
212 env.directory_environment(dir.clone().into(), cx)
213 })
214 })
215 .await
216 .unwrap_or_default()
217 } else {
218 Default::default()
219 };
220
221 // Disable pagers so agent/terminal commands don't hang behind interactive UIs
222 env.insert("PAGER".into(), "".into());
223 // Override user core.pager (e.g. delta) which Git prefers over PAGER
224 env.insert("GIT_PAGER".into(), "cat".into());
225 env.extend(env_vars);
226
227 // Use remote shell or default system shell, as appropriate
228 let shell = project
229 .update(cx, |project, cx| {
230 project
231 .remote_client()
232 .and_then(|r| r.read(cx).default_system_shell())
233 .map(Shell::Program)
234 })
235 .unwrap_or_else(|| Shell::Program(get_default_system_shell_preferring_bash()));
236 let is_windows = project.read_with(cx, |project, cx| project.path_style(cx).is_windows());
237 let (task_command, task_args) = task::ShellBuilder::new(&shell, is_windows)
238 .redirect_stdin_to_dev_null()
239 .build(Some(command.clone()), &args);
240
241 project
242 .update(cx, |project, cx| {
243 project.create_terminal_task(
244 task::SpawnInTerminal {
245 command: Some(task_command),
246 args: task_args,
247 cwd,
248 env,
249 ..Default::default()
250 },
251 cx,
252 )
253 })
254 .await
255}