Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 42 additions & 1 deletion crates/ui/src/chat/components/activity.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use crate::theme::ActiveTheme as _;
use crate::widgets::menu::{ContextMenuExt as _, CopyText};
use crate::widgets::spinner::Spinner;
use crate::{
icon::{Icon, IconName},
Expand Down Expand Up @@ -180,7 +181,47 @@ pub(crate) fn activity_row(
if expanded && expandable {
block = block.child(activity_detail(entry, command_detail, elided_output, cx));
}
block.into_any_element()
let copy_items = activity_copy_items(entry);
block
.context_menu(move |menu, _, _| {
copy_items
.iter()
.cloned()
.fold(menu, |menu, (label, text)| {
menu.menu(label, Box::new(CopyText(text)))
})
})
.into_any_element()
}

/// What the row's context menu copies: the detail's parts are plain text, so
/// the window selection cannot reach them, and a reader wants the whole
/// command or output rather than its one-line preview.
fn activity_copy_items(entry: &TimelineEntry) -> Vec<(String, String)> {
let mut items = Vec::new();
let mut push = |key: &str, text: &str| {
if !text.trim().is_empty() {
items.push((crate::tr!(key).into_owned(), text.to_string()));
}
};
match &entry.content {
EntryContent::Item(ItemContent::CommandExecution {
command, output, ..
}) => {
push("chat.copy_command", command);
push("chat.copy_output", output);
}
EntryContent::Item(ItemContent::ToolCall { input, output, .. }) => {
let input = serde_json::to_string_pretty(input).unwrap_or_else(|_| input.to_string());
push("chat.copy_input", &input);
push("chat.copy_output", output.as_deref().unwrap_or_default());
}
EntryContent::Item(ItemContent::Reasoning { text }) => push("chat.copy_text", text),
EntryContent::Item(ItemContent::WebSearch { query }) => push("chat.copy_text", query),
EntryContent::Item(ItemContent::Other { summary, .. }) => push("chat.copy_text", summary),
_ => {}
}
items
}

/// A row summary in the tool-chips grammar: the bare verb, then its argument
Expand Down
30 changes: 25 additions & 5 deletions crates/ui/src/chat/components/assistant.rs
Original file line number Diff line number Diff line change
@@ -1,22 +1,24 @@
use std::path::Path;
use std::rc::Rc;
use std::sync::Arc;
use std::time::Duration;

use crate::theme::ActiveTheme as _;
use crate::widgets::button::{Button, ButtonVariants as _};
use crate::widgets::menu::{ContextMenuExt as _, CopyText, PopupMenu};
use crate::{
icon::{Icon, IconName},
sizing::Sizable as _,
};
use gpui::{
Animation, AnimationExt as _, AnyElement, App, AppContext as _, ClickEvent, Div, Entity,
InteractiveElement as _, IntoElement, ParentElement as _, SharedString, Styled as _, Window,
div, prelude::FluentBuilder as _, px,
Animation, AnimationExt as _, AnyElement, App, AppContext as _, ClickEvent, Context, Div,
Entity, InteractiveElement as _, IntoElement, ParentElement as _, SharedString, Styled as _,
Window, div, prelude::FluentBuilder as _, px,
};
use gpui_base::{h_flex, v_flex};

use crate::markdown::parse::ParsedDocument;
use crate::markdown::{MarkdownState, MarkdownView};
use crate::markdown::{MarkdownState, MarkdownView, MenuExtension};

use super::super::model::{MdSync, md_sync};

Expand Down Expand Up @@ -68,6 +70,18 @@ pub(crate) struct AssistantData<'a> {
pub(crate) copied: bool,
}

/// The message's own context-menu item: the whole text, as the hover Copy
/// button copies it. Offered inside the Markdown (below the view's own
/// items) and on the margin around it.
pub(crate) fn copy_message_items(text: Arc<str>) -> MenuExtension {
Rc::new(move |menu: PopupMenu, _: &mut Window, _: &mut App| {
menu.menu(
crate::tr!("chat.copy_message").into_owned(),
Box::new(CopyText(text.to_string())),
)
})
}

/// An assistant message: rendered markdown plus a hover-revealed Copy action.
pub(crate) fn assistant(
data: AssistantData<'_>,
Expand All @@ -84,13 +98,15 @@ pub(crate) fn assistant(
show_actions,
copied,
} = data;
let menu_items = copy_message_items(Arc::from(text));
let content = markdown.map_or_else(
|| div().child(text.to_string()).into_any_element(),
|markdown| {
MarkdownView::new(&markdown)
.compact_headings(true)
.selectable(true)
.base_dir(cwd)
.menu_extension(menu_items.clone())
.into_any_element()
},
);
Expand All @@ -101,9 +117,12 @@ pub(crate) fn assistant(
.line_height(px(26.))
.child(content),
);
let margin_menu = move |menu: PopupMenu, window: &mut Window, cx: &mut Context<PopupMenu>| {
menu_items(menu, window, cx)
};

if !show_actions {
return message.into_any_element();
return message.context_menu(margin_menu).into_any_element();
}

let group_key = SharedString::from(format!("assistant-{id}"));
Expand All @@ -123,6 +142,7 @@ pub(crate) fn assistant(
|element, delta| element.opacity(delta),
),
)
.context_menu(margin_menu)
.into_any_element()
}

