Cargo.lock 🔗
@@ -730,6 +730,9 @@ dependencies = [
"serde",
"serde_json",
"settings",
+ "task",
+ "terminal",
+ "terminal_view",
"tree-sitter-rust",
"ui",
"unindent",
João Marcos created
Also, don't require a worktree to run the terminal tool.
Release Notes:
- N/A
Cargo.lock | 3
crates/assistant_tool/src/assistant_tool.rs | 7
crates/assistant_tools/Cargo.toml | 4
crates/assistant_tools/src/edit_file_tool.rs | 31
crates/assistant_tools/src/terminal_tool.rs | 699 ++++++----
crates/assistant_tools/src/terminal_tool/description.md | 4
crates/assistant_tools/src/web_search_tool.rs | 8
crates/image_viewer/src/image_info.rs | 27
crates/project/src/terminals.rs | 2
crates/terminal/src/terminal.rs | 29
crates/terminal_view/src/terminal_element.rs | 2
crates/terminal_view/src/terminal_panel.rs | 2
crates/util/src/size.rs | 48
crates/util/src/time.rs | 41
crates/util/src/util.rs | 2
15 files changed, 563 insertions(+), 346 deletions(-)
@@ -730,6 +730,9 @@ dependencies = [
"serde",
"serde_json",
"settings",
+ "task",
+ "terminal",
+ "terminal_view",
"tree-sitter-rust",
"ui",
"unindent",
@@ -51,6 +51,13 @@ impl ToolUseStatus {
ToolUseStatus::Error(out) => out.clone(),
}
}
+
+ pub fn error(&self) -> Option<SharedString> {
+ match self {
+ ToolUseStatus::Error(out) => Some(out.clone()),
+ _ => None,
+ }
+ }
}
/// The result of running a tool, containing both the asynchronous output
@@ -34,6 +34,9 @@ regex.workspace = true
schemars.workspace = true
serde.workspace = true
serde_json.workspace = true
+task.workspace = true
+terminal.workspace = true
+terminal_view.workspace = true
ui.workspace = true
util.workspace = true
web_search.workspace = true
@@ -51,6 +54,7 @@ project = { workspace = true, features = ["test-support"] }
rand.workspace = true
pretty_assertions.workspace = true
settings = { workspace = true, features = ["test-support"] }
+task = { workspace = true, features = ["test-support"]}
tree-sitter-rust.workspace = true
workspace = { workspace = true, features = ["test-support"] }
unindent.workspace = true
@@ -24,6 +24,8 @@ use ui::{Disclosure, Tooltip, Window, prelude::*};
use util::ResultExt;
use workspace::Workspace;
+pub struct EditFileTool;
+
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct EditFileToolInput {
/// A user-friendly markdown description of what's being replaced. This will be shown in the UI.
@@ -75,8 +77,6 @@ struct PartialInput {
new_string: String,
}
-pub struct EditFileTool;
-
const DEFAULT_UI_TEXT: &str = "Editing file";
impl Tool for EditFileTool {
@@ -627,7 +627,6 @@ mod tests {
#[test]
fn still_streaming_ui_text_with_path() {
- let tool = EditFileTool;
let input = json!({
"path": "src/main.rs",
"display_description": "",
@@ -635,12 +634,11 @@ mod tests {
"new_string": "new code"
});
- assert_eq!(tool.still_streaming_ui_text(&input), "src/main.rs");
+ assert_eq!(EditFileTool.still_streaming_ui_text(&input), "src/main.rs");
}
#[test]
fn still_streaming_ui_text_with_description() {
- let tool = EditFileTool;
let input = json!({
"path": "",
"display_description": "Fix error handling",
@@ -648,12 +646,14 @@ mod tests {
"new_string": "new code"
});
- assert_eq!(tool.still_streaming_ui_text(&input), "Fix error handling");
+ assert_eq!(
+ EditFileTool.still_streaming_ui_text(&input),
+ "Fix error handling",
+ );
}
#[test]
fn still_streaming_ui_text_with_path_and_description() {
- let tool = EditFileTool;
let input = json!({
"path": "src/main.rs",
"display_description": "Fix error handling",
@@ -661,12 +661,14 @@ mod tests {
"new_string": "new code"
});
- assert_eq!(tool.still_streaming_ui_text(&input), "Fix error handling");
+ assert_eq!(
+ EditFileTool.still_streaming_ui_text(&input),
+ "Fix error handling",
+ );
}
#[test]
fn still_streaming_ui_text_no_path_or_description() {
- let tool = EditFileTool;
let input = json!({
"path": "",
"display_description": "",
@@ -674,14 +676,19 @@ mod tests {
"new_string": "new code"
});
- assert_eq!(tool.still_streaming_ui_text(&input), DEFAULT_UI_TEXT);
+ assert_eq!(
+ EditFileTool.still_streaming_ui_text(&input),
+ DEFAULT_UI_TEXT,
+ );
}
#[test]
fn still_streaming_ui_text_with_null() {
- let tool = EditFileTool;
let input = serde_json::Value::Null;
- assert_eq!(tool.still_streaming_ui_text(&input), DEFAULT_UI_TEXT);
+ assert_eq!(
+ EditFileTool.still_streaming_ui_text(&input),
+ DEFAULT_UI_TEXT,
+ );
}
}
@@ -1,23 +1,32 @@
use crate::schema::json_schema_for;
use anyhow::{Context as _, Result, anyhow};
-use assistant_tool::{ActionLog, Tool, ToolResult};
-use futures::io::BufReader;
-use futures::{AsyncBufReadExt, AsyncReadExt, FutureExt};
-use gpui::{AnyWindowHandle, App, AppContext, Entity, Task};
+use assistant_tool::{ActionLog, Tool, ToolCard, ToolResult, ToolUseStatus};
+use gpui::{
+ Animation, AnimationExt, AnyWindowHandle, App, AppContext, Empty, Entity, EntityId, Task,
+ Transformation, WeakEntity, Window, percentage,
+};
use language_model::{LanguageModelRequestMessage, LanguageModelToolSchemaFormat};
-use project::Project;
+use project::{Project, terminals::TerminalKind};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
-use std::future;
-use util::get_system_shell;
-
-use std::path::Path;
-use std::sync::Arc;
-use ui::IconName;
-use util::command::new_smol_command;
-use util::markdown::MarkdownInlineCode;
-
-#[derive(Debug, Serialize, Deserialize, JsonSchema)]
+use std::{
+ env,
+ path::{Path, PathBuf},
+ process::ExitStatus,
+ sync::Arc,
+ time::{Duration, Instant},
+};
+use terminal_view::TerminalView;
+use ui::{Disclosure, IconName, Tooltip, prelude::*};
+use util::{
+ get_system_shell, markdown::MarkdownInlineCode, size::format_file_size,
+ time::duration_alt_display,
+};
+use workspace::Workspace;
+
+const COMMAND_OUTPUT_LIMIT: usize = 16 * 1024;
+
+#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
pub struct TerminalToolInput {
/// The one-liner command to execute.
command: String,
@@ -75,308 +84,426 @@ impl Tool for TerminalTool {
_messages: &[LanguageModelRequestMessage],
project: Entity<Project>,
_action_log: Entity<ActionLog>,
- _window: Option<AnyWindowHandle>,
+ window: Option<AnyWindowHandle>,
cx: &mut App,
) -> ToolResult {
+ let Some(window) = window else {
+ return Task::ready(Err(anyhow!("no window options"))).into();
+ };
+
let input: TerminalToolInput = match serde_json::from_value(input) {
Ok(input) => input,
Err(err) => return Task::ready(Err(anyhow!(err))).into(),
};
- let project = project.read(cx);
let input_path = Path::new(&input.cd);
- let working_dir = if input.cd == "." {
- // Accept "." as meaning "the one worktree" if we only have one worktree.
- let mut worktrees = project.worktrees(cx);
-
- let only_worktree = match worktrees.next() {
- Some(worktree) => worktree,
- None => {
- return Task::ready(Err(anyhow!("No worktrees found in the project"))).into();
- }
- };
-
- if worktrees.next().is_some() {
- return Task::ready(Err(anyhow!(
- "'.' is ambiguous in multi-root workspaces. Please specify a root directory explicitly."
- ))).into();
- }
-
- only_worktree.read(cx).abs_path()
- } else if input_path.is_absolute() {
- // Absolute paths are allowed, but only if they're in one of the project's worktrees.
- if !project
- .worktrees(cx)
- .any(|worktree| input_path.starts_with(&worktree.read(cx).abs_path()))
- {
- return Task::ready(Err(anyhow!(
- "The absolute path must be within one of the project's worktrees"
- )))
- .into();
- }
-
- input_path.into()
- } else {
- let Some(worktree) = project.worktree_for_root_name(&input.cd, cx) else {
- return Task::ready(Err(anyhow!(
- "`cd` directory {} not found in the project",
- &input.cd
- )))
- .into();
- };
-
- worktree.read(cx).abs_path()
+ let working_dir = match working_dir(cx, &input, &project, input_path) {
+ Ok(dir) => dir,
+ Err(err) => return Task::ready(Err(anyhow!(err))).into(),
};
+ let terminal = project.update(cx, |project, cx| {
+ project.create_terminal(
+ TerminalKind::Task(task::SpawnInTerminal {
+ command: get_system_shell(),
+ args: vec!["-c".into(), input.command.clone()],
+ cwd: working_dir.clone(),
+ ..Default::default()
+ }),
+ window,
+ cx,
+ )
+ });
+
+ let card = cx.new(|cx| {
+ TerminalToolCard::new(input.command.clone(), working_dir.clone(), cx.entity_id())
+ });
+
+ let output = cx.spawn({
+ let card = card.clone();
+ async move |cx| {
+ let terminal = terminal.await?;
+ let workspace = window
+ .downcast::<Workspace>()
+ .and_then(|handle| handle.entity(cx).ok())
+ .context("no workspace entity in root of window")?;
+
+ let terminal_view = window.update(cx, |_, window, cx| {
+ cx.new(|cx| {
+ TerminalView::new(
+ terminal.clone(),
+ workspace.downgrade(),
+ None,
+ project.downgrade(),
+ window,
+ cx,
+ )
+ })
+ })?;
+ let _ = card.update(cx, |card, _| {
+ card.terminal = Some(terminal_view.clone());
+ card.start_instant = Instant::now();
+ });
+
+ let exit_status = terminal
+ .update(cx, |terminal, cx| terminal.wait_for_completed_task(cx))?
+ .await;
+ let (content, content_line_count) = terminal.update(cx, |terminal, _| {
+ (terminal.get_content(), terminal.total_lines())
+ })?;
+
+ let previous_len = content.len();
+ let (processed_content, finished_with_empty_output) =
+ process_content(content, &input.command, exit_status);
+
+ let _ = card.update(cx, |card, _| {
+ card.command_finished = true;
+ card.exit_status = exit_status;
+ card.was_content_truncated = processed_content.len() < previous_len;
+ card.original_content_len = previous_len;
+ card.content_line_count = content_line_count;
+ card.finished_with_empty_output = finished_with_empty_output;
+ card.elapsed_time = Some(card.start_instant.elapsed());
+ });
+
+ Ok(processed_content)
+ }
+ });
- cx.background_spawn(run_command_limited(working_dir, input.command))
- .into()
+ ToolResult {
+ output,
+ card: Some(card.into()),
+ }
}
}
-const LIMIT: usize = 16 * 1024;
-
-async fn run_command_limited(working_dir: Arc<Path>, command: String) -> Result<String> {
- let shell = get_system_shell();
-
- let mut cmd = new_smol_command(&shell)
- .arg("-c")
- .arg(&command)
- .current_dir(working_dir)
- .stdout(std::process::Stdio::piped())
- .stderr(std::process::Stdio::piped())
- .spawn()
- .context("Failed to execute terminal command")?;
-
- let mut combined_buffer = String::with_capacity(LIMIT + 1);
-
- let mut out_reader = BufReader::new(cmd.stdout.take().context("Failed to get stdout")?);
- let mut out_tmp_buffer = String::with_capacity(512);
- let mut err_reader = BufReader::new(cmd.stderr.take().context("Failed to get stderr")?);
- let mut err_tmp_buffer = String::with_capacity(512);
-
- let mut out_line = Box::pin(
- out_reader
- .read_line(&mut out_tmp_buffer)
- .left_future()
- .fuse(),
- );
- let mut err_line = Box::pin(
- err_reader
- .read_line(&mut err_tmp_buffer)
- .left_future()
- .fuse(),
+fn process_content(
+ content: String,
+ command: &str,
+ exit_status: Option<ExitStatus>,
+) -> (String, bool) {
+ let should_truncate = content.len() > COMMAND_OUTPUT_LIMIT;
+
+ let content = if should_truncate {
+ let mut end_ix = COMMAND_OUTPUT_LIMIT.min(content.len());
+ while !content.is_char_boundary(end_ix) {
+ end_ix -= 1;
+ }
+ // Don't truncate mid-line, clear the remainder of the last line
+ end_ix = content[..end_ix].rfind('\n').unwrap_or(end_ix);
+ &content[..end_ix]
+ } else {
+ content.as_str()
+ };
+ let is_empty = content.trim().is_empty();
+
+ let content = format!(
+ "```\n{}{}```",
+ content,
+ if content.ends_with('\n') { "" } else { "\n" }
);
- let mut has_stdout = true;
- let mut has_stderr = true;
- while (has_stdout || has_stderr) && combined_buffer.len() < LIMIT + 1 {
- futures::select_biased! {
- read = out_line => {
- drop(out_line);
- combined_buffer.extend(out_tmp_buffer.drain(..));
- if read? == 0 {
- out_line = Box::pin(future::pending().right_future().fuse());
- has_stdout = false;
- } else {
- out_line = Box::pin(out_reader.read_line(&mut out_tmp_buffer).left_future().fuse());
- }
- }
- read = err_line => {
- drop(err_line);
- combined_buffer.extend(err_tmp_buffer.drain(..));
- if read? == 0 {
- err_line = Box::pin(future::pending().right_future().fuse());
- has_stderr = false;
- } else {
- err_line = Box::pin(err_reader.read_line(&mut err_tmp_buffer).left_future().fuse());
- }
- }
- };
- }
-
- drop((out_line, err_line));
-
- let truncated = combined_buffer.len() > LIMIT;
- combined_buffer.truncate(LIMIT);
-
- consume_reader(out_reader, truncated).await?;
- consume_reader(err_reader, truncated).await?;
-
- // Handle potential errors during status retrieval, including interruption.
- match cmd.status().await {
- Ok(status) => {
- let output_string = if truncated {
- // Valid to find `\n` in UTF-8 since 0-127 ASCII characters are not used in
- // multi-byte characters.
- let last_line_ix = combined_buffer.bytes().rposition(|b| b == b'\n');
- let buffer_content =
- &combined_buffer[..last_line_ix.unwrap_or(combined_buffer.len())];
-
- format!(
- "Command output too long. The first {} bytes:\n\n{}",
- buffer_content.len(),
- output_block(buffer_content),
- )
+ let content = if should_truncate {
+ format!(
+ "Command output too long. The first {} bytes:\n\n{}",
+ content.len(),
+ content,
+ )
+ } else {
+ content
+ };
+
+ let content = match exit_status {
+ Some(exit_status) if exit_status.success() => {
+ if is_empty {
+ "Command executed successfully.".to_string()
} else {
- output_block(&combined_buffer)
- };
-
- let output_with_status = if status.success() {
- if output_string.is_empty() {
- "Command executed successfully.".to_string()
- } else {
- output_string
- }
+ content.to_string()
+ }
+ }
+ Some(exit_status) => {
+ let code = exit_status.code().unwrap_or(-1);
+ if is_empty {
+ format!("Command \"{command}\" failed with exit code {code}.")
} else {
- format!(
- "Command failed with exit code {} (shell: {}).\n\n{}",
- status.code().unwrap_or(-1),
- shell,
- output_string,
- )
- };
-
- Ok(output_with_status)
+ format!("Command \"{command}\" failed with exit code {code}.\n\n{content}")
+ }
}
- Err(err) => {
- // Error occurred getting status (potential interruption). Include partial output.
- let partial_output = output_block(&combined_buffer);
- let error_message = format!(
+ None => {
+ format!(
"Command failed or was interrupted.\nPartial output captured:\n\n{}",
- partial_output
- );
- Err(anyhow!(err).context(error_message))
+ content,
+ )
}
- }
+ };
+ (content, is_empty)
}
-async fn consume_reader<T: AsyncReadExt + Unpin>(
- mut reader: BufReader<T>,
- truncated: bool,
-) -> Result<(), std::io::Error> {
- loop {
- let skipped_bytes = reader.fill_buf().await?;
- if skipped_bytes.is_empty() {
- break;
+fn working_dir(
+ cx: &mut App,
+ input: &TerminalToolInput,
+ project: &Entity<Project>,
+ input_path: &Path,
+) -> Result<Option<PathBuf>, &'static str> {
+ let project = project.read(cx);
+
+ if input.cd == "." {
+ // Accept "." as meaning "the one worktree" if we only have one worktree.
+ let mut worktrees = project.worktrees(cx);
+
+ match worktrees.next() {
+ Some(worktree) => {
+ if worktrees.next().is_some() {
+ return Err(
+ "'.' is ambiguous in multi-root workspaces. Please specify a root directory explicitly.",
+ );
+ }
+ Ok(Some(worktree.read(cx).abs_path().to_path_buf()))
+ }
+ None => Ok(None),
}
- let skipped_bytes_len = skipped_bytes.len();
- reader.consume_unpin(skipped_bytes_len);
+ } else if input_path.is_absolute() {
+ // Absolute paths are allowed, but only if they're in one of the project's worktrees.
+ if !project
+ .worktrees(cx)
+ .any(|worktree| input_path.starts_with(&worktree.read(cx).abs_path()))
+ {
+ return Err("The absolute path must be within one of the project's worktrees");
+ }
+
+ Ok(Some(input_path.into()))
+ } else {
+ let Some(worktree) = project.worktree_for_root_name(&input.cd, cx) else {
+ return Err("`cd` directory {} not found in the project");
+ };
- // Should only skip if we went over the limit
- debug_assert!(truncated);
+ Ok(Some(worktree.read(cx).abs_path().to_path_buf()))
}
- Ok(())
}
-fn output_block(output: &str) -> String {
- format!(
- "```\n{}{}```",
- output,
- if output.ends_with('\n') { "" } else { "\n" }
- )
+struct TerminalToolCard {
+ input_command: String,
+ working_dir: Option<PathBuf>,
+ entity_id: EntityId,
+ exit_status: Option<ExitStatus>,
+ terminal: Option<Entity<TerminalView>>,
+ command_finished: bool,
+ was_content_truncated: bool,
+ finished_with_empty_output: bool,
+ content_line_count: usize,
+ original_content_len: usize,
+ preview_expanded: bool,
+ start_instant: Instant,
+ elapsed_time: Option<Duration>,
}
-#[cfg(test)]
-#[cfg(not(windows))]
-mod tests {
- use gpui::TestAppContext;
-
- use super::*;
-
- #[gpui::test(iterations = 10)]
- async fn test_run_command_simple(cx: &mut TestAppContext) {
- cx.executor().allow_parking();
-
- let result =
- run_command_limited(Path::new(".").into(), "echo 'Hello, World!'".to_string()).await;
-
- assert!(result.is_ok());
- assert_eq!(result.unwrap(), "```\nHello, World!\n```");
- }
-
- #[gpui::test(iterations = 10)]
- async fn test_interleaved_stdout_stderr(cx: &mut TestAppContext) {
- cx.executor().allow_parking();
-
- let command = "echo 'stdout 1' && sleep 0.01 && echo 'stderr 1' >&2 && sleep 0.01 && echo 'stdout 2' && sleep 0.01 && echo 'stderr 2' >&2";
- let result = run_command_limited(Path::new(".").into(), command.to_string()).await;
-
- assert!(result.is_ok());
- assert_eq!(
- result.unwrap(),
- "```\nstdout 1\nstderr 1\nstdout 2\nstderr 2\n```"
- );
- }
-
- #[gpui::test(iterations = 10)]
- async fn test_multiple_output_reads(cx: &mut TestAppContext) {
- cx.executor().allow_parking();
-
- // Command with multiple outputs that might require multiple reads
- let result = run_command_limited(
- Path::new(".").into(),
- "echo '1'; sleep 0.01; echo '2'; sleep 0.01; echo '3'".to_string(),
- )
- .await;
-
- assert!(result.is_ok());
- assert_eq!(result.unwrap(), "```\n1\n2\n3\n```");
- }
-
- #[gpui::test(iterations = 10)]
- async fn test_output_truncation_single_line(cx: &mut TestAppContext) {
- cx.executor().allow_parking();
-
- let cmd = format!("echo '{}'; sleep 0.01;", "X".repeat(LIMIT * 2));
-
- let result = run_command_limited(Path::new(".").into(), cmd).await;
-
- assert!(result.is_ok());
- let output = result.unwrap();
-
- let content_start = output.find("```\n").map(|i| i + 4).unwrap_or(0);
- let content_end = output.rfind("\n```").unwrap_or(output.len());
- let content_length = content_end - content_start;
-
- // Output should be exactly the limit
- assert_eq!(content_length, LIMIT);
- }
-
- #[gpui::test(iterations = 10)]
- async fn test_output_truncation_multiline(cx: &mut TestAppContext) {
- cx.executor().allow_parking();
-
- let cmd = format!("echo '{}'; ", "X".repeat(120)).repeat(160);
- let result = run_command_limited(Path::new(".").into(), cmd).await;
-
- assert!(result.is_ok());
- let output = result.unwrap();
-
- assert!(output.starts_with("Command output too long. The first 16334 bytes:\n\n"));
-
- let content_start = output.find("```\n").map(|i| i + 4).unwrap_or(0);
- let content_end = output.rfind("\n```").unwrap_or(output.len());
- let content_length = content_end - content_start;
-
- assert!(content_length <= LIMIT);
+impl TerminalToolCard {
+ pub fn new(input_command: String, working_dir: Option<PathBuf>, entity_id: EntityId) -> Self {
+ Self {
+ input_command,
+ working_dir,
+ entity_id,
+ exit_status: None,
+ terminal: None,
+ command_finished: false,
+ was_content_truncated: false,
+ finished_with_empty_output: false,
+ original_content_len: 0,
+ content_line_count: 0,
+ preview_expanded: true,
+ start_instant: Instant::now(),
+ elapsed_time: None,
+ }
}
+}
- #[gpui::test(iterations = 10)]
- async fn test_command_failure(cx: &mut TestAppContext) {
- cx.executor().allow_parking();
-
- let result = run_command_limited(Path::new(".").into(), "exit 42".to_string()).await;
-
- assert!(result.is_ok());
- let output = result.unwrap();
-
- // Extract the shell name from path for cleaner test output
- let shell_path = std::env::var("SHELL").unwrap_or("bash".to_string());
+impl ToolCard for TerminalToolCard {
+ fn render(
+ &mut self,
+ status: &ToolUseStatus,
+ _window: &mut Window,
+ _workspace: WeakEntity<Workspace>,
+ cx: &mut Context<Self>,
+ ) -> impl IntoElement {
+ let Some(terminal) = self.terminal.as_ref() else {
+ return Empty.into_any();
+ };
- let expected_output = format!(
- "Command failed with exit code 42 (shell: {}).\n\n```\n\n```",
- shell_path
- );
- assert_eq!(output, expected_output);
+ let tool_failed = matches!(status, ToolUseStatus::Error(_));
+ let command_failed =
+ self.command_finished && self.exit_status.is_none_or(|code| !code.success());
+ if (tool_failed || command_failed) && self.elapsed_time.is_none() {
+ self.elapsed_time = Some(self.start_instant.elapsed());
+ }
+ let time_elapsed = self
+ .elapsed_time
+ .unwrap_or_else(|| self.start_instant.elapsed());
+ let should_hide_terminal =
+ tool_failed || self.finished_with_empty_output || !self.preview_expanded;
+
+ let border_color = cx.theme().colors().border.opacity(0.6);
+ let header_bg = cx
+ .theme()
+ .colors()
+ .element_background
+ .blend(cx.theme().colors().editor_foreground.opacity(0.025));
+
+ let header_label = h_flex()
+ .w_full()
+ .max_w_full()
+ .px_1()
+ .gap_0p5()
+ .opacity(0.8)
+ .child(
+ h_flex()
+ .child(
+ Icon::new(IconName::Terminal)
+ .size(IconSize::XSmall)
+ .color(Color::Muted),
+ )
+ .child(
+ div()
+ .id(("terminal-tool-header-input-command", self.entity_id))
+ .text_size(rems(0.8125))
+ .font_buffer(cx)
+ .child(self.input_command.clone())
+ .ml_1p5()
+ .mr_0p5()
+ .tooltip({
+ let path = self
+ .working_dir
+ .as_ref()
+ .cloned()
+ .or_else(|| env::current_dir().ok())
+ .map(|path| format!("\"{}\"", path.display()))
+ .unwrap_or_else(|| "current directory".to_string());
+ Tooltip::text(if self.command_finished {
+ format!("Ran in {path}")
+ } else {
+ format!("Running in {path}")
+ })
+ }),
+ ),
+ )
+ .into_any_element();
+
+ let header = h_flex()
+ .flex_none()
+ .p_1()
+ .gap_1()
+ .justify_between()
+ .rounded_t_md()
+ .bg(header_bg)
+ .child(header_label)
+ .map(|header| {
+ let header = header
+ .when(self.was_content_truncated, |header| {
+ let tooltip =
+ if self.content_line_count + 10 > terminal::MAX_SCROLL_HISTORY_LINES {
+ "Output exceeded terminal max lines and was \
+ truncated, the model received the first 16 KB."
+ .to_string()
+ } else {
+ format!(
+ "Output is {} long, to avoid unexpected token usage, \
+ only 16 KB was sent back to the model.",
+ format_file_size(self.original_content_len as u64, true),
+ )
+ };
+ header.child(
+ div()
+ .id(("terminal-tool-truncated-label", self.entity_id))
+ .tooltip(Tooltip::text(tooltip))
+ .child(
+ Label::new("(truncated)")
+ .color(Color::Disabled)
+ .size(LabelSize::Small),
+ ),
+ )
+ })
+ .when(time_elapsed > Duration::from_secs(10), |header| {
+ header.child(
+ Label::new(format!("({})", duration_alt_display(time_elapsed)))
+ .buffer_font(cx)
+ .color(Color::Disabled)
+ .size(LabelSize::Small),
+ )
+ });
+
+ if tool_failed || command_failed {
+ header.child(
+ div()
+ .id(("terminal-tool-error-code-indicator", self.entity_id))
+ .child(
+ Icon::new(IconName::Close)
+ .size(IconSize::Small)
+ .color(Color::Error),
+ )
+ .when(command_failed && self.exit_status.is_some(), |this| {
+ this.tooltip(Tooltip::text(format!(
+ "Exited with code {}",
+ self.exit_status
+ .and_then(|status| status.code())
+ .unwrap_or(-1),
+ )))
+ })
+ .when(
+ !command_failed && tool_failed && status.error().is_some(),
+ |this| {
+ this.tooltip(Tooltip::text(format!(
+ "Error: {}",
+ status.error().unwrap(),
+ )))
+ },
+ ),
+ )
+ } else if self.command_finished {
+ header.child(
+ Icon::new(IconName::Check)
+ .size(IconSize::Small)
+ .color(Color::Success),
+ )
+ } else {
+ header.child(
+ Icon::new(IconName::ArrowCircle)
+ .size(IconSize::Small)
+ .color(Color::Info)
+ .with_animation(
+ "arrow-circle",
+ Animation::new(Duration::from_secs(2)).repeat(),
+ |icon, delta| {
+ icon.transform(Transformation::rotate(percentage(delta)))
+ },
+ ),
+ )
+ }
+ })
+ .when(!tool_failed && !self.finished_with_empty_output, |header| {
+ header.child(
+ Disclosure::new(
+ ("terminal-tool-disclosure", self.entity_id),
+ self.preview_expanded,
+ )
+ .opened_icon(IconName::ChevronUp)
+ .closed_icon(IconName::ChevronDown)
+ .on_click(cx.listener(
+ move |this, _event, _window, _cx| {
+ this.preview_expanded = !this.preview_expanded;
+ },
+ )),
+ )
+ });
+
+ v_flex()
+ .mb_2()
+ .border_1()
+ .when(tool_failed || command_failed, |card| card.border_dashed())
+ .border_color(border_color)
+ .rounded_lg()
+ .overflow_hidden()
+ .child(header)
+ .when(!should_hide_terminal, |this| {
+ this.child(div().child(terminal.clone()).min_h(px(250.0)))
+ })
+ .into_any()
}
}
@@ -1,6 +1,8 @@
Executes a shell one-liner and returns the combined output.
-This tool spawns a process using the user's current shell, combines stdout and stderr into one interleaved stream as they are produced (preserving the order of writes), and captures that stream into a string which is returned.
+This tool spawns a process using the user's shell, reads from stdout and stderr (preserving the order of writes), and returns a string with the combined output result.
+
+The output results will be shown to the user already, only list it again if necessary, avoid being redundant.
Make sure you use the `cd` parameter to navigate to one of the root directories of the project. NEVER do it as part of the `command` itself, otherwise it will error.
@@ -23,7 +23,6 @@ pub struct WebSearchToolInput {
query: String,
}
-#[derive(RegisterComponent)]
pub struct WebSearchTool;
impl Tool for WebSearchTool {
@@ -84,6 +83,7 @@ impl Tool for WebSearchTool {
}
}
+#[derive(RegisterComponent)]
struct WebSearchToolCard {
response: Option<Result<WebSearchResponse>>,
_task: Task<()>,
@@ -185,15 +185,11 @@ impl ToolCard for WebSearchToolCard {
}
}
-impl Component for WebSearchTool {
+impl Component for WebSearchToolCard {
fn scope() -> ComponentScope {
ComponentScope::Agent
}
- fn sort_name() -> &'static str {
- "ToolWebSearch"
- }
-
fn preview(window: &mut Window, cx: &mut App) -> Option<AnyElement> {
let in_progress_search = cx.new(|cx| WebSearchToolCard {
response: None,
@@ -2,6 +2,7 @@ use gpui::{Context, Entity, IntoElement, ParentElement, Render, Subscription, di
use project::image_store::{ImageFormat, ImageMetadata};
use settings::Settings;
use ui::prelude::*;
+use util::size::format_file_size;
use workspace::{ItemHandle, StatusItemView, Workspace};
use crate::{ImageFileSizeUnit, ImageView, ImageViewerSettings};
@@ -36,27 +37,9 @@ impl ImageInfo {
}
}
-fn format_file_size(size: u64, image_unit_type: ImageFileSizeUnit) -> String {
- match image_unit_type {
- ImageFileSizeUnit::Binary => {
- if size < 1024 {
- format!("{size}B")
- } else if size < 1024 * 1024 {
- format!("{:.1}KiB", size as f64 / 1024.0)
- } else {
- format!("{:.1}MiB", size as f64 / (1024.0 * 1024.0))
- }
- }
- ImageFileSizeUnit::Decimal => {
- if size < 1000 {
- format!("{size}B")
- } else if size < 1000 * 1000 {
- format!("{:.1}KB", size as f64 / 1000.0)
- } else {
- format!("{:.1}MB", size as f64 / (1000.0 * 1000.0))
- }
- }
- }
+fn format_image_size(size: u64, image_unit_type: ImageFileSizeUnit) -> String {
+ let use_decimal = matches!(image_unit_type, ImageFileSizeUnit::Decimal);
+ format_file_size(size, use_decimal)
}
impl Render for ImageInfo {
@@ -69,7 +52,7 @@ impl Render for ImageInfo {
let mut components = Vec::new();
components.push(format!("{}x{}", metadata.width, metadata.height));
- components.push(format_file_size(metadata.file_size, settings.unit));
+ components.push(format_image_size(metadata.file_size, settings.unit));
if let Some(colors) = metadata.colors {
components.push(format!(
@@ -514,7 +514,7 @@ impl Project {
terminal_handle: &Entity<Terminal>,
cx: &mut App,
) {
- terminal_handle.update(cx, |terminal, _| terminal.input_bytes(command.into_bytes()));
+ terminal_handle.update(cx, |terminal, _| terminal.input(command));
}
pub fn local_terminal_handles(&self) -> &Vec<WeakEntity<terminal::Terminal>> {
@@ -313,7 +313,7 @@ impl Display for TerminalError {
// https://github.com/alacritty/alacritty/blob/cb3a79dbf6472740daca8440d5166c1d4af5029e/extra/man/alacritty.5.scd?plain=1#L207-L213
const DEFAULT_SCROLL_HISTORY_LINES: usize = 10_000;
-const MAX_SCROLL_HISTORY_LINES: usize = 100_000;
+pub const MAX_SCROLL_HISTORY_LINES: usize = 100_000;
const URL_REGEX: &str = r#"(ipfs:|ipns:|magnet:|mailto:|gemini://|gopher://|https://|http://|news:|file://|git://|ssh:|ftp://)[^\u{0000}-\u{001F}\u{007F}-\u{009F}<>"\s{-}\^⟨⟩`]+"#;
// Optional suffix matches MSBuild diagnostic suffixes for path parsing in PathLikeWithPosition
// https://learn.microsoft.com/en-us/visualstudio/msbuild/msbuild-diagnostic-format-for-tasks
@@ -1216,15 +1216,11 @@ impl Terminal {
}
///Write the Input payload to the tty.
- fn write_to_pty(&self, input: String) {
- self.pty_tx.notify(input.into_bytes());
+ fn write_to_pty(&self, input: impl Into<Vec<u8>>) {
+ self.pty_tx.notify(input.into());
}
- fn write_bytes_to_pty(&self, input: Vec<u8>) {
- self.pty_tx.notify(input);
- }
-
- pub fn input(&mut self, input: String) {
+ pub fn input(&mut self, input: impl Into<Vec<u8>>) {
self.events
.push_back(InternalEvent::Scroll(AlacScroll::Bottom));
self.events.push_back(InternalEvent::SetSelection(None));
@@ -1232,14 +1228,6 @@ impl Terminal {
self.write_to_pty(input);
}
- pub fn input_bytes(&mut self, input: Vec<u8>) {
- self.events
- .push_back(InternalEvent::Scroll(AlacScroll::Bottom));
- self.events.push_back(InternalEvent::SetSelection(None));
-
- self.write_bytes_to_pty(input);
- }
-
pub fn toggle_vi_mode(&mut self) {
self.events.push_back(InternalEvent::ToggleViMode);
}
@@ -1420,6 +1408,13 @@ impl Terminal {
}
}
+ pub fn get_content(&self) -> String {
+ let term = self.term.lock_unfair();
+ let start = AlacPoint::new(term.topmost_line(), Column(0));
+ let end = AlacPoint::new(term.bottommost_line(), term.last_column());
+ term.bounds_to_string(start, end)
+ }
+
pub fn last_n_non_empty_lines(&self, n: usize) -> Vec<String> {
let term = self.term.clone();
let terminal = term.lock_unfair();
@@ -1858,6 +1853,8 @@ impl Terminal {
let completion_receiver = task.completion_rx.clone();
return cx
.spawn(async move |_| completion_receiver.recv().await.log_err().flatten());
+ } else if let Ok(status) = task.completion_rx.try_recv() {
+ return Task::ready(status);
}
}
Task::ready(None)
@@ -1036,7 +1036,7 @@ impl InputHandler for TerminalInputHandler {
cx: &mut App,
) {
self.terminal.update(cx, |terminal, _| {
- terminal.input(text.into());
+ terminal.input(text);
});
self.workspace
@@ -1133,7 +1133,7 @@ async fn wait_for_terminals_tasks(
})
.ok()
});
- let _: Vec<_> = join_all(pending_tasks).await;
+ join_all(pending_tasks).await;
}
fn add_paths_to_terminal(
@@ -0,0 +1,48 @@
+pub fn format_file_size(size: u64, use_decimal: bool) -> String {
+ if use_decimal {
+ if size < 1000 {
+ format!("{size}B")
+ } else if size < 1000 * 1000 {
+ format!("{:.1}KB", size as f64 / 1000.0)
+ } else {
+ format!("{:.1}MB", size as f64 / (1000.0 * 1000.0))
+ }
+ } else {
+ if size < 1024 {
+ format!("{size}B")
+ } else if size < 1024 * 1024 {
+ format!("{:.1}KiB", size as f64 / 1024.0)
+ } else {
+ format!("{:.1}MiB", size as f64 / (1024.0 * 1024.0))
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_format_file_size_decimal() {
+ assert_eq!(format_file_size(0, true), "0B");
+ assert_eq!(format_file_size(999, true), "999B");
+ assert_eq!(format_file_size(1000, true), "1.0KB");
+ assert_eq!(format_file_size(1500, true), "1.5KB");
+ assert_eq!(format_file_size(999999, true), "1000.0KB");
+ assert_eq!(format_file_size(1000000, true), "1.0MB");
+ assert_eq!(format_file_size(1500000, true), "1.5MB");
+ assert_eq!(format_file_size(10000000, true), "10.0MB");
+ }
+
+ #[test]
+ fn test_format_file_size_binary() {
+ assert_eq!(format_file_size(0, false), "0B");
+ assert_eq!(format_file_size(1023, false), "1023B");
+ assert_eq!(format_file_size(1024, false), "1.0KiB");
+ assert_eq!(format_file_size(1536, false), "1.5KiB");
+ assert_eq!(format_file_size(1048575, false), "1024.0KiB");
+ assert_eq!(format_file_size(1048576, false), "1.0MiB");
+ assert_eq!(format_file_size(1572864, false), "1.5MiB");
+ assert_eq!(format_file_size(10485760, false), "10.0MiB");
+ }
+}
@@ -0,0 +1,41 @@
+use std::time::Duration;
+
+pub fn duration_alt_display(duration: Duration) -> String {
+ if duration < Duration::from_secs(60) {
+ format!("{}s", duration.as_secs())
+ } else {
+ duration_clock_format(duration)
+ }
+}
+
+fn duration_clock_format(duration: Duration) -> String {
+ let hours = duration.as_secs() / 3600;
+ let minutes = (duration.as_secs() % 3600) / 60;
+ let seconds = duration.as_secs() % 60;
+
+ if hours > 0 {
+ format!("{hours}:{minutes:02}:{seconds:02}")
+ } else if minutes > 0 {
+ format!("{minutes}:{seconds:02}")
+ } else {
+ format!("{seconds}")
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_duration_to_clock_format() {
+ use duration_clock_format as f;
+ assert_eq!("0", f(Duration::from_secs(0)));
+ assert_eq!("59", f(Duration::from_secs(59)));
+ assert_eq!("1:00", f(Duration::from_secs(60)));
+ assert_eq!("10:00", f(Duration::from_secs(600)));
+ assert_eq!("1:00:00", f(Duration::from_secs(3600)));
+ assert_eq!("3:02:01", f(Duration::from_secs(3600 * 3 + 60 * 2 + 1)));
+ assert_eq!("23:59:59", f(Duration::from_secs(3600 * 24 - 1)));
+ assert_eq!("100:00:00", f(Duration::from_secs(3600 * 100)));
+ }
+}
@@ -4,8 +4,10 @@ pub mod fs;
pub mod markdown;
pub mod paths;
pub mod serde;
+pub mod size;
#[cfg(any(test, feature = "test-support"))]
pub mod test;
+pub mod time;
use anyhow::Result;
use futures::Future;