Detailed changes
@@ -2,6 +2,7 @@ use crate::{
Templates,
edit_agent::{EditAgent, EditAgentOutput, EditAgentOutputEvent},
schema::json_schema_for,
+ ui::{COLLAPSED_LINES, ToolOutputPreview},
};
use anyhow::{Context as _, Result, anyhow};
use assistant_tool::{
@@ -13,7 +14,7 @@ use editor::{Editor, EditorMode, MinimapVisibility, MultiBuffer, PathKey};
use futures::StreamExt;
use gpui::{
Animation, AnimationExt, AnyWindowHandle, App, AppContext, AsyncApp, Entity, Task,
- TextStyleRefinement, WeakEntity, pulsating_between,
+ TextStyleRefinement, WeakEntity, pulsating_between, px,
};
use indoc::formatdoc;
use language::{
@@ -884,30 +885,8 @@ impl ToolCard for EditFileToolCard {
(element.into_any_element(), line_height)
});
- let (full_height_icon, full_height_tooltip_label) = if self.full_height_expanded {
- (IconName::ChevronUp, "Collapse Code Block")
- } else {
- (IconName::ChevronDown, "Expand Code Block")
- };
-
- let gradient_overlay =
- div()
- .absolute()
- .bottom_0()
- .left_0()
- .w_full()
- .h_2_5()
- .bg(gpui::linear_gradient(
- 0.,
- gpui::linear_color_stop(cx.theme().colors().editor_background, 0.),
- gpui::linear_color_stop(cx.theme().colors().editor_background.opacity(0.), 1.),
- ));
-
let border_color = cx.theme().colors().border.opacity(0.6);
- const DEFAULT_COLLAPSED_LINES: u32 = 10;
- let is_collapsible = self.total_lines.unwrap_or(0) > DEFAULT_COLLAPSED_LINES;
-
let waiting_for_diff = {
let styles = [
("w_4_5", (0.1, 0.85), 2000),
@@ -992,48 +971,34 @@ impl ToolCard for EditFileToolCard {
card.child(waiting_for_diff)
})
.when(self.preview_expanded && !self.is_loading(), |card| {
+ let editor_view = v_flex()
+ .relative()
+ .h_full()
+ .when(!self.full_height_expanded, |editor_container| {
+ editor_container.max_h(px(COLLAPSED_LINES as f32 * editor_line_height.0))
+ })
+ .overflow_hidden()
+ .border_t_1()
+ .border_color(border_color)
+ .bg(cx.theme().colors().editor_background)
+ .child(editor);
+
card.child(
- v_flex()
- .relative()
- .h_full()
- .when(!self.full_height_expanded, |editor_container| {
- editor_container
- .max_h(DEFAULT_COLLAPSED_LINES as f32 * editor_line_height)
- })
- .overflow_hidden()
- .border_t_1()
- .border_color(border_color)
- .bg(cx.theme().colors().editor_background)
- .child(editor)
- .when(
- !self.full_height_expanded && is_collapsible,
- |editor_container| editor_container.child(gradient_overlay),
- ),
+ ToolOutputPreview::new(editor_view.into_any_element(), self.editor.entity_id())
+ .with_total_lines(self.total_lines.unwrap_or(0) as usize)
+ .toggle_state(self.full_height_expanded)
+ .with_collapsed_fade()
+ .on_toggle({
+ let this = cx.entity().downgrade();
+ move |is_expanded, _window, cx| {
+ if let Some(this) = this.upgrade() {
+ this.update(cx, |this, _cx| {
+ this.full_height_expanded = is_expanded;
+ });
+ }
+ }
+ }),
)
- .when(is_collapsible, |card| {
- card.child(
- h_flex()
- .id(("expand-button", self.editor.entity_id()))
- .flex_none()
- .cursor_pointer()
- .h_5()
- .justify_center()
- .border_t_1()
- .rounded_b_md()
- .border_color(border_color)
- .bg(cx.theme().colors().editor_background)
- .hover(|style| style.bg(cx.theme().colors().element_hover.opacity(0.1)))
- .child(
- Icon::new(full_height_icon)
- .size(IconSize::Small)
- .color(Color::Muted),
- )
- .tooltip(Tooltip::text(full_height_tooltip_label))
- .on_click(cx.listener(move |this, _event, _window, _cx| {
- this.full_height_expanded = !this.full_height_expanded;
- })),
- )
- })
})
}
}
@@ -1,4 +1,7 @@
-use crate::schema::json_schema_for;
+use crate::{
+ schema::json_schema_for,
+ ui::{COLLAPSED_LINES, ToolOutputPreview},
+};
use anyhow::{Context as _, Result, anyhow};
use assistant_tool::{ActionLog, Tool, ToolCard, ToolResult, ToolUseStatus};
use futures::{FutureExt as _, future::Shared};
@@ -25,7 +28,7 @@ use terminal_view::TerminalView;
use theme::ThemeSettings;
use ui::{Disclosure, Tooltip, prelude::*};
use util::{
- get_system_shell, markdown::MarkdownInlineCode, size::format_file_size,
+ ResultExt, get_system_shell, markdown::MarkdownInlineCode, size::format_file_size,
time::duration_alt_display,
};
use workspace::Workspace;
@@ -254,22 +257,24 @@ impl Tool for TerminalTool {
let terminal_view = window.update(cx, |_, window, cx| {
cx.new(|cx| {
- TerminalView::new(
+ let mut view = TerminalView::new(
terminal.clone(),
workspace.downgrade(),
None,
project.downgrade(),
- true,
window,
cx,
- )
+ );
+ view.set_embedded_mode(None, cx);
+ view
})
})?;
- let _ = card.update(cx, |card, _| {
+ card.update(cx, |card, _| {
card.terminal = Some(terminal_view.clone());
card.start_instant = Instant::now();
- });
+ })
+ .log_err();
let exit_status = terminal
.update(cx, |terminal, cx| terminal.wait_for_completed_task(cx))?
@@ -285,7 +290,7 @@ impl Tool for TerminalTool {
exit_status.map(portable_pty::ExitStatus::from),
);
- let _ = card.update(cx, |card, _| {
+ card.update(cx, |card, _| {
card.command_finished = true;
card.exit_status = exit_status;
card.was_content_truncated = processed_content.len() < previous_len;
@@ -293,7 +298,8 @@ impl Tool for TerminalTool {
card.content_line_count = content_line_count;
card.finished_with_empty_output = finished_with_empty_output;
card.elapsed_time = Some(card.start_instant.elapsed());
- });
+ })
+ .log_err();
Ok(processed_content.into())
}
@@ -473,7 +479,6 @@ impl ToolCard for TerminalToolCard {
let time_elapsed = self
.elapsed_time
.unwrap_or_else(|| self.start_instant.elapsed());
- let should_hide_terminal = tool_failed || self.finished_with_empty_output;
let header_bg = cx
.theme()
@@ -574,7 +579,7 @@ impl ToolCard for TerminalToolCard {
),
)
})
- .when(!should_hide_terminal, |header| {
+ .when(!self.finished_with_empty_output, |header| {
header.child(
Disclosure::new(
("terminal-tool-disclosure", self.entity_id),
@@ -618,19 +623,43 @@ impl ToolCard for TerminalToolCard {
),
),
)
- .when(self.preview_expanded && !should_hide_terminal, |this| {
- this.child(
- div()
- .pt_2()
- .min_h_72()
- .border_t_1()
- .border_color(border_color)
- .bg(cx.theme().colors().editor_background)
- .rounded_b_md()
- .text_ui_sm(cx)
- .child(terminal.clone()),
- )
- })
+ .when(
+ self.preview_expanded && !self.finished_with_empty_output,
+ |this| {
+ this.child(
+ div()
+ .pt_2()
+ .border_t_1()
+ .border_color(border_color)
+ .bg(cx.theme().colors().editor_background)
+ .rounded_b_md()
+ .text_ui_sm(cx)
+ .child(
+ ToolOutputPreview::new(
+ terminal.clone().into_any_element(),
+ terminal.entity_id(),
+ )
+ .with_total_lines(self.content_line_count)
+ .toggle_state(!terminal.read(cx).is_content_limited(window))
+ .on_toggle({
+ let terminal = terminal.clone();
+ move |is_expanded, _, cx| {
+ terminal.update(cx, |terminal, cx| {
+ terminal.set_embedded_mode(
+ if is_expanded {
+ None
+ } else {
+ Some(COLLAPSED_LINES)
+ },
+ cx,
+ );
+ });
+ }
+ }),
+ ),
+ )
+ },
+ )
.into_any()
}
}
@@ -1,3 +1,5 @@
mod tool_call_card_header;
+mod tool_output_preview;
pub use tool_call_card_header::*;
+pub use tool_output_preview::*;
@@ -0,0 +1,115 @@
+use gpui::{AnyElement, EntityId, prelude::*};
+use ui::{Tooltip, prelude::*};
+
+#[derive(IntoElement)]
+pub struct ToolOutputPreview<F>
+where
+ F: Fn(bool, &mut Window, &mut App) + 'static,
+{
+ content: AnyElement,
+ entity_id: EntityId,
+ full_height: bool,
+ total_lines: usize,
+ collapsed_fade: bool,
+ on_toggle: Option<F>,
+}
+
+pub const COLLAPSED_LINES: usize = 10;
+
+impl<F> ToolOutputPreview<F>
+where
+ F: Fn(bool, &mut Window, &mut App) + 'static,
+{
+ pub fn new(content: AnyElement, entity_id: EntityId) -> Self {
+ Self {
+ content,
+ entity_id,
+ full_height: true,
+ total_lines: 0,
+ collapsed_fade: false,
+ on_toggle: None,
+ }
+ }
+
+ pub fn with_total_lines(mut self, total_lines: usize) -> Self {
+ self.total_lines = total_lines;
+ self
+ }
+
+ pub fn toggle_state(mut self, full_height: bool) -> Self {
+ self.full_height = full_height;
+ self
+ }
+
+ pub fn with_collapsed_fade(mut self) -> Self {
+ self.collapsed_fade = true;
+ self
+ }
+
+ pub fn on_toggle(mut self, listener: F) -> Self {
+ self.on_toggle = Some(listener);
+ self
+ }
+}
+
+impl<F> RenderOnce for ToolOutputPreview<F>
+where
+ F: Fn(bool, &mut Window, &mut App) + 'static,
+{
+ fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
+ if self.total_lines <= COLLAPSED_LINES {
+ return self.content;
+ }
+ let border_color = cx.theme().colors().border.opacity(0.6);
+
+ let (icon, tooltip_label) = if self.full_height {
+ (IconName::ChevronUp, "Collapse")
+ } else {
+ (IconName::ChevronDown, "Expand")
+ };
+
+ let gradient_overlay =
+ if self.collapsed_fade && !self.full_height {
+ Some(div().absolute().bottom_5().left_0().w_full().h_2_5().bg(
+ gpui::linear_gradient(
+ 0.,
+ gpui::linear_color_stop(cx.theme().colors().editor_background, 0.),
+ gpui::linear_color_stop(
+ cx.theme().colors().editor_background.opacity(0.),
+ 1.,
+ ),
+ ),
+ ))
+ } else {
+ None
+ };
+
+ v_flex()
+ .relative()
+ .child(self.content)
+ .children(gradient_overlay)
+ .child(
+ h_flex()
+ .id(("expand-button", self.entity_id))
+ .flex_none()
+ .cursor_pointer()
+ .h_5()
+ .justify_center()
+ .border_t_1()
+ .rounded_b_md()
+ .border_color(border_color)
+ .bg(cx.theme().colors().editor_background)
+ .hover(|style| style.bg(cx.theme().colors().element_hover.opacity(0.1)))
+ .child(Icon::new(icon).size(IconSize::Small).color(Color::Muted))
+ .tooltip(Tooltip::text(tooltip_label))
+ .when_some(self.on_toggle, |this, on_toggle| {
+ this.on_click({
+ move |_, window, cx| {
+ on_toggle(!self.full_height, window, cx);
+ }
+ })
+ }),
+ )
+ .into_any()
+ }
+}
@@ -901,7 +901,6 @@ impl RunningState {
weak_workspace,
None,
weak_project,
- false,
window,
cx,
)
@@ -1055,15 +1054,7 @@ impl RunningState {
let terminal = terminal_task.await?;
let terminal_view = cx.new_window_entity(|window, cx| {
- TerminalView::new(
- terminal.clone(),
- workspace,
- None,
- weak_project,
- false,
- window,
- cx,
- )
+ TerminalView::new(terminal.clone(), workspace, None, weak_project, window, cx)
})?;
running.update_in(cx, |running, window, cx| {
@@ -264,7 +264,6 @@ async fn deserialize_pane_group(
workspace.clone(),
Some(workspace_id),
project.downgrade(),
- false,
window,
cx,
)
@@ -1,9 +1,9 @@
use editor::{CursorLayout, HighlightedRange, HighlightedRangeLine};
use gpui::{
AnyElement, App, AvailableSpace, Bounds, ContentMask, Context, DispatchPhase, Element,
- ElementId, Entity, FocusHandle, Focusable, Font, FontStyle, FontWeight, GlobalElementId,
- HighlightStyle, Hitbox, Hsla, InputHandler, InteractiveElement, Interactivity, IntoElement,
- LayoutId, ModifiersChangedEvent, MouseButton, MouseMoveEvent, Pixels, Point, ShapedLine,
+ ElementId, Entity, FocusHandle, Font, FontStyle, FontWeight, GlobalElementId, HighlightStyle,
+ Hitbox, Hsla, InputHandler, InteractiveElement, Interactivity, IntoElement, LayoutId,
+ ModifiersChangedEvent, MouseButton, MouseMoveEvent, Pixels, Point, ShapedLine,
StatefulInteractiveElement, StrikethroughStyle, Styled, TextRun, TextStyle, UTF16Selection,
UnderlineStyle, WeakEntity, WhiteSpace, Window, WindowTextSystem, div, fill, point, px,
relative, size,
@@ -32,7 +32,7 @@ use workspace::Workspace;
use std::mem;
use std::{fmt::Debug, ops::RangeInclusive, rc::Rc};
-use crate::{BlockContext, BlockProperties, TerminalView};
+use crate::{BlockContext, BlockProperties, TerminalMode, TerminalView};
/// The information generated during layout that is necessary for painting.
pub struct LayoutState {
@@ -160,7 +160,7 @@ pub struct TerminalElement {
focused: bool,
cursor_visible: bool,
interactivity: Interactivity,
- embedded: bool,
+ mode: TerminalMode,
block_below_cursor: Option<Rc<BlockProperties>>,
}
@@ -181,7 +181,7 @@ impl TerminalElement {
focused: bool,
cursor_visible: bool,
block_below_cursor: Option<Rc<BlockProperties>>,
- embedded: bool,
+ mode: TerminalMode,
) -> TerminalElement {
TerminalElement {
terminal,
@@ -191,7 +191,7 @@ impl TerminalElement {
focus: focus.clone(),
cursor_visible,
block_below_cursor,
- embedded,
+ mode,
interactivity: Default::default(),
}
.track_focus(&focus)
@@ -511,21 +511,20 @@ impl TerminalElement {
},
),
);
- self.interactivity.on_scroll_wheel({
- let terminal_view = self.terminal_view.downgrade();
- move |e, window, cx| {
- terminal_view
- .update(cx, |terminal_view, cx| {
- if !terminal_view.embedded
- || terminal_view.focus_handle(cx).is_focused(window)
- {
+
+ if !matches!(self.mode, TerminalMode::Embedded { .. }) {
+ self.interactivity.on_scroll_wheel({
+ let terminal_view = self.terminal_view.downgrade();
+ move |e, _window, cx| {
+ terminal_view
+ .update(cx, |terminal_view, cx| {
terminal_view.scroll_wheel(e, cx);
cx.notify();
- }
- })
- .ok();
- }
- });
+ })
+ .ok();
+ }
+ });
+ }
// Mouse mode handlers:
// All mouse modes need the extra click handlers
@@ -606,16 +605,6 @@ impl Element for TerminalElement {
window: &mut Window,
cx: &mut App,
) -> (LayoutId, Self::RequestLayoutState) {
- if self.embedded {
- let scrollable = {
- let term = self.terminal.read(cx);
- !term.scrolled_to_top() && !term.scrolled_to_bottom() && self.focused
- };
- if scrollable {
- self.interactivity.occlude_mouse();
- }
- }
-
let layout_id = self.interactivity.request_layout(
global_id,
inspector_id,
@@ -623,8 +612,29 @@ impl Element for TerminalElement {
cx,
|mut style, window, cx| {
style.size.width = relative(1.).into();
- style.size.height = relative(1.).into();
- // style.overflow = point(Overflow::Hidden, Overflow::Hidden);
+
+ match &self.mode {
+ TerminalMode::Scrollable => {
+ style.size.height = relative(1.).into();
+ }
+ TerminalMode::Embedded { max_lines } => {
+ let rem_size = window.rem_size();
+ let line_height = window.text_style().font_size.to_pixels(rem_size)
+ * TerminalSettings::get_global(cx)
+ .line_height
+ .value()
+ .to_pixels(rem_size)
+ .0;
+
+ let mut line_count = self.terminal.read(cx).total_lines();
+ if !self.focused {
+ if let Some(max_lines) = max_lines {
+ line_count = line_count.min(*max_lines);
+ }
+ }
+ style.size.height = (line_count * line_height).into();
+ }
+ }
window.request_layout(style, None, cx)
},
@@ -679,12 +689,13 @@ impl Element for TerminalElement {
let line_height = terminal_settings.line_height.value();
- let font_size = if self.embedded {
- window.text_style().font_size.to_pixels(window.rem_size())
- } else {
- terminal_settings
+ let font_size = match &self.mode {
+ TerminalMode::Embedded { .. } => {
+ window.text_style().font_size.to_pixels(window.rem_size())
+ }
+ TerminalMode::Scrollable => terminal_settings
.font_size
- .map_or(buffer_font_size, |size| theme::adjusted_font_size(size, cx))
+ .map_or(buffer_font_size, |size| theme::adjusted_font_size(size, cx)),
};
let theme = cx.theme().clone();
@@ -439,7 +439,6 @@ impl TerminalPanel {
weak_workspace.clone(),
database_id,
project.downgrade(),
- false,
window,
cx,
)
@@ -677,7 +676,6 @@ impl TerminalPanel {
workspace.weak_handle(),
workspace.database_id(),
workspace.project().downgrade(),
- false,
window,
cx,
)
@@ -718,7 +716,6 @@ impl TerminalPanel {
workspace.weak_handle(),
workspace.database_id(),
workspace.project().downgrade(),
- false,
window,
cx,
)
@@ -116,7 +116,7 @@ pub struct TerminalView {
context_menu: Option<(Entity<ContextMenu>, gpui::Point<Pixels>, Subscription)>,
cursor_shape: CursorShape,
blink_state: bool,
- embedded: bool,
+ mode: TerminalMode,
blinking_terminal_enabled: bool,
cwd_serialized: bool,
blinking_paused: bool,
@@ -137,6 +137,15 @@ pub struct TerminalView {
_terminal_subscriptions: Vec<Subscription>,
}
+#[derive(Default, Clone)]
+pub enum TerminalMode {
+ #[default]
+ Scrollable,
+ Embedded {
+ max_lines: Option<usize>,
+ },
+}
+
#[derive(Debug)]
struct HoverTarget {
tooltip: String,
@@ -176,7 +185,6 @@ impl TerminalView {
workspace: WeakEntity<Workspace>,
workspace_id: Option<WorkspaceId>,
project: WeakEntity<Project>,
- embedded: bool,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
@@ -215,7 +223,7 @@ impl TerminalView {
blink_epoch: 0,
hover: None,
hover_tooltip_update: Task::ready(()),
- embedded,
+ mode: TerminalMode::Scrollable,
workspace_id,
show_breadcrumbs: TerminalSettings::get_global(cx).toolbar.breadcrumbs,
block_below_cursor: None,
@@ -236,6 +244,21 @@ impl TerminalView {
}
}
+ /// Enable 'embedded' mode where the terminal displays the full content with an optional limit of lines.
+ pub fn set_embedded_mode(&mut self, max_lines: Option<usize>, cx: &mut Context<Self>) {
+ self.mode = TerminalMode::Embedded { max_lines };
+ cx.notify();
+ }
+
+ pub fn is_content_limited(&self, window: &Window) -> bool {
+ match &self.mode {
+ TerminalMode::Scrollable => false,
+ TerminalMode::Embedded { max_lines } => {
+ !self.focus_handle.is_focused(window) && max_lines.is_some()
+ }
+ }
+ }
+
/// Sets the marked (pre-edit) text from the IME.
pub(crate) fn set_marked_text(
&mut self,
@@ -820,6 +843,7 @@ impl TerminalView {
fn render_scrollbar(&self, cx: &mut Context<Self>) -> Option<Stateful<Div>> {
if !Self::should_show_scrollbar(cx)
|| !(self.show_scrollbar || self.scrollbar_state.is_dragging())
+ || matches!(self.mode, TerminalMode::Embedded { .. })
{
return None;
}
@@ -1467,7 +1491,7 @@ impl Render for TerminalView {
focused,
self.should_show_cursor(focused, cx),
self.block_below_cursor.clone(),
- self.embedded,
+ self.mode.clone(),
))
.when_some(self.render_scrollbar(cx), |div, scrollbar| {
div.child(scrollbar)
@@ -1593,7 +1617,6 @@ impl Item for TerminalView {
self.workspace.clone(),
workspace_id,
self.project.clone(),
- false,
window,
cx,
)
@@ -1751,7 +1774,6 @@ impl SerializableItem for TerminalView {
workspace,
Some(workspace_id),
project.downgrade(),
- false,
window,
cx,
)