@@ -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 {
@@ -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
}
@@ -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();
+ }
}
}