Add display-only terminals

Richard Feldman created

Change summary

crates/acp_thread/src/acp_thread.rs              | 116 +++++--
crates/acp_thread/src/terminal.rs                |  64 ++++
crates/debugger_ui/src/session/running.rs        |   5 
crates/terminal/src/terminal.rs                  | 241 ++++++++++++++---
crates/terminal_view/src/terminal_tab_tooltip.rs |  16 
crates/terminal_view/src/terminal_view.rs        |   4 
6 files changed, 346 insertions(+), 100 deletions(-)

Detailed changes

crates/acp_thread/src/acp_thread.rs 🔗

@@ -3,6 +3,8 @@ mod diff;
 mod mention;
 mod terminal;
 
+use ::terminal::TerminalBuilder;
+use ::terminal::terminal_settings::{AlternateScroll, CursorShape};
 use agent_settings::AgentSettings;
 use collections::HashSet;
 pub use connection::*;
@@ -1144,8 +1146,32 @@ impl AcpThread {
 
         match update {
             ToolCallUpdate::UpdateFields(update) => {
+                // Check if there's terminal output in the meta field
+                let terminal_output_result = update
+                    .meta
+                    .as_ref()
+                    .and_then(|meta| meta.get("terminal_output"))
+                    .and_then(|terminal_output| {
+                        match (
+                            terminal_output.get("terminal_id").and_then(|v| v.as_str()),
+                            terminal_output.get("data").and_then(|v| v.as_str()),
+                        ) {
+                            (Some(terminal_id_str), Some(data_str)) => {
+                                let data = data_str.as_bytes().to_vec();
+                                let terminal_id = acp::TerminalId(terminal_id_str.into());
+                                Some((terminal_id, data))
+                            }
+                            _ => None,
+                        }
+                    });
+
                 let location_updated = update.fields.locations.is_some();
                 call.update_fields(update.fields, languages, &self.terminals, cx)?;
+
+                if let Some((terminal_id, data)) = terminal_output_result {
+                    // Silently ignore errors - terminal output streaming is best-effort
+                    let _ = self.write_terminal_output(terminal_id, &data, cx);
+                }
                 if location_updated {
                     self.resolve_locations(update.id, cx);
                 }
@@ -1991,32 +2017,16 @@ impl AcpThread {
                 .redirect_stdin_to_dev_null()
                 .build(Some(command), &args);
 
-                // For display-only terminals, create a terminal that doesn't actually execute
                 let terminal = if is_display_only {
-                    // Create a display-only terminal that just shows a header
-                    // The actual output will be streamed from the agent
-                    project
-                        .update(cx, |project, cx| {
-                            project.create_terminal_task(
-                                task::SpawnInTerminal {
-                                    // Use a simple command that shows the header and waits
-                                    // We use sleep to keep the terminal alive for output streaming
-                                    command: Some("sh".to_string()),
-                                    args: vec![
-                                        "-c".to_string(),
-                                        format!(
-                                            "echo '\\033[1;34m[Display Terminal]\\033[0m {}' && sleep 86400",
-                                            command
-                                        ),
-                                    ],
-                                    cwd: cwd.clone(),
-                                    env: Default::default(),  // Don't pass environment to avoid printing it
-                                    ..Default::default()
-                                },
-                                cx,
-                            )
-                        })?
-                        .await?
+                    cx.update(|cx| {
+                        TerminalBuilder::new_display_only(
+                            Some(format!("Display: {}", command).into()),
+                            CursorShape::Block,
+                            AlternateScroll::On,
+                            Some(10_000),
+                            cx,
+                        )
+                    })??
                 } else {
                     project
                         .update(cx, |project, cx| {
@@ -2034,17 +2044,31 @@ impl AcpThread {
                         .await?
                 };
 
-                cx.new(|cx| {
-                    Terminal::new(
-                        terminal_id,
-                        &format!("{} {}", command, args.join(" ")),
-                        cwd,
-                        output_byte_limit.map(|l| l as usize),
-                        terminal,
-                        language_registry,
-                        cx,
-                    )
-                })
+                if is_display_only {
+                    // For display-only terminals, we need special handling
+                    cx.new(|cx| {
+                        Terminal::new_display_only(
+                            terminal_id,
+                            &format!("{} {}", command, args.join(" ")),
+                            cwd,
+                            output_byte_limit.map(|l| l as usize),
+                            terminal,
+                            cx,
+                        )
+                    })
+                } else {
+                    cx.new(|cx| {
+                        Terminal::new(
+                            terminal_id,
+                            &format!("{} {}", command, args.join(" ")),
+                            cwd,
+                            output_byte_limit.map(|l| l as usize),
+                            terminal,
+                            language_registry,
+                            cx,
+                        )
+                    })
+                }
             }
         });
 
@@ -2090,8 +2114,26 @@ impl AcpThread {
     pub fn terminal(&self, terminal_id: acp::TerminalId) -> Result<Entity<Terminal>> {
         self.terminals
             .get(&terminal_id)
-            .context("Terminal not found")
             .cloned()
+            .context("Terminal not found")
+    }
+
+    pub fn write_terminal_output(
+        &mut self,
+        terminal_id: acp::TerminalId,
+        output: &[u8],
+        cx: &mut Context<Self>,
+    ) -> Result<()> {
+        let terminal = self
+            .terminals
+            .get(&terminal_id)
+            .context("Terminal not found")?;
+
+        terminal.update(cx, |terminal, cx| {
+            terminal.write_output(output, cx);
+        });
+
+        Ok(())
     }
 
     pub fn to_markdown(&self, cx: &App) -> String {

crates/acp_thread/src/terminal.rs 🔗

@@ -82,6 +82,70 @@ impl Terminal {
         }
     }
 
+    pub fn new_display_only(
+        id: acp::TerminalId,
+        command_label: &str,
+        working_dir: Option<PathBuf>,
+        output_byte_limit: Option<usize>,
+        terminal: Entity<terminal::Terminal>,
+        cx: &mut Context<Self>,
+    ) -> Self {
+        // Display-only terminals don't have a real process, so there's no exit status
+        let command_task = Task::ready(None);
+
+        Self {
+            id,
+            command: cx.new(|_cx| {
+                // For display-only terminals, we don't need the markdown wrapper
+                // The terminal itself will handle the display
+                Markdown::new(
+                    format!("```\n{}\n```", command_label).into(),
+                    None,
+                    None,
+                    _cx,
+                )
+            }),
+            working_dir,
+            terminal,
+            started_at: Instant::now(),
+            output: None,
+            output_byte_limit,
+            _output_task: cx
+                .spawn(async move |this, cx| {
+                    // Display-only terminals don't really exit, but we need to handle this
+                    let exit_status = command_task.await;
+
+                    this.update(cx, |this, cx| {
+                        let (content, original_content_len) = this.truncated_output(cx);
+                        let content_line_count = this.terminal.read(cx).total_lines();
+
+                        this.output = Some(TerminalOutput {
+                            ended_at: Instant::now(),
+                            exit_status,
+                            content,
+                            original_content_len,
+                            content_line_count,
+                        });
+                        cx.notify();
+                    })
+                    .ok();
+
+                    acp::TerminalExitStatus {
+                        exit_code: None,
+                        signal: None,
+                        meta: None,
+                    }
+                })
+                .shared(),
+        }
+    }
+
+    pub fn write_output(&mut self, data: &[u8], cx: &mut Context<Self>) {
+        self.terminal.update(cx, |terminal, cx| {
+            terminal.write_output(data, cx);
+        });
+    }
+
     pub fn id(&self) -> &acp::TerminalId {
         &self.id
     }

crates/debugger_ui/src/session/running.rs 🔗

@@ -1228,9 +1228,8 @@ impl RunningState {
 
             terminal.read_with(cx, |terminal, _| {
                 terminal
-                    .pty_info
-                    .pid()
-                    .map(|pid| pid.as_u32())
+                    .pty_info()
+                    .and_then(|info| info.pid().map(|pid| pid.as_u32()))
                     .context("Terminal was spawned but PID was not available")
             })?
         });

crates/terminal/src/terminal.rs 🔗

@@ -64,8 +64,8 @@ use std::{
 use thiserror::Error;
 
 use gpui::{
-    App, AppContext as _, Bounds, ClipboardItem, Context, EventEmitter, Hsla, Keystroke, Modifiers,
-    MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, Rgba,
+    App, AppContext as _, Bounds, ClipboardItem, Context, Entity, EventEmitter, Hsla, Keystroke,
+    Modifiers, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, Rgba,
     ScrollWheelEvent, SharedString, Size, Task, TouchPhase, Window, actions, black, px,
 };
 
@@ -338,6 +338,73 @@ pub struct TerminalBuilder {
 }
 
 impl TerminalBuilder {
+    /// Creates a display-only terminal without a real PTY process
+    pub fn new_display_only(
+        title: Option<SharedString>,
+        cursor_shape: CursorShape,
+        alternate_scroll: AlternateScroll,
+        max_scroll_history_lines: Option<usize>,
+        cx: &mut App,
+    ) -> Result<Entity<Terminal>> {
+        let (events_tx, events_rx) = unbounded();
+
+        let max_scroll_history_lines =
+            max_scroll_history_lines.unwrap_or(DEFAULT_SCROLL_HISTORY_LINES);
+        let config = Config {
+            scrolling_history: max_scroll_history_lines,
+            ..Default::default()
+        };
+
+        let listener = ZedListener(events_tx.clone());
+        let term = Arc::new(FairMutex::new(Term::new(
+            config.clone(),
+            &TerminalBounds::default(),
+            listener.clone(),
+        )));
+
+        let terminal = Terminal {
+            mode: TerminalMode::DisplayOnly,
+            task: None,
+            completion_tx: None,
+            term,
+            term_config: config,
+            title_override: title.clone(),
+            events: VecDeque::with_capacity(10),
+            last_content: Default::default(),
+            last_mouse: None,
+            matches: Vec::new(),
+            selection_head: None,
+            breadcrumb_text: title.map(|t| t.to_string()).unwrap_or_default(),
+            scroll_px: px(0.),
+            next_link_id: 0,
+            selection_phase: SelectionPhase::Ended,
+            hyperlink_regex_searches: RegexSearches::new(),
+            vi_mode_enabled: false,
+            is_ssh_terminal: false,
+            last_mouse_move_time: Instant::now(),
+            last_hyperlink_search_position: None,
+            #[cfg(windows)]
+            shell_program: None,
+            activation_script: Vec::new(),
+            template: CopyTemplate {
+                shell: Shell::System,
+                env: HashMap::default(),
+                cursor_shape,
+                alternate_scroll,
+                max_scroll_history_lines: Some(max_scroll_history_lines),
+                window_id: 0, // Not used for display-only terminals
+            },
+            child_exited: None,
+        };
+
+        let builder = TerminalBuilder {
+            terminal,
+            events_rx,
+        };
+
+        Ok(cx.new(|cx| builder.subscribe(cx)))
+    }
+
     pub fn new(
         working_directory: Option<PathBuf>,
         task: Option<TaskState>,
@@ -497,8 +564,11 @@ impl TerminalBuilder {
         let no_task = task.is_none();
 
         let mut terminal = Terminal {
+            mode: TerminalMode::Shell {
+                pty_tx: Notifier(pty_tx),
+                pty_info,
+            },
             task,
-            pty_tx: Notifier(pty_tx),
             completion_tx,
             term,
             term_config: config,
@@ -508,7 +578,6 @@ impl TerminalBuilder {
             last_mouse: None,
             matches: Vec::new(),
             selection_head: None,
-            pty_info,
             breadcrumb_text: String::new(),
             scroll_px: px(0.),
             next_link_id: 0,
@@ -698,8 +767,16 @@ pub enum SelectionPhase {
     Ended,
 }
 
+pub enum TerminalMode {
+    Shell {
+        pty_tx: Notifier,
+        pty_info: PtyProcessInfo,
+    },
+    DisplayOnly,
+}
+
 pub struct Terminal {
-    pty_tx: Notifier,
+    mode: TerminalMode,
     completion_tx: Option<Sender<Option<ExitStatus>>>,
     term: Arc<FairMutex<Term<ZedListener>>>,
     term_config: Config,
@@ -710,7 +787,6 @@ pub struct Terminal {
     pub last_content: TerminalContent,
     pub selection_head: Option<AlacPoint>,
     pub breadcrumb_text: String,
-    pub pty_info: PtyProcessInfo,
     title_override: Option<SharedString>,
     scroll_px: Pixels,
     next_link_id: usize,
@@ -833,8 +909,10 @@ impl Terminal {
             AlacTermEvent::Wakeup => {
                 cx.emit(Event::Wakeup);
 
-                if self.pty_info.has_changed() {
-                    cx.emit(Event::TitleChanged);
+                if let TerminalMode::Shell { pty_info, .. } = &mut self.mode {
+                    if pty_info.has_changed() {
+                        cx.emit(Event::TitleChanged);
+                    }
                 }
             }
             AlacTermEvent::ColorRequest(index, format) => {
@@ -875,7 +953,9 @@ impl Terminal {
 
                 self.last_content.terminal_bounds = new_bounds;
 
-                self.pty_tx.0.send(Msg::Resize(new_bounds.into())).ok();
+                if let TerminalMode::Shell { pty_tx, .. } = &self.mode {
+                    pty_tx.0.send(Msg::Resize(new_bounds.into())).ok();
+                }
 
                 term.resize(new_bounds);
             }
@@ -1237,7 +1317,35 @@ impl Terminal {
 
     ///Write the Input payload to the tty.
     fn write_to_pty(&self, input: impl Into<Cow<'static, [u8]>>) {
-        self.pty_tx.notify(input.into());
+        if let TerminalMode::Shell { pty_tx, .. } = &self.mode {
+            pty_tx.notify(input.into());
+        }
+    }
+
+    /// Write output directly to a display-only terminal
+    pub fn write_output(&mut self, data: &[u8], cx: &mut Context<Self>) {
+        if !matches!(self.mode, TerminalMode::DisplayOnly) {
+            return;
+        }
+
+        {
+            let mut term = self.term.lock();
+            let text = String::from_utf8_lossy(data);
+            for ch in text.chars() {
+                term.input(ch);
+            }
+        }
+
+        // Notify that content has changed
+        cx.notify();
+    }
+
+    pub fn is_display_only(&self) -> bool {
+        matches!(self.mode, TerminalMode::DisplayOnly)
+    }
+
+    pub fn mode(&self) -> &TerminalMode {
+        &self.mode
     }
 
     pub fn input(&mut self, input: impl Into<Cow<'static, [u8]>>) {
@@ -1541,7 +1649,9 @@ impl Terminal {
                 && let Some(bytes) =
                     mouse_moved_report(point, e.pressed_button, e.modifiers, self.last_content.mode)
             {
-                self.pty_tx.notify(bytes);
+                if let TerminalMode::Shell { pty_tx, .. } = &self.mode {
+                    pty_tx.notify(bytes);
+                }
             }
         } else if e.modifiers.secondary() {
             self.word_from_position(e.position);
@@ -1648,7 +1758,9 @@ impl Terminal {
             if let Some(bytes) =
                 mouse_button_report(point, e.button, e.modifiers, true, self.last_content.mode)
             {
-                self.pty_tx.notify(bytes);
+                if let TerminalMode::Shell { pty_tx, .. } = &self.mode {
+                    pty_tx.notify(bytes);
+                }
             }
         } else {
             match e.button {
@@ -1707,7 +1819,9 @@ impl Terminal {
             if let Some(bytes) =
                 mouse_button_report(point, e.button, e.modifiers, false, self.last_content.mode)
             {
-                self.pty_tx.notify(bytes);
+                if let TerminalMode::Shell { pty_tx, .. } = &self.mode {
+                    pty_tx.notify(bytes);
+                }
             }
         } else {
             if e.button == MouseButton::Left && setting.copy_on_select {
@@ -1745,8 +1859,10 @@ impl Terminal {
 
                 if let Some(scrolls) = scroll_report(point, scroll_lines, e, self.last_content.mode)
                 {
-                    for scroll in scrolls {
-                        self.pty_tx.notify(scroll);
+                    if let TerminalMode::Shell { pty_tx, .. } = &self.mode {
+                        for scroll in scrolls {
+                            pty_tx.notify(scroll);
+                        }
                     }
                 };
             } else if self
@@ -1755,7 +1871,9 @@ impl Terminal {
                 .contains(TermMode::ALT_SCREEN | TermMode::ALTERNATE_SCROLL)
                 && !e.shift
             {
-                self.pty_tx.notify(alt_scroll(scroll_lines))
+                if let TerminalMode::Shell { pty_tx, .. } = &self.mode {
+                    pty_tx.notify(alt_scroll(scroll_lines))
+                }
             } else if scroll_lines != 0 {
                 let scroll = AlacScroll::Delta(scroll_lines);
 
@@ -1826,10 +1944,12 @@ impl Terminal {
     /// This does *not* return the working directory of the shell that runs on the
     /// remote host, in case Zed is connected to a remote host.
     fn client_side_working_directory(&self) -> Option<PathBuf> {
-        self.pty_info
-            .current
-            .as_ref()
-            .map(|process| process.cwd.clone())
+        match &self.mode {
+            TerminalMode::Shell { pty_info, .. } => {
+                pty_info.current.as_ref().map(|info| info.cwd.clone())
+            }
+            TerminalMode::DisplayOnly => None,
+        }
     }
 
     pub fn title(&self, truncate: bool) -> String {
@@ -1847,46 +1967,56 @@ impl Terminal {
                 .as_ref()
                 .map(|title_override| title_override.to_string())
                 .unwrap_or_else(|| {
-                    self.pty_info
-                        .current
-                        .as_ref()
-                        .map(|fpi| {
-                            let process_file = fpi
-                                .cwd
-                                .file_name()
-                                .map(|name| name.to_string_lossy().to_string())
-                                .unwrap_or_default();
-
-                            let argv = fpi.argv.as_slice();
-                            let process_name = format!(
-                                "{}{}",
-                                fpi.name,
-                                if !argv.is_empty() {
-                                    format!(" {}", (argv[1..]).join(" "))
-                                } else {
-                                    "".to_string()
-                                }
-                            );
-                            let (process_file, process_name) = if truncate {
-                                (
-                                    truncate_and_trailoff(&process_file, MAX_CHARS),
-                                    truncate_and_trailoff(&process_name, MAX_CHARS),
-                                )
+                    match &self.mode {
+                        TerminalMode::Shell { pty_info, .. } => pty_info.current.as_ref(),
+                        TerminalMode::DisplayOnly => None,
+                    }
+                    .map(|fpi| {
+                        let process_file = fpi
+                            .cwd
+                            .file_name()
+                            .map(|name| name.to_string_lossy().to_string())
+                            .unwrap_or_default();
+
+                        let argv = fpi.argv.as_slice();
+                        let process_name = format!(
+                            "{}{}",
+                            fpi.name,
+                            if !argv.is_empty() {
+                                format!(" {}", (argv[1..]).join(" "))
                             } else {
-                                (process_file, process_name)
-                            };
-                            format!("{process_file} — {process_name}")
-                        })
-                        .unwrap_or_else(|| "Terminal".to_string())
+                                "".to_string()
+                            }
+                        );
+                        let (process_file, process_name) = if truncate {
+                            (
+                                truncate_and_trailoff(&process_file, MAX_CHARS),
+                                truncate_and_trailoff(&process_name, MAX_CHARS),
+                            )
+                        } else {
+                            (process_file, process_name)
+                        };
+                        format!("{process_file} — {process_name}")
+                    })
+                    .unwrap_or_else(|| "Terminal".to_string())
                 }),
         }
     }
 
+    pub fn pty_info(&self) -> Option<&PtyProcessInfo> {
+        match &self.mode {
+            TerminalMode::Shell { pty_info, .. } => Some(pty_info),
+            TerminalMode::DisplayOnly => None,
+        }
+    }
+
     pub fn kill_active_task(&mut self) {
         if let Some(task) = self.task()
             && task.status == TaskStatus::Running
         {
-            self.pty_info.kill_current_process();
+            if let TerminalMode::Shell { pty_info, .. } = &mut self.mode {
+                pty_info.kill_current_process();
+            }
         }
     }
 
@@ -1896,6 +2026,11 @@ impl Terminal {
 
     pub fn wait_for_completed_task(&self, cx: &App) -> Task<Option<ExitStatus>> {
         if let Some(task) = self.task() {
+            if matches!(self.mode, TerminalMode::DisplayOnly) {
+                // Display-only terminals don't have a real process, so there's no exit status
+                return Task::ready(None);
+            }
+
             if task.status == TaskStatus::Running {
                 let completion_receiver = task.completion_rx.clone();
                 return cx.spawn(async move |_| completion_receiver.recv().await.ok().flatten());
@@ -2073,7 +2208,9 @@ unsafe fn append_text_to_term(term: &mut Term<ZedListener>, text_lines: &[&str])
 
 impl Drop for Terminal {
     fn drop(&mut self) {
-        self.pty_tx.0.send(Msg::Shutdown).ok();
+        if let TerminalMode::Shell { pty_tx, .. } = &self.mode {
+            pty_tx.0.send(Msg::Shutdown).ok();
+        }
     }
 }
 

crates/terminal_view/src/terminal_tab_tooltip.rs 🔗

@@ -3,11 +3,11 @@ use ui::{Divider, prelude::*, tooltip_container};
 
 pub struct TerminalTooltip {
     title: SharedString,
-    pid: u32,
+    pid: Option<u32>,
 }
 
 impl TerminalTooltip {
-    pub fn new(title: impl Into<SharedString>, pid: u32) -> Self {
+    pub fn new(title: impl Into<SharedString>, pid: Option<u32>) -> Self {
         Self {
             title: title.into(),
             pid,
@@ -25,11 +25,13 @@ impl Render for TerminalTooltip {
                         .gap_1()
                         .child(Label::new(self.title.clone()))
                         .child(Divider::horizontal())
-                        .child(
-                            Label::new(format!("Process ID (PID): {}", self.pid))
-                                .color(Color::Muted)
-                                .size(LabelSize::Small),
-                        ),
+                        .when_some(self.pid, |this, pid| {
+                            this.child(
+                                Label::new(format!("Process ID (PID): {}", pid))
+                                    .color(Color::Muted)
+                                    .size(LabelSize::Small),
+                            )
+                        }),
                 )
         })
     }

crates/terminal_view/src/terminal_view.rs 🔗

@@ -1142,7 +1142,9 @@ impl Item for TerminalView {
     fn tab_tooltip_content(&self, cx: &App) -> Option<TabTooltipContent> {
         let terminal = self.terminal().read(cx);
         let title = terminal.title(false);
-        let pid = terminal.pty_info.pid_getter().fallback_pid();
+        let pid = terminal
+            .pty_info()
+            .map(|info| info.pid_getter().fallback_pid());
 
         Some(TabTooltipContent::Custom(Box::new(move |_window, cx| {
             cx.new(|_| TerminalTooltip::new(title.clone(), pid)).into()