From b7692bf47a9e9bfcadffce2aae5b2925dcc1df76 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Sun, 21 Sep 2025 23:16:55 -0400 Subject: [PATCH] Add display-only terminals --- 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 ++++++++++++++---- .../terminal_view/src/terminal_tab_tooltip.rs | 16 +- crates/terminal_view/src/terminal_view.rs | 4 +- 6 files changed, 346 insertions(+), 100 deletions(-) diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 821331a8341cfa1dab258178dcf2cbff19ad3245..8f93f1c87b4e85ab13da3740fb3ed63201e616be 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/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> { 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, + ) -> 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 { diff --git a/crates/acp_thread/src/terminal.rs b/crates/acp_thread/src/terminal.rs index 888c7698c3d2270769f3afbe712ecba7d08b055f..d94af12e0e5612ccf2bfd1030aa7c96597dfabe4 100644 --- a/crates/acp_thread/src/terminal.rs +++ b/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, + output_byte_limit: Option, + terminal: Entity, + cx: &mut Context, + ) -> 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.terminal.update(cx, |terminal, cx| { + terminal.write_output(data, cx); + }); + } + pub fn id(&self) -> &acp::TerminalId { &self.id } diff --git a/crates/debugger_ui/src/session/running.rs b/crates/debugger_ui/src/session/running.rs index a18a186469a0aaaf5f3d061830446f5ba27dec72..c8c8e4b63d8270d97a6fe07ea44478e78a63678d 100644 --- a/crates/debugger_ui/src/session/running.rs +++ b/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") })? }); diff --git a/crates/terminal/src/terminal.rs b/crates/terminal/src/terminal.rs index 6bdeb9638a329c2384e538e27e13c21f02df7284..00e60a5678e86a1b4882d7170ad556932f503b6c 100644 --- a/crates/terminal/src/terminal.rs +++ b/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, + cursor_shape: CursorShape, + alternate_scroll: AlternateScroll, + max_scroll_history_lines: Option, + cx: &mut App, + ) -> Result> { + 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, task: Option, @@ -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>>, term: Arc>>, term_config: Config, @@ -710,7 +787,6 @@ pub struct Terminal { pub last_content: TerminalContent, pub selection_head: Option, pub breadcrumb_text: String, - pub pty_info: PtyProcessInfo, title_override: Option, 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>) { - 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) { + 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>) { @@ -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 { - 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> { 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, 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(); + } } } diff --git a/crates/terminal_view/src/terminal_tab_tooltip.rs b/crates/terminal_view/src/terminal_tab_tooltip.rs index 2e25e4798ff4ad9326d76897c56edc9937375793..eb1fb8e38adb71942c533ff549136a3307cd9817 100644 --- a/crates/terminal_view/src/terminal_tab_tooltip.rs +++ b/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, } impl TerminalTooltip { - pub fn new(title: impl Into, pid: u32) -> Self { + pub fn new(title: impl Into, pid: Option) -> 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), + ) + }), ) }) } diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index d7adf74acb37e848ccb2d8670f970054d46ea0ae..bebdb7bb903fcffde1eccad04621e3797b5acdb3 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -1142,7 +1142,9 @@ impl Item for TerminalView { fn tab_tooltip_content(&self, cx: &App) -> Option { 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()