assistant: Add `term` slash command (#13162)

Bennet Bo Fenner created

This adds a `term` slash command to the assistant which allows to inject
the latest terminal output into the context.

Release Notes:

- N/A

Change summary

Cargo.lock                                         |   1 
crates/assistant/Cargo.toml                        |   1 
crates/assistant/src/assistant.rs                  |   3 
crates/assistant/src/slash_command.rs              |   1 
crates/assistant/src/slash_command/term_command.rs | 105 ++++++++++++++++
crates/terminal/src/terminal.rs                    |  25 +++
6 files changed, 135 insertions(+), 1 deletion(-)

Detailed changes

Cargo.lock 🔗

@@ -405,6 +405,7 @@ dependencies = [
  "strsim 0.11.1",
  "strum",
  "telemetry_events",
+ "terminal_view",
  "theme",
  "tiktoken-rs",
  "toml 0.8.10",

crates/assistant/Cargo.toml 🔗

@@ -55,6 +55,7 @@ smol.workspace = true
 strsim = "0.11"
 strum.workspace = true
 telemetry_events.workspace = true
+terminal_view.workspace = true
 theme.workspace = true
 tiktoken-rs.workspace = true
 toml.workspace = true

crates/assistant/src/assistant.rs 🔗

@@ -27,7 +27,7 @@ use serde::{Deserialize, Serialize};
 use settings::{Settings, SettingsStore};
 use slash_command::{
     active_command, default_command, diagnostics_command, fetch_command, file_command, now_command,
-    project_command, prompt_command, rustdoc_command, search_command, tabs_command,
+    project_command, prompt_command, rustdoc_command, search_command, tabs_command, term_command,
 };
 use std::{
     fmt::{self, Display},
@@ -314,6 +314,7 @@ fn register_slash_commands(cx: &mut AppContext) {
     slash_command_registry.register_command(search_command::SearchSlashCommand, true);
     slash_command_registry.register_command(prompt_command::PromptSlashCommand, true);
     slash_command_registry.register_command(default_command::DefaultSlashCommand, true);
+    slash_command_registry.register_command(term_command::TermSlashCommand, true);
     slash_command_registry.register_command(now_command::NowSlashCommand, true);
     slash_command_registry.register_command(diagnostics_command::DiagnosticsCommand, true);
     slash_command_registry.register_command(rustdoc_command::RustdocSlashCommand, false);

crates/assistant/src/slash_command.rs 🔗

@@ -28,6 +28,7 @@ pub mod prompt_command;
 pub mod rustdoc_command;
 pub mod search_command;
 pub mod tabs_command;
+pub mod term_command;
 
 pub(crate) struct SlashCommandCompletionProvider {
     commands: Arc<SlashCommandRegistry>,

crates/assistant/src/slash_command/term_command.rs 🔗

@@ -0,0 +1,105 @@
+use std::sync::atomic::AtomicBool;
+use std::sync::Arc;
+
+use anyhow::Result;
+use assistant_slash_command::{SlashCommand, SlashCommandOutput, SlashCommandOutputSection};
+use gpui::{AppContext, Task, WeakView};
+use language::{CodeLabel, LspAdapterDelegate};
+use terminal_view::{terminal_panel::TerminalPanel, TerminalView};
+use ui::prelude::*;
+use workspace::Workspace;
+
+use super::create_label_for_command;
+
+pub(crate) struct TermSlashCommand;
+
+const LINE_COUNT_ARG: &str = "--line-count";
+
+impl SlashCommand for TermSlashCommand {
+    fn name(&self) -> String {
+        "term".into()
+    }
+
+    fn label(&self, cx: &AppContext) -> CodeLabel {
+        create_label_for_command("term", &[LINE_COUNT_ARG], cx)
+    }
+
+    fn description(&self) -> String {
+        "insert terminal output".into()
+    }
+
+    fn menu_text(&self) -> String {
+        "Insert terminal output".into()
+    }
+
+    fn requires_argument(&self) -> bool {
+        false
+    }
+
+    fn complete_argument(
+        self: Arc<Self>,
+        _query: String,
+        _cancel: Arc<AtomicBool>,
+        _workspace: Option<WeakView<Workspace>>,
+        _cx: &mut AppContext,
+    ) -> Task<Result<Vec<String>>> {
+        Task::ready(Ok(vec![LINE_COUNT_ARG.to_string()]))
+    }
+
+    fn run(
+        self: Arc<Self>,
+        argument: Option<&str>,
+        workspace: WeakView<Workspace>,
+        _delegate: Arc<dyn LspAdapterDelegate>,
+        cx: &mut WindowContext,
+    ) -> Task<Result<SlashCommandOutput>> {
+        let Some(workspace) = workspace.upgrade() else {
+            return Task::ready(Err(anyhow::anyhow!("workspace was dropped")));
+        };
+        let Some(terminal_panel) = workspace.read(cx).panel::<TerminalPanel>(cx) else {
+            return Task::ready(Err(anyhow::anyhow!("no terminal panel open")));
+        };
+        let Some(active_terminal) = terminal_panel
+            .read(cx)
+            .pane()
+            .read(cx)
+            .active_item()
+            .and_then(|t| t.downcast::<TerminalView>())
+        else {
+            return Task::ready(Err(anyhow::anyhow!("no active terminal")));
+        };
+
+        let line_count = argument.and_then(|a| parse_argument(a)).unwrap_or(20);
+
+        let lines = active_terminal
+            .read(cx)
+            .model()
+            .read(cx)
+            .last_n_non_empty_lines(line_count);
+
+        let mut text = String::new();
+        text.push_str("Terminal output:\n");
+        text.push_str(&lines.join("\n"));
+        let range = 0..text.len();
+
+        Task::ready(Ok(SlashCommandOutput {
+            text,
+            sections: vec![SlashCommandOutputSection {
+                range,
+                icon: IconName::Terminal,
+                label: "Terminal".into(),
+            }],
+            run_commands_in_text: false,
+        }))
+    }
+}
+
+fn parse_argument(argument: &str) -> Option<usize> {
+    let mut args = argument.split(' ');
+    if args.next() == Some(LINE_COUNT_ARG) {
+        if let Some(line_count) = args.next().and_then(|s| s.parse::<usize>().ok()) {
+            return Some(line_count);
+        }
+    }
+    None
+}

crates/terminal/src/terminal.rs 🔗

@@ -1083,6 +1083,31 @@ impl Terminal {
         }
     }
 
+    pub fn last_n_non_empty_lines(&self, n: usize) -> Vec<String> {
+        let term = self.term.clone();
+        let terminal = term.lock_unfair();
+
+        let mut lines = Vec::new();
+        let mut current_line = terminal.bottommost_line();
+        while lines.len() < n {
+            let mut line_buffer = String::new();
+            for cell in &terminal.grid()[current_line] {
+                line_buffer.push(cell.c);
+            }
+            let line = line_buffer.trim_end();
+            if !line.is_empty() {
+                lines.push(line.to_string());
+            }
+
+            if current_line == terminal.topmost_line() {
+                break;
+            }
+            current_line = Line(current_line.0 - 1);
+        }
+        lines.reverse();
+        lines
+    }
+
     pub fn focus_in(&self) {
         if self.last_content.mode.contains(TermMode::FOCUS_IN_OUT) {
             self.write_to_pty("\x1b[I".to_string());