terminal.rs

  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}