Expand Down
58 changes: 57 additions & 1 deletion crates/ui/src/chat/components/bubble.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,25 @@ use std::path::{Path, PathBuf};
use std::sync::Arc;

use crate::theme::ActiveTheme as _;
use crate::widgets::menu::{ContextMenuExt as _, PopupMenu};
use crate::{
icon::{Icon, IconName},
sizing::Sizable as _,
};
use agent::RewindMode;
use gpui::{
Anchor, AnyElement, App, ClickEvent, Entity, InteractiveElement as _, IntoElement as _,
ObjectFit, ParentElement as _, Role, SharedString, StatefulInteractiveElement as _,
Styled as _, StyledImage as _, Window, div, img, prelude::FluentBuilder as _, px,
};
use gpui_base::{h_flex, v_flex};
use std::rc::Rc;

use tcode_core::session::SteeringStatus;

use super::super::RewindTurn;
use super::assistant;
use crate::markdown::{MarkdownState, MarkdownView};
use crate::markdown::{MarkdownState, MarkdownView, MenuExtension};

pub(crate) type ClickHandler = Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>;
pub(crate) type SharedClickHandler = Arc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>;
Expand All @@ -42,6 +46,52 @@ pub(crate) struct BubbleData<'a> {
pub(crate) copied: bool,
pub(crate) markdown: Option<Entity<MarkdownState>>,
pub(crate) rewind: Option<AnyElement>,
/// The rewind the context menu offers, when the harness has one: the
/// turn, whether it is blocked right now, and whether a conversation
/// rewind is possible (there is none for the first turn).
pub(crate) rewind_menu: Option<RewindMenu>,
}

#[derive(Clone, Copy)]
pub(crate) struct RewindMenu {
pub(crate) turn: usize,
pub(crate) disabled: bool,
}

/// The bubble's context-menu items: copy the message, then the rewind modes
/// the popover button offers. Shared by the Markdown inside the bubble and
/// the bubble's own margin.
fn bubble_menu_items(copy_text: Arc<str>, rewind: Option<RewindMenu>) -> MenuExtension {
Rc::new(move |menu: PopupMenu, window: &mut Window, cx: &mut App| {
let menu = if copy_text.trim().is_empty() {
menu
} else {
assistant::copy_message_items(copy_text.clone())(menu, window, cx)
};
let Some(RewindMenu { turn, disabled }) = rewind else {
return menu;
};
let mut modes = Vec::new();
if turn > 0 {
modes.push((
crate::tr!("chat.rewind_all").into_owned(),
RewindMode::FilesAndConversation,
));
modes.push((
crate::tr!("chat.rewind_conversation").into_owned(),
RewindMode::Conversation,
));
}
modes.push((
crate::tr!("chat.rewind_files").into_owned(),
RewindMode::Files,
));
modes
.into_iter()
.fold(menu.separator(), |menu, (label, mode)| {
menu.menu_with_enable(label, Box::new(RewindTurn { turn, mode }), !disabled)
})
})
}

