agent_ui: Make provider-owned terminals read-only (#64550)

Make provider-owned terminal cards read-only for user input while
preserving local inspection. This builds on the explicit terminal
ownership introduced in #64544.

- Add a construction-time `TerminalView::with_read_only` option,
selected from ACP process ownership rather than PTY presence. Existing
views remain interactive by default, including client-owned headless
terminals.
- Reject typing, send actions, paste, file/path drops, IME input,
clear/rerun actions, and focus reports in read-only views.
- Preserve selection, copying, links, scrolling, search, and local Vi
navigation. Leaving Vi mode does not enable terminal input.
- Pass an explicit mouse-routing policy to the existing lower terminal
handlers so provider-requested mouse and alternate-scroll modes cannot
consume inspection gestures. Real modifiers remain unchanged, and
Linux/FreeBSD middle-click paste is suppressed.

Release Notes:

- N/A
This commit is contained in:
Ben Brandt authored and GitHub committed 2026-09-21 15:37:37 +00:00
1 parent a18b1a1026
commit 50061c9cbd
5 files changed
+678 -152

No files matched your search

+123
View File
@@ -9562,6 +9562,7 @@ pub(crate) mod tests {
) )
.subscribe(cx) .subscribe(cx)
}); });
let display_terminal = terminal.clone();
thread.update(cx, |thread, cx| { thread.update(cx, |thread, cx| {
thread.on_terminal_provider_event( thread.on_terminal_provider_event(
acp_thread::TerminalProviderEvent::Created { acp_thread::TerminalProviderEvent::Created {
@@ -9593,6 +9594,49 @@ pub(crate) mod tests {
state.expand_tool_call(tool_id.clone()); state.expand_tool_call(tool_id.clone());
cx.notify(); cx.notify();
}); });
let acp_terminal = thread.read_with(cx, |thread, _| {
thread
.terminal(terminal_id.clone())
.expect("terminal tool call should contain a terminal")
});
let terminal_view = entry_state.read_with(cx, |state, _| {
state
.entry(0)
.and_then(|entry| entry.terminal(&acp_terminal))
.expect("terminal tool card should contain a terminal view")
});
assert!(terminal_view.read_with(cx, |view, _| view.is_read_only()));
display_terminal.update(cx, |terminal, _| {
assert!(terminal.take_input_log().is_empty());
});
terminal_view.update_in(cx, |view, window, cx| {
window.focus(&view.focus_handle(cx), cx);
});
cx.simulate_keystrokes("a");
assert!(
display_terminal
.update(cx, |terminal, _| terminal.take_input_log())
.is_empty(),
"input dispatched to a display-only terminal view must not reach the lower terminal",
);
thread.update(cx, |thread, cx| {
thread.on_terminal_provider_event(
acp_thread::TerminalProviderEvent::Output {
terminal_id: terminal_id.clone(),
data: b"provider output remains visible".to_vec(),
},
cx,
);
});
cx.run_until_parked();
assert!(
display_terminal
.read_with(cx, |terminal, _| terminal.get_content())
.contains("provider output remains visible"),
"provider output should continue to render in a read-only terminal",
);
assert!(cx.debug_bounds("ICON-Stop").is_none()); assert!(cx.debug_bounds("ICON-Stop").is_none());
thread.update(cx, |thread, cx| { thread.update(cx, |thread, cx| {
thread thread
@@ -9627,6 +9671,85 @@ pub(crate) mod tests {
} }
} }
#[gpui::test]
async fn test_acp_owned_terminal_tool_card_is_interactive(cx: &mut TestAppContext) {
init_test(cx);
let (conversation_view, cx) =
setup_conversation_view(StubAgentServer::new(StubAgentConnection::new()), cx).await;
add_to_workspace_with_size(conversation_view.clone(), true, cx);
let thread_view = active_thread(&conversation_view, cx);
let thread = thread_view.read_with(cx, |view, _| view.thread.clone());
let terminal_id = acp::TerminalId::new("acp-owned-terminal");
let tool_id = acp::ToolCallId::new("acp-owned-tool");
let lower_terminal = cx.new(|cx| {
terminal::TerminalBuilder::new_display_only(
Default::default(),
terminal::terminal_settings::AlternateScroll::On,
None,
0,
cx.background_executor(),
util::paths::PathStyle::local(),
)
.subscribe(cx)
});
assert!(!lower_terminal.read_with(cx, |terminal, _| terminal.is_pty()));
let acp_terminal = thread.update(cx, |thread, cx| {
let acp_terminal = thread.register_terminal_created(
terminal_id.clone(),
"client-owned command".into(),
None,
None,
lower_terminal.clone(),
cx,
);
thread
.handle_session_update(
acp::SessionUpdate::ToolCall(
acp::ToolCall::new(tool_id.clone(), "client-owned command")
.kind(acp::ToolKind::Execute)
.status(acp::ToolCallStatus::InProgress)
.content(vec![acp::ToolCallContent::Terminal(acp::Terminal::new(
terminal_id,
))]),
),
cx,
)
.expect("terminal tool call");
acp_terminal
});
assert!(
acp_terminal.read_with(cx, |terminal, _| terminal.is_process_backed()),
"registration ownership, not the lower renderer's PTY, marks this terminal process-backed",
);
cx.run_until_parked();
let entry_state = thread_view.read_with(cx, |view, _| view.entry_view_state.clone());
entry_state.update(cx, |state, cx| {
state.expand_tool_call(tool_id);
cx.notify();
});
let terminal_view = entry_state.read_with(cx, |state, _| {
state
.entry(0)
.and_then(|entry| entry.terminal(&acp_terminal))
.expect("terminal tool card should contain a terminal view")
});
assert!(!terminal_view.read_with(cx, |view, _| view.is_read_only()));
lower_terminal.update(cx, |terminal, _| {
assert!(terminal.take_input_log().is_empty());
});
terminal_view.update_in(cx, |view, window, cx| {
window.focus(&view.focus_handle(cx), cx);
});
cx.simulate_keystrokes("a");
assert_eq!(
lower_terminal.update(cx, |terminal, _| terminal.take_input_log()),
vec![b"a".to_vec()],
);
}
#[gpui::test] #[gpui::test]
async fn test_tool_permission_buttons_terminal_with_pattern(cx: &mut TestAppContext) { async fn test_tool_permission_buttons_terminal_with_pattern(cx: &mut TestAppContext) {
init_test(cx); init_test(cx);
+3 -1
View File
@@ -643,6 +643,7 @@ fn create_terminal(
cx: &mut App, cx: &mut App,
) -> Entity<TerminalView> { ) -> Entity<TerminalView> {
cx.new(|cx| { cx.new(|cx| {
let read_only = !terminal.read(cx).is_process_backed();
let mut view = TerminalView::new( let mut view = TerminalView::new(
terminal.read(cx).inner().clone(), terminal.read(cx).inner().clone(),
workspace, workspace,
@@ -650,7 +651,8 @@ fn create_terminal(
project, project,
window, window,
cx, cx,
); )
.with_read_only(read_only);
view.set_embedded_mode(Some(1000), cx); view.set_embedded_mode(Some(1000), cx);
view view
}) })
+174 -26
View File
@@ -934,6 +934,12 @@ fn init_command_startup_marker_command(shell_kind: ShellKind, marker_id: u64) ->
/// sender and receiver remain paired. /// sender and receiver remain paired.
pub struct TerminalMode(TerminalModeKind); pub struct TerminalMode(TerminalModeKind);
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum MouseInputMode {
ReportToTerminal,
LocalSelection,
}
enum TerminalModeKind { enum TerminalModeKind {
Interactive, Interactive,
InteractiveWithCompletion(Sender<Option<ExitStatus>>), InteractiveWithCompletion(Sender<Option<ExitStatus>>),
@@ -2476,13 +2482,15 @@ impl Terminal {
} }
} }
pub fn mouse_mode(&self, shift: bool) -> bool { pub fn mouse_mode(&self, shift: bool, mode: MouseInputMode) -> bool {
self.last_content.mode.intersects(Modes::MOUSE_MODE) && !shift mode == MouseInputMode::ReportToTerminal
&& self.last_content.mode.intersects(Modes::MOUSE_MODE)
&& !shift
} }
pub fn mouse_move(&mut self, e: &MouseMoveEvent, cx: &mut Context<Self>) { pub fn mouse_move(&mut self, e: &MouseMoveEvent, mode: MouseInputMode, cx: &mut Context<Self>) {
let position = e.position - self.last_content.terminal_bounds.bounds.origin; let position = e.position - self.last_content.terminal_bounds.bounds.origin;
if self.mouse_mode(e.modifiers.shift) { if self.mouse_mode(e.modifiers.shift, mode) {
// A ctrl/cmd press on a link suppressed its button-press report in // A ctrl/cmd press on a link suppressed its button-press report in
// `mouse_down`. Since the app never saw the press, we must swallow // `mouse_down`. Since the app never saw the press, we must swallow
// the whole gesture rather than forward later motion/release // the whole gesture rather than forward later motion/release
@@ -2580,10 +2588,11 @@ impl Terminal {
&mut self, &mut self,
e: &MouseMoveEvent, e: &MouseMoveEvent,
region: Bounds<Pixels>, region: Bounds<Pixels>,
mode: MouseInputMode,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) { ) {
let position = e.position - self.last_content.terminal_bounds.bounds.origin; let position = e.position - self.last_content.terminal_bounds.bounds.origin;
if !self.mouse_mode(e.modifiers.shift) { if !self.mouse_mode(e.modifiers.shift, mode) {
if let Some(hyperlink) = &self.mouse_down_hyperlink { if let Some(hyperlink) = &self.mouse_down_hyperlink {
let point = grid_point( let point = grid_point(
position, position,
@@ -2646,7 +2655,7 @@ impl Terminal {
Some(scroll_lines.clamp(-3, 3)) Some(scroll_lines.clamp(-3, 3))
} }
pub fn mouse_down(&mut self, e: &MouseDownEvent, cx: &mut Context<Self>) { pub fn mouse_down(&mut self, e: &MouseDownEvent, mode: MouseInputMode, cx: &mut Context<Self>) {
let position = e.position - self.last_content.terminal_bounds.bounds.origin; let position = e.position - self.last_content.terminal_bounds.bounds.origin;
let point = grid_point( let point = grid_point(
position, position,
@@ -2657,7 +2666,7 @@ impl Terminal {
if e.button == MouseButton::Left if e.button == MouseButton::Left
&& e.modifiers.secondary() && e.modifiers.secondary()
&& (TerminalSettings::get_global(cx).open_links_in_mouse_mode && (TerminalSettings::get_global(cx).open_links_in_mouse_mode
|| !self.mouse_mode(e.modifiers.shift)) || !self.mouse_mode(e.modifiers.shift, mode))
{ {
self.mouse_down_hyperlink = self.find_hyperlink_at_point(point); self.mouse_down_hyperlink = self.find_hyperlink_at_point(point);
@@ -2666,7 +2675,7 @@ impl Terminal {
} }
} }
if self.mouse_mode(e.modifiers.shift) { if self.mouse_mode(e.modifiers.shift, mode) {
let bytes = let bytes =
mouse_button_report(point, e.button, e.modifiers, true, self.last_content.mode); mouse_button_report(point, e.button, e.modifiers, true, self.last_content.mode);
@@ -2717,7 +2726,9 @@ impl Terminal {
} }
#[cfg(any(target_os = "linux", target_os = "freebsd"))] #[cfg(any(target_os = "linux", target_os = "freebsd"))]
MouseButton::Middle => { MouseButton::Middle => {
if let Some(item) = cx.read_from_primary() { if mode == MouseInputMode::ReportToTerminal
&& let Some(item) = cx.read_from_primary()
{
let text = item.text().unwrap_or_default(); let text = item.text().unwrap_or_default();
self.paste(&text); self.paste(&text);
} }
@@ -2727,7 +2738,7 @@ impl Terminal {
} }
} }
pub fn mouse_up(&mut self, e: &MouseUpEvent, cx: &Context<Self>) { pub fn mouse_up(&mut self, e: &MouseUpEvent, mode: MouseInputMode, cx: &Context<Self>) {
let setting = TerminalSettings::get_global(cx); let setting = TerminalSettings::get_global(cx);
let position = e.position - self.last_content.terminal_bounds.bounds.origin; let position = e.position - self.last_content.terminal_bounds.bounds.origin;
@@ -2750,7 +2761,7 @@ impl Terminal {
return; return;
} }
if self.mouse_mode(e.modifiers.shift) { if self.mouse_mode(e.modifiers.shift, mode) {
self.selection_phase = SelectionPhase::Ended; self.selection_phase = SelectionPhase::Ended;
self.last_mouse = None; self.last_mouse = None;
self.mouse_down_position = None; self.mouse_down_position = None;
@@ -2758,7 +2769,7 @@ impl Terminal {
} }
} }
if self.mouse_mode(e.modifiers.shift) { if self.mouse_mode(e.modifiers.shift, mode) {
let point = grid_point( let point = grid_point(
position, position,
self.last_content.terminal_bounds, self.last_content.terminal_bounds,
@@ -2800,8 +2811,13 @@ impl Terminal {
} }
///Scroll the terminal ///Scroll the terminal
pub fn scroll_wheel(&mut self, e: &ScrollWheelEvent, scroll_multiplier: f32) { pub fn scroll_wheel(
let mouse_mode = self.mouse_mode(e.shift); &mut self,
e: &ScrollWheelEvent,
scroll_multiplier: f32,
mode: MouseInputMode,
) {
let mouse_mode = self.mouse_mode(e.shift, mode);
let scroll_multiplier = if mouse_mode { 1. } else { scroll_multiplier }; let scroll_multiplier = if mouse_mode { 1. } else { scroll_multiplier };
if let Some(scroll_lines) = self.determine_scroll_lines(e, scroll_multiplier) if let Some(scroll_lines) = self.determine_scroll_lines(e, scroll_multiplier)
@@ -2820,10 +2836,11 @@ impl Terminal {
self.write_to_pty(scroll); self.write_to_pty(scroll);
} }
}; };
} else if self } else if mode == MouseInputMode::ReportToTerminal
.last_content && self
.mode .last_content
.contains(Modes::ALT_SCREEN | Modes::ALTERNATE_SCROLL) .mode
.contains(Modes::ALT_SCREEN | Modes::ALTERNATE_SCROLL)
&& !e.shift && !e.shift
{ {
self.write_to_pty(alt_scroll(scroll_lines)); self.write_to_pty(alt_scroll(scroll_lines));
@@ -3580,7 +3597,8 @@ mod tests {
}; };
use collections::HashMap; use collections::HashMap;
use gpui::{ use gpui::{
ClipboardItem, Entity, Pixels, TestAppContext, VisualTestContext, bounds, point, size, ClipboardItem, Entity, Pixels, ScrollDelta, TestAppContext, VisualContext,
VisualTestContext, bounds, point, size,
}; };
use parking_lot::Mutex; use parking_lot::Mutex;
use rand::{Rng, distr, rngs::StdRng}; use rand::{Rng, distr, rngs::StdRng};
@@ -3927,7 +3945,7 @@ mod tests {
click_count: 1, click_count: 1,
first_mouse: true, first_mouse: true,
}; };
terminal.mouse_down(&mouse_down, cx); terminal.mouse_down(&mouse_down, MouseInputMode::ReportToTerminal, cx);
} }
fn left_mouse_up_at( fn left_mouse_up_at(
@@ -3941,7 +3959,7 @@ mod tests {
modifiers: Modifiers::none(), modifiers: Modifiers::none(),
click_count: 1, click_count: 1,
}; };
terminal.mouse_up(&mouse_up, cx); terminal.mouse_up(&mouse_up, MouseInputMode::ReportToTerminal, cx);
} }
fn left_mouse_drag_to( fn left_mouse_drag_to(
@@ -3955,7 +3973,7 @@ mod tests {
pressed_button: Some(MouseButton::Left), pressed_button: Some(MouseButton::Left),
modifiers: Modifiers::none(), modifiers: Modifiers::none(),
}; };
terminal.mouse_drag(&drag_event, region, cx); terminal.mouse_drag(&drag_event, region, MouseInputMode::ReportToTerminal, cx);
} }
/// A left click that jitters by a pixel or two (e.g. the window-focusing /// A left click that jitters by a pixel or two (e.g. the window-focusing
@@ -4006,6 +4024,92 @@ mod tests {
}); });
} }
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
#[gpui::test]
async fn test_local_selection_does_not_paste_primary_selection(cx: &mut TestAppContext) {
let terminal = init_terminal_test(cx, b"hello world\r\n");
terminal.update(cx, |terminal, cx| {
cx.write_to_primary(ClipboardItem::new_string("primary selection".into()));
let event = MouseDownEvent {
button: MouseButton::Middle,
position: point(px(50.), px(10.)),
modifiers: Modifiers::none(),
click_count: 1,
first_mouse: true,
};
terminal.mouse_down(&event, MouseInputMode::LocalSelection, cx);
assert!(terminal.take_input_log().is_empty());
assert!(terminal.take_pty_write_log().is_empty());
terminal.mouse_down(&event, MouseInputMode::ReportToTerminal, cx);
assert_eq!(
terminal.take_input_log(),
vec![b"primary selection".to_vec()]
);
assert_eq!(
terminal.take_pty_write_log(),
vec![b"primary selection".to_vec()]
);
});
}
#[gpui::test]
async fn test_local_selection_scrolls_without_terminal_reports(cx: &mut TestAppContext) {
let mut output = Vec::new();
output.extend_from_slice(b"\x1b[?1002h\x1b[?1006h");
for line in 0..80 {
output.extend_from_slice(format!("scrollback line {line}\r\n").as_bytes());
}
let (terminal, cx) = init_terminal_test_with_window(cx, &output);
cx.run_until_parked();
cx.update_window_entity(&terminal, |terminal, window, cx| {
terminal.sync(window, cx);
assert!(terminal.last_content.mode.intersects(Modes::MOUSE_MODE));
assert_eq!(display_offset(&terminal.term.lock()), 0);
let scroll_event = ScrollWheelEvent {
position: point(px(50.0), px(10.0)),
delta: ScrollDelta::Lines(point(0.0, 1.0)),
..Default::default()
};
terminal.scroll_wheel(&scroll_event, 1.0, MouseInputMode::LocalSelection);
terminal.sync(window, cx);
assert!(display_offset(&terminal.term.lock()) > 0);
assert!(terminal.take_input_log().is_empty());
assert!(terminal.take_pty_write_log().is_empty());
terminal.scroll_wheel(&scroll_event, 1.0, MouseInputMode::ReportToTerminal);
assert!(!terminal.take_pty_write_log().is_empty());
terminal.write_output(b"\x1b[?1049h\x1b[?1007h", cx);
});
cx.run_until_parked();
cx.update_window_entity(&terminal, |terminal, window, cx| {
terminal.sync(window, cx);
assert!(
terminal
.last_content
.mode
.contains(Modes::ALT_SCREEN | Modes::ALTERNATE_SCROLL)
);
assert_eq!(display_offset(&terminal.term.lock()), 0);
let scroll_event = ScrollWheelEvent {
position: point(px(50.0), px(10.0)),
delta: ScrollDelta::Lines(point(0.0, 1.0)),
..Default::default()
};
terminal.scroll_wheel(&scroll_event, 1.0, MouseInputMode::LocalSelection);
terminal.sync(window, cx);
assert_eq!(display_offset(&terminal.term.lock()), 0);
assert!(terminal.take_input_log().is_empty());
assert!(terminal.take_pty_write_log().is_empty());
});
}
/// With mouse tracking active (e.g. htop), Shift is the escape hatch to /// With mouse tracking active (e.g. htop), Shift is the escape hatch to
/// select terminal text. Shift+drag must start a selection rather than being /// select terminal text. Shift+drag must start a selection rather than being
/// swallowed as a "extend existing selection" no-op. Regression test for #60254. /// swallowed as a "extend existing selection" no-op. Regression test for #60254.
@@ -4032,6 +4136,7 @@ mod tests {
click_count: 1, click_count: 1,
first_mouse: true, first_mouse: true,
}, },
MouseInputMode::ReportToTerminal,
cx, cx,
); );
@@ -4054,6 +4159,7 @@ mod tests {
modifiers: shift, modifiers: shift,
}, },
region, region,
MouseInputMode::ReportToTerminal,
cx, cx,
); );
@@ -4094,6 +4200,7 @@ mod tests {
click_count: 1, click_count: 1,
first_mouse: true, first_mouse: true,
}, },
MouseInputMode::ReportToTerminal,
cx, cx,
); );
@@ -4822,7 +4929,7 @@ mod tests {
click_count: 1, click_count: 1,
first_mouse: true, first_mouse: true,
}; };
terminal.mouse_down(&mouse_down, cx); terminal.mouse_down(&mouse_down, MouseInputMode::ReportToTerminal, cx);
} }
fn ctrl_mouse_drag_to( fn ctrl_mouse_drag_to(
@@ -4836,7 +4943,12 @@ mod tests {
pressed_button: Some(MouseButton::Left), pressed_button: Some(MouseButton::Left),
modifiers: Modifiers::secondary_key(), modifiers: Modifiers::secondary_key(),
}; };
terminal.mouse_drag(&drag_event, terminal_bounds, cx); terminal.mouse_drag(
&drag_event,
terminal_bounds,
MouseInputMode::ReportToTerminal,
cx,
);
} }
fn ctrl_mouse_up_at( fn ctrl_mouse_up_at(
@@ -4850,7 +4962,7 @@ mod tests {
modifiers: Modifiers::secondary_key(), modifiers: Modifiers::secondary_key(),
click_count: 1, click_count: 1,
}; };
terminal.mouse_up(&mouse_up, cx); terminal.mouse_up(&mouse_up, MouseInputMode::ReportToTerminal, cx);
} }
macro_rules! any_event_matches { macro_rules! any_event_matches {
@@ -4897,6 +5009,38 @@ mod tests {
}); });
} }
#[gpui::test]
async fn test_local_selection_activates_hyperlink_in_mouse_mode(cx: &mut TestAppContext) {
let terminal = init_terminal_test(cx, b"Visit https://zed.dev/ for more\r\n");
terminal.update(cx, |terminal, cx| {
terminal.last_content.mode = Modes::MOUSE_MODE;
let position = point(px(80.0), px(10.0));
let mouse_down = MouseDownEvent {
button: MouseButton::Left,
position,
modifiers: Modifiers::secondary_key(),
click_count: 1,
first_mouse: true,
};
let mouse_up = MouseUpEvent {
button: MouseButton::Left,
position,
modifiers: Modifiers::secondary_key(),
click_count: 1,
};
terminal.mouse_down(&mouse_down, MouseInputMode::LocalSelection, cx);
terminal.mouse_up(&mouse_up, MouseInputMode::LocalSelection, cx);
assert!(any_event_matches!(
terminal,
InternalEvent::ProcessHyperlink(_, true)
));
assert!(terminal.take_pty_write_log().is_empty());
});
}
#[gpui::test] #[gpui::test]
async fn test_hyperlink_ctrl_click_mismatch_in_mouse_mode_consumes_gesture( async fn test_hyperlink_ctrl_click_mismatch_in_mouse_mode_consumes_gesture(
cx: &mut TestAppContext, cx: &mut TestAppContext,
@@ -4920,6 +5064,7 @@ mod tests {
pressed_button: Some(MouseButton::Left), pressed_button: Some(MouseButton::Left),
modifiers: Modifiers::secondary_key(), modifiers: Modifiers::secondary_key(),
}, },
MouseInputMode::ReportToTerminal,
cx, cx,
); );
ctrl_mouse_up_at(terminal, up_position, cx); ctrl_mouse_up_at(terminal, up_position, cx);
@@ -5205,7 +5350,8 @@ mod tests {
}; };
self.window.simulate_mouse_move(position, self.cx); self.window.simulate_mouse_move(position, self.cx);
self.unthrottle(); self.unthrottle();
self.terminal.mouse_move(&move_event, self.cx); self.terminal
.mouse_move(&move_event, MouseInputMode::ReportToTerminal, self.cx);
} }
fn try_modifiers_change(&mut self, modifiers: Modifiers) { fn try_modifiers_change(&mut self, modifiers: Modifiers) {
@@ -5752,6 +5898,7 @@ mod tests {
position, position,
..default() ..default()
}, },
MouseInputMode::ReportToTerminal,
cx, cx,
); );
@@ -5762,6 +5909,7 @@ mod tests {
..default() ..default()
}, },
1.0, 1.0,
MouseInputMode::ReportToTerminal,
); );
assert!( assert!(
+133 -102
View File
@@ -1,9 +1,9 @@
use editor::{CursorLayout, EditorSettings, HighlightedRange, HighlightedRangeLine}; use editor::{CursorLayout, EditorSettings, HighlightedRange, HighlightedRangeLine};
use gpui::{ use gpui::{
AbsoluteLength, AnyElement, App, AvailableSpace, Bounds, ContentMask, Context, DispatchPhase, AbsoluteLength, AnyElement, App, AvailableSpace, Bounds, ContentMask, DispatchPhase, Element,
Element, ElementId, Entity, FocusHandle, Font, FontFeatures, FontStyle, FontWeight, ElementId, Entity, FocusHandle, Font, FontFeatures, FontStyle, FontWeight, GlobalElementId,
GlobalElementId, HighlightStyle, Hitbox, Hsla, InputHandler, InteractiveElement, Interactivity, HighlightStyle, Hitbox, Hsla, InputHandler, InteractiveElement, Interactivity, IntoElement,
IntoElement, LayoutId, Length, ModifiersChangedEvent, MouseButton, MouseMoveEvent, Pixels, LayoutId, Length, ModifiersChangedEvent, MouseButton, MouseMoveEvent, Pixels,
Point as GpuiPoint, StatefulInteractiveElement, StrikethroughStyle, Styled, TextRun, TextStyle, Point as GpuiPoint, StatefulInteractiveElement, StrikethroughStyle, Styled, TextRun, TextStyle,
UTF16Selection, UnderlineStyle, WeakEntity, WhiteSpace, Window, div, fill, point, px, relative, UTF16Selection, UnderlineStyle, WeakEntity, WhiteSpace, Window, div, fill, point, px, relative,
size, size,
@@ -13,9 +13,10 @@ use language::CursorShape as EditorCursorShape;
use settings::Settings; use settings::Settings;
use std::time::Instant; use std::time::Instant;
use terminal::{ use terminal::{
Cell, Color, Content, CursorShape, IndexedCell, Modes, NamedColor, Point, Range, Terminal, Cell, Color, Content, CursorShape, IndexedCell, Modes, MouseInputMode, NamedColor, Point,
TerminalBounds, is_app_chosen_exact_color as terminal_is_app_chosen_exact_color, Range, Terminal, TerminalBounds,
is_default_background_color, terminal_settings::TerminalSettings, is_app_chosen_exact_color as terminal_is_app_chosen_exact_color, is_default_background_color,
terminal_settings::TerminalSettings,
}; };
use theme::{ActiveTheme, Theme}; use theme::{ActiveTheme, Theme};
use theme_settings::ThemeSettings; use theme_settings::ThemeSettings;
@@ -40,7 +41,6 @@ pub struct LayoutState {
ime_cursor_bounds: Option<Bounds<Pixels>>, ime_cursor_bounds: Option<Bounds<Pixels>>,
background_color: Hsla, background_color: Hsla,
dimensions: TerminalBounds, dimensions: TerminalBounds,
mode: Modes,
display_offset: usize, display_offset: usize,
hyperlink_tooltip: Option<AnyElement>, hyperlink_tooltip: Option<AnyElement>,
block_below_cursor_element: Option<AnyElement>, block_below_cursor_element: Option<AnyElement>,
@@ -940,31 +940,11 @@ impl TerminalElement {
result result
} }
fn generic_button_handler<E>(
connection: Entity<Terminal>,
focus_handle: FocusHandle,
steal_focus: bool,
f: impl Fn(&mut Terminal, &E, &mut Context<Terminal>),
) -> impl Fn(&E, &mut Window, &mut App) {
move |event, window, cx| {
if steal_focus {
window.focus(&focus_handle, cx);
} else if !focus_handle.is_focused(window) {
return;
}
connection.update(cx, |terminal, cx| {
f(terminal, event, cx);
cx.notify();
})
}
}
fn register_mouse_listeners( fn register_mouse_listeners(
&mut self, &mut self,
mode: Modes,
hitbox: &Hitbox, hitbox: &Hitbox,
content_mode: &ContentMode, content_mode: &ContentMode,
mouse_input_mode: MouseInputMode,
window: &mut Window, window: &mut Window,
) { ) {
let focus = self.focus.clone(); let focus = self.focus.clone();
@@ -979,13 +959,14 @@ impl TerminalElement {
move |e, window, cx| { move |e, window, cx| {
window.focus(&focus, cx); window.focus(&focus, cx);
let scroll_top = terminal_view.read(cx).scroll_top; let view = terminal_view.read(cx);
let scroll_top = view.scroll_top;
terminal.update(cx, |terminal, cx| { terminal.update(cx, |terminal, cx| {
let mut adjusted_event = e.clone(); let mut adjusted_event = e.clone();
if scroll_top > Pixels::ZERO { if scroll_top > Pixels::ZERO {
adjusted_event.position.y += scroll_top; adjusted_event.position.y += scroll_top;
} }
terminal.mouse_down(&adjusted_event, cx); terminal.mouse_down(&adjusted_event, mouse_input_mode, cx);
cx.notify(); cx.notify();
}) })
} }
@@ -995,7 +976,6 @@ impl TerminalElement {
let terminal = self.terminal.clone(); let terminal = self.terminal.clone();
let hitbox = hitbox.clone(); let hitbox = hitbox.clone();
let focus = focus.clone(); let focus = focus.clone();
let terminal_view = terminal_view;
move |e: &MouseMoveEvent, phase, window, cx| { move |e: &MouseMoveEvent, phase, window, cx| {
if phase != DispatchPhase::Bubble { if phase != DispatchPhase::Bubble {
return; return;
@@ -1011,7 +991,12 @@ impl TerminalElement {
if scroll_top > Pixels::ZERO { if scroll_top > Pixels::ZERO {
adjusted_event.position.y += scroll_top; adjusted_event.position.y += scroll_top;
} }
terminal.mouse_drag(&adjusted_event, hitbox.bounds, cx); terminal.mouse_drag(
&adjusted_event,
hitbox.bounds,
mouse_input_mode,
cx,
);
cx.notify(); cx.notify();
} }
}) })
@@ -1019,34 +1004,60 @@ impl TerminalElement {
if hitbox.is_hovered(window) { if hitbox.is_hovered(window) {
terminal.update(cx, |terminal, cx| { terminal.update(cx, |terminal, cx| {
terminal.mouse_move(e, cx); terminal.mouse_move(e, mouse_input_mode, cx);
}) })
} }
} }
}); });
self.interactivity.on_mouse_up( for button in [MouseButton::Left, MouseButton::Middle, MouseButton::Right] {
MouseButton::Left, self.interactivity.on_mouse_up(button, {
TerminalElement::generic_button_handler( let terminal = terminal.clone();
terminal.clone(), let focus = focus.clone();
focus.clone(), move |event, window, cx| {
false, if !focus.is_focused(window) {
move |terminal, e, cx| { return;
terminal.mouse_up(e, cx); }
}, if button != MouseButton::Left
), && (mouse_input_mode == MouseInputMode::LocalSelection
); || !terminal
self.interactivity.on_mouse_down( .read(cx)
MouseButton::Middle, .last_content
TerminalElement::generic_button_handler( .mode
terminal.clone(), .intersects(Modes::MOUSE_MODE))
focus.clone(), {
true, return;
move |terminal, e, cx| { }
terminal.mouse_down(e, cx); terminal.update(cx, |terminal, cx| {
}, terminal.mouse_up(event, mouse_input_mode, cx);
), cx.notify();
); });
}
});
}
for button in [MouseButton::Middle, MouseButton::Right] {
self.interactivity.on_mouse_down(button, {
let terminal = terminal.clone();
let focus = focus.clone();
move |event, window, cx| {
if mouse_input_mode == MouseInputMode::LocalSelection
|| (button == MouseButton::Right
&& !terminal
.read(cx)
.last_content
.mode
.intersects(Modes::MOUSE_MODE))
{
return;
}
window.focus(&focus, cx);
terminal.update(cx, |terminal, cx| {
terminal.mouse_down(event, mouse_input_mode, cx);
cx.notify();
});
}
});
}
if content_mode.is_scrollable() { if content_mode.is_scrollable() {
self.interactivity.on_scroll_wheel({ self.interactivity.on_scroll_wheel({
@@ -1065,44 +1076,6 @@ impl TerminalElement {
} }
}); });
} }
// Mouse mode handlers:
// All mouse modes need the extra click handlers
if mode.intersects(Modes::MOUSE_MODE) {
self.interactivity.on_mouse_down(
MouseButton::Right,
TerminalElement::generic_button_handler(
terminal.clone(),
focus.clone(),
true,
move |terminal, e, cx| {
terminal.mouse_down(e, cx);
},
),
);
self.interactivity.on_mouse_up(
MouseButton::Right,
TerminalElement::generic_button_handler(
terminal.clone(),
focus.clone(),
false,
move |terminal, e, cx| {
terminal.mouse_up(e, cx);
},
),
);
self.interactivity.on_mouse_up(
MouseButton::Middle,
TerminalElement::generic_button_handler(
terminal,
focus,
false,
move |terminal, e, cx| {
terminal.mouse_up(e, cx);
},
),
);
}
} }
fn rem_size(&self, cx: &mut App) -> Option<Pixels> { fn rem_size(&self, cx: &mut App) -> Option<Pixels> {
@@ -1395,14 +1368,12 @@ impl Element for TerminalElement {
let Content { let Content {
cells, cells,
mode,
display_offset, display_offset,
cursor_char, cursor_char,
selection, selection,
cursor, cursor,
.. ..
} = &self.terminal.read(cx).last_content; } = &self.terminal.read(cx).last_content;
let mode = *mode;
let display_offset = *display_offset; let display_offset = *display_offset;
// searches, highlights to a single range representations // searches, highlights to a single range representations
@@ -1589,7 +1560,6 @@ impl Element for TerminalElement {
dimensions, dimensions,
rects, rects,
relative_highlighted_ranges, relative_highlighted_ranges,
mode,
display_offset, display_offset,
hyperlink_tooltip, hyperlink_tooltip,
block_below_cursor_element, block_below_cursor_element,
@@ -1612,7 +1582,9 @@ impl Element for TerminalElement {
) { ) {
let paint_start = Instant::now(); let paint_start = Instant::now();
window.with_content_mask(Some(ContentMask { bounds }), |window| { window.with_content_mask(Some(ContentMask { bounds }), |window| {
let scroll_top = self.terminal_view.read(cx).scroll_top; let terminal_view = self.terminal_view.read(cx);
let scroll_top = terminal_view.scroll_top;
let mouse_input_mode = terminal_view.mouse_input_mode();
window.paint_quad(fill(bounds, layout.background_color)); window.paint_quad(fill(bounds, layout.background_color));
let origin = layout.dimensions.bounds.origin - GpuiPoint::new(px(0.), scroll_top); let origin = layout.dimensions.bounds.origin - GpuiPoint::new(px(0.), scroll_top);
@@ -1634,9 +1606,9 @@ impl Element for TerminalElement {
}; };
self.register_mouse_listeners( self.register_mouse_listeners(
layout.mode,
&layout.hitbox, &layout.hitbox,
&layout.content_mode, &layout.content_mode,
mouse_input_mode,
window, window,
); );
if window.modifiers().secondary() if window.modifiers().secondary()
@@ -1799,10 +1771,13 @@ struct TerminalInputHandler {
impl InputHandler for TerminalInputHandler { impl InputHandler for TerminalInputHandler {
fn selected_text_range( fn selected_text_range(
&mut self, &mut self,
_ignore_disabled_input: bool, ignore_disabled_input: bool,
_: &mut Window, _: &mut Window,
_cx: &mut App, cx: &mut App,
) -> Option<UTF16Selection> { ) -> Option<UTF16Selection> {
if self.terminal_view.read(cx).is_read_only() && !ignore_disabled_input {
return None;
}
// Always return a valid selection for IME positioning, // Always return a valid selection for IME positioning,
// even in ALT_SCREEN mode (fullscreen TUI apps like opencode, vim, etc.) // even in ALT_SCREEN mode (fullscreen TUI apps like opencode, vim, etc.)
// The terminal still has a cursor position that should be used for IME candidate window placement. // The terminal still has a cursor position that should be used for IME candidate window placement.
@@ -1837,6 +1812,9 @@ impl InputHandler for TerminalInputHandler {
window: &mut Window, window: &mut Window,
cx: &mut App, cx: &mut App,
) { ) {
if self.terminal_view.read(cx).is_read_only() {
return;
}
self.terminal_view.update(cx, |view, view_cx| { self.terminal_view.update(cx, |view, view_cx| {
view.clear_marked_text(view_cx); view.clear_marked_text(view_cx);
view.commit_text(text, view_cx); view.commit_text(text, view_cx);
@@ -2030,9 +2008,62 @@ pub fn convert_color(fg: &Color, theme: &Theme) -> Hsla {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use gpui::{AbsoluteLength, Hsla, font}; use gpui::{AbsoluteLength, Hsla, TestAppContext, font};
use ui::utils::apca_contrast; use ui::utils::apca_contrast;
#[gpui::test]
async fn terminal_input_handler_respects_read_only_mode(cx: &mut TestAppContext) {
let (project, workspace, window_handle) = crate::tests::init_test_with_window(cx).await;
for read_only in [false, true] {
let (_pane, terminal, terminal_view) = crate::tests::add_display_only_terminal(
&project,
window_handle,
true,
read_only,
cx,
);
window_handle
.update(cx, |_multi_workspace, window, cx| {
let mut input_handler = TerminalInputHandler {
terminal_view,
workspace: workspace.downgrade(),
cursor_bounds: None,
};
assert_eq!(
input_handler
.selected_text_range(false, window, cx)
.is_none(),
read_only
);
assert!(
input_handler
.selected_text_range(true, window, cx)
.is_some()
);
input_handler.replace_and_mark_text_in_range(None, "あ", None, window, cx);
assert_eq!(
input_handler.marked_text_range(window, cx),
(!read_only).then_some(0..1)
);
input_handler.replace_text_in_range(None, "亜", window, cx);
assert_eq!(input_handler.marked_text_range(window, cx), None);
assert_eq!(
terminal.update(cx, |terminal, _| terminal.take_input_log()),
if read_only {
Vec::new()
} else {
vec!["亜".as_bytes().to_vec()]
}
);
})
.expect("test window should remain open");
}
}
#[test] #[test]
fn test_is_decorative_character() { fn test_is_decorative_character() {
// Box Drawing characters (U+2500 to U+257F) // Box Drawing characters (U+2500 to U+257F)
+245 -23
View File
@@ -33,9 +33,10 @@ use std::{
}; };
use task::TaskId; use task::TaskId;
use terminal::{ use terminal::{
Clear, Copy, Event, HoveredWord, MaybeNavigationTarget, Modes, Paste, PasteText, Point, Range, Clear, Copy, Event, HoveredWord, MaybeNavigationTarget, Modes, MouseInputMode, Paste,
ScrollLineDown, ScrollLineUp, ScrollPageDown, ScrollPageUp, ScrollToBottom, ScrollToTop, PasteText, Point, Range, ScrollLineDown, ScrollLineUp, ScrollPageDown, ScrollPageUp,
Search, ShowCharacterPalette, TaskState, TaskStatus, Terminal, TerminalBounds, ToggleViMode, ScrollToBottom, ScrollToTop, Search, ShowCharacterPalette, TaskState, TaskStatus, Terminal,
TerminalBounds, ToggleViMode,
terminal_settings::{CursorShape, TerminalSettings}, terminal_settings::{CursorShape, TerminalSettings},
}; };
use terminal_element::TerminalElement; use terminal_element::TerminalElement;
@@ -138,6 +139,7 @@ pub struct TerminalView {
cursor_shape: CursorShape, cursor_shape: CursorShape,
blink_manager: Entity<BlinkManager>, blink_manager: Entity<BlinkManager>,
mode: TerminalMode, mode: TerminalMode,
read_only: bool,
// Explicit override for whether workspace-specific context menu actions are shown. // Explicit override for whether workspace-specific context menu actions are shown.
// When `None`, visibility is derived from `mode` (hidden for embedded terminals). // When `None`, visibility is derived from `mode` (hidden for embedded terminals).
show_workspace_actions: Option<bool>, show_workspace_actions: Option<bool>,
@@ -290,6 +292,7 @@ impl TerminalView {
hover: None, hover: None,
hover_tooltip_update: Task::ready(()), hover_tooltip_update: Task::ready(()),
mode: TerminalMode::Standalone, mode: TerminalMode::Standalone,
read_only: false,
show_workspace_actions: None, show_workspace_actions: None,
workspace_id, workspace_id,
show_breadcrumbs: TerminalSettings::get_global(cx).toolbar.breadcrumbs, show_breadcrumbs: TerminalSettings::get_global(cx).toolbar.breadcrumbs,
@@ -319,6 +322,25 @@ impl TerminalView {
cx.notify(); cx.notify();
} }
pub fn is_read_only(&self) -> bool {
self.read_only
}
/// Fixes the view's input policy at construction, without restricting producer output
/// or local navigation.
pub fn with_read_only(mut self, read_only: bool) -> Self {
self.read_only = read_only;
self
}
fn mouse_input_mode(&self) -> MouseInputMode {
if self.read_only {
MouseInputMode::LocalSelection
} else {
MouseInputMode::ReportToTerminal
}
}
/// Explicitly override whether workspace-specific context menu actions (e.g. creating or /// Explicitly override whether workspace-specific context menu actions (e.g. creating or
/// closing terminal tabs, inline assist) are shown. /// closing terminal tabs, inline assist) are shown.
/// ///
@@ -371,6 +393,9 @@ impl TerminalView {
/// Sets the marked (pre-edit) text from the IME. /// Sets the marked (pre-edit) text from the IME.
pub(crate) fn set_marked_text(&mut self, text: String, cx: &mut Context<Self>) { pub(crate) fn set_marked_text(&mut self, text: String, cx: &mut Context<Self>) {
if self.read_only {
return;
}
if text.is_empty() { if text.is_empty() {
return self.clear_marked_text(cx); return self.clear_marked_text(cx);
} }
@@ -395,7 +420,7 @@ impl TerminalView {
/// Commits (sends) the given text to the PTY. Called by InputHandler::replace_text_in_range. /// Commits (sends) the given text to the PTY. Called by InputHandler::replace_text_in_range.
pub(crate) fn commit_text(&mut self, text: &str, cx: &mut Context<Self>) { pub(crate) fn commit_text(&mut self, text: &str, cx: &mut Context<Self>) {
if !text.is_empty() { if !self.read_only && !text.is_empty() {
self.terminal.update(cx, |term, _| { self.terminal.update(cx, |term, _| {
term.input(text.to_string().into_bytes()); term.input(text.to_string().into_bytes());
}); });
@@ -543,7 +568,7 @@ impl TerminalView {
}) })
.action("Copy", Box::new(Copy)) .action("Copy", Box::new(Copy))
.when( .when(
!matches!(self.mode, TerminalMode::Embedded { .. }), !self.read_only && !matches!(self.mode, TerminalMode::Embedded { .. }),
|menu| { |menu| {
menu.action("Paste", Box::new(Paste)) menu.action("Paste", Box::new(Paste))
.action("Paste Text", Box::new(PasteText)) .action("Paste Text", Box::new(PasteText))
@@ -551,14 +576,16 @@ impl TerminalView {
) )
.action("Select All", Box::new(SelectAll)) .action("Select All", Box::new(SelectAll))
.when( .when(
!matches!(self.mode, TerminalMode::Embedded { .. }), !self.read_only && !matches!(self.mode, TerminalMode::Embedded { .. }),
|menu| menu.action("Clear", Box::new(Clear)), |menu| menu.action("Clear", Box::new(Clear)),
) )
.when( .when(
assistant_enabled && !matches!(self.mode, TerminalMode::Embedded { .. }), assistant_enabled && !matches!(self.mode, TerminalMode::Embedded { .. }),
|menu| { |menu| {
menu.separator() menu.separator()
.action("Inline Assist", Box::new(InlineAssist::default())) .when(!self.read_only, |menu| {
menu.action("Inline Assist", Box::new(InlineAssist::default()))
})
.when(has_selection && self.shows_workspace_actions(), |menu| { .when(has_selection && self.shows_workspace_actions(), |menu| {
menu.action("Add to Agent Thread", Box::new(AddSelectionToThread)) menu.action("Add to Agent Thread", Box::new(AddSelectionToThread))
}) })
@@ -633,6 +660,9 @@ impl TerminalView {
window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) { ) {
if self.read_only {
return;
}
if self if self
.terminal .terminal
.read(cx) .read(cx)
@@ -657,6 +687,9 @@ impl TerminalView {
} }
fn rerun_task(&mut self, _: &RerunTask, window: &mut Window, cx: &mut Context<Self>) { fn rerun_task(&mut self, _: &RerunTask, window: &mut Window, cx: &mut Context<Self>) {
if self.read_only {
return;
}
let task = self let task = self
.terminal .terminal
.read(cx) .read(cx)
@@ -667,6 +700,9 @@ impl TerminalView {
} }
fn clear(&mut self, _: &Clear, _: &mut Window, cx: &mut Context<Self>) { fn clear(&mut self, _: &Clear, _: &mut Window, cx: &mut Context<Self>) {
if self.read_only {
return;
}
self.scroll_top = px(0.); self.scroll_top = px(0.);
self.terminal.update(cx, |term, _| term.clear()); self.terminal.update(cx, |term, _| term.clear());
cx.notify(); cx.notify();
@@ -711,6 +747,7 @@ impl TerminalView {
term.scroll_wheel( term.scroll_wheel(
event, event,
TerminalSettings::get_global(cx).scroll_multiplier.max(0.01), TerminalSettings::get_global(cx).scroll_multiplier.max(0.01),
self.mouse_input_mode(),
) )
}); });
} }
@@ -910,6 +947,9 @@ impl TerminalView {
///Attempt to paste the clipboard into the terminal ///Attempt to paste the clipboard into the terminal
fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context<Self>) { fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context<Self>) {
if self.read_only {
return;
}
let Some(clipboard) = cx.read_from_clipboard() else { let Some(clipboard) = cx.read_from_clipboard() else {
return; return;
}; };
@@ -944,6 +984,9 @@ impl TerminalView {
///Attempt to paste the clipboard text into the terminal ///Attempt to paste the clipboard text into the terminal
fn paste_text(&mut self, _: &PasteText, _: &mut Window, cx: &mut Context<Self>) { fn paste_text(&mut self, _: &PasteText, _: &mut Window, cx: &mut Context<Self>) {
if self.read_only {
return;
}
let Some(clipboard) = cx.read_from_clipboard() else { let Some(clipboard) = cx.read_from_clipboard() else {
return; return;
}; };
@@ -963,6 +1006,9 @@ impl TerminalView {
} }
pub fn add_paths_to_terminal(&self, paths: &[PathBuf], window: &mut Window, cx: &mut App) { pub fn add_paths_to_terminal(&self, paths: &[PathBuf], window: &mut Window, cx: &mut App) {
if self.read_only {
return;
}
let mut text = paths let mut text = paths
.iter() .iter()
.filter_map(|path| Some(format!(" {}", shlex::try_quote(path.to_str()?).ok()?))) .filter_map(|path| Some(format!(" {}", shlex::try_quote(path.to_str()?).ok()?)))
@@ -975,6 +1021,9 @@ impl TerminalView {
} }
fn send_text(&mut self, text: &SendText, _: &mut Window, cx: &mut Context<Self>) { fn send_text(&mut self, text: &SendText, _: &mut Window, cx: &mut Context<Self>) {
if self.read_only {
return;
}
self.clear_bell(cx); self.clear_bell(cx);
self.blink_manager.update(cx, BlinkManager::pause_blinking); self.blink_manager.update(cx, BlinkManager::pause_blinking);
self.terminal.update(cx, |term, _| { self.terminal.update(cx, |term, _| {
@@ -1085,8 +1134,8 @@ impl TerminalView {
self.terminal = terminal; self.terminal = terminal;
} }
fn rerun_button(task: &TaskState) -> Option<IconButton> { fn rerun_button(&self, task: &TaskState) -> Option<IconButton> {
if !task.spawned_task.show_rerun { if self.read_only || !task.spawned_task.show_rerun {
return None; return None;
} }
@@ -1271,6 +1320,9 @@ impl TerminalView {
/// updates the cursor locally without sending data to the shell, so there's no /// updates the cursor locally without sending data to the shell, so there's no
/// shell output to automatically trigger a re-render. /// shell output to automatically trigger a re-render.
fn process_keystroke(&mut self, keystroke: &Keystroke, cx: &mut Context<Self>) -> bool { fn process_keystroke(&mut self, keystroke: &Keystroke, cx: &mut Context<Self>) -> bool {
if self.read_only && !self.terminal.read(cx).vi_mode_enabled() {
return false;
}
let (handled, vi_mode_enabled) = self.terminal.update(cx, |term, cx| { let (handled, vi_mode_enabled) = self.terminal.update(cx, |term, cx| {
( (
term.try_keystroke(keystroke, TerminalSettings::get_global(cx).option_as_meta), term.try_keystroke(keystroke, TerminalSettings::get_global(cx).option_as_meta),
@@ -1304,7 +1356,9 @@ impl TerminalView {
fn focus_in(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn focus_in(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.terminal.update(cx, |terminal, _| { self.terminal.update(cx, |terminal, _| {
terminal.set_cursor_shape(self.cursor_shape); terminal.set_cursor_shape(self.cursor_shape);
terminal.focus_in(); if !self.read_only {
terminal.focus_in();
}
}); });
let should_blink = match TerminalSettings::get_global(cx).blinking { let should_blink = match TerminalSettings::get_global(cx).blinking {
@@ -1324,7 +1378,9 @@ impl TerminalView {
fn focus_out(&mut self, _window: &mut Window, cx: &mut Context<Self>) { fn focus_out(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
self.blink_manager.update(cx, BlinkManager::disable); self.blink_manager.update(cx, BlinkManager::disable);
self.terminal.update(cx, |terminal, _| { self.terminal.update(cx, |terminal, _| {
terminal.focus_out(); if !self.read_only {
terminal.focus_out();
}
terminal.set_cursor_shape(CursorShape::Hollow); terminal.set_cursor_shape(CursorShape::Hollow);
}); });
cx.notify(); cx.notify();
@@ -1381,7 +1437,11 @@ impl Render for TerminalView {
.on_mouse_down( .on_mouse_down(
MouseButton::Right, MouseButton::Right,
cx.listener(|this, event: &MouseDownEvent, window, cx| { cx.listener(|this, event: &MouseDownEvent, window, cx| {
if !this.terminal.read(cx).mouse_mode(event.modifiers.shift) { if !this
.terminal
.read(cx)
.mouse_mode(event.modifiers.shift, this.mouse_input_mode())
{
let had_selection = this.terminal.read(cx).last_content.selection.is_some(); let had_selection = this.terminal.read(cx).last_content.selection.is_some();
if !had_selection { if !had_selection {
this.terminal.update(cx, |terminal, _| { this.terminal.update(cx, |terminal, _| {
@@ -1482,15 +1542,15 @@ impl Item for TerminalView {
TaskStatus::Running => ( TaskStatus::Running => (
IconName::PlayFilled, IconName::PlayFilled,
Color::Disabled, Color::Disabled,
TerminalView::rerun_button(terminal_task), self.rerun_button(terminal_task),
), ),
TaskStatus::Unknown => ( TaskStatus::Unknown => (
IconName::Warning, IconName::Warning,
Color::Warning, Color::Warning,
TerminalView::rerun_button(terminal_task), self.rerun_button(terminal_task),
), ),
TaskStatus::Completed { success } => { TaskStatus::Completed { success } => {
let rerun_button = TerminalView::rerun_button(terminal_task); let rerun_button = self.rerun_button(terminal_task);
if *success { if *success {
(IconName::Check, Color::Success, rerun_button) (IconName::Check, Color::Success, rerun_button)
@@ -1591,6 +1651,9 @@ impl Item for TerminalView {
window: &mut Window, window: &mut Window,
cx: &mut App, cx: &mut App,
) -> bool { ) -> bool {
if self.read_only {
return false;
}
let Some(project) = self.project.upgrade() else { let Some(project) = self.project.upgrade() else {
return false; return false;
}; };
@@ -2233,7 +2296,7 @@ mod tests {
) { ) {
let (project, _workspace, window_handle) = init_test_with_window(cx).await; let (project, _workspace, window_handle) = init_test_with_window(cx).await;
let (_pane, terminal, _terminal_view) = let (_pane, terminal, _terminal_view) =
add_display_only_terminal(&project, window_handle, true, cx); add_display_only_terminal(&project, window_handle, true, false, cx);
let mut cx = VisualTestContext::from_window(window_handle.into(), cx); let mut cx = VisualTestContext::from_window(window_handle.into(), cx);
cx.update(|window, cx| { cx.update(|window, cx| {
@@ -2253,12 +2316,169 @@ mod tests {
}); });
} }
#[gpui::test]
async fn read_only_blocks_input_actions_and_preserves_output(cx: &mut TestAppContext) {
let (project, _workspace, window_handle) = init_test_with_window(cx).await;
cx.update(load_default_keymap);
let rerun_count = Rc::new(std::cell::Cell::new(0));
cx.update(|cx| {
cx.on_action({
let rerun_count = rerun_count.clone();
move |_: &zed_actions::Rerun, _| rerun_count.set(rerun_count.get() + 1)
});
});
let (pane, terminal, terminal_view) =
add_display_only_terminal(&project, window_handle, true, true, cx);
let mut cx = VisualTestContext::from_window(window_handle.into(), cx);
for output in [
b"provider output".as_slice(),
b"\x1b[?1049hprovider alternate-screen output".as_slice(),
] {
let content = cx.update(|window, cx| {
terminal.update(cx, |terminal, cx| {
terminal.write_output(output, cx);
terminal.sync(window, cx);
terminal.get_content()
})
});
cx.simulate_keystrokes("a enter ctrl-v shift-up");
cx.update(|_, cx| {
cx.write_to_clipboard(gpui::ClipboardItem::new_string("clipboard input".into()));
});
let actions: Vec<Box<dyn Action>> = vec![
Box::new(SendText("direct input".into())),
Box::new(SendKeystroke("enter".into())),
Box::new(Paste),
Box::new(editor::actions::Paste),
Box::new(PasteText),
Box::new(Clear),
Box::new(ShowCharacterPalette),
Box::new(RerunTask),
];
for action in actions {
cx.update(|window, cx| window.dispatch_action(action, cx));
cx.run_until_parked();
}
cx.update(|window, cx| {
let paths = ExternalPaths(vec![PathBuf::from(util::path!("/root.txt"))].into());
assert!(!pane.update(cx, |pane, cx| {
terminal_view.update(cx, |view, cx| view.handle_drop(pane, &paths, window, cx))
}));
terminal_view.update(cx, |view, cx| {
view.add_paths_to_terminal(paths.paths(), window, cx);
});
terminal.update(cx, |terminal, cx| {
terminal.sync(window, cx);
assert!(terminal.take_input_log().is_empty());
assert!(terminal.take_pty_write_log().is_empty());
assert_eq!(terminal.get_content(), content);
});
});
}
assert_eq!(rerun_count.get(), 0);
}
#[gpui::test]
async fn read_only_preserves_vi_navigation_without_enabling_input(cx: &mut TestAppContext) {
let (project, _workspace, window_handle) = init_test_with_window(cx).await;
let (_pane, terminal, _terminal_view) =
add_display_only_terminal(&project, window_handle, true, true, cx);
let mut cx = VisualTestContext::from_window(window_handle.into(), cx);
cx.update(|window, cx| {
terminal.update(cx, |terminal, cx| {
terminal.write_output(b"first line\nsecond line\n", cx);
terminal.sync(window, cx);
});
window.dispatch_action(Box::new(ToggleViMode), cx);
});
cx.run_until_parked();
let cursor = terminal.read_with(&cx, |terminal, _| {
assert!(terminal.vi_mode_enabled());
terminal.last_content.cursor.point
});
cx.update(|window, cx| {
window.dispatch_action(Box::new(SendKeystroke("k".into())), cx);
});
cx.run_until_parked();
assert_eq!(
terminal.read_with(&cx, |terminal, _| terminal.last_content.cursor.point.line),
cursor.line - 1,
);
cx.simulate_keystrokes("v l");
assert!(
terminal
.read_with(&cx, |terminal, _| terminal
.last_content
.selection_text
.clone())
.is_some_and(|text| !text.is_empty()),
);
cx.simulate_keystrokes("y");
assert!(
cx.update(|_, cx| cx.read_from_clipboard().and_then(|item| item.text()))
.is_some_and(|text| !text.is_empty()),
);
cx.simulate_keystrokes("i");
assert!(!terminal.read_with(&cx, |terminal, _| terminal.vi_mode_enabled()));
cx.simulate_keystrokes("a enter");
cx.update(|window, cx| {
window.dispatch_action(Box::new(SendKeystroke("enter".into())), cx);
});
cx.run_until_parked();
assert!(
terminal
.update(&mut cx, |terminal, _| terminal.take_input_log())
.is_empty()
);
}
#[gpui::test]
async fn read_only_mouse_selection_and_focus_do_not_report_input(cx: &mut TestAppContext) {
let (project, _workspace, window_handle) = init_test_with_window(cx).await;
let (_pane, terminal, _terminal_view) =
add_display_only_terminal(&project, window_handle, true, true, cx);
let mut cx = VisualTestContext::from_window(window_handle.into(), cx);
cx.update(|window, cx| {
terminal.update(cx, |terminal, cx| {
terminal.write_output(b"\x1b[?1003h\x1b[?1006h\x1b[?1004hhello world\n", cx);
terminal.sync(window, cx);
terminal.take_pty_write_log();
});
window.blur(cx);
});
cx.run_until_parked();
let bounds = terminal.read_with(&cx, |terminal, _| terminal.last_content.terminal_bounds);
let start =
bounds.bounds.origin + gpui::point(bounds.cell_width * 0.1, bounds.line_height * 0.5);
let end =
bounds.bounds.origin + gpui::point(bounds.cell_width * 5.1, bounds.line_height * 0.5);
cx.simulate_mouse_move(start, None, gpui::Modifiers::default());
cx.simulate_mouse_down(start, MouseButton::Left, gpui::Modifiers::default());
cx.simulate_mouse_move(end, Some(MouseButton::Left), gpui::Modifiers::default());
cx.simulate_mouse_up(end, MouseButton::Left, gpui::Modifiers::default());
cx.update(|window, cx| window.dispatch_action(Box::new(Copy), cx));
cx.run_until_parked();
assert_eq!(
cx.update(|_, cx| cx.read_from_clipboard().and_then(|item| item.text())),
Some("hello".into()),
);
cx.update(|window, cx| window.blur(cx));
cx.run_until_parked();
assert!(
terminal
.update(&mut cx, |terminal, _| terminal.take_pty_write_log())
.is_empty()
);
}
#[gpui::test] #[gpui::test]
async fn shift_up_scrolls_history_in_normal_screen(cx: &mut TestAppContext) { async fn shift_up_scrolls_history_in_normal_screen(cx: &mut TestAppContext) {
let (project, _workspace, window_handle) = init_test_with_window(cx).await; let (project, _workspace, window_handle) = init_test_with_window(cx).await;
cx.update(load_default_keymap); cx.update(load_default_keymap);
let (_pane, terminal, _terminal_view) = let (_pane, terminal, _terminal_view) =
add_display_only_terminal(&project, window_handle, true, cx); add_display_only_terminal(&project, window_handle, true, false, cx);
let mut cx = VisualTestContext::from_window(window_handle.into(), cx); let mut cx = VisualTestContext::from_window(window_handle.into(), cx);
cx.update(|window, cx| { cx.update(|window, cx| {
@@ -2303,7 +2523,7 @@ mod tests {
let (project, _workspace, window_handle) = init_test_with_window(cx).await; let (project, _workspace, window_handle) = init_test_with_window(cx).await;
cx.update(load_default_keymap); cx.update(load_default_keymap);
let (_pane, terminal, _terminal_view) = let (_pane, terminal, _terminal_view) =
add_display_only_terminal(&project, window_handle, true, cx); add_display_only_terminal(&project, window_handle, true, false, cx);
let mut cx = VisualTestContext::from_window(window_handle.into(), cx); let mut cx = VisualTestContext::from_window(window_handle.into(), cx);
cx.update(|window, cx| { cx.update(|window, cx| {
@@ -2335,7 +2555,7 @@ mod tests {
let (project, _workspace, window_handle) = init_test_with_window(cx).await; let (project, _workspace, window_handle) = init_test_with_window(cx).await;
cx.update(load_default_keymap); cx.update(load_default_keymap);
let (_pane, terminal, _terminal_view) = let (_pane, terminal, _terminal_view) =
add_display_only_terminal(&project, window_handle, true, cx); add_display_only_terminal(&project, window_handle, true, false, cx);
let mut cx = VisualTestContext::from_window(window_handle.into(), cx); let mut cx = VisualTestContext::from_window(window_handle.into(), cx);
cx.update(|window, cx| { cx.update(|window, cx| {
@@ -2362,7 +2582,7 @@ mod tests {
}); });
}); });
let (_pane, terminal, _terminal_view) = let (_pane, terminal, _terminal_view) =
add_display_only_terminal(&project, window_handle, true, cx); add_display_only_terminal(&project, window_handle, true, false, cx);
let mut cx = VisualTestContext::from_window(window_handle.into(), cx); let mut cx = VisualTestContext::from_window(window_handle.into(), cx);
cx.update(|window, cx| { cx.update(|window, cx| {
@@ -2597,10 +2817,11 @@ mod tests {
); );
} }
fn add_display_only_terminal( pub(super) fn add_display_only_terminal(
project: &Entity<Project>, project: &Entity<Project>,
window_handle: gpui::WindowHandle<MultiWorkspace>, window_handle: gpui::WindowHandle<MultiWorkspace>,
focus: bool, focus: bool,
read_only: bool,
cx: &mut TestAppContext, cx: &mut TestAppContext,
) -> (Entity<Pane>, Entity<Terminal>, Entity<TerminalView>) { ) -> (Entity<Pane>, Entity<Terminal>, Entity<TerminalView>) {
let project = project.clone(); let project = project.clone();
@@ -2629,6 +2850,7 @@ mod tests {
window, window,
cx, cx,
) )
.with_read_only(read_only)
}); });
active_pane.update(cx, |pane, cx| { active_pane.update(cx, |pane, cx| {
@@ -2653,7 +2875,7 @@ mod tests {
} }
/// Creates a worktree with 1 file /root.txt and returns the project, workspace, and window handle. /// Creates a worktree with 1 file /root.txt and returns the project, workspace, and window handle.
async fn init_test_with_window( pub(super) async fn init_test_with_window(
cx: &mut TestAppContext, cx: &mut TestAppContext,
) -> ( ) -> (
Entity<Project>, Entity<Project>,
@@ -2853,7 +3075,7 @@ mod tests {
.unwrap(); .unwrap();
let (active_pane, terminal, terminal_view) = let (active_pane, terminal, terminal_view) =
add_display_only_terminal(&project, window_handle, false, cx); add_display_only_terminal(&project, window_handle, false, false, cx);
let tab_item = window_handle let tab_item = window_handle
.update(cx, |_, window, cx| { .update(cx, |_, window, cx| {