diff --git a/crates/agent_ui/src/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs index e2896e9ad8..efe36015d7 100644 --- a/crates/agent_ui/src/conversation_view.rs +++ b/crates/agent_ui/src/conversation_view.rs @@ -9562,6 +9562,7 @@ pub(crate) mod tests { ) .subscribe(cx) }); + let display_terminal = terminal.clone(); thread.update(cx, |thread, cx| { thread.on_terminal_provider_event( acp_thread::TerminalProviderEvent::Created { @@ -9593,6 +9594,49 @@ pub(crate) mod tests { state.expand_tool_call(tool_id.clone()); 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()); thread.update(cx, |thread, cx| { 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] async fn test_tool_permission_buttons_terminal_with_pattern(cx: &mut TestAppContext) { init_test(cx); diff --git a/crates/agent_ui/src/entry_view_state.rs b/crates/agent_ui/src/entry_view_state.rs index a77366b7d8..7f9a9e61d9 100644 --- a/crates/agent_ui/src/entry_view_state.rs +++ b/crates/agent_ui/src/entry_view_state.rs @@ -643,6 +643,7 @@ fn create_terminal( cx: &mut App, ) -> Entity { cx.new(|cx| { + let read_only = !terminal.read(cx).is_process_backed(); let mut view = TerminalView::new( terminal.read(cx).inner().clone(), workspace, @@ -650,7 +651,8 @@ fn create_terminal( project, window, cx, - ); + ) + .with_read_only(read_only); view.set_embedded_mode(Some(1000), cx); view }) diff --git a/crates/terminal/src/terminal.rs b/crates/terminal/src/terminal.rs index 8a669dd6bc..ed12f8eb44 100644 --- a/crates/terminal/src/terminal.rs +++ b/crates/terminal/src/terminal.rs @@ -934,6 +934,12 @@ fn init_command_startup_marker_command(shell_kind: ShellKind, marker_id: u64) -> /// sender and receiver remain paired. pub struct TerminalMode(TerminalModeKind); +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum MouseInputMode { + ReportToTerminal, + LocalSelection, +} + enum TerminalModeKind { Interactive, InteractiveWithCompletion(Sender>), @@ -2476,13 +2482,15 @@ impl Terminal { } } - pub fn mouse_mode(&self, shift: bool) -> bool { - self.last_content.mode.intersects(Modes::MOUSE_MODE) && !shift + pub fn mouse_mode(&self, shift: bool, mode: MouseInputMode) -> bool { + mode == MouseInputMode::ReportToTerminal + && self.last_content.mode.intersects(Modes::MOUSE_MODE) + && !shift } - pub fn mouse_move(&mut self, e: &MouseMoveEvent, cx: &mut Context) { + pub fn mouse_move(&mut self, e: &MouseMoveEvent, mode: MouseInputMode, cx: &mut Context) { 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 // `mouse_down`. Since the app never saw the press, we must swallow // the whole gesture rather than forward later motion/release @@ -2580,10 +2588,11 @@ impl Terminal { &mut self, e: &MouseMoveEvent, region: Bounds, + mode: MouseInputMode, cx: &mut Context, ) { 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 { let point = grid_point( position, @@ -2646,7 +2655,7 @@ impl Terminal { Some(scroll_lines.clamp(-3, 3)) } - pub fn mouse_down(&mut self, e: &MouseDownEvent, cx: &mut Context) { + pub fn mouse_down(&mut self, e: &MouseDownEvent, mode: MouseInputMode, cx: &mut Context) { let position = e.position - self.last_content.terminal_bounds.bounds.origin; let point = grid_point( position, @@ -2657,7 +2666,7 @@ impl Terminal { if e.button == MouseButton::Left && e.modifiers.secondary() && (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); @@ -2666,7 +2675,7 @@ impl Terminal { } } - if self.mouse_mode(e.modifiers.shift) { + if self.mouse_mode(e.modifiers.shift, mode) { let bytes = 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"))] 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(); self.paste(&text); } @@ -2727,7 +2738,7 @@ impl Terminal { } } - pub fn mouse_up(&mut self, e: &MouseUpEvent, cx: &Context) { + pub fn mouse_up(&mut self, e: &MouseUpEvent, mode: MouseInputMode, cx: &Context) { let setting = TerminalSettings::get_global(cx); let position = e.position - self.last_content.terminal_bounds.bounds.origin; @@ -2750,7 +2761,7 @@ impl Terminal { return; } - if self.mouse_mode(e.modifiers.shift) { + if self.mouse_mode(e.modifiers.shift, mode) { self.selection_phase = SelectionPhase::Ended; self.last_mouse = 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( position, self.last_content.terminal_bounds, @@ -2800,8 +2811,13 @@ impl Terminal { } ///Scroll the terminal - pub fn scroll_wheel(&mut self, e: &ScrollWheelEvent, scroll_multiplier: f32) { - let mouse_mode = self.mouse_mode(e.shift); + pub fn scroll_wheel( + &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 }; if let Some(scroll_lines) = self.determine_scroll_lines(e, scroll_multiplier) @@ -2820,10 +2836,11 @@ impl Terminal { self.write_to_pty(scroll); } }; - } else if self - .last_content - .mode - .contains(Modes::ALT_SCREEN | Modes::ALTERNATE_SCROLL) + } else if mode == MouseInputMode::ReportToTerminal + && self + .last_content + .mode + .contains(Modes::ALT_SCREEN | Modes::ALTERNATE_SCROLL) && !e.shift { self.write_to_pty(alt_scroll(scroll_lines)); @@ -3580,7 +3597,8 @@ mod tests { }; use collections::HashMap; 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 rand::{Rng, distr, rngs::StdRng}; @@ -3927,7 +3945,7 @@ mod tests { click_count: 1, first_mouse: true, }; - terminal.mouse_down(&mouse_down, cx); + terminal.mouse_down(&mouse_down, MouseInputMode::ReportToTerminal, cx); } fn left_mouse_up_at( @@ -3941,7 +3959,7 @@ mod tests { modifiers: Modifiers::none(), click_count: 1, }; - terminal.mouse_up(&mouse_up, cx); + terminal.mouse_up(&mouse_up, MouseInputMode::ReportToTerminal, cx); } fn left_mouse_drag_to( @@ -3955,7 +3973,7 @@ mod tests { pressed_button: Some(MouseButton::Left), 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 @@ -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 /// select terminal text. Shift+drag must start a selection rather than being /// swallowed as a "extend existing selection" no-op. Regression test for #60254. @@ -4032,6 +4136,7 @@ mod tests { click_count: 1, first_mouse: true, }, + MouseInputMode::ReportToTerminal, cx, ); @@ -4054,6 +4159,7 @@ mod tests { modifiers: shift, }, region, + MouseInputMode::ReportToTerminal, cx, ); @@ -4094,6 +4200,7 @@ mod tests { click_count: 1, first_mouse: true, }, + MouseInputMode::ReportToTerminal, cx, ); @@ -4822,7 +4929,7 @@ mod tests { click_count: 1, first_mouse: true, }; - terminal.mouse_down(&mouse_down, cx); + terminal.mouse_down(&mouse_down, MouseInputMode::ReportToTerminal, cx); } fn ctrl_mouse_drag_to( @@ -4836,7 +4943,12 @@ mod tests { pressed_button: Some(MouseButton::Left), 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( @@ -4850,7 +4962,7 @@ mod tests { modifiers: Modifiers::secondary_key(), click_count: 1, }; - terminal.mouse_up(&mouse_up, cx); + terminal.mouse_up(&mouse_up, MouseInputMode::ReportToTerminal, cx); } 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] async fn test_hyperlink_ctrl_click_mismatch_in_mouse_mode_consumes_gesture( cx: &mut TestAppContext, @@ -4920,6 +5064,7 @@ mod tests { pressed_button: Some(MouseButton::Left), modifiers: Modifiers::secondary_key(), }, + MouseInputMode::ReportToTerminal, cx, ); ctrl_mouse_up_at(terminal, up_position, cx); @@ -5205,7 +5350,8 @@ mod tests { }; self.window.simulate_mouse_move(position, self.cx); 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) { @@ -5752,6 +5898,7 @@ mod tests { position, ..default() }, + MouseInputMode::ReportToTerminal, cx, ); @@ -5762,6 +5909,7 @@ mod tests { ..default() }, 1.0, + MouseInputMode::ReportToTerminal, ); assert!( diff --git a/crates/terminal_view/src/terminal_element.rs b/crates/terminal_view/src/terminal_element.rs index 3067462f41..7e5c2ab2cd 100644 --- a/crates/terminal_view/src/terminal_element.rs +++ b/crates/terminal_view/src/terminal_element.rs @@ -1,9 +1,9 @@ use editor::{CursorLayout, EditorSettings, HighlightedRange, HighlightedRangeLine}; use gpui::{ - AbsoluteLength, AnyElement, App, AvailableSpace, Bounds, ContentMask, Context, DispatchPhase, - Element, ElementId, Entity, FocusHandle, Font, FontFeatures, FontStyle, FontWeight, - GlobalElementId, HighlightStyle, Hitbox, Hsla, InputHandler, InteractiveElement, Interactivity, - IntoElement, LayoutId, Length, ModifiersChangedEvent, MouseButton, MouseMoveEvent, Pixels, + AbsoluteLength, AnyElement, App, AvailableSpace, Bounds, ContentMask, DispatchPhase, Element, + ElementId, Entity, FocusHandle, Font, FontFeatures, FontStyle, FontWeight, GlobalElementId, + HighlightStyle, Hitbox, Hsla, InputHandler, InteractiveElement, Interactivity, IntoElement, + LayoutId, Length, ModifiersChangedEvent, MouseButton, MouseMoveEvent, Pixels, Point as GpuiPoint, StatefulInteractiveElement, StrikethroughStyle, Styled, TextRun, TextStyle, UTF16Selection, UnderlineStyle, WeakEntity, WhiteSpace, Window, div, fill, point, px, relative, size, @@ -13,9 +13,10 @@ use language::CursorShape as EditorCursorShape; use settings::Settings; use std::time::Instant; use terminal::{ - Cell, Color, Content, CursorShape, IndexedCell, Modes, NamedColor, Point, Range, Terminal, - TerminalBounds, is_app_chosen_exact_color as terminal_is_app_chosen_exact_color, - is_default_background_color, terminal_settings::TerminalSettings, + Cell, Color, Content, CursorShape, IndexedCell, Modes, MouseInputMode, NamedColor, Point, + Range, Terminal, TerminalBounds, + 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_settings::ThemeSettings; @@ -40,7 +41,6 @@ pub struct LayoutState { ime_cursor_bounds: Option>, background_color: Hsla, dimensions: TerminalBounds, - mode: Modes, display_offset: usize, hyperlink_tooltip: Option, block_below_cursor_element: Option, @@ -940,31 +940,11 @@ impl TerminalElement { result } - fn generic_button_handler( - connection: Entity, - focus_handle: FocusHandle, - steal_focus: bool, - f: impl Fn(&mut Terminal, &E, &mut Context), - ) -> 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( &mut self, - mode: Modes, hitbox: &Hitbox, content_mode: &ContentMode, + mouse_input_mode: MouseInputMode, window: &mut Window, ) { let focus = self.focus.clone(); @@ -979,13 +959,14 @@ impl TerminalElement { move |e, window, 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| { let mut adjusted_event = e.clone(); if scroll_top > Pixels::ZERO { adjusted_event.position.y += scroll_top; } - terminal.mouse_down(&adjusted_event, cx); + terminal.mouse_down(&adjusted_event, mouse_input_mode, cx); cx.notify(); }) } @@ -995,7 +976,6 @@ impl TerminalElement { let terminal = self.terminal.clone(); let hitbox = hitbox.clone(); let focus = focus.clone(); - let terminal_view = terminal_view; move |e: &MouseMoveEvent, phase, window, cx| { if phase != DispatchPhase::Bubble { return; @@ -1011,7 +991,12 @@ impl TerminalElement { if scroll_top > Pixels::ZERO { 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(); } }) @@ -1019,34 +1004,60 @@ impl TerminalElement { if hitbox.is_hovered(window) { terminal.update(cx, |terminal, cx| { - terminal.mouse_move(e, cx); + terminal.mouse_move(e, mouse_input_mode, cx); }) } } }); - self.interactivity.on_mouse_up( - MouseButton::Left, - TerminalElement::generic_button_handler( - terminal.clone(), - focus.clone(), - false, - move |terminal, e, cx| { - terminal.mouse_up(e, cx); - }, - ), - ); - self.interactivity.on_mouse_down( - MouseButton::Middle, - TerminalElement::generic_button_handler( - terminal.clone(), - focus.clone(), - true, - move |terminal, e, cx| { - terminal.mouse_down(e, cx); - }, - ), - ); + for button in [MouseButton::Left, MouseButton::Middle, MouseButton::Right] { + self.interactivity.on_mouse_up(button, { + let terminal = terminal.clone(); + let focus = focus.clone(); + move |event, window, cx| { + if !focus.is_focused(window) { + return; + } + if button != MouseButton::Left + && (mouse_input_mode == MouseInputMode::LocalSelection + || !terminal + .read(cx) + .last_content + .mode + .intersects(Modes::MOUSE_MODE)) + { + return; + } + 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() { 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 { @@ -1395,14 +1368,12 @@ impl Element for TerminalElement { let Content { cells, - mode, display_offset, cursor_char, selection, cursor, .. } = &self.terminal.read(cx).last_content; - let mode = *mode; let display_offset = *display_offset; // searches, highlights to a single range representations @@ -1589,7 +1560,6 @@ impl Element for TerminalElement { dimensions, rects, relative_highlighted_ranges, - mode, display_offset, hyperlink_tooltip, block_below_cursor_element, @@ -1612,7 +1582,9 @@ impl Element for TerminalElement { ) { let paint_start = Instant::now(); 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)); let origin = layout.dimensions.bounds.origin - GpuiPoint::new(px(0.), scroll_top); @@ -1634,9 +1606,9 @@ impl Element for TerminalElement { }; self.register_mouse_listeners( - layout.mode, &layout.hitbox, &layout.content_mode, + mouse_input_mode, window, ); if window.modifiers().secondary() @@ -1799,10 +1771,13 @@ struct TerminalInputHandler { impl InputHandler for TerminalInputHandler { fn selected_text_range( &mut self, - _ignore_disabled_input: bool, + ignore_disabled_input: bool, _: &mut Window, - _cx: &mut App, + cx: &mut App, ) -> Option { + if self.terminal_view.read(cx).is_read_only() && !ignore_disabled_input { + return None; + } // Always return a valid selection for IME positioning, // 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. @@ -1837,6 +1812,9 @@ impl InputHandler for TerminalInputHandler { window: &mut Window, cx: &mut App, ) { + if self.terminal_view.read(cx).is_read_only() { + return; + } self.terminal_view.update(cx, |view, view_cx| { view.clear_marked_text(view_cx); view.commit_text(text, view_cx); @@ -2030,9 +2008,62 @@ pub fn convert_color(fg: &Color, theme: &Theme) -> Hsla { #[cfg(test)] mod tests { use super::*; - use gpui::{AbsoluteLength, Hsla, font}; + use gpui::{AbsoluteLength, Hsla, TestAppContext, font}; 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] fn test_is_decorative_character() { // Box Drawing characters (U+2500 to U+257F) diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index 5f711f229c..7651034547 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -33,9 +33,10 @@ use std::{ }; use task::TaskId; use terminal::{ - Clear, Copy, Event, HoveredWord, MaybeNavigationTarget, Modes, Paste, PasteText, Point, Range, - ScrollLineDown, ScrollLineUp, ScrollPageDown, ScrollPageUp, ScrollToBottom, ScrollToTop, - Search, ShowCharacterPalette, TaskState, TaskStatus, Terminal, TerminalBounds, ToggleViMode, + Clear, Copy, Event, HoveredWord, MaybeNavigationTarget, Modes, MouseInputMode, Paste, + PasteText, Point, Range, ScrollLineDown, ScrollLineUp, ScrollPageDown, ScrollPageUp, + ScrollToBottom, ScrollToTop, Search, ShowCharacterPalette, TaskState, TaskStatus, Terminal, + TerminalBounds, ToggleViMode, terminal_settings::{CursorShape, TerminalSettings}, }; use terminal_element::TerminalElement; @@ -138,6 +139,7 @@ pub struct TerminalView { cursor_shape: CursorShape, blink_manager: Entity, mode: TerminalMode, + read_only: bool, // Explicit override for whether workspace-specific context menu actions are shown. // When `None`, visibility is derived from `mode` (hidden for embedded terminals). show_workspace_actions: Option, @@ -290,6 +292,7 @@ impl TerminalView { hover: None, hover_tooltip_update: Task::ready(()), mode: TerminalMode::Standalone, + read_only: false, show_workspace_actions: None, workspace_id, show_breadcrumbs: TerminalSettings::get_global(cx).toolbar.breadcrumbs, @@ -319,6 +322,25 @@ impl TerminalView { 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 /// closing terminal tabs, inline assist) are shown. /// @@ -371,6 +393,9 @@ impl TerminalView { /// Sets the marked (pre-edit) text from the IME. pub(crate) fn set_marked_text(&mut self, text: String, cx: &mut Context) { + if self.read_only { + return; + } if text.is_empty() { 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. pub(crate) fn commit_text(&mut self, text: &str, cx: &mut Context) { - if !text.is_empty() { + if !self.read_only && !text.is_empty() { self.terminal.update(cx, |term, _| { term.input(text.to_string().into_bytes()); }); @@ -543,7 +568,7 @@ impl TerminalView { }) .action("Copy", Box::new(Copy)) .when( - !matches!(self.mode, TerminalMode::Embedded { .. }), + !self.read_only && !matches!(self.mode, TerminalMode::Embedded { .. }), |menu| { menu.action("Paste", Box::new(Paste)) .action("Paste Text", Box::new(PasteText)) @@ -551,14 +576,16 @@ impl TerminalView { ) .action("Select All", Box::new(SelectAll)) .when( - !matches!(self.mode, TerminalMode::Embedded { .. }), + !self.read_only && !matches!(self.mode, TerminalMode::Embedded { .. }), |menu| menu.action("Clear", Box::new(Clear)), ) .when( assistant_enabled && !matches!(self.mode, TerminalMode::Embedded { .. }), |menu| { 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| { menu.action("Add to Agent Thread", Box::new(AddSelectionToThread)) }) @@ -633,6 +660,9 @@ impl TerminalView { window: &mut Window, cx: &mut Context, ) { + if self.read_only { + return; + } if self .terminal .read(cx) @@ -657,6 +687,9 @@ impl TerminalView { } fn rerun_task(&mut self, _: &RerunTask, window: &mut Window, cx: &mut Context) { + if self.read_only { + return; + } let task = self .terminal .read(cx) @@ -667,6 +700,9 @@ impl TerminalView { } fn clear(&mut self, _: &Clear, _: &mut Window, cx: &mut Context) { + if self.read_only { + return; + } self.scroll_top = px(0.); self.terminal.update(cx, |term, _| term.clear()); cx.notify(); @@ -711,6 +747,7 @@ impl TerminalView { term.scroll_wheel( event, 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 fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context) { + if self.read_only { + return; + } let Some(clipboard) = cx.read_from_clipboard() else { return; }; @@ -944,6 +984,9 @@ impl TerminalView { ///Attempt to paste the clipboard text into the terminal fn paste_text(&mut self, _: &PasteText, _: &mut Window, cx: &mut Context) { + if self.read_only { + return; + } let Some(clipboard) = cx.read_from_clipboard() else { return; }; @@ -963,6 +1006,9 @@ impl TerminalView { } pub fn add_paths_to_terminal(&self, paths: &[PathBuf], window: &mut Window, cx: &mut App) { + if self.read_only { + return; + } let mut text = paths .iter() .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) { + if self.read_only { + return; + } self.clear_bell(cx); self.blink_manager.update(cx, BlinkManager::pause_blinking); self.terminal.update(cx, |term, _| { @@ -1085,8 +1134,8 @@ impl TerminalView { self.terminal = terminal; } - fn rerun_button(task: &TaskState) -> Option { - if !task.spawned_task.show_rerun { + fn rerun_button(&self, task: &TaskState) -> Option { + if self.read_only || !task.spawned_task.show_rerun { return None; } @@ -1271,6 +1320,9 @@ impl TerminalView { /// updates the cursor locally without sending data to the shell, so there's no /// shell output to automatically trigger a re-render. fn process_keystroke(&mut self, keystroke: &Keystroke, cx: &mut Context) -> 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| { ( 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.terminal.update(cx, |terminal, _| { 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 { @@ -1324,7 +1378,9 @@ impl TerminalView { fn focus_out(&mut self, _window: &mut Window, cx: &mut Context) { self.blink_manager.update(cx, BlinkManager::disable); self.terminal.update(cx, |terminal, _| { - terminal.focus_out(); + if !self.read_only { + terminal.focus_out(); + } terminal.set_cursor_shape(CursorShape::Hollow); }); cx.notify(); @@ -1381,7 +1437,11 @@ impl Render for TerminalView { .on_mouse_down( MouseButton::Right, 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(); if !had_selection { this.terminal.update(cx, |terminal, _| { @@ -1482,15 +1542,15 @@ impl Item for TerminalView { TaskStatus::Running => ( IconName::PlayFilled, Color::Disabled, - TerminalView::rerun_button(terminal_task), + self.rerun_button(terminal_task), ), TaskStatus::Unknown => ( IconName::Warning, Color::Warning, - TerminalView::rerun_button(terminal_task), + self.rerun_button(terminal_task), ), TaskStatus::Completed { success } => { - let rerun_button = TerminalView::rerun_button(terminal_task); + let rerun_button = self.rerun_button(terminal_task); if *success { (IconName::Check, Color::Success, rerun_button) @@ -1591,6 +1651,9 @@ impl Item for TerminalView { window: &mut Window, cx: &mut App, ) -> bool { + if self.read_only { + return false; + } let Some(project) = self.project.upgrade() else { return false; }; @@ -2233,7 +2296,7 @@ mod tests { ) { let (project, _workspace, window_handle) = init_test_with_window(cx).await; 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); 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> = 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] async fn shift_up_scrolls_history_in_normal_screen(cx: &mut TestAppContext) { let (project, _workspace, window_handle) = init_test_with_window(cx).await; cx.update(load_default_keymap); 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); cx.update(|window, cx| { @@ -2303,7 +2523,7 @@ mod tests { let (project, _workspace, window_handle) = init_test_with_window(cx).await; cx.update(load_default_keymap); 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); cx.update(|window, cx| { @@ -2335,7 +2555,7 @@ mod tests { let (project, _workspace, window_handle) = init_test_with_window(cx).await; cx.update(load_default_keymap); 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); cx.update(|window, cx| { @@ -2362,7 +2582,7 @@ mod tests { }); }); 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); cx.update(|window, cx| { @@ -2597,10 +2817,11 @@ mod tests { ); } - fn add_display_only_terminal( + pub(super) fn add_display_only_terminal( project: &Entity, window_handle: gpui::WindowHandle, focus: bool, + read_only: bool, cx: &mut TestAppContext, ) -> (Entity, Entity, Entity) { let project = project.clone(); @@ -2629,6 +2850,7 @@ mod tests { window, cx, ) + .with_read_only(read_only) }); 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. - async fn init_test_with_window( + pub(super) async fn init_test_with_window( cx: &mut TestAppContext, ) -> ( Entity, @@ -2853,7 +3075,7 @@ mod tests { .unwrap(); 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 .update(cx, |_, window, cx| {