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