terminal_tool.rs

  1use crate::schema::json_schema_for;
  2use anyhow::{Context as _, Result, anyhow};
  3use assistant_tool::{ActionLog, Tool, ToolCard, ToolResult, ToolUseStatus};
  4use gpui::{
  5    Animation, AnimationExt, AnyWindowHandle, App, AppContext, Empty, Entity, EntityId, Task,
  6    Transformation, WeakEntity, Window, percentage,
  7};
  8use language_model::{LanguageModelRequestMessage, LanguageModelToolSchemaFormat};
  9use project::{Project, terminals::TerminalKind};
 10use schemars::JsonSchema;
 11use serde::{Deserialize, Serialize};
 12use std::{
 13    env,
 14    path::{Path, PathBuf},
 15    process::ExitStatus,
 16    sync::Arc,
 17    time::{Duration, Instant},
 18};
 19use terminal_view::TerminalView;
 20use ui::{Disclosure, IconName, Tooltip, prelude::*};
 21use util::{
 22    get_system_shell, markdown::MarkdownInlineCode, size::format_file_size,
 23    time::duration_alt_display,
 24};
 25use workspace::Workspace;
 26
 27const COMMAND_OUTPUT_LIMIT: usize = 16 * 1024;
 28
 29#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
 30pub struct TerminalToolInput {
 31    /// The one-liner command to execute.
 32    command: String,
 33    /// Working directory for the command. This must be one of the root directories of the project.
 34    cd: String,
 35}
 36
 37pub struct TerminalTool;
 38
 39impl Tool for TerminalTool {
 40    fn name(&self) -> String {
 41        "terminal".to_string()
 42    }
 43
 44    fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool {
 45        true
 46    }
 47
 48    fn description(&self) -> String {
 49        include_str!("./terminal_tool/description.md").to_string()
 50    }
 51
 52    fn icon(&self) -> IconName {
 53        IconName::Terminal
 54    }
 55
 56    fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result<serde_json::Value> {
 57        json_schema_for::<TerminalToolInput>(format)
 58    }
 59
 60    fn ui_text(&self, input: &serde_json::Value) -> String {
 61        match serde_json::from_value::<TerminalToolInput>(input.clone()) {
 62            Ok(input) => {
 63                let mut lines = input.command.lines();
 64                let first_line = lines.next().unwrap_or_default();
 65                let remaining_line_count = lines.count();
 66                match remaining_line_count {
 67                    0 => MarkdownInlineCode(&first_line).to_string(),
 68                    1 => MarkdownInlineCode(&format!(
 69                        "{} - {} more line",
 70                        first_line, remaining_line_count
 71                    ))
 72                    .to_string(),
 73                    n => MarkdownInlineCode(&format!("{} - {} more lines", first_line, n))
 74                        .to_string(),
 75                }
 76            }
 77            Err(_) => "Run terminal command".to_string(),
 78        }
 79    }
 80
 81    fn run(
 82        self: Arc<Self>,
 83        input: serde_json::Value,
 84        _messages: &[LanguageModelRequestMessage],
 85        project: Entity<Project>,
 86        _action_log: Entity<ActionLog>,
 87        window: Option<AnyWindowHandle>,
 88        cx: &mut App,
 89    ) -> ToolResult {
 90        let Some(window) = window else {
 91            return Task::ready(Err(anyhow!("no window options"))).into();
 92        };
 93
 94        let input: TerminalToolInput = match serde_json::from_value(input) {
 95            Ok(input) => input,
 96            Err(err) => return Task::ready(Err(anyhow!(err))).into(),
 97        };
 98
 99        let input_path = Path::new(&input.cd);
100        let working_dir = match working_dir(cx, &input, &project, input_path) {
101            Ok(dir) => dir,
102            Err(err) => return Task::ready(Err(anyhow!(err))).into(),
103        };
104        let terminal = project.update(cx, |project, cx| {
105            project.create_terminal(
106                TerminalKind::Task(task::SpawnInTerminal {
107                    command: get_system_shell(),
108                    args: vec!["-c".into(), input.command.clone()],
109                    cwd: working_dir.clone(),
110                    ..Default::default()
111                }),
112                window,
113                cx,
114            )
115        });
116
117        let card = cx.new(|cx| {
118            TerminalToolCard::new(input.command.clone(), working_dir.clone(), cx.entity_id())
119        });
120
121        let output = cx.spawn({
122            let card = card.clone();
123            async move |cx| {
124                let terminal = terminal.await?;
125                let workspace = window
126                    .downcast::<Workspace>()
127                    .and_then(|handle| handle.entity(cx).ok())
128                    .context("no workspace entity in root of window")?;
129
130                let terminal_view = window.update(cx, |_, window, cx| {
131                    cx.new(|cx| {
132                        TerminalView::new(
133                            terminal.clone(),
134                            workspace.downgrade(),
135                            None,
136                            project.downgrade(),
137                            window,
138                            cx,
139                        )
140                    })
141                })?;
142                let _ = card.update(cx, |card, _| {
143                    card.terminal = Some(terminal_view.clone());
144                    card.start_instant = Instant::now();
145                });
146
147                let exit_status = terminal
148                    .update(cx, |terminal, cx| terminal.wait_for_completed_task(cx))?
149                    .await;
150                let (content, content_line_count) = terminal.update(cx, |terminal, _| {
151                    (terminal.get_content(), terminal.total_lines())
152                })?;
153
154                let previous_len = content.len();
155                let (processed_content, finished_with_empty_output) =
156                    process_content(content, &input.command, exit_status);
157
158                let _ = card.update(cx, |card, _| {
159                    card.command_finished = true;
160                    card.exit_status = exit_status;
161                    card.was_content_truncated = processed_content.len() < previous_len;
162                    card.original_content_len = previous_len;
163                    card.content_line_count = content_line_count;
164                    card.finished_with_empty_output = finished_with_empty_output;
165                    card.elapsed_time = Some(card.start_instant.elapsed());
166                });
167
168                Ok(processed_content)
169            }
170        });
171
172        ToolResult {
173            output,
174            card: Some(card.into()),
175        }
176    }
177}
178
179fn process_content(
180    content: String,
181    command: &str,
182    exit_status: Option<ExitStatus>,
183) -> (String, bool) {
184    let should_truncate = content.len() > COMMAND_OUTPUT_LIMIT;
185
186    let content = if should_truncate {
187        let mut end_ix = COMMAND_OUTPUT_LIMIT.min(content.len());
188        while !content.is_char_boundary(end_ix) {
189            end_ix -= 1;
190        }
191        // Don't truncate mid-line, clear the remainder of the last line
192        end_ix = content[..end_ix].rfind('\n').unwrap_or(end_ix);
193        &content[..end_ix]
194    } else {
195        content.as_str()
196    };
197    let is_empty = content.trim().is_empty();
198
199    let content = format!(
200        "```\n{}{}```",
201        content,
202        if content.ends_with('\n') { "" } else { "\n" }
203    );
204
205    let content = if should_truncate {
206        format!(
207            "Command output too long. The first {} bytes:\n\n{}",
208            content.len(),
209            content,
210        )
211    } else {
212        content
213    };
214
215    let content = match exit_status {
216        Some(exit_status) if exit_status.success() => {
217            if is_empty {
218                "Command executed successfully.".to_string()
219            } else {
220                content.to_string()
221            }
222        }
223        Some(exit_status) => {
224            let code = exit_status.code().unwrap_or(-1);
225            if is_empty {
226                format!("Command \"{command}\" failed with exit code {code}.")
227            } else {
228                format!("Command \"{command}\" failed with exit code {code}.\n\n{content}")
229            }
230        }
231        None => {
232            format!(
233                "Command failed or was interrupted.\nPartial output captured:\n\n{}",
234                content,
235            )
236        }
237    };
238    (content, is_empty)
239}
240
241fn working_dir(
242    cx: &mut App,
243    input: &TerminalToolInput,
244    project: &Entity<Project>,
245    input_path: &Path,
246) -> Result<Option<PathBuf>, &'static str> {
247    let project = project.read(cx);
248
249    if input.cd == "." {
250        // Accept "." as meaning "the one worktree" if we only have one worktree.
251        let mut worktrees = project.worktrees(cx);
252
253        match worktrees.next() {
254            Some(worktree) => {
255                if worktrees.next().is_some() {
256                    return Err(
257                        "'.' is ambiguous in multi-root workspaces. Please specify a root directory explicitly.",
258                    );
259                }
260                Ok(Some(worktree.read(cx).abs_path().to_path_buf()))
261            }
262            None => Ok(None),
263        }
264    } else if input_path.is_absolute() {
265        // Absolute paths are allowed, but only if they're in one of the project's worktrees.
266        if !project
267            .worktrees(cx)
268            .any(|worktree| input_path.starts_with(&worktree.read(cx).abs_path()))
269        {
270            return Err("The absolute path must be within one of the project's worktrees");
271        }
272
273        Ok(Some(input_path.into()))
274    } else {
275        let Some(worktree) = project.worktree_for_root_name(&input.cd, cx) else {
276            return Err("`cd` directory {} not found in the project");
277        };
278
279        Ok(Some(worktree.read(cx).abs_path().to_path_buf()))
280    }
281}
282
283struct TerminalToolCard {
284    input_command: String,
285    working_dir: Option<PathBuf>,
286    entity_id: EntityId,
287    exit_status: Option<ExitStatus>,
288    terminal: Option<Entity<TerminalView>>,
289    command_finished: bool,
290    was_content_truncated: bool,
291    finished_with_empty_output: bool,
292    content_line_count: usize,
293    original_content_len: usize,
294    preview_expanded: bool,
295    start_instant: Instant,
296    elapsed_time: Option<Duration>,
297}
298
299impl TerminalToolCard {
300    pub fn new(input_command: String, working_dir: Option<PathBuf>, entity_id: EntityId) -> Self {
301        Self {
302            input_command,
303            working_dir,
304            entity_id,
305            exit_status: None,
306            terminal: None,
307            command_finished: false,
308            was_content_truncated: false,
309            finished_with_empty_output: false,
310            original_content_len: 0,
311            content_line_count: 0,
312            preview_expanded: true,
313            start_instant: Instant::now(),
314            elapsed_time: None,
315        }
316    }
317}
318
319impl ToolCard for TerminalToolCard {
320    fn render(
321        &mut self,
322        status: &ToolUseStatus,
323        _window: &mut Window,
324        _workspace: WeakEntity<Workspace>,
325        cx: &mut Context<Self>,
326    ) -> impl IntoElement {
327        let Some(terminal) = self.terminal.as_ref() else {
328            return Empty.into_any();
329        };
330
331        let tool_failed = matches!(status, ToolUseStatus::Error(_));
332        let command_failed =
333            self.command_finished && self.exit_status.is_none_or(|code| !code.success());
334        if (tool_failed || command_failed) && self.elapsed_time.is_none() {
335            self.elapsed_time = Some(self.start_instant.elapsed());
336        }
337        let time_elapsed = self
338            .elapsed_time
339            .unwrap_or_else(|| self.start_instant.elapsed());
340        let should_hide_terminal =
341            tool_failed || self.finished_with_empty_output || !self.preview_expanded;
342
343        let border_color = cx.theme().colors().border.opacity(0.6);
344        let header_bg = cx
345            .theme()
346            .colors()
347            .element_background
348            .blend(cx.theme().colors().editor_foreground.opacity(0.025));
349
350        let header_label = h_flex()
351            .w_full()
352            .max_w_full()
353            .px_1()
354            .gap_0p5()
355            .opacity(0.8)
356            .child(
357                h_flex()
358                    .child(
359                        Icon::new(IconName::Terminal)
360                            .size(IconSize::XSmall)
361                            .color(Color::Muted),
362                    )
363                    .child(
364                        div()
365                            .id(("terminal-tool-header-input-command", self.entity_id))
366                            .text_size(rems(0.8125))
367                            .font_buffer(cx)
368                            .child(self.input_command.clone())
369                            .ml_1p5()
370                            .mr_0p5()
371                            .tooltip({
372                                let path = self
373                                    .working_dir
374                                    .as_ref()
375                                    .cloned()
376                                    .or_else(|| env::current_dir().ok())
377                                    .map(|path| format!("\"{}\"", path.display()))
378                                    .unwrap_or_else(|| "current directory".to_string());
379                                Tooltip::text(if self.command_finished {
380                                    format!("Ran in {path}")
381                                } else {
382                                    format!("Running in {path}")
383                                })
384                            }),
385                    ),
386            )
387            .into_any_element();
388
389        let header = h_flex()
390            .flex_none()
391            .p_1()
392            .gap_1()
393            .justify_between()
394            .rounded_t_md()
395            .bg(header_bg)
396            .child(header_label)
397            .map(|header| {
398                let header = header
399                    .when(self.was_content_truncated, |header| {
400                        let tooltip =
401                            if self.content_line_count + 10 > terminal::MAX_SCROLL_HISTORY_LINES {
402                                "Output exceeded terminal max lines and was \
403                                truncated, the model received the first 16 KB."
404                                    .to_string()
405                            } else {
406                                format!(
407                                    "Output is {} long, to avoid unexpected token usage, \
408                                    only 16 KB was sent back to the model.",
409                                    format_file_size(self.original_content_len as u64, true),
410                                )
411                            };
412                        header.child(
413                            div()
414                                .id(("terminal-tool-truncated-label", self.entity_id))
415                                .tooltip(Tooltip::text(tooltip))
416                                .child(
417                                    Label::new("(truncated)")
418                                        .color(Color::Disabled)
419                                        .size(LabelSize::Small),
420                                ),
421                        )
422                    })
423                    .when(time_elapsed > Duration::from_secs(10), |header| {
424                        header.child(
425                            Label::new(format!("({})", duration_alt_display(time_elapsed)))
426                                .buffer_font(cx)
427                                .color(Color::Disabled)
428                                .size(LabelSize::Small),
429                        )
430                    });
431
432                if tool_failed || command_failed {
433                    header.child(
434                        div()
435                            .id(("terminal-tool-error-code-indicator", self.entity_id))
436                            .child(
437                                Icon::new(IconName::Close)
438                                    .size(IconSize::Small)
439                                    .color(Color::Error),
440                            )
441                            .when(command_failed && self.exit_status.is_some(), |this| {
442                                this.tooltip(Tooltip::text(format!(
443                                    "Exited with code {}",
444                                    self.exit_status
445                                        .and_then(|status| status.code())
446                                        .unwrap_or(-1),
447                                )))
448                            })
449                            .when(
450                                !command_failed && tool_failed && status.error().is_some(),
451                                |this| {
452                                    this.tooltip(Tooltip::text(format!(
453                                        "Error: {}",
454                                        status.error().unwrap(),
455                                    )))
456                                },
457                            ),
458                    )
459                } else if self.command_finished {
460                    header.child(
461                        Icon::new(IconName::Check)
462                            .size(IconSize::Small)
463                            .color(Color::Success),
464                    )
465                } else {
466                    header.child(
467                        Icon::new(IconName::ArrowCircle)
468                            .size(IconSize::Small)
469                            .color(Color::Info)
470                            .with_animation(
471                                "arrow-circle",
472                                Animation::new(Duration::from_secs(2)).repeat(),
473                                |icon, delta| {
474                                    icon.transform(Transformation::rotate(percentage(delta)))
475                                },
476                            ),
477                    )
478                }
479            })
480            .when(!tool_failed && !self.finished_with_empty_output, |header| {
481                header.child(
482                    Disclosure::new(
483                        ("terminal-tool-disclosure", self.entity_id),
484                        self.preview_expanded,
485                    )
486                    .opened_icon(IconName::ChevronUp)
487                    .closed_icon(IconName::ChevronDown)
488                    .on_click(cx.listener(
489                        move |this, _event, _window, _cx| {
490                            this.preview_expanded = !this.preview_expanded;
491                        },
492                    )),
493                )
494            });
495
496        v_flex()
497            .mb_2()
498            .border_1()
499            .when(tool_failed || command_failed, |card| card.border_dashed())
500            .border_color(border_color)
501            .rounded_lg()
502            .overflow_hidden()
503            .child(header)
504            .when(!should_hide_terminal, |this| {
505                this.child(div().child(terminal.clone()).min_h(px(250.0)))
506            })
507            .into_any()
508    }
509}