pub(crate) struct BubbleHandlers {
Expand Down Expand Up @@ -165,8 +215,10 @@ pub(crate) fn user_bubble(
copied,
markdown,
rewind,
rewind_menu,
} = data;
let BubbleHandlers { copy, images } = handlers;
let menu_items = bubble_menu_items(Arc::from(visible), rewind_menu);
let text_style = window.text_style();
let text_width = visible.lines().fold(px(0.), |width, line| {
let run = text_style.to_run(line.len());
Expand Down Expand Up @@ -201,6 +253,7 @@ pub(crate) fn user_bubble(
.zip(images)
.enumerate()
.map(|(image_index, (path, on_click))| {
let menu_path = path.clone();
let path = PathBuf::from(path);
div()
.id(SharedString::from(format!(
Expand All @@ -218,6 +271,7 @@ pub(crate) fn user_bubble(
.object_fit(ObjectFit::Cover),
)
.on_click(on_click)
.context_menu(move |menu, _, _| menu.path_items(&menu_path, None))
})
.collect::<Vec<_>>(),
)
Expand All @@ -230,6 +284,7 @@ pub(crate) fn user_bubble(
.selectable(true)
.compact_headings(true)
.base_dir(cwd)
.menu_extension(menu_items.clone())
.into_any_element()
},
);
Expand Down Expand Up @@ -284,5 +339,6 @@ pub(crate) fn user_bubble(
.child(assistant::reserve_action_row(
actions, group_key, pinned, compact,
))
.context_menu(move |menu, window, cx| menu_items(menu, window, cx))
.into_any_element()
}
28 changes: 27 additions & 1 deletion crates/ui/src/chat/components/changed_files.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use std::sync::Arc;
use crate::icon::{Icon, IconName};
use crate::theme::ActiveTheme as _;
use crate::theme::HighlightTheme;
use crate::widgets::menu::{ContextMenuExt as _, PopupMenu};
use crate::widgets::tooltip::Tooltip;
use agent::{ChangeCompleteness, FileChange, FileChangeKind};
use gpui::{
Expand Down Expand Up @@ -146,6 +147,21 @@ pub(crate) fn changed_files(
.cursor_pointer()
.hover(|chip| chip.bg(cx.theme().accent))
.on_click(on_click)
.context_menu({
let path = change.path.clone();
let cwd = cwd.to_path_buf();
move |menu, _, _| {
menu.menu(
crate::tr!("chat.view_diff").into_owned(),
Box::new(super::super::OpenFileDiff {
turn: index,
path: path.clone(),
}),
)
.separator()
.path_items(&path, Some(&cwd))
}
})
.into_any_element(),
);
}
Expand Down Expand Up @@ -234,6 +250,7 @@ impl FileEditRowStyle {
pub(crate) fn file_edit_row(
key: &str,
row: &LiveEditRow,
cwd: &Path,
expanded: bool,
inline_diff: Option<AnyElement>,
on_toggle: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
Expand All @@ -242,6 +259,13 @@ pub(crate) fn file_edit_row(
let style = FileEditRowStyle::from_theme(cx);
let expandable = row.counts.is_some();
let header = file_edit_row_header(row, expanded, expandable, &style);
let path_menu = {
let path = row.path.clone();
let cwd = cwd.to_path_buf();
move |menu: PopupMenu, _: &mut Window, _: &mut gpui::Context<PopupMenu>| {
menu.path_items(&path, Some(&cwd))
}
};
let header: AnyElement = if expandable {
crate::material::accessible_clickable(
header,
Expand All @@ -255,9 +279,10 @@ pub(crate) fn file_edit_row(
.cursor_pointer()
.hover(|header| header.bg(cx.theme().accent))
.on_click(on_toggle)
.context_menu(path_menu)
.into_any_element()
} else {
header.into_any_element()
header.context_menu(path_menu).into_any_element()
};

v_flex()
Expand Down Expand Up @@ -569,6 +594,7 @@ mod tests {
gpui_base::v_flex().size_full().child(file_edit_row(
key,
&self.row,
std::path::Path::new("/"),
self.expanded,
diff,
|_, _, _| {},
Expand Down
7 changes: 6 additions & 1 deletion crates/ui/src/chat/components/disclosure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use std::path::Path;

use crate::theme::ActiveTheme as _;
use crate::widgets::button::{Button, ButtonVariants as _};
use crate::widgets::menu::ContextMenuExt as _;
use crate::{
icon::{Icon, IconName},
sizing::Sizable as _,
Expand All @@ -19,6 +20,7 @@ use tcode_core::session::OrchestrateCallback;

use super::super::model::one_line;
use crate::markdown::{MarkdownState, MarkdownView};
use crate::plan_panel::plan_menu_items;

const CALLBACK_TITLE_MAX_CHARS: usize = 24;
const DISCLOSURE_LINE_HEIGHT: f32 = 20.;
Expand Down Expand Up @@ -169,6 +171,7 @@ pub(crate) fn proposed_plan_card(
let title = tcode_core::session::plan_title(markdown)
.unwrap_or_else(|| crate::tr!("plan.proposed_plan").into_owned());
let long = markdown.chars().count() > 900 || markdown.lines().count() > 20;
let menu_items = plan_menu_items(markdown.to_string());

let body: AnyElement = if collapsed {
div().into_any_element()
Expand All @@ -180,7 +183,8 @@ pub(crate) fn proposed_plan_card(
.child(
MarkdownView::new(&markdown_state)
.selectable(true)
.base_dir(cwd),
.base_dir(cwd)
.menu_extension(menu_items.clone()),
)
.into_any_element()
} else {
Expand Down Expand Up @@ -289,6 +293,7 @@ pub(crate) fn proposed_plan_card(
.bg(cx.theme().info),
)
.child(content)
.context_menu(move |menu, window, cx| menu_items(menu, window, cx))
.into_any_element()
}

Expand Down
10 changes: 10 additions & 0 deletions crates/ui/src/chat/components/error_card.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use crate::theme::ActiveTheme as _;
use crate::widgets::button::{Button, ButtonVariants as _};
use crate::widgets::menu::{ContextMenuExt as _, CopyText};
use crate::{
icon::{Icon, IconName},
sizing::Sizable as _,
Expand Down Expand Up @@ -140,5 +141,14 @@ pub(crate) fn error_card(
.bg(danger),
)
.child(content)
.context_menu({
let message = message.to_string();
move |menu, _, _| {
menu.menu(
crate::tr!("chat.copy_error").into_owned(),
Box::new(CopyText(message.clone())),
)
}
})
.into_any_element()
}
Loading
Loading