Compare commits

...

2 Commits

Author SHA1 Message Date
Richard Feldman
b7692bf47a Add display-only terminals 2025-09-21 23:16:55 -04:00
Richard Feldman
31fa0b4c30 terminal proof-of-concept 2025-09-19 17:30:56 -04:00
8 changed files with 372 additions and 87 deletions

View File

@@ -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);
}
@@ -1949,6 +1975,7 @@ impl AcpThread {
extra_env: Vec<acp::EnvVariable>,
cwd: Option<PathBuf>,
output_byte_limit: Option<u64>,
is_display_only: bool,
cx: &mut Context<Self>,
) -> Task<Result<Entity<Terminal>>> {
let env = match &cwd {
@@ -1989,32 +2016,59 @@ impl AcpThread {
)
.redirect_stdin_to_dev_null()
.build(Some(command), &args);
let terminal = project
.update(cx, |project, cx| {
project.create_terminal_task(
task::SpawnInTerminal {
command: Some(command.clone()),
args: args.clone(),
cwd: cwd.clone(),
env,
..Default::default()
},
let terminal = if is_display_only {
cx.update(|cx| {
TerminalBuilder::new_display_only(
Some(format!("Display: {}", command).into()),
CursorShape::Block,
AlternateScroll::On,
Some(10_000),
cx,
)
})?
.await?;
})??
} else {
project
.update(cx, |project, cx| {
project.create_terminal_task(
task::SpawnInTerminal {
command: Some(command.clone()),
args: args.clone(),
cwd: cwd.clone(),
env,
..Default::default()
},
cx,
)
})?
.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,
)
})
}
}
});
@@ -2060,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 {

View File

@@ -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
}

View File

@@ -1144,7 +1144,7 @@ impl ThreadEnvironment for AcpThreadEnvironment {
cx: &mut AsyncApp,
) -> Task<Result<Rc<dyn TerminalHandle>>> {
let task = self.acp_thread.update(cx, |thread, cx| {
thread.create_terminal(command, vec![], vec![], cwd, output_byte_limit, cx)
thread.create_terminal(command, vec![], vec![], cwd, output_byte_limit, false, cx)
});
let acp_thread = self.acp_thread.clone();

View File

@@ -606,6 +606,14 @@ impl acp::Client for ClientDelegate {
&self,
args: acp::CreateTerminalRequest,
) -> Result<acp::CreateTerminalResponse, acp::Error> {
// Check if this is a display-only terminal from the metadata
let is_display_only = args
.meta
.as_ref()
.and_then(|meta| meta.get("display_only"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
let terminal = self
.session_thread(&args.session_id)?
.update(&mut self.cx.clone(), |thread, cx| {
@@ -615,6 +623,7 @@ impl acp::Client for ClientDelegate {
args.env,
args.cwd,
args.output_byte_limit,
is_display_only,
cx,
)
})?

View File

@@ -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")
})?
});

View File

@@ -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();
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 {
"".to_string()
}
);
let (process_file, process_name) = if truncate {
(
truncate_and_trailoff(&process_file, MAX_CHARS),
truncate_and_trailoff(&process_name, MAX_CHARS),
)
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();
}
}
}

View File

@@ -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),
)
}),
)
})
}

View File

@@ -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()