1use agent_client_protocol as acp;
2
3use futures::{FutureExt as _, future::Shared};
4use gpui::{App, AppContext, Context, Entity, Task};
5use language::LanguageRegistry;
6use markdown::Markdown;
7use std::{path::PathBuf, process::ExitStatus, sync::Arc, time::Instant};
8
9pub struct Terminal {
10 id: acp::TerminalId,
11 command: Entity<Markdown>,
12 working_dir: Option<PathBuf>,
13 terminal: Entity<terminal::Terminal>,
14 started_at: Instant,
15 output: Option<TerminalOutput>,
16 output_byte_limit: Option<usize>,
17 _output_task: Shared<Task<acp::TerminalExitStatus>>,
18}
19
20pub struct TerminalOutput {
21 pub ended_at: Instant,
22 pub exit_status: Option<ExitStatus>,
23 pub content: String,
24 pub original_content_len: usize,
25 pub content_line_count: usize,
26}
27
28impl Terminal {
29 pub fn new(
30 id: acp::TerminalId,
31 command_label: &str,
32 working_dir: Option<PathBuf>,
33 output_byte_limit: Option<usize>,
34 terminal: Entity<terminal::Terminal>,
35 language_registry: Arc<LanguageRegistry>,
36 cx: &mut Context<Self>,
37 ) -> Self {
38 let command_task = terminal.read(cx).wait_for_completed_task(cx);
39 Self {
40 id,
41 command: cx.new(|cx| {
42 Markdown::new(
43 format!("```\n{}\n```", command_label).into(),
44 Some(language_registry.clone()),
45 None,
46 cx,
47 )
48 }),
49 working_dir,
50 terminal,
51 started_at: Instant::now(),
52 output: None,
53 output_byte_limit,
54 _output_task: cx
55 .spawn(async move |this, cx| {
56 let exit_status = command_task.await;
57
58 this.update(cx, |this, cx| {
59 let (content, original_content_len) = this.truncated_output(cx);
60 let content_line_count = this.terminal.read(cx).total_lines();
61
62 this.output = Some(TerminalOutput {
63 ended_at: Instant::now(),
64 exit_status,
65 content,
66 original_content_len,
67 content_line_count,
68 });
69 cx.notify();
70 })
71 .ok();
72
73 let exit_status = exit_status.map(portable_pty::ExitStatus::from);
74
75 acp::TerminalExitStatus {
76 exit_code: exit_status.as_ref().map(|e| e.exit_code()),
77 signal: exit_status.and_then(|e| e.signal().map(Into::into)),
78 meta: None,
79 }
80 })
81 .shared(),
82 }
83 }
84
85 pub fn new_display_only(
86 id: acp::TerminalId,
87 command_label: &str,
88 working_dir: Option<PathBuf>,
89 output_byte_limit: Option<usize>,
90 terminal: Entity<terminal::Terminal>,
91 cx: &mut Context<Self>,
92 ) -> Self {
93 // Display-only terminals don't have a real process, so there's no exit status
94 let command_task = Task::ready(None);
95
96 Self {
97 id,
98 command: cx.new(|_cx| {
99 // For display-only terminals, we don't need the markdown wrapper
100 // The terminal itself will handle the display
101 Markdown::new(
102 format!("```\n{}\n```", command_label).into(),
103 None,
104 None,
105 _cx,
106 )
107 }),
108 working_dir,
109 terminal,
110 started_at: Instant::now(),
111 output: None,
112 output_byte_limit,
113 _output_task: cx
114 .spawn(async move |this, cx| {
115 // Display-only terminals don't really exit, but we need to handle this
116 let exit_status = command_task.await;
117
118 this.update(cx, |this, cx| {
119 let (content, original_content_len) = this.truncated_output(cx);
120 let content_line_count = this.terminal.read(cx).total_lines();
121
122 this.output = Some(TerminalOutput {
123 ended_at: Instant::now(),
124 exit_status,
125 content,
126 original_content_len,
127 content_line_count,
128 });
129 cx.notify();
130 })
131 .ok();
132
133 acp::TerminalExitStatus {
134 exit_code: None,
135 signal: None,
136 meta: None,
137 }
138 })
139 .shared(),
140 }
141 }
142
143 pub fn write_output(&mut self, data: &[u8], cx: &mut Context<Self>) {
144 self.terminal.update(cx, |terminal, cx| {
145 terminal.write_output(data, cx);
146 });
147 }
148
149 pub fn id(&self) -> &acp::TerminalId {
150 &self.id
151 }
152
153 pub fn wait_for_exit(&self) -> Shared<Task<acp::TerminalExitStatus>> {
154 self._output_task.clone()
155 }
156
157 pub fn kill(&mut self, cx: &mut App) {
158 self.terminal.update(cx, |terminal, _cx| {
159 terminal.kill_active_task();
160 });
161 }
162
163 pub fn current_output(&self, cx: &App) -> acp::TerminalOutputResponse {
164 if let Some(output) = self.output.as_ref() {
165 let exit_status = output.exit_status.map(portable_pty::ExitStatus::from);
166
167 acp::TerminalOutputResponse {
168 output: output.content.clone(),
169 truncated: output.original_content_len > output.content.len(),
170 exit_status: Some(acp::TerminalExitStatus {
171 exit_code: exit_status.as_ref().map(|e| e.exit_code()),
172 signal: exit_status.and_then(|e| e.signal().map(Into::into)),
173 meta: None,
174 }),
175 meta: None,
176 }
177 } else {
178 let (current_content, original_len) = self.truncated_output(cx);
179
180 acp::TerminalOutputResponse {
181 truncated: current_content.len() < original_len,
182 output: current_content,
183 exit_status: None,
184 meta: None,
185 }
186 }
187 }
188
189 fn truncated_output(&self, cx: &App) -> (String, usize) {
190 let terminal = self.terminal.read(cx);
191 let mut content = terminal.get_content();
192
193 let original_content_len = content.len();
194
195 if let Some(limit) = self.output_byte_limit
196 && content.len() > limit
197 {
198 let mut end_ix = limit.min(content.len());
199 while !content.is_char_boundary(end_ix) {
200 end_ix -= 1;
201 }
202 // Don't truncate mid-line, clear the remainder of the last line
203 end_ix = content[..end_ix].rfind('\n').unwrap_or(end_ix);
204 content.truncate(end_ix);
205 }
206
207 (content, original_content_len)
208 }
209
210 pub fn command(&self) -> &Entity<Markdown> {
211 &self.command
212 }
213
214 pub fn working_dir(&self) -> &Option<PathBuf> {
215 &self.working_dir
216 }
217
218 pub fn started_at(&self) -> Instant {
219 self.started_at
220 }
221
222 pub fn output(&self) -> Option<&TerminalOutput> {
223 self.output.as_ref()
224 }
225
226 pub fn inner(&self) -> &Entity<terminal::Terminal> {
227 &self.terminal
228 }
229
230 pub fn to_markdown(&self, cx: &App) -> String {
231 format!(
232 "Terminal:\n```\n{}\n```\n",
233 self.terminal.read(cx).get_content()
234 )
235 }
